iOS
Kotlin
KotlinNative

Kotlin/Nativeを使ってiOSアプリを作ってみる

More than 1 year has passed since last update.

この記事は Retty Advent Calendar 9日目です。
昨日は @sakuさんの swiftでHTMLからNSAttributedStringを作る方法 でした。

去年に引き続き1 Kotlinネタです。Kotlinといえば、今年5月にGoogle I/OでAndroidの公式言語にサポートされて以来、巷での人気が急速に高まっていますね。
今年からは KotlinConf2 も始まり、Android界隈を中心に非常に注目されている言語です。

ですが、僕はサーバサイドKotlin勢で、今はまだAndroidは書けません。というわけで、今回は Kotlin/Native を使って簡単なiOSアプリを作ってみました。

Kotlin/Nativeとは

Kotlin/Native3 は、Kotlinのコードをネイティブコードにコンパイルしてくれるコンパイラです。内部的にはLLVMが使われていて、Kotlin → LLVMビットコード → ネイティブコード へと変換が行われています。

JVM上で動くわけではないので、java.* 以下のパッケージを使うことはできません。(kotlin.* 以下のパッケージは使えます。)

Kotlin/Nativeを使ってiOSアプリを作る

実は Kotlin/Native には既にiOSアプリのサンプルが入っていて、動かすだけであればサンプル以下にあるREADMEに従ってコンパイルすることができますので、是非試してみて下さい。

自分でイチからアプリを作る場合は、KotlinConfのアプリ4 が非常に参考になります。
今回も、イチから構築していくことを試みます。

準備

Kotlin/Nativeをインストールする

Kotlin/Native を使ってiOSアプリを作る前に、PCに Kotlin/Native をインストールします。(今回はKotlin/Native v0.4をインストールしました。)READMEに従って下記のコマンドを実行すれば dist ディレクトリ以下にバイナリが吐き出されます。

./gradlew dependencies:update
./gradlew bundle

※1時間程度かかります

終わったらdistごと移動させて、bin以下にPATHを通しておきましょう。
これで、いつもどこでもビルドができるようになります。(ちなみにbin以下だけ移動させても動いてくれないので、必ずdistごと動かしてあげて下さい。)

iOSアプリのプロジェクトを作る

iOSアプリを開発してビルドするには、.xcodeprojやStoryboardなどXcodeでないと作れないファイルがいくつかあります。従って、あらかじめプロジェクトをXcodeで作ります。 Single View App を選択しておくと良いと思います。

ちなみに、プロジェクトを作る際に言語を Swift か Objective-C かで選ぶことが出来ますが、Kotlin/Native する場合はObjective-Cでないと動かないようです。Swiftを選んでしまうと、IBOutletやIBActionがKotlinのソースと繋がらず悲しい思いをします5 ので、残念ですが Objective-Cを選択して下さい。

とりあえず何かKotlinファイルを置いてみる

.xcodeprojファイルと同階層から src/main/kotlin ディレクトリを掘って、その下に次の3つのファイルを置いてみます。

main.kt
import kotlinx.cinterop.*
import platform.Foundation.*
import platform.UIKit.*

fun main(args: Array<String>) {
    memScoped {
        val argc = args.size + 1
        val argv = (arrayOf("konan") + args).map { it.cstr.getPointer(memScope) }.toCValues()

        autoreleasepool {
            UIApplicationMain(argc, argv, null, NSStringFromClass(AppDelegate))
        }
    }
}
AppDelegate.kt
import kotlinx.cinterop.*
import platform.Foundation.*
import platform.UIKit.*

class AppDelegate : UIResponder(), UIApplicationDelegateProtocol {
    companion object : UIResponderMeta(), UIApplicationDelegateProtocolMeta {}

    override fun init() = initBy(AppDelegate())

    private var _window: UIWindow? = null
    override fun window() = _window
    override fun setWindow(window: UIWindow?) {
        _window = window
    }
}
ViewController.kt
import kotlinx.cinterop.*
import platform.Foundation.*
import platform.UIKit.*

@ExportObjCClass
class ViewController(aDecoder: NSCoder) : UIViewController(aDecoder) {
    override fun initWithCoder(aDecoder: NSCoder) = this.initBy(ViewController(aDecoder))

    override fun debugDescription() = "ViewController"
}

これらのソースコードで、iOSプロジェクトで Single View App を作ったときにデフォルトで用意されるソースコードと同じ機能(白い画面を1枚表示するだけ)を提供できます。

Gradleを用意する

ビルドツールには、ふだんのKotlinビルドでもおなじみGradleを使います。build.gradle を下記のように記述して、.xcodeproj等と同じディレクトリに置きます。

build.gradle
apply plugin: "konan"

buildscript {
    repositories {
        mavenCentral()
        maven {
            url "https://dl.bintray.com/jetbrains/kotlin-native-dependencies"
        }
    }

    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-native-gradle-plugin:+"
    }
}

konan.targets = ["iphone"]

konanArtifacts {
    program("app")
}

Gradle Wrapperも適当なところから持ってきて同じところに置きます。
この時点のディレクトリ構成はこんな感じになってると思います。

ディレクトリ構成
project-root/
  ├─ build.gradle
  ├─ gradle/
  ├─ gradlew
  ├─ gradlew.bat
  ├─ sample-ios-app/
  ├─ sample-ios-app.xcodeproj
  └─ src/main/kotlin/
           ├─ AppDelegate.kt
           ├─ ViewController.kt
           └─ main.kt

さっそくビルドしてみる

この時点でKotlinのコードをビルドすることができます。

$ ./gradlew compileKonanApp
:checkKonanCompiler
:compileKonanAppIphone
KtFile: AppDelegate.kt
KtFile: ViewController.kt
KtFile: main.kt
:compileKonanApp

BUILD SUCCESSFUL

Total time: 18.543 secs

試しに、Gradleでコンパイルすると ./build/konan/bin/iphone/app.kexe というファイルが吐き出されます。

iOSアプリのBuild Phaseでバイナリを置換する

Kotlinから作られるバイナリをアプリに組み込む設定をします。
Xcodeを起動し、プロジェクトのBuild Phaseに下記の3つのRun Scriptを追加します。

  • Compile Sourcesの手前
remove_binary
rm -f "$TARGET_BUILD_DIR/$EXECUTABLE_PATH"
  • Copy Bundle Resourceの手前
build_binary_from_kotlin_sources
$SRCROOT/gradlew -p $SRCROOT compileKonanApp
replace_binary
cp "$SRCROOT/build/konan/bin/iphone/app.kexe" "$TARGET_BUILD_DIR/$EXECUTABLE_PATH"

この設定によって、Objective-Cをビルドして作られたバイナリは捨て去られて、Kotlinをビルドして作られたバイナリに置換されます。(なので、Objective-Cでどんなロジックを書いていても意味はありません。)

なお、2017年12月時点では、シミュレータへのインストールは出来ません。実機にインストールできる必要があります。

インストールしてみる

ここまでできたら、Xcodeの Run (⌘R) が正常に動作します。設定に問題がなければ実機にインストールされて真っ白の画面が表示されるはずです。

TODOアプリを作ってみる

ここまで来たら、何か簡単なアプリを作ってみたいですよね。というわけで定番ではありますがTODOアプリを作ってみます。

Storyboardを準備する

Storyboardは、コードでUIを記述する場合を除いて、KotlinでiOSアプリを作るときにも使うことができます。ひとまず一覧表示用のListViewControllerと追加・編集用のEditViewControllerを作っておきます6

一覧画面にはUITableViewがいますので、delegateとdataSourceもこのタイミングで繋いでしまうと良いです。

また、今回はtableViewCellをカスタムクラスで記述することにしたので、IBOutletもこのタイミングで貼ります。どうせObjective-Cのコードは捨てられるので、アクセスコントロールはここでは考えずに .h の方に貼ってしまって問題ありません。

画面のロジックを書いてみる

ここからいよいよKotlinを使ってViewControllerのロジックを書いていきます。ここでは例として一覧画面を書いてみます。

なお、KotlinでiOSを書く場合、まだ補完がほぼ使えません。従って、メソッド名がわからない時にはXcodeでObjective-Cを書き、それをKotlinで再度表現する流れになります。Kotlinを書くのに今回はIntelliJを使っていますが文法サポートがメインです。

というわけでIntelliJを開き ListViewController.kt を作ります。準備の節でViewController.kt を作ったのでそれをリネームすると良いですね。最初に、tableViewを動かすために必要な UITableViewDelegateとUITableViewDataSourceをKotlinで実装してみます。

ListViewController.kt
import kotlinx.cinterop.*
import platform.Foundation.*
import platform.UIKit.*

@ExportObjCClass
class ListViewController(aDecoder: NSCoder) : UIViewController(aDecoder),
                                              UITableViewDelegateProtocol,
                                              UITableViewDataSourceProtocol {

    override fun initWithCoder(aDecoder: NSCoder) = this.initBy(ListViewController(aDecoder))

    override fun debugDescription() = this::class.simpleName!!

    override fun tableView(tableView: UITableView, numberOfRowsInSection: NSInteger): NSInteger {
        return 10
    }

    override fun tableView(tableView: UITableView, cellForRowAtIndexPath: NSIndexPath): UITableViewCell {
        val cell = tableView.dequeueReusableCellWithIdentifier(TaskItemTableViewCell::class.simpleName!!,
                cellForRowAtIndexPath)

        return cell
    }
}

@ExportObjCClass
class TaskItemTableViewCell(aDecoder: NSCoder) : UITableViewCell(aDecoder) {
    @ObjCOutlet
    private lateinit var taskTitleLabel: UILabel

    @ObjCOutlet
    private lateinit var taskDeadlineLabel: UILabel

    override fun initWithCoder(aDecoder: NSCoder) = this.initBy(TaskItemTableViewCell(aDecoder))
}

こんな感じになりました。iOSアプリを書いたことのある方なら、結構見慣れた感じのコードになっているのではないでしょうか? Objective-C/Swift と比べて違う点は

違う点
Protocol名 HogeHogeProtocol という名前になる
IBOutlet ObjCOutlet アノテーションをつける

といったところでしょうか。実装するメソッド名は Objective-C/Swift でのメソッド名にほぼ対応していますね。

Objective-Cの場合
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
Kotlinの場合
override fun tableView(tableView: UITableView, numberOfRowsInSection: NSInteger): NSInteger
override fun tableView(tableView: UITableView, cellForRowAtIndexPath: NSIndexPath): UITableViewCell

ところで、Kotlinには Objective-C/Swift にあるような Function Argument Labels がなく、そのまま変数名になってしまいます。これは、次の2つのメソッドを同時に実装したいときに問題になりそうです。

UITableViewDelegateのmethod
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
UITableViewDelegateのmethod
override fun tableView(tableView: UITableView, cellForRowAtIndexPath: NSIndexPath): UITableViewCell
override fun tableView(tableView: UITableView, heightForRowAtIndexPath: NSIndexPath): CGFloat

この2つはObjective-C/Swiftの世界では別のメソッドシグネチャを持ちますが、Kotlinの世界ではパラメータ名が異なるだけなので同じメソッドシグネチャになってしまいますよね。
が、実はクラスに @Suppress("CONFLICTING_OVERLOADS") とアノテーションをつけるだけでよしなに扱ってくれます。

なお、この状況でビルドしてみると実はコケます。

どうやらNSIntegerは定義されていないようです。NSInteger /* = Long */ と言ってくれていますし、NSIntegerは良く使う子なので、typealiasを定義しておくと良さそうですね。

typealiases.kt
typealias NSInteger = Long

画面遷移を作ってみる

さて、中身はまだ静的であるものの、これで一覧画面はだいたいできました。実際にタスクリストとしていく過程で、NSUserDefaultsでも使って出し入れしてあげればよさそうです。

ここではその過程は割愛して、画面遷移をコードで書いてみます。

ListViewController.kt
@ExportObjCClass
@Suppress("CONFLICTING_OVERLOADS")
class ListViewController(aDecoder: NSCoder) : UIViewController(aDecoder),
                                              UITableViewDelegateProtocol,
                                              UITableViewDataSourceProtocol {
    ...

    override fun viewDidLoad() {
        super.viewDidLoad()

        this.title = "タスク一覧"
        this.navigationItem.rightBarButtonItem = UIBarButtonItem(
                title = "追加",
                style = UIBarButtonItemStylePlain,
                target = this,
                action = NSSelectorFromString("addButtonDidTap:")
        )
    }

    ...

    @ObjCAction
    fun addButtonDidTap(sender: ObjCObject?) {
        val storyboard = UIStoryboard.storyboardWithName(MAIN_STORYBOARD_NAME, bundle = null)
        val editViewController = storyboard.instantiateViewControllerWithIdentifier(
                EditViewController::class.simpleName!!)

        val navigationController = UINavigationController(rootViewController = editViewController)

        this.presentViewController(navigationController, animated = true, completion = null)
    }
}

このあたりのメソッドについても、基本はObjective-C/Swiftのメソッドに対応しているので、自由気ままに書いていくと良いです。

ここで注目して欲しい点は、 addButtonDidTap メソッドに ObjCAction アノテーションが付いている点です。これは NSSelectorFromString を使う際に必要になります。(ちなみに、StoryboardからIBActionを紐付ける際にもこのアノテーションが必要です。)

ここまで来れば、Kotlinだからといって、どのように作ればいいか分からない点と言うのはそこまで無さそうです。メソッド名やプロパティが分からない!ということになっても、Xcodeや Apple Developer Documentation で調べられますし、大概の場合はそのままKotlin化することができます。

Kotlin/NativeでiOSを書くときに注意したいポイント

iOSはもちろんSwiftで書くことも出来ますが、Java譲りの強力な型システムを持つKotlinで書けるというのは大きな魅力の1つです。が、今回実際に簡単なiOSアプリを作ってみて、はまったポイントが幾つかありましたのでご紹介します。

kotlin.String と Foundation.NSString

iOSにおける文字列型といえばFoundation.NSStringですよね。このクラスとkotlin.Stringには継承関係がありません。とはいっても、Kotlinで書かれたロジックの中では普通にkotlin.Stringを使えますし、UIKitの文字列プロパティでも多くはkotlin.Stringをそのまま設定することが出来るようになっています。

kotlin.Stringが使えないのは、NSDictionaryを使う必要があるケースです。NSUserDefaultsなど、パラメータにNSDictionaryが要求されるクラスが幾つかあります。
NSDictionaryはキーにNSCopyingを要求しますが、kotlin.Stringはこれを実装していないため、Foundation.NSString型などに変換する必要があります。(ついでにいうと、値の方にもNSObjectが要求されていますのでこちらも変換が必要です。)

なお、kotlin.StringとFoundation.NSStringは以下のように相互変換できます。

// kotlin.String -> Foundation.NSString
val nsstring = interpretObjCPointer<NSString>(CreateNSStringFromKString(string))

// Foundation.NSString -> kotlin.String
nsstring.toString()

Foundation.NSDictionaryやFoundation.NSArrayを使い始めると変換系のメソッドをいくつも生やす羽目になるので、必要なければなるべくkotlin.collections.Listやkotlin.collections.Mapで済ませたほうが良いかもしれません。

ObjCObject型(id型)の型変換

SwiftやKotlinに慣れていると、ついつい as を使って型変換したくなるのですが、実はObjective-Cでいうid型に代入できる型では使えません。NSArrayやNSDictionaryに入れた値は、その時点でid型になってしまいますので、それを変換したいときには uncheckedCast を使います。

val nsarray = /* NSArrayの定義 */
val nsstring = nsarray.objectAtIndex(0).uncheckedCast<NSString>()

NS_ENUMの取り扱い

NS_ENUMで宣言されている型は、多くはkotlin.Longを実体とするtypealiasに変換されており、各値がkotlin.Long型の定数として宣言されています。
例えば、UIBarButtonItemStyleの場合

println(UIBarButtonItemStyle::class.qualifiedName) // kotlin.Long
println(UIBarButtonItemStyle)                      // kotlin.Long.Companion@7404ffc8
println(UIBarButtonItemStylePlain)                 // 0
println(UIBarButtonItemStyleDone)                  // 2

となっています。が、一部のNS_ENUMはEnum Classに変換されています。実際、UITableViewCellEditingStyleの場合

println(UITableViewCellEditingStyle::class.qualifiedName)              // platform.UIKit.UITableViewCellEditingStyle
println(UITableViewCellEditingStyle)                                   // platform.UIKit.UITableViewCellEditingStyle.Companion@74034748
println(UITableViewCellEditingStyle.UITableViewCellEditingStyleNone)   // UITableViewCellEditingStyleNone
println(UITableViewCellEditingStyle.UITableViewCellEditingStyleDelete) // UITableViewCellEditingStyleDelete
println(UITableViewCellEditingStyle.UITableViewCellEditingStyleInsert) // UITableViewCellEditingStyleInsert

となっていて、UITableViewCellEditingStyleには values メソッドも生えています。

この2種類は、Apple Developer Documentationでみても違いがなく、コンパイルエラーになるかどうかで判断するしか無さそうです。

まとめ

いかがでしたでしょうか?
Kotlin/NativeでiOSアプリを書く場合、IDEの補完がまだ使えなかったり、思わぬはまりポイントがあったりなど、正直な話、敷居は高めだと思います。(ドキュメントもまだあまり無いと思います。)

しかしながら、Swiftとは違う思想の型システムを備えたKotlinを使えば、Swiftとはまた違う設計のシステムが実現できて、iOS開発の見方が変わるかもしれません。

また、もしKotlin製のライブラリや、ユーティリティクラスが手元にあれば、java.*に依存さえしていなければ導入することもできそうです。(弊社謹製の redux-kt は残念ながらjava.util.concurrent.locks.ReentrantLock に依存しているため使えません...)

なお、このネタのために作ったTODOアプリはこちらに上げています。今後 Kotlin/Native を使ってiOSアプリを書く際のご参考になりましたら幸いです。

明日は @tkngueくんの database-lessでserver-lessな社内図書システム です。お楽しみに!


  1. 去年はこんな記事を書きました。
    https://qiita.com/noripi/items/0649415c84b08d1de3bb 

  2. https://www.kotlinconf.com 

  3. https://github.com/JetBrains/kotlin-native 

  4. KotlinConfのアプリは Kotlin/Native を使って書かれています。
    https://github.com/JetBrains/kotlinconf-app 

  5. 仕組み的にはそんなこと無いような気もするのですが、何度試してもSwiftではダメでした。 

  6. Objective-CでiOSアプリを書く場合、クラス名にprefixをつけるのが一般的ですが、Kotlinで書く場合には付けなくても問題ありません。