Kotlin Flow響應式程式設計,StateFlow和SharedFlow

hfhsdgzsdgsdg發表於2023-02-20

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/,如需轉載,請註明出處,否則將追究法律責任。

相關文章