Kotlin Flow響應式程式設計,StateFlow和SharedFlow
Flow的生命週期管理
首先,我們接著在 Kotlin Flow響應式程式設計,基礎知識入門 這篇文章中編寫的計時器例子來繼續學習。
之前在編寫這個例子的時候我有提到過,首要目的就是要讓它能跑起來,以至於在一些細節方面的寫法甚至都錯誤的。
那麼今天我們就要來看一看,之前的計時器到底錯在哪裡了。
如果只是直觀地從介面上看,好像一切都是可以正常工作的。但是,假如我們再新增一些日誌來進行觀察的話,問題就會浮出水面了。
那麼我們在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 {
lifecycleScope.launch {
mainViewModel.timeFlow.collect { time ->
textView.text = time.toString()
Log.d("FlowTest", "Update time $time in UI.")
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
這裡每當計時器更新一次的時候,我們同時列印一行日誌來方便進行進行觀察。
另外,MainViewModel中的程式碼這裡我也貼上吧,雖然它是完全沒有改動的:
class MainViewModel : ViewModel() {
val timeFlow = flow {
var time = 0
while (true) {
emit(time)
delay(1000)
time++
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
執行程式看一看效果:
一開始的時候,介面上計時器每更新一次,同時控制檯也會列印一行日誌,這還算是正常。
可接下來,當我們按下Home鍵回到桌面後,控制檯的日誌依然會持續列印。好傢伙,這還得了?
這說明,即使我們的程式已經不在前臺了,UI更新依然在持續進行當中。這是非常危險的事情,因為在非前臺的情況下更新UI,某些場景下是會導致程式崩潰的。
也就是說,我們並沒有很好地管理Flow的生命週期,它沒有與Activity的生命週期同步,而是始終在接收著Flow上游傳送過來的資料。
那這個問題要怎麼解決呢?lifecycleScope除了launch函式可以用於啟動一個協程之外,還有幾個與Activity生命週期關聯的launch函式可以使用。比如說,launchWhenStarted函式就是用於保證只有在Activity處於Started狀態的情況下,協程中的程式碼才會執行。
那麼我們用launchWhenStarted函式來改造一下上述程式碼:
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.")
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
變動就只有這一處,我們使用launchWhenStarted函式替換了之前的launch函式,其餘部分都是保持不變的。
現在重新執行一下程式,效果如下圖所示:
可以看到,這次當我們將程式切到後臺的時候,日誌就會停止列印,說明剛才的改動生效了。而當我們將程式重新切回到前臺時,計時器會接著剛才切出去的時間繼續計時。
那麼現在程式終於一切正常了嗎?
很遺憾,還沒有。
還有什麼問題呢?上圖其實已經將問題顯現出來了。
現在的主要問題在於,當我們將程式從後臺切回到前臺時,計時器會接著之前切出去的時間繼續計時。
這說明瞭什麼?說明程式在後臺的時候,Flow的管道中一直會暫存著一些的舊資料,這些資料不僅可能已經失去了時效性,而且還會造成一些記憶體上的問題。
要知道,我們使用flow構建函式構建出的Flow是屬於冷流,也就是在沒有任何接受端的情況下,Flow是不會工作的。但是上述例子當中,即使程式切到了後臺,Flow依然沒有中止,還是為它保留了過期資料,這就是一種記憶體上的浪費。
當然,我們這個例子非常簡單,在實際專案中一個Flow可能又是由多個上游Flow合併而成的。在這種情況下,如果程式進入了後臺,卻仍有大量Flow依然處於活躍的狀態,那麼記憶體問題會變得更加嚴重。
為此,Google推薦我們使用repeatOnLifecycle函式來解決這個問題,寫法如下:
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.")
}
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
repeatOnLifecycle函式接受一個Lifecycle.State引數,這裡我們傳入Lifecycle.State.STARTED,同樣表示只有在Activity處於Started狀態的情況下,協程中的程式碼才會執行。
使用repeatOnLifecycle函式改造之後,執行效果會完全不一樣,我們來看一下:
可以看到,當我們將程式切到後臺之後,日誌列印就停止了。當我們將程式重新切回前臺時,計時器會從零開始重新計時。
這說明什麼?說明Flow在程式進入後臺之後就完全停止了,不會保留任何資料。程式回到前臺之後Flow又從頭開始工作,所以才會從零開始計時。
正確使用repeatOnLifecycle函式,這樣才能讓我們的程式在使用Flow的時候更加安全。
StateFlow的基本用法
即使你從來沒有使用過Flow,但是我相信你一定使用過LiveData。
而如果談到在Flow的所有概念當中,最最接近LiveData的,那毫無疑問就是StateFlow了。
可以說,StateFlow的基本用法甚至能夠做到與LiveData完全一致。對於廣大Android開發者來說,我認為這是一個非常容易上手的元件。
下面我們就透過一個例子來學習一下StateFlow的基本用法。例子非常簡單,就是複用了剛才計時器的例子,並稍微進行了一下改造。
首先是對MainViewModel的改造,程式碼如下所示:
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)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
可以看到,這裡我們採取了和基礎知識入門篇完全不一樣的計時器實現策略。
之前我們是藉助Flow和協程的延遲機制來實現計時器效果的,而這裡則改成了藉助Java的Timer類來實現。
現在,只要呼叫了startTimer()函式,每隔一秒鐘Java的Timer定時器都會執行一次。那麼執行了要幹什麼呢?這就非常關鍵了,我們每次都給StateFlow的value值加1 。
你會發現,這個例子中展示的StateFlow的用法幾乎和LiveData是完全一致。同樣都是透過給value變數賦值來更新資料,甚至同樣都是建立一個Mutable的private版本來進行內部操作(一個叫MutableStateFlow,一個叫MutableLiveData),再轉換一個public的外部版本進行資料觀察(一個叫StateFlow,一個叫LiveData)。
如此來看,在MainViewModel層面確實是非常好理解的。
接下來看一下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()
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
當點選按鈕時,我們會呼叫MainViewModel中的startTimer()函式開啟定時器。
然後,這裡透過lifecycleScope啟動了一個協程作用域,並開始對我們剛才定義的StateFlow進行監聽。上述程式碼中的collect函式相當於LiveData中的observe函式。
StateFlow的基本用法就是這樣了,現在讓我們來執行一下程式吧:
看上去計時器已經可以正常工作了,非常開心。
StateFlow其中一個重要的價值就是它和LiveData的用法保持了高度一致性。如果你的專案之前使用的是LiveData,那麼終於可以放寬了心,零成本地遷移到Flow上了吧?
StateFlow的高階用法
雖說我們使用StateFlow改造的計時器已經可以成功執行了,但是有沒有覺得剛才的寫法有點太過於傳統了,看著非常得不響應式(畢竟用法和LiveData完全一致)。
實際上,StateFlow也有更加響應式的用法,藉助stateIn函式,可以將其他的Flow轉換成StateFlow。
不過,為了能夠更好地講解stateIn函式,我們還需要對之前的例子進行一下改造。
首先將MainViewModel中的程式碼還原到最初版本:
class MainViewModel : ViewModel() {
val timeFlow = flow {
var time = 0
while (true) {
emit(time)
delay(1000)
time++
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
然後修改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()
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
這裡我們移除了對Button點選事件的監聽,而是在onCreate函式中直接讓計時器就開始工作。
為什麼要做這樣的修改呢?
因為這會暴露出我們之前程式碼中隱藏的另外一個問題,觀察如下效果圖:
可以看到,原來除了程式進入後臺之外,手機發生橫豎屏切換也會讓計時器重新開始計時。
出現這個情況的原因是,手機橫豎屏切換會導致Activity重新建立,重新建立就會使得timeFlow重新被collect,而冷流每次被collect都是要重新執行的。
但這並不是我們想看到的現象,因為橫豎屏切換是很迅速的事情,在這種情況下我們沒必要讓所有的Flow都停止工作再重新啟動。
那麼該怎麼解決呢?現在終於可以引入stateIn函式了,先上程式碼,我再進行講解。修改如下:
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
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
前面已經介紹了,stateIn函式可以將其他的Flow轉換成StateFlow。那麼這裡,我們就是將之前的timeFlow轉換成了StateFlow。
stateIn函式接收3個引數,其中第1個引數是作用域,傳入viewModelScope即可。第3個引數是初始值,計時器的初始值傳入0即可。
而第2個引數則是最有意思的了。剛才有說過,當手機橫豎屏切換的時候,我們不希望Flow停止工作。但是再之前又提到了,當程式切到後臺時,我們希望Flow停止工作。
這該怎麼區分分別是哪種場景呢?
Google給出的方案是使用超時機制來區分。
因為橫豎屏切換通常很快就能完成,這裡我們透過stateIn函式的第2個引數指定了一個5秒的超時時長,那麼只要在5秒鐘內橫豎屏切換完成了,Flow就不會停止工作。
反過來講,這也使得程式切到後臺之後,如果5秒鐘之內再回到前臺,那麼Flow也不會停止工作。但是如果切到後臺超過了5秒鐘,Flow就會全部停止了。
這點開銷還是完全可以接受的。
好的,接下來我們在MainActivity中改成對StateFlow進行collect,從而完成這個例子吧:
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()
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
現在重新執行一下程式看一看效果:
可以看到,現在手機橫豎屏切換計時器依然是可以正常計時的,說明關聯的Flow也都在繼續工作,符合我們的預期。
到這裡,StateFlow的相關內容基本就都講完了。接下來還有今天的最後一塊主題,SharedFlow。
SharedFlow
要想輕鬆理解SharedFlow,首先我們得要先理解粘性這個概念。
如果你接觸過EventBus,應該對粘性不會感到陌生吧?
這是一個響應式程式設計中專有的概念。響應式程式設計是一種傳送者和觀察者配合工作的程式設計模式,由傳送者發出資料訊息,觀察者接收到了訊息之後進行邏輯處理。
普通場景下,這種傳送者和觀察者的工作模式還是很好理解的。但是,如果在觀察者還沒有開始工作的情況下,傳送者就已經先將訊息發出來了,稍後觀察者才開始工作,那麼此時觀察者還應該收到剛才發出的那條訊息嗎?
不管你覺得是應該還是不應該,這都不重要。這裡我丟擲這個問題是為了引出粘性的定義。如果此時觀察者還能收到訊息,那麼這種行為就叫做粘性。而如果此時觀察者收不到之前的訊息,那麼這種行為就叫做非粘性。
EventBus允許我們在使用的時候透過配置指定它是粘性的還是非粘性的。而LiveData則不允許我們進行指定,它的行為永遠都是粘性的。
剛才我們也說過,StateFlow和LiveData具有高度一致性,因此可想而知,StateFlow也是粘性的。
怎麼證明呢?透過一個非常簡單的例子即可證明。
修改MainViewModel中的程式碼,如下所示:
class MainViewModel : ViewModel() {
private val _clickCountFlow = MutableStateFlow(0)
val clickCountFlow = _clickCountFlow.asStateFlow()
fun increaseClickCount() {
_clickCountFlow.value += 1
}
}
1
2
3
4
5
6
7
8
9
10
這裡我們使用了一個名為clickCountFlow的StateFlow來進行簡單的計數功能。然後定義了一個increaseClickCount()函式,用於將計數值加1。
接下來修改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()
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
可以看到,每當點選一次按鈕時,我們都呼叫increaseClickCount()函式來讓計數值加1。
另外就是使用前面學習過的寫法,對clickCountFlow進行collect。
現在執行一下程式,效果如下圖所示:
這裡需要關注的重點是,當手機發生橫豎屏切換時,計數器的數字仍然會保留在螢幕上。
你覺得這很正常?其實則不然。因為當手機發生橫豎屏切換時,整個Activity都重新建立了,則此呼叫clickCountFlow的collect函式之後,並沒有什麼新的資料傳送過來,但我們仍然能在介面上顯示之前計數器的數字。
由此說明,StateFlow確實是粘性的。
粘性特性在絕大多數場景下都非常好使,這也是為什麼LiveData和StateFlow都設計成粘性的原因。
但確實在一些場景下,粘性又會導致出現某些問題。而LiveData並沒有提供非粘性的版本,所以網上甚至還出現了一些用Hook技術來讓LiveData變成非粘性的方案。
相比之下,Flow則人性化了很多。想要使用非粘性的StateFlow版本?那麼用SharedFlow就可以了。
在開始介紹SharedFlow的用法之前,我們先來看一下到底是什麼樣的場景不適用於粘性特性。
假設我們現在正在開發一個登入功能,點選按鈕開始執行登入操作,登入成功之後彈出一個Toast告知使用者。
首先修改MainViewModel中的程式碼,如下所示:
class MainViewModel : ViewModel() {
private val _loginFlow = MutableStateFlow("")
val loginFlow = _loginFlow.asStateFlow()
fun startLogin() {
// Handle login logic here.
_loginFlow.value = "Login Success"
}
}
1
2
3
4
5
6
7
8
9
10
11
這裡我們定義了一個startLogin函式,當呼叫這個函式時開始執行登入邏輯操作,登入成功之後向loginFlow進行賦值來告知使用者登入成功了。
接著修改MainActivity中的程式碼,如下所示:
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()
}
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
這裡當點選按鈕時,我們呼叫MainViewModel中的startLogin函式開始執行登入。
然後在對loginFlow進行collect的地方,透過彈出一個Toast來告知使用者登入已經成功了。
現在執行一下程式,效果如下圖所示:
可以看到,當點選按鈕開始執行登入時,彈出了一個Login Success的Toast,說明登入成功了。到這裡都還挺正常的。
接下來當我們嘗試去旋轉一下螢幕,此時又會彈出一個Login Success的Toast,這就不對勁了。
而這,就是粘性所導致的問題。
現在我們明白了在某些場景下粘性特性是不太適用的,接下來我們就學習一下如何使用SharedFlow這個非粘性的版本來解決這個問題。
修改MainViewModel中的程式碼,如下所示:
class MainViewModel : ViewModel() {
private val _loginFlow = MutableSharedFlow<String>()
val loginFlow = _loginFlow.asSharedFlow()
fun startLogin() {
// Handle login logic here.
viewModelScope.launch {
_loginFlow.emit("Login Success")
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
SharedFlow和StateFlow的用法還是略有不同的。
首先,MutableSharedFlow是不需要傳入初始值引數的。因為非粘性的特性,它本身就不要求觀察者在觀察的那一刻就能收到訊息,所以也沒有傳入初始值的必要。
另外就是,SharedFlow無法像StateFlow那樣透過給value變數賦值來傳送訊息,而是隻能像傳統Flow那樣呼叫emit函式。而emit函式又是一個掛起函式,所以這裡需要呼叫viewModelScope的launch函式啟動一個協程,然後再傳送訊息。
總體改動就是這麼多,MainActivity中的程式碼是不需要做修改的,現在讓我們重新執行一下程式吧:
可以看到,這次當我們再旋轉一下螢幕,不會再像剛才那樣又彈出一次Toast了,說明SharedFlow的改動已經生效了。
當然,其實SharedFlow的用法還遠不止這些,我們可以透過一些引數的配置來讓SharedFlow在有觀察者開始工作之前快取一定數量的訊息,甚至還可以讓SharedFlow模擬出StateFlow的效果。
但是我覺得這些配置會讓SharedFlow更難理解,就不打算講了。還是讓它們之間的區別更純粹一些,透過粘性和非粘性的需求來選擇你所需要的那個版本即可。
好了,到這裡,Kotlin Flow三部曲全劇終。
雖不敢說透過這三篇文章你就能成為Flow大神了,但是相信這些知識已經足夠你解決工作中遇到了絕大多數問題了。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70026759/viewspace-2936014/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 1.4.0協程之StateFlow和SharedFlow介紹
- 使用Java 9 Flow進行響應式程式設計Java程式設計
- 使用Reactor響應式程式設計React程式設計
- 函式響應式程式設計與RxSwift函式程式設計Swift
- 響應式程式設計入門(RxJava)程式設計RxJava
- 響應式程式設計庫RxJava初探程式設計RxJava
- 揚帆起航:從指令式程式設計到函式響應式程式設計程式設計函式
- Spring Boot 中的響應式程式設計和 WebFlux 入門Spring Boot程式設計WebUX
- RxDart——Dart和Flutter中的響應式程式設計入門DartFlutter程式設計
- 響應式程式設計簡介之:Reactor程式設計React
- 響應式程式設計機制總結程式設計
- [譯]Flutter 響應式程式設計:Steams 和 BLoC 實踐範例Flutter程式設計BloC
- 完美解釋 Javascript 響應式程式設計原理JavaScript程式設計
- 對響應式程式設計的懷疑 - lukaseder程式設計
- RxJS 系列故事(1)——理解響應式程式設計JS程式設計
- Ajax、JSON、響應式設計和Node.jsJSONNode.js
- Spring Cloud Stream的函式式和響應式Reactive程式設計特點 - spring.ioSpringCloud函式React程式設計
- 響應式設計?響應式設計的基本原理是什麼?如何做?
- 響應式程式設計與MVVM架構—理論篇程式設計MVVM架構
- Spring 5與Spring cloud的響應式程式設計之旅SpringCloud程式設計
- 前端RxJs響應式程式設計之運算子實踐前端JS程式設計
- Spring Boot 2 (十):Spring Boot 中的響應式程式設計和 WebFlux 入門Spring Boot程式設計WebUX
- 淺談前端響應式設計(一)前端
- 淺談前端響應式設計(二)前端
- 響應式程式設計在Android 中的一些探索程式設計Android
- Spring響應式Reactive程式設計的10個陷阱 -Jeroen RosenbergSpringReact程式設計ROS
- 響應式程式設計基礎教程:Spring Boot 與 Lettuce 整合程式設計Spring Boot
- Responsive Web Design 響應式網頁設計Web網頁
- Tailwind CSS 響應式設計實戰指南AICSS
- 第七章:C#響應式程式設計System.ReactiveC#程式設計React
- 【python socket程式設計】—— 3.響應Python程式設計
- 為程式設計師而設,TOP5 Tensor Flow和ML課程!程式設計師
- 阻塞式程式設計和非阻塞式程式設計區別程式設計
- [翻譯] 響應式程式設計(Reactive Programming) - 流(Streams) - BLoC - 實際應用案例程式設計ReactBloC
- token響應式設定
- Java程式設計方法論-響應式 之 Rxjava篇 視訊解讀程式設計RxJava
- Java9第四篇-Reactive Stream API響應式程式設計JavaReactAPI程式設計
- 《響應式程式設計(Reactive Programming)介紹》文章總結與案例分析程式設計React