Blog Infos
Author
Published
Topics
Published
Topics

Part II. Handy abstractions to mix in and code organization

 

Learning strait“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
}
view raw LoginContext.kt hosted with ❤ by GitHub

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)
}
}
view raw LoginState.kt hosted with ❤ by GitHub

Base state with context

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

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

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
This article is a follow-up to one I published in April 2019. You can…
READ MORE
blog
This is the first article of the ‘MVI with state-machine’ series that describes the…
READ MORE
blog
This is the third part of the ‘MVI with state-machine’ series that describes how…
READ MORE
blog
In this story, I’ll talk about why I believe we can strongly improve the…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu