Kotlin Multiplatform の SKIE について Flows 機能の動作を TCA を使って確認しました

# はじめに

Kotlin Multiplatform における iOS 向け API を Swift から使いやすくするツール [SKIE](https://skie.touchlab.co/) (スカイ)を活用しています。これまで[いくつかある機能](https://skie.touchlab.co/features/)のうち、[Sealed Classes](https://skie.touchlab.co/features/sealed) と [Suspend Functions](https://skie.touchlab.co/features/suspend) を紹介してきました。今回は [Flows](https://skie.touchlab.co/features/flows) 機能を iOS のありものの状態管理クラスである [TCA](https://github.com/pointfreeco/swift-composable-architecture)  の Reducer に乗せて動作確認する形で紹介しようと思います。

## Sealed Classes 機能の紹介と導入事例

https://qiita.com/tfandkusu/items/833b16c2ef4a03307c77

https://blog.kyash.co/entry/2023/12/01/123716

## Suspend Functions 機能の紹介

https://qiita.com/tfandkusu/items/e65eacc80e066c0e86c4

# Kotlin 側の実装を作成

値を保持する [MutableStateFlow](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow#stateflow) を作成して、[Flow](https://developer.android.com/kotlin/flow) としての取得と値への加算ができる Kotlin の関数を作ります。

```Example.kt
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](https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift) を参考にしています。この時点では SKIE は導入していません。よって Flow は Kotlinx_coroutines_coreFlowCollector プロトコルに準拠したクラスを作って監視します。

```FlowExpReducer.swift
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増えて、その値が表示されます。

![スクリーンショット 2023-12-30 17.35.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/8ff803b9-fb15-52d4-a118-7340ec0384a4.png)


```FlowExpView.swift
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()
                }
        }
    }
}
```

しかしこれはクラッシュします。
理由は

```swift
try await ExampleKt.getExampleFlow().collect(collector: collector)
```

の行が suspend 関数になっているためです。[前回の suspend 関数の記事](https://qiita.com/tfandkusu/items/e65eacc80e066c0e86c4#skie-%E3%82%92%E4%BD%BF%E3%82%8F%E3%81%9A%E3%81%AB%E3%83%93%E3%83%AB%E3%83%89%E3%81%97%E3%81%9F%E7%B5%90%E6%9E%9C)で解説した通り、Kotlin の suspend 関数はメインスレッドからしか呼べず、FlowExpReducer の `.run` に渡したブロックの中はメインスレッドで無いためです。

# SKIE の Suspend Functions 機能のみ有効化する

SKIE を導入し Flows 機能を無効化することで Suspend Functions 機能のみ有効化します。導入方法は[こちら](https://skie.touchlab.co/Installation)にあり、設定方法は[こちら](https://skie.touchlab.co/configuration/)にあります。

```build.gradle
plugins {
    id("co.touchlab.skie") version "0.6.1"
}

// 略

skie {
    features {
        group {
            FlowInterop.Enabled(false)
        }
    }
}

```

ビルドが通るように Reducer の `.run` ブロックを少し変更します。ついでに SKIE の [Global Functions](https://skie.touchlab.co/features/global-functions) 機能も有効になっているので、クラス名を無くしました。さらに `collect` メソッドが適切にキャンセルされているか確認できるように `CancellationError` を Catch したら標準出力するようにしました。

```FlowExpReducer.swift
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")
        }
    }
}
```

これは動作します。

![Simulator Screen Recording - iPhone 15 Pro - 2023-12-30 at 20.59.36.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/c78dad62-1716-a101-bcf3-59026929aa39.gif)

画面を閉じると `CancellationError` 例外が発生するため、再度画面を開いても Flow は多重に監視されません。

# SKIE の Flows 機能を使う

SKIE の Suspend Functions 機能だけでも動作はしますが `Kotlinx_coroutines_coreFlowCollector` プロトコルに準拠するクラスを作ったり、`Any?` 型をキャストしたりと活用が面倒です。ここで SKIE の Flows 機能を有効にするとどのように書けるかを見てみます。

[公式の Example](https://skie.touchlab.co/features/flows#example) に従ってこのように書けます。

```FlowExpReducer.swift
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` 関数の実装はこのようになっていました。

```swift
public func getExampleFlow() -> shared.SkieSwiftFlow<shared.KotlinInt> {
    return shared.ExampleKt.getExampleFlow()
}
```

[SKIE 公式の解説](https://skie.touchlab.co/features/flows)にあるように `SkieSwiftFlow` が Swift の [AsyncSequence](https://developer.apple.com/documentation/swift/asyncsequence) プロトコルに準拠しているのでこのように書けます。

# まとめ

モバイルアプリ開発には、他の画面で行われた更新を抜けなく反映する所謂「いいね問題」があります。そのひとつの解決方法は[信頼できる唯一の情報源](https://developer.android.com/topic/architecture#single-source-of-truth)の値が流れてくる Flow を取得、監視することで、リアルタイムに UI に反映する作りにすることです。

しかし[状態ホルダー](https://developer.android.com/topic/architecture#ui-layer)が Swift で[ドメインレイヤー](https://developer.android.com/jetpack/guide#domain-layer)が Kotlin で書かれているケースの場合、Kotlin Multiplatform だけでは Flow の活用は難解です。そこで SKIE の Flows 機能を使うことで、Kotlin の Flow は Swift の AsyncSequence プロトコルに準拠したクラスに変換されるため、Swift から容易に活用できます。そのことを今回は TCA を使って確認することができました。