Kotlin Coroutines FlowをSwiftでobserveしてみた
はじめに
ACCESS Advent Calendar 2021 12/15の担当はスマホアプリ開発のtonionagauzziです。Qiitaの企画ですが、Zennで書いた人が何人かいたので私も倣ってみます。
本記事の内容は、UI以外をKotlin Multiplatform Mobileで実装し、UIはSwiftで実装した場合、KotlinのFlowをSwiftに伝搬させるにはどうすればよいのか調査した話です。
環境構築
- 必須要件:Android Studio 4.2以上 / Xcode 11.3以上 / macOS Mojave 10.14.4
- 私の環境:Android Studio Arctic Fox 2020.3.1 / Xcode 13.1 (13A1030d) / macOS Big Sur 11.5.2
せっかくなので、Kotlin Multiplatform Mobile(以下、KMM)アプリを1から作る手順を載せます。
- Android Studioで、Android Studio→Preferences→Pluginsで、Kotlin Multiplatform Mobileをインストール
- Android Studioで、File→New→New Project、KMM Applicationを選択
- パッケージ名など入れる
- Add sample tests for Shared moduleにチェック入れ(本記事ではテストコード書かないですが今後のため)、iOS framework distributionはRegular frameworkとする
- フォルダ階層をAndroidからProjectに変える
- iOSのデバッグ設定をEdit Configurationsから行うExecution Targetを好みのSimulatorに指定Simulatorが出てこない場合はXcode→Window→Devices and Simulators→Simulatorsタブの+ボタンで追加
- これで準備完了。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以上に設定 - Gradleにcommonで使う依存関係を追加します。
- 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標準のフォトアプリで写真を見せてると、手を伸ばして触れていろんな操作をしてしまいます。
たとえば削除してゴミ箱を空にしたりとか、共有が開いてメールが開いて画像を誰かに送信なんてことがあると困るんですね。タッチパネルが敏感すぎて予期せぬことが意外と起きます…。
だからアルバムアプリの仕様は次のようにします。
- 15秒ごとに異なる画像をランダムに表示する
- ユーザー入力は一切受け付けない
- Homeボタンを押さないとアプリを抜けられない
設計はこうします。Repositoryより先はありません。
さて、ここまでを細かく書いてたら時間が足りなくなってきたのと、サーバーの実装が絡んだら記事が長くなって本質がどこかわからなくなるので、思いっきりシンプルなプログラム仕様にします。
- InteractorとRepositoryは、15秒ごとに[0-4]の数字をランダムでStateに反映
- ViewModelは、StateをSubscribeし、その数字に応じた画像名をViewにPublish
- Viewは、@Published変数をSubscribeし、その画像をアセットから見つけて表示
その通りコードを書いていきます。Kotlinは全てsharedのcommonMain階層下です。
まずは、本記事のメインテーマとなるStateFlowをSwiftで使うためのSwiftStateFlow
を、こちらを参考に実装します。
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を実装します。
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)
)
}
}
}
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
}
}
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を作ります。
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
が配列で画像名を持っていますが、実際の画像はBabyImage1
〜BabyImage5
をassetに登録済みです。
最後に、ContentViewをデフォルトから変更します。これが設計図のViewにあたります。
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の強みである様々なオペレーター(map
、filter
、onEach
、reduce
など)を使いたいのです。
とはいえFlowと同じオペレーターをSwiftで1から実装するのは大変だし、shared.hに定義されてるKotlinx_coroutines_core系の型を使ってFlowの扉をこじ開けるのもちょっと根気が必要そうです。
そもそも、無理してFlowに合わせるより、SwiftならSwiftらしい実装をしたいですよね。
そこで、Swift標準の非同期フレームワークであるCombineの出番です!
SwiftStateFlowとAlbumViewModelを以下のように書き直します。
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を外部公開しました。
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