December 23, 2019

オレの考えた最強のKotlin Multiplatform Projectアーキテクチャ2020

この記事はKotlin Advent Calendar 2019 の20日目の代打記事です。
そのため、日付の順序が逆になってしまっています。
Kotlin/Nativeをバックグラウンドスレッドで使う方法は22日目の記事を見て下さい。

Kotlin/NativeでBackground Threadを使う - AAbrain

Kotlin Coroutinesが、Kotlin/Nativeでもバックグラウンドスレッドに対応したので使い方を解説します

Overview

上記の記事で解説した通りまだPreview版ですが、Kotlin/Nativeでも遂にバックグラウンドスレッドが利用可能になりました。
この記事では、CoroutinesのFlowを使って俺の考えた最強のKotlin Multiplatform Project(MPP)設計を語ります。

※ タイトルはネタです。設計なんてサービスによって変わります。(今回はそれなりに大きなサービスを想定しています)
※ 設計は宗教です。異教徒の人は暖かく見守って下さい。宗教戦争ダメ。ゼッタイ。
※ Kotlin/Nativeはβ版です。さらにCoroutinesのバックグラウンド対応版はPreview版です。半年後には気が変わってるかもしれません。
※ Clean Architecture, MVVM, MVP等の一般的な設計論は知っている前提で話します。

今回のサンプルはこちらにあります。

GitHub - AAkira/multithread-mpp: This is the best architecture of Kotlin Multiplatform Project I think! This project works on background thread using kotlinx.Coroutines.

This is the best architecture of Kotlin Multiplatform Project I think! This project works on background thread using kotlinx.Coroutines. - AAkira/multithread-mpp

設計を考える

バックグラウンドの処理が出来るということは、通常のアプリ開発の設計のように時間のかかる処理に対してスレッドの切り替えを行うことが可能になります。とはいえ、スレッドの切り替えを自分で全て書こうとすると大変です。
CoroutinesにはFlowというRxのようなリアクティブな仕組みがあります。
さらにFlowにはlaunchIn(CoroutineScope)flowOn(CoroutineScope)という、RxにあるobserveOnsubscribeOnに似た、チェーンの形でスレッドの切り替えが出来る仕組みが用意されています。

flowOf(1)                       // background thread
  .map { it * 2 }               // background thread
  .flowOn(backgroundDispatcher) // ここより上が background therad
  .onEach {
     println(it)                // main thread
  }
  .launchIn(mainDispatcher)

Flowが扱えるということは、MPPの共通モジュールからはFlowで値を返して、各プラットフォーム側でObserve処理を書けば、 以前Rxでやっていたようなリアクティブプログラミングの形がとれるようになります。
イメージ図はこのようになります。

architecture

JetBrainsはMVPの形をオススメしている気がします(個人の感想)が、プレゼンターまでを共通化してしまうと、Android, iOS, (WEB)特有の実装が入った際にどうしても設計が難しくなってしまう場面に遭遇すると考えています。
確かにプレゼンターのレイヤを共有出来ると実装箇所がかなり減るのでとても効率が良いのですが、上記の問題が起きた場合にどうしてもView側にロジックが漏れてしまうため、個人的にはメジャーなクロスプラットフォームツールで多く用いられているViewまでを共通化してしまう弊害が発生すると思っています。
2重実装になってしまいますが、そこにViewModelの層を1つ挟んであげることで、AndroidであったらLivDataの処理を書いたり、iOSではRxSwiftなりに変換してあげてプラットフォーム毎に必要な機能を実装してあげるのが個人的には良いかなと現状では考えています。

実装

実際にどのように書いていくのかを説明します。
残念ながら現状KtorはCoroutinesに依存しているため、native-mtのバージョンを使うことが出来ず、バックグラウンドで使う事は出来ませんでした。そのため、今回はSQLDelight を使ってデータベースの取得部分を非同期処理して説明します。

SQLDelightについては以前解説した記事を見て下さい。

今回使用するバージョンはこのようになっています。

ライブラリバージョン
Kotlin1.3.61
org.jetbrains.kotlinx:kotlinx-coroutines1.3.3-native-mt
co.touchlab.sqldelight1.3.0-mt2-SNAPSHOT

SQLDelightはcom.squareup.sqldelightではないのでご注意下さい。CoroutinesのMultithreadに対応したバージョンをTouchlabの@kpgalligan がフォークしてco.touchlab.sqldelightとして上げ直してくれています。

アーキテクチャ

今回は上記の図の簡易バージョンを作成します。
説明をシンプルにするため図中のViewModel部分とRepositoryは省略した形になっています。

具体的には、commonMain(共通モジュール)にFlowを返すインタフェースを作ってあげて、AndroidからはlifecycleScope等のAndroidのライフサイクルに即したCoroutine Scopeで呼び出しをします。 iOS側も同様にSwiftからiOSのライフサイクルで呼び出しを行いたいですが、現状ではまだKotlin/Native側からsuspend functionに触ることが出来ないため、iosMainのディレクトリでiOSのみ拡張関数を定義してプロキシしてあげます。 全体の概要図はこのようになります。

 Platform  |   MPP
  [View] <-+-> [Service] <-> [Dao] <-> [DB]

Android

Andridは通常通りCoroutineを利用出来るため、そのまま共通モジュールのインタフェース経由で値を取得します。
上記の概要図をより詳しく書くとこうなります。

     Platform      |   MPP
  [MainActivity] <-+-> [Service] <-> [Dao] <-> [DB]

iOS

iOSは前述の通り、suspend functionに直接触ることが出来ないので、現状コールバック形式で呼び出すか、CoroutinesのDefferedを使うしかありません。
当然そのコードはMPP側に書くことになりますのでiOSはAndroidよりも一階層多くなってしまいます。

      Platform       |   MPP
  [ViewController] <-+-> [MPP Proxy] <-> [Service] <-> [Dao] <-> [DB]

DB呼び出し

DBの呼び出しクラスをDaoとして作ります。
今回はDBから値を取得してGreetingというモデルを返してあげます。

data class Greeting(val hello: String)

class GreetingDao {

    private val greetingDatabase = createDb()
    private val queries = greetingDatabase?.greetingQueries

    fun storeGreeting(hello: String) {
        queries?.insertItem(hello)
    }

    fun getGreetings(): Flow<Query<Greeting>> {
        val queries = queries ?: return flowOf()

        return queries
            .selectAll(mapper = { _, hello -> Greeting(hello) })
            .asFlow()
    }
}

これは、MPP側での実装です。
今回はFlowで値を繋いでいくため、DB取得からの戻り値はFlowにして下さい。

※ Coroutinesバージョンの関係で、asFlowはバックグラウンド用にSQLDelightで定義されたものではなく、独自で実装しています。

Service(UseCase)層

class GreetingService {

    private val dao = GreetingDao()

    fun saveGreetings(greeting: String) {
        dao.storeGreeting(greeting)
    }

    fun getGreetings(): Flow<Query<Greeting>> =
        dao.getGreetings()
            .doOnNext {
                assertNotMainThread()

                println("----------------")
                println("Common Background Thread: ${currentThreadName()}")
                println("----------------")
            }
            .flowOn(backgroundDispatcher)  // ここより上の呼び出しがbackground thread
}

このクラスはDaoからの値を直接取得してView側に繋ぐ役割を持っています。実際はロジックを記述していく事になると思います。本来はこの前にRepository層が入りますが説明のため省略してあります。
doOnNextはRxにある変更を加えないオペレーターを真似て独自に作ったものなので、Flow自体には存在しないので注意してください。
注目して頂きたいのはflowOn(backgroundDispatcher)の部分です。flowOnで簡単に根本から呼び出し部分のスレッドを切り替えることが出来ます。
MPP内にこの記述が出来るようになったため重い処理はバックグラウンドスレッドでの呼び出しを強制することが出来ます。

Viewからの呼び出し

Android

Androidは普通にActivityで、この様に書くだけです。

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // initialize mpp
        mppAppContext = this.applicationContext

        val service = GreetingService()
        // store value
        service.saveGreetings("Hello")
        // observe db value
        service.getGreetings()
            .onEach { query ->
                Log.v("Android", "Android Main Thread: ${Thread.currentThread().name}")
                query.executeAsList().firstOrNull()?.let {
                    helloText.text = it.hello
                }
            }
            .catch {
                helloText.text = it.toString()
            }
            .launchIn(lifecycleScope)
    }
}

launchIn(lifecycleScope)でActivityのライフサイクルに即したメインスレッドで呼び出しています。 そのため、DBから取ってくる箇所はバックグラウンドスレッドになり、onEach()で書かれた部分はメインスレッドで処理されます。
Serviceに書かれたログと、Activityに書かれたログの実行結果はこのようになります。

----------------
Common Background Thread: DefaultDispatcher-worker-2
----------------
Android Main Thread: main

iOS

先程も述べた通りiOSでは、一階層挟む必要があります。
ただしKotlinの拡張関数とトップレベル関数を組み合わせれば、かなり綺麗に記述することが出来ます。(主観)

fun GreetingService.getGreetings(
    successCallback: (Greeting) -> Unit,
    errorCallback: (Throwable) -> Unit
) {
    getGreetings()
        .doOnNext {
            println("iOS Actual Thread: " + currentThreadName())
        }
        .onEach {
            it.executeAsList()
                .firstOrNull()
                ?.let(successCallback)
        }
        .catch {
            errorCallback(it)
        }
        .launchIn(mainScope)
}

この実装はMPP側のKotlin実装になります。
コールバック形式の引数を持ったServiceクラスと同名の拡張関数を定義してあげます。引数が被らないので同名の関数でもオーバロードされ定義可能です。
他はAndroidと同じく呼び出しのスレッドをlaunchIn(mainScope)で指定してあげて、メインスレッドに戻した状態で引数のコールバックを返してViewから呼び出しをします。

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let label = UILabel(frame:
            CGRect(x: 0, y: 0, width: view.frame.size.width, height: view.frame.size.height)
        )
        self.view.addSubview(label)

        let service = GreetingService()
        service.saveGreetings(greeting: "Hello")
        service.getGreetings (
            successCallback: { result in
                label.text = result.hello
            }, errorCallback: { error in
                label.text = error.message
                print(error)
            }
        )
    }
}

iOS側で特別なことは特にしていません。 コールバック形式で呼び出した結果を表示しています。
Android同様に実行結果のログを見ると見事iOSでもDB取得部分はバックグラウンドスレッドで動作しているのがわかります。

----------------
Common Background Thread: <NSThread: 0x6000035b58c0>{number = 7, name = (null)}
----------------
iOS Actual Thread: <NSThread: 0x6000035ea5c0>{number = 1, name = main}

まとめ

iOSがバックグラウンドスレッドで動作可能になったことで、Kotlin Multiplatform Projectの可能性が大幅に広がりました。
さらにFlowやChannelのようなCoroutinesの機能を使えば、通常のアプリ開発と遜色ないリアクティブプログラミングの設計で各プラットフォームのコードを共有出来るようになります。 夢はますます広がるばかりです。
とはいえ、まだCoroutines バックグラウンド対応版はPreview版なので、本番投入にはもう少し時間はかかるでしょう。
来年はMPPが本格的に実践出来る年になりますように。
Have a nice Kotlin!

宣伝

ついに、半年ぐらい前から共著で書いていた本が来年1月末頃に発売されます!
技術評論社から出版されている「みんなのシリーズ」の1つとして、タイトルは「みんなのKotlin」です。
私は4章のMPPの章を担当しました。
MPPはまだβ版で書けるところが少なく、Kotlin Festの発表を聞いて頂いた方は大体聞いたことある内容となっているので、 物凄く参考になるかと言われると微妙なのですが、発表では触れられなかった箇所にもいくつか触れているのでお布施ぐらいの気持ちで買って頂けるとありがたいです。(サンプルも書いたりしたらページ数では一番多くなってしまったっぽい)
もちろん、他の章にはAndroid, Server, Test等 一通りKotlinの文法を覚えて、いざ業務で使うという時に参考になる内容が多いので、興味がある方は是非お手元にどうぞ。
Kindle版はそのうち出ると思います。

© AAkira 2023