Jetpack Compose Composition 和 Recomposition 那一兩件事情

Jast Lai
Jastzeonic
Published in
14 min readNov 28, 2021

在此之前,我一直以為使用 compose 能和使用 Flutter 一樣,只要能夠讓他更新 State 就好,直到我發現事情並不是這樣…
- Jast Lai

前言

Jetpack Compose 可以算是 Flutter 的攣生兄弟,我常常這樣說,但是兩者顯然還是有差別的,而且這差別直到我在接觸需要使用動畫的時候才發現。

之前我使用 Flutter ,我要把一個自從左移到右邊,

class _MyHomePageState extends State<MyHomePage> {
double padding = 0.0;

void _incrementCounter() {
runMoveAnimation();
}

Future runMoveAnimation() async {
for (var i = 0; i < 100; i++) {
await Future.delayed(Duration(milliseconds: 10), () {
padding += 0.4;
setState(() {});
});
}
}


@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: genText(),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}

List<Widget> genText() {
return List<Widget>.generate(
35,
(index) => Padding(
padding: EdgeInsets.only(left: padding),
child: Text("Text")));
}

}

那可以看到我每次 setState ,都會更新一次我設定的 padding ,運用 delay 達到每次增加一點 padding 的效果來製造 text 移動的假象。

PS:當然我 Flutter 是用 Release 版的,開 Debbug 會有奇妙的延遲狀況,這個我想有玩過 Flutter 都很清楚

只是我用了相同的邏輯,到了 Compose 卻發生了一點問題:

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Greeting()
}
lifecycleScope.launch {
delay(1000)
var takeTime = System.currentTimeMillis()
repeat(100) {
delay(50)
animationPoint.value = animationPoint.value + 1
}
takeTime = System.currentTimeMillis() - takeTime
Log.v("tag", "end $takeTime")
}
}
}

var animationPoint = mutableStateOf(0)

@Composable
fun Greeting() {
LogCompositions("tagtag", "")
Surface(
modifier = Modifier
.fillMaxWidth()
.offset(x = animationPoint.value.dp)
) {
Column {
repeat(40)

Text("Text ${animationPoint.value.dp}")
}

}
}
}

那結果如下:

那其實可以看得出來,開頭有一點點微妙的 Delay … 這不禁讓人納悶了,難不成 Flutter 在這方面技高一籌?

關於 MutableState 那一兩件事情

Jetpack Compose 畢竟還是依著 Android Native view 而生的,很多地方還是得依循著 Android Native 的原則。如果跟我一樣,在碰過 Flutter 後再去寫 Compose,當我需要跟 Flutter 一樣用 setState 來去更新狀態時,會赫然發現根本沒有類似 setState 之類的東西可以使用。

取而代之的是 MutableState ,接著就會有很奇妙的語法出現:

@Composable
fun Greeting() {
var rememberValue by remember {
mutableStateOf(0)
}
LogCompositions("tagtag", "")
Surface(
// ...ignore
)
}

這都啥玩意阿?要我在一個 function 裏頭記住這個 value ?

但這倒是可以從 remember 這個 method 的說明得到一點線索。

Remember the value produced by calculation. calculation will only be evaluated during the composition. Recomposition will always return the value produced by composition.

記住藉由計算出來的結果。計算只會在 composition 的時發生,Recomposition 會回傳在 composition 產生時的 value。

Composition ? Recomposition ?

先從這兩個名詞開始解釋吧。

一開始弄一個畫面時,我們依照 Compose 本身的寫法,把 Layout 給弄出來,以上面那個例子為主,我們利用 Column 把 40 個 Text 給畫到畫面上。

那可以看到是透過 Activity 的 onCreate 呼叫一個叫做 setContent 的東西在裏頭呼叫 Greeting() 把畫面畫上去的。

setContent {
Greeting()
}

setContent 原則上跟大家都很熟的 setContentView 一樣,只是到這轉成使用 ComposeView 了,這對 Compose 來說,在啥都沒有的情況下把 ComposeView 弄上去,呼叫 annotation composable 的 method ,這個動作叫做 Composition。

那問題來了,我 Text 甚麼的都在 onCreate 的時候呼叫到了,那我要更新某個 Text 的時候要怎麼辦?

再呼叫一次 setContent {} 嗎?

有意思的地方是在 compose 建立之後,在這邊就會被攔截掉了,雖然這個 method 被 deprecate 了,也許會來會變,但目前就是這樣,所以不能寄望用 setContent 來更新 View 的 state 了。

那有甚麼方法呢?

用 MutableState 。

這玩意跟 LiveData 類似,只是整體過程看起來更玄學,有機會再來做解釋好了。總之,當你 assign 他的 Value 他會 trigger 有下 Annotation 的 composable 的 function。

例如:

var text = mutableStateOf("Hello")

@Composable
fun Greeting() {
Surface(
modifier = Modifier .fillMaxWidth()
) {
Column {
Text(text.value)
}
}
}

那我如果寫了

text.value = "Hello 2"

則會觸發:

@Composable
fun Greeting() {
Surface(
modifier = Modifier.fillMaxWidth()
) {
Column {
Text(text.value)
}
}
}

整個 Greeting() 被 invoke ,這個過程我們稱為 Recomposition。那這個 Text 也被成功的更新顯示成 Hello 2。

只是有意思的地方在這裡,這邊看起來跟 Flutter 的 setState 很類似,但是事實上兩者還是有區別的,我一直在說 Compose 和 Flutter 像是攣生兄弟,Widget 得名字都相似,但是兩者在根本層面上還是有出入,我目前碰到最大的出入就是 state 管控上。

Flutter 會根據 widget 輸入的 parameter 做 diff,來決定 widget 要更新甚麼,所以可以直接用 setState 來更新整個 UI tree。而 Compose 雖然也可以這樣可以,但對 Compose 來說,更新整顆 UI tree 非常昂貴。我猜是因為 draw 的方式不同,Compose 應該還是依賴 Android Native 的 canvas 而有所限制,不能很單純的 diff parameter,具體還得再研究。所以 Compose 會更傾向 Recomposition 需要的 Composable。

以上面的例子來說,會 Recomposition 到整個 Surface ,那其實有點沒有必要,這邊可以改寫成這樣:

var text = mutableStateOf("Hello")

@Composable
fun Greeting() {
Surface(
modifier = Modifier.fillMaxWidth()
) {
Column {
genText()
}
}
}
@Composable
fun genText() {
Text(text.value)
}

這樣便可以把 Recomposition 的範圍限制在 genText() 內,不會把範圍擴大到 Surface 和 Column 那頭。

那可能有人會好奇了,如果說今天寫成這樣:

var text = mutableStateOf("Hello")

@Composable
fun Greeting() {
Surface(
modifier = Modifier.fillMaxWidth()
) {
Column {
genText()
Text(text.value)
}
}
}
@Composable
fun genText() {
Text(text.value)
}

那這樣我更新 text會發生甚麼事情?

這樣也只會更新一次而已,範圍是 Greeting() 下至 genText()。genText() 並不會單獨再跟新一次

通常更新到的部分會以最外層,又或者以比較資結的語言說:只會更新最接近 UI tree 最接近 root node 的 composable method 為優先 Recomposition。

但說昂貴是昂貴,若不是在使用到動畫這種需要精準控制到每一個 Frame 、重畫很多 Compose 的時候,也就不需要這麼刻意去減少 Recomposition 的範圍就是了,在使用上需要知道自己在做甚麼,而是自己眼下的情況去做改變,不要太過滑坡認為一律要盡可能減少 Recomposition 就是了。

那 by Remember 是啥玩意?

那話說回來 Remember 那又是啥鬼玩意?

現在對 Recomposition 有概念了,我們再回來看這段話:

Remember the value produced by calculation. calculation will only be evaluated during the composition. Recomposition will always return the value produced by composition.

一開始我們 setContent 在甚麼都沒有的情況下生成 Compose 時叫 composition ,透過 MutableState 的 setValue trigger 已存在的 Compose 更新其 state 叫做 recomposition。也就是說使用 remember 產生的 MutableState ,在 recomposition 時,並不會被重新生成,而是沿用一開始那個。

這有啥用呢?我們很常會有一整排 View 都需要被管控的時候,但是實際上該 View 的狀態只會在某個 Compose 的範圍內會被影響到,那如果那時候需要一整排 MutableState 當成 Global value 也是挺擾人的。例如我有一個 TextField ,下面有個按鈕只有在 TextField 有字時才能按,那會寫成這樣:

var text = mutableStateOf("")

@Composable
fun Greeting() {
var enableButton by remember {
mutableStateOf(false)
}
Surface(
modifier = Modifier.fillMaxWidth()
) {
Column {
TextField(value = text.value, onValueChange = {
enableButton = it.isNotEmpty()
text.value = it
}
)
Button(
onClick = { },
enabled = enableButton
) {
Text(text = "Button")

}
Text(text.value)
}
}
}

那看起來 Code 跑起來跟我想的一樣,這樣就解釋了 remember 的目的了。

結語

認真地說,如果不是認為 Jetpack compose 跟 Flutter 類似,然後用類似的做法去做了動畫,也就不會踩到這個坑了,也就不會藉此瞭解所謂的 Recomposition 和 remember 的目的了。

說來也有趣,上頭那個動畫是相當極端的狀況,即便真得要搞一個遊戲的動畫也不太可能碰到這情況就是了(所以我還沒想到方法讓 Compose 版跑得跟 Flutter 版一樣順暢,畢竟只是範例而已)。但說實話,如果真的要用 Compose 搞一個相當動態的畫面時,目前情況還是需要避免不必要的 Recomposition 就是了。

如果有任何問題,或是看到寫錯字,請不要吝嗇對我發問或對我糾正,您的回覆和迴響會是我寫文章最大的動力。

參考文章

https://developer.android.com/jetpack/compose/mental-model

--

--

Jast Lai
Jastzeonic

A senior who happened to be an Android engineer.