DroidKaigi 公式アプリのKotlin Multiplatform

どこでもKotlin #7 〜Kotlin MPP特集〜で話す予定の内容です！他の方の発表面白そうですし、先着順でまだ空きがあるようなので参加まだの方いれば早めにどうぞ！
https://m3-engineer.connpass.com/event/123055/

# 概要
自分の知見というより、DroidKaigiアプリとしての知見になりますが、このままではもったいないので、書いておきます。
このあたり、自分はAndroid版の機能をレビュー、実装している間に、コントリビューターの方々がやっていただけたものになります。 @kikuchy さんが特にこの基盤作りをメインにやってくれていました。他にもiOSの有名な方々など、さまざまな方が開発に参加していただけました。

DroidKaigiではAndroid版とiOS版をリリースしています。そこで培った知見を公開しておこうと思います。

https://itunes.apple.com/jp/app/droidkaigi-2019/id1450771424?mt=8

# Kotlin Multiplatformとは
Kotlin Multiplatformを使うとAndroidとiOSでコードを共通化することが出来ます。
例えば、DroidKaigi 2019のアプリではKotlin Multiplatformを使って、APIの呼び出しや、アプリ内で使うモデルのクラスたちを共通化しています。現状のKotlin Multiplatformを使った開発では、UIの共通化を行うことはあまり考えられておらず、UIの部分はiOSであればSwiftで開発し、AndroidではKotlinまたはJavaを用いて開発を行い、APIやDBなどの部分を共通化するのが普通です。
これはいい面も悪い面もあります。UI、UXはプラットフォームに合わせて最適化しやすいのですが、逆に共通化できる部分は結構少なくなります。

## Androidでのビルド
KotlinのコードはJavaと同様にJavaのクラスファイル、バイトコードに変換され、それがDalvikバイトコードに変換されます。
Kotlin Multiplatformのコードも同様に変換されます。何も問題なく動作します。

## iOSでのビルド
Kotlin/Nativeによって、ネイティブバイナリにKotlinを変換することができ、JVMなどの仮想環境がないiOSでも実行することが出来ます。
基本的には以下のようなGradleタスクを動かして、iOSのFrameworkを作成して、使います。

```groovy
task packForXCode(type: Sync) {
    final File frameworkDir = new File(buildDir, "xcode-frameworks")
    final String mode = project.findProperty("XCODE_CONFIGURATION")?.toUpperCase() ?: 'DEBUG'
    def target = project.findProperty("kotlin.target") ?: "iOS"

    inputs.property "mode", mode
    dependsOn kotlin.targets."$target".compilations.main.target.binaries.findFramework("", mode).linkTask

    from { kotlin.targets."$target".compilations.main.target.binaries.findFramework("", mode).outputFile.parentFile }
    into frameworkDir

    doLast {
        new File(frameworkDir, 'gradlew').with {
            text = "#!/bin/bash\nexport 'JAVA_HOME=${System.getProperty("java.home")}'\ncd '${rootProject.rootDir}'\n./gradlew \$@\n"
            setExecutable(true)
        }
    }
}
```

そしてこのGradleタスクをXcodeのBuild Phasesで指定してあげることで、作ってあげることが出来ます。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/27388/ed14e419-a9cb-8359-bdc6-73b5acc44b2e.png)


# 使ったライブラリ

Kotlin Multiplatformのために現在、さまざまなライブラリが開発されており、DroidKaigiではKtor-ClientやTimber、Klockなどのライブラリを用いて開発を行いました。これを使うことで、Multiplatformで例えばAPIの呼び出しの処理を共通化出来たりします。

## Ktor-Client
https://ktor.io/clients/http-client.html

Kotlin Multiplatformで使えるHttpClient。 OkHttpと連携できたり、GsonやKotlinx.Serializationと連携できる。インターフェースはRetrofitのほうがいい感じ。

## Kotlinx.Serialization
https://github.com/Kotlin/kotlinx.serialization

Kotlin Multiplatformで使えるJSON, CBOR, Protobufフォーマットが使えるシリアライザ。gsonやmoshiの代わりに使う。

## Klock
Kotlin Multiplatformで使える日付や時間を表現できるライブラリ。これを使わない場合は、自分でそれぞれのプラットフォームのDate型とかを定義しないといけないのでだいぶ助かります。JetBrains公式ではなく、今後JetBrains公式のものを考えているようなので、それが出るまでのつなぎになりそうな気がしています。

## Timber

com.jakewharton.timber:timber-common:5.0.0-SNAPSHOTを使うことで、Androidで有名なログライブラリのTimberを使えます。
**現状、Kotlin/Native(iOS向け)には対応していないので注意が必要**

# DroidKaigiでの構成

DroidKaigiではAPI呼び出しの部分とModel部分を共通化しています。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/27388/e9b7b8de-9578-f998-487a-93b552c88624.png)


# Kotlin MultiplatformとMulti Module
なぜこのような構成になっているのか見ていきましょう。
まず最初に自分が作っていたときは機能を実装していかなくてはいけないので、APIやModelのモジュールを分けており、ModelはKotlin Multiplatform Project(MPP) モジュールで作っていました。
そこでiOS版を作るときに1つ問題が発生しました。

**iOSのプロジェクトから複数のKotlin MPPモジュールを参照できませんでした。**

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27388/36fb49d0-7777-bd87-b578-ff9571f87fa4.png)

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27388/24b81e4f-b989-0068-a44d-61f8210fce71.png)

https://twitter.com/kikuchy/status/1083999701160972290 より

そのため1つのモジュールで、それぞれのモジュールを参照できるモジュール `ios-combined`を作成し、`srcDirs`を使って無理やり参照するという荒業により解決しました。

```groovy
    sourceSets {
        final List<String> projectsList = [
                ":model",
                ":data:api",
                ":data:api-impl",
                ...
        ]
        commonMain {
            projectsList.forEach {
                kotlin.srcDirs += "${project(it).projectDir}/src/commonMain/kotlin"
            }
```

**ただ、この構成も完璧ではありません。Android Studio 3.4以上で以下のエラーがAndroid Studio上で起こることが観測されています。現状は、settings.gradleを変更しながら開発する必要があります。**
https://github.com/DroidKaigi/conference-app-2019/issues/738

# Kotlin MultiplatformとDagger

Kotlin MPPモジュールではプラットフォーム依存のコードも入れることが出来、Kotlin MPPモジュール内のAndroidのモジュール内限定になりますが、Daggerに関連するコードも書くことが出来ます。

<img src="https://qiita-image-store.s3.amazonaws.com/0/27388/364a838d-2fd7-6fad-7452-7843bbe32bb0.png" width="300px" />

この中の一番下の部分だけDaggerが使えます。

<img src="https://qiita-image-store.s3.amazonaws.com/0/27388/af1147d2-67ee-24a2-2f03-fda8abeb4ecb.png" width="300px" />

そのため、以下のように継承するなどすることで、配布することが出来ます。

Kotlin MPPモジュール内

```kotlin
interface DroidKaigiApi {
    suspend fun getSessions(): Response
...
}
```

```kotlin
open class KtorDroidKaigiApi constructor(
    val httpClient: HttpClient,
    val apiEndpoint: String,
    val coroutineDispatcherForCallback: CoroutineContext?
) : DroidKaigiApi {
    override suspend fun getSessions(): Response {
... // Ktorの処理は本筋ではないので省略
    }
```

api-impl/src/main(Android用のフォルダ内)

```kotlin
class InjectableKtorDroidKaigiApi @Inject constructor(
    httpClient: HttpClient,
    @Named("apiEndpoint") apiEndpoint: String
) : KtorDroidKaigiApi(httpClient, apiEndpoint, null)
```

```
internal abstract class ApiModule {
    @Binds abstract fun DroidKaigiApi(impl: InjectableKtorDroidKaigiApi): DroidKaigiApi
```

# Kotlin MultiplatformとKotlin Coroutines

**iOSアプリからKotlinのsuspend functionを呼ぶことは通常できません。**
そのため最初は以下のようにコールバックを作成し、実装していました。
これを1つずつ繰り返すのはかなり骨が折れる作業ではないでしょうか :sob: 

```kotlin
    override fun getSessions(
        callback: (response: Response) -> Unit,
        onError: (error: Exception) -> Unit
    ) {
        GlobalScope.launch(requireNotNull(coroutineDispatcherForCallback)) {
            try {
                val response = getSessions()
                callback(response)
            } catch (ex: Exception) {
                onError(ex)
            }
        }
    }
```

Deferredを返す関数を作っておけば、iOSからDeferred.invokeOnCompletion()を呼び出せば可能であることに着目し、`Kotlinx_coroutines_core_nativeDeferred`に対する`extension function`を生やすことでRxSwiftのSingleを返せるようなメソッドを作り、それを使うことで @nukka123 さんが通信を可能にしてくれました。

https://github.com/DroidKaigi/conference-app-2019/pull/601

Kotlin MPPのコード

```kotlin
    override fun getSessionsAsync(): Deferred<Response> =
        GlobalScope.async(requireNotNull(coroutineDispatcherForCallback)) {
            getSessions()
        }
```

Swiftの呼び出しコード

```swift
        return ApiComponentKt.generateDroidKaigiApi()
            .getSessionsAsync()
            .asSingle(Response.self)
```

SwiftのDeferredに対するextension function

```swift
import Foundation
import RxSwift
import ioscombined

extension KotlinThrowable: LocalizedError {
    public var errorDescription: String? {
        return self.message ?? "No message. \(self)"
    }
}

extension Kotlinx_coroutines_core_nativeDeferred {

    func asSingle<ElementType>(_ elementType: ElementType.Type) -> Single<ElementType> {
        return Single<ElementType>.create { observer in
            self.invokeOnCompletion { cause in
                if let cause = cause {
                    observer(.error(cause))
                    return KotlinUnit()
                }

                if let result = self.getCompleted() as? ElementType {
                    observer(.success(result))
                    return KotlinUnit()
                }

                fatalError("Illegal state or invalid elementType.")
            }

            return Disposables.create {
                self.cancel()
            }
        }
    }
}
```

# Kotlin MultiplatformとDynamic Feature Module

AndroidではDynamic Feature Moduleという後からモジュールを読み込むことで、アプリを小さくできる仕組みがあります。これを組み込もうとしたときに以下のようなエラーが出ました。

```
ZipException: duplicate entry: META-INF/ktor-client-core.kotlin_module
```

これは`ktor-client-core-1.1.2.jar` と `ktor-client-core-jvm-1.1.2.jar`で同じ`ktor-client-core.kotlin_module`が含まれているため起こっていると思われます。
現在、以下のissueを作成しており、S1の優先度で対応していただいているので、そのうち直ると思われます。
https://issuetracker.google.com/issues/125696148

# Parcelizeとの連携
ParcelableはAndroidでIntentにインスタンスを乗せてデータを送ったりするときに使う仕組みです。ParcelizeはAndroid向けのParcelableを簡単に作ってくれるKotlinの機能です。そのParcelizeは工夫することで、Kotlin Multiplatform Moduleの中でも使えます。
詳しくは以下の最高の記事があるのでそれをご覧ください。
https://aakira.app/blog/2018/12/kotlin-mpp-android-parcelable/

# ハマったポイント(うろ覚え)

## Kotlin/Nativeの対応ArchitectureによるiOSでのリリースの制限
iOSのリリースの前日、`Undefined symbols for architecture armv7`というエラーに悩まされる。そもそもサポートされていなかったので除外して解決。(古いiPhoneでは動かない模様)
https://github.com/JetBrains/kotlin-native/issues/1460

## Kotlin MultiplatformのクラスがAndroid Studio上で赤くて解決されない
`enableFeaturePreview('GRADLE_METADATA')`がないとダメ。
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">Kotlin Multiplatformで1モジュール指定でプラットフォームごとに読み込める理由はGradle module metadataファイル を読み込んで、platform-typeとか指定されているから勝手に使えるっぽい。この機能を使うにはGradle 4.7でenableFeaturePreview(&#39;GRADLE_METADATA&#39;)じゃないとうまく動かない。 <a href="https://t.co/0HWlaPKvb9">pic.twitter.com/0HWlaPKvb9</a></p>&mdash; takahirom (@new_runnable) <a href="https://twitter.com/new_runnable/status/1076465944346546176?ref_src=twsrc%5Etfw">2018年12月22日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

https://github.com/DroidKaigi/conference-app-2019/pull/81

Gradle Metadataにもバージョンがあって、Gradle 4.7のMetadataとそれ以降のMetadataがあり、現状は、新しいバージョンのメタデータに大体のライブラリが対応しているので、それを使うと問題なく利用できます。(うまくビルドできないときは確認してみると良さそう)

ちなみに、Gradle 5.3からGradle module metadataは1.0になるみたいでまたライブラリのアップデート必要そうかもです :sweat_smile: 
https://github.com/gradle/gradle/blob/de88b30e5374ede4dc393f5709fa71a7f349785e/subprojects/docs/src/docs/design/gradle-module-metadata-1.0-specification.md

# まとめ

iOSの実装など、ほとんど自分の力ではないのですが、さまざまな人の力によりDroidKaigiのKotlin Multiplatformの実装がされました。このようにPRによって知見が集まってくることは、個人的にはかなりすごいことだと思っています。