JetPack Compose 之 state

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

我們通常都是用下面的結構來開發(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緊密結合窘行,導致難以進行單測
  2. 當有很多事件需要更新state時,可能會忘記更新state
  3. 當每個state變化時图仓,都要手動去更新UI罐盔,如果忘記了就會導致UI顯示異常
  4. 導致代碼邏輯復雜

單向數(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中轉移到了ViewModel中。state代表一個抽象的概念六孵,在ViewModel中 state通過LiveData來表現(xiàn)纬黎,也可以說是一種數(shù)據(jù)模型,只不過這個數(shù)據(jù)模型可以當做UI的狀態(tài)狸臣,用來更新UI莹桅。

其實整體上和上面的代碼差別不大,只是中間多了個ViewModel來中轉數(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)向下傳遞的設計模式选脊。

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

Compose 中的state

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

在Compose中通常這樣聲明state

val name by mutableStateOf("Compose")

這里用到了Kotlin的by關鍵字,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)的,所以要進行導入

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只作為了局部變量,當state變化的時候咧栗,會重新調(diào)用函數(shù)逆甜,導致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)絡請求,之前那一套MVVM 在Compose框架里也完全適用咽块。我們可以將這個簡單的Counter改為mvvm架構的绘面。
首先定義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晚凿,這個類和屏幕的刷新機制息息相關,Compose其實就是在收到屏幕的刷新信號時做的重組瘦馍。

Compose 如何確定重組范圍

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

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

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

而對于有返回值的函數(shù)柑营,由于返回值的變化會影響調(diào)用方屈雄,因此無法單獨重組,而必須連同調(dià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的方法堕汞。

當我們嘗試用Column包裹一下


image.png

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

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

總結:

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

?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市误阻,隨后出現(xiàn)的幾起案子债蜜,更是在濱河造成了極大的恐慌,老刑警劉巖究反,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件寻定,死亡現(xiàn)場離奇詭異,居然都是意外死亡精耐,警方通過查閱死者的電腦和手機狼速,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來卦停,“玉大人向胡,你說我怎么就攤上這事【辏” “怎么了僵芹?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵,是天一觀的道長小槐。 經(jīng)常有香客問我拇派,道長,這世上最難降的妖魔是什么凿跳? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任件豌,我火速辦了婚禮,結果婚禮上控嗜,老公的妹妹穿的比我還像新娘茧彤。我一直安慰自己,他們只是感情好躬审,可當我...
    茶點故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布棘街。 她就那樣靜靜地躺著,像睡著了一般承边。 火紅的嫁衣襯著肌膚如雪遭殉。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天博助,我揣著相機與錄音险污,去河邊找鬼。 笑死富岳,一個胖子當著我的面吹牛蛔糯,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播窖式,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼蚁飒,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了萝喘?” 一聲冷哼從身側響起淮逻,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎阁簸,沒想到半個月后爬早,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡启妹,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年筛严,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片饶米。...
    茶點故事閱讀 40,040評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡桨啃,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出咙崎,到底是詐尸還是另有隱情优幸,我是刑警寧澤,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布褪猛,位于F島的核電站网杆,受9級特大地震影響,放射性物質發(fā)生泄漏伊滋。R本人自食惡果不足惜碳却,卻給世界環(huán)境...
    茶點故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望笑旺。 院中可真熱鬧昼浦,春花似錦、人聲如沸筒主。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至使兔,卻和暖如春建钥,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背虐沥。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工熊经, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人欲险。 一個月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓镐依,卻偏偏與公主長得像,于是被迫代替她去往敵國和親天试。 傳聞我的和親對象是個殘疾皇子槐壳,可洞房花燭夜當晚...
    茶點故事閱讀 44,979評論 2 355

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