Jetpack Compose(3) —— 狀態管理

上一篇文章拿 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。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章