MVI beyond state reducers
This is the first part in a series of articles on Android architecture in Badoo. You can find the next one here:
Story time: problems of state
Your application has a state at any given time that determines its behaviour and what the user sees on the UI. If you focus on just a couple of classes, this state consists of all the values stored in your variables, be they a simple boolean flag or an instance of an object. All those variables have a life of their own, changed at will by different parts of your code. It’s difficult to say exactly what state your application is in at any given point, without going through them all and checking them one by one.
When we write code, we are writing a scheme to map out our internal mental model of how a system should work. We are good at implementing the happy cases, when everything goes the way we want, but we are horribly inept at thinking about all the possible edge cases, and all the possible states our application might end up in. Getting to one of those unexpected states is only a matter of time, and then we have a bug.
When we first produced it, we adapted our code to our internal mental model. And now, going through the five stages of debugging, we painfully need to do the opposite, and adapt our own internal mental model to the reality of the actual system we produced. Hopefully, at the end of the day we leave with an “Aha!” moment, realising what went wrong — and the bug is fixed.
But not all cases are so lucky. The more complex your system is, the more there’s a chance of it entering a weird, previously unexpected state, which is a pain to debug. At Badoo, our applications are massively asynchronous, not just as a result of the functionality that users can trigger themselves through the UI, but all the one-sided server pushes that can happen as well. All of these elements have an impact on the state and flow of the application, from billing status changes to missing data blockers to new matches.
As a result of this, we’ve had quite a few weird and hard-to-reproduce bugs in our chat module, which was causing everyone a headache. QA could record a video of them happening from time to time, only to never see them again on the developer’s device. Since the code was massively asynchronous, the same order of events, the same race condition happening again had only a slight mathematical chance. And since it didn’t crash the application, we had no stack trace to even tell us where to start looking.
Clean architecture alone didn’t help us either. Even after rewriting the whole chat module with that approach, A/B tests were constantly showing a small, but not at all insignificant difference in the number of first messages sent by users using the new module compared to the old one. We supposed this was because of some difficult-to-reproduce bugs and race conditions. The difference was present after controlling the groups for every other factor, and it was hurting business goals just as much as it was hurting developers. You can’t release a new component while it’s performing worse than the previous one, but you also don’t want to work on the old one — there was a reason you wanted to move away from it in the first place. So, you need to find out why there are slightly fewer messages being sent in a system that does not crash or give any sign of behaving wrongly.
How do you even start looking for that?
To clarify, this is not the fault of Clean Architecture. This was human error. We fixed these bugs eventually, but it took us quite a long time and definitely a lot of effort. And so we wondered: is there a better way, to avoid ending up in situations like this in the first place?
The light at the end of the tunnel…
Buzzwords like MVI (Model-View-Intent) and unidirectional dataflow are nothing new nowadays.
(If they are for you, I definitely suggest googling them as there have been a lot of really nice articles about these topics. For Android, I especially recommend Hannes Dorfmann’s 7 part series: http://hannesdorfmann.com/android/mosby3-mvi-1)
We first experimented with ideas borrowed from the webdev world in early 2017. Approaches similar to Flux / Redux turned out to be really useful for us on Android, offering ways to ease the pain of the problems we were facing.
First, having all the elements of what our state consists of (i.e. all the variables we rely on to show something on the UI and trigger different flows) stored in a single, immutable State object solves a lot of the problems. For a start, now everything is in one place and is easier to understand as a whole.
For example, if we want to represent loading some data using this approach, the State needs to hold: (payload, isLoading). Just by looking at these two fields, we can see immediately if there’s anything to display (payload) and whether to show a progress loading animation on the UI or not (isLoading).
Next, if instead of parallel executions with callbacks, we model the changes of the state of the application as a series of transactions, we can now have a single entry point where it all happens. Meet the Reducer (a concept taken from functional programming), which will take the current state, some information about what to do with it, and create a new state based on the two:
Reducer = (State, Effect) -> State
Following on the image loading example above, we could have the following Effects:
And we could write our Reducer with these rules:
- If the Effect is StartedLoading, create a new State from the old one by copying the old one and setting isLoading to true.
- If the Effect is FinishedWithSuccess, create a new State from the old one in which isLoading is set to false and the payload is set to whatever we loaded.
If we now logged the series of states that were created, we would see:
- State(payload = nothing, isLoading = false) — this is our initial state
- State(payload = nothing, isLoading = true) — after StartedLoading
- State(payload = something, isLoading = false) — after FinishedWithSuccess
Rendering these states on the UI would show the whole process, from having nothing to display at the start, to showing a loading animation, to finally showing the data we wanted.
This approach has many benefits.
First, by modifying our state in a centralised way with a series of transactions, we can get rid of race conditions and a lot of pesky, difficult-to-find bugs before we even start.
Next, just by looking at this series of transactions, it’s easier to understand what happened, why it happened, and how exactly it modified the state. Looking at our Reducer implementation, it’s also easier to reason about all the possible state transitions before we even launch the new feature for the first time on our device.
Another added benefit is that we can create a light UI. If we have our state stored in a single place (the Store), which takes Intents about how to change the state, applies changes using a Reducer and provides an observable stream of new states, we can now move all our business logic to the Store, and use our UI only to trigger Intents and render states.
Or can we?
…might be the train coming towards you
Having one Reducer to do all the work surely won’t be enough. What about asynchronous tasks with multiple possible results to begin with? What about reacting to server pushes? What about triggering additional jobs after a certain state change has occurred, like clearing a cache or reloading something from the database? Either we leave this logic out of the Reducer (which is like saying that half of the business logic won’t be encapsulated, and everyone else needs to take care of the wiring if they want to use our component), or we pollute the Reducer with multiple concerns.
Expectations of a good MVI framework
We definitely wanted to encapsulate all the business logic related to a business feature in one single component that requires no extra wiring, and that is as easy for other teams to use as injecting an instance of it somewhere and subscribing to its state.
- It should be able to communicate easily with other components in the system if needed.
- It should have an internal structure that separates concerns nicely.
- It should be deterministic in all of its internal invocations.
- It should be simple to implement such a component, getting more complex only with optionally pluggable parts if there’s a need for them.
The road from having a single Reducer to the solution we use today was not a straightforward one. The different teams were facing different problems that required different approaches, and coming up with a single solution that fits everyone did not always seem possible.
And yet, today we are happy to say that everyone seems to like what we have now. So without further ado, please welcome MVICore.
The library is open-source and is available on GitHub: https://github.com/badoo/MVICore
What MVICore offers you
- An easy way to implement your business features in a reactive way with unidirectional dataflow
- Scaling with complexity: operate with a single Reducer if needed, with the option of having the full power of additional components to handle more complex cases
- A solution to handling events that you don’t want to store in the state (AKA the SingleLiveEvent problem)
- A simple API to bind your features (and all other reactive endpoints in your system) to the UI, to each other, or to any other reactive component with lifecycle handling
- Custom middlewares for every single component in the system
- An out-of-the-box logger and time travel debugger for every single component in the system
A quick introduction to Features
As the GitHub repo has proper step by step tutorials, I’m skipping detailed code examples here, but would like to introduce some of the base concepts and components.
A Feature is a central piece of the framework, meant to encapsulate the whole business logic of a component. It has three type parameters in its signature:
interface Feature<Wish, State, News>
Wish is the Intent in Model-View-Intent — that is, the way we want our Model to change (as Intent already has its own meaning in the Android ecosystem, we needed to find another name). It is the input to a Feature.
State is, well, the state of the component, as we’ve already seen. It contains every piece of information about it, and is meant to be immutable: we cannot change the values within a State, we can only create new States. It is also the output of a Feature: every time a new State is created, it will be emitted on an observable stream.
News is the event type of the component for all those signals that shouldn’t be stored in the State, but consumed only once when they happen (AKA the SingleLiveEvent problem). News are also optional (if a Feature doesn’t have any events, we can use the Kotlin Nothing type in its place).
A Feature must have a Reducer at the very least (in these cases, all inputs to the Feature, that is all Wishes, are automatically Effects that the Reducer uses to create a new State).
In addition, Features can have the following optional components:
- The Actor encapsulates asynchronous jobs and / or conditional state modification based on the current state (e.g. form validation). The Actor maps one intent to any number of Effects, which is then passed to the Reducer.
- The NewsPublisher is called every time an intent results in any Effect, which results in any new State, and it can decide based on all this information whether or not News (the event type) should be emitted.
- The PostProcessor is also called after a new State is created, and it also knows what intent and what intermediate Effect resulted in this State. Based on this information it can trigger additional Actions to execute. Actions are “internal intents” (like clearing the cache, for example) which cannot be triggered from the outside (as is the case with Wishes), and are executed in the Actor, resulting in a new chain of Effects and States.
- The Bootstrapper is a component which can trigger Actions by itself. Its main usages are initialising the Feature and / or listening to external sources and mapping them to internal intents. These external sources can be News emitted by another Feature, for example, or server pushes that need to modify the State without user intervention.
It can be as simple in its internal workings as this:
Or as complex with all the pluggable parts mentioned above as this:
All the while the Feature itself, encapsulating all this business logic, appears as simple as this, ready to be used as a building block:
We’ve seen so far how the Feature, the centerpiece of the framework, works on a conceptual level. But the library offers so much more:
- Since all* the internal components of the Feature are deterministic mappers, all of them can be wrapped in any kind of middleware — and the library comes with out-of-the-box implementations for logging and time-travel debugger middlewares! (* The only exception is the Actor, which is not fully deterministic since it talks to external data sources — but even in this case, at least the execution path it chooses is only determined by the input it is invoked with, with no external conditions)
- These middlewares are applicable not only to your Features, but to any other objects which implement the generic Consumer<T> interface, making it the ultimate debugging tool.
- There’s a DebugDrawer module to control the time-travel debugger, which you can easily add to your project.
- The library comes with an IDEA plugin you can use to add file templates of some of the most common Feature implementations, saving you a lot of time not having to write those skeletons.
- There are Android helper classes for state management and automatic lifecycle handling, but the library itself is not tied to Android.
- There’s an out-of-the-box solution for binding your components to the UI or to one another, via a super easy-to-use API.
In the next article, we’ll focus on the latter. You can check it out here:
In the meantime, we hope you’ll give the library a try, and that you’ll have just as much fun using it as we had making it!
You can also follow me on Twitter.