About a year ago I became interested in some new technology: Kotlin Multiplatform. Since then I have been actively experimenting in this area and promoting this technology within our company. One outcome, for example, is our Reaktive library — Reactive extensions for Kotlin Multiplatform.
At Bumble — the parent company operating Badoo and Bumble apps — for Android development, we use the MVI architectural pattern (read more about our architecture Zsolt Kocsi’s article: “MVI beyond state reducers”). Thanks to different projects that I have been working on I have become a big fan of this approach. Of course, I could not miss my opportunity to try MVI in Kotlin Multiplatform. And the occasion was very appropriate: we needed to write samples for the Reaktive library.
All my experiments have left me even more inspired by MVI. In fact, I believe this pattern is best suited to Kotlin Multiplatform so I decided to write this series of three articles on the topic:
- A brief description of the MVI pattern, problem definition and writing the common (shared) module using Kotlin Multiplatform
- Integration of the shared module into iOS and Android applications
- Unit and integration testing
This first article will be of interest to anyone already using or planning to use Kotlin Multiplatform in the near future. To understand the material I cover you are going to need at least some basic knowledge. If you feel that might be lacking in this area, I recommend that you first familiarise yourself with the introduction and the documentation. Prior understanding of the Kotlin/Native memory model is also highly desirable. I especially recommend reading the following Concurrency and Immutability sections of the documentation. This article does not cover the configuration of the project, modules, nor other matters that are not relevant to the topic.
First, let’s recall what MVI is. The acronym MVI stands for Model-View-Intent. There are only two main components in the system:
- Model — belonging to the domain (business logic) layer. The Model also stores the current state of the system.
- View — belonging to the UI layer. The View renders the State of the system and produces Intents.
The following diagram will be familiar to many:
There are just two main components: Model and View. Everything else is the data that circulates between these two components.
We immediately see that the data flows strictly in one direction. The States come from the Model and enter into the View for rendering. Intents come from the View and enter into the Model for processing. This circulation is called Unidirectional Data Flow.
In practice, the Model is often represented by an entity called Store, a name borrowed from Redux. However, this is not always the case. For example, in our library MVICore the Model is called Feature.
- Our library Reaktive — Kotlin multiplatform implementation of Reactive Extensions
- Channels and Flow — asynchronous data streams based on Kotlin coroutines.
The purpose of this article is to show how the MVI pattern can be used in Kotlin Multiplatform and explore the advantages and disadvantages of this approach. Therefore, I will not be tied to any specific implementation of MVI. However, I will be using Reaktive, as data streams are still needed. If desired, once the basic idea is understood, Reaktive can be replaced with Flow and coroutines. In general, I will try to make our MVI as simple as possible, free of any unnecessary complications.
To demonstrate MVI I aim to implement the simplest possible project, and which meets the following requirements:
- Supports Android and iOS
- Demonstration of asynchronous work (IO, data processing, etc.)
- Maximised code sharing
- UI should be implemented natively on each platform
- Rx should not be exported to platforms (there is no need to specify Rx dependencies as API nor they will be available to consumers).
As an example, I chose a very simple application: one screen with a button. Click on the button and a list with random images of cats 🐈 is downloaded and displayed. To get image URLs I use the public API: https://thecatapi.com. This allows us to satisfy the requirement for asynchronous work, as we have to download lists from the network and parse JSON.
To see the entire source code of the project, check out our GitHub: https://github.com/badoo/KmpMvi
Getting started: abstractions for MVI
First, we need to introduce some abstractions for our MVI. Flowing the MVI definition we will need two basic components (Store and View), as well as two typealiases.
For Intent processing, we introduce the Actor, a function that takes a current State and an Intent and returns a stream of Effects (results).
We also need a Reducer, a function that accepts a current State and an Effect (result) from Actor and returns a new State:
The Store will represent the Model from MVI. It should accept Intents and provide a stream of States. When subscribing to a stream of States, the current state should be emitted.
Let’s introduce the following interface:
So, our Store has the following features:
- It has two Generic parameters: input Intent and output State
- It’s a consumer of Intent (Consumer<Intent>)
- It’s a stream of States (Observable<State>)
- It’s disposable.
Because it’s not very convenient to implement such an interface every time, we need a helper:
StoreHelper is a small class that will make it easier for us to create Stores. It has the following properties:
- It has three Generic parameters: input Intent and Effect and output State
- It accepts the initial State, Actor and Reducer via the constructor
- It’s a stream of States
- It’s Disposable
- It’s not freezable (so that subscribers are also not frozen)
- It implements DisposableScope (interface from Reaktive for managing subscriptions)
- It accepts and processes Intents and Effects.
Here is a diagram of the Store:
As you can see, both Actor and Reducer are implementation details of a Store.
Let’s take a closer look at the onIntent method.:
- Takes Intent as an argument
- Invokes the Actor and passes the current State and Intention into it
- Subscribes to the stream of Effects returned by the actor
- Directs all Effects to the onEffect method
- Subscription to the stream of Effects is performed using the isThreadLocal flag (avoids freezing in Kotlin/Native)
Now, let’s take a closer look at the onEffect method:
- Takes Effect as an argument
- Calls the Reducer and passes the current State and the Effect to it
- Passes a new State to the BehaviorSubject, which leads to the emission of the new state to all subscribers
Now let’s get into the View. It should accept (render) Models and provide a stream of Events. Here is the interface:
The View has the following properties:
- It has two Generic parameters: input Model and output Event
- It accepts Models via the “render” method
- It provides a stream of Events via “events” property
I added the MVI prefix to the MviView name to avoid confusion with Android View. Also, I did not extend the Consumer and Observable interfaces but simply used property and method. This is so that you can expose (export) a View interface to be implemented in the platform (Android or iOS) without exporting Rx as an API dependency. The trick here is that clients will not directly interact with these properties but will implement the MviView interface by extending an abstract class.
So let’s add this abstract class for the View:
This class will help us with the dispatching of Events. It will also save platforms from interacting with Rx.
Here is a diagram of how this will work:
Store produces States that are converted to Models and displayed by the View. The latter produces Events that are converted to Intents and delivered to the Store for processing. This approach eliminates the coupling between Store and View. But in simple cases, a View can work directly with States and Intents.
That’s all we need for MVI! Now let’s write some shared code.
- We will make a shared module whose responsibility will be to download and display a list of images of cats 🐱
- We will abstract our UI with an interface and accept its implementation from outside
- We will hide the entire implementation behind a convenient facade
Let’s start with the most important thing, make KittenStore, which will load a list of image URLs.
We have extended the Store interface and specified its generic types (Intent and State). Note that the interface is declared as internal. Our KittenStore is the implementation details of the module. We have only one Intent, Reload, that causes the loading of a list of images. But the State is worth exploring in more detail:
- The “isLoading” flag indicates whether the download is currently in progress or not
- The “data” property can be one of two variants:
- Images — a list of image URLs
- Error — indicates an error
Now let’s start the implementation, we’ll do it step by step. First, create an empty KittenStoreImpl class, which will implement the KittenStore interface.
We also implemented the familiar DisposableScope interface. We will need this for convenient subscription management.
We will need to download a list of images from the network and parse JSON. Declare the corresponding dependencies:
Network will download JSON text from the network, and Parser will parse JSON and return a list of image URLs. In case of an error, Maybe will just complete without result. In this article, we are not interested in the kind of error that occurred.
Now declare Effects and Reducer:
Right before downloading, we issue LoadingStarted which causes the isLoading flag to be set. Once the download is complete, we issue either LoadingFinished or LoadingFailed. In the first case, we clear the isLoading flag and apply a list of image URLs. In the second case, we also reset the flag and apply the error state. Please note that Effects is our KittenStore’s private API.
Now we implement the loading itself:
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 as properties from the constructor. This is in order to avoid references to “this” and, consequently freezing the entire KittenStore.
And finally, use StoreHelper and finish the KittenStore implementation:
Now, our KittenStore is ready, we move on to the View.
Let’s define the following interface:
We defined a View Model with loading and error flags and a list of image URLs. We also defined an event, RefreshTriggered, which will be produced every time a user triggers the update. KittenView is the public API of our module. It will be implemented on each platform.
The responsibility of this data source is to download JSON text from the network. As usual, we declare the interface:
The data source will be implemented separately for each platform. Therefore, we can declare a factory method using expect/actual:
Implementations of the data source will be discussed in the next part, where we will implement applications for iOS and Android.
The final stage comprises integrating all components.
Here is the Network interface implementation:
Here is the Parser interface implementation:
Here we used the kotlinx.serialization library. Parsing is performed on the computation scheduler to avoid blocking the main thread.
State to View Model conversion:
Event to Intent conversion:
And finally, our facade. Let’s first specify its life cycle:
This is briefly what is happening here:
- The first method called is “onCreate”, then “onViewCreated” and then “onStart”. Now the facade is started.
- Then at some point “onStop” will be called, the facade is now stopped.
- When the facade is stopped either “onStart” or “onViewDestroyed” can be called, so the facade can just be started again or its view can be destroyed.
- When the View is destroyed either “onViewCreated” or “onDestroy” can be called, so the View can be recreated or the whole facade can be destroyed.
An implementation might look something like this:
How it works:
- First, we create a new instance of the KittenStore
- In the onViewCreated method we remember the KittenView reference
- In onStart we subscribe KittenStore and KittenView to each other
- In onStop we unsubscribe
- In onViewDestroyed clear the KittenView reference
- In onDestroy we dispose the KittenStore
In this part of the article we:
- refreshed our memories of what MVI is and how it works
- made a simple MVI implementation in Kotlin Multiplatform using the Reaktive library
- made a shared module for loading a list of images using MVI.
Let’s highlight the most important features of our shared module:
- We managed to put all the code (except UI) into the shared multiplatform module. All the logic plus wirings and conversions between the logic and the UI are shared.
- There is no coupling between the logic and the UI
- The implementation of the UI will be very simple: you only need to render the incoming Models and produce Events
- Module integration is also simple. All that is needed is to:
- implement the KittenView interface (protocol)
- instantiate the KittenComponent
- call its life cycle methods at the right time.
- This approach avoids the leakage of Rx (or coroutines) into the platforms. This means that we don’t have to manage any subscriptions at the application level
- Everything is abstracted with interfaces and is testable.
To see the entire source code of the project, check out our GitHub: https://github.com/badoo/KmpMvi
In the second part, I will show in practice how we can integrate the KittenComponent into iOS and Android applications. Stay tuned!
You can also follow me on Twitter.