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

# はじめに

先日、Kotlin Multiplatform における iOS 向け API を Swift から使いやすくするツール [SKIE](https://skie.touchlab.co/) の[機能](https://skie.touchlab.co/features/) のうち、 [Sealed Classes](https://skie.touchlab.co/features/sealed) を紹介しました。導入方法もこちらで解説しています。

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

今回は [Suspend Functions](https://skie.touchlab.co/features/suspend) についての紹介と [The Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) (TCA) を用いた動作確認を行います。

ちなみに SKIE の発音は空の「スカイ」です。

https://github.com/touchlab/SKIE/issues/13

# 公式ドキュメント

公式ドキュメントの [Suspend Functions](https://skie.touchlab.co/features/suspend) には、このように書かれています。

> The Kotlin compiler outputs suspend functions and you can call them from Swift with the async/await syntax.

> Kotlin コンパイラは suspend 関数を出力し、Swift から async/await 構文で呼ぶことができる。

> However, these functions are not cancellable and by default they need to be called from the main thread, or they can crash the runtime.

> しかしながら、これらの関数はキャンセルできない。またメインスレッドから呼ぶ必要があり、そうでないと実行時にクラッシュする。

> SKIE exposes suspend functions that cooperate with both Swift and Kotlin's concurrency contexts. That means automatic two-way cancellation, and no thread restrictions.

> SKIE は Swift と Kotlin で協調する concurrency contexts を公開している。それは自動での双方向のキャンセルとスレッドの制限がないことを意味している。

SKIE を導入することで、キャンセルとメインスレッド以外からの呼び出しができるようです。この記事ではキャンセルとメインスレッド以外からの呼び出しが実際にできるか TCA を使って動作確認を行います。

# 動作確認用の Kotlin の suspend 関数を作成

今回の動作確認用に Kotlin 側にシンプルな suspend 関数を作成しました。

```Example.kt
suspend fun exampleFunction() {
    // 3秒待って終了
    delay(3000)
}
```

# 動作確認用の TCA の Reducer を作成

今回の動作確認用に、ありものの SwiftUI の状態管理クラスである TCA の Reducer のクラスを作成しました。書き方は [TCA の GitHub トップページ](https://github.com/pointfreeco/swift-composable-architecture#basic-usage)や[サンプルの Search](https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/Search/Search/SearchView.swift) を参考にしています。

```SuspendReducer.swift
struct SuspendReducer: Reducer {
    // キャンセル ID を定義
    private enum CancelID { case example }

    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        // 呼ばれた action に対して現在の state を次の state へ更新したり、次に呼ぶ action を返却する。
        switch action {
        case .callFunction:
            state.status = .running
            // この処理のキャンセルは CancelID.example を指定することで行えるようにした
            return .run { send in
                do {
                    // Kotlin の suspend 関数呼び出し
                    try await ExampleKt.exampleFunction()
                    // 処理の完了アクションを返却
                    await send(.completeFunction)
                } catch {
                    // 処理がキャンセルされると CancellationError 例外が発生する
                    if error is CancellationError {
                        // キャンセルされたことが分かるように標準出力する
                        print("CancellationError")
                    }
                }
            }.cancellable(id: CancelID.example)
        case .cancelFunction:
            // 処理のキャンセルが要求された
            state.status = .canceled
            // 処理をキャンセルする
            return .cancel(id: CancelID.example)
        case .completeFunction:
            // 処理の完了
            state.status = .completed
            return .none
        }
    }

    enum Status: Equatable {
        case initial // 初期状態
        case running // suspend 関数動作中
        case canceled // suspend 関数がキャンセルされた
        case completed // suspend 関数が完了した
    }

    // 画面の状態
    struct State: Equatable {
        var status = Status.initial
    }

    // 画面の操作や非同期処理の結果
    enum Action: Equatable {
        case callFunction // suspend 関数を呼び出す
        case cancelFunction // suspend 関数をキャンセルする
        case completeFunction // suspend 関数が完了した
    }
}
```

# Reducer に対応する SwiftUI を作成

Reducer に対応する SwiftUI をこのように作成しました。

```SuspendView.swift
struct SuspendView: View {
    let store: StoreOf<SuspendReducer>

    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            VStack {
                HStack {
                    Button(
                        action: {
                            viewStore.send(.callFunction)
                        }
                    ) {
                        Text("関数呼び出し")
                    }.padding(16)
                    Button(
                        action: {
                            viewStore.send(.cancelFunction)
                        }
                    ) {
                        Text("関数キャンセル")
                    }.padding(16)
                }
                switch viewStore.status {
                case .initial:
                    EmptyView()
                case .running:
                    Text("suspend 関数動作中")
                case .completed:
                    Text("suspend 関数完了")
                case .canceled:
                    Text("suspend 関数はキャンセルされた")
                }
                Spacer()
            }.navigationBarTitle("suspend 関数動作確認")
        }
    }
}
```

見た目はこのようになっています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/d2b97448-4f7f-6d41-9d0f-7b47216cc73e.png" width="200">

# SKIE を使わずにビルドした結果

まずは SKIE を使わずに Kotlin Multiplatform プロジェクトをビルドします。すでに導入済みの場合は、[Gradle Configuration](https://skie.touchlab.co/Configuration#gradle-configuration) を変更して `SuspendInterop` を無効にします。

```build.gradle
skie {
    features {
        group {
            co.touchlab.skie.configuration.SuspendInterop.Enabled(false)
        }
    }
}
```

アプリの「関数呼び出し」ボタンを押すと、 `.callFunction` アクションが実行されますが、 `try await ExampleKt.exampleFunction()` で

```
Calling Kotlin suspend functions from Swift/Objective-C is currently supported only on main thread
```

エラーが発生してクラッシュします。[SKIE の Suspend Functions の解説](https://skie.touchlab.co/features/suspend)の通り Kotlin の suspend 関数はメインスレッドからしか呼べないようです。

動作しているスレッドを `print` 関数で確認してみます。

```SuspendReactor.swift
        switch action {
        case .callFunction:
            state.status = .running
            print(".callFunction is on main thread: \(Thread.isMainThread)")
            return .run { send in
                do {
                    print("run is on main thread: \(Thread.isMainThread)")
                    try await ExampleKt.exampleFunction()
                    await send(.completeFunction)
                } catch {
                    if error is CancellationError {
                        print("CancellationError")
                    }
                }
            }.cancellable(id: CancelID.example)
```

```
.callFunction is on main thread: true
run is on main thread: false
```

`.run` に渡したブロックの中はメインスレッドではないようです。このままでは Kotlin の suspend 関数を TCA の Reducer では使えません。

# SKIE を使ってビルドした結果

そこで SKIE の Suspend Functions 機能を有効にしてビルドします。
「関数呼び出し」ボタンを押すと、 `.callFunction` アクションが実行され、  `try await ExampleKt.exampleFunction()` は無事に完了します。

![Simulator Screen Recording - iPhone 14 Pro - 2023-10-02 at 03.12.50.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/3f1d3fa1-83af-1abd-1c93-ac1fa7fca875.gif)

また `try await ExampleKt.exampleFunction()` は途中でキャンセルすることができます。

![Simulator Screen Recording - iPhone 14 Pro - 2023-10-02 at 03.17.09.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/4b0e62ac-2439-cb02-da70-fc3eb9a1fe76.gif)

キャンセルされたときに発生する例外 `CancellationError` も標準出力で確認できました。

```SuspendReducer.kt
            do {
                // Kotlin の suspend 関数呼び出し
                try await ExampleKt.exampleFunction()
                await send(.completeFunction)
            } catch {
                // 処理がキャンセルされると、CancellationError 例外が発生する
                if error is CancellationError {
                    // キャンセルされたことが分かるように標準出力する
                    print("CancellationError")
                }
            }
```

# まとめ

今回は [SKIE](https://skie.touchlab.co/) の [Suspend Functions 機能](https://skie.touchlab.co/features/suspend)について、[TCA](https://github.com/pointfreeco/swift-composable-architecture) に乗せる形で動作を確認しました。Kotlin Multiplatform による共通化の範囲として、[Android のアプリ推奨アーキテクチャ](https://developer.android.com/jetpack/guide)を前提に説明すると

- [状態ホルダー](https://developer.android.com/topic/architecture#ui-layer)以下の層を共通化
- [ドメインレイヤ](https://developer.android.com/jetpack/guide#domain-layer)以下の層を共通化

の2パターンがあると思います。後者の方針を採用し、ドメインレイヤの処理が Kotlin の suspend 関数で、状態ホルダーに TCA を採用した場合、問題なく Kotlin の suspend 関数を呼べてキャンセルもできることが分かりました。