🏎️

Kotlin Coroutines FlowをSwiftでobserveしてみた

2021/12/15に公開

はじめに

ACCESS Advent Calendar 2021 12/15の担当はスマホアプリ開発のtonionagauzziです。Qiitaの企画ですが、Zennで書いた人が何人かいたので私も倣ってみます。

本記事の内容は、UI以外をKotlin Multiplatform Mobileで実装し、UIはSwiftで実装した場合、KotlinのFlowをSwiftに伝搬させるにはどうすればよいのか調査した話です。

環境構築

せっかくなので、Kotlin Multiplatform Mobile(以下、KMM)アプリを1から作る手順を載せます。

  1. Android Studioで、Android Studio→Preferences→Pluginsで、Kotlin Multiplatform Mobileをインストール
  2. Android Studioで、File→New→New Project、KMM Applicationを選択
  3. パッケージ名など入れる
  4. Add sample tests for Shared moduleにチェック入れ(本記事ではテストコード書かないですが今後のため)、iOS framework distributionはRegular frameworkとする
  5. フォルダ階層をAndroidからProjectに変える
  6. iOSのデバッグ設定をEdit Configurationsから行うExecution Targetを好みのSimulatorに指定Simulatorが出てこない場合はXcode→Window→Devices and Simulators→Simulatorsタブの+ボタンで追加
  7. これで準備完了。Runすると…Androidエミュレーターでスクショ撮ったらGLDirectMem/Vulkan should be enabled. host GPU blacklisted?みたいなエラーで落ちました。
    このエラーは後でAndroid Emulator 30.9.5から31.1.4に更新したら直ったが、ひとまず実機Pixel 5aでスクショ撮りました。
    AndroidはOKですね。続いてiOSは…バッチリですね!
    ちなみにGradle SettingでJDK 1.8だとAndroid Gradle plugin requires Java 11 to run. You are currently using Java 1.8.というエラーになるので、Android Studio→Preferences→Build, Execution, Deployment→Build Tools→GradleでGradle JDKを11以上に設定
  8. Gradleにcommonで使う依存関係を追加します。
shared/build.gradle.kts
-        val commonMain by getting
+        val commonMain by getting {
+            dependencies {
+                implementation("org.jetbrains.kotlin:kotlin-stdlib:1.6.0")
+                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2-native-mt"){
+                    version {
+                        strictly("1.5.2-native-mt")
+                    }
+                }
+            }
+        }

native-mtを付与しないとiOSからのCoroutines呼び出しで落ちます。後々Ktorなどの依存関係を追加する場合に備えてstrictlyも設定します。

アプリ作り

作るのは「赤ちゃんに見せるアルバムアプリ」です。

11ヶ月の娘が、自分やいとこの写真をスマホで見るのが大好きなんです。

しかし、OS標準のフォトアプリで写真を見せてると、手を伸ばして触れていろんな操作をしてしまいます。

たとえば削除してゴミ箱を空にしたりとか、共有が開いてメールが開いて画像を誰かに送信なんてことがあると困るんですね。タッチパネルが敏感すぎて予期せぬことが意外と起きます…。

だからアルバムアプリの仕様は次のようにします。

  1. 15秒ごとに異なる画像をランダムに表示する
  2. ユーザー入力は一切受け付けない
  3. Homeボタンを押さないとアプリを抜けられない

設計はこうします。Repositoryより先はありません。

さて、ここまでを細かく書いてたら時間が足りなくなってきたのと、サーバーの実装が絡んだら記事が長くなって本質がどこかわからなくなるので、思いっきりシンプルなプログラム仕様にします。

  1. InteractorとRepositoryは、15秒ごとに[0-4]の数字をランダムでStateに反映
  2. ViewModelは、StateをSubscribeし、その数字に応じた画像名をViewにPublish
  3. Viewは、@Published変数をSubscribeし、その画像をアセットから見つけて表示

その通りコードを書いていきます。Kotlinは全てsharedのcommonMain階層下です。

まずは、本記事のメインテーマとなるStateFlowをSwiftで使うためのSwiftStateFlowを、こちらを参考に実装します。

SwiftStateFlow.kt
package com.vitantonio.nagauzzi.babyalbum

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach

interface Closeable {
    fun close()
}

class SwiftStateFlow<T>(private val kotlinStateFlow: StateFlow<T>) : Flow<T> by kotlinStateFlow {
    val value = kotlinStateFlow.value

    fun observe(continuation: ((T) -> Unit)): Closeable {
        val job = Job()
        kotlinStateFlow.onEach {
            continuation(it)
        }.launchIn(
            CoroutineScope(Dispatchers.Main + job)
        )
        return object : Closeable {
            override fun close() {
                job.cancel()
            }
        }
    }
}

呼び元はcontinuationクロージャの引数でデータを受け取ります。

FlowでなくStateFlowを選択したのは、前回と同じ値を連続発行しないためのチェックをInteractorに設ける予定だからです。

ここで、Flowの豊富なオペレーターを駆使すればいいのでは?と思われた方は鋭いですね!ぜひ後述のCombineとFlowを連動してより使いやすくするというところを見てください。

observeの戻り値でCloseableオブジェクトが取れるので、呼び元が破棄されるときにclose()を呼ぶと中断もできます。実際、後述のViewModelのdeinitで呼んでます。

次に、KMM側のInteractor/Repository/Stateを実装します。

PublishNumber.kt
package com.vitantonio.nagauzzi.babyalbum.domain.interactor

import com.vitantonio.nagauzzi.babyalbum.SwiftStateFlow
import com.vitantonio.nagauzzi.babyalbum.domain.repository.NumberRepository
import com.vitantonio.nagauzzi.babyalbum.domain.state.NumberState
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

class PublishNumber(
    private val repository: NumberRepository
) {
    private val mutableState = MutableStateFlow(NumberState.Init(0) as NumberState)
    private val swiftMutableState = SwiftStateFlow(mutableState)

    val state: StateFlow<NumberState>
        get() = mutableState
    val swiftState: SwiftStateFlow<NumberState>
        get() = swiftMutableState

    fun execute(min: Int, max: Int, times: Int) =
        CoroutineScope(Dispatchers.Default).launch {
            repeat(times) {
                delay(15000)
                mutableState.value = NumberState.Updated(
                    repository.getChangedRandom(min, max, before = state.value.number)
                )
            }
        }
}
NumberRepository.kt
package com.vitantonio.nagauzzi.babyalbum.domain.repository

class NumberRepository {
    fun getChangedRandom(min: Int, max: Int, before: Int): Int {
        val random = (min..max).random()
        return if (random == before) getChangedRandom(min, max, before) else random
    }
}
NumberState.kt
package com.vitantonio.nagauzzi.babyalbum.domain.state

sealed class NumberState(open val number: Int) {
    data class Init(override val number: Int) : NumberState(number)
    data class Updated(override val number: Int) : NumberState(number)
}

この程度ならInteractor/Repository/Stateに分けるメリットがあまり無いのですが、行く行くはサーバーから画像をダウンロードしてByteArrayか何かでInteractorに渡し、StateはInit/Success/Failureに分かれることを想定しているので、今のうちにと分けてしまいました。

さて、ここからはSwift側です。

KMMのプロジェクトを作るだけでSwiftUIがデフォルト生成されているので、まずViewModelを作ります。

AlbumViewModel.swift
import shared

class AlbumViewModel: ObservableObject {
    private let photos = (1...5).map { "BabyImage\($0)" }
    private var closeables: [Closeable] = []
    
    @Published var photoName: String

    init(interactor: PublishNumber) {
        self.photoName = self.photos[0]
        let closeable = interactor.swiftState.observe { newState in
            if 0...4 ~= newState!.number {
                self.photoName = self.photos[Int(newState!.number)]
            } else {
                fatalError("newNumber isn't supported number")
            }
        }
        closeables = [closeable]
    }
    
    deinit {
        closeables.forEach {
            $0.close()
        }
    }
}

先頭のimport sharedで、KMMで作ったSwiftStateFlowやInteractorにアクセスできるわけです。始めれば難しくないですよね、KMM!

そして、observeがFlowを監視・データ受信する部分です。画像名に変えてphotoNameに入れると、@Published が付いているのでViewの更新トリガーの役割を果たしてくれます。

photosが配列で画像名を持っていますが、実際の画像はBabyImage1BabyImage5をassetに登録済みです。

最後に、ContentViewをデフォルトから変更します。これが設計図のViewにあたります。

ContentView.swift
 import shared
 
 struct ContentView: View {
-    let greet = Greeting().greeting()
-
+    @ObservedObject var viewModel: AlbumViewModel
+    let interactor: PublishNumber
+    
+    init() {
+        self.interactor = PublishNumber(repository: NumberRepository())
+        self.viewModel = AlbumViewModel(interactor: interactor)
+    }
+    
     var body: some View {
-        Text(greet)
+        ZStack {
+            Color.black
+                .ignoresSafeArea()
+            Image(viewModel.photoName)
+                .resizable()
+                .aspectRatio(contentMode: .fill)
+        }.task {
+            self.interactor.execute(min: 0, max: 4, times: 100)
+        }
     }
 }

ここで、SwiftUIのプレビューが動いていないことに気づきました。

Android Studio側でRunするとビルドできるのですが、Xcode側ではまだxcframeworkをインポートしてないので、そのビルドエラーによってプレビューやデバッグ、エラー調査などができないのです。

Xcodeでそれらができたら開発効率が上がるので、直しましょう。

iosAppのTARGETS→Build Phases→Link Binary With Librariesで、./BabyAlbum/shared/build/bin/iosX64/debugFramework/shared.frameworkを追加します。

ただし、とりあえずビルドを通したいだけなので、CPUアーキテクチャーによっては上記出力先のframeworkじゃダメなこともあります。

いちいち設定を弄るのは面倒なので、実際に動かすためのビルドはAndroid Studioからがよいでしょう。

これでひとまず仕様は満たせました。

プレビューもされてるし、実行すると15秒毎に違う画像に変わります。これはSwiftStateFlowが15秒毎のFlowのデータ送出をSwiftにうまく伝えているからですね。

iPadに入れて赤ちゃんに見せてあげたいと思います😊

CombineとFlowを連動してより使いやすくする

さて、ここまでで満足かというと、「本当に作りたかったものはこれじゃない」感があります。

SwiftStateFlowがonEachを逐次横流ししてるだけじゃん!みたいな。

Flowの強みである様々なオペレーター(mapfilteronEachreduceなど)を使いたいのです。

とはいえFlowと同じオペレーターをSwiftで1から実装するのは大変だし、shared.hに定義されてるKotlinx_coroutines_core系の型を使ってFlowの扉をこじ開けるのもちょっと根気が必要そうです。

そもそも、無理してFlowに合わせるより、SwiftならSwiftらしい実装をしたいですよね。

そこで、Swift標準の非同期フレームワークであるCombineの出番です!

SwiftStateFlowとAlbumViewModelを以下のように書き直します。

SwiftStateFlow.kt
package com.vitantonio.nagauzzi.babyalbum

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach

class SwiftStateFlow<T>(private val kotlinStateFlow: StateFlow<T>) : Flow<T> by kotlinStateFlow {
    val value = kotlinStateFlow.value
    var job: Job? = null

    fun observe(continuation: ((T) -> Unit)) {
        kotlinStateFlow.onEach {
            continuation(it)
        }.launchIn(
            CoroutineScope(Dispatchers.Main + Job().also { job = it })
        )
    }

    fun close() {
        job?.cancel()
        job = null
    }
}

Closeableをなくした代わりにcloseを外部公開しました。

AlbumViewModel.swift
import Combine
import shared

class AlbumViewModel: ObservableObject {
    private let photos = (1...5).map { "BabyImage\($0)" }
    private let interactor: PublishNumber
    private var cancellable: Cancellable?
    
    @Published var photoName: String

    init(interactor: PublishNumber) {
        self.interactor = interactor
        self.photoName = self.photos[0]
        self.cancellable = NumberStatePublisher(stateFlow: interactor.swiftState)
            .map { newValue in
                self.photos[Int(newValue.number)]
            }
            .assign(to: \.photoName, on: self)
    }
    
    deinit {
        self.cancellable?.cancel()
    }
}

public struct NumberStatePublisher: Publisher {
    public typealias Output = NumberState
    public typealias Failure = Never
    
    private let stateFlow: SwiftStateFlow<Output>
    
    public init(stateFlow: SwiftStateFlow<Output>) {
        self.stateFlow = stateFlow
    }

    public func receive<S: Subscriber>(subscriber: S) where S.Input == Output, S.Failure == Failure {
        let subscription = NumberStateSubscription(stateFlow: stateFlow, subscriber: subscriber)
        subscriber.receive(subscription: subscription)
    }
}

final class NumberStateSubscription<S: Subscriber>: Subscription where S.Input == NumberState, S.Failure == Never {
    private let stateFlow: SwiftStateFlow<S.Input>
    private var subscriber: S?

    public init(stateFlow: SwiftStateFlow<S.Input>, subscriber: S) {
        self.stateFlow = stateFlow
        self.subscriber = subscriber
      
        stateFlow.observe { newValue in
            _ = subscriber.receive(newValue!)
        }
    }
  
    func cancel() {
        subscriber = nil
        stateFlow.close()
    }

    func request(_ demand: Subscribers.Demand) {}
}

Combineの仕組みは省略します。以下記事がとてもわかりやすいです。
Combineの内部の仕組み - 【Swift】CombineでshareReplayの実装を考えながら内部の仕組みを学ぶ

図に従い、必要なPublisherとSubscriptionを作りました。

肝心なのはfunc startSubscribe()で、受け取った数字をmapで文字列加工して、直接photoNameにバインドしています。とてもシンプル!もちろんmap以外のCombineオペレーターも使用可能です。

Publisherを何かにassignするとCancellableオブジェクトが取れるので、その参照を取っておいてViewModelが解放される際に監視を止めることも可能です。

ちなみに、以下の記事が近いことをやっているのですが、Publisherを作るときなどにとても参考になりました。
Wrapping Kotlin Flow with Swift Combine Publisher in a Kotlin Multiplatform project

さいごに

これまでZennやQiitaに公開してきた記事の総集編のようなものをACCESSテックブック2という本にまとめ、技術書典12で公開しようと思っています。ぜひチェックお願いします!

Discussion