はじめに
Kotlin Multiplatform における iOS 向け API を Swift から使いやすくするツール SKIE (スカイ)を活用しています。これまでいくつかある機能のうち、Sealed Classes と Suspend Functions を紹介してきました。今回は Flows 機能を iOS のありものの状態管理クラスである TCA の Reducer に乗せて動作確認する形で紹介しようと思います。
Sealed Classes 機能の紹介と導入事例
Suspend Functions 機能の紹介
Kotlin 側の実装を作成
値を保持する MutableStateFlow を作成して、Flow としての取得と値への加算ができる Kotlin の関数を作ります。
private val exampleMutableStateFlow = MutableStateFlow(0)
fun getExampleFlow(): Flow<Int> {
return exampleMutableStateFlow
}
fun countUpExampleFlowValue() {
++exampleMutableStateFlow.value
}
SKIE を使わずに TCA から Flow を使う
TCA の Reducer から上記の Kotlin 関数を呼び出します。TCA の書き方は公式サンプルの 02-Effects-LongLiving.swift を参考にしています。この時点では SKIE は導入していません。よって Flow は Kotlinx_coroutines_coreFlowCollector プロトコルに準拠したクラスを作って監視します。
import ComposableArchitecture
import shared
@Reducer
struct FlowExpReducer {
struct State: Equatable {
var count = 0
}
enum Action {
case task
// Count Up ボタンが押されたときに呼ばれる
case countUp
// 表示カウントを更新する
case updateCount(count: Int)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .task:
return .run { send in
class Collector: Kotlinx_coroutines_coreFlowCollector {
let sendTask: (Int) async -> Void
init(sendTask: @escaping (Int) async -> Void) {
self.sendTask = sendTask
}
func emit(value: Any?) async throws {
if let intValue = value as! Int? {
await self.sendTask(intValue)
}
}
}
let collector = Collector(
sendTask: { intValue in
await send(.updateCount(count: intValue))
}
)
try await ExampleKt.getExampleFlow().collect(collector: collector)
}
case .countUp:
ExampleKt.countUpExampleFlowValue()
return .none
case let .updateCount(count: count):
state.count = count
return .none
}
}
}
}
見た目はこのようなものを作りました。「Count Up」ボタンを押すと、Kotlin 側の MutableStateFlow の値が1増えて、その値が表示されます。
import ComposableArchitecture
import SwiftUI
struct FlowExpView: View {
let store: StoreOf<FlowExpReducer>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
VStack {
Text("Count = \(viewStore.count)").padding(8)
Button(
action: {
viewStore.send(.countUp)
}
) {
Text("Count Up")
}.padding(8)
Spacer()
}.navigationBarTitle("Flow 動作確認")
.task {
await viewStore.send(.task).finish()
}
}
}
}
しかしこれはクラッシュします。
理由は
try await ExampleKt.getExampleFlow().collect(collector: collector)
の行が suspend 関数になっているためです。前回の suspend 関数の記事で解説した通り、Kotlin の suspend 関数はメインスレッドからしか呼べず、FlowExpReducer の .run
に渡したブロックの中はメインスレッドで無いためです。
SKIE の Suspend Functions 機能のみ有効化する
SKIE を導入し Flows 機能を無効化することで Suspend Functions 機能のみ有効化します。導入方法はこちらにあり、設定方法はこちらにあります。
plugins {
id("co.touchlab.skie") version "0.6.1"
}
// 略
skie {
features {
group {
FlowInterop.Enabled(false)
}
}
}
ビルドが通るように Reducer の .run
ブロックを少し変更します。ついでに SKIE の Global Functions 機能も有効になっているので、クラス名を無くしました。さらに collect
メソッドが適切にキャンセルされているか確認できるように CancellationError
を Catch したら標準出力するようにしました。
return .run { send in
class Collector: Kotlinx_coroutines_coreFlowCollector {
let sendTask: (Int) async -> Void
init(sendTask: @escaping (Int) async -> Void) {
self.sendTask = sendTask
}
// SKIE でメソッド名が変化している
func __emit(value: Any?) async throws {
if let intValue = value as! Int? {
await self.sendTask(intValue)
}
}
}
let collector = Collector(
sendTask: { intValue in
await send(.updateCount(count: intValue))
}
)
do {
// Global Functions 機能でクラス名の指定不要
try await getExampleFlow().collect(collector: collector)
} catch {
// 処理がキャンセルされると、CancellationError 例外が発生する
if error is CancellationError {
// キャンセルされたことが分かるように標準出力する
print("CancellationError")
}
}
}
これは動作します。
画面を閉じると CancellationError
例外が発生するため、再度画面を開いても Flow は多重に監視されません。
SKIE の Flows 機能を使う
SKIE の Suspend Functions 機能だけでも動作はしますが Kotlinx_coroutines_coreFlowCollector
プロトコルに準拠するクラスを作ったり、Any?
型をキャストしたりと活用が面倒です。ここで SKIE の Flows 機能を有効にするとどのように書けるかを見てみます。
公式の Example に従ってこのように書けます。
return .run { send in
for await value in getExampleFlow() {
await send(.updateCount(count: Int(truncating: value)))
}
}
value
が KotlinInt
型だったので Xcode のエラーおよび警告の Fix ボタンからの提案に従い Int(truncating: value)
で Int 型に変換しました。もちろん画面を閉じると for ループを抜けることができます。
SKIE によって自動生成された getExampleFlow
関数の実装はこのようになっていました。
public func getExampleFlow() -> shared.SkieSwiftFlow<shared.KotlinInt> {
return shared.ExampleKt.getExampleFlow()
}
SKIE 公式の解説にあるように SkieSwiftFlow
が Swift の AsyncSequence プロトコルに準拠しているのでこのように書けます。
まとめ
モバイルアプリ開発には、他の画面で行われた更新を抜けなく反映する所謂「いいね問題」があります。そのひとつの解決方法は信頼できる唯一の情報源の値が流れてくる Flow を取得、監視することで、リアルタイムに UI に反映する作りにすることです。
しかし状態ホルダーが Swift でドメインレイヤーが Kotlin で書かれているケースの場合、Kotlin Multiplatform だけでは Flow の活用は難解です。そこで SKIE の Flows 機能を使うことで、Kotlin の Flow は Swift の AsyncSequence プロトコルに準拠したクラスに変換されるため、Swift から容易に活用できます。そのことを今回は TCA を使って確認することができました。