Kotlin Flow Reactive Programming, StateFlow and SharedFlow

This article is simultaneously published on my WeChat official account. You can follow by scanning the QR code at the bottom of the article or searching for Guo Lin on WeChat. Articles are updated every working day.

Hello everyone, today is the last article of the Kotlin Flow Responsive Programming Trilogy.

In fact, looking back at my original intention of writing this Kotlin Flow trilogy, it is mainly because I want to learn this knowledge myself.

Although I have learned Kotlin for many years, I have never had much contact with Flow. Maybe it's because I haven't been able to use it at work. The main language I work on now is still Java.

And I have always been like this, blogging is basically not written for anyone, most of it is just because I want to learn. But after learning it, you will forget it soon, so I often record it in the form of articles, which can be regarded as helping others and helping myself.

And Kotlin Flow is unlikely to be used in my work in the foreseeable time, so this series is basically my personal study notes.

In today's article, I am going to talk about the knowledge of StateFlow and SharedFlow. The content has a certain relationship with the previous two articles, so if you haven’t read the previous two articles, it is recommended to refer to Kotlin Flow Responsive Programming, Getting Started with Basic Knowledge and Kotlin Flow Responsive Programming, Operator Function Introduction order .


Flow life cycle management

First, let 's continue learning with the timer example written in this article in Kotlin Flow Responsive Programming, Getting Started with Basics .

When I wrote this example before, I mentioned that the primary purpose is to make it run, so that some details are even written wrong.

So today we are going to take a look at what went wrong with the previous timer.

If you just look at it intuitively from the interface, everything seems to be working normally. However, if we add some logs to observe, the problem will surface.

Then we add some logs in MainActivity as follows:

class MainActivity : AppCompatActivity() {
    
    

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
    
    
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
    
    
            lifecycleScope.launch {
    
    
                mainViewModel.timeFlow.collect {
    
     time ->
                    textView.text = time.toString()
                    Log.d("FlowTest", "Update time $time in UI.")
                }
            }
        }
    }
}

Here, whenever the timer is updated, we also print a line of logs to facilitate observation.

In addition, I will also paste the code in MainViewModel here, although it has not been changed at all:

class MainViewModel : ViewModel() {
    
    

    val timeFlow = flow {
    
    
        var time = 0
        while (true) {
    
    
            emit(time)
            delay(1000)
            time++
        }
    }
    
}

Run the program to see the effect:

insert image description here

At the beginning, every time the timer on the interface is updated, a line of logs will be printed on the console at the same time, which is normal.

But then, when we press the Home button to return to the desktop, the console log will continue to print. Good guy, is this okay?

This shows that even if our program is no longer in the foreground, UI updates are still in progress. This is a very dangerous thing, because updating the UI when it is not in the foreground may cause the program to crash in some scenarios.

That is to say, we did not manage the life cycle of Flow very well. It is not synchronized with the life cycle of Activity, but is always receiving the data sent from the upstream of Flow.

How to solve this problem? LifecycleScope In addition to the launch function that can be used to start a coroutine, there are several launch functions associated with the Activity life cycle that can be used. For example, the launchWhenStarted function is used to ensure that only when the Activity is in the Started state, the code in the coroutine will be executed.

Then we use the launchWhenStarted function to modify the above code:

class MainActivity : AppCompatActivity() {
    
    

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
    
    
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
    
    
            lifecycleScope.launchWhenStarted {
    
    
                mainViewModel.timeFlow.collect {
    
     time ->
                    textView.text = time.toString()
                    Log.d("FlowTest", "Update time $time in UI.")
                }
            }
        }
    }
}

The only change is this, we use the launchWhenStarted function to replace the previous launch function, and the rest remain unchanged.

Now run the program again, the effect is as shown in the figure below:

insert image description here

It can be seen that when we switch the program to the background this time, the log will stop printing, indicating that the changes just now have taken effect. And when we switch the program back to the foreground, the timer will continue to count the time just switched out.

So now the program finally works?

Sadly, not yet.

What's the problem? The picture above actually shows the problem.

The main problem now is that when we switch the program from the background back to the foreground, the timer will continue to count from the time it was switched out before.

This shows what? It shows that when the program is in the background, there will always be some old data temporarily stored in the pipeline of Flow. These data may not only lose their timeliness, but also cause some memory problems.

You should know that the Flow we built using the flow construction function is a cold flow, that is, the Flow will not work without any receiving end. However, in the above example, even if the program cuts to the background, Flow still does not stop, and expired data is reserved for it, which is a waste of memory.

Of course, our example is very simple. In an actual project, a Flow may be merged from multiple upstream Flows. In this case, if the program enters the background, but there are still a lot of Flow still active, the memory problem will become more serious.

For this reason, Google recommends that we use the repeatOnLifecycle function to solve this problem, written as follows:

class MainActivity : AppCompatActivity() {
    
    

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
    
    
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
    
    
            lifecycleScope.launch {
    
    
                repeatOnLifecycle(Lifecycle.State.STARTED) {
    
    
                    mainViewModel.timeFlow.collect {
    
     time ->
                        textView.text = time.toString()
                        Log.d("FlowTest", "Update time $time in UI.")
                    }
                }
            }
        }
    }
}

The repeatOnLifecycle function accepts a Lifecycle.State parameter. Here we pass in Lifecycle.State.STARTED, which also means that only when the Activity is in the Started state, the code in the coroutine will be executed.

After using the repeatOnLifecycle function, the operation effect will be completely different. Let's take a look:

insert image description here

It can be seen that when we cut the program to the background, the log printing stops. When we switch the program back to the foreground, the timer restarts from zero.

What does this mean? It means that Flow will stop completely after the program enters the background and will not retain any data. After the program returns to the foreground, Flow starts working again from the beginning, so it starts timing from zero.

Correct use of the repeatOnLifecycle function will make our program safer when using Flow.


Basic usage of StateFlow

Even if you have never used Flow, I believe you must have used LiveData.

And if you talk about the closest to LiveData among all the concepts of Flow, there is no doubt that it is StateFlow.

It can be said that the basic usage of StateFlow can even be completely consistent with LiveData. For the majority of Android developers, I think this is a very easy-to-use component.

Let's use an example to learn the basic usage of StateFlow. The example is very simple, it just reused the example of the timer just now, and made a little modification.

The first is the transformation of MainViewModel, the code is as follows:

class MainViewModel : ViewModel() {
    
    

    private val _stateFlow = MutableStateFlow(0)

    val stateFlow = _stateFlow.asStateFlow()

    fun startTimer() {
    
    
        val timer = Timer()
        timer.scheduleAtFixedRate(object : TimerTask() {
    
    
            override fun run() {
    
    
                _stateFlow.value += 1
            }
        }, 0, 1000)
    }
}

It can be seen that here we have adopted a timer implementation strategy that is completely different from the basic knowledge entry.

Before, we used the delay mechanism of Flow and coroutines to realize the timer effect, but here we changed it to use Java's Timer class to achieve it.

Now, as long as the startTimer() function is called, Java's Timer timer will be executed every second. So what to do after executing it? This is very critical, we add 1 to the value of StateFlow every time.

You will find that the usage of StateFlow shown in this example is almost exactly the same as that of LiveData. They also update data by assigning values ​​to value variables, and even create a private version of Mutable for internal operations (one called MutableStateFlow, one called MutableLiveData), and then convert a public external version for data observation (one called StateFlow , one called LiveData).

From this point of view, it is indeed very easy to understand at the MainViewModel level.

Next, look at the code modification in MainActivity:

class MainActivity : AppCompatActivity() {
    
    

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
    
    
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
    
    
            mainViewModel.startTimer()
        }
        lifecycleScope.launch {
    
    
            repeatOnLifecycle(Lifecycle.State.STARTED) {
    
    
                mainViewModel.stateFlow.collect {
    
    
                    textView.text = it.toString()
                }
            }
        }
    }
}

When the button is clicked, we call the startTimer() function in MainViewModel to start the timer.

Then, a coroutine scope is started here through lifecycleScope, and it starts to listen to the StateFlow we just defined. The collect function in the above code is equivalent to the observe function in LiveData.

The basic usage of StateFlow is like this, now let's run the program:

insert image description here

Looks like the timer is working fine, very happy.

One of the important values ​​of StateFlow is that it maintains a high degree of consistency with the usage of LiveData. If your project used LiveData before, you can finally relax and migrate to Flow at zero cost, right?


Advanced usage of StateFlow

Although the timer we modified with StateFlow can already run successfully, do you feel that the writing method just now is a bit too traditional, and it looks very unresponsive (after all, the usage is exactly the same as LiveData).

In fact, StateFlow also has a more responsive usage. With the help of the stateIn function, other Flows can be converted into StateFlows.

However, in order to better explain the stateIn function, we need to modify the previous example.

First restore the code in MainViewModel to the original version:

class MainViewModel : ViewModel() {
    
    

    val timeFlow = flow {
    
    
        var time = 0
        while (true) {
    
    
            emit(time)
            delay(1000)
            time++
        }
    }
    
}

Then modify the code in MainActivity:

class MainActivity : AppCompatActivity() {
    
    

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
    
    
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        lifecycleScope.launch {
    
    
            repeatOnLifecycle(Lifecycle.State.STARTED) {
    
    
                mainViewModel.timeFlow.collect {
    
     time ->
                    textView.text = time.toString()
                }
            }
        }
    }
}

Here we remove the monitoring of the Button click event, but let the timer start working directly in the onCreate function.

Why make such a modification?

Because this will expose another problem hidden in our previous code, observe the following renderings:

insert image description here

It can be seen that in addition to the program entering the background, the horizontal and vertical screen switching of the mobile phone will also restart the timer.

The reason for this situation is that switching between the horizontal and vertical screens of the mobile phone will cause the Activity to be recreated, and the re-creation will cause the timeFlow to be collected again, and the cold flow must be re-executed every time it is collected.

But this is not what we want to see, because switching between horizontal and vertical screens is very fast. In this case, we don't need to stop all Flow and restart it.

So how to solve it? Now it is finally possible to introduce the stateIn function, first upload the code, and then I will explain it. amend as below:

class MainViewModel : ViewModel() {
    
    

    private val timeFlow = flow {
    
    
        var time = 0
        while (true) {
    
    
            emit(time)
            delay(1000)
            time++
        }
    }

    val stateFlow =
        timeFlow.stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5000), 
            0
        )
}

As mentioned earlier, the stateIn function can convert other Flows into StateFlows. So here, we are converting the previous timeFlow into StateFlow.

The stateIn function receives 3 parameters, the first parameter is the scope, just pass in viewModelScope. The third parameter is the initial value, and the initial value of the timer can be passed to 0.

The second parameter is the most interesting. As I said just now, we don't want Flow to stop working when the mobile phone is switched between horizontal and vertical screens. But as mentioned before, when the program cuts to the background, we want Flow to stop working.

How to distinguish which scene is which?

The solution given by Google is to use the timeout mechanism to distinguish.

Because the horizontal and vertical screen switching is usually completed quickly, here we specify a 5-second timeout through the second parameter of the stateIn function, so as long as the horizontal and vertical screen switching is completed within 5 seconds, Flow will not stop working.

Conversely, this also makes the program cut to the background, if it returns to the foreground within 5 seconds, then Flow will not stop working. But if you cut to the background for more than 5 seconds, Flow will stop completely.

This cost is still completely acceptable.

Ok, let's change to collect StateFlow in MainActivity to complete this example:

class MainActivity : AppCompatActivity() {
    
    

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
    
    
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        lifecycleScope.launch {
    
    
            repeatOnLifecycle(Lifecycle.State.STARTED) {
    
    
                mainViewModel.stateFlow.collect {
    
     time ->
                    textView.text = time.toString()
                }
            }
        }
    }
}

Now re-run the program to see the effect:

insert image description here

It can be seen that the horizontal and vertical screen switching timer of the mobile phone can still time normally, indicating that the associated Flow is also continuing to work, which is in line with our expectations.

At this point, the related content of StateFlow is basically finished. Then there is the last topic of today, SharedFlow.


SharedFlow

To easily understand SharedFlow, we must first understand the concept of stickiness.

If you have been exposed to EventBus, you should not be unfamiliar with stickiness, right?

This is a unique concept in reactive programming. Responsive programming is a programming mode in which the sender and the observer work together. The sender sends a data message, and the observer performs logical processing after receiving the message.

In common scenarios, the working mode of this sender and observer is well understood. However, if the sender has already sent the message before the observer has started to work, and the observer starts to work later, should the observer still receive the message just sent at this time?

It doesn't matter whether you think you should or shouldn't. Here I raise this question to elicit the definition of stickiness. If the observer can still receive the message at this time, then this behavior is called stickiness. And if the observer cannot receive the previous message at this time, then this behavior is called non-sticky.

EventBus allows us to specify whether it is sticky or non-sticky through configuration when we use it. LiveData does not allow us to specify, its behavior is always sticky.

We also said just now that StateFlow and LiveData are highly consistent, so it is conceivable that StateFlow is also sticky.

How to prove it? This can be demonstrated with a very simple example.

Modify the code in MainViewModel as follows:

class MainViewModel : ViewModel() {
    
    

    private val _clickCountFlow = MutableStateFlow(0)

    val clickCountFlow = _clickCountFlow.asStateFlow()

    fun increaseClickCount() {
    
    
        _clickCountFlow.value += 1
    }
}

Here we use a StateFlow called clickCountFlow for a simple counting function. Then an increaseClickCount() function is defined to increase the count value by 1.

Next modify the code in MainActivity:

class MainActivity : AppCompatActivity() {
    
    

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
    
    
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val textView = findViewById<TextView>(R.id.text_view)
        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
    
    
            mainViewModel.increaseClickCount()
        }
        lifecycleScope.launch {
    
    
            repeatOnLifecycle(Lifecycle.State.STARTED) {
    
    
                mainViewModel.clickCountFlow.collect {
    
     time ->
                    textView.text = time.toString()
                }
            }
        }
    }
}

As you can see, every time the button is clicked, we call the increaseClickCount() function to increase the count by 1.

The other is to use the writing method learned before to collect clickCountFlow.

Now run the program, the effect is as shown in the figure below:

insert image description here

The important thing to pay attention to here is that when the mobile phone is switched between horizontal and vertical screens, the number of the counter will still remain on the screen.

Do you think this is normal? In fact, it is not. Because when the mobile phone switches between horizontal and vertical screens, the entire Activity is recreated. After calling the collect function of clickCountFlow, no new data is sent, but we can still display the previous counter numbers on the interface.

This shows that StateFlow is indeed sticky.

The sticky feature works well in most scenarios, which is why both LiveData and StateFlow are designed to be sticky.

But it is true that in some scenarios, stickiness can cause certain problems. However, LiveData does not provide a non-sticky version, so there are even some solutions on the Internet that use Hook technology to make LiveData non-sticky.

In contrast, Flow is a lot more humanized. Want to use a non-sticky version of StateFlow? Then use SharedFlow.

Before we start to introduce the usage of SharedFlow, let's take a look at what kind of scenarios are not suitable for the sticky feature.

Suppose we are developing a login function now, click the button to start the login operation, and a Toast will pop up to inform the user after the login is successful.

First modify the code in MainViewModel as follows:

class MainViewModel : ViewModel() {
    
    

    private val _loginFlow = MutableStateFlow("")

    val loginFlow = _loginFlow.asStateFlow()

    fun startLogin() {
    
    
        // Handle login logic here.
        _loginFlow.value = "Login Success"
    }
}

Here we define a startLogin function. When this function is called, the login logic operation is executed. After the login is successful, a value is assigned to loginFlow to inform the user that the login is successful.

Then modify the code in MainActivity as follows:

class MainActivity : AppCompatActivity() {
    
    

    private val mainViewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
    
    
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val button = findViewById<Button>(R.id.button)
        button.setOnClickListener {
    
    
            mainViewModel.startLogin()
        }
        lifecycleScope.launch {
    
    
            repeatOnLifecycle(Lifecycle.State.STARTED) {
    
    
                mainViewModel.loginFlow.collect {
    
    
                    if (it.isNotBlank()) {
    
    
                        Toast.makeText(this@MainActivity, it, Toast.LENGTH_SHORT).show()
                    }
                }
            }
        }
    }
}

Here when the button is clicked, we call the startLogin function in MainViewModel to start the login.

Then, at the place where loginFlow is collected, a Toast pops up to inform the user that the login has been successful.

Now run the program, the effect is as shown in the figure below:

insert image description here

It can be seen that when the button is clicked to start the login, a Login Success Toast pops up, indicating that the login is successful. It's pretty normal up to here.

Next, when we try to rotate the screen, a Login Success Toast will pop up again, which is not right.

And this is the problem caused by stickiness.

Now that we understand that the sticky feature is not applicable in some scenarios, let's learn how to use the non-sticky version of SharedFlow to solve this problem.

Modify the code in MainViewModel as follows:

class MainViewModel : ViewModel() {
    
    

    private val _loginFlow = MutableSharedFlow<String>()

    val loginFlow = _loginFlow.asSharedFlow()

    fun startLogin() {
    
    
        // Handle login logic here.
        viewModelScope.launch {
    
    
            _loginFlow.emit("Login Success")
        }
    }
}

The usage of SharedFlow and StateFlow is slightly different.

First of all, MutableSharedFlow does not need to pass in the initial value parameter. Because of the non-sticky nature, it does not require the observer to receive the message at the moment of observation, so there is no need to pass in the initial value.

In addition, SharedFlow cannot send messages by assigning values ​​to value variables like StateFlow, but can only call the emit function like traditional Flow. The emit function is also a suspending function, so here you need to call the launch function of viewModelScope to start a coroutine, and then send the message.

There are so many overall changes, the code in MainActivity does not need to be modified, now let's run the program again:

insert image description here

It can be seen that when we rotate the screen again this time, the Toast will not pop up again like just now, indicating that the changes to SharedFlow have taken effect.

Of course, the usage of SharedFlow is far more than that. We can configure some parameters to let SharedFlow cache a certain amount of messages before observers start working, and even let SharedFlow simulate the effect of StateFlow.

But I think these configurations will make SharedFlow more difficult to understand, so I don't plan to talk about it. Or let the distinction between them be more pure, just choose the version you need through sticky and non-sticky requirements.

Well, here we are, the end of the Kotlin Flow trilogy.

Although I dare not say that you can become a master of Flow through these three articles, I believe that this knowledge is enough for you to solve most of the problems you encounter in your work.


If you want to learn Kotlin and the latest Android knowledge, you can refer to my new book "The First Line of Code 3rd Edition" , click here to view details .

Guess you like

Origin blog.csdn.net/sinyu890807/article/details/128591076