Jetpack Compose(3) —— 狀態管理

SharpCJ發表於2024-03-13

上一篇文章拿 TextField 元件舉例時,提到了 State,即狀態。本篇文章,即講解 State 的相關改概念。

一、什麼是狀態

與其它宣告式 UI 框架一樣,Compose 的職責非常單純,僅作為對資料狀態的反應。如果資料狀態沒有改變,則 UI 永遠不會自行改變。在 Compose 中,每一個元件都是一個被 @Composable 修飾的函式,其狀態就是函式的引數,當引數不變,則函式的輸出就不會變,唯一的引數決定唯一輸出。反言之,如果要讓介面發生變化,則需要改變介面的狀態,然後 Composable 響應這種變化。
下面還是拿個例子來說,做一個簡單的計數器,有一個顯示計數的控制元件,一個增加的按鈕,每點選一次,則技術計數器加 1 ,一個減少的按鈕,每點選一次,計時器減 1。
假如我們用此前的 View 檢視體系,來寫這個方法。程式碼大概像下面這樣:

class MainActivity : AppCompatActivity() {
    // ...
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ...
        binding.incrementBtn.setOnClickListener {
            binding.tvCounter.text = "${Integer.valueOf(binding.tvCounter.text.toString()) + 1 }"
        }

        binding.decrementBtn.setOnClickListener {
            binding.tvCounter.text = "${Integer.valueOf(binding.tvCounter.text.toString()) - 1 }"
        }
    }
}

顯然上面這個程式碼,計數邏輯和 UI 的耦合度就很高。稍微最佳化一下:

class MainActivity : AppCompatActivity() {
    // ...
    private var counter: Int = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ...
        binding.incrementBtn.setOnClickListener {
            counter++
            updateCounter()
        }

        binding.decrementBtn.setOnClickListener {
            counter--
            updateCounter()
        }
    }

    private fun updateCounter() {
        binding.tvCounter.text = "$counter"
    }
}

這個程式碼的改動主要在於,新增了 counter 用於計數,本質上屬於一種 “狀態上提”, 原本 TextView 內部的狀態 “mText”, 上提到了 Activity 中,這樣,即使更換了計數器的 UI, 計數邏輯依然可以複用。

但是當前的程式碼,仍然有一些問題,比如計數邏輯在 Activity 中,無法到其它頁面進行復用,進一步使用 MVVM 結構進行改造。引入 ViewModel, 將狀態從 Activity 中上提到 ViewModel 中。

class CounterViewModel: ViewModel() {
    private var _counter: MutableStateFlow<Int> = MutableStateFlow(0)
    val counter: StateFlow<Int> get() = _counter

    fun incrementCounter() {
        _counter.value++
    }

    fun decrementCounter() {
        _counter.value--
    }
}

class MainActivity : AppCompatActivity() {
    // ...
    private val viewModel: CounterViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ...
        binding.incrementBtn.setOnClickListener {
            viewModel.incrementCounter()
        }

        binding.decrementBtn.setOnClickListener {
            viewModel.decrementCounter()
        }

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.counter.collect {
                    binding.tvCounter.text = $it
                }
            }
        }
    }
}

有 Jetpack 庫使用經驗的應該非常熟悉上面的程式碼,將狀態上提到 ViewModel 中,使用 StateFlow 或者 LiveData 包裝起來,在 Ativity 中監聽狀態的變化,從而自動重新整理 UI。

下面,我們在 Compose 中實現上述計數器:

@Composable
fun CounterPage() {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        var counter = 0
        Text(text = "$counter")
        Button(onClick = { counter++ }) {
            Text(text = "increment")
        }
        Button(onClick = { counter-- }) {
            Text(text = "decrement")
        }
    }
}

我們寫出上面的程式碼,執行。

結果發現,無論怎麼點選,Text 顯示的值總是 0 ,我們的計數邏輯沒有生效。為了說明這個問題,現在增加一點日誌:

@Composable
fun CounterPage() {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        var counter = 0
        Log.d("sharpcj", "counter text --> $counter")
        Text(text = "$counter")
        Button(onClick = {
            Log.d("sharpcj", "increment button click ")
            counter++
        }) {
            Text(text = "increment")
        }
        Button(onClick = {
            Log.d("sharpcj", "decrement button click ")
            counter--
        }) {
            Text(text = "decrement")
        }
    }
}

再次執行,點選按鈕,看到日誌如下:

2024-03-12 21:39:27.530 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:39:30.859 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 21:39:31.309 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 21:39:31.468 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 21:39:31.762 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 21:39:31.927 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 21:39:32.661 21949-21949 sharpcj                 com.sharpcj.hellocompose             D  decrement button click 

我們重新捋一捋,Compose 的元件實際上就是一個個函式,Compose 重新整理 UI 的邏輯是,狀態發生變化,觸發了重組,函式被重新呼叫,然後由於引數發生了變化,函式輸出改變了,最終渲染出的的畫面才會發生變化。
再看上面的程式碼,我們期望是定義 counter 作為了 Text 元件的狀態,點選 Button,改變 counter, 到這裡都沒有問題,那麼問題處在了哪裡呢?問題主要是 counter 發生了變化,沒有觸發重組,即函式沒有被重新呼叫,日誌也證明了這一點。
回看我們上面傳統 View 檢視的寫法,此前,我們改變了狀態,需要主動呼叫 updateCounter 方法去重新整理 UI, 後面經過改造,我們把狀態提升到 ViewModel 中,不論是使用 StateFlow 還是使用 LiveData 包裝後,我們都需要在 Activity 中監聽狀態的變化,才能對狀態的變化做出響應。針對上面的例子,我們現在清楚了,計數器不生效原因在於 counter 改變後,Compose 沒有感知到,沒有觸發重組。下面需要開始學習 Compose 中的狀態了。

二、Compsoe 中的狀態 State

2.1 State

如同傳統試圖中,需要使用 StateFlow 或者 LiveData 將狀態變數包裝成一個可觀察型別的物件。Compose 中也提供了可觀察的狀態型別,可變狀態型別 MutableState 和 不可變狀態型別 State。我們需要使用 State/MutableState 將狀態變數包裝起來,這樣即可觸發重組。更為方便的是,宣告式 UI 框架中,不需要我們顯示註冊監聽狀態變化,框架自動實現了這一訂閱關係。我們來改寫上面的程式碼:

@Composable
fun CounterPage() {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        val counter: MutableState<Int> = mutableStateOf(0)
        Log.d("sharpcj", "counter text --> ${counter.value}")
        Text(text = "${counter.value}")
        Button(onClick = {
            Log.d("sharpcj", "increment button click ")
            counter.value++
        }) {
            Text(text = "increment")
        }
        Button(onClick = {
            Log.d("sharpcj", "decrement button click ")
            counter.value--
        }) {
            Text(text = "decrement")
        }
    }
}

我們使用了 mutableStateOf() 方法初始化了一個 MutableState 型別的狀態變數,並傳入預設值 0 ,使用的時候,需要呼叫 counter.value
再次執行,結果發現,點選按鈕,計數器值還是沒有變化,日誌如下:

2024-03-12 21:57:24.773  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:57:31.428  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 21:57:31.437  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:57:31.825  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 21:57:31.834  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:57:33.047  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 21:57:33.055  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:57:33.216  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 21:57:33.224  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:57:33.634  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 21:57:33.643  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 21:57:33.792  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 21:57:33.801  6791-6791  sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0

和上一次不一樣了,這次發現,點選按鈕之後, Text(text = "${counter.value}") 有重新執行,即發生了重組,但是執行的時候,引數沒有改變,依然是 0,其實這裡涉及到一個重組作用域的概念,就是重組是有一個範圍的,關於重組作用範圍,稍後再講。這裡需要知道,發生了重組,Text(text = "${counter.value}") 有重新執行,那麼 val counter: MutableState<Int> = mutableStateOf(0) 也有重新執行,相當於重組時,counter 被重新初始化了,並賦予了預設值 0 。所以點選按鈕發生了重組,但是計數器的值沒有發生改變。要解決這個問題,則需要使用到 Compose 中的一個重要函式 remember

2.2 remember

我們先看看 remember 函式的原始碼:

/**
 * Remember the value produced by [calculation]. [calculation] will only be evaluated during the composition.
 * Recomposition will always return the value produced by composition.
 */
@Composable
inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
    currentComposer.cache(false, calculation)

remember 方法的作用是,對其包裹起來的變數值進行快取,後續發生重組過程中,不會重新初始化,而是直接從快取中取。具體使用如下:

@Composable
fun CounterPage() {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        val counter: MutableState<Int> = remember { mutableStateOf(0) }
        Log.d("sharpcj", "counter text --> ${counter.value}")
        Text(text = "${counter.value}")
        Button(onClick = {
            Log.d("sharpcj", "increment button click ")
            counter.value++
        }) {
            Text(text = "increment")
        }
        Button(onClick = {
            Log.d("sharpcj", "decrement button click ")
            counter.value--
        }) {
            Text(text = "decrement")
        }
    }
}

再次執行,這次終於正常了。

看日誌也正確了。每次點選都出發了重組,並且 counter 的值也沒有重新初始化。

2024-03-12 22:18:53.744 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 0
2024-03-12 22:19:10.397 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 22:19:10.421 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 1
2024-03-12 22:19:10.967 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 22:19:10.981 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 2
2024-03-12 22:19:11.181 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 22:19:11.195 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 3
2024-03-12 22:19:11.649 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 22:19:11.663 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 4
2024-03-12 22:19:11.806 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 22:19:11.821 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 5
2024-03-12 22:19:12.364 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 22:19:12.377 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 4
2024-03-12 22:19:12.640 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 22:19:12.657 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 3
2024-03-12 22:19:13.204 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  increment button click 
2024-03-12 22:19:13.220 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 4
2024-03-12 22:19:13.747 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  decrement button click 
2024-03-12 22:19:13.761 19790-19790 sharpcj                 com.sharpcj.hellocompose             D  counter text --> 3

上面的程式碼中,我們建立 State 的方法如下:

val counter: MutableState<Int> = remember { mutableStateOf(0) }

使用時,透過 counter.value 來使用,這樣的程式碼看起來就很繁瑣,我們可以進一步精簡寫法。
首先, Kotlin 支援型別推導,所以可以寫成下面這樣:

val counter = remember { mutableStateOf(0) }

另外,藉助於 Kotlin 委託語法,Compose 實現了委託方式賦值,使用 by 關鍵字即可,用法如下:

var counter by remember { mutableStateOf(0) }

並匯入如下方法:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

在使用時,直接使用 counter++counter--

需要注意的一點是,沒有使用委託方式建立的物件,型別是 MutableState 型別,我們用 val 宣告,使用委託方式建立物件,物件型別是 MutableState 包裝的物件型別,這裡由於賦初始值為 0 ,根據型別推導,counter 就是 Int 型,由於要修改 counter 的值,所以須使用 var 將其宣告為一個可變型別物件。

2.3 rememberSaveable

使用 remember 雖然解決了重組過程中,狀態被重新初始化的問題,但是當 Activity 銷燬重建時,狀態值依然會重新初始化,比如橫豎屏旋轉,UiMode 切換等場景。在傳統試圖體系中,也存在這樣的問題,對此的解決方案有很多,比如重寫 Activity 的回撥方法,在合適的時機,對資料進行儲存和恢復,又或者使用 ViewModel 存放資料,這些方法對於 Compose 當然也有效,但是考慮到在使用 Compose 時,應該弱化 Activity 生命週期的概念,所以前者不適合在 Compose 中使用,而使用 ViewModel 依然是一種優秀的選擇,後文再介紹。但是把所有的資料都放到 ViewModel 中,是否是最好的呢,這個要根據具體場景,進行甄別。舉個例子,
針對這種場景,Compose 提供了 rememberSaveable 這個方法來解決這種場景的問題。

var counter by rememberSaveable { mutableStateOf(0) }

用法與 remember 方法用法類似,區別在於,rememberSaveable 在橫豎屏旋轉,UiMode 切換等場景中,能夠對其包裹的資料進行快取。那是否說明 rememberSaveable 可以在所有的場景替換 remember , remember 方法就沒用了? rememberSaveable 方法比 remember 方法功能更強勁,代價就是效能要差一些,具體使用根據實際場景來選擇。

到這裡,狀態相關的知識點,應該就很清楚了,再回頭看上一篇文章中的 TextField 元件,應該能明白為什麼那樣寫了。

三、 Stateless 和 Stateful

宣告式 UI 的元件一般都可以分為 Stateless 元件和 Stateful 元件。
所謂 stateless 是指這個元件除了依賴引數以外,不依賴其它任何狀態。比如 Text 元件,

Text("Hello, Compose")

相對的,某個元件除了引數以外,還持有或者訪問了外部的狀態,稱為 stateful 元件。比如上一篇文章中提到的 TextField 元件,

var text by remember { mutableStateOf("文字框初始值") }
TextField(value = text, onValueChange = {
    text = it
})

Stateless 是不依賴於外部狀態,僅依賴傳入進來的引數,它是一個“純函式”,即唯一輸入,對應唯一輸出。也就是引數不變,UI 就不會變化,它的重組只能是來自上層的呼叫,因此 Compose 編譯器對其進行了最佳化,當 Stateless 的引數沒有變化時,它就不會參與重組,重組的範圍侷限於 Stateless 外部。另外 Stateless 不耦合任何業務,功能更純粹,所以複用性更好,也更容易測試。
基於此,我們應該儘可能地將 stateful 元件改造成 stateless 元件,這個過程稱之為狀態上提。

3.1 狀態上提

狀態上提,通常的做法就是將內部狀態移除,以引數的形式傳入。以及需要回撥給呼叫方的事件,也以引數形式傳入。
還是以上面計數器的程式碼為例,為了簡潔,去掉前面新增的 log, 程式碼如下:

@Composable
fun CounterPage() {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        var counter by remember{ mutableStateOf(0) }
        Text(text = "$counter")
        Button(onClick = {
            counter++
        }) {
            Text(text = "increment")
        }
        Button(onClick = {
            counter--
        }) {
            Text(text = "decrement")
        }
    }
}

這裡計數器主要是依賴了內部狀態 counter, 同時兩個按鈕的點選事件,會改變 counter。狀態上提之後,該方法如下:

@Composable
fun CounterPage(counter: Int, onIncrement: () -> Unit, onDecrement: () -> Unit) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(text = "$counter")
        Button(onClick = {
            onIncrement()
        }) {
            Text(text = "increment")
        }
        Button(onClick = {
            onDecrement()
        }) {
            Text(text = "decrement")
        }
    }
}

這樣,Counter 元件,就變成了 stateless 元件,不再與業務耦合,職責更加單一,可複用性和可測試性都更強了。此外,狀態上提,有助於單一資料來源模型的打造。

四、狀態管理

我們再來看一下在 Compose 中應該如何管理狀態。

4.1 使用 stateful 管理狀態

簡單的的 UI 狀態,且與業務無關的狀態,適合在 Compose 中直接管理。
比如我有一個選單列表,點一開關,展開一個選單,再點一下,收起選單,列表的狀態,僅由點選開關這一單一事件決定。並且,列表的狀態與任何外部業務無關。那麼這種就適合在 Compose 內部進行管理。

4.2 使用 StateHolder 管理狀態

當業務有一定的複雜度之後,我們可以將業務邏輯相關的狀態統一封裝到一個 StateHoler 進行管理。剝離 Ui 邏輯,讓 Composable 專注 UI 佈局。

4.3 使用 ViewModel 管理狀態

從某種意義上講,ViewModel 也是一種特殊的 StateHolde。單因為它是儲存在 ViewModelStore 中,所以有一下特點:

  • 存活範圍大,可以脫離 Composition 存在,被所有 Composable 共享。
  • 存活時間長,不會因為橫豎屏切換或者 UiMode 切換導致資料丟失。

因此,ViewModel 適合管理應用程式全域性狀態,而且 ViewModel更傾向於管理哪些非 UI 的業務狀態。

以上管理方式可以同時使用,結合具體的業務靈活搭配。

4.4 LiveData、Rxjava、Flow 轉 State

在 MVVM 架構中,使用 ViewModel 來管理狀態,如果是新專案,把狀態直接定義 State 型別就可以了。

對於傳統試圖專案,一般使用 LiveData、Rxjava 或者 Flow 這類響應式資料框架。而在 Compose 中需要 State 觸發重組,重新整理 UI,也有相應的方法,將上述響應式資料流轉換為 Compose 中的 State。當上有資料變化時,可以驅動 Composable 完成重組。具體方法如下:

擴充方法 依賴庫
LiveData.observeAsState() androidx.compose:runtime-livedata
Flow.collectAsState() 不依賴三方庫,Compose 自帶
Observable.subscribeAsState() androidx.compose:runtime-rxjava2 或者 androidx.compose:runtime-rxjava3

五、小結

本文主要講解了 Compose 中狀態的概念。最後做個小結,

  • Compose UI 依賴狀態變化,觸發重組,驅動介面更新。
  • 使用 remember 和 rememberSaveable 進行狀態持久化。remember 保證在 recompose 過程中狀態穩定,rememberSaveable 保證 Activity 自動銷燬重建過程中狀態穩定。
  • 狀態上提,儘可能將 Stateful 元件轉換為 Stateless 元件。
  • 視情況使用 Stateful、StateHoler、ViewModel 管理狀態。
  • 將 LiveData、RxJava、Flow 資料流轉換為 State。

相關文章