Android MVI
For a long time, applying proper app architecture patterns to Android developers has remained a burden or mission. This is because developing an app on Android is itself a very difficult structure to apply a single pattern. It has releated to do with the running environment of the mobile app. While programs on the PC run as a single monolithic process with a single entry point, Android apps run different processes mixing tasks with each other. In addition, almost all elements such as user input processing, data loading, UI interaction, use of various sensors or device resources, etc., operate asynchronously is one of the factors that makes it difficult to simplify the structure of the app. Despite these difficulties, attempts to apply app architecture to Android apps have been steadily progressing. Representatively, there are MV brothers. All of you reading this already know the MV brothers, and maybe some of you are pretty close. I think there are probably the MV brothers that come to mind right now. For example, friends like MVP or MVVM. By the way, have you even heard of MVI? Today, I am going to talk about MVI, the youngest of the MV brothers.
The problem is the state.
Developing Android apps is a complex task, but if you think about it simply, you can describe it as managing state. Everything that is displayed on the screen, including the information you want to convey to the user or expressing an error situation, can be viewed as the state of the app. Most of the problems with an app occur when the state of that app is not being handled as intended. Why does that happen? There are two main reasons for this.
1. Dispersed data
No app architecture recommends dispersing multiple pieces of data that make up your app’s state. However, apps with complex screens and complex business logics sometimes unintentionally disperse and manage the data that makes up the app’s state. Data distributed in View, ViewModel, Presenter, or even Model and managed separately may cause a collision of state at some point if not managed with careful care.
2. Complex data flow
It is one cause of complex data flow such as network communication or data input and output. Typically, an app mixes several tasks and tries to change the state of the app each other.
MVI is a state-oriented app architecture featuring single state management and one-way data flow using immutable objects to avoid collisions of the states and to facilitate data flow tracking.
Hello, MVI
So, shall we take a quick look at the MVI? First, look at the picture below.
It represents the most basic human interaction without any disturbance. They each say what they want to say and react to what they hear. In this traditional way, the two can communicate. What if you change the relationship between two people into computers and people?
For human-computer interaction, we need interfaces such as a mouse or keyboard or monitor and speakers. Instead of talking, the user uses a mouse or keyboard to create an output that is input to the computer, the computer analyzes that input, and the output that is input to the user is displayed on the monitor. In general, from a mathematical point of view, you need a function to connect inputs and outputs. In the figure above, the input and output contents can be converted into functions as follows.
All components in the circle above have their respective outputs as inputs to the next function, and all data can go in only one direction. The result of user() is passed as input to intent(). The result of intent() becomes the input of model(). Similarly, model() output is passed as input to view(). And the output of view() is used to call user() again, and this cycle continues like this: Expressing this in code is as follows.
intent(user(view(model(intent(user())))))
Of these, user() is not subject to programming, so if you exclude it, it will eventually look like this.
view(model(intent()))
Where did you see it a lot? These three functions create the main components of the MVI architecture: Model, View, and Intent. The meaning of Model, View, and Intent, which means each of MVI, is as follows.
- Intent: The intention to change the state of the app. Substituting into the diagram above, you can see that it represents the result of user(), that is, something the user wants to do.
- Model: This represents the only state the app currently has. Analyzes the user’s intention, that is, the intent passed as a result of intent(), and creates a model with a new immutable object according to the current state of the app.
- View: Receives the model that is the result of the model() function, that is, the state of the app, and renders the UI on the screen.
What View means is not very different from the MV brothers I have come across. However, unlike MVP, MVVM, and MVC’s P and VM and C, which are used to refer to the actual components that process the main logic of the app, I of MVI refers to the intent to change the state of the app. Not assigning an important place in the name to structural components like other MV siblings suggests that MVI has a different perspective from other app architectures in the past. MVI is more of a paradigm about how to handle the state of the app and the flow of data, rather than the answer to the question of how Android apps should be structured. In fact, if you are looking for example code for MVI now, you can easily find code written based on MVP or MVVM. The way to implement the MVI pattern is open.
Why MVI?
Myrealtrip’s app I’m working on is developing with the MVI architecture pattern. There are two questions I hear the most when talking about this. The first is “Are you really using it?” The second is “Why?” to be. The answer to the first is of course “yes”. I will try the second answer.
From the past, our app was developed as a mixture of MVP and MVVM. Selecting one of the app architectures and integrating them was given as a top challenge, and we wanted to compare the two architectures and choose the one that meets the conditions below.
- A meaningful test should be possible.
- Easy to debug.
What was the result? Both have passed. Well-designed MVP and MVVM code was testable and debugging was relatively straightforward. The problem was that it had to be “well designed”. In fact, most commercial products are developed as a team. Team members have different personalities and criteria for outcomes. And each has different competencies. That’s why it takes a lot of effort to consistently maintain a high standard of “well designed”. I wanted an architecture that gave some level of output no matter who developed it, and I added the following conditions to the previous two conditions:
3. No matter who develops, a certain level of results will be guaranteed.
I found that possibility in MVI. The Views and Models of MVI play a relatively clear role. The View receives the Model and draws the screen. The Model just needs to contain the data to be displayed on the screen. It is the part where structural problems are difficult to occur depending on the individual’s ability. So, I thought that if I could handle the intent and normalize the part that generates the model, I could satisfy the condition of 3. The project that started from thinking to make this idea a reality is the Box project, which I will introduce next.
What is Box?
Box’s github page introduces Box like this.
Box is an MVI-based Android app architecture framework that leverages StateMachine-inspired Blueprints and Kotlin’s coroutines.
To put it simply again, Box is a Kotlin-based framework that helps you develop Android apps with the MVI architecture. Box has the concept of Blueprint for normalization of Intent processing and Model generation based on general MVI architecture paradigm. As the name suggests, Blueprints act as the blueprint for your app. It is designed so that the relationship between the events occurring in the app and the state of the app is declared as dsl, and Box takes care of the rest. I think most of the developers who are reading this article probably want to see the code rather than this explanation. Let’s take a look at how you can use Box to develop apps based on the MVI architecture while creating a simple app.
SideEffect
Before that, there is one concept that I couldn’t explain. I explained earlier that MVI consists of Model, View, and Intent. Remember this diagram?
We want the app to look like the above pure function cycle, but in reality the app is not that simple. So there are elements that cannot be included in that function’s cycle. For example, background tasks that take a long time… API communication, I/O operations, or exposure of dialogs or toasts, and activity switching are those kinds of operations. MVI handles this as a concept called SideEffect.
Between intent()
and model()
there is a hidden component that handles sideeffects. When the user dispatches intent()
the result of intent()
is usually passed as input to model()
. At the same time, intent()
asks another component to execute a sideeffect. The result of this side effect can be a new intent()
.
So, let’s find out the name of each element of MVI in Box?
- Model ->
BoxState
: State represents the single state of the app. - View ->
BoxView
: View receives State and renders the screen. - Intent ->
BoxEvent
: Event changes the state of the app and fires SideEffect if necessary. - SideEffect ->
BoxSideEffect
: SideEffect is the same as previously described.
Among the above components, except for View, other elements such as State, Event, and SideEffect become the basic units that define the behavior of the MVI app. So, defining how these three elements interact is exactly what the Blueprint mentioned earlier does, and the tool that defines this Blueprint is a friend called BoxVm.
Now, let’s create the above-described components one by one.
Oh, let’s briefly explain the app we’re going to create? The app we are going to create is a simple app that loads image files from a specific site and displays them to the user. It works like this.
First, let’s define each component of State, Event, and SideEffect. The screen we will create is expected to go through the following steps.
- The data is imported by extracting the url from the source to load the image.
- The imported data is processed into a specific form (possibly image url) and drawn as a view.
- During image loading, it displays the status in progress and displays an error screen if the image fails to load.
State can be defined as follows to display the above 1–2–3 process on the screen..
Each value has the following meaning.
onProgress
: When true, the renderer draws progress on the screen.onError
: If true, the renderer draws an error screen on the screen.source
: When clicking Retry on the error screen, this value is used to request an image again.images
: If there is a value, the renderer draws an image on the screen.
So what events will there in the app? Since it is a simple app, you can think of the following events.
- Event requesting image information
- Event that successfully received the requested image information
- The event that the image request failed
- Events that retry failed requests
These types of events are represented in code as follows.
So what will be SideEffects? First of all, it would be appropriate to make an image request to the server. Let’s define it as follows.
Now the basic components are defined. Now let’s define the relationship between these components as blueprints. As mentioned earlier, Blueprints are defined using a component called BoxVm.
Simple isn’t it? Now just write the contents of onCreatedBlueprint()
and you are done. Blueprints can be created with the blueprint()
builder, which is an extension function of BoxVm. The builder consists of an on()
function that defines an event, a to()
function that defines a new state to be generated by receiving the event, and finally there are main()
, background()
, and io()
functions that define the sideeffect.
First, let’s write the event we defined above in one Blueprint.
From the beginning, the most complex events have appeared, but in fact it is not difficult. The blueprint builder receives the initial state as an argument. In this case, there is no particular screen to be drawn initially, so I created and passed a default MainState.
- Using the on function, we declared that it is the definition for the RequestImages event.
- Using the to function, we have defined the state to be created when the RequestImages event is delivered. Since MVI is based on immutable objects, a new state is created using the
copy()
function based on the previous state. - At the same time, we declared that MainSideEffect.RequestImages is a side effect that will be triggered when the RequestImages event occurs as the second argument of the to function.
Okay, now, when the RequestImages event occurs, Box delivers the new state changed to the source passed through the event. And onProgress will be true and onError will be false from the previous state. Simultaneously fires the MainSideEffect.RequestImages side effect with the source passed through the event.
Now let’s define what the MainSideEffect.RequestImages should do?
Simple isn’t it? We defined the behavior of SideEffect through io()
. In this case, MainSideEffect.RequestImages executes the requestImage()
function in the IO Dispathcer. If you want to run it on the default dispatcher, you can use the background()
function. Box supports a function that declares SideEffect to support each dispatcher as follows.
main()
: Defines the SideEffect operating in Dispatchers.Main.background()
: Defines SideEffect running in Dispatchers.Default.io()
: Defines SideEffect running in Dispatchers.IO.async()
: Operates in Dispatcher.Main the same as themain()
function, butDeferred
can be passed as a return value. It is used to handle SideEffect performed byasync()
.
So what would the requestImage function look like?
It will look like the code above. As previously defined, requestImages()
is declared as a suspend function that can handle network operations by running through the IO Dispatcher. Loads the image from source using the repository pattern and returns success or failure events depending on the result. The event returned in this way is once again delivered to BoxVm. Now, let’s define the rest of the events in this way too.
Please note that
requestImages()
does not define State, Event, SideEffect, so it is written in Vm code, notonCreatedBlueprint()
.
The finished Vm will look like this. This completes the definition of the relationship between State, Event, and SideEffect defined above. Now the app using Box will run based on the above schematic. Now all that remains is a View that will render the screen based on the State created and delivered by the Vm.
As you know, in Android, View is representative of Activity and Fragment. Box provides BoxActivity and BoxFragment for each implementation. This time, we will implement it using BoxActivity.
For the convenience of writing test code, BoxAcitvity doesn’t do much. Here’s what you need to implement for BoxActivity:
layout
— screen to compose the screen, it can be omitted.Renderer
— , which receives the state and draws the screen, can also be omitted if there is no screen. For complex screens, it is also possible to implement partial rendering by using multiple renderers. For this example, this is a simple screen, so we use one renderer.ViewInitializer
— that initializes the view to be used on the screen, initializes the view, or fires the first event of the app.BoxVm
— Activity declares a Vm with a blueprint that defines the interrelationships of State, Event, and SideEffect.
Let’s look at the layout first? Box is forcing data binding on the layout. Therefore, the layout must be in the form of data binding. The layout of screen_main is as follows.
It is no different from the usual data binding layout. Each view component determines how to draw the view from the value that will be passed from the BoxActivity’s Renderer. It is worth paying attention to the delivery of Vm. Vm is used to generate an event by receiving user input in xml. In the above xml code, if you look at the button’s onClick
, you can see that MainEvent.Retry is triggered by the vm.intent()
function.
Next, let’s look at the ViewInitializer.
The initializeView()
function of ViewInitializer is called once after the BoxActivity is created. Therefore, it is suitable for generating an event necessary for initializing the screen or initializing the view. In the case of the code above, the MainEvent.RequestImages
event is raised using the vm.intent()
function.
The renderer is responsible for drawing the screen. In some cases, you can even use multiple renderers to implement partial rendering.
In this example code, the renderer is supposed to pass the state’s data to data binding and render the image if it exists images in the State.
So far, we have seen how to compose screens with Box. So, how do you write meaningful test code with Box?
Box operates according to the interrelationship of State, Event, and SideEffect defined in Blueprint. Therefore, it verifies the change of the renderer according to the blueprint code and state.
Then, let’s look at them one by one. First is the blueprint’s test code. Blueprint’s code is implemented in a structure that is difficult to test by making the entire mock. Therefore, it is recommended to extend the base test class to write test code. The basic test class provides the following features.
testIntent()
:intent()
the event to Vm. In the actual code, the Event delivered to Vm is executed as State and SideEffect respectively according to the contents defined in the blueprint, but thetestIntent()
function returns the result of the Event in the form of Ouput. Valid Output isValid
, invalid Ouput, that is, returnsVoid
when there is no result for Event. Ouput.Valid has the State to be passed as theto
property, and the SideEffect created with thesideEffect
property.do~SideEffect()
: Execute the side effect. Run sideEffect of mock VM. Depending on the type of SideEffect, you can select one of thedoIoSideEffect()
,doHeavySideEffect()
, anddoSideEffect()
functions.
The test code of Vm written based on the above is in the form below.
Next is a test code that verifies the state of the renderer according to the state. Normal Android View code is difficult to write test code, but Box’s renderer only looks at the state passed as an argument and performs rendering, so you can write test code relatively easily.
So far, we have learned about the open source Box framework for developing apps with high testability more easily based on Android MVI and MVI architecture.
The sample code introduced above can be viewed in more detail at https://github.com/jaeho/jallery.
As I said at the outset, Android developers always bear the burden of a good app architecture. MVVM, which has already become a trend, is a great app architecture, but are there still other possibilities for us to look at? Here are some of the things that could be seen as a reason to choose MVI.
- The data cycles in one direction -> The logic is predictable and it is easy to track if something goes wrong with your app.
- There are no state conflicts -> Apps can only have one immutable state at a time. Immutability also has general benefits such as thread stability and shareability.
- Separate logic -> Each component performs only its own responsibility. It is easier to write unit tests based on this structure. Not only that, but it also makes it easier to modify/add functions by forcing a flexible structure.
However, MVI is not a magical key that can solve all problems. It also has these disadvantages.
- The entry barrier is high. -> Running curve is high when applied to new team members or existing projects.
- More files -> Separate files such as State, Event, SideEffect are required. As more files become available, the cost of object creation increases.
- Even small UI changes require cycles through intents, so sometimes it is inconvenient.
Although I haven’t introduced all of them here, Box complements the shortcomings of MVI and has many features that make it easier to create a better structured app. If you are thinking about a new app structure, how about MVI? MVI is not difficult if you start with Box!