toggl / komposable-architecture

🏗️ Kotlin implementation of Point-Free's composable architecture
Apache License 2.0
280 stars 20 forks source link
android architecture coroutines-android kotlin kotlin-android kotlin-coroutines mvi-android

🧩 Komposable Architecture Maven Central Build Status License

Kotlin implementation of Point-Free's The Composable Architecture

🚧 Project Status

We've been using the Komposable Architecture in production for years now, and we haven't encountered any major issues. However, the API is still subject to change, at least until we reach version 1.0. We are working to make the setup more straightforward and are considering ways to integrate Jetpack Navigation as well.

💡 Motivations

When it came time to rewrite Toggl's mobile apps, we chose a native approach instead of continuing with Xamarin. We quickly realized that, despite the apps not sharing a common codebase, we could still share many aspects across them. Using the same architecture allowed us to share specs, GitHub issues, and create a single common language that both Android and iOS developers can use. This approach has even sped up the development of features already implemented on the other platform!

We chose to use Point-Free's Composable Architecture as the apps's architecture, which meant we had to set out to implement it in Kotlin. This repo is the result of our efforts!

🍎 Differences from iOS

While all the core concepts are the same, the composable architecture is still written with Swift in mind, which means not everything can be translated 1:1 to Kotlin. Here are the problems we faced and the solutions we found:

No KeyPaths

The lack of KeyPaths in Kotlin forces us to use functions in order to map from global state to local state.

No Value Types

There's no way to simply mutate the state in Kotlin like the Composable architecture does in Swift. Instead, the reduced state is returned from the reducer along with any effects in ReduceResult.

Subscriptions

Additionally we decided to extend Point-Free architecture with something called subscriptions. This concept is taken from the Elm Architecture. It's basically a way for us to leverage observable capabilities of different APIs, in our case it's mostly for observing data stored in Room Database.

📲 Sample App

To run the sample app, start by cloning this repo:

git clone git@github.com:toggl/komposable-architecture.git

Next, open Android Studio and open the newly created project folder. You'll want to run the todo-sample app.

For more examples take a look at Point-Free's swift samples

🚀 Installation

The latest release is available on Maven Central.

implementation("com.toggl:komposable-architecture:1.0.0-preview04")
testImplementation("com.toggl:komposable-architecture-test:1.0.0-preview04") // optional testing extensions
ksp("com.toggl:komposable-architecture-compiler:1.0.0-preview04'")  // optional compiler plugin (still experimental)

© Licence

Copyright 2021 Toggl LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

   http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

🧭 High-level View

[!WARNING]
This documentation applies to versions before 1.0.0. Version 1.0.0 (currently in preview) introduces breaking changes.

This is a high level overview of the different parts of the architecture.

There's one global Store and one AppState. But we can view into the store to get sub-stores that only work on one part of the state. More on that later.

There's also one main Reducer and multiple sub-reducers that handle a limited set of actions and only a part of the state. Those reducers are then pulled back and combined into the main reducer.

🔎 Getting into the weeds

Store & State

The Store exposes a flow which emits the whole state of the app every time there's a change and a method to send actions that will modify that state. The State is just a data class that contains ALL the state of the application. It also includes the local state of all the specific modules that need local state. More on this later.

The store interface looks like this:

interface Store<State, Action : Any> {
    val state: Flow<State>
    fun send(actions: List<Action>)
    // more code
}

And you can create a new store using:

createStore(
    initialState = AppState(),
    reducer = reducer,
    subscription = subscription,
    dispatcherProvider = dispatcherProvider,
    storeScopeProvider = application as StoreScopeProvider
)

actions are sent like this:

store.send(AppAction.BackPressed)

and views can subscribe like this:

store.state
    .onEach { Log.d(tag, "The whole state: \($0)") }
    .launchIn(scope)

// or

store.state
    .map { it.email }
    .onEach { emailTextField.text = it }
    .launchIn(scope)

The store can be "viewed into", which means that we'll treat a generic store as if it was a more specific one which deals with only part of the app state and a subset of the actions. More on the Store Views section.

Actions

Actions are sealed classes, which makes it easier to discover which actions are available and also add the certainty that we are handling all of them in reducers.

sealed class EditAction {
    data class TitleChanged(val title: String) : EditAction()
    data class DescriptionChanged(val description: String) : EditAction()
    object CloseTapped : EditAction()
    object SaveTapped : EditAction()
    object Saved : EditAction()
}

These sealed actions are embedded into each other starting with the "root" AppAction

sealed class AppAction {
    class List(override val action: ListAction) : AppAction(), ActionWrapper<ListAction>
    class Edit(override val action: EditAction) : AppAction(), ActionWrapper<EditAction>
    object BackPressed : AppAction()
}

So to send an EditAction to a store that takes AppActions we would do

store.send(AppAction.Edit(EditAction.TitleChanged("new title")))

But if the store is a view that takes EditActions we'd do it like this:

store.send(EditAction.TitleChanged("new title"))

Reducers & Effects

Reducers are classes that implement the following interface:

fun interface Reducer<State, Action> {
    fun reduce(state: State, action: Action): ReduceResult<State, Action>
}

The idea is they take the state and an action and modify the state depending on the action and its payload.

In order to send actions asynchronously we use Effects. Reducers return an array of Effects. The store waits for those effects and sends whatever action they emit, if any.

An effect interface is also straightforward:

interface Effect<out Action> {
    suspend fun execute(): Action?
}

Subscriptions

Subscriptions are similar to effects:

fun interface Subscription<State, Action : Any> {
    fun subscribe(state: Flow<State>): Flow<Action>
}

The difference is that Subscriptions are not triggered by Actions. They start immediately after the store is created and continue emitting as long as the store exists.

Subscriptions are typically used to observe some data in the database:

class ListSubscription @Inject constructor(val todoDao: TodoDao) : Subscription<AppState, AppAction> {
    override fun subscribe(state: Flow<AppState>): Flow<AppAction> =
        todoDao.getAll().map { AppAction.List(ListAction.ListUpdated(it)) }
}

Or some other observable APIs like for example location services. Subscription flow can be also steered by state changes:

class ListSubscription @Inject constructor(val locationProvider: LocationProvider) : Subscription<AppState, AppAction> {
    override fun subscribe(state: Flow<AppState>): Flow<AppAction> =
        if (state.isPermissionGranted) 
          locationProvider.observeCurrentLocation().map { AppAction.Map(MapAction.LocationUpdated(it)) }
        else 
          flowOf()
}

Pullback

There's one app level reducer that gets injected into the store. This reducer takes the whole AppState and the complete set of AppActions.

The rest of the reducers only handle one part of that state, for a particular subset of the actions.

This aids in modularity. But in order to merge those reducers with the app level one, their types need to be compatible. That's what pullback is for. It converts a specific reducer into a global one.

internal class PullbackReducer<LocalState, GlobalState, LocalAction, GlobalAction>(
    private val innerReducer: Reducer<LocalState, LocalAction>,
    private val mapToLocalState: (GlobalState) -> LocalState,
    private val mapToLocalAction: (GlobalAction) -> LocalAction?,
    private val mapToGlobalState: (GlobalState, LocalState) -> GlobalState,
    private val mapToGlobalAction: (LocalAction) -> GlobalAction,
) : Reducer<GlobalState, GlobalAction> {
    override fun reduce(
        state: GlobalState,
        action: GlobalAction,
    ): ReduceResult<GlobalState, GlobalAction> {
        val localAction = mapToLocalAction(action)
            ?: return ReduceResult(state, noEffect())

        val newLocalState = innerReducer.reduce(mapToLocalState(state), localAction)

        return ReduceResult(
            mapToGlobalState(state, newLocalState.state),
            newLocalState.effects.map { effects -> effects.map { e -> e?.run(mapToGlobalAction) } },
        )
    }
}

After we've transformed the reducer we can use combine to merge it with other reducers to create one single reducer that is then injected into the store.

Store Views

Similarly to reducers and pullback, the store itself can be "mapped" into a specific type of store that only holds some part of the state and only handles some subset of actions. Only this operation is not exactly "map", so it's called view.

class MutableStateFlowStore<State, Action : Any> private constructor(
    override val state: Flow<State>,
    private val sendFn: (List<Action>) -> Unit
) : Store<State, Action> {

    override fun <ViewState, ViewAction : Any> view(
        mapToLocalState: (State) -> ViewState,
        mapToGlobalAction: (ViewAction) -> Action?,
    ): Store<ViewState, ViewAction> = MutableStateFlowStore(
        state = state.map { mapToLocalState(it) }.distinctUntilChanged(),
        sendFn = { actions ->
            val globalActions = actions.mapNotNull(mapToGlobalAction)
            sendFn(globalActions)
        },
    )
}

This method on Store takes two functions, one to map the global state into local state and another one to map local action to global action.

Different modules or features of the app use different store views so they are only able to listen to changes to parts of the state and are only able to send certain actions.

Local State

Some features have the need of adding some state to be handled by their reducer, but maybe that state is not necessary for the rest of the application. Consider email & password fields in a theoretical Auth module.

To deal with this kind of state we do the following:

This is how could the AuthState look like:

data class AuthState(
    val user: Loadable<User>,
    val localState: LocalState
) {
    data class LocalState internal constructor(
        internal val email: Email,
        internal val password: Password
    ) {
        constructor() : this(Email.Invalid(""), Password.Invalid(""))
    }
}

This is how it looks in the global app state

data class AppState(
    val authLocalState: AuthState.LocalState = AuthState.LocalState(),
)

High-order reducers

High-order reducers are basically reducers that take another reducer (and maybe also some other parameters). The outer reducer adds some behavior to the inner one, maybe transforming actions, stopping them or doing something with them before sending them forward to the inner reducer.

The simplest example of this is a logging reducer, which logs every action sent to the console:

class LoggingReducer(override val innerReducer: Reducer<AppState, AppAction>)
    : HigherOrderReducer<AppState, AppAction> {
    override fun reduce(
        state: AppState,
        action: AppAction
    ): ReduceResult<AppAction> {
        Log.i(
            "LoggingReducer", when (action) {
                is AppAction.List -> action.list.formatForDebug()
                is AppAction.Edit -> action.edit.formatForDebug()
            }
        )

        return innerReducer.reduce(state, action)
    }
}

✅ Testing Extensions

If you decide to include com.toggl:komposable-architecture-test to your dependencies, you'll be able to use a small set of Reducer extensions designed to make testing easier.

Take a look at this test from Todo Sample app which is making a good use of testReduce extension method:

@Test
fun `ListUpdated action should update the list of todos and return no effects`() = runTest {
     val initialState = ListState(todoList = emptyList(), backStack = emptyList())
     reducer.testReduce(
         initialState,
         ListAction.ListUpdated(listOf(testTodoItem))
     ) { state, effects ->
         assertEquals(initialState.copy(todoList = listOf(testTodoItem)), state)
         assertEquals(noEffect(), effects)
     }
}