グダグダ書くよ
Android/iOSアプリをKotlin Multiplatform Projectで作るのにいい感じのアーキテクチャをいろいろ試行錯誤している。
Kotlin Multiplatform Projectでアプリを作るにあたって、単純にAndroidアプリを作るようにはいかないところがいくつかあるので、なかなかおもしろい。 その"おもしろい"点というのは、たとえば下記のような点だ。
freezing
というランタイムの特性があり、変更可能なデータがスレッドをまたげない最初の3つはまあ言いたいことは同じで、つまりリアクティブを実現するための仕組みがKotln Coroutinesしかない、ということだ。
データのストリームは基本的にKotlin CoroutinesのChannelを利用して表現することになりそう。
Flowというコールドストリームの実装が1.3.30で来たけど、ViewModelから公開するStateのストリームにはChannelのほうが相性は良さそう。
ユースケースとかデータ層とか、場所によっては使えそう。このへんの使い分けはRxJavaのSubject/Observableと変わらない。
ChannelをFlowに変換していろいろなオペレータで加工する、というのは勿論ありうるしようやくRx的なことができるようになって嬉しい限り。
LiveData
は最近更新が途絶えてるけどMultiplatform対応のライブラリがあるので、それを更新すればいけなくもない。
とはいえKotlin Coroutinesがかなり充実してきているのでわざわざ使う必要もなさそう。
AndroidではActivity/FragmentでChannelをLiveDataに変換してあげるとちょっと扱いやすくなるかもしれない。
最近はAndroid JetpackのCoroutinesサポートが充実してきたのであんま必要ないかも。
ただCoroutinesはSwiftから直接呼べないので、コールバック形式に変換してあげる必要がある。
あるいは、CoroutineScope
を実装したアダプタのようなものを作ってあげるのもいいかもしれない。
class ViewModel : CoroutineScope{
val states: Channel<State>
}
たとえば上記のようなViewModel
があったとして、このval states: Channel<State>
はSwift側からは普通に触ることはできない。
そこで下記のようなクラスを用意する。
class StateListener(val context: CoroutineContext) : CoroutineScope {
override val coroutineContext = context + SupervisorJob()
fun listenToStateUpdate(viewModel: ViewModel, callback: (state: State) -> Unit) {
launch {
viewModel.states.consumeEach { s ->
callback(s)
}
}
}
}
これをSwiftで書かれたViewController
で使えば、ViewModel
のI/Fを変えないまま使い回すことができる。ViewModel
もStateListener
もCoroutineScope
を実装しているので、適当なタイミングでCoroutineScope#cancel()
を呼んであげればライフサイクル的な問題もないはず。
ただこれは基本的にただのリスナですよー、って認識を徹底してここにiOS固有のロジックを書かないようにしたほうがいい。
あと、Kotlin/Nativeではジェネリクスが使えない(使えるけど、Kotlin外から見るとAnyになってしまう)ので、このリスナクラスはViewModel
ごとに作ってあげる必要がある。
Objective-Cヘッダのジェネリクスまわりは1.3.40から改善しそうなので期待。
ちなみにKotlin/Native上のCorutinesはメインスレッドしかサポートしてないので注意が必要。
commonコードでAndroidのDispatchers.IO
とか意識したい場合は、下記の用にexpect - actual
で書き分けたらよい。
// common
expect val mainContext: CoroutineContext
expect val backgroundContext: CoroutineContext
// android
actual val mainContext: CoroutineContext = Dispatchers.Main
actual val backgroundContext: CoroutineContext = Dispatchers.IO
// ios. 自分で用意したメインスレッド用のDispatcherを使う
actual val mainContext: CoroutineContext = ApplicationDispatcher
actual val backgroundContext: CoroutineContext = ApplicationDispatcher
Kotlin/NativeではConcurrencyのモデルがJVMとはかなり異なっている。Worker
というAPIを使えば並列処理はできるけど、そもそもKotlin/NativeにしかないAPIなのでKMPでcommonコードから扱いたいときは各プラットフォーム用の抽象化が難しい。
また、Workerとメインスレッドでオブジェクトをやり取りする際はオブジェクトをfreeze
しなければならない。freeze
したオブジェクトは変更不可能になり、var
で宣言した値でも再アサインしようとするとInvalidMutabilityException
が投げられる(そう、ランタイムの特性なのだ!)。
また、freeze
されたオブジェクトを参照してたり参照したりしてるオブジェクト(オブジェクトのサブグラフ)もfreeze
されるのでよくわからないことになる。
AtomicReference系の一部クラスを使うこともできるけど非常に限られたAPIで、無理をして実装するよりは新しいパラダイムになれたほうがよさそう。
ちなみにfreeze
されたコールバックのラムダ式とかからCoroutinesを使おうとすると、マルチスレッド対応してないので前述のInvalidMutabilityException
を投げて死ぬ。
コールバックをサブグラフに注意しつつThreadLocalで保持して、freeze
されたラムダ内からメインスレッドに戻した後に呼ぶ、とか回りくどいことをやれば一応回避はできる。できるけどメインスレッドには戻ってしまう。
詳しくは文末の参考資料に挙げた記事を読んでみてほしい。
touchlab/DroidconKotlinが実装としては参考になる。
ちなみにKotlin Multiplatform対応のSQLIte3ラッパーsquare/sqldelightのクエリリスナはfreeze
されるので、この辺の考慮が必要。
freezing
厄介実際に採用したアーキテクチャについてはまた後ほど詳しく書くかも。