JetPack Compose 之 state

和所有響應(yīng)式UI框架一樣,Compose 也是使用State來更新UI的

我們通常都是用下面的結(jié)構(gòu)來開發(fā):

class HelloCodelabActivity : AppCompatActivity() {

   private lateinit var binding: ActivityHelloCodelabBinding
   var name = ""

   override fun onCreate(savedInstanceState: Bundle?) {
       /* ... */
       binding.textInput.doAfterTextChanged {text ->
           name = text.toString()
           updateHello()
       }
   }

   private fun updateHello() {
       binding.helloText.text = "Hello, $name"
   }
}

這種方式就是典型的命令式編程,想要改變UI就必須得調(diào)用更新UI的方法,這種方式有以下缺點

  1. UI狀態(tài)和Views緊密結(jié)合,導(dǎo)致難以進行單測
  2. 當(dāng)有很多事件需要更新state時,可能會忘記更新state
  3. 當(dāng)每個state變化時,都要手動去更新UI,如果忘記了就會導(dǎo)致UI顯示異常
  4. 導(dǎo)致代碼邏輯復(fù)雜

單向數(shù)據(jù)流

為了解決這個問題,Android 推出了ViewModel 和LivaData

通過ViewModel 我們可以從UI中提取state,也可以定義更新UI state的事件。
看下面的例子

class HelloCodelabViewModel: ViewModel() {

   // LiveData holds state which is observed by the UI
   // (state flows down from ViewModel)
   private val _name = MutableLiveData("")
   val name: LiveData<String> = _name

   // onNameChanged is an event we're defining that the UI can invoke
   // (events flow up from UI)
   fun onNameChanged(newName: String) {
       _name.value = newName
   }
}

class HelloCodeLabActivityWithViewModel : AppCompatActivity() {
   private val helloViewModel by viewModels<HelloCodelabViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       /* ... */

       binding.textInput.doAfterTextChanged {
           helloViewModel.onNameChanged(it.toString()) 
       }

       helloViewModel.name.observe(this) { name ->
           binding.helloText.text = "Hello, $name"
       }
   }
}

在這個例子中我們把state從Activity中轉(zhuǎn)移到了ViewModel中。state代表一個抽象的概念,在ViewModel中 state通過LiveData來表現(xiàn),也可以說是一種數(shù)據(jù)模型,只不過這個數(shù)據(jù)模型可以當(dāng)做UI的狀態(tài),用來更新UI。

其實整體上和上面的代碼差別不大,只是中間多了個ViewModel來中轉(zhuǎn)數(shù)據(jù),其實也可以是其他的observeable,只不過谷歌給大家封裝好了,就叫ViewModel。
這樣既達成了解耦的成就,也實現(xiàn)了我們所說的單向數(shù)據(jù)流。


image.png

這樣做有以下幾點好處

  1. 可測試-UI和state分離,容易分別測試ViewModel和Activity
  2. state封裝-只能通過ViewModel來更改state,可以避免局部state更新造成的bug
  3. UI一致性-state改變之后,所有觀察該state的UI會馬上更新

單向數(shù)據(jù)流就是指 符合事件向上傳遞而狀態(tài)向下傳遞的設(shè)計模式。

例如 在ViewModel中,事件通過UI的調(diào)用向上傳遞給ViewModel,而狀態(tài)通過LiveData 的 setValue 向下傳遞。
就像剛才說的,單向數(shù)據(jù)流不僅僅是描述ViewModel的術(shù)語,任何屬于這種設(shè)計的能被稱之為單向數(shù)據(jù)流。

Compose 中的state

在前面我們了解了什么是單向數(shù)據(jù)流模型,Compose也是遵循這個模型的一個UI框架,在Compose中推薦用MutableState 來管理狀態(tài),而不是LiveData。

在Compose中通常這樣聲明state

val name by mutableStateOf("Compose")

這里用到了Kotlin的by關(guān)鍵字,name的類型,取決于mutableStateOf方法傳進去的類型,在這里其實就是String類型,通過by引用的對象,在取值和賦值的時候均會調(diào)用 代理類的getValue 和 setValue方法方法,這兩個方法分別在State接口和 MutableState 中聲明。

State 中的getValue
inline operator fun <T> State<T>.getValue(thisObj: Any?, property: KProperty<*>): T = value

MutableState 中的setValue
inline operator fun <T> MutableState<T>.setValue(thisObj: Any?, property: KProperty<*>, value: T) {
    this.value = value
}

注意 這兩個方法是通過擴展方法實現(xiàn)的,所以要進行導(dǎo)入

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

在這兩個接口中只是簡單的實現(xiàn)了一下代理方法,看著沒有任何邏輯。
其實在setValue中賦值的時候,最終會調(diào)用到 this.value的set方法,在SnapshotMutableStateImpl中有實現(xiàn)。

override var value: T
        get() = next.readable(this).value
        set(value) = next.withCurrent {
            if (!policy.equivalent(it.value, value)) {
                next.writable(this) { this.value = value }
            }
        }

仿照Flutter 寫個Counter

@Composable
fun Counter() {
    var count by mutableStateOf(1)
    Button(onClick = {
        count ++
    }) {
        Text(text = count.toString())
    }
}

在這段代碼中,用代理模式,將int類型的count代理給了mutableStateOf 返回的state,然后對count進行set操作的時候就會觸發(fā) recompose,然后對Counter進行重新繪制。

上面的代碼是有問題的,Compose 不像Flutter,每個Widget都是一個類,state可以作為一個類的屬性,但是在Compose中,每個組件其實就是一個函數(shù),在這個函數(shù)里,state只作為了局部變量,當(dāng)state變化的時候,會重新調(diào)用函數(shù),導(dǎo)致state重新初始化,就失去了state的意義。官方推薦的做法是:

@Composable
fun Counter() {
    val count = remember {
        mutableStateOf(0)
    }

    Button(onClick = { count.value ++ }) {
        Text(text = count.value.toString())
    }
}

state 使用remember包裹起來,remember方法會對state實例進行保存,每次recompose 的時候會把暫存的state取出來。由于remember的返回值只能試val類型,下面又要對count更新,所以不能用by,只能用“=” 得到的是個 State,所以下面要調(diào)用.value 來更新。

其實很多時候數(shù)據(jù)的更新并不是那么簡單,比如網(wǎng)絡(luò)請求,之前那一套MVVM 在Compose框架里也完全適用。我們可以將這個簡單的Counter改為mvvm架構(gòu)的。
首先定義ViewModel

class HelloViewModel : ViewModel() {

    var cout by mutableStateOf(0)
        private set

    fun plus() {
        cout ++
    }

}

由于是從ViewModel中取數(shù)據(jù),所以state就沒必要使用remember進行包裹

修改Counter,將viewModel作為入?yún)魅?/p>

@Composable
fun Counter(viewModel: HelloViewModel) {
    Button(onClick = { viewModel.plus() }) {
        Text(text = viewModel.cout.toString())
    }
}

State改變之后是怎么recompose的

通過打斷點可以看到Compose重組時的調(diào)用棧


image.png

倒數(shù)第二行 出現(xiàn)了 Choreographer,這個類和屏幕的刷新機制息息相關(guān),Compose其實就是在收到屏幕的刷新信號時做的重組。

Compose 如何確定重組范圍

Compose 在編譯期分析出會受到某 state 變化影響的代碼塊,并記錄其引用,當(dāng)此 state 變化時,會根據(jù)引用找到這些代碼塊并標(biāo)記為 Invalid 。在下一渲染幀到來之前 Compose 會觸發(fā) recomposition,并在重組過程中執(zhí)行 invalid 代碼塊。

Invalid 代碼塊即編譯器找出的下次重組范圍。能夠被標(biāo)記為 Invalid 的代碼必須是非 inline 且無返回值的 @Composalbe function/lambda,必須遵循 重組范圍最小化 原則。

為何是 非 inline 且無返回值(返回 Unit)?
對于 inline 函數(shù),由于在編譯期會在調(diào)用處中展開,因此無法在下次重組時找到合適的調(diào)用入口,只能共享調(diào)用方的重組范圍。

而對于有返回值的函數(shù),由于返回值的變化會影響調(diào)用方,因此無法單獨重組,而必須連同調(diào)用方一同參與重組,因此它不能作為入口被標(biāo)記為 invalid。

范圍最小化原則
只有會受到 state 變化影響的代碼塊才會參與到重組,不依賴 state 的代碼不參與重組。

image.png

在這個例子中,重組的只是Text,Button并沒有重組,因為重組只發(fā)生在 state read的函數(shù)中,write的函數(shù)并不在重組范圍內(nèi)。真正重組的起始不是Text方法,而是Button 后面的lambda。

如果我們稍微改寫一下

@Composable
fun Counter(viewModel: HelloViewModel) {
    Log.d("Counter", "recompose")
    Button(
        onClick = { viewModel.plus() })
    {
        Text(text = "按鈕")
    }
    Text(text = viewModel.cout.toString(), color = Color.Black)
}

再次斷點


image.png

就再次論證了剛才的觀點,重組源頭不是Text,而是包裹著Text的方法。

當(dāng)我們嘗試用Column包裹一下


image.png

再次斷點,發(fā)現(xiàn)重組的源頭還是Counter方法,而不是Column,那是因為
Column、Row、Box 乃至 Layout 這種容器類 Composable 都是 inline 函數(shù),所以在運行時就相當(dāng)于沒有Column這一層,所以如果想通過縮小重組范圍提高性能的話可以通過自定義Composable

@Composable
fun Wrapper(content: @Composable () -> Unit) {
    Log.d(TAG, "Wrapper recomposing")
    Box {
        Log.d(TAG, "Box")
        content()
    }
}

總結(jié):

此文只是簡單介紹了Compose中的state是什么,為什么要設(shè)計state,以及簡單的介紹了一下recompose的過程,并未說明recompose到底是怎么觸發(fā)的,以及怎么確定的recompose的作用域。本文大量參考了Compose CodeLab,和Compose 技術(shù)原理,在下不才,如有疑惑之處請移步這兩篇文章。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,030評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,310評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,951評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,796評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 71,566評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,055評論 1 322
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,142評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,303評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,799評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,683評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,899評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,409評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,135評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,520評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,757評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,528評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 47,844評論 2 372

推薦閱讀更多精彩內(nèi)容