はじめに
Swift+TCAで作成したアプリをKotlinに移植する機会がありました。TCAの使い心地がよく、取り入れてみたいと思ったのでChatGPTに壁打ちしながら作成しました。以前書いた記事よりも進歩があったのでもう一度まとめてみます。
JitPackで公開しているので使ってみたい方はぜひ!KSPを利用しているのでご注意くさだい。
Gitはこちら
使用例
簡単なサンプルをお見せして、本家(Swift)と見比べていきます。一部にコード生成(KSP)
構成
Home画面+2つのカウンター画面にしました。この構成ができれば個人開発規模のアプリであれば対応できるでしょう
Home
全体は以下のようになります
object Home : ReducerOf<Home.State, Home.Action> {
sealed class Destination {
@ChildFeature(Counter::class)
object Counter1 : Destination()
@ChildFeature(Counter::class)
object Counter2 : Destination()
}
data class State(
val title: String = "Home",
@ChildState val destination: DestinationState? = null,
)
sealed class Action {
data class SetTitle(val title: String) : Action()
class CounterButton1Tapped() : Action()
class CounterButton2Tapped() : Action()
@ChildAction class Destination(val action: DestinationAction) : Action()
}
override fun body(): ReducerOf<State, Action> =
LetScope(
statePath = destinationKey,
actionPath = destinationCase,
reducer = DestinationReducer
) +
Reduce<State, Action>{ state, action ->
when (action) {
is Action.SetTitle -> state.copy(title = action.title) to Effect.none()
is Action.CounterButton1Tapped->{
destinationKey.set(state, DestinationState.Counter1(state = Counter.State(count = 0))) to Effect.none()
}
is Action.CounterButton2Tapped->{
destinationKey.set(state, DestinationState.Counter2(state = Counter.State(count = 10))) to Effect.none()
}
is Action.Destination -> {
when {
(destinationCase + Destination.Counter1.case).extract(action) is Counter.Action.DismissTapped -> {
destinationKey.set(state, null) to Effect.none()
}
(destinationCase + Destination.Counter2.case).extract(action) is Counter.Action.DismissTapped -> {
destinationKey.set(state, null) to Effect.none()
}
else -> state to Effect.none()
}
}
}
}
}
Feature
Feature
(ここではHome
)はReducerOf
に準拠させます。本家ではプロトコルに準拠させない代わりに@Reducer
をつけています。
State
data class
で定義します。これはSwiftのstruct
に合わせています。
子(カウンター)の状態には@ChildState
をつけます。これによってSwiftのKeyPath
に相当するクロージャーを生成します。
Action
sealed class
で定義します。引数が欲しいActionはdata class
、いらないActionはclass
もしくはobject
にします。
こちらも子のアクションには@ChildAction
をつけます。これによりCasePath
(TCA作成者が作ったKeyPath
のenum版)が作られます。引数はかならずaction
としてください。
Destination
複数の画面に遷移がある場合はDestinationをもちいて、N者択一になるような遷移を組みます。sealed class
で定義して遷移先の画面を並べます。@ChildFeature(Feature::class)
の形式でアノテーションをつけることで遷移先のFeature
を紐づけます。これを元にDestinationState
やDestinationAction
を作成します。sealed class
の仕様などが絡み、本家とは少し異なる実装となりました。
body
Reduce
は基本的に変わりません。
Destinationは一見、when文を繰り返すことでネストが深くなってしまいがちですが、先ほど生成したCasePath
を用いることで回避できます。とはいえSwiftよりも型推論が弱いため名前が長くなりがちであり、これを使いこなすのは難しいと思います。改善するならばここといった感じですね。
子への処理はLetScope
で流します。本家は.ifLet
でしたが、Kotlinのlet構文にあやかってこの名前にしました。子の状態は親よりも先に更新したいと思うのでReduceの前に持っていき、生成されたパスとReducerを指定してあげます。
子が1つの時は下記のように簡略化できます。
data class State(
@ChildState val counter: Counter.State? = null,
)
sealed class Action {
class CounterButtonTapped() : Action()
@ChildAction class Counter(val action: Counter.Action) : Action()
}
override fun body(): ReducerOf<State, Action> =
LetScope(
statePath = counterKey,
actionPath = counterCase,
reducer = Counter
) +
//...
View
Viewの全体像です
@Composable
fun HomeScreen(
store: StoreOf<Home.State, Home.Action>
) {
val state by store.state.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
) {
Text(text = state.title)
Spacer(modifier = Modifier.height(16.dp))
Row {
Button(onClick = { store.send(Home.Action.CounterButton1Tapped()) }) {
Text("Counter 1")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = { store.send(Home.Action.CounterButton2Tapped()) }) {
Text("Counter 2")
}
}
}
FullScreenNavigation(
item = store.optionalScope(
statePath = Home.destinationKey + Home.Destination.Counter1.key,
actionPath = Home.destinationCase + Home.Destination.Counter1.case
)
) {
CounterScreen(it)
}
SheetNavigation(
item = store.optionalScope(
statePath = Home.destinationKey + Home.Destination.Counter2.key,
actionPath = Home.destinationCase + Home.Destination.Counter2.case
)
) {
CounterScreen(it)
}
}
Store
を外から注入し、collectAsState
を使って購読します
画面遷移のためにFullScreenNavigation
かSheetNavigation
を用意しました。optionalScope
で子Storeが作成され、nonnullになるとCounterScreen(it)
が表示されるといった具合です。これはかなりほんけに近づけたと思います。
Counter
object Counter : ReducerOf<Counter.State, Counter.Action> {
data class State(val count: Int = 0)
sealed class Action {
object IncrementTapped : Action()
object DecrementTapped : Action()
object TimerTapped : Action()
object DismissTapped : Action()
object CancelTapped : Action()
}
override fun body(): ReducerOf<State, Action> =
Reduce { state, action ->
when (action) {
is Action.IncrementTapped -> state.copy(count = state.count + 1) to Effect.none()
is Action.DecrementTapped -> state.copy(count = state.count - 1) to Effect.none()
is Action.DismissTapped -> state to Effect.none()
is Action.TimerTapped -> state to Effect.run(id = "Timer") { send ->
while (true) {
delay(1000L)
send(Action.IncrementTapped)
}
}
is Action.CancelTapped -> state to Effect.cancel("Timer")
}
}
}
非同期処理のために、Effect
にrun
とcancel
が追加されています。こちらも本家同様といった感じです。
CounterのView側は特筆することがないのでGitを見てください。
おわりに
最大限近づけることはできたと思うので興味が持てた人、TCAの経験があってKotlinを始める人などはぜひ使ってみてください。
ライブラリのマネジメント経験がほとんどないので不安定かとは思いますが、よりよいアーキテクチャを作っていきたいと思います。