iT邦幫忙

2021 iThome 鐵人賽

DAY 16
1
Mobile Development

認真學 Compose - 對 Jetpack Compose 的問題與探索系列 第 16

D16/ 所以到底為什麼 remember 是 composable function? - @Composable 是什麼 part 2

今天大概會聊到的範圍

  • compose runtime
  • compose compiler

今天會更深入的研究 Compose 在執行過程中,發生了什麼事情、內部的資料如何存放的。大部分的資訊可能都不會在平常開發 Compose 的時候用到。
很建議大家可以看看 Leland Richardson 在 Compose 還是 alpha 的時候,在 Android Dev Summit '19 的 talk。大部分的理解都是因為這個影片(和對應的文章)才看懂的

前情提要

在上一篇有說到,Composable 最初的呼叫點是 Composer.invokeComposableComposer 內部的運作,是會將 composable 一一存放在一個陣列中(想像的陣列中)。在每次 recomposition 時,透過重頭走訪的方式,來判斷每個陣列中的物件是否需要被修改。一但需要被修改,compoer 就會將陣列尾端的空位移動至目前 cursor 所在處,並重新 compose (insert) 後續的物件。

產生空陣列 依序填入資料
重新走訪時,可能修改參數 當需要改變 composable 時,將尾端空位提前 在空位中放進新的資料

上一篇也有提到,這樣的存放資料方式,是為了讓每次做新增、刪除、修改的處理都能是固定時間的。唯獨移動空位是最耗效能的。但也因為移動空位的發生機率較低(大多時候動態的是資料),且需要發生畫面異動時往往也會一次異動一整個區塊,因此這樣的設計是最有效率的。

實際上怎麼執行?

當一個 Composable 要執行時,追根溯源最終會在 Composer 的 invokeComposable function 中,被強轉成一般的 function 執行

// class ComposerImpl

internal fun invokeComposable(composer: Composer, composable: @Composable () -> Unit) {
    @Suppress("UNCHECKED_CAST")
    val realFn = composable as Function2<Composer, Int, Unit>
    realFn(composer, 1)
}

source

值得注意的是,composable function 被轉成一般 function  時,這個 function 多了兩個參數:一個 Composer 一個 Int。其實  composable function 在 compile 時,compose compiler 會偷偷將這個 function 增加這兩個參數。

參數加上 Composer

// 在 ComposableFunctionBodyTransformer 中可以看到的說明

@Composable
fun A(x: Int) {
    f(x)
}

// getting transformed into

@Composable
fun A(x: Int, $composer: Composer<*>, $changed: Int) {
    $composer.startRestartGroup()
    // ...
    f(x)
    $composer.endRestartGroup()?.updateScope { next -> A(x, next, $changed or 0b1) }
}

source
ComposableFunctionBodyTransformer 需要依賴  ComposerParamTransformer 先執行過,  ComposerParamTransformer 會將 function 加入 composer 參數 source

執行時寫入 Group Key

執行 invokeComposable 的是 doCompose 這個 function。在執行 invokeComposable 的前後,會先 start group 並且提供一個 key。在執行完 Composable 後,會 end group。

// Composer#doCompose

startGroup(invocationKey, invocation)
invokeComposable(this, content)
endGroup()

source

這邊帶入了一個 group 的概念,其實每個 composable function 都會被一個 group 框起來,並且每個 group 都會有一個 key。

講簡單一點

爬 source code 還沒有很理解,讓我們直接轉化成 sample code 和圖示

假設我們有一個計數器,點擊 text 會讓數數字 +1

@Composable
fun Counter() {

    var count by remember { mutableStateOf(0) }

    Text(text = "count: $count", modifier = Modifier.clickable { count += 1 })

}

可以想像他會在背景被加上兩個參數:composer 和 key

@Composable
fun Counter( $composer:Composer, $key: Int) {        // <-- input composer and key

    var count by remember { mutableStateOf(0) }

    Text(text = "count: $count", modifier = Modifier.clickable { count += 1 })

}

在整個 composable 的最開始與最後,會對 composer 呼叫 start ( start group ) 與 end ( end group ),並給他 key

@Composable
fun Counter( $composer:Composer, $key: Int) {
    $composer.start($key)     // <-- start group with $key
    var count by remember { mutableStateOf(0) }

    Text(text = "count: $count", modifier = Modifier.clickable { count += 1 })
    $composer.end()          // <-- end group with $key
}

這個動作會穿透給一個 composable,composer 會一路被傳下去、並替每個 composable 定義一個不同的 key ( 這邊用 123, 456 代替 )

@Composable
fun Counter( $composer:Composer, $key: Int) {
    $composer.start($key)     
    var count by remember($composer, key = 123) { mutableStateOf(0) }    // <-- composer 會傳遞給 child composable

    Text(text = "count: $count", modifier = Modifier.clickable { count += 1 }, $composer, key = 456)  
    $composer.end()       
}

當今天 Composer 在走訪整個 composable 的時候

  1. $composer.start 時,會先將 $key 寫入陣列中
  2. 走訪到 remember,會將 remember 的 group 寫在陣列中(並帶有 key = 123 )
  3. 並且將 remember 這個 composable 的參數與回傳寫進陣列中 ( 這邊沒有參數,回傳為 state )
  4. 接著走到 Text,一樣會先寫一個 group ( key = 456 )
  5. Text 的參數寫進陣列中 ( text / modifier )
  6. Text 內部可能還會有別的 composable,他們會遞迴的產生一個又一個的 Group

基本上, Composer 透過一個一維的資料結構,存放了樹狀 composable 資料結構。

再舉個例子,如果我們的 Composable 中有條件判斷

@Composable
fun App() {
    val result = getData()
    
    if (result == null) {
        Loading()
    } else {
        Page(result)
    }
}

在加入 composer 時,compose compiler 會分析整個 function 中,有可能有變動的地方,在不同的 branch 加上不同的 group。

@Composable
fun App($composer) {
    val result = getData()
    
    if (result == null) {
        $composer.start(123)    // group 123
        Loading()
        $composer.end()
    } else {
        $composer.start(456)    // group 456
        Page(result)
        $composer.end()
    }
}

假設第一次 result 真的是 null,整個資料陣列會是這個樣子的:

https://ithelp.ithome.com.tw/upload/images/20210930/20141597oFy85VFGom.png

但當我第二次進來,假設 getData 有值時,Composer 會在走到 if-else 這邊取得 Group(456)。Composer 發現之前存的 Group key 和期待的不同時,會將尾端的空位移到目前這個 Group 的位置,並且開始重新 insert 後續的 element

https://ithelp.ithome.com.tw/upload/images/20210930/20141597Hi8y5xrrnk.png

Positional Memorization

其實 Composer 還藏了另一個武器,被稱為 Positional Memorization。他其實很像 locale cache,composer 會將參數與運算的資料與運算結果一同放在列表中。當重新走訪時,Composer 會確認目前的參數是否與這個 Group 的參數一樣,若一樣,Composer 將不會花力氣去運算,而是直接回傳運算結果。

這邊有個在研究這段時發現的有去事實。

一直以為是 remember 這個 function 在幫我記錄 state。但是其實 remember 的 lambda 只會在首次 composition 時執行一次,在後續的 recomposition 不會再次執行。若希望再次執行,要在呼叫 remember lambda 時,帶入 key 。

 remember("key") {  ... } 

帶入 key 的 remember 會在 Group 的第一格寫入 key , 第二格寫入 remember lambda 執行的結果。當key 改變時,才會重新觸發 lambda 內的運算。

我們目前的用法,其實是依賴 mutableStateOf 所產生出來的 MutableStateMutableState 會產生一個 Snapshot,當 Snapshot 改變時觸發 recompistion。所以在 recompistion 時,Composer 拿出來的 mutableState 其實是同一個,是裡面的 value 不同

小結

回到最初的問題,到底 @Composable 做了什麼,為什麼 remember 等各種 function 都是 @Composable 呢?答案可以簡化成:@Composable function 是一個種可以被 Composer 認得,並解紀錄的元件。這些元件的參數改變時,將會觸發後續的 composable tree 被重新繪製。

越研究這個主題,越覺得自己跳進了一個無底洞。回答了一個 remember 的問題,卻產生更多問題還沒有好好被回答到:為什麼 LocalDensity.current 也是 composable? mutableState 怎麼觸發 recomposition 的?

雖然這些對使用 Compose 來說可能真的沒什麼幫助,但研究後卻被這個新的 framework 精妙的設計所震憾,也在爬 source code 的過程學到了很多。


Reference:


上一篇
D15/ 為什麼 remember 是 composable function? - @Composable 是什麼
下一篇
D17/ 我要用的 View 沒有支援 Compose 怎麼辦? - AndroidView
系列文
認真學 Compose - 對 Jetpack Compose 的問題與探索30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言