Tart という Kotlin Multiplatform 向けの Flux ライクな状態管理フレームワークを作っています。(いちおう現時点で rc-02
として Maven Central にも publish してします。)
これは一応、ネィティブアプリ(Android /iOS)の「画面」の状態管理用に作っていたのですが、ふと、別に画面全体でなくても、Compose のコンポーネント単位で利用してもよいな と思いついたので試してみました。
今回は、次のような簡単なカウンターのコンポーネントを作ってみます。
まず、カウンター用の State、Acton、Event を用意します。
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 を用意します。
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 のコンポーネントを作ります。
@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")
}
}
}
}
アプリを起動すると、きちんと動作していることが分かります
Repository と連携してみる
さらに、このコンポーネントに Repositoy を連携して、データの取得や保存ができるのではないか?と思ったので、やってみます。
コンポーネントで直接、Repository を取り扱うのは微妙な気がするので、Store の factory クラスを用意して、ここに Repository を持たせます。ここでは省略しますが、この factory のインスタンスは、Dagger 等で Activity に inject します。
class CounterStoreFactory(
private val counterRepository: CounterRepository,
) {
fun create(coroutineContext: CoroutineContext): CounterStore {
return CounterStore(
counterRepository = counterRepository,
coroutineContext = coroutineContext,
)
}
}
Store を、Repository を通してデータ取得、保存するように書き換えます。
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 の作成に利用するようにします。
@Composable
fun Counter(counterStoreFactory: CounterStoreFactory) {
val scope = rememberCoroutineScope()
val store = remember {
counterStoreFactory.create(scope.coroutineContext)
}
val viewStore = rememberViewStore(store)
// ... あとは同じ
}
これでデータの永続化ができました
おわりに
最初に意図したライブラリの使い方ではないですが、Compose のコンポーネントと合わせて、本ライブラリの Store、State、Action、Event のクラス群、あと Repository や UseCase を連携する場合は Store の factory クラス、を一式で用意しておけば、再利用可能なコンポーネントが作りやすいのかなあと思ったりしました。
[追記] 検討項目
- 画面回転時や Activity 破棄時の State の保存
- rememberSaveable を使う?
- factoriy クラスそコンポーネントに引き回すの面倒問題
- CompositionLocal を使う?
- コンポーネントの preview への対応
@Composable fun Counter(counterStoreFactory: CounterStoreFactory) { val scope = rememberCoroutineScope() val store = remember { counterStoreFactory.create(scope.coroutineContext) } val viewStore = rememberViewStore(store) SubComponent(viewStore) // こっちに内容を移してpreviewの対象とする? }