A simple MVI framework for Jetpack Compose

A simple MVI framework for Jetpack Compose

A simple way to manage state in a Jetpack Compose application

Choosing the right architecture is critical because architectural changes can be costly later on. MVP has been replaced by MVVM and MVI, with MVI being more popular. MVI enforces a structured approach to state management by only updating state in the reducer and handling all intents through the pipeline. In contrast, MVVM provides more freedom in where the state is updated, but the process is more difficult to understand and debug. State management is critical to application development and scaling.

Introduction to MVI

There are three architectural components in MVI:

  • Model: Represents the state of an application or a specific screen and the business logic that generates that state.
  • View: UI that represents user interaction.
  • Intent: These are actions that trigger a new model, either from the user or externally.
    It should be noted that state is immutable in MVI and MVI follows one-way data flow. This can be represented by the following diagram:

The basic process is as follows:

Generate an initial model and push it to the view for rendering.
An action (i.e. intent) is triggered by the user or external factors (such as network load completion).
Intents are processed in business logic, generate a new model, and publish it to the view.
The cycle repeats infinitely. This architecture provides a clear separation of responsibilities: views are responsible for rendering the UI, intents are responsible for hosting operations, and models are responsible for business logic.

Create basic scaffolding for the MVI framework

We will start by creating basic scaffolding for the MVI framework. The solution described here is for Android and tailored specifically for the Jetpack Compose app, but the principles can be applied to any mobile app or other type of app.

We will build the model based on Android's ViewModel as this solution integrates well with the Android framework and is lifecycle aware, but it is important to note that this is not required and other solutions are equally possible.

In order to create scaffolding we need the following components:

  • An immutable state (model) for the view to observe.
  • A pipe to push the intent (I'll call it an action to avoid confusion with Android's Intent).
  • A reducer that generates new state based on existing state and current operations.
    Since this solution is designed for Jetpack Compose, we will use MutableState as the model. For the pipeline, we will use MutableSharedFlow to feed the reducer. Although not required, I also like to define marker interfaces for states and actions. Let’s look at the code of the MVI scaffolding:
// 1
interface State

// 2
interface Action

// 3
interface Reducer<S : State, A : Action> {
    
    
    /**
     * Generates a new instance of the [State] based on the [Action]
     *
     * @param state the current [State]
     * @param action the [Action] to reduce the [State] with
     * @return the reduced [State]
     */
    fun reduce(state: S, action: A): S
}

private const val BufferSize = 64

// 4
open class MviViewModel<S : State, A : Action>(
    private val reducer: Reducer<S, A>,
    initialState: S,
) : ViewModel() {
    
    

    // 5
    private val actions = MutableSharedFlow<A>(extraBufferCapacity = BufferSize)

    // 6
    var state: S by mutableStateOf(initialState)
        private set

    init {
    
    
        // 7
        viewModelScope.launch {
    
    
            actions.collect {
    
     action ->
                state = reducer.reduce(state, action)
            }
        }
    }

    // 8
    fun dispatch(action: A) {
    
    
        val success = actions.tryEmit(action)
        if (!success) error("MVI action buffer overflow")
    }
}
  1. We define a marker interface for state.

  2. We do the same for operations.

  3. The reducer will be responsible for updating the state, it has a single function that takes the current state and action and generates a new state. It should be noted that the reducer's reduce method must be a pure function - the generated state can only depend on the input state and operation, and the state must be generated synchronously.

  4. MviViewModel is the basis of the MVI framework. MviViewModel receives a reducer and initial state to start the MVI process.

  5. For the pipeline of operations, we use MutableSharedFlow and set a specific buffer capacity.

  6. The state is saved in a MutableState object and exposed as a read-only property. It is initialized to the provided initial state in the MviViewModel constructor.

  7. When the ViewModel starts, we start a coroutine to collect operations from MutableSharedFlow, and each time an operation is emitted, we run the reducer and update the state accordingly. Note that for this simple example I am using viewModelScope as the coroutine scope, but it is recommended to provide a dedicated scope for better testability. The full example linked at the end of this article shows how.

  8. Finally, we need a way to push operations into the pipeline, we do this using the dispatch method, which takes an operation and pushes it into MutableSharedFlow. If the buffer is full, this may indicate some kind of problem, so we choose to throw an exception in this case.

With this scaffolding in hand, we can now create a simple application. We will create a sample application with a typical architecture of your choice, a counter with two buttons, one for incrementing the count and one for decrementing the count.

Basic sample application
For our sample application, we need the following components:

  • State
  • Actions
  • Reducer
  • ViewModel
  • UI

Let's start by defining our state. For this very simple example, our state only needs to hold one property, the current count value:

// 1
data class CounterState(
    // 2
    val counter: Int,
    // 3
) : State {
    
    
    companion object {
    
    
        // 4
        val initial: CounterState = CounterState(
            counter = 0,
        )
    }
}
  1. We use a data class for the state so that we can make use of generated functions such as the copy function which we will use to create a new state from an existing state.

  2. Our state has only one property, the value of the counter.

  3. This state inherits our marker interface.

  4. Finally, we provide a default value to use as a starting point.

Next, we'll define the action. For this example, we only have two operations, one to increment the state and one to decrement it:

// 1
sealed interface CounterAction : Action {
    
    
    // 2
    data object Increment : CounterAction
    // 3
    data object Decrement : CounterAction
}
  1. We use a sealed interface for counter operations, which inherits from our marker interface.

  2. The operation we need does not carry any payload, so we create a data object for the increment operation. In most applications, when a payload is required, we use both data objects and data classes.

  3. We do the same thing with reducing operations.

Now that we have the state and action, we can build our reducer, which is responsible for generating a new state based on the current state and action. Let's take a look at the code:

// 1
class CounterReducer : Reducer<CounterState, CounterAction> {
    
    
    // 2
    override fun reduce(state: CounterState, action: CounterAction): CounterState {
    
    
        // 3
        return when (action) {
    
    
            CounterAction.Decrement -> state.copy(counter = state.counter - 1)
            CounterAction.Increment -> state.copy(counter = state.counter + 1)
        }
    }
}
  1. CounterReducer implements the Reducer interface.

  2. We rewrote the reduce function, which is responsible for generating state.

  3. We have a comprehensive when operation on the operations, and in each operation, a new state is generated based on the current state.

We only have two parts left, ViewModel and UI. Let's first create our viewModel:

class CounterViewModel(
    // 1
    reducer: CounterReducer = CounterReducer(),
    // 2
) : MviViewModel<CounterState, CounterAction>(
    reducer = reducer,
    initialState = CounterState.initial,
) {
    
    
    // 3
    fun onDecrement() {
    
    
        dispatch(CounterAction.Decrement)
    }

    // 4
    fun onIncrement() {
    
    
        dispatch(CounterAction.Increment)
    }
}
  1. CounterviewModelReceived as CounterReducerconstructor parameter. In this example we instantiate the reducer in the constructor, but in a real application we would use dependency injection.

  2. CounterviewModelInherited from our base MviViewModel, provides reducer and initial state.

  3. We define a onDecrementmethod called which will push reduce operations to the MVI pipeline.

  4. We also handle the addition operation in the same way and define corresponding onIncrementmethods.

All we're left with is the UI, which I'll cover briefly because the details of how we actually present state into the UI don't really matter when it comes to the MVI framework. Here's a simple UI showing a counter and two buttons to increment/decrease it:

@Composable
fun CounterScreen(
    viewModel: CounterViewModel,
    modifier: Modifier = Modifier,
) {
    
    
    CounterScreen(
        state = viewModel.state,
        onDecreaseClick = viewModel::onDecrement,
        onIncreaseClick = viewModel::onIncrement,
        modifier = modifier,
    )
}

@Composable
fun CounterScreen(
    state: CounterState,
    onDecreaseClick: () -> Unit,
    onIncreaseClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    
    
    Row(
        modifier = modifier,
        verticalAlignment = Alignment.CenterVertically,
    ) {
    
    
        IconButton(onClick = onDecreaseClick) {
    
    
            Icon(
                imageVector = Icons.Default.RemoveCircleOutline,
                contentDescription = "decrement"
            )
        }
        Text(
            text = state.counter.toString(),
            style = MaterialTheme.typography.displaySmall,
            modifier = Modifier.padding(horizontal = 16.dp),
        )
        IconButton(onClick = onIncreaseClick) {
    
    
            Icon(
                imageVector = Icons.Default.AddCircleOutline,
                contentDescription = "increment"
            )
        }
    }
}

With this basic MVI framework and a sample application, we're set up. But our solution lacks the ability to handle asynchronous (or long-running) operations because our reducer is updating the state synchronously. Next, we'll see how to enhance our MVI framework to support asynchronous work.

Handle asynchronous work

To handle asynchronous work in MVI framework, we will add a new concept, Middleware. Middleware is a component that is inserted into the MVI pipeline and can perform operations asynchronously. Middleware typically emits its own operations at the beginning, during and end of its work (for example, if we have an operation that requires a network call, the Middleware may emit an operation to indicate that network loading has started, and may emit other operations to Updates the progress indicator on the network load and emits a final load complete action when the network load is complete).

Like other components, we will create a base class for Middleware:

// 1
interface Dispatcher<A : Action> {
    
    
    fun dispatch(action: A)
}

// 2
abstract class Middleware<S : State, A : Action>() {
    
    
    // 3
    private lateinit var dispatcher: Dispatcher<A>
  
    // 4
    abstract suspend fun process(state: S, action: A)

    // 5
    protected fun dispatch(action: A) = dispatcher.dispatch(action)

    // 6
    internal fun setDispatcher(
        dispatcher: Dispatcher<A>,
    ) {
    
    
        this.dispatcher = dispatcher
    }
}
  1. Middleware needs to distribute its own Actions, so we define a Dispatcher interface (we will see how to use it later).

  2. Middleware classes are parameterized on State and Action, similar to reducers.

  3. The Middleware will receive the Dispacher to push operations to the MVI pipeline.

  4. The pending process method is where the asynchronous work will take place.

  5. This is a practical way to push operations to the MVI framework so that we can keep the Dispatcher private.

  6. Finally, we have a method for initializing the Dispatcher used in Middleware.

Next, let's see how to update our MviViewModel to insert Middleware in the MVI process:

open class MviViewModel<S : State, A : Action>(
    private val reducer: Reducer<S, A>,
    // 1
    private val middlewares: List<Middleware<S, A>> = emptyList(),
    initialState: S,
) : ViewModel(), Dispatcher<A> {
    
    

    // 2
    private data class ActionImpl<S : State, A : Action>(
        val state: S,
        val action: A,
    )

    private val actions = MutableSharedFlow<ActionImpl<S, A>>(extraBufferCapacity = BufferSize)

    var state: S by mutableStateOf(initialState)
        private set

    init {
    
    
        // 3
        middlewares.forEach {
    
     middleware -> middleware.setDispatcher(this) }
        // 4
        viewModelScope.launch {
    
    
            actions
                .onEach {
    
     actionImpl ->
                    // 5
                    middlewares.forEach {
    
     middleware ->
                        // 6
                        middleware.process(actionImpl.state, actionImpl.action)
                    }
                }
                .collect()
        }
        viewModelScope.launch {
    
    
            actions.collect {
    
    
                state = reducer.reduce(state, it.action)
            }
        }
    }

    override fun dispatch(action: A) {
    
    
        val success = actions.tryEmit(action)
        if (!success) error("MVI action buffer overflow")
    }
}
  1. Now, MviViewModel receives a list of middlewares, which defaults to an empty list since not all screens have async work. ViewModel also implements the Dispatcher interface.

  2. We define a wrapper class that wraps the current state and operations, which we push onto the pipe so that we have a copy of the state when we receive the operations.

  3. In the init block, we loop through the middleware and set the scheduler for each one, which is the ViewModel itself.

  4. Next, we start a coroutine to observe the emission of Action and State of MutableSharedFlow.

  5. For each emission, we loop through all middleware.

  6. For each middleware, we call its process method to handle the operation.

The idea of ​​this approach is that we will have a set of middleware, each middleware is responsible for a part of the business logic of the application; each middleware will observe the operations coming from the MVI pipeline, and when the operation it is responsible for is emitted, it will Start an asynchronous operation. In a large application, we can split the screen into parts that are handled by independent middleware, or we can separate middleware based on the business logic they perform. The idea is to have small, granular middleware that each performs only one or a small set of operations, rather than one big middleware that handles all the asynchronous work.

Update counter application

The MVI framework is already complete with middleware and updated MviViewModel, but it will be easier to understand with an example, so we will add a button to the counter screen that will generate a random value for the counter. We assume that generating this random value is a long-running process that needs to run on a background thread, so we will create a middleware for this operation. Since this is a long-running operation, we will display a progress indicator while the work is being performed.

We'll first update the counter state to include the loading indicator:

data class CounterState(
    // 1
    val loading: Boolean,
    val counter: Int,
) : State {
    
    
    companion object {
    
    
        val initial: CounterState = CounterState(
            // 2
            loading = false,
            counter = 0,
        )
    }
}
  1. We added a loading flag to the state.
  2. and updates the initial value to set that flag to false.
    Next, we need a new operation to generate random counter values, so we add it to the enclosing hierarchy. Likewise, we need to update the state when the number is ready, so we need another action to trigger the update. For this second operation we have a payload which is a randomly generated counter value so we will use a data class. Finally, we want to show a loading indicator while the background task is running, so we'll add a third action to show a progress indicator:
sealed interface CounterAction : Action {
    
    
    data object Loading : CounterAction
    data object Increment : CounterAction
    data object Decrement : CounterAction
    data object GenerateRandom : CounterAction
    data class CounterUpdated(val value: Int) : CounterAction
}

Next, we will create the middleware responsible for generating random numbers. We will issue a load operation when we start and CounterUpdatedan operation when we finish. We will use delays to simulate long operations:

// 1
class CounterMiddleware : Middleware<CounterState, CounterAction>() {
    
    

    // 2
    override suspend fun process(state: CounterState, action: CounterAction) {
    
    
        // 3
        when (action) {
    
    
            CounterAction.GenerateRandom -> generateRandom()
            else -> {
    
     /* no-op */ }
        }
    }

    private suspend fun generateRandom() {
    
    
        // 4
        dispatch(CounterAction.Loading)
        // 5
        delay(500L + Random.nextLong(2000L))
        val counterValue = Random.nextInt(100)
        // 6
        dispatch(CounterAction.CounterUpdated(counterValue))
    }
}
  1. CounterMiddleware extends our middleware base class.
  2. We overridden the process method that is responsible for asynchronous work.
  3. We check the operations and only handle the GenerateRandom operation.
  4. When we receive the correct operation, we issue the Loading operation, which triggers a status update to show the progress indicator.
  5. Next, we get to work, simulating here with a delay operation.
  6. And when the work is completed, we emit the result through a new operation.
    That's what CounterMiddleware is all about. Next, we need to update the reducer to handle the additional operations we defined earlier. The reducer doesn't have to handle all the operations, the GenerateRandom operation is only handled at the middleware, so it won't do anything. Let's take a look at the code:
class CounterReducer : Reducer<CounterState, CounterAction> {
    
    

    override fun reduce(state: CounterState, action: CounterAction): CounterState {
    
    
        return when (action) {
    
    
            // 1
            CounterAction.Loading -> state.copy(loading = true)
            CounterAction.Decrement -> state.copy(counter = state.counter - 1)
            CounterAction.Increment -> state.copy(counter = state.counter + 1)
            // 2
            is CounterAction.CounterUpdated -> state.copy(
                loading = false,
                counter = action.value,
            )
            // 3
            CounterAction.GenerateRandom -> state
        }
    }
}
  1. When an operation is received Loading, the status is updated to indicate the long-running operation in progress.
  2. When an operation is received CounterUpdated, we clear the load flag and update the counter value with the operation payload.
  3. GenerateRandomis not reducerprocessed in, so the existing state is returned.
    Next, we need to update viewmodel, provide the middleware to the base class, and add a new method to handle generating random numbers. Let's take a look at the updates:
class CounterViewModel(
    // 1
    middleware: CounterMiddleware = CounterMiddleware(),
    reducer: CounterReducer = CounterReducer(),
) : MviViewModel<CounterState, CounterAction>(
    reducer = reducer,
    // 2
    middlewares = listOf(middleware),
    initialState = CounterState.initial,
) {
    
    
    fun onDecrement() {
    
    
        dispatch(CounterAction.Decrement)
    }

    fun onIncrement() {
    
    
        dispatch(CounterAction.Increment)
    }

    // 3
    fun onGenerateRandom() {
    
    
        dispatch(CounterAction.GenerateRandom)
    }
}
  1. We provide CounterMiddleware in the constructor. Like the reducer, this is usually injected, but for simplicity we instantiate it here.
  2. We provide the middleware (in our case just one) to the base class to insert into the MVI stream.
  3. Finally, we have a new method for handling generating random counter values.
    This basically ends the example. The final step is to update the UI to provide a trigger to generate random numbers and display a progress indicator while the application is busy with a long-running operation. The following code shows a possible implementation:
@Composable
fun CounterScreen(
    state: CounterState,
    onDecreaseClick: () -> Unit,
    onIncreaseClick: () -> Unit,
    onGenerateRandom: () -> Unit,
    modifier: Modifier = Modifier,
) {
    
    
    Box(
        contentAlignment = Alignment.Center,
        modifier = modifier,
    ) {
    
    
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
    
    
            Row(
                verticalAlignment = Alignment.CenterVertically,
            ) {
    
    
                IconButton(
                    onClick = onDecreaseClick,
                    enabled = !state.loading,
                ) {
    
    
                    Icon(
                        imageVector = Icons.Default.RemoveCircleOutline,
                        contentDescription = "decrement"
                    )
                }
                Text(
                    text = state.counter.toString(),
                    style = MaterialTheme.typography.displaySmall,
                    modifier = Modifier.padding(horizontal = 16.dp),
                )
                IconButton(
                    onClick = onIncreaseClick,
                    enabled = !state.loading,
                ) {
    
    
                    Icon(
                        imageVector = Icons.Default.AddCircleOutline,
                        contentDescription = "increment"
                    )
                }
            }
            Button(
                onClick = onGenerateRandom,
                enabled = !state.loading,
                modifier = Modifier.padding(top = 16.dp),
            ) {
    
    
                Text(
                    text = "Generate Random"
                )
            }
        }
        if (state.loading) {
    
    
            CircularProgressIndicator()
        }
    }
}

The following is a sample code of the MVI architecture for reference for interested readers.

GitHub

https://github.com/fvilarino/Weather-Sample

おすすめ

転載: blog.csdn.net/u011897062/article/details/133162324