preface
This paper aims to illustrate how to use Kotlin Flow to solve the pain points in Android development through actual business scenarios, and then study how to use Flow gracefully and correct some typical misunderstandings. For an introduction to Flow and its operator usage, please refer to: Asynchronous stream - Kotlin language Chinese station , this article will not repeat. The MVVM architecture based on LiveData+ViewModel has limitations in some scenarios (typical of horizontal and vertical screens). This paper will introduce the MVI architecture based on Flow/Channel suitable for Android development.
background
Energetically smart client team has deeply adapted the horizontal and vertical screen scenarios on the tablet App, and reconstructed the original MVP architecture based on Rxjava into an MVVM architecture based on LiveData+ViewModel+Kotlin collaboration. With the increasing complexity of business scenarios, LiveData, as the only carrier of data, seems to be unable to undertake this task. One of the pain points is that it blurs the boundary between "state" and "event". The sticky mechanism of LiveData will bring side effects, but this is not a design defect of LiveData in itself, but its overuse.
Kotlin Flow is a set of asynchronous data Flow framework based on kotlin coroutine, which can be used to return multiple values asynchronously. When the official version of kotlin 1.4.0 was released, StateFlow and SharedFlow were launched. Both of them have many features of the Channel, which can be regarded as an important operation to push the Flow to the front and hide the Channel behind the scenes. For new technologies and frameworks, we will not blindly access them. After a period of research and trial, we found that Flow can indeed relieve pain and improve efficiency for business development. This exploration process is shared below.
Pain point 1: poor handling of ViewModel and View layer communication
discover problems
When the screen can be rotated, LiveData is not easy to use?
When the project transitions from MVP to MVVM, one of the typical refactoring methods is to rewrite the callback writing method in the Presenter to hold LiveData in the ViewModel and subscribe to the View layer, such as the following scenarios:
In the vigorous study room, when the teacher switches to the interactive mode, the Toast prompt will pop up when the page needs to be changed, and the mode has been switched.
RoomViewModel.kt class RoomViewModel : ViewModel() { private val _modeLiveData = MutableLiveData<Int>(-1) private val modeLiveData : LiveData<Int> = _mode fun switchMode(modeSpec : Int) { _modeLiveData.postValue(modeSpec) } }
RoomActivity.kt class RoomActivity : BaseActivity() { ... override fun initObserver() { roomViewModel.modeLiveData.observe(this, Observer { updateUI() showToast(it) }) } }
At first glance, there is nothing wrong with this writing method, but it does not take into account the horizontal and vertical screen switching. If it is accompanied by the destruction and reconstruction of the page, it will lead to the re execution of observe every screen rotation of the current page, and the Toast will be played after each rotation.
LiveData ensures that subscribers can always observe the latest value when the value changes, and each observer who subscribes for the first time will execute a callback method. Such a feature has no problem in maintaining the consistency of UI and data, but it is beyond its ability to observe LiveData to launch one-time events.
Of course, there is a solution that encapsulates the SingleLiveEvent of MutableLiveData by ensuring that the same value of LiveData will only trigger the onChanged callback once. Let's not talk about whether it has other problems, but my first feeling about its magic modified packaging of LiveData is that it is not sweet, which violates the design idea of LiveData. Secondly, does it have other problems?
Is it sufficient for the communication between ViewModel and View layer to rely only on LiveData?
When using MVVM architecture, data changes drive UI updates. For the UI, you only need to care about the final state, but for some events, you don't want to discard all the events before the latest one according to the consolidation strategy of LiveData. In most cases, you want every event to be executed, and LiveData is not designed for this.
In the vigorous self-study room, the teacher will praise the students who perform well. The students who receive praise will pop up different styles of praise pop-up windows according to the praise type. In order to prevent repeated pop ups caused by horizontal and vertical screens or configuration changes, the SingleLiveEvent mentioned above is used
RoomViewModel.kt class RoomViewModel : ViewModel() { private val praiseEvent = SingleLiveEvent<Int>() fun recvPraise(praiseType : Int) { praiseEvent.postValue(praiseType) } }
RoomActivity.kt class RoomActivity : BaseActivity() { ... override fun initObserver() { roomViewModel.praiseEvent.observe(this, Observer { showPraiseDialog(it) }) } }
Considering the following situations, the teacher will praise classmate A for "sitting upright" and "active interaction". It is expected to play the praise pop-up window twice. However, according to the above implementation, if recvPraise is called continuously within A UI refresh cycle twice, that is, liveData is post ed twice continuously in A very short time, the student will only pop up the pop-up window of the second praise.
Generally speaking, the above two problems are fundamentally due to the lack of better means to deal with the communication between ViewModel and View layer, which is manifested in the extensive use of LiveData and the lack of distinction between "state" and "event"
Analyze problems
According to the above summary, LiveData is indeed suitable to represent "status", but "event" should not be represented by a single value. To make the View layer consume each event sequentially without affecting the sending of events, my first reaction is to use a blocking queue to carry events. However, we should consider the following issues when selecting models, which is also the advantage of LiveData being recommended:
- Whether there will be memory leakage, and whether the observer can clean itself after the life cycle is destroyed
- Does it support thread switching, such as LiveData, to ensure that the main thread senses changes and updates the UI
- Events will not be consumed when the observer is inactive, such as LiveData, to prevent crash caused by consumption when the Activity stops
Scheme 1: blocking queue
The ViewModel holds the blocking queue, and the View layer reads the contents of the queue in the main process loop. lifecycleObserver needs to be added manually to ensure thread suspension and recovery, and coprocessing is not supported. Consider using Channel substitution in kotlin collaboration.
Scheme 2: Kotlin Channel
The Kotlin Channel is similar to the blocking queue, except that the Channel replaces the blocking put with the pending send operation and the blocking take with the pending receive operation. Then open the soul three questions:
Will consuming channels in lifecycle components cause memory leaks?
No, because the Channel does not hold the reference of life cycle components, it is not like the use of LiveData passed into the Observer.
Do you support thread switching?
Yes, the collection of channels needs to start the collaboration process, and the collaboration context can be switched in the collaboration process, so as to realize thread switching.
Will observers consume events when they are inactive?
Using the launchWhenX method in the lifecycle runtime KTX library, the collection process of the Channel will be suspended when the component life cycle is < x, so as to avoid exceptions. You can also use repeatonlife (state) to collect data at the UI layer. When the life cycle is less than state, the collaboration process will be cancelled and restarted when it is restored.
It seems that using Channel to host events is a good choice, and generally speaking, event distribution is one-to-one, so it is not necessary to support one to many BroadcastChannel (the latter has been gradually abandoned and replaced by SharedFlow)
How to create a Channel? Take a look at the construction methods available for Channel external exposure, and consider passing in appropriate parameters.
public fun <E> Channel( // Buffer capacity. When the capacity is exceeded, the policy specified by onBufferOverflow will be triggered capacity: Int = RENDEZVOUS, // Buffer overflow policy. The default is suspend and DROP_OLDEST and DROP_LATEST onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND, // The processing element fails to be delivered successfully, such as the subscriber is cancelled or an exception is thrown onUndeliveredElement: ((E) -> Unit)? = null ): Channel<E>
First, the Channel is hot, that is, sending elements to the Channel at any time will be executed even if there are no subscribers. Therefore, considering that there is a case of sending events when the subscriber collaboration is cancelled, that is, there is a case that the Channel receives events in the gap period when there is no subscriber. For example, when an Activity uses the repeatonllifecycle method to start a collaboration to consume the event messages in the Channel held by ViewModel, the current Activity cancels the collaboration because it is in the stopped state.
According to the demands of the previous analysis, events in the gap period cannot be discarded, but should be consumed in turn when the Activity returns to the active state. Therefore, when the buffer overflows, the policy is suspend, and the capacity is 0 by default, that is, the default construction method meets our needs.
We mentioned earlier that BroadcastChannel has been replaced by SharedFlow. Is it feasible for us to replace Channel with Flow?
Scheme 3: ordinary Flow (cold Flow)
Flow is cold, Channel is hot. The so-called stream is cold, that is, the code in the stream constructor will not be executed until the stream is collected. The following is a very classic example:
fun fibonacci(): Flow<BigInteger> = flow { var x = BigInteger.ZERO var y = BigInteger.ONE while (true) { emit(x) x = y.also { y += x } } } fibonacci().take(100).collect { println(it) }
If the code in the flow constructor does not depend on the subscriber to execute independently, the above will directly loop, and the actual operation shows that it is normal output.
So back to our question, is it feasible to use cold flow here? Obviously, it is not appropriate, because first, intuitively, the cold flow cannot transmit data outside the constructor.
But in fact, the answer is not absolute. By using channel inside the flow constructor, dynamic emission can also be realized, such as channelFlow. However, channelFlow itself does not support transmitting values outside the constructor. Channel can be converted to channelFlow through the Channel.receiveAsFlow operator. The flow "external cooling and internal heating" generated in this way is almost the same as the direct collection channel.
private val testChannel: Channel<Int> = Channel() private val testChannelFlow = testChannel.receiveAsFlow () Copy code
Scheme 4: SharedFlow/StateFlow
First, both are heat flows and support the emission of data outside the constructor. Take a simple look at how they are constructed
public fun <T> MutableSharedFlow( // The number of replays received when each new subscriber subscribes. The default is 0 replay: Int = 0, // The capacity of the cache, except the number of replay s, is 0 by default extraBufferCapacity: Int = 0, // The policy in case of buffer overflow. The default is suspend. onBufferOverflow takes effect only when there is at least one subscriber. When there are no subscribers, only the value of the number of recent replay s will be saved, and onBufferOverflow is invalid. onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND ) Copy code
//MutableStateFlow is equivalent to SharedFlow using the following construction parameters MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) Copy code
There are two main reasons why SharedFlow is passed:
- SharedFlow supports subscription by multiple subscribers, resulting in multiple consumption of the same event, which is not in line with expectations.
- If it is considered that 1 can also be controlled through the development specification, SharedFlow's feature of discarding data when there are no subscribers will completely prevent it from being selected to carry the events that must be executed
StateFlow can be understood as a special SharedFlow, which will have the above two problems anyway.
Of course, there are many scenarios suitable for using SharedFlow/StateFlow, which will be studied in detail below.
summary
When you want to launch events that must be executed and can only be executed once in the ViewModel layer for the View layer to execute, do not let the View layer listen to LiveData postValue. It is recommended to use Channel or ChannelFlow created through Channel.receiveAsFlow method to realize event sending of ViewModel layer.
solve the problem
RoomViewModel.kt class RoomViewModel : ViewModel() { private val _effect = Channel<Effect> = Channel () val effect = _effect. receiveAsFlow () private fun setEffect(builder: () -> Effect) { val newEffect = builder() viewModelScope.launch { _effect.send(newEffect) } } fun showToast(text : String) { setEffect { Effect.ShowToastEffect(text) } } } sealed class Effect { data class ShowToastEffect(val text: String) : Effect() }
RoomActivity.kt class RoomActivity : BaseActivity() { ... override fun initObserver() { lifecycleScope.launchWhenStarted { viewModel.effect.collect { when (it) { is Effect.ShowToastEffect -> { showToast(it.text) } } } } } }
Pain point 2: the problem of Activity/Fragment communicating through shared ViewModel
We often let Activity and its Fragment jointly hold the ViewModel constructed by Activity as the ViewModelStoreOwner to realize the communication between Activity and Fragment and Fragment. Typical scenarios are as follows:
class MyActivity : BaseActivity() { private val viewModel : MyViewModel by viewModels() private fun initObserver() { viewModel.countLiveData.observe { it-> updateUI(it) } } private fun initListener() { button.setOnClickListener { viewModel.increaseCount() } } } class MyFragment : BaseFragment() { private val activityVM : MyViewModel by activityViewModels() private fun initObserver() { activityVM.countLiveData.observe { it-> updateUI(it) } } } class MyViewModel : ViewModel() { private val _countLiveData = MutableLiveData<Int>(0) private val countLiveData : LiveData<Int> = _countLiveData fun increaseCount() { _countLiveData.value = 1 + _countLiveData.value ?: 0 } }
Simply put, consistency is achieved by allowing Activity and Fragment to observe the same liveData.
So if you want to call Activity in Fragment, is it feasible to share ViewModel?
discover problems
Communication between DialogFragment and Activity
We usually use DialogFragment to implement pop-up window. When setting the click event of pop-up window in its host Activity, if the Activity object is referenced in the callback function, it is easy to generate reference errors caused by horizontal and vertical screen page reconstruction. Therefore, we suggest that the Activity implement the interface. Each time an Attach window pops up, the currently attached Activity will be forcibly converted into an interface object to set the callback method.
class NoticeDialogFragment : DialogFragment() { internal lateinit var listener: NoticeDialogListener interface NoticeDialogListener { fun onDialogPositiveClick(dialog: DialogFragment) fun onDialogNegativeClick(dialog: DialogFragment) } override fun onAttach(context: Context) { super.onAttach(context) try { listener = context as NoticeDialogListener } catch (e: ClassCastException) { throw ClassCastException((context.toString() + " must implement NoticeDialogListener")) } } }
class MainActivity : FragmentActivity(), NoticeDialogFragment.NoticeDialogListener { fun showNoticeDialog() { val dialog = NoticeDialogFragment() dialog.show(supportFragmentManager, "NoticeDialogFragment") } override fun onDialogPositiveClick(dialog: DialogFragment) { // User touched the dialog's positive button } override fun onDialogNegativeClick(dialog: DialogFragment) { // User touched the dialog's negative button } }
This writing method will not have the above problems, but with more pop-up windows supported on the page, the Activity needs to implement more and more interfaces, which is not very friendly to coding or reading code. Do you have a chance to borrow the shared ViewModel to do some articles?
Analyze problems
We want to send events to the ViewModel and let all components that depend on it receive events. For example, clicking the key on fragment a triggers event a, and its host Activity, fragment B and fragment A of the same host need to respond to the event.
It is a bit like broadcasting and has two characteristics:
- Support one to many, that is, a message can be consumed by multiple subscribers
- With timeliness, expired messages are meaningless and should not be delayed.
It seems that EventBus is an implementation method, but it is obviously wasteful to reuse ViewModel as a medium. EventBus is more suitable for cross page and cross component communication. Comparing the use of several models analyzed above, it is found that SharedFlow is very useful in this scenario.
- SharedFlow is similar to BroadcastChannel. It supports multiple subscribers and sends multiple purchases at a time.
- SharedFlow configuration is flexible. For example, capacity = 0 and replay = 0 are configured by default, which means that new subscribers will not receive playback similar to LiveData. When there are no subscribers, they will be discarded directly, which is in line with the characteristics of the above timeliness events.
solve the problem
class NoticeDialogFragment : DialogFragment() { private val activityVM : MyViewModel by activityViewModels() fun initListener() { posBtn.setOnClickListener { activityVM.sendEvent(NoticeDialogPosClickEvent(textField.text)) dismiss() } negBtn.setOnClickListener { activityVM.sendEvent(NoticeDialogNegClickEvent) dismiss() } } } class MainActivity : FragmentActivity() { private val viewModel : MyViewModel by viewModels() fun showNoticeDialog() { val dialog = NoticeDialogFragment() dialog.show(supportFragmentManager, "NoticeDialogFragment") } fun initObserver() { lifecycleScope.launchWhenStarted { viewModel.event.collect { when(it) { is NoticeDialogPosClickEvent -> { handleNoticePosClicked(it.text) } NoticeDialogNegClickEvent -> { handleNoticeNegClicked() } } } } } } class MyViewModel : ViewModel() { private val _event: MutableSharedFlow<Event> = MutableSharedFlow () val event = _event. asSharedFlow () fun sendEvent(event: Event) { viewModelScope.launch { _event.emit(event) } } }
Starting a collaboration through lifecycleScope.launchWhenX here is not a best practice. If you want the Activity to directly discard the received events in an inactive state, you should use repeatonlife to control the initiation and cancellation of the collaboration instead of hanging. However, considering that the lifetime of DialogFragment is a subset of the host Activity, there is no big problem here.
MVI architecture based on Flow/Channel
The pain points mentioned above are actually for the MVI architecture to be introduced next. The specific implementation of MVI architecture is to integrate the above solutions into the template code to give full play to the advantages of the architecture.
What is MVI
MVI corresponds to Model, View and Intent
Model: not the data layer referred to by M in MVC and MVP, but the aggregate object representing the UI state. The model is immutable, and the model corresponds to the UI presented one by one.
View: like V in MVC and MVP, it refers to the unit rendering UI, which can be Activity or view. The user's interaction intention can be received, and the UI will be drawn responsively according to the new Model.
Intent: it is not the intent in the traditional Android design. It generally refers to the user's intention to interact with the UI, such as button clicking. Intent is the only source for changing the Model.
What are the main differences between MVVM and MVVM?
- MVVM does not restrict the interaction between the View layer and the ViewModel. Specifically, the View layer can call the methods in the ViewModel at will. The implementation of ViewModel under MVI architecture shields the View layer and can only drive events by sending Intent.
- The MVVM architecture does not emphasize the convergence of the Model value representing the UI state, and the modification of the value that can affect the UI can be scattered within each method that can be called directly. In MVI architecture, Intent is the only source driving UI changes, and the value representing UI state converges in one variable.
How to implement MVI based on Flow/Channel
Abstract the base class BaseViewModel
UiState is a Model that can represent the UI and is hosted by StateFlow (or LiveData)
UiEvent is an Intent representing interactive events, which is hosted by SharedFlow
UiEffect is the side effect of events other than changing the UI, which is carried by channelFlow
BaseViewModel.kt abstract class BaseViewModel<State : UiState, Event : UiEvent, Effect : UiEffect> : ViewModel() { /** * Initial state * stateFlow Unlike LiveData, it must have an initial value */ private val initialState: State by lazy { createInitialState() } abstract fun createInitialState(): State /** * uiState Aggregate all UI states of the page */ private val _uiState: MutableStateFlow<State> = MutableStateFlow(initialState) val uiState = _uiState.asStateFlow() /** * event It includes the user's interaction with the ui (such as click operation) and messages from the background (such as switching self-study mode) */ private val _event: MutableSharedFlow<Event> = MutableSharedFlow() val event = _event.asSharedFlow() /** * effect It is used as a side effect of events, usually a one-time event and a one-to-one subscription relationship * For example, play Toast, navigation Fragment, etc */ private val _effect: Channel<Effect> = Channel() val effect = _effect.receiveAsFlow() init { subscribeEvents() } private fun subscribeEvents() { viewModelScope.launch { event.collect { handleEvent(it) } } } protected abstract fun handleEvent(event: Event) fun sendEvent(event: Event) { viewModelScope.launch { _event.emit(event) } } protected fun setState(reduce: State.() -> State) { val newState = currentState.reduce() _uiState.value = newState } protected fun setEffect(builder: () -> Effect) { val newEffect = builder() viewModelScope.launch { _effect.send(newEffect) } } } interface UiState interface UiEvent interface UiEffect
StateFlow is basically equivalent to LiveData. The difference is that StateFlow must have an initial value, which is more consistent with the logic that the page must have an initial state. data class is generally used to implement UiState, and the states of all elements of the page are represented by member variables.
SharedFlow is used for user interaction events. It has timeliness and supports one to many subscriptions. It can solve the pain point 2 problem mentioned above.
The side effects caused by consumption events are carried by ChannelFlow and will not be lost. The one-to-one subscription is only executed once. Using it can solve the pain point mentioned above.
Protocol class, which defines the State, Event and Effect classes required by specific business
class NoteContract { /** * pageTitle: Page title * loadStatus: Status of pull-up loading * refreshStatus: Status of drop-down refresh * noteList : Memo list */ data class State( val pageTitle: String, val loadStatus: LoadStatus, val refreshStatus: RefreshStatus, val noteList: MutableList<NoteItem> ) : UiState sealed class Event : UiEvent { // Drop down refresh event object RefreshNoteListEvent : Event() // Pull up loading event object LoadMoreNoteListEvent: Event() // Add key click event object AddingButtonClickEvent : Event() // List item click event data class ListItemClickEvent(val item: NoteItem) : Event() // Add item pop-up disappearance event object AddingNoteDialogDismiss : Event() // Add item pop-up window add confirmation click event data class AddingNoteDialogConfirm(val title: String, val desc: String) : Event() // Add item pop-up window cancel confirmation click event object AddingNoteDialogCanceled : Event() } sealed class Effect : UiEffect { // Pop up data loading error Toast data class ShowErrorToastEffect(val text: String) : Effect() // Pop up add item pop-up window object ShowAddNoteDialog : Effect() } sealed class LoadStatus { object LoadMoreInit : LoadStatus() object LoadMoreLoading : LoadStatus() data class LoadMoreSuccess(val hasMore: Boolean) : LoadStatus() data class LoadMoreError(val exception: Throwable) : LoadStatus() data class LoadMoreFailed(val errCode: Int) : LoadStatus() } sealed class RefreshStatus { object RefreshInit : RefreshStatus() object RefreshLoading : RefreshStatus() data class RefreshSuccess(val hasMore: Boolean) : RefreshStatus() data class RefreshError(val exception: Throwable) : RefreshStatus() data class RefreshFailed(val errCode: Int) : RefreshStatus() } }
Collect state change flow and one-time event flow in life cycle components and send user interaction events
class NotePadActivity : BaseActivity() { ... override fun initObserver() { super.initObserver() lifecycleScope.launchWhenStarted { viewModel.uiState.collect { when (it.loadStatus) { is NoteContract.LoadStatus.LoadMoreLoading -> { adapter.loadMoreModule.loadMoreToLoading() } ... } when (it.refreshStatus) { is NoteContract.RefreshStatus.RefreshSuccess -> { adapter.setDiffNewData(it.noteList) refresh_layout.finishRefresh() if (it.refreshStatus.hasMore) { adapter.loadMoreModule.loadMoreComplete() } else { adapter.loadMoreModule.loadMoreEnd(false) } } ... } txv_title.text = it.pageTitle txv_desc.text = "${it.noteList.size}Records" } } lifecycleScope.launchWhenStarted { viewModel.effect.collect { when (it) { is NoteContract.Effect.ShowErrorToastEffect -> { showToast(it.text) } is NoteContract.Effect.ShowAddNoteDialog -> { showAddNoteDialog() } } } } } private fun initListener() { btn_floating.setOnClickListener { viewModel.sendEvent(NoteContract.Event.AddingButtonClickEvent) } } }
What are the benefits of using MVI
- Solved the above two pain points. This is why I spent a long time introducing the process of solving the two problems. Only when it really hurts will you feel the advantage of choosing the right architecture.
- In one-way data flow, any state change comes from events, so it is easier to locate problems.
- Ideally, the View layer and ViewModel layer are isolated from each other, making them more decoupled.
- States and events are clearly divided from the architecture level, which is convenient for constraining developers to write beautiful code.
Practical problems
- For the expanded UiState, when the page complexity increases, it means that the data class of UiState will be seriously expanded, and because it affects the whole body, the cost of local update is very high. Therefore, for complex pages, the complexity can be disassembled by splitting modules so that each Fragment/View holds its own ViewModel.
- For most event processing, it only calls methods, which defines the event type and the code of the transit part more than the direct call.
conclusion
The use of SharedFlow and channelFlow in the architecture is definitely worth preserving. Even if MVI architecture is not used, reference to the implementation here can also help solve many development problems, especially those involving horizontal and vertical screens.
You can choose to use StateFlow/LiveData to converge all the states of the page, or split it into multiple states. However, it is more recommended to split and converge by UI component module.
Skipping the use of Intent and calling the ViewModel method directly is also acceptable.
What else can Flow bring us
Simpler than Rxjava and more operators than LiveData
For example, use the flowOn operator to switch the collaboration context, use the buffer operator, use the collide operator to deal with the back pressure, use the debounce operator to realize anti shake, use the combine operator to realize the combination of flow, and so on.
It is easier to rewrite the callback based api into a call like synchronous code than to use the coprocessor directly
Using callbackFlow, the asynchronous operation results are transmitted in the form of synchronous suspension.