Modern MVot-based Kotlin architecture
Over the past two years, Android developers at Badoo have come a long, thorny path from MVP to a completely different approach to application architecture. ANublo and I want to share a translation of the article by our colleague Zsolt Kocsi , describing the problems we encountered and their solution.
This is the first of several articles devoted to the development of modern MVI architecture at Kotlin.
Let's start from the beginning: state problems
At each moment in time, an application has a certain state that determines its behavior and what the user sees. If you focus only on a pair of classes, this state includes all the values of variables - from simple flags to individual objects. Each of these variables lives its own life and is controlled by different parts of the code. You can determine the current state of the application only by checking them all one by one.
Working on the code, we create an existing system model in our head. We easily realize the ideal cases when everything goes according to plan, but completely unable to calculate all possible problems and conditions of the application. And sooner or later, one of the states that we have not foreseen will overtake us, and we will encounter a bug.
Initially, the code is written in accordance with our ideas about how the system should work. But later, going through five stages of debugging , you have to painfully redo everything, simultaneously changing the existing model in your head. It remains to hope that sooner or later an understanding of what went wrong will come to us and the bug will be fixed.
But so luck is not always. The more complex the system, the more likely it is to encounter any unforeseen condition, debugging of which will take a long night in nightmares.
In Badoo, all applications are essentially asynchronous - not only because of the extensive functionality available to the user through the UI, but also because of the possibility of one-way sending data by the server. Much affects the state and behavior of the application - from changing the payment status to new matches and verification requests.
As a result, in our chat module, we came across several strange and hard-to-reproduce bugs, which spoiled a lot of blood. Sometimes testers managed to write them down, but they did not repeat on the developer’s device. Due to the asynchronous code, the repetition in full of one or another chain of events was extremely unlikely. And since the application did not fall, we didn’t even have a stack trace that would show where to start the search.
Clean Architecture ( pure architecture) also could not help us. Even after we rewrote the chat module, the A / B tests revealed small but significant inconsistencies in the number of messages from users who used the new and old modules. We decided that this was due to the hard reproducibility of bugs and the state of the race. The discrepancy persisted after checking all other factors. The interests of the company suffered, developers had a hard time maintaining the code.
It is impossible to release a new component, if it works worse than the existing one, but it is also impossible not to release it - since an update was required, it means that there was a reason. So, it is necessary to figure out why in a system that looks perfectly normal and does not crash, the number of messages drops.
Where to start the search?
Spoiler: this is not the fault of Clean Architecture - the human factor, as always, is to blame. In the end, of course, we fixed these bugs, but spent a lot of time and effort on this. Then we thought: Is there an easier way to avoid these problems?
The light at the end of the tunnel ...
Fashionable terms like Model-View-Intent and “unidirectional data flow” are well known to us. If in your case this is not the case, I advise them to google - there are many articles on the Internet on these topics. Android developers especially recommend the Hannes Dorfman material in eight parts .
We started playing with these ideas from web development back in early 2017. Approaches like Flux and Redux have proven very useful - they helped us cope with many problems.
First of all, it is very useful to contain all state elements (variables that affect the UI and trigger various actions) in one object - State. When everything is stored in one place, the overall picture is better visible. For example, if you want to submit data loading using this approach, then you will need payload and isLoading fields . Looking at them, you will see when the data is received ( payload ) and whether the animation is shown to the user ( isLoading ).
Further, if we move away from the parallel execution of the code with callbacks and express the state changes of the application in the form of a series of transactions, we will get a single entry point. We present you the Reducer , which arrived to us from functional programming. It takes the current state and data on further actions ( Intent ) and creates from them a new state:
Reducer = (State, Intent) -> State
Continuing with the previous example of loading data, we get the following actions:
Then you can create a Reducer with the following rules:
- In the case of StartedLoading, create a new State object by copying the old one and set isLoading to true.
- In the case of FinishedWithSuccess, create a new State object by copying the old one, in which the isLoading value will be set to false and the payload value will
correspond to the loaded one.
If we put the resulting State series into a log, we see the following:
- State ( payload = null, isLoading = false) - initial state.
- State ( payload = null, isLoading = true) - after StartedLoading.
- State ( payload = data, isLoading = false) - after FinishedWithSuccess.
By connecting these states to the UI, you will see all the stages of the process: first a blank screen, then the boot screen, and finally the required data.
This approach has many advantages.
- First, by changing the state centrally with a series of transactions, we do not allow the state of the race and the many imperceptible annoying bugs.
- Secondly, after examining a series of transactions, we can understand what happened, why it happened and how it affected the state of the application. In addition, with Reducer it is much easier to present all state changes even before the first launch of the application on the device.
- Finally, we have the ability to create a simple interface. Since all states are stored in one place (Store), which takes into account intentions (Intents), makes changes with the help of Reducer and visually demonstrates a chain of states, it means that you can put all business logic in the Store and use the interface to launch intentions and deduce states.
... can be a train rushing at you
One Reducer is not enough. How to deal with asynchronous tasks with different results? How to respond to the push from the server? How to deal with the launch of additional tasks (for example, clearing the cache or loading data from the local database) after changing the state? It turns out that either we do not include all this logic in the Reducer (that is, a good half of the business logic will not be covered, and those who decide to use our component will have to take care of it), or force the Reducer to do everything at once.
Requirements for the MVI framework
Of course, we would like to enclose the entire business logic of a separate feature into a separate component, with which developers from other teams could easily work by simply creating its copy and subscribing to its state.
- it should easily interact with other components of the system;
- there should be a clear division of responsibilities within its internal structure;
- all internal parts of the component must be fully deterministic;
- The basic implementation of such a component should be simple and complicated only when it is necessary to connect additional elements.
We did not immediately switch from Reducer to the solution we use today. Each team faced problems when using different approaches, and developing a universal solution that would suit everyone seemed unlikely.
And yet, the current state of affairs suits everyone. We are glad to present you MVICore! The source code of the library is open and available on GitHub .
What is MVICore good about
- Easy way to implement business features in the style of reactive programming with unidirectional data flow.
- Scaling: the basic implementation only includes a Reducer, and in more complex cases, you can use additional components.
- Solution for working with events that you do not want to include in the state ( problem SingleLiveEvent ).
- A simple API to bind features (and other reactive components of your system) to the UI and to each other with support for the Android life cycle (and not only).
- Middleware support (about this below) for each component of the system.
- Ready logger and the possibility of time travel debaga for each component.
A brief introduction to the Feature
Since step-by-step instructions have already been posted on GitHub, I will omit detailed examples and focus on the main components of the framework.
Feature is the central element of the framework containing the entire business logic of the component. Feature is defined by three parameters: interface Feature <Wish, State, News>
Wish corresponds to Intent from Model-View-Intent - these are the changes we want to see in the model (since the term Intent has its meaning in the Android developer environment, we had to find other name). Wish is the entry point for Feature.
State- this is, as you already understood, the state of the component. State is immutable: we cannot change its internal values, but we can create new States. This is the output: every time we create a new state, we transfer it to the Rx stream.
News - a component for processing signals that should not be in the State; News is used once at creation ( problem SingleLiveEvent ). Using News is optional (you can use Nothing from Kotlin in the Feature signature).
Also in the Feature must be present Reducer .
Feature may contain the following components:
- Actor - performs asynchronous tasks and / or conditional state modifications based on the current state (for example, form validation). Actor binds Wish to a certain number of Effect, and then passes it to the Reducer (in the absence of Actor Reducer receives Wish directly).
- NewsPublisher - called when Wish becomes any Effect that gives a result in the form of a new State. According to this data, he decides whether to create News.
- PostProcessor is also called after creating a new State and also knows what effect led to its creation. It launches certain additional actions (Actions). Action is “internal wishes” (for example, clearing the cache) that cannot be started from the outside. They are executed in Actor, which leads to a new chain of Effects and States.
- Bootstrapper is a component that can run actions on its own. Its main function is to initialize the Feature and / or correlate external sources with the Action. These external sources can be News from another Feature or server data that must modify State without user interaction.
The scheme may look simple:
or include all the additional components listed above:
The Feature itself, containing all the business logic and ready for use, looks simpler than ever:
Feature, the foundation stone of the framework, works at a conceptual level. But the library has much more to offer.
- Since all Feature components are deterministic (with the exception of Actor, which is not fully deterministic because it interacts with external data sources, but even so, the branch it performs is determined by the input data, not external conditions), each of them can be wrapped in Middleware. At the same time, the library already contains ready-made solutions for logging and time travel debug .
- Middleware is applicable not only to Feature, but also to any other objects that implement the Consumer <T> interface, which makes it an indispensable tool for debugging.
- When using a debugger for debugging when moving in the opposite direction, you can implement the DebugDrawer module .
- The library includes an IDEA plugin that can be used to add templates for the most common implementations of Feature, which saves a lot of time.
- There are helper classes to support Android, but the library itself is not tied to Android.
- There is a ready-made solution for linking components to the UI and to each other through an elementary API (we will talk about it in the next article).
We hope you try our library and its use will give you as much joy as we do - its creation!
November 24 and 25, you can try your hand and join us! We will hold a mobile hiring event: in one day, it will be possible to go through all the stages of selection and get an offer. My colleagues from iOS- and Android-teams will come to communicate with candidates to Moscow. If you are from another city, Badoo charges you for travel expenses. To get an invitation, pass the qualifying test on the link . Good luck!