MVI Architectural Template in Kotlin Multiplatform, Part 1



About a year ago, I became interested in the new Kotlin Multiplatform technology. It allows you to write common code and compile it for different platforms, while having access to their API. Since then I have been actively experimenting in this area and promoting this tool in our company. One result, for example, is our Reaktive library - Reactive Extensions for Kotlin Multiplatform.

In the Badoo and Bumble applications for Android development, we use the MVI architectural template (for more details about our architecture, read the article by Zsolt Kocsi: “ Modern MVI architecture based on Kotlin"). Working on various projects, I became a big fan of this approach. Of course, I could not miss the opportunity to try MVI in Kotlin Multiplatform. Moreover, the case was suitable: we needed to write examples for the Reaktive library. After these experiments of mine, I was even more inspired by MVI.

I always pay attention to how developers use Kotlin Multiplatform and how they build the architecture of such projects. According to my observations, the average Kotlin Multiplatform developer is actually an Android developer who uses the MVVM template in his work simply because he is so used to it. Some additionally apply “clean architecture”. However, in my opinion, MVI is best suited for Kotlin Multiplatform, and “clean architecture” is an unnecessary complication.

Therefore, I decided to write this series of three articles on the following topics:

  1. A brief description of the MVI template, statement of the problem and the creation of a common module using Kotlin Multiplatform.
  2. Integration of a common module in iOS and Android applications.
  3. Unit and integration testing.

Below is the first article in the series. It will be of interest to everyone who already uses or is just planning to use Kotlin Multiplatform.

I note right away that the purpose of this article is not to teach you how to work with the Kotlin Multiplatform itself. If you feel that you do not have enough knowledge in this area, I recommend that you first familiarize yourself with the introduction and documentation (especially the “ Concurrency ” and “ Immutabilitysections to understand the features of the Kotlin / Native memory model). In this article I will not describe the configuration of the project, modules and other things that are not related to the topic.

MVI


First, let's recall what MVI is. The abbreviation stands for Model-View-Intent. There are only two main components in the system:

  • model - a layer of logic and data (the model also stores the current state of the system);
  • (View) — UI-, (states) (intents).

The following diagram is probably already familiar to many:



So, we see those very basic components: the model and presentation. Everything else is the data that circulates between them.

It is easy to see that data moves only in one direction. States come from the model and fall into the view for display, intentions come from the view and go into the model for processing. This circulation is called Unidirectional Data Flow.

In practice, a model is often represented by an entity called Store (it is borrowed from Redux). However, this does not always happen. For example, in our MVICore library , the model is called Feature.

It is also worth noting that MVI is very closely related to reactivity. Presentation of data streams and their transformation, as well as life cycle management of subscriptions is very convenient to implement using reactive programming libraries. A fairly large number of them are now available, however, when writing general code in Kotlin Multiplatform, we can only use multi-platform libraries. We need abstraction for data streams, we need the ability to connect and disconnect their inputs and outputs, as well as carry out transformations. At the moment, I know of two such libraries:

  • our Reaktive library - implementation of Reactive Extensions on Kotlin Multiplatform;
  • Coroutines and Flow - implementation of cold streams with Kotlin coroutines.

Formulation of the problem


The purpose of this article is to show how to use the MVI template in Kotlin Multiplatform and what are the advantages and disadvantages of this approach. Therefore, I will not become attached to any specific MVI implementation. However, I will use Reaktive, because data streams are still needed. If desired, having understood the idea, Reaktive can be replaced by coroutines and Flow. In general, I will try to make our MVI as simple as possible, without unnecessary complications.

To demonstrate MVI, I will try to implement the simplest project that meets the following requirements:

  • support for Android and iOS;
  • Demonstration of asynchronous operation (input-output, data processing, etc.);
  • as much common code as possible;
  • UI implementation with native tools of each platform;
  • lack of Rx on the platform side (so that you do not have to specify dependencies on Rx as an “api”).

As an example, I chose a very simple application: one screen with a button, by clicking on which a list with arbitrary images of cats will be downloaded and displayed. To upload images, I will use the open API: https://thecatapi.com . This will allow you to fulfill the requirement of asynchronous operation, since you have to download lists from the Web and parse the JSON file.

You can find all source code of the project on our GitHub .

Getting started: abstractions for MVI


First we need to introduce some abstractions for our MVI. We will need the very basic components — the model and view — and a couple of typealias.

Typealiases


To process intentions, we introduce an actor (Actor) - a function that accepts the intent and current state and returns a stream of results (Effect):

typealias Actor < State , Intent , Effect > = ( State , Intent ) - > Observable < Effect >
view raw MviKmpActor.kt hosted with ❤ by GitHub

We also need a reducer (Reducer) - a function that takes effect and current state and returns a new state:

typealias Reducer < State , Effect > = ( State , Effect ) - > State
view raw MviKmpReducer.kt hosted with ❤ by GitHub

Store


Store will present a model from MVI. He must accept intentions and give out a stream of states. When subscribing to a state stream, the current state should be issued.

Let's introduce the appropriate interface:

interface Store < in Intent : Any , out State : Any > : Consumer < Intent >, Observable < State >, Disposable

So, our Store has the following properties:

  • has two generic parameters: input Intent and output State;
  • is a consumer of intentions (Consumer <Intent>);
  • is a state stream (Observable <State>);
  • it is destructible (Disposable).

Since it is not very convenient to implement such an interface each time, we will need a certain assistant:

class StoreHelper < in Intent : Any , out State : Any , in Effect : Any > (
initialState : State ,
private val actor : Actor < State , Intent , Effect >,
private val reducer: Reducer<State, Effect>
) : Observable<State>, DisposableScope by DisposableScope() {
init {
ensureNeverFrozen()
}
private val subject = BehaviorSubject(initialState)
fun onIntent(intent: Intent) {
actor(subject.value, intent).subscribeScoped(isThreadLocal = true, onNext = ::onEffect)
}
fun onEffect ( effect : Effect ) {
subject.onNext (reducer (subject.value, effect))
}
override fun subscribe ( observer : ObservableObserver < State >) {
subject.subscribe (observer)
}
}


StoreHelper is a small class that will make it easier for us to create a Store. It has the following properties:

  • has three generic parameters: input Intent and Effect and output State;
  • accepts the initial state through the constructor, the actor and the gearbox;
  • is a stream of states;
  • destructible (Disposable);
  • non-freezing (so that subscribers are also not frozen );
  • implements DisposableScope (interface from Reaktive for managing subscriptions);
  • accepts and processes intentions and effects.

Look at the diagram of our Store. The actor and gearbox in it are implementation details:



Let us consider the onIntent method in more detail:

  • accepts intentions as an argument;
  • calls the actor and passes the intention and current state into it;
  • subscribes to the effects stream returned by the actor;
  • directs all effects to the onEffect method;
  • Subscribing to effects is performed using the isThreadLocal flag (this avoids freezing in Kotlin / Native).

Now let's take a closer look at the onEffect method:

  • takes effects as an argument;
  • calls the gearbox and transfers the effect and the current state into it;
  • passes the new state to the BehaviorSubject, which leads to the receipt of the new state by all subscribers.

View


Now let's get into the presentation. It should accept models for display and give out a stream of events. We will also make a separate interface:

interface MviView < in Model : Any , out Event : Any > {
val events : Observable < Event >
fun render ( model : Model )
}
view raw MviKmpMviView.kt hosted with ❤ by GitHub


A view has the following properties:

  • has two generic parameters: input Model and output Event;
  • accepts models for display using the render method;
  • dispatches an event stream using the events property.

I added the Mvi prefix to the MviView name to avoid confusion with Android View. Also, I did not expand the Consumer and Observable interfaces, but simply used the property and method. This is so that you can set the presentation interface to the platform for implementation (Android or iOS) without exporting Rx as an “api” dependency. The trick is that clients will not directly interact with the “events” property, but will implement the MviView interface, expanding the abstract class.

Immediately add this abstract class to represent:

abstract class AbstractMviView<in Model : Any, Event : Any> : MviView<Model, Event> {
private val subject = PublishSubject<Event>()
override val events: Observable<Event> = subject
protected fun dispatch(event: Event) {
subject.onNext(event)
}
}

This class will help us with the issuance of events, as well as save the platform from interacting with Rx.

Here is a diagram that shows how this will work: The



Store produces states that are transformed into models and displayed by the view. The latter produces events that are converted to intentions and delivered to the Store for processing. This approach removes the coherence between Store and presentation. But in simple cases, a view can work directly with states and intentions.

That's all we need to implement MVI. Let's write the general code.

Common code


Plan


  1. We will make a general module whose task is to download and display a list of images of cats.
  2. UI we abstract the interface and we will transfer its implementation outside.
  3. We will hide our implementation behind a convenient facade.

Kittenstore


Let's start with the main thing - create a KittenStore that will load a list of images:

internal interface KittenStore : Store < Intent , State > {
sealed class Intent {
object Reload : Intent ()
}
data class State (
val isLoading : Boolean = false ,
val data : Data = Data . Images ()
) {
sealed class Data {
data class Images ( val urls : List < String > = emptyList ()) : Data ()
object Error : Data ()
}
}
}


We've expanded the Store interface with intent types and state types. Please note: the interface is declared as internal. Our KittenStore is the implementation details of the module. Our intention is only one - Reload, it causes the loading of a list of images. But the condition is worth considering in more detail:

  • the isLoading flag indicates whether the download is currently underway or not;
  • The data property can take one of two options:
    • Images - a list of links to images;
    • Error - means that an error has occurred.

Now let's start the implementation. We will do this in stages. First, create an empty KittenStoreImpl class that will implement the KittenStore interface:

internal class KittenStoreImpl (
) : KittenStore , DisposableScope by DisposableScope () {
override fun onNext ( value : Intent ) {
}
override fun subscribe ( observer : ObservableObserver < State >) {
}
}


We also implemented the familiar DisposableScope interface. This is necessary for convenient subscription management.

We will need to download the list of images from the Web and parse the JSON file. Declare the corresponding dependencies:

internal class KittenStoreImpl (
private val network : Network ,
the private val parser : Parser
) : KittenStore, DisposableScope by DisposableScope() {
override fun onNext(value: Intent) {
}
override fun subscribe(observer: ObservableObserver<State>) {
}
interface Network {
fun load(): Maybe<String>
}
interface Parser {
fun parse ( json : String ) : Maybe < List < String >>
}
}

Network will download the text for the JSON file from the Network, and Parser will parse the JSON file and return a list of image links. In the event of an error, Maybe will simply end without result. In this article, we are not interested in the type of error.

Now declare the effects and reducer:

internal class KittenStoreImpl (
private val network : Network ,
the private val parser : Parser
) : KittenStore, DisposableScope by DisposableScope() {
override fun onNext(value: Intent) {
}
override fun subscribe(observer: ObservableObserver<State>) {
}
private fun reduce(state: State, effect: Effect): State =
when (effect) {
is Effect.LoadingStarted -> state.copy(isLoading = true)
is Effect.LoadingFinished -> state.copy(isLoading = false, data = State.Data.Images(urls = effect.imageUrls))
is Effect.LoadingFailed -> state.copy(isLoading = false, data = State.Data.Error)
}
private sealed class Effect {
object LoadingStarted : Effect()
data class LoadingFinished(val imageUrls: List<String>) : Effect()
object LoadingFailed : Effect()
}
interface Network {
fun load(): Maybe<String>
}
interface Parser {
fun parse ( json : String ) : Maybe < List < String >>
}
}

Before starting the download, we issue the LoadingStarted effect, which causes the isLoading flag to be set. After the download is complete, we issue either LoadingFinished or LoadingFailed. In the first case, we clear the isLoading flag and apply the list of images, in the second, we also clear the flag and apply the error state. Please note that effects are our KittenStore's private API.

Now we implement the download itself:

internal class KittenStoreImpl (
private val network: Network,
private val parser: Parser
) : KittenStore, DisposableScope by DisposableScope() {
override fun onNext(value: Intent) {
}
override fun subscribe(observer: ObservableObserver<State>) {
}
private fun reload(network: Network, parser: Parser): Observable<Effect> =
network
.load()
.flatMap(parser::parse)
.map(Effect::LoadingFinished)
.observeOn(mainScheduler)
.asObservable()
.defaultIfEmpty(Effect.LoadingFailed)
.startWithValue(Effect.LoadingStarted)
private fun reduce(state: State, effect: Effect): State =
// Omitted code
private sealed class Effect {
// Omitted code
}
interface Network {
fun load(): Maybe<String>
}
interface Parser {
fun parse(json: String): Maybe<List<String>>
}
}

Here it is worth paying attention to the fact that we passed Network and Parser to the reload function, despite the fact that they are already available to us as properties from the constructor. This is done in order to avoid references to this and, as a result, freezing the entire KittenStore.

Well, finally, use StoreHelper and finish KittenStore implementation:

internal class KittenStoreImpl (
private val network : Network ,
the private val parser : Parser
) : KittenStore , DisposableScope by DisposableScope () {
private val helper = StoreHelper(State(), ::handleIntent, ::reduce).scope()
override fun onNext(value: Intent) {
helper.onIntent(value)
}
override fun subscribe(observer: ObservableObserver<State>) {
helper.subscribe(observer)
}
private fun handleIntent(state: State, intent: Intent): Observable<Effect> =
when (intent) {
is Intent.Reload -> reload(network, parser)
}
private fun reload(network: Network, parser: Parser): Observable<Effect> =
// Omitted code
private fun reduce(state: State, effect: Effect): State =
// Omitted code
private sealed class Effect {
// Omitted code
}
interface Network {
fun load () : Maybe < String >
}
interface Parser {
fun parse ( json : String ) : Maybe < List < String >>
}
}

Our KittenStore is ready! We pass to presentation.

Kittenview


Declare the following interface:

interface KittenView : MviView < Model , Event > {
data class Model (
val isLoading : Boolean ,
val isError : Boolean ,
val imageUrls : List < String >
)
sealed class Event {
object RefreshTriggered : Event ()
}
}

We announced a view model with load and error flags and a list of image links. We have only one event - RefreshTriggered. It is issued every time the user invokes an update. KittenView is the public API of our module.

KittenDataSource


The task of this data source is to download text for a JSON file from the Web. As usual, declare the interface:

internal interface KittenDataSource {
fun load ( limit : Int , offset : Int ) : Maybe < String >
}

Data source implementations will be made for each platform separately. Therefore, we can declare a factory method using expect / actual:

internal expect fun KittenDataSource () : KittenDataSource

Implementations of the data source will be discussed in the next part, where we will implement applications for iOS and Android.

Integration


The final stage is the integration of all components.

Network interface implementation:

internal class KittenStoreNetwork (
private val dataSource : KittenDataSource
) : KittenStoreImpl . Network {
override fun load () : Maybe < String > = dataSource.load (limit = 50 , offset = 0 )
}


Parser interface implementation:

internal object KittenStoreParser : KittenStoreImpl . Parser {
override fun parse ( json : String ) : Maybe < List < String >> =
maybeFromFunction {
Json ( JsonConfiguration . Stable )
.parseJson (json)
.jsonArray
.map {it.jsonObject.getPrimitive ( " url " ) .content}
}
.subscribeOn (computationScheduler)
.onErrorComplete ()
}

Here we used the kotlinx.serialization library . Parsing is performed on a computation scheduler to avoid blocking the main thread.

Convert state to view model:

internal fun State. toModel () : Model =
Model (
isLoading = isLoading,
isError = when ( data ) {
is State . The Data . Images - > false
is State . The Data . Error - > true
},
imageUrls = when ( data ) {
is State . The Data . Images - > data .urls
is State . The Data . Error - > emptyList ()
}
)

Convert events to intentions:

internal fun Event. toIntent () : Intent =
when ( this ) {
is Event . RefreshTriggered - > Intent . Reload
}

Facade preparation:

class KittenComponent {
fun onViewCreated ( view : KittenView ) {
}
fun onStart () {
}
fun onStop () {
}
fun onViewDestroyed () {
}
fun onDestroy () {
}
}

Familiar to many Android developers life cycle. It is great for iOS, and even JavaScript. The diagram of the transition between the states of the life cycle of our facade looks like this: I will


explain briefly what is happening here:

  • first, the onCreate method is called, after it - onViewCreated, and then - onStart: this puts the facade in working condition (started);
  • at some point after this, the onStop method is called: this puts the facade in a stopped state (stopped);
  • in a stopped state, one of two methods can be called: onStart or onViewDestroyed, that is, either the facade can be started again, or its view can be destroyed;
  • when the view is destroyed, either it can be created again (onViewCreated), or the entire facade can be destroyed (onDestroy).

The facade implementation may look like this:

class KittenComponent {
private val store =
KittenStoreImpl (
network = KittenStoreNetwork (dataSource = KittenDataSource ()),
parser = KittenStoreParser
)
private var view : KittenView? = null
private var startStopScope : DisposableScope? = null
fun onViewCreated ( view : KittenView ) {
this .view = view
}
fun onStart() {
val view = requireNotNull(view)
startStopScope = disposableScope {
store.subscribeScoped(onNext = view::render)
view.events.map(Event::toIntent).subscribeScoped(onNext = store::onNext)
}
}
fun onStop() {
startStopScope?.dispose()
}
fun onViewDestroyed() {
view = null
}
fun onDestroy() {
store.dispose()
}
}

How it works:

  • first we create an instance of KittenStore;
  • in the onViewCreated method we remember the link to KittenView;
  • in onStart we sign KittenStore and KittenView to each other;
  • in onStop we mirror them from each other;
  • in onViewDestroyed we clear the link to the view;
  • in onDestroy, destroy KittenStore.

Conclusion


This was the first article in my MVI series at Kotlin Multiplatform. In it we:

  • remembered what MVI is and how it works;
  • made the simplest MVI implementation on Kotlin Multiplatform using the Reaktive library;
  • created a common module for loading a list of images using MVI.

Note the most important properties of our common module:

  • we managed to put all the code into the multiplatform module except for the UI code; all logic, plus the connections and conversions between logic and UI are common;
  • logic and UI are completely unrelated;
  • the implementation of the UI is very simple: you only need to display the incoming view models and throw events;
  • module integration is also simple: all you need is:
    • implement KittenView interface (protocol);
    • create an instance of KittenComponent;
    • call his life cycle methods at the right time;
  • this approach avoids the “flow” of Rx (or coroutine) into the platforms, which means that we do not have to manage any subscriptions at the application level;
  • all important classes are abstracted by interfaces and tested.

In the next part, I will show in practice what KittenComponent integration looks like in iOS and Android applications.

Follow me on Twitter and stay connected!

All Articles