0
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ライクな実装

Last updated at Posted at 2025-06-07

はじめに

Swift+TCAで作成したアプリをKotlinに移植するため、GPTに作ってもらいました。基本的なところはできたつもりですが、まだ抜けがあるかもしれません。また私はKotlin初心者なので悪しからず。サンプルはこちらです。

使用例

ホーム画面(親)とカウンター画面(子)の構成の基本的なアプリ構成にしました。UIにはJetpackを使ってます。

MainActivity

MainActivity.kt
package com.example.kotlin_tca

import HomeView
import com.example.kotlin_tca.core.tca.*
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import com.example.kotlin_tca.core.theme.AppTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val store = Store<HomeFeature.State, HomeFeature.Action>(
            initialState = HomeFeature.State(),
            reducer = HomeFeature,
        )
        setContent {
            AppTheme {
                Surface(
                    color = MaterialTheme.colorScheme.background
                ) {
                    HomeView(store = store)
                }
            }
        }
    }
}

ホーム画面

Home.kt
import com.example.kotlin_tca.core.tca.*
import com.example.kotlin_tca.counter.CounterFeature
import com.example.kotlin_tca.counter.CounterView
import androidx.compose.runtime.*
import androidx.compose.foundation.layout.*
import androidx.compose.ui.*
import androidx.compose.material3.*

object HomeFeature : ReducerOf<HomeFeature.State, HomeFeature.Action> {
    data class State(
        val title: String = "Home",
        val counter: CounterFeature.State? = null
    )

    sealed class Action {
        data class SetTitle(val title: String) : Action()
        class CounterButtonTapped() : Action()
        data class Counter(val action: CounterFeature.Action) : Action()
    }
    //WritableKeyPathの代わり
    val counterLens = Lens<HomeFeature.State, CounterFeature.State?>(
        get = { it.counter },
        set = { parent, child -> parent.copy(counter = child) }
    )
    val counterPrism = Prism<HomeFeature.Action, CounterFeature.Action>(
        extract = { (it as? Action.Counter)?.action },
        embed = { Action.Counter(it) }
    )

    override fun body(): ReducerOf<State, Action> =
        Reduce<State, Action>{ state, action ->
            when (action) {
                is Action.SetTitle -> state.copy(title = action.title) to Effect.none()
                is Action.CounterButtonTapped->{
                    state.copy(counter = CounterFeature.State(1)) to Effect.none()
                }
                is Action.Counter -> {
                    when (val innerAction = action.action) {
                        is CounterFeature.Action.DismissTapped -> {
                            state.copy(counter = null) to Effect.none()
                        }
                        else -> state to Effect.none()
                    }
                }
            }
        } +
        OptionalScope(
            stateLens = counterLens,
            actionPrism = counterPrism,
            reducer = CounterFeature
        )
}
@Composable
fun HomeView(store: StoreOf<HomeFeature.State, HomeFeature.Action>) {
    val state by store.state.collectAsState()
    
    Navigation(
        store = store,
        lens = HomeFeature.counterLens,
        prism = HomeFeature.counterPrism,
        child = { store ->
            CounterView(store)
        }
    ) {
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(state.title)
            Button(onClick = {
                store.send(HomeFeature.Action.CounterButtonTapped())
            }) {
                Text("Go to Counter")
            }
            Button(onClick = {
                store.send(HomeFeature.Action.SetTitle(state.title))
            }) {
                Text("Title ")
            }
        }
    }
}

WritableKeyPath(\.counter)が実装できなかったのでsetter,getterのペアを自作する必要があります。Navigationでtree-based-navigation(状態のnull/nonnullで遷移)を実現してます。遷移のアニメーションとかはなくただ表示を切り替えてるだけなので今後要改善。

Counter

Counter.kt
package com.example.kotlin_tca.counter

import com.example.kotlin_tca.core.tca.*
import kotlinx.coroutines.delay
import androidx.compose.runtime.*
import androidx.compose.foundation.layout.*
import androidx.compose.ui.*
import androidx.compose.material3.*

object CounterFeature : ReducerOf<CounterFeature.State, CounterFeature.Action> {

    data class State(val count: Int = 0)

    sealed class Action {
        object IncrementTapped : Action()
        object DecrementTapped : Action()
        object DismissTapped : 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()
        }
    }
}

@Composable
fun CounterView(store: StoreOf<CounterFeature.State, CounterFeature.Action>) {
    val state by store.state.collectAsState()

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Count: ${state.count}")
        Row {
            Button(onClick = { store.send(CounterFeature.Action.DecrementTapped) }) {
                Text("-")
            }
            Button(onClick = { store.send(CounterFeature.Action.IncrementTapped) }) {
                Text("+")
            }
        }
        Button(onClick = { store.send(CounterFeature.Action.DismissTapped) }) {
            Text("Dismiss")
        }
    }
}

ライブラリ

正式なライブラリにはしてないのでコピペして使ってください。

TCA.kt
interface ReducerOf<State, Action> {
    fun body(): ReducerOf<State, Action>

    fun reduce(state: State, action: Action): Pair<State, Effect<Action>> {
        return body().reduce(state, action)
    }
}


fun <S, A> Reduce(block: (S, A) -> Pair<S, Effect<A>>): ReducerOf<S, A> {
    return object: ReducerOf<S, A> {
        override fun body() = this
        override fun reduce(state: S, action: A) = block(state, action)
    }
}

fun <ParentState, ParentAction, ChildState, ChildAction> Scope(
    stateLens: Lens<ParentState, ChildState>,
    actionPrism: Prism<ParentAction, ChildAction>,
    reducer: ReducerOf<ChildState, ChildAction>
): ReducerOf<ParentState, ParentAction> {
    return object : ReducerOf<ParentState, ParentAction> {
        override fun body() = this

        override fun reduce(parentState: ParentState, parentAction: ParentAction): Pair<ParentState, Effect<ParentAction>> {
            val childAction = actionPrism.extract(parentAction) ?: return parentState to Effect.none()
            val childState = stateLens.get(parentState)

            val (newChildState, childEffect) = reducer.reduce(childState, childAction)
            val newParentState = stateLens.set(parentState, newChildState)
            val newParentEffect = childEffect.map { actionPrism.embed(it) }

            return newParentState to newParentEffect
        }
    }
}

fun <ParentState, ParentAction, ChildState, ChildAction> OptionalScope(
    stateLens: Lens<ParentState, ChildState?>,
    actionPrism: Prism<ParentAction, ChildAction>,
    reducer: ReducerOf<ChildState, ChildAction>
): ReducerOf<ParentState, ParentAction> {
    return object : ReducerOf<ParentState, ParentAction> {
        override fun body() = this

        override fun reduce(parentState: ParentState, parentAction: ParentAction): Pair<ParentState, Effect<ParentAction>> {
            val childAction = actionPrism.extract(parentAction) ?: return parentState to Effect.none()
            val childState = stateLens.get(parentState) ?: return parentState to Effect.none()

            val (newChildState, childEffect) = reducer.reduce(childState, childAction)
            val newParentState = stateLens.set(parentState, newChildState)
            val newParentEffect = childEffect.map { actionPrism.embed(it) }

            return newParentState to newParentEffect
        }
    }
}

operator fun <S, A> ReducerOf<S, A>.plus(
    other: ReducerOf<S, A>
): ReducerOf<S, A> {
    return object : ReducerOf<S, A> {
        override fun body() = this
        override fun reduce(state: S, action: A): Pair<S, Effect<A>> {
            val (s1, e1) = this@plus.reduce(state, action)
            val (s2, e2) = other.reduce(s1, action)
            return s2 to Effect.merge(e1, e2)
        }
    }
}

interface StoreOf<State, Action> {
    val state: StateFlow<State>
    fun send(action: Action)
    fun <ChildState, ChildAction>scope(
        lens: Lens<State, ChildState?>,
        prism: Prism<Action, ChildAction>,
    ): ScopedStore<ChildState, ChildAction>?{

        val current = state.value
        val childState = lens.get(current) ?: return null

        val childStateFlow = state
            .mapNotNull { lens.get(it) }
            .distinctUntilChanged()
            .stateIn(
                CoroutineScope(Dispatchers.Main),
                SharingStarted.Eagerly,
                initialValue = childState
            )
        return ScopedStore<ChildState, ChildAction>(
            state = childStateFlow,
            sendAction = { childAction ->
                this.send(prism.embed(childAction))
            }
        )
    }

}

class Store<State, Action>(
    initialState: State,
    private val reducer: ReducerOf<State, Action>,
) : StoreOf<State, Action> {

    private val _state = MutableStateFlow(initialState)
    override val state: StateFlow<State> = _state
    private val coroutineScope = CoroutineScope(Dispatchers.Main)

    override fun send(action: Action) {
        val (newState, effect) = reducer.reduce(_state.value, action)
        _state.value = newState
        coroutineScope.launch {
            effect.collect { newAction ->
                send(newAction)
            }
        }
    }
}

class ScopedStore<ChildState, ChildAction>(
    override val state: StateFlow<ChildState>,
    private val sendAction: (ChildAction) -> Unit
) : StoreOf<ChildState, ChildAction> {

    override fun send(action: ChildAction) {
        sendAction(action)
    }
}

class Effect<A> private constructor(
    private val flow: Flow<A>
) {
    fun <B> map(transform: (A) -> B): Effect<B> =
        Effect(flow.map(transform))

    suspend fun collect(collector: suspend (A) -> Unit) = flow.collect(collector)

    companion object {
        fun <A> none(): Effect<A> = Effect(emptyFlow())
        fun <A> just(value: A): Effect<A> = Effect(flowOf(value))
        fun <A> merge(vararg effects: Effect<A>): Effect<A> =
            Effect(merge(*effects.map { it.flow }.toTypedArray()))
        fun <A> run(
            dispatcher: CoroutineDispatcher = Dispatchers.Default,
            block: suspend (suspend (A) -> Unit) -> Unit
        ): Effect<A> {
            val flow = flow {
                coroutineScope {
                    block { action -> emit(action) }
                }
            }.flowOn(dispatcher)
            return Effect(flow)
        }
    }
}

class Lens<S, A>(
    val get: (S) -> A,
    val set: (S, A) -> S
) {
    fun <B> compose(other: Lens<A, B>): Lens<S, B> = Lens(
        get = { s -> other.get(this.get(s)) },
        set = { s, b -> this.set(s, other.set(this.get(s), b)) }
    )
}

class Prism<ParentAction, ChildAction>(
    val extract: (ParentAction) -> ChildAction?,
    val embed: (ChildAction) -> ParentAction
)

@Composable
fun <ParentState, ParentAction, ChildState, ChildAction> Navigation(
    store: StoreOf<ParentState, ParentAction>,
    lens: Lens<ParentState, ChildState?>,
    prism: Prism<ParentAction, ChildAction>,
    child: @Composable (StoreOf<ChildState, ChildAction>) -> Unit,
    parent: @Composable () -> Unit
) {
    val parentState by store.state.collectAsState()
    val childState = lens.get(parentState)

    val childStore = remember(childState) {
        store.scope(lens = lens, prism = prism)
    }

    if (childStore != null) {
        child(childStore)
    } else {
        parent()
    }
}

おわりに

進捗があったら追記していきます。単方向データフローはいいぞ。

0
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
0
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?