Great intelligence technology team – client duliang people

preface

This paper aims to explain how to use Kotlin Flow to solve the pain points in Android development through actual business scenarios, and then study how to use Flow elegantly and correct some typical misunderstandings. For more information about asynchronous Flow, see the Kotlin language. MVVM architecture based on LiveData+ViewModel has limitations in some scenarios (typical horizontal and vertical screens). This article will introduce Flow/Channel based MVI architecture suitable for Android development.

background

The smart client team deeply adapted the vertical and horizontal screen scenes on the tablet side of the App, and reconstructed the ORIGINAL MVP architecture based on Rxjava into the MVVM architecture based on LiveData+ViewModel+Kotlin coroutine. With the increasing complexity of business scenarios, LiveData as the only carrier of data seems to be increasingly unable to undertake this responsibility, one of the pain points is due to the blurring of the boundary between “state” and “event”. LiveData’s stickiness mechanism has side effects, but this is not a design flaw of LiveData per se, but an overuse of it.

Kotlin Flow is an asynchronous data Flow framework based on Kotlin coroutines that can be used to return multiple values asynchronously. With the official release of Kotlin 1.4.0, StateFlow and SharedFlow were introduced, both of which have many of the features of Channel. They can be seen as a one-hand operation to push Flow to the forefront and Channel to the back. As for new technologies and frameworks, we will not blindly access them. After a period of investigation and trial, we found that Flow can indeed improve the pain relief of business development. The process of exploration will be shared below.

Pain point 1: Poor handling of ViewModel and View layer communication

Found the problem

LiveData doesn’t work when the screen is rotatable?

One of the typical refactoring methods in a project transitioned from MVP to MVVM is to rewrite the callback in Presenter to hold LiveData in ViewModel and subscribe to the View layer, as in the following scenario:

In Dali Self-study room, when the teacher switches to the interactive mode, the page needs to be changed and a Toast reminder pops up indicating that 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)
    }
}
Copy the code
RoomActivity.kt

class RoomActivity : BaseActivity() {

    ...

    override fun initObserver() {
        roomViewModel.modeLiveData.observe(this, Observer {
            updateUI()
            showToast(it)
        })
    }
}
Copy the code

At first glance, this is fine, but it does not take into account that if the screen switch is accompanied by page destruction and reconstruction, observe will be re-executed every time the screen is rotated on the current page, which will result in Toast being played again after each rotation.

LiveData guarantees that subscribers will always see the latest value when the value changes, and that each first-time observer will perform a callback method. Such features have no problem maintaining consistency between the UI and the data, but looking at LiveData to fire one-off events is beyond its scope.

Of course, there is a solution that encapsulates MutableLiveData’s SingleLiveEvent by ensuring that the same LiveData value only triggers an onChanged callback once. Not to mention whether it has other problems, but the magic packaging of LiveData gives me the first feeling that it is hard to be sweet and goes against the design idea of LiveData, and then there are no other problems with it?

Is it enough to rely solely on LiveData for the ViewModel to communicate with the View layer?

With the MVVM architecture, data changes drive UI updates. For the UI, you only care about the final state, but for some events, you don’t always want to discard all events prior to the last one as per the LiveData merge strategy. For the most part, you want every event to be executed, and LiveData is not designed for that.

In Dali self-study room, the teacher will give a thumbs-up to the students who perform well, and the students who receive the thumbs-up will pop up different styles of thumbs-up pop-ups according to the type of thumbs-up. The SingleLiveEvent mentioned above is used to prevent repeated pop-ups caused by horizontal and vertical screens or configuration changes

RoomViewModel.kt

class RoomViewModel : ViewModel() {

    private val praiseEvent = SingleLiveEvent<Int>()

    fun recvPraise(praiseType : Int) {
        praiseEvent.postValue(praiseType)
    }
}
Copy the code
RoomActivity.kt

class RoomActivity : BaseActivity() {

    ...

    override fun initObserver() {
        roomViewModel.praiseEvent.observe(this, Observer {
            showPraiseDialog(it)
        })
    }
}
Copy the code

Consider the following situation: The teacher gives two kinds of likes to student A: “Sitting upright” and “active interaction” at the same time, and the expectation is to play the “like” popover twice respectively. However, according to the above implementation, if two recvPraise calls are made in a row within a UI refresh cycle, that is, liveData posts two times in a row within a very short period of time, eventually the student will only pop up the second “like” popover.

In general, both of the above problems are fundamentally due to the lack of a better way to handle the communication between the ViewModel and View layer, which is embodied in the excessive use of LiveData and the lack of distinction between “states” and “events.

To analyze problems

According to the above summary, LiveData is appropriate to represent “state”, but “event” should not be represented by a single value. My first reaction to having the View layer consume each event sequentially without affecting the delivery of the event was to use a blocking queue to host the event. However, we should consider the following issues in selection, which is also the advantage of LiveData being recommended:

  1. Whether memory leaks occur, and whether the observer’s life cycle can clean itself up after being destroyed
  2. Whether thread switching is supported, such as LiveData ensuring that changes are sensed in the main thread and the UI is updated
  3. Events will not be consumed while the observer is inactive, such as LiveData, to prevent crashes caused by consumption when the Activity stops

Scheme 1: block the queue

The ViewModel holds the blocking queue, and the View layer reads the queue in an infinite loop on the main thread. LifecycleObserver needs to be manually added to keep threads suspended and resumed, and coroutines are not supported. Consider using a Channel in the Kotlin coroutine instead.

Scheme 2: Kotlin Channel

A Kotlin Channel is similar to a blocking queue, except that a Channel replaces a blocking PUT with a suspended send and a blocked Take with a suspended receive. Then open the three soul questions:

Does consuming a Channel in a lifecycle component leak memory?

No, because a Channel does not hold a reference to a lifecycle component, unlike LiveData passed in for Observer use.

Is thread switching supported?

Yes, collection of channels requires the coroutine to be enabled. The coroutine context can be switched to achieve thread switching.

Do observers consume events when they are inactive?

Using the launchWhenX method in the lifecycle-runtime-ktx library, the collection coroutine for a Channel hangs when the component lifecycle < X to avoid exceptions. RepeatOnLifecycle (State) can also be used to collect at the UI layer, and when the life cycle < State, the coroutine will be removed and restarted when it resumes.

It seems like a good idea to use a Channel to host events, and since events are typically distributed one-to-one, there is no need to support a one-to-many BroadcastChannel (which is becoming obsolete in favor of SharedFlow).

How to create a Channel? Take a look at the constructors available for Channel exposure and consider passing in the appropriate parameters.

Public fun <E> Channel(// Buffer capacity, onBufferOverflow policy: Int = RENDEZVOUS, // Buffer overflow policy, default to hang, and DROP_OLDEST and DROP_LATEST onBufferOverflow: BufferOverflow = bufferoverflow.suspend, // Handle elements that fail to be delivered successfully, such as subscriber cancellation or exception thrown onUndeliveredElement: ((E) -> Unit)? = null ): Channel<E>Copy the code

First, a Channel is hot, that is, sending elements to a Channel at any time will be executed even if there are no subscribers. Therefore, the event will be sent when the subscriber coroutine is cancelled, that is, the Channel will receive the event in the gap period when there is no subscriber. For example, when the Activity starts the coroutine using repeatOnLifecycle to consume event messages in the ViewModel held Channel, the Activity cancelled the coroutine because it was in the STOPED state.

According to the appeal of the previous analysis, in-between events should not be discarded, but should be consumed in sequence when the Activity returns to the active state. So consider that when the buffer overflows and the policy is suspend, the capacity defaults to 0, which means that the default constructor meets our requirements.

As mentioned earlier, BroadcastChannel has been replaced by SharedFlow. Is it feasible to use Flow instead of Channel?

Scheme 3: Ordinary Flow (cold Flow)

Flow is cold, Channel is hot. A stream is a cold stream constructor whose code does not execute until the stream is collected. Here 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) } 
Copy the code

If the code in the flow constructor does not execute independently of the subscriber, the above loop is straight up, whereas the actual run discovery is normal output.

So back to our question, is it possible to use cold flow here? Obviously not, because cold flow intuitively cannot emit data outside of the constructor in the first place.

However, the answer is not absolute. It is also possible to implement dynamic emission by using channels inside the flow constructor, such as channelFlow. ChannelFlow itself, however, does not support transmitting values outside the constructor. A Channel can be converted to channelFlow using the channel. receiveAsFlow operator. The resulting Flow is “cold on the outside and hot on the inside”, and the effect is almost indistinguishable from collecting channels directly.

private val testChannel: Channel<Int> = Channel()

private val testChannelFlow = testChannel.receiveAsFlow ()
Copy the code

Solution 4: SharedFlow/StateFlow

First, both are heat flow and allow data to be emitted outside the constructor. Just a quick look at how they’re constructed

Public fun <T> MutableSharedFlow(// The number of replays received when each new subscriber subscribs, default 0 replay: ExtraBufferCapacity: Int = 0, // Cache overflow policy, default is suspend. OnBufferOverflow will only take effect if there is at least one subscriber. When there are no subscribers, only the values of the most recent number of replays are saved, and onBufferOverflow is invalid. onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND )Copy the code
//MutableStateFlow is equivalent to SharedFlow MutableSharedFlow(replay = 1, onBufferOverflow = bufferoverflow.drop_simple).Copy the code

SharedFlow is passed for two main reasons:

  1. SharedFlow can be subscribed by multiple subscribers, resulting in the same event being consumed multiple times, which is not as expected.
  2. Assuming that 1 can also be controlled by development specifications, SharedFlow’s ability to discard data in the absence of a subscriber precludes it from being chosen to host events that must be executed

However, StateFlow can be understood as a special SharedFlow, which in any case has the above two problems.

Of course, there are many scenarios that are suitable for using SharedFlow/StateFlow, which will be highlighted in the following sections.

conclusion

Do not let the View layer listen on LiveData postValue when you want to fire an event that must be executed only once in the ViewModel layer. Channel or ChannelFlow created through the channel. receiveAsFlow method is recommended for event sending in the ViewModel layer.

To 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()
}
Copy the code
RoomActivity.kt

class RoomActivity : BaseActivity() {

    ...

    override fun initObserver() {
        lifecycleScope.launchWhenStarted {
            viewModel.effect.collect {
                when (it) {
                    is Effect.ShowToastEffect -> {
                        showToast(it.text)
                    }
                }
            }
       }
    }
}
Copy the code

Pain point 2: Activity/Fragment communication via shared ViewModel

We often have both activities and fragments holding viewModels constructed by Acitivity as ViewModelStoreOwner to implement communication between activities and fragments. The typical scenario is 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
    }

}
Copy the code

In simple terms, the Activity and Fragment can observe the same liveData to achieve consistency.

If you want to call an Activity method in a Fragment, is it possible to share the ViewModel?

Found the problem

Communication between DialogFragment and Activity

We usually use DialogFragment to implement pop-ups. When setting the pop-up’s click event in its host Activity, if the Activity object is referenced in the callback function, it is easy to generate a reference error caused by vertical and horizontal page reconstruction. Therefore, we suggest that the Activity implement the interface, and every time the popover attaches, 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"))
        }
    }
}
Copy the code
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
    }

}
Copy the code

This approach doesn’t have these problems, but as more popovers are supported on a page, the Activity needs to implement more interfaces, which are not very friendly either to code or to read code. Is there an opportunity to do some writing with the shared ViewModel?

To analyze problems

We want to send events to the ViewModel and have them received by all components that depend on it. For example, if A button click on FragmentA triggers event A, its host Activity, FragmentB of the same host, and FragmentA itself need to respond to that event.

It’s a bit like broadcasting, with two properties:

  1. One-to-many support, where a message can be consumed by multiple subscribers
  2. Time-sensitive, expired messages are meaningless and should not be consumed late.

EventBus seems like the way to do it, but with the ViewModel as the medium, it’s a waste. EventBus is better suited for cross-page, cross-component communication. Comparing the use of several models analyzed earlier, SharedFlow is very useful in this scenario.

  1. Similar to BroadcastChannel, SharedFlow supports multiple subscribers and multiple consumption at a time.
  2. The SharedFlow configuration is flexible. For example, the default configurations of Capacity = 0 and replay = 0 mean that new subscribers will not receive replays similar to LiveData. When there are no subscribers, it will be discarded directly, which is in line with the characteristics of the above timeliness events.

To 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)
        }
    }
}
Copy the code

Start here by lifecycleScope. LaunchWhenX coroutines is not best practice, if you want to Activity in the active state directly discarded receive events, you should use repeatOnLifecycle to control the open and cancellation of coroutines rather than hang. But considering that the DialogFragment life cycle is a subset of the host Activity, there is no big problem here.

Flow/ channel-based MVI architecture

The pain points mentioned above are actually for the introduction of MVI architecture. The concrete implementation of MVI architecture is to integrate the above solutions into template code to maximize the advantages of architecture.

What is the MVI

MVI refers to Model, View, and Intent

Model: not the data layer referred to by M in MVC and MVP, but the aggregation object representing UI state. The Model is immutable and has a one-to-one correspondence with the rendered UI.

View: Same as V in MVC and MVP, refers to the unit of UI rendering, which can be an Activity or a View. The user’s interaction intentions are received and the UI is drawn in response to the new Model.

Intents: Intents are not traditional Android designs. They generally refer to the Intent of the user to interact with the UI, such as a button click. Intents are the only source of Model changes.

The relationship among the three can be expressed in the following figure. MVI’s Model emphasizes one-way data flow (V -> I -> M -> V) and unique data source Model

However, the model in the figure is too idealistic. In actual development, intents do not only come from user interaction with the UI, but also from background tasks such as message services. In addition to changing the Model to update the UI, intEnts can also have side effects such as Toast popping. But this does not affect the idea of one-way data flow in the minimal model abstracted.

What are the main differences compared to MVVM?

  1. MVVM does not restrict the way the View layer interacts with the ViewModel. Specifically, the View layer can call methods in the ViewModel at will, while the implementation of the ViewModel in the MVI architecture is shielded from the View layer and can only send intEnts to drive events.
  2. The MVVM architecture does not emphasize convergence of Model values that represent THE state of the UI, and changes to values that affect the UI can be spread across methods that can be called directly. In MVI, the Intent is the only source that drives UI changes, and the value representing UI state converges into a variable.

How to implement Flow/ Channel-based MVI

Abstract the base class BaseViewModel

UiState is a Model that can represent the UI, hosted by StateFlow (or LiveData can be used)

UiEvent is an Intent that represents an interactive event and is carried by SharedFlow

Uieffects are events that have side effects other than changing the UI, carried by channelFlow

BaseViewModel.kt abstract class BaseViewModel<State : UiState, Event : UiEvent, Effect : UiEffect> : ViewModel() {/** * stateFlow must have an initial value to distinguish from LiveData */ private val initialState: State by lazy { createInitialState() } abstract fun createInitialState(): State /** * uiState */ private val _uiState: MutableStateFlow<State> = MutableStateFlow(initialState) val uiState = _uiState.asStateFlow() /** * Events include user interactions with the UI (such as click actions), as well as messages from the background (such as switching self-study mode) */ private val _event: MutableSharedFlow<Event> = MutableSharedFlow() val Event = _event.assharedflow () /** * effect Private val _effect: private val _effect: private val _effect: 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 UiEffectCopy the code

StateFlow is basically the same as LiveData, except that StateFlow must have an initial value, which is more consistent with the logic that the page must have an initial state. UiState is typically implemented using data Class, where the states of all elements on a page are represented by member variables.

User interaction events use SharedFlow, which is time-sensitive and supports one-to-many subscriptions. Using SharedFlow can solve the two pain points mentioned above.

The side effects of consumption events are carried by ChannelFlow, which is not lost and subscribes one-to-one, only once. Using it can solve the pain point mentioned above.

Protocol class, which defines State, Event and Effect classes required by specific business

Class NoteContract {/** * pageTitle: "loadStatus" : "refreshStatus" : "noteList" : */ 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() // Drop up load Event object LoadMoreNoteListEvent: Data class ListItemClickEvent(Val item: ListItemClickEvent) NoteItem) : Event() object AddingNoteDialogDismiss: Data class AddingNoteDialogConfirm(Val Title: String, val desc: String) : Object AddingNoteDialogCanceled: Event()} Sealed Class Effect: UiEffect {// pop-up data loading error Toast data class ShowErrorToastEffect(val text: String) : 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() } }Copy the code

Collect state change streams and one-time event streams in the lifecycle component to 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) } } }Copy the code

What are the benefits of using MVI

  1. Solves the two pain points mentioned above. That’s why I spent a lot of time describing the process of solving two problems. The benefits of choosing the right architecture are felt only when the pain is real.
  2. One-way data flow, where any change in state comes from events, makes it easier to locate problems.
  3. Ideally, the View layer and the ViewModel layer are interface isolated and more decoupled.
  4. States and events are clearly delineated at an architectural level to constrain developers from writing beautiful code.

Practical use down the question

  1. Bloated UiState. When the page complexity increases, it means that the data class of UiState will greatly swell, and due to its characteristics of affecting the whole body, it is very expensive to update the local part. Therefore, for complex pages, you can break down the complexity by splitting modules so that each Fragment/View holds its own ViewModel.
  2. For the most part, event handling is just calling methods, with extra coding to define the event type and relay parts rather than calling directly.

conclusion

The use of SharedFlow and channelFlow in the architecture is definitely worth keeping, and even if you don’t use the MVI architecture, the implementation here can help solve many of the development challenges, especially those involving vertical and horizontal screens.

You can choose to use StateFlow/LiveData to converge the entire page state or split it into multiple pages. However, it is more recommended to split and converge by UI component module.

It is also acceptable to skip the Intent and call the ViewModel method directly.

What else can Flow give us

Simpler than Rxjava and more operators than LiveData

Examples include using the flowOn operator to switch coroutine context, using buffer, conflate to handle back pressure, debounce for anti-shock, combine flow using the Combine operator, and so on.

It’s easier to rewrite callback-based apis to make calls like synchronous code than using coroutines directly

Using callbackFlow, the result of an asynchronous operation is emitted as a synchronous suspension.

conclusion

I think whenever we introduce new technology, we don’t necessarily need to immediately learn to use it, even the best framework is just a castle in the air without the business scenario. But if the new technology really does solve our development pain points, understanding and practicing it might open your mind, just as if you’re running into a problem like the one in this article, try using Flow.