Kotlin Multiplatform Projectで考えることいろいろ

グダグダ書くよ


Android/iOSアプリをKotlin Multiplatform Projectで作るのにいい感じのアーキテクチャをいろいろ試行錯誤している。

Kotlin Multiplatform Projectでアプリを作るにあたって、単純にAndroidアプリを作るようにはいかないところがいくつかあるので、なかなかおもしろい。 その"おもしろい"点というのは、たとえば下記のような点だ。

  • Kotlin Multiplatform ProjectではRxが使えない
  • LiveDataもAndroid Specificなので使えない
  • 非同期は基本Coroutinesでやる必要がある
  • CoroutinesはSwiftから使えない
  • Kotlin/Nativeではfreezingというランタイムの特性があり、変更可能なデータがスレッドをまたげない

前提条件

  • 筆者はMVI(Model - View - Intent)推し
  • Clean Architectureぽい階層型のアーキテクチャを採用している

非同期

最初の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

ただCoroutinesはSwiftから直接呼べないので、コールバック形式に変換してあげる必要がある。
あるいは、CoroutineScopeを実装したアダプタのようなものを作ってあげるのもいいかもしれない。

kotlin
class ViewModel : CoroutineScope{
  val states: Channel<State>
}

たとえば上記のようなViewModelがあったとして、このval states: Channel<State>はSwift側からは普通に触ることはできない。
そこで下記のようなクラスを用意する。

kotlin
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を変えないまま使い回すことができる。
ViewModelStateListenerCoroutineScopeを実装しているので、適当なタイミングで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

freezing

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されるので、この辺の考慮が必要。

まとめ

  • リアクティブプログラミングはKotlin CoroutinesのChannel/Flowを使って実現
  • Swiftから使うときはコールバック形式に変換するかアダプタを作ってあげる
  • freezing厄介

実際に採用したアーキテクチャについてはまた後ほど詳しく書くかも。

参考