KMM UI Architecture - Part 2
Introduction
This is part 2 of a series of two articles:
- Part 1: a summary of common ui patterns and what we learn from them
- Part 2: creating a simple, adaptable, predictable and composable UI framework for Kotlin Multiplatform
The patterns/frameworks discussed in part 1 serve as inspiration to define a UI framework for Kotlin Multiplatform. The essential ideas we take from them are:
- data flowing between model and view is immutable
- data flows unidirectional from the view to the model and back
- data flows are reactive
- every concern is mapped to a component or a function to have clear separation
- functions are first-class citizens
What’s still unclear is:
- how to decompose/compose the user interface and the business logic
- how state should be stored, in a single centralized store vs. decentralized, business logic component specific state
In https://dev.to/feresr/a-case-against-the-mvi-architecture-pattern-1add good questions are raised concerning point 1 and there’s obviously a good amount of discussion going on around the question single-store vs. multiple stores vs. local state containers.
The approach I took here is to be as un-opinionated as possible and support different “styles”. The goal was to create a framework that supports both centralized and decentralized approaches for business logic and state containers.
Architectural Goals
The architectural goals that helped shape the solution were:
- Be platform-agnostic: it’s a KMM framework so this is obvious.
- Be reactive: reactive UIs have become the standard in recent years for good reasons.
- Be composable: ability to decompose the ui into small components and combine them into larger components again.
- Be as un-opinionated as possible: support different technologies, programming styles, app complexities and team sizes.
- Minimalistic and lightweight: some existing frameworks are very comprehensive but also heavyweight and require to write lots of boilerplate code (e.g. Decompose). Strict contracts between components (leading to boilerplate code) are crucial in larger teams but they can bog down the team’s productivity when speed is crucial (and in smaller teams). A more strict approach should be supported but not enforced.
- Be predictable: the order of execution (synchronous and asynchronous) and the concurrency model must be clearly specified and lead to predictable and repeatable outcome.
Kotlin Bloc
Kotlin Bloc
is the name of the framework to meet these architectural goals. Bloc stands for Business Logic Component, a term created by Google and popular in the Flutter world. The full framework documentation can be found here: https://1gravity.github.io/Kotlin-Bloc.
Overview
The framework has two main components:
- The Bloc (Business Logic Component) encapsulates your application’s business logic. It receives Action(s) from the view, processes those actions and outputs Proposals and optionally SideEffect(s).
- The BlocState holds the component’s State. It’s separate from the actual Bloc to support different scenarios like:
- share state between business logic components
- persist state (database, network)
- use a global state container like Redux
The View is obviously an important component too but technically not part of the framework (although there are numerous extensions that support/simplify the implementation for different target platforms).
Not surprisingly the Flutter Bloc nomenclature is used for some of the other components / sub-components of this architecture as well:
- A Sink is a destination for arbitrary data and used to send data from one to another component. A Bloc has a sink for Actions while a BlocState has a sink for Proposals.
- A Stream is a source of asynchronous data. Stream are always “hot” meaning data is emitted regardless whether a component is listening (or subscribed or collecting -> different names for the same thing). A Bloc has two streams, one for State and one for SideEffects while a BlocState has one for State.
Bloc
The inner workings of a Bloc are depicted in the following diagram:
The main parts of a Bloc are reducers, thunks and intializers.
1. Reducer
A reducer is a function that receives the current state and an action object, decides how to update the state if necessary, and returns the new state:
(state, action) => newState
(https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow)
Above definition is the official Redux reducer definition and captures its essence, although reducers in the context of Kotlin Bloc
are a bit more complex:
suspend (State, Action, CoroutineScope) -> Proposal
Compared to a Redux reducer, this one is:
- suspending
- takes a CoroutineScope as parameter (on top of the
State
and theAction
) - returns a
Proposal
instead ofState
There are different types of reducers which we’ll discuss more in the last chapter “Different Styles”. Here’s a simple example:
// single-action reducer
reduce<Increment> { state + 1 }
reduce<Decrement> { state - 1 }// catch-all reducer
reduce {
when (action) {
Increment -> state + 1
Decrement -> state - 1
}
}
2. Thunk
While reducers are executed asynchronously, their intended purpose is to update State
in a timely fashion to make sure the user interface is responsive to user input and updates "without" perceptible delay. Longer running operations should be executed using a Thunk
:
The word “thunk” is a programming term that means “a piece of code that does some delayed work”. Rather than execute some logic now, we can write a function body or code that can be used to perform the work later.
https://redux.js.org/usage/writing-logic-thunks
A Thunk
in the context of Kotlin Bloc
is exactly what above definition implies, although its implementation and especially its execution is completely different from a Redux thunk. While the latter is a function, dispatched as an action to a Redux store and processed by the redux-thunk middleware, "our" thunk is not dispatched as an action but triggered the same way a reducer is triggered, by reacting to an Action
that was sent to the Bloc
. There are more differences:
- it’s a suspending function
- it takes a CoroutineScope as parameter (next to the
GetState
,Action
andDispatcher
parameters) - dispatching of
Actions
follows strict rules (explained here)
Here’s a simple example:
thunk<Load> {
dispatch(Loading)
val result = repository.load()
dispatch(Loaded(result))
}reduce<Loading> {
state.copy(loading = true)
}reduce<Loaded> {
state.copy(loading = false, items = action.result)
}
3. Initializer
Initializers are functions executed when the bloc is created. They are similar to thunks since they can execute asynchronous code and dispatch actions to be processed by other thunks and reducers. Unlike thunks, initializers are executed once and once only during the Lifecycle of a bloc.
onCreate {
if (state.isEmpty()) dispatch(Load)
}
BlocState
BlocState
is the actual keeper of State
, a source of asynchronous state data (StateStream
) and a Sink
for Proposals
to (potentially) alter its state. Its interface is simple:
// StateStream
public val value: State
public suspend fun collect(collector: FlowCollector<State>)// Sink
public fun send(proposal: Proposal)
The default BlocState implementation takes an accept()
function to accept/reject Proposals as updates to State (taken from SAM):
Due to the clear separation between Bloc and BlocState, we can easily exchange a specific BlocState with another one, e.g. from our todo app example:
fun toDoBloc(context: BlocContext) = bloc<List<ToDo>, ToDoAction>(
context = context,
blocState = PersistingToDoState()
) {
PersistingToDoState
is, as the name implies, storing to do data persistently. Changing one line of code can change that behavior (transparently for the Bloc):
fun toDoBloc(context: BlocContext) = bloc<List<ToDo>, ToDoAction>(
context = context,
blocState = blocState(emptyList())
) {
Different Styles
As mentioned before, we want to support different programming styles to support different types of apps, different level of complexities, different team sizes and different preferences on how to do things.
Bloc
One feature to support those goals are catch-all vs. single-action reducers that make it possible to write more monolithic reducers or more isolated ones:
The same syntax can be used for thunks and side effects as well:
thunk<Load> { ... }
thunk<Save> { ... }
thunk {
when(action) {
Load -> ...
Save -> ...
}
}sideEffect<Load> { ... }
sideEffect { ... }
The “Orbit” model takes this idea one step further. The Orbit framework has the concept of a ContainerHost
used for classes that want to launch Orbit intents
:
Since any class can be a ContainerHost
any class can contain reducer code addressing the main concern raised in this article.
Kotlin Bloc
has the concept of BlocOwners
, an interface that can be implemented by any class and allows us to use the Orbit/MVVM+ syntax:
So we can declare reducers and thunks “builder style” (centralized in one builder block) or “BlocOwner style” (decentralized). Obviously we can also just use regular Kotlin features to decompose/compose business logic code (like extracting reducer code into separate functions or using extension functions).
BlocState
Another important feature that supports the “be un-opinionated” goal is the separation between state container (BlocState) and the actual business logic (Bloc) and the fact that the interaction between the two follows a strict protocol. This allows us to pick between a single-store vs. multiple stores vs. local state strategy (or a combination of the three).
We can e.g. share state between Blocs:
// define the shared state
private val commonState = blocState<BookState>(BookState.Empty)// first Bloc to use the commonState
private val clearBloc = bloc<BookState, BookAction.Clear>(
context,
commonState
) {
// business logic
}// second Bloc to use the commonState
private val loadBloc = bloc<BookState, BookAction>(
context,
commonState
) {
// business logic
}
We can also use a global state container like Redux to share state across all business logic components:
As a matter of fact Kotlin Bloc
comes with a Redux adapter that converts a Redux store to a BlocState and supports memoized selector functions.
Summary
Kotlin Bloc
is a simple and composable UI framework for Kotlin Multiplatform that will adapt to your programming style and integrates well into existing applications. It hits the sweet spot between guiding towards a certain way of implementing business logic, state containers and how to connect to the user interface without being too rigid.
Thanks for reading and I appreciate your feedback in the comments section.