Part II. Handy abstractions to mix in and code organization
“Learning strait” to adopt the state-machine MVI architecture by Ulyana Kotchetkova
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.
Handy abstractions
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.
Use-cases
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 flavour of Clean Architecture or similar. Example of using an external use-case could be found in welcome example:
class CredentialsCheckState(private val checkCredentials: CheckCredentials) { | |
// State logic | |
override fun doStart() { | |
stateScope.launch { | |
// Runs use-case | |
val valid = checkCredentials() | |
} | |
} | |
} |
Injecting a use-case to the machine state
UI-state renderer
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.
internal class LoginRenderer { | |
/** | |
* Renders password form | |
*/ | |
fun renderPassword( | |
data: LoginDataState, | |
isValid: Boolean | |
): LoginUiState.PasswordEntry = LoginUiState.PasswordEntry( | |
data.email, | |
data.password.orEmpty(), | |
isValid | |
) | |
} |
UI-state renderer
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 PasswordEntryState and
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 e.g. 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.
Inter-state data
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:
class CredentialsCheckState(data: LoginDataState): CoroutineState<LoginGesture, LoginUiState>() { | |
/** | |
* Should have valid email at this point passed with inter-state data | |
*/ | |
private val email = requireNotNull(data.commonData.email) { | |
"Email is not provided" | |
} | |
/** | |
* Should have valid password at this point passed with inter-state data | |
*/ | |
private val password = requireNotNull(data.password) { | |
"Password is not provided" | |
} | |
} |
Inter-state data passing
State-specific dependencies
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:
class CredentialsCheckState( | |
data: LoginDataState, | |
private val checkCredentials: CheckCredentials | |
) : CoroutineState<LoginGesture, LoginUiState>() { | |
// State logic | |
/** | |
* Dedicated state factory | |
*/ | |
@LoginScope | |
class Factory @Inject constructor(private val checkCredentials: CheckCredentials) { | |
operator fun invoke(): CoroutineState<LoginGesture, LoginUiState>() = CredentialsCheckState( | |
checkCredentials | |
) | |
} | |
} |
Dedicated 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 Context
:
interface LoginContext { | |
/** | |
* Common state factory (see below) | |
*/ | |
val factory: LoginStateFactory | |
/** | |
* External interface | |
*/ | |
val host: WelcomeFeatureHost | |
/** | |
* UI-state renderer | |
*/ | |
val renderer: LoginRenderer | |
} |
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:
abstract class LoginState( | |
context: LoginContext | |
): CoroutineState<LoginGesture, LoginUiState>(), LoginContext by context { | |
override fun doProcess(gesture: LoginGesture) { | |
Timber.w("Unsupported gesture: %s", gesture) | |
} | |
} |
Base state with context
Job Offers
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:
class CredentialsCheckState( | |
context: LoginContext, | |
private val data: LoginDataState, | |
private val checkCredentials: CheckCredentials | |
) : LoginState(context) { | |
override fun doStart() { | |
// Use a context-provided dependency | |
setUiState(renderer.renderLoading(data)) | |
} | |
/** | |
* Factory updated to pass common context | |
*/ | |
@LoginScope | |
class Factory @Inject constructor(private val checkCredentials: CheckCredentials) { | |
operator fun invoke( | |
context: LoginContext, | |
data: LoginDataState | |
): LoginState = CredentialsCheckState( | |
context, | |
data, | |
checkCredentials | |
) | |
} | |
} |
All dependencies provided
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:
interface LoginStateFactory { | |
/** | |
* Creates a state to handle existing user's password entry | |
* @param data Login data state | |
*/ | |
fun passwordEntry(data: LoginDataState): LoginState | |
/** | |
* Creates a state to check email/password | |
* @param data Data state | |
*/ | |
fun checking(data: LoginDataState): LoginState | |
/** | |
* Creates a state for password error screen | |
*/ | |
fun error(data: LoginDataState, error: Throwable): LoginState | |
} |
Common state factory interface
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:
@LoginScope | |
class LoginStateFactoryImpl @Inject constructor( | |
host: WelcomeFeatureHost, // External interface | |
renderer: LoginRenderer, // Renderer | |
private val createCredentialsCheck: CredentialsCheckState.Factory // Concrete state factory | |
) : LoginStateFactory { | |
// Dependencies common for each state provided through the context | |
private val context: LoginContext = object : LoginContext { | |
override val factory: LoginStateFactory = this@Impl | |
override val host: WelcomeFeatureHost = host | |
override val renderer: LoginRenderer = renderer | |
} | |
override fun passwordEntry(data: LoginDataState): LoginState { | |
// Create explicitly | |
return PasswordEntryState(context, data) | |
} | |
override fun checking(data: LoginDataState): LoginState { | |
// Use provided state-factory | |
return createCredentialsCheck(context, data) | |
} | |
override fun error(data: LoginDataState, error: Throwable): LoginState { | |
// Create explicitly | |
return ErrorState(context, data, error) | |
} | |
} |
Login state factory implementation
The factory is made available to your machine states through the common context effectively decoupling your states from the others:
class CredentialsCheckState(context: LoginContext) : LoginState(context) { | |
// State logic... | |
/** | |
* A part of [process] template to process UI gesture | |
*/ | |
override fun doProcess(gesture: LoginGesture) = when(gesture) { | |
LoginGesture.Back -> onBack() | |
else -> super.doProcess(gesture) | |
} | |
private fun onBack() { | |
// Use provided factory to create a new state | |
setMachineState(factory.passwordEntry(data)) | |
} | |
} |
Using factory to create another state
We could mock the factory in our tests and check state transitions thoroughly:
class CredentialsCheckStateTest { | |
private val data = LoginDataState() | |
private val factory: LoginStateFactory = mockk() | |
private val passwordEntry: LoginState = mockk() | |
@Test | |
fun returnsToPasswordEntryOnBack() = runTest { | |
every { factory.passwordEntry(any()) } returns passwordEntry | |
state.start(stateMachine) | |
state.process(LoginGesture.Back) | |
verify { stateMachine.setMachineState(passwordEntry) } | |
verify { factory.passwordEntry(data) } | |
} | |
} |
Mocking a state factory
We can also provide the state factory to the ViewModel
and use it to initialize the state-machine:
@HiltViewModel | |
class LoginViewModel @Inject constructor(private val factory: LoginStateFactory) : ViewModel() { | |
/** | |
* Creates initializing state | |
*/ | |
private fun initializeStateMachine(): CommonMachineState<WelcomeGesture, WelcomeUiState> { | |
// Obtain data required to start from a saved-state handle or injection | |
val commonData = LoginDataState() | |
return factory.passwordEntry(commonData) | |
} | |
/** | |
* State machine | |
*/ | |
private val stateMachine = FlowStateMachine(::initializeStateMachine) | |
} |
Initializing a state-machine with the state factory
View lifecycle management with FlowStateMachine
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 pause 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 FlowStateMachine, that 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:
class WithIdleViewModel : ViewModel() { | |
/** | |
* Creates initial state for state-machine | |
* You could process a deep-link here or restore from a saved state | |
*/ | |
private fun initStateMachine(): CommonMachineState<SomeGesture, SomeUiState> = InitialState() | |
/** | |
* State-machine instance | |
*/ | |
private val stateMachine = FlowStateMachine(::initStateMachine) | |
/** | |
* UI State | |
*/ | |
val state: SharedFlow<SomeUiState> = stateMachine.uiState | |
init { | |
// Subscribes to active subscribers count and updates state machine with corresponding gestures | |
stateMachine.mapUiSubscriptions( | |
viewModelScope, | |
onActive = { SomeGesture.OnActive }, | |
onInactive = { SomeGesture.OnInactive } | |
) | |
} | |
} |
Conclusion
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. Nevertheless, 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.
This article was originally published on proandroiddev.com on August 19, 2022