どこでもKotlin #7 〜Kotlin MPP特集〜で話す予定の内容です!他の方の発表面白そうですし、先着順でまだ空きがあるようなので参加まだの方いれば早めにどうぞ!
https://m3-engineer.connpass.com/event/123055/
概要
自分の知見というより、DroidKaigiアプリとしての知見になりますが、このままではもったいないので、書いておきます。
このあたり、自分はAndroid版の機能をレビュー、実装している間に、コントリビューターの方々がやっていただけたものになります。 @kikuchy さんが特にこの基盤作りをメインにやってくれていました。他にもiOSの有名な方々など、さまざまな方が開発に参加していただけました。
DroidKaigiではAndroid版とiOS版をリリースしています。そこで培った知見を公開しておこうと思います。
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で指定してあげることで、作ってあげることが出来ます。
使ったライブラリ
Kotlin Multiplatformのために現在、さまざまなライブラリが開発されており、DroidKaigiではKtor-ClientやTimber、Klockなどのライブラリを用いて開発を行いました。これを使うことで、Multiplatformで例えばAPIの呼び出しの処理を共通化出来たりします。
Ktor-Client
Kotlin Multiplatformで使えるHttpClient。 OkHttpと連携できたり、GsonやKotlinx.Serializationと連携できる。インターフェースはRetrofitのほうがいい感じ。
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部分を共通化しています。
Kotlin MultiplatformとMulti Module
なぜこのような構成になっているのか見ていきましょう。
まず最初に自分が作っていたときは機能を実装していかなくてはいけないので、APIやModelのモジュールを分けており、ModelはKotlin Multiplatform Project(MPP) モジュールで作っていました。
そこでiOS版を作るときに1つ問題が発生しました。
iOSのプロジェクトから複数のKotlin MPPモジュールを参照できませんでした。
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つずつ繰り返すのはかなり骨が折れる作業ではないでしょうか
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 さんが通信を可能にしてくれました。
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.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')
がないとダメ。
Kotlin Multiplatformで1モジュール指定でプラットフォームごとに読み込める理由はGradle module metadataファイル を読み込んで、platform-typeとか指定されているから勝手に使えるっぽい。この機能を使うにはGradle 4.7でenableFeaturePreview('GRADLE_METADATA')じゃないとうまく動かない。 pic.twitter.com/0HWlaPKvb9
— takahirom (@new_runnable) 2018年12月22日
Gradle Metadataにもバージョンがあって、Gradle 4.7のMetadataとそれ以降のMetadataがあり、現状は、新しいバージョンのメタデータに大体のライブラリが対応しているので、それを使うと問題なく利用できます。(うまくビルドできないときは確認してみると良さそう)
ちなみに、Gradle 5.3からGradle module metadataは1.0になるみたいでまたライブラリのアップデート必要そうかもです
https://github.com/gradle/gradle/blob/de88b30e5374ede4dc393f5709fa71a7f349785e/subprojects/docs/src/docs/design/gradle-module-metadata-1.0-specification.md
まとめ
iOSの実装など、ほとんど自分の力ではないのですが、さまざまな人の力によりDroidKaigiのKotlin Multiplatformの実装がされました。このようにPRによって知見が集まってくることは、個人的にはかなりすごいことだと思っています。