The most popular architectural pattern for Android development is the powerful and easy-to-use MVVM implementation provided by the Jetpack architectural component. Last year, we were retooling an old, important business, and we completely switched to Kotlin + Coroutines + Jetpack AAC from our Java + unarchitectedimplementation. We are quite satisfied with the overall effect, and have not found any obvious defects and shortcomings.

Jetpack AAC, while awesome, didn’t work with KMM, so we found an “alternative” in the open source community — MVIKotlin.

MVIKotlin is a framework that implements THE MVI pattern. It can be used not only for KMM, but also for JavaScript, JVM, LinusX64, MacX64 and many other Kotlin targets.

So what kind of model is MVI? In short, it’s an improved version of MVVM. In MVVM, the View listens for changes in data within the ViewModel (LiveData/StateFlow, etc.) to complete updates, while user operations on the View trigger changes in data state through direct calls to the ViewModel. In MVI, the View triggers changes in the data state to send “intents”, further decoupling.

Here’s an illustration from MVIKotlin’s website:

It looks pretty simple, but actually it’s too simplistic. The Model should actually be static, so holding State requires another component (or concept, container) similar to the ViewModel in MVVM. In MVIKotlin, this viewModel-like concept is called a Store. Therefore, the following chart is provided to illustrate the data flow in detail:

The Store handles all the dynamic stuff, including pulling data, notifying the UI, saving state, etc. Binder binds the Store to View and starts and stops the process.

This makes the implementation of the architecture much clearer. However, the core of the MVIKotlin library implementation lies in the Store. There are many concepts inside the Store, and they are organized together in a more complex way. The library users need to strictly follow the organization form of these concepts to program:

Executor is the Store engine. It receives the Intent sent by the View and produces data based on the Intent. The Result is sent to the Reducer, which processes it into the State that can be displayed by the View and publishes it. Reducer is conceptually a little like LiveData in Jetpack. The Bootstrapper is a launcher that is used to first issue an Action to load data at initialization. The Executor is still receiving the Action.

In MVIKotlin’s design, Bootstrapper and Action are optional, and every other concept should be strictly defined. Let’s take a look at an official full Store Demo:

internal interface CalculatorStore : Store<Intent, State, Nothing> { sealed class Intent { object Increment : Intent() object Decrement : Intent() data class Sum(val n: Int): Intent() } data class State(val value: Long = 0L) } internal class CalculatorStoreFactory(private val storeFactory: StoreFactory) { private sealed class Result { class Value(val value: Long) : Result() } private object ReducerImpl : Reducer<State, Result> { override fun State.reduce(result: Result): State = when (result) { is Result.Value -> copy(value = result.value) } } fun create(): CalculatorStore = object : CalculatorStore, Store<Intent, State, Nothing> by storeFactory.create( name = "CounterStore", initialState = State(), bootstrapper = BootstrapperImpl, executorFactory = ::ExecutorImpl, reducer = ReducerImpl) {} private sealed class Action { class SetValue(val value: Long): Action() } private class BootstrapperImpl : CoroutineBootstrapper<Action>() { override fun invoke() { scope.launch { val sum = withContext(Dispatchers.Default) { (1L.. 1000000.toLong()).sum() } dispatch(Action.SetValue(sum)) } } } private class ExecutorImpl : CoroutineExecutor<Intent, Action, State, Result, Nothing>() { override fun executeAction(action: Action, getState: () -> State) = when (action) { is Action.SetValue -> dispatch(Result.Value(action.value)) } override fun executeIntent(intent: Intent, getState: () -> State) = when (intent) { is Intent.Increment -> dispatch(Result.Value(getState().value + 1)) is Intent.Decrement -> dispatch(Result.Value(getState().value - 1)) is Intent.Sum -> sum(intent.n) } private fun sum(n: Int) { scope.launch { val sum = withContext(Dispatchers.Default) { (1L.. n.toLong()).sum() } dispatch(Result.Value(sum)) } } }Copy the code

In the design of Store, a lot of template method design pattern is used. When users use the library, they should strictly inherit the superclass provided by the library, and implement every abstract function in strict accordance with the rules. Strict definition reduces flexibility and increases learning cost, which leads to the decline of its promotion speed. However, it forces the improvement of code standardization, which has both advantages and disadvantages. In addition, MVIKotlin provides extensions for Coroutines and Reaktive, similar to Jetpack’s viewModelScope and lifecycleScope, which automatically help us stop asynchronous tasks depending on the lifecycle.

However, this library has some disadvantages as well — data flows between each component with different types, such as Action, Intent, Result, State, and so on. They all depend on sealed classes, and each sealed class has many subclasses. Therefore, each business module needs to define a large number of classes. And even a very simple business requires a lot of boilerplate code.

So how do you optimize away a lot of type definitions? I have the following three suggestions:

  1. If you can use a value class, use a value class.

  2. Unify actions and Intents; Actions are only used once during initialization. It is not cost-effective or meaningful to define a single type for an Action.

  3. Unify the Result of the whole project. The usual function of Result is to indicate success or failure. If it is successful, it carries data, and if it fails, it carries abnormal information.

Let’s look at the View layer demo:

interface CalculatorView : MviView<Model, Event> { data class Model(val value: String) sealed class Event { object IncrementClicked: Event() object DecrementClicked: Event() } } class CalculatorViewImpl(root: View) : BaseMviView<Model, Event>(), CalculatorView { private val textView = root.requireViewById<TextView>(R.id.text) init { root.requireViewById<View>(R.id.button_increment).setOnClickListener { dispatch(Event.IncrementClicked) } root.requireViewById<View>(R.id.button_decrement).setOnClickListener { dispatch(Event.DecrementClicked) } } override fun  render(model: Model) { super.render(model) textView.text = model.value } }Copy the code

We also need to implement the library interface MviView. The Model and Event defined above are actually State and Intent.

So what does it look like on iOS?

class CalculatorViewProxy: BaseMviView<CalculatorViewModel, CalculatorViewEvent>, CalculatorView, ObservableObject { @Published var model: CalculatorViewModel? override func render(model: CalculatorViewModel) { self.model = model } } struct CalculatorView: View { @ObservedObject var proxy = CalculatorViewProxy() var body: some View { VStack { Text(proxy.model? .value ?? "") Button(action: { self.proxy.dispatch(event: CalculatorViewEvent.IncrementClicked()) }) { Text("Increment") } Button(action: { self.proxy.dispatch(event: CalculatorViewEvent.DecrementClicked()) }) { Text("Decrement") } } } }Copy the code

Finally, Binder demo:

class CalculatorController { private val store = CalculatorStoreFactory(DefaultStoreFactory).create() private var binder: Binder? = null fun onViewCreated(view: CalculatorView) { binder = bind { store.states.map(stateToModel) bindTo view // Use store.labels to bind Labels to a consumer view.events.map(eventToIntent) bindTo store } } fun onStart() { binder? .start() } fun onStop() { binder? .stop() } fun onViewDestroyed() { binder = null } fun onDestroy() { store.dispose() } }Copy the code

Binder flow data states depend on exposed API calls. In our example, Binder start and stop calls can be lifecycle based on platform-specific UI components (activities, fragments, UIViewControllers, etc.). And store’s Dispose function.

There is also an extension for Jetpack Lifecycle that binds Binder to Lifecycle.

Personal view

On Android alone, Jetpack AAC is a much better development experience than MVIKotlin, and with a bit of tweaks Jetpack AAC can smoothly implement MVI mode. MVIKotlin does not Lifecycle and cannot share data by accessing the same Store with the same owner. So the only advantage of MVIKotlin at present is that it can cross end and solve the urgent problem of our current KMM project. However, the actual stability of MVIKotlin remains to be seen in the production environment over a period of time.

Overcoming the ViewModel layer is one of our main goals when the current Model layer is not too big a hurdle, and MVIKotlin is the only option for now, but not forever. The porting of Jetpack AAC to iOS is an intriguing goal; StateFlow itself is part of Coroutines Flow, which has been put in place as a multi-platform replacement for LiveData. The main porting to the iOS platform is the ViewModel, Lifecycle, and customizing our own UIViewController for Lifecycle.

It’s been a while since the KMM Survival Series was updated, and there hasn’t been much progress in KMM UI cross-platform in the past few months, but the good news is that Kotlin/Native’s new GC is almost done, and ideally, By the time Kotlin is released 1.6.20 or 1.6.30, KMM concurrent programming is no longer limited by the object subgraph mechanism. So what else can we do besides continue to work on architectural components? The perfection of unit testing of KMM project and the statistics of Kotlin/Native code coverage are all topics worth exploring.