iOS
Kotlin
KotlinNative

Kotlin/Nativeを使ってXcode + CLionでiOSアプリを書く

はじめに

この記事はエムスリーAdvent Calendarの19日目の記事です。昨日は@maeharinによるサーバーサイドKotlinについての話でした。

私はAndroidをメインとしたアプリエンジニアなので、本日はなぜかKotlin Advent Calendarでも18日現在触れられていないiOSアプリをKotlinで書く話をします。
(Kotlinアドベントカレンダーの方はうかうかしてたら埋まってました😥)

またアプリと記事を書いてから気付いたのですが、RettyさんのAdvent Calendarで既にKotlinでiOSアプリを書かれている先駆者がいらっしゃいました。
https://qiita.com/noripi/items/4ee969c48b3da5ca6fbd

そのため現状まともにKotlin/Nativeを記述できる唯一のIDEであるCLionを用いる部分に少し重きを置いて解説いたします。

Kotlin/Nativeについて

以前どこでもKotlinという弊社主催のKotlin勉強会でv0.3時代のKotlin/Nativeの解説を行いましたので、概要はそちらをご覧ください。

Starting Kotlin/Native おなじところ、ちがうところ #m3kt

ただし、バージョンアップにより上の発表時点との差異が出てきています。大きな変更点を簡単に挙げると、

  • Gradleファイルの書き方が変わった
  • CMakeでもビルド可能になった
  • 割とまともにiOSアプリを書けるようになった

という感じです。3番目はKotlinConfのアプリがKotlinで書かれていることでも話題になりましたね。
そこで本記事ではiOSアプリをKotlinで書いてみます。

注意事項

以下の情報については一般的に解説されている部分については説明いたしません。これらについて経験のない方は、一般的な情報を調べていただければと思います。

  • XcodeでStoryboardを用いてiOSアプリを作る方法
  • CMakeについて
  • Gradleについて

環境構築

Macに以下のソフトウェアをインストールします。

  • Xcode 9
  • CLion 2017.3.1 EAP
    • Kotlinプラグイン
    • Kotlin/Nativeプラグイン
  • CocoaPods

CLionはJetBrainsが作っているC/C++向けのIDEです。All Products PackかCLion単品をご購入ください。CLion単品なら個人ライセンスで1万弱/年で購入可能です。1日あたり27円ちょいです。
ちなみに私は昨年まで学生だったので、ディスカウントを利用してAll Products Packを購入しました。
経済を回していきましょう。
https://www.jetbrains.com/store/
http://samuraism.com/products/jetbrains

学生であればIndividual Licenseを無料で使用できるのでそちらを使うと良いと思います。
https://www.jetbrains.com/student/

動作環境は、現在のところ64ビット版iOSが動くデバイスのみとなっています。シミュレータやiPhone 5以前のiPhone, 4世代目以前のiPadなどでは動かないので注意が必要です。筆者は第4世代のiPadしか持っていなかったので、この記事を執筆する機会にiPadを買い替えました。
経済を回していきましょう。
ちなみにこの記事の数日前にシミュレータ対応のプルリクエストがマージされていましたので次期バージョンではシミュレータで動かせると思います。

ワークスペースのディレクトリ構成

現状複数のソフトウェアやビルドツールを跨いでビルドする必要があるため、ディレクトリ構成は若干複雑になります。
基本的にはXcodeプロジェクトにKotlin/Native用のファイルが追加された形になります。
KotlinConfアプリの構成を参考に載せておきます。

root
  ├── KotlinCMakeModule
  │    └── (Kotlin/Native用CMakeファイル, 主にCLion用)
  ├── gradle
  |    └── (Gradle Wrapperファイル群)
  ├── src
  │    └── main
  │       ├── kotlin
  │       │    └── (Kotlinのソース・ファイル)
  │       └── c_interop
  │            └── (cinteropの定義ファイル)
  ├── (プロジェクト名)
  │    └── 通常のXcodeプロジェクトと同様
  ├── (プロジェクト名).xcodeproj
  ├── (プロジェクト名).xcworkspace
  ├── gradlew
  ├── build.gradle
  └── CMakeLists.txt (CLion用)

プロジェクトの作成

Xcodeでの作業

今回は単一のプロジェクトしか使わないので、ワークスペースは作成しません。

  1. Xcodeで新しいプロジェクトを作成します。
  2. プロジェクトの種類はSingle View App、言語はObjective-Cを選択します。
  3. プロジェクトの設定でBuild Phasesに以下のようなRun Script Phaseを2つ追加します。適宜名前をつけることをお薦めします。
    ./gradlew -p $SRCROOT compileKonanApp
    cp "$SRCROOT/build/konan/bin/iphone/app.kexe" "$TARGET_BUILD_DIR/$EXECUTABLE_PATH"
  4. 自動的に生成されたObjCなどのファイルはそのままにしておきます。

CLionでの作業

以下の作業は、Kotlin/Native関連のCMake関連ファイルをコピーするために行う作業です。既に他のプロジェクトがある場合は、KotlinCMakeModuleディレクトリと、以下のCMakeLists.txtをルートにコピーするだけで問題ないです。

CLionの一時的なプロジェクトを作成

Xcodeのプロジェクトを作成したディレクトリとは別の、空のディレクトリに新規プロジェクトを作成してください。
Kotlin/Nativeプラグインを入れていれば、Kotlin/Native Applicationを選択できるはずなので、そちらを選択します。
現在はサンプルからしか選べず、またiOSのサンプルは選べないので、"Hello World"プロジェクトを選択します。
image.png

KotlinCMakeModuleの抽出

CLionを終了し、作ったディレクトリからKotlinCMakeModuleディレクトリをXcodeのプロジェクトを作成したディレクトリにコピーします。CLionで作ったディレクトリはもう不要なので削除してください。

ソースディレクトリの作成

src/main/kotlinディレクトリを作成します。

CLionを再度開く

CLionをXcodeのプロジェクトのディレクトリで開きます。今はまだCMakeLists.txtがないので警告が出るはずです。

CMakeLists.txtの作成

以下のようなCMakeLists.txtを作成し、プロジェクトのルートに配置します。

CMakeLists.txt
cmake_minimum_required(VERSION 3.8)

set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_LIST_DIR}/KotlinCMakeModule)

project("iOS Sample" Kotlin)

konanc_executable(
        NAME ios-kotlin-native-sample
        TARGET iphone
        SOURCES src/main/kotlin/
)

キャッシュを削除してリロードします。
image.png

これでsrc/main/kotlinディレクトリ内にKotlinファイルを配置するとIDE支援を受けることができます。
※ ただしめちゃくちゃ重い
clion_suggestion.gif

Gradleの配置、記述

一般的なGradleプロジェクトと同様にGradle Wrapper (v3.3) を配置します。

また、以下のようにbuild.gradleを記述します。

build.gradle
buildscript {
    repositories {
        mavenCentral()
        maven {
            url "https://dl.bintray.com/jetbrains/kotlin-native-dependencies"
        }
    }
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-native-gradle-plugin:0.3.4"
    }
}

apply plugin: "konan"

konan.targets = ["iphone"]

konanArtifacts {
    program("app")
}

アプリの記述

基本的には、Kotlinファイルの編集はCLion、StoryboardやObjCなどXcode由来のファイルはXcodeで記述します。
また、GradleはIntelliJ IDEAで編集すると良いかもしれません。

標準出力Hello World

まずはmain.ktファイルを作成します。Kotlinは必ずしもクラスごとにファイルを分ける必要がなく、今回は記述量が少ないため、全てをmain.ktに書くことにします。

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))
        }
    }
}

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

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

    override fun application(application: UIApplication, didFinishLaunchingWithOptions: NSDictionary?): Boolean {
        println("Hello Kotlin/Native")
        return true
    }

    private var _window: UIWindow? = null
    override fun window() = _window
    override fun setWindow(window: UIWindow?) { _window = window }
}

@ExportObjCClass
class ViewController : UIViewController {

    constructor(aDecoder: NSCoder) : super(aDecoder)
    override fun initWithCoder(aDecoder: NSCoder) = initBy(ViewController(aDecoder))
}

この時点で実行すると、iOS上には白い画面しか出てきませんが、標準出力に"Hello Kotlin/Native"と出力されます。(Xcodeから確認できます)

image.png

GUI操作

現在はただの白い画面しか見えないため、テキストを表示するようにします。

  1. Main.storyboardにLabelを追加し、ViewController.mlabelという名でOutletを追加します。
    Labelの位置はConstraintなどで良い感じに設定してください。
  2. main.ktのうち、ViewControllerクラスの部分を以下のように修正します。
main.kt
...

@ExportObjCClass
class ViewController : UIViewController {
    private val formatter: NSDateFormatter = NSDateFormatter()

    @ObjCOutlet lateinit var label: UILabel

    constructor(aDecoder: NSCoder) : super(aDecoder) {
        formatter.dateFormat = NSDateFormatter.dateFormatFromTemplate("HH:mm:ss", options = 0, locale = NSLocale("ja_JP")) ?: "HH:mm:ss"
    }
    override fun initWithCoder(aDecoder: NSCoder) = initBy(ViewController(aDecoder))

    override fun viewDidLoad() {
        val timer = NSTimer.scheduledTimerWithTimeInterval(1.0, target = this, selector = NSSelectorFromString("update:"), userInfo = null, repeats = true)
        timer.fire()
    }

    @ObjCAction
    fun update(timer: NSTimer?) {
        label.text = "Current time\n${formatter.stringFromDate(NSDate())}"
    }
}

結果

ipad_clock.gif
※ Storyboardでフォントなどの設定をした後の表示です。

ObjCのライブラリを使う

ObjCのネットワーキングライブラリのド定番であるAFNetworkingを例に、ライブラリを追加する方法を記します。

CocoaPodsでライブラリを追加

ルートディレクトリにPodfileを配置します。

Podfile
project '(プロジェクト名).xcodeproj'
platform :ios, '9.0'

target '(プロジェクト名)' do
    use_frameworks!
    pod 'AFNetworking', '~> 3.0'
end

podコマンドでライブラリをインストールします。

$ pod install

ワークスペースをXcodeで開く

今までは単体のプロジェクトをXcodeで開いていましたが、CocoaPodsを用いたプロジェクトではワークスペースを用います。
pod installを行うと、ルートディレクトリに(プロジェクト名).xcworkspaceができあがっているのでそちらをXcodeで開きます。

Manage SchemesでAFNetworkingを表示します。
image.png
image.png

SchemeをAFNetworking、ビルドターゲットをGeneric iOS Deviceにし、Product > Archiveでアーカイブを作成します。
image.png

PodsプロジェクトのProducts内にAFNetworking.frameworkができているので、Show in Finderで保存先ディレクトリを開き、プロジェクトのFrameworksディレクトリにコピーします。

root
  ├── Frameworks
  │    └── AFNetworking.framework
  ︙

Kotlin/Nativeでライブラリを使用する

src/main/c_interopディレクトリを作成し、ライブラリ情報を記述したlibs.defファイルを配置します。

libs.def
headers = AFNetworking/AFNetworking.h
headerFilter = AFNetworking/**
language = Objective-C
linkerOpts = -framework AFNetworking
compilerOpts = -framework AFNetworking

CMakeLists.txt, build.gradleをそれぞれ以下のように書き換えます。

CMakeLists.txt
...

  project("iOS Kotlin Native Sample" Kotlin)
+ 
+ cinterop(
+         NAME libs
+         TARGET iphone
+         DEF_FILE src/main/c_interop/libs.def
+ )

  konanc_executable(
          NAME ios-kotlin-native-sample
          TARGET iphone
-         SOURCES src/main/kotlin/
+         SOURCES src/main/kotlin/ build/konan/libs/iphone  # この行は本来は修正が不要かもしれないがバグがあるようなので追加
  )
build.gradle
...
  konan.targets = ["iphone"]

  konanArtifacts {
-     program("app")
+     def productsDir = new File("Frameworks").absolutePath
+     interop("libs") {
+         defFile "src/main/c_interop/libs.def"
+ 
+         compilerOpts "-F${productsDir}"
+         linkerOpts "-F${productsDir}"
+
+         includeDirs new File(".").getAbsolutePath()
+     }
+ 
+     program("app") {
+         linkerOpts '-rpath', '@executable_path/Frameworks'
+ 
+         linkerOpts "-F${productsDir}"
+ 
+         libraries {
+             artifact "libs"
+         }
+     }
  }

cinteropのコンパイル

以下のコマンドを実行します。
bash
$ ./gradlew compileKonanLibs

この時点でbuild/konan/libs/iphone/libs.klib-build/kotlin/libs/libs.ktというファイルが作成されます。このファイルを読み込むように設定したので、ObjCのライブラリを使用可能になります。

Kotlin/NativeでiOSアプリを書いた所感、まとめ

面倒くさい

まだプレビュー版なので、手作業ですることが多いです。ここらへんはそのうち改善されると思います。

Swfit 2系まで戻った気分になる

Swift 3系から短くなったメソッド名ですが、そちらの名前はサポートされていないので、従来の長い名称を書く必要があります。

ObjCのファイルを残す必要あり?

サンプルなどではObjCのファイルが残っているので、想像ですが多分Xcodeとの兼ね合いで必要なんだと思います。
なおViewController.mに追加したOutletの記述を削除しても、コンパイル・動作には影響ありません。

Swiftは現状非対応

ObjCで書かれたライブラリは用いることが可能ですが、Swiftのものは現状手軽には使えないと思います。(補完を考えなければ使えるかもしれませんが)

結構重い

現状CLion+Kotlin/NativeはXcode+Swiftより重いです。今後に期待しましょう。

CLionがObjCに非対応

AppCodeはObjCに対応していますが、CLionは対応していないので、ソースのシンタックスハイライトが残念です。
ちなみにSwiftには対応しています。現状Kotlin/Nativeとの共存は難しいですが。

Kotlinの標準メソッドを使える

Kotlinの標準的なメソッドは使えます。またStringとNSStringなど部分的に互換性を持つものもあります。
ただしハマりポイントも多そうなので、これも今後に期待ですね。

まとめ

最後に否定的なことを多く書きましたが、v0.3時代ではできなかったObjCの相互運用が可能になったことで、Kotlin/Nativeの将来的なユースケースは大幅に広がったと思います。

さらにCとの相互運用に比べると、非常にKotlinらしい書き方ができるようになっています。

今後の発展に期待しましょう!