1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

KotlinでTCAを再現してみた

Posted at

はじめに

Swift+TCAで作成したアプリをKotlinに移植する機会がありました。TCAの使い心地がよく、取り入れてみたいと思ったのでChatGPTに壁打ちしながら作成しました。以前書いた記事よりも進歩があったのでもう一度まとめてみます。

JitPackで公開しているので使ってみたい方はぜひ!KSPを利用しているのでご注意くさだい。

Gitはこちら

使用例

簡単なサンプルをお見せして、本家(Swift)と見比べていきます。一部にコード生成(KSP)

構成

Home画面+2つのカウンター画面にしました。この構成ができれば個人開発規模のアプリであれば対応できるでしょう

Home

全体は以下のようになります

Home.kt
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を紐づけます。これを元にDestinationStateDestinationActionを作成します。sealed classの仕様などが絡み、本家とは少し異なる実装となりました。

body

Reduceは基本的に変わりません。
Destinationは一見、when文を繰り返すことでネストが深くなってしまいがちですが、先ほど生成したCasePathを用いることで回避できます。とはいえSwiftよりも型推論が弱いため名前が長くなりがちであり、これを使いこなすのは難しいと思います。改善するならばここといった感じですね。

子への処理はLetScopeで流します。本家は.ifLetでしたが、Kotlinのlet構文にあやかってこの名前にしました。子の状態は親よりも先に更新したいと思うのでReduceの前に持っていき、生成されたパスとReducerを指定してあげます。


子が1つの時は下記のように簡略化できます。

Home.kt
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の全体像です

HomeScreen.kt
@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を使って購読します
画面遷移のためにFullScreenNavigationSheetNavigationを用意しました。optionalScopeで子Storeが作成され、nonnullになるとCounterScreen(it)が表示されるといった具合です。これはかなりほんけに近づけたと思います。

Counter

Counter.kt
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")
            }
        }
}

非同期処理のために、Effectruncancelが追加されています。こちらも本家同様といった感じです。
CounterのView側は特筆することがないのでGitを見てください。

おわりに

最大限近づけることはできたと思うので興味が持てた人、TCAの経験があってKotlinを始める人などはぜひ使ってみてください。
ライブラリのマネジメント経験がほとんどないので不安定かとは思いますが、よりよいアーキテクチャを作っていきたいと思います。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?