Comopose のコンポーネントで Tart を利用する

**Tart** という Kotlin Multiplatform 向けの Flux ライクな状態管理フレームワークを作っています。（いちおう現時点で `rc-02` として [Maven Central](https://central.sonatype.com/) にも publish してします。）

https://github.com/yumemi-inc/Tart

これは一応、ネィティブアプリ（Android /iOS）の「画面」の状態管理用に作っていたのですが、ふと、別に画面全体でなくても、Compose のコンポーネント単位で利用してもよいな:thinking: と思いついたので試してみました。

今回は、次のような簡単なカウンターのコンポーネントを作ってみます。

![スクリーンショット 2024-12-13 17.07.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35733/f39758a6-53cd-d13f-487f-8b23e7225894.png)

---

まず、カウンター用の *State*、*Acton*、*Event* を用意します。

```kotlin
data class CounterState(val count: Int) : State

sealed interface CounterAction : Action {
    data object Increment : CounterAction
    data object Decrement : CounterAction
}

sealed interface CounterEvent : Event // empty
```

次に、*Store* を用意します。

```kotlin
class CounterStore(
    coroutineContext: CoroutineContext,
) : Store.Base<CounterState, CounterAction, CounterEvent>(
    initialState = CounterState(count = 0),
    coroutineContext = coroutineContext,
) {
    override suspend fun onDispatch(state: CounterState, action: CounterAction): CounterState = when (action) {
        is CounterAction.Increment -> {
            state.copy(count = state.count + 1)
        }

        is CounterAction.Decrement -> {
            if (0 < state.count) {
                state.copy(count = state.count - 1)
            } else {
                state // do not change State
            }
        }
    }
}
```

これでロジック的なものは定義できたので、最後に目的である、Compose のコンポーネントを作ります。

```kotlin
@Composable
fun Counter() {
    val scope = rememberCoroutineScope()
    val store = remember {
        CounterStore(
            coroutineContext = scope.coroutineContext,
        )
    }
    val viewStore = rememberViewStore(store)

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text(
            text = viewStore.state.count.toString(),
        )
        Row {
            Button(
                onClick = { viewStore.dispatch(CounterAction.Increment) },
            ) {
                Text(text = "Increment")
            }
            Spacer(
                modifier = Modifier.width(8.dp),
            )
            Button(
                onClick = { viewStore.dispatch(CounterAction.Decrement) },
            ) {
                Text(text = "Decrement")
            }
        }
    }
}
```

アプリを起動すると、きちんと動作していることが分かります :thumbsup: 

 <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35733/f15a5d30-6ec0-f4d2-355c-11f7da734c1c.gif" width=30%>
 
## Repository と連携してみる

さらに、このコンポーネントに Repositoy を連携して、データの取得や保存ができるのではないか？と思ったので、やってみます。

コンポーネントで直接、Repository を取り扱うのは微妙な気がするので、*Store* の factory クラスを用意して、ここに Repository を持たせます。ここでは省略しますが、この factory のインスタンスは、Dagger 等で Activity に inject します。

```kotlin
class CounterStoreFactory(
    private val counterRepository: CounterRepository,
) {
    fun create(coroutineContext: CoroutineContext): CounterStore {
        return CounterStore(
            counterRepository = counterRepository,
            coroutineContext = coroutineContext,
        )
    }
}
```

*Store* を、Repository を通してデータ取得、保存するように書き換えます。

```kotlin
class CounterStore(
    private val counterRepository: CounterRepository,
    coroutineContext: CoroutineContext,
) : Store.Base<CounterState, CounterAction, CounterEvent>(
    initialState = CounterState(count = -1), // 初期値はいったん -1 にしておく
    coroutineContext = coroutineContext,
) {
    override suspend fun onEnter(state: CounterState): CounterState {
        val count = counterRepository.get() // データをロード
        return state.copy(count = count)
    }

    override suspend fun onDispatch(state: CounterState, action: CounterAction): CounterState = when (action) {
        is CounterAction.Increment -> {
            val count = state.count + 1
            state.copy(count = count).apply {
                counterRepository.set(count) // データをセーブ
            }
        }

        is CounterAction.Decrement -> {
            if (0 < state.count) {
                val count = state.count - 1
                state.copy(count = count).apply {
                    counterRepository.set(count) // データをセーブ
                }
            } else {
                state // do not change State
            }
        }
    }
}
```

コンポーネントは、factory を受け取って、それを *Store* の作成に利用するようにします。

```kotlin
@Composable
fun Counter(counterStoreFactory: CounterStoreFactory) {
    val scope = rememberCoroutineScope()
    val store = remember {
        counterStoreFactory.create(scope.coroutineContext)
    }
    val viewStore = rememberViewStore(store)

    // ... あとは同じ
}
```

これでデータの永続化ができました :thumbsup: 

## おわりに

最初に意図したライブラリの使い方ではないですが、Compose のコンポーネントと合わせて、本ライブラリの *Store*、*State*、*Action*、*Event* のクラス群、あと Repository や UseCase を連携する場合は *Store* の factory クラス、を一式で用意しておけば、再利用可能なコンポーネントが作りやすいのかなあと思ったりしました。

## [追記] 検討項目

- 画面回転時や Activity 破棄時の State の保存
  - rememberSaveable を使う？
- factoriy クラスそコンポーネントに引き回すの面倒問題
  - CompositionLocal を使う？
- コンポーネントの preview への対応
    ```kotlin
    @Composable
    fun Counter(counterStoreFactory: CounterStoreFactory) {
        val scope = rememberCoroutineScope()
        val store = remember {
            counterStoreFactory.create(scope.coroutineContext)
        }
        val viewStore = rememberViewStore(store)
    
        SubComponent(viewStore) // こっちに内容を移してpreviewの対象とする？
    }
    ```