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を作成して、使います。

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


使ったライブラリ

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


Kotlin MultiplatformとMulti Module

なぜこのような構成になっているのか見ていきましょう。

まず最初に自分が作っていたときは機能を実装していかなくてはいけないので、APIやModelのモジュールを分けており、ModelはKotlin Multiplatform Project(MPP) モジュールで作っていました。

そこでiOS版を作るときに1つ問題が発生しました。

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

image.png

image.png

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

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

    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に関連するコードも書くことが出来ます。

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

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

Kotlin MPPモジュール内

interface DroidKaigiApi {

suspend fun getSessions(): Response
...
}

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用のフォルダ内)

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:

    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のコード

    override fun getSessionsAsync(): Deferred<Response> =

GlobalScope.async(requireNotNull(coroutineDispatcherForCallback)) {
getSessions()
}

Swiftの呼び出しコード

        return ApiComponentKt.generateDroidKaigiApi()

.getSessionsAsync()
.asSingle(Response.self)

SwiftのDeferredに対するextension function

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.jarktor-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')がないとダメ。


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によって知見が集まってくることは、個人的にはかなりすごいことだと思っています。