All the code for this article is in compose_Architecture, and you can take what you need
In the last article, we explained how to use the MVVM and MVI architectures in compose, and finally solved the multi-page communication problem in the end. This article will focus on the implementation of the Redux architecture in Compose, but since the MVI implementation in the last article was a little less “elegant”, The conversion between Flow and LiveData is not fully exploited, so let’s implement the previous MVI in an elegant way before we start this article
FLow and livedata conversion implemented by MVI
The last implementation was less elegant because we defined liveData in the ViewModel layer to observe state and flow in response to action, and then forwarded state to LiveData in the collect of the flow. In fact, there is a more convenient way to convert flow and livedata. AsLiveData can directly convert flow to livedata, and it is more suitable for mvi unidirectional flow, so we can directly look at the code
class MVIViewModel : ViewModel() { val userIntent = Channel<UiAction>(Channel.UNLIMITED) val viewState: LiveData<ViewState> = handleAction() private fun add(num: Int): ViewState { return if (viewState.value ! = null) { viewState.value!! .copy(viewState.value!! .count + num) } else { ViewState() } } private fun reduce(num: Int): ViewState { return if (viewState.value ! = null) { viewState.value!! .copy(viewState.value!! .count - num) } else { ViewState() } } private fun handleAction() = userIntent.consumeAsFlow().map { when (it) { is UiAction.AddAction -> add(it.num) is UiAction.ReduceAction -> reduce(it.num) } }.asLiveData() data class ViewState(val count: Int = 1) sealed class UiAction { class AddAction(val num: Int) : UiAction() class ReduceAction(val num: Int) : UiAction() } }Copy the code
It can be seen that the MVI implemented in this way is more in line with unidirectional flow. The user sends an action and generates the corresponding state according to the action. The view observes the change of state and renders the new page.
All right, let’s get to the subject of this post, Redux
Redux architecture
The Redux architecture may be unfamiliar to Android developers, but it should be fairly familiar to front-end developers. However, it is not very mysterious, especially after we understand the MVI architecture, in fact, it is a variant of THE MVI architecture (or MVI architecture borrowed from the idea of Redux), like MVI architecture, Redux is also an architecture emphasizing unidirectional flow and state. As we said in the last post, mVI is a little bit inconvenient when it comes to multi-page communication, and we have solved it in other ways, but Redux’s solution is more violent. It provides a global viewModel called store, and state is also a global state, so you can communicate through the global Store. Therefore, we can treat Redux as a global MVI, but in order to isolate the logical operations of each page, reducer is used in Redux to process action and generate new state
So let’s look at how redux is implemented in compose and what are some of the concepts in redux
-
State is a state class. You can use the data class in compose, but in order to provide the initState method, you need to provide a no-argument constructor to fill in the initial value, and Koltin’s copy method makes it easy to create new states
-
Action is an action class, and only a data class is required. It is recommended to define the type of action and the data to be passed
data class CountAction(val type: CountActionType, val data: Int) {
enum class CountActionType {
Add, Reduce
}
companion object {
fun provideAddAction(data: Int): CountAction {
return CountAction(CountActionType.Add, data = data)
}
fun provideReduceAction(data: Int): CountAction {
return CountAction(CountActionType.Reduce, data = data)
}
}
}
Copy the code
3. Reduce a pure function, return the new state from the action and the current state. I define a base class here, and only need to implement the corresponding method when using it
abstract class Reducer<S, A>(val stateClass: Class<S>, val actionClass: Class<A>) {
abstract suspend fun reduce(state: S, action: A): S
}
Copy the code
When the stateClass and actionClass are stored in store, it is convenient to obtain the class type, which indicates the action and type that the Reducer needs to process. In addition, the reduce function is marked as suspend, which is a coroutine scheduler that can be easily transferred
- The store global handler class, which is responsible for distributing action and getting state and listening for state changes, is implemented using a context-based viewModel that provides getState and dispatchAction methods, Since our state is a stream implementation based on livedata, we do not need to provide a special listener method to listen for state changes, as shown in the following code
class StoreViewModel(val list: List<Reducer<Any, Any>>) : ViewModel() { private val _reducerMap = mutableMapOf<Class<*>, Channel<Any>>() private val _stateMap = mutableMapOf<Any, LiveData<Any>>() init { viewModelScope.launch { list.forEach { _reducerMap[it.actionClass] = Channel(Channel.UNLIMITED) _stateMap[it.stateClass] = _reducerMap[it.actionClass]!! .consumeAsFlow().map { action -> if (_stateMap[it.stateClass]? .value ! = null) it.reduce(_stateMap[it.stateClass]!! .value!! . action = action) else it.stateClass.newInstance() }.asLiveData() //send a message to init state _reducerMap[it.actionClass]!! .send("") } } } fun dispatch(action: Any) { viewModelScope.launch { _reducerMap[action::class.java]!! .send(action) } } suspend fun dispatchWithCoroutine(action: Any) { _reducerMap[action::class.java]!! .send(action) } fun <T> getState(stateClass: Class<T>): MutableLiveData<T> { return _stateMap[stateClass]!! as MutableLiveData<T> } }Copy the code
The flow flow receives the actions and converts them into states. At the same time, all the states are saved. The getState method obtains the corresponding state based on the state class
We also need a factoty to create the StoreViewModel that accepts the Reducer parameter as follows
class StoreViewModelFactory(val list: List<Reducer<out Any, out Any>>?) : ViewModelProvider.Factory { override fun <T : ViewModel? > create(modelClass: Class<T>): T { if (StoreViewModel::class.java.isAssignableFrom(modelClass)) { return StoreViewModel(list = list!! as List<Reducer<Any, Any>>) as T } throw RuntimeException("unknown class:" + modelClass.name) } }Copy the code
At the same time, we need to provide a quick retrieve store function, the code is as follows
The reducer state action code is as follows
@Composable
fun storeViewModel(
list: List<Reducer<out Any, out Any>>? = null,
viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalContext.current as ViewModelStoreOwner) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
}
): StoreViewModel =
viewModel(
StoreViewModel::class.java,
factory = StoreViewModelFactory(list = list),
viewModelStoreOwner = viewModelStoreOwner
)
Copy the code
When you first initialize the reducer list, you need to pass it to the Reducer list. You do not need to obtain the Reducer list later. The ViewModel is provided based on context, so you can obtain the Reducer list globally
Next we implement count Add based on redux
The following code
data class CountAction(val type: CountActionType, val data: Int) {
enum class CountActionType {
Add, Reduce
}
companion object {
fun provideAddAction(data: Int): CountAction {
return CountAction(CountActionType.Add, data = data)
}
fun provideReduceAction(data: Int): CountAction {
return CountAction(CountActionType.Reduce, data = data)
}
}
}
data class CountState(val count: Int = 1)
class CountReducer :
Reducer<CountState, CountAction>(CountState::class.java, CountAction::class.java) {
override suspend fun reduce(
countState: CountState,
action: CountAction
): CountState {
return withContext(Dispatchers.IO) {
when (action.type) {
CountAction.CountActionType.Add -> return@withContext countState.copy(count = countState.count + action.data)
CountAction.CountActionType.Reduce -> return@withContext countState.copy(count = countState.count - action.data)
}
}
}
}
Copy the code
The screen code looks like this
@Composable
fun Screen1(
navController: NavController
) {
val s = storeViewModel()
val state: CountState by s.getState(CountState::class.java)
.observeAsState(CountState(1))
Content1(count = state.count,
{ navController.navigate("screen2") }
) {
s.dispatch(CountAction.provideAddAction(1))
}
}
Copy the code
Redux strengths and weaknesses and improvements
-
Redux first used the global state, if for some pages don’t need to keep the exit when state, need clear on exit status, it can learn fish – redux to differentiate between the global and local state, so that you can solve this problem, in fact, for most applications, the local state of page is more than the global state.
-
In fact, we also found that since each reduce can only generate one state, although this is the design philosophy of Redux, there may be many inconveniences in practical application. For example, when we obtain data, we usually need to switch the view state to Loading first, refresh the data after obtaining data, and switch the view to display state. According to the processing of Redux, such a simple operation needs to send two actions to achieve, which is very inconvenient to use. Therefore, we can simply transform redux to support one reduce to send multiple states
First, we modify the Reduce function so that it no longer returns state directly, but returns flow, so that we can emit state multiple times through flow. The code is as follows
abstract class Reducer<S, A>(val stateClass: Class<S>, val actionClass: Class<A>) {
abstract fun reduce(state: S, action: A): Flow<S>
}
Copy the code
Also change the store code
class StoreViewModel(val list: List<Reducer<Any, Any>>) : ViewModel() { private val _reducerMap = mutableMapOf<Class<*>, Channel<Any>>() private val _stateMap = mutableMapOf<Any, LiveData<Any>>() init { viewModelScope.launch { list.forEach { _reducerMap[it.actionClass] = Channel(Channel.UNLIMITED) _stateMap[it.stateClass] = _reducerMap[it.actionClass]!! .consumeAsFlow().flatMapConcat { action -> if (_stateMap[it.stateClass]? .value ! = null) it.reduce(_stateMap[it.stateClass]!! .value!! , action = action) else flow { try { emit(it.stateClass.newInstance()) } catch (e: InstantiationException) { throw IllegalArgumentException("${it.stateClass} must provide zero argument constructor used to init state") } } }.asLiveData() //send a message to init state _reducerMap[it.actionClass]!! .send("") } } } fun dispatch(action: Any) { viewModelScope.launch { _reducerMap[action::class.java]!! .send(action) } } suspend fun dispatchWithCoroutine(action: Any) { _reducerMap[action::class.java]!! .send(action) } fun <T> getState(stateClass: Class<T>): MutableLiveData<T> { return _stateMap[stateClass]!! as MutableLiveData<T> } }Copy the code
By returning reduce flow from flatMapConcat, let’s see how to complete reduce
class CountReducer :
Reducer<CountState, CountAction>(CountState::class.java, CountAction::class.java) {
override fun reduce(
countState: CountState,
action: CountAction
): Flow<CountState> {
return flow {
emit(action)
}.flowOn(Dispatchers.IO).flatMapConcat { action ->
flow {
if (action.type == CountAction.CountActionType.Add)
emit(countState.copy(count = countState.count + action.data))
else
emit(countState.copy(count = countState.count - action.data))
kotlinx.coroutines.delay(1000)
emit(countState.copy(count = countState.count + 3))
}
}.flowOn(Dispatchers.IO)
}
}
Copy the code
Multiple states can be sent through the Flow emit
conclusion
Through the two explanations, we find that we can easily implement various architectures in Compose by combining jetpack component, and we also find that most architectures have the same concept. In fact, we hope that you can draw lessons from other examples and understand that architecture is for convenient development, rather than sticking to a certain architecture. We need to flexibly choose and transform the architecture according to our own project, so as to better serve our project development