Part II. Handy abstractions to mix in and code organization
This is the second part of the ‘MVI with state-machine’ series that describes some handy tools and abstractions to organize your code. Check the other parts of the series for basic steps and multi-module app implementation:
The source code for the article and the very basic core library is available on GitHub. The library is totally optional — just in case you want to save some time on not writing your own core.
The code snippets in this article are based on the login module of the advanced example — ‘Welcome app’. We will look into the complete app structure in the part III of the series.
In the basic example from the previous article all the work was done by the machine state objects. They did:
– running a “network operation”
– view-state data rendering
– next state creation
That is a quite a big responsibility which might not be so good in terms of coupling and testing. Let’s introduce some abstractions that will lift the burden off the state’s shoulders.
By use-case I assume any business logic external to your view logic implemented in a state. Be it some network operation or some other “use-case” — provide it to your state and use them as you like. There is nothing new here — I’m sure you already use the approach in your flavor of Clean Architecture or similar. Example of using an external use-case could be found in welcome example:
Preparing the complex ui-state from your state data might be a non-trivial task in applications with complex interface. Moving a coupling to the view-state and data structures away from your state logic might be a good idea. Testing the exact view-state creation would be much easier if you make it
as more or less a clean function.
Another point is your machine states may share the same rendering logic so externalizing the renderer would play greatly in terms of code reuse. For example the same view-state rendering is used by
ErrorState of the welcome example. You could inject your renderer in a state factory or get it from common context (see below).
Data passing and dependency provision
Creating new states explicitly to pass them to the state-machine later (like in the basic example) is not a generally a good idea in terms of coupling and dependency provision.
The machine state, when created, may require three main classes of dependencies:
- Inter-state data eg data loaded in a previous state, common data state, etc. Inter-state data varies greatly from transition to transition and couldn’t be provided once-and-for-all most of the time.
- State-specific dependencies like use-cases the state operates. Could be provided once per state-machine assembly instance (statically).
- Common dependencies for all states in machine: renderers, resource providers, factories. Could also be provided statically.
You are free to choose the way to provide dependencies however let’s take a look at the approach that I’ve come to and which plays well both in dependency provision and testing/mocking.
By inter-state data I assume any dynamic data that is passed between states. It may be a product of some calculation, user-generated data, etc. To keep our state-API clean and to promote immutability let’s pass the inter-state data to the state constructor:
To provide dependencies that are specific to each particular state class I suggest using dedicated state factories that are injected with your DI framework. Let’s take the use-case example above and extend it with a state-factory:
Dependencies common to all states of a state-machine
Common dependencies may include renderers, state factories, common external interfaces and anything else that is required by all states that make up the state-machine. For convenience and to save the number of constructor parameters I suggest binding them to some common interface and provide it as a whole. Let’s name it a common
You could provide the context to your state through the constructor parameters. To make things even easier let’s make some common base state for the state-machine assembly and use a delegation to provide each of the context dependencies:
Thus every sub-class of the
LoginState has any context dependency at hand by getting it from the corresponding property as if they were provided explicitly:
Common state factory
As I’ve already mentioned, creating new states explicitly to pass them to the state-machine (like in the basic example) is not a good idea in terms of coupling and dependency provision.
Let’s move it away from our machine states by introducing a common factory interface that will take the responsibility to provide dependencies and abstract our state creation logic:
Each factory method here will accept only the dynamic inter-state data. Dependencies static for the state-machine instance (context and state-specific dependencies) will be provided implicitly. This will decouple state logic from the concrete implementations, reduce coupling, and increase our testability greatly.
The exact factory implementation that binds together all data and dependencies may look like that:
The factory is made available to your machine states through the common context effectively decoupling your states from the others:
We could mock the factory in our tests and check state transitions thoroughly:
We can also provide the state factory to the
ViewModel and use it to initialize the state-machine:
View lifecycle management with
Imagine we have a resource-consuming operation, like location tracking, running in our state. It may save the client’s resources if we choose to track tracking when the view is inactive – app goes to the background or the Android activity is paused. In that case, I suggest creating special gestures and pass them to state-machine as soon asthe lifecycle state changes. For example, the
FlowStateMachinethat we have used in Part I, exports the
uiStateSubscriptionCount property that is a flow of number of subscribers listening to the
uiState property. If you use some
repeatOnLifecycle or similar functions to subscribe to
uiState, you could use this property to create your special processing of lifecycle events. to recap,
repeatOnLifecycle stops collecting the flow when view lifecycle is paused and resumes it when resumed. For convenience, there is a
mapUiSubscriptions extension function available to reduce boilerplate. It accepts two gesture-producing functions and updates the state-machine with them when the subscriber’s state changes:
Tools described in this article could help you to provide dependencies, to decouple your machine-states from one another, and improve testability. The patterns provided here are just a suggestion and illustrate one possible approach to organizing your code. As I’ve already stated in Part I of the series — the architecture aims to be as minimal and non-opinionated as possible, so you could choose the way to handle your app structure the way you like. Interesting, the code structuring patterns and tools described in this part work great for me and help to organize complex multi-screen flows.
A common practice these days is to split your application into independent library modules. Let’s get to Part III to learn how we could go multi-module and multi-platform with the state-machine.