解決 Jetpack Compose 中效能問題的實用做法

1. 事前準備

在本程式碼研究室中,我們會說明如何提升 Compose 應用程式的執行階段效能,引導您採用科學方法評估、偵錯和提高效能。您將利用系統追蹤調查多個效能問題,並修改範例應用程式中效能不佳的執行階段程式碼。範例應用程式中含有多個畫面,分別代表不同工作。每個畫面皆以不同方式建構,列舉如下:

  • 第一個畫面是有兩個資料欄的清單,清單中有圖片項目,項目頂端有些標記。您將在這個畫面改進耗用大量資源的可組合項。

8afabbbbbfc1d506.gif

  • 第二和第三個畫面含有經常重組的狀態。您將在這些畫面中移除非必要的重組作業,進而提升效能。

f0ccf14d1c240032.gif 51dc23231ebd5f1a.gif

  • 最後一個畫面含有不穩定的項目。您將在這個畫面中使用多種技巧,將項目調整為穩定狀態。

127f2e4a2fc1a381.gif

必要條件

課程內容

  • 如何利用系統追蹤組合追蹤找出效能問題。
  • 如何編寫能流暢算繪且效能良好的 Compose 應用程式。

軟硬體需求

2. 做好準備

首先,請按照下列步驟操作:

  1. 複製 GitHub 存放區:
$ git clone https://github.com/android/codelab-android-compose.git

您也可以透過 ZIP 檔案下載存放區:

  1. 開啟 PerformanceCodelab 專案,該專案含有下列分支版本:
  • main:包含此專案的範例程式碼,您將修改這些程式碼來完成本程式碼研究室。
  • end:含有本程式碼研究室的解決方案程式碼。

建議您先從 main 分支版本的程式碼著手,依自己的步調逐步完成本程式碼研究室。

  1. 如要查看解決方案程式碼,請執行以下指令:
$ git clone -b end https://github.com/android/codelab-android-compose.git

或者,您也可以下載解決方案程式碼:

選用:本程式碼研究室使用的系統追蹤

您將執行幾項基準測試,擷取本程式碼研究室執行期間的系統追蹤記錄。

如果無法執行這些基準測試,可以改為下載以下系統追蹤記錄:

3. 修正效能問題的方法

您可以直接用肉眼瀏覽應用程式,找出效能不佳的緩慢 UI,但在根據假設著手修正程式碼之前,您應評估程式碼的效能,以便瞭解所做變更是否會有效。

如果您在開發期間使用應用程式的 debuggable 版本,可能會發現某部分的效能未符合需求,因而想著手處理這項問題。但 debuggable 應用程式的效能並不代表使用者體驗,因此請務必使用 non-debuggable 應用程式確認是否真的存在問題。在 debuggable 應用程式中,所有程式碼都須在執行階段前解讀。

以 Compose 中的效能而言,您不必遵照任何硬性規則來實作特定功能,也不應過早採取以下措施:

  • 緊盯並修正程式碼中出現的每個不穩定參數。
  • 移除導致可組合項重組的動畫。
  • 憑直覺採用難以解讀的改進措施。

您應利用合適工具明智地採取修正措施,確保這些措施能解決效能問題。

處理效能問題時,您應採取以下科學方法:

  1. 評估並確認初始效能。
  2. 觀察問題成因。
  3. 根據觀察結果修改程式碼。
  4. 再次評估效能,並與初始效能比較。
  5. 重複上述步驟。

如不採用條理分明的方法,某些修正措施或許雖能提升效能,其他措施卻可能造成反效果,導致最終回到原點。

建議您觀看下列影片,瞭解如何使用 Compose 提升應用程式效能,包括修正效能問題的整個流程,以及一些提升效能的秘訣。

產生基準設定檔

著手調查效能問題前,請為應用程式產生基準設定檔。在 Android 6 (API 級別 23) 以上版本,應用程式執行的程式碼是在執行階段解讀,並在安裝時進行及時 (JIT) 和預先 (AOT) 編譯。比起 AOT 編譯,經過解讀的 JIT 編譯程式碼速度較慢,但占用的磁碟和記憶體空間較少,因此並非所有程式碼都應採用 AOT 編譯。

實作基準設定檔後,您可以將應用程式啟動程序的效能提高 30%,並將在執行階段以 JIT 模式執行的程式碼減少八倍,如以下 Now in Android 範例應用程式的圖片所示:

b51455a2ca65ea8.png

如要進一步瞭解基準設定檔,請參閱下列資源:

評估效能

如要評估效能,建議使用 Jetpack Macrobenchmark 設定及編寫基準測試。Macrobenchmark 是檢測設備測試,會以使用者身分與應用程式互動,同時監控應用程式效能。也就是說,這類測試不會插入測試程式碼,因此不會汙染應用程式程式碼,還能提供可靠的效能資訊。

在本程式碼研究室中,我們已設定程式碼集和編寫基準測試,因此重點會直接放在修正效能問題。如果不確定如何在專案中設定和使用 Macrobenchmark,請參閱下列資源:

透過 Macrobenchmark,您可以選擇下列其中一種編譯模式

  • None:重設編譯狀態,並以 JIT 模式執行所有程式碼。
  • Partial:使用基準設定檔和/或暖機疊代預先編譯應用程式,並以 JIT 模式執行。
  • Full:預先編譯完整應用程式程式碼,因此沒有程式碼會以 JIT 模式執行。

在本程式碼研究室中,您只會針對基準測試使用 CompilationMode.Full() 模式,因為您只需在乎所做的程式碼變更,不必考量應用程式的編譯狀態。這個做法能減少以 JIT 模式執行程式碼所造成的變異數,而在實作自訂基準設定檔後,應能減少這類問題。請注意,Full 模式可能會對應用程式啟動作業造成負面影響,因此請勿將這個模式用於評估應用程式啟動作業的基準測試,只應用於評估執行階段效能提升幅度的基準測試。

實行效能提升措施後,如要檢查使用者安裝應用程式時的效能,請使用 CompilationMode.Partial() 模式的基準設定檔。

在下一節中,您將瞭解如何讀取追蹤記錄,找出效能問題。

4. 利用系統追蹤分析效能

透過應用程式的 debuggable 版本,您可以使用版面配置檢查器和組合計算功能,快速瞭解項目的重組作業何時過於頻繁。

b7edfea340674732.gif

不過,這只是整體效能調查的一環,因為您只取得了 Proxy 評估結果,而非取得可組合項的實際算繪時間。如果總耗時少於一毫秒,對重組 N 次的項目而言,影響或許不大。但如果項目只重組一或兩次,且每次耗時 100 毫秒,這就有重大影響。可組合項通常只可能組合一次,如果耗時過長,就會拖慢畫面顯示速度。

如要確實調查效能問題,深入瞭解應用程式執行作業的內容以及是否耗時過長,您可以使用「系統追蹤」搭配組合追蹤。

系統追蹤可提供時間資訊,讓您瞭解各項應用程式作業。此外,系統追蹤不會增加應用程式負擔,因此可保留在正式版應用程式中,不必擔心會對效能造成負面影響。

設定組合追蹤

Compose 會自動填入一些執行階段資訊,例如項目的重組時間,或是延遲版面配置預先擷取項目的時間。不過,這些資訊並不足以找出可能有問題的區段。如要提升資訊量,您可以設定組合追蹤,瞭解系統在記錄期間所組合的每個可組合項名稱。這樣您就能開始調查效能問題,而不必新增許多自訂 trace("label") 區段。

如要啟用組合追蹤,請按照下列步驟操作:

  1. runtime-tracing 依附元件新增至 :app 模組:
implementation("androidx.compose.runtime:runtime-tracing:1.0.0-beta01")

這時您可以使用 Android Studio 分析器取得系統追蹤記錄,該分析器會納入所有資訊,但我們將使用 Macrobenchmark 評估效能和取得系統追蹤記錄。

  1. 將額外依附元件新增至 :measure 模組,使用 Macrobenchmark 啟用組合追蹤:
implementation("androidx.tracing:tracing-perfetto:1.0.0")
implementation("androidx.tracing:tracing-perfetto-binary:1.0.0")
  1. androidx.benchmark.fullTracing.enable=true 檢測引數新增至 :measure 模組的 build.gradle 檔案:
defaultConfig {
    // ...
    testInstrumentationRunnerArguments["androidx.benchmark.fullTracing.enable"] = "true"
}

如要進一步瞭解如何設定組合追蹤,包括如何從終端機使用此追蹤記錄,請參閱說明文件

使用 Macrobenchmark 擷取初始效能

擷取系統追蹤記錄檔案的方法有很多種。舉例來說,您可以使用 Android Studio 分析器記錄透過裝置擷取,或使用 Macrobenchmark 擷取系統追蹤記錄。在本程式碼研究室中,您將使用以 Macrobenchmark 程式庫擷取的追蹤記錄。

這項專案的 :measure 模組含有基準測試,執行這些測試即可取得效能評估結果。為節省時間,本程式碼研究室的專案已調整設定,因此基準測試只會疊代一次。在實際應用程式中,如果輸出變異數較高,建議至少疊代十次。

如要擷取初始效能,請採用捲動第一個工作畫面的 AccelerateHeavyScreenBenchmark 測試,並按照下列步驟操作:

  1. 開啟 AccelerateHeavyScreenBenchmark.kt 檔案。
  2. 使用基準測試類別旁的空白邊動作,執行基準測試:

e93fb1dc8a9edf4b.png

這項基礎測試會捲動「Task 1」畫面,擷取影格時間和自訂

追蹤區段。

8afabbbbbfc1d506.gif

基準測試結束後,您應在 Android Studio 輸出窗格中看到以下結果:

AccelerateHeavyScreenBenchmark_accelerateHeavyScreenCompilationFull
ImagePlaceholderCount               min  20.0,   median  20.0,   max  20.0
ImagePlaceholderMs                  min  22.9,   median  22.9,   max  22.9
ItemTagCount                        min  80.0,   median  80.0,   max  80.0
ItemTagMs                           min   3.2,   median   3.2,   max   3.2
PublishDate.registerReceiverCount   min   1.0,   median   1.0,   max   1.0
PublishDate.registerReceiverMs      min   1.9,   median   1.9,   max   1.9
frameDurationCpuMs                  P50    5.4,   P90    9.0,   P95   10.5,   P99   57.5
frameOverrunMs                      P50   -4.2,   P90   -3.5,   P95   -3.2,   P99   74.9
Traces: Iteration 0

以下是輸出內容中的重要指標:

  • frameDurationCpuMs:說明算繪影格的時間長度,越短越好。
  • frameOverrunMs:說明超出影格時限的時間長度,包含 GPU 上的作業。負數表示未超過時限,是好的結果。

ImagePlaceholderMs 等其他指標使用自訂追蹤區段,以及根據追蹤記錄檔案中所有區段加總的輸出結果,包括與 ImagePlaceholderCount 指標一同發生的次數和時長。

上述所有指標都能協助我們瞭解,所做的程式碼集變更是否確實提升效能。

讀取追蹤記錄檔案

您可以透過 Android Studio 或網路式工具 Perfetto 讀取系統追蹤。

Android Studio 分析器能迅速開啟追蹤記錄並顯示應用程式程序,Perfetto 則提供強大的 SQL 查詢和更多功能,可更深入調查系統執行的所有程序。在本程式碼研究室中,您將使用 Perfetto 分析系統追蹤記錄。

  1. 開啟 Perfetto 網站,該網站會載入工具資訊主頁。
  2. 在主機檔案系統的 [module]/outputs/connected_android_test_additional_output/benchmarkRelease/connected/[device]/ 資料夾中,找出 Macrobenchmark 擷取的系統追蹤記錄。每個基準測試疊代都會產生獨立的追蹤記錄檔案,檔案中均含有相同的應用程式互動。

51589f24d9da28be.png

  1. AccelerateHeavyScreenBenchmark_...iter000...perfetto-trace 檔案拖曳至 Perfetto UI,等待系統載入追蹤記錄檔案。
  2. 選用:如果無法執行基準測試和產生追蹤記錄檔案,請先下載記錄檔案,再拖曳至 Perfetto:

547507cdf63ae73.gif

  1. 找出名為 com.compose.performance 的應用程式程序。前景應用程式通常位於硬體資訊通道和一些系統通道下方。
  2. 開啟含有應用程式程序名稱的下拉式選單。畫面上的清單會列出應用程式目前執行的執行緒。請保持開啟追蹤記錄檔案,這會在下一步驟中會用到。

582b71388fa7e8b.gif

如要找出應用程式的效能問題,可以利用應用程式執行緒清單上方的「Expected Timeline」和「Actual Timeline」:

1bd6170d6642427e.png

「Expected Timeline」說明系統預期應用程式需要多少時間,才能產生影格並顯示效能良好的流暢 UI。在本例中,預期時間為 16 毫秒和 600 微秒 (1000 毫秒/60)。「Actual Timeline」顯示應用程式實際產生影格的時間長度,包含 GPU 工作。

不同顏色代表的意義如下:

  • 綠色影格:影格及時產生。
  • 紅色影格:影格卡頓,產生時間超出預期。您應調查這些影格中執行的工作,避免效能問題。
  • 淺綠色影格:影格在時限內產生,但較晚顯示,因此輸入延遲時間較長。
  • 黃色影格:影格卡頓,但並非應用程式所致。

系統在螢幕上算繪 UI 時,顯示變更的速度必須比裝置預期建立影格所需的時間更快。過去的螢幕刷新率為 60 Hz,影格建立時間約為 16.6 毫秒,但新型 Android 裝置的刷新率為 90 Hz 以上,因此影格建立時間可能約為 11 毫秒或更短。此外,由於刷新率有所不同,每個影格的建立時間也不盡相同。

舉例來說,如果 UI 含有 16 個項目,則每個項目約須在 1 毫秒內建立,才能避免影格遭略過。另一方面,如果只有一個項目 (例如影片播放器),最多可使用 16 毫秒來組合項目,而不出現卡頓。

解讀系統追蹤呼叫圖

下圖是簡化版系統追蹤記錄的範例,正在顯示重組作業。

8f16db803ca19a7d.png

上方列即為下方各列時間的總和,每一列也分別對應至所呼叫的程式碼區段。Compose 呼叫會在組合階層中重組。第一個可組合項為 MaterialThemeMaterialTheme 內部是本機組合,提供主題資訊。系統會在此呼叫 HomeScreen 可組合項。主畫面可組合項會呼叫 MyImageMyButton 可組合項,做為組合的一部分。

系統追蹤記錄中的間隙是來自已執行但未追蹤的程式碼,因為這份記錄只顯示已標記追蹤的程式碼。這類程式碼的執行時間是在系統呼叫 MyImage 後,但在呼叫 MyButton 之前,所占用的時間長度即間隙大小。

在下一步驟中,您將分析上一步驟擷取的追蹤記錄。

5. 加速處理耗用大量資源的可組合項

嘗試提升應用程式效能的首要步驟,應是找出耗用大量資源的可組合項,或在主執行緒上長時間執行的工作。長時間執行的工作可能指不同項目,具體取決於 UI 複雜度,以及系統組合 UI 時可用的時間長度。

因此,如果影格遭到捨棄,您需要找出並加快處理耗時過長的可組合項,方法是卸載主執行緒,或略過主執行緒上的部分工作。

如要分析在 AccelerateHeavyScreenBenchmark 測試中擷取的追蹤記錄,請按照下列步驟執行:

  1. 開啟在上一步驟中擷取的系統追蹤記錄。
  2. 放大檢視第一個長影格,其中含有資料載入後的 UI 初始化作業。影格內容類似下圖所示:

838787b87b14bbaf.png

在追蹤記錄的 Choreographer#doFrame 區段下方,您可以看到許多作業發生在單一影格內。如圖中所示,最大宗的工作來自含有 ImagePlaceholder 區段的可組合項,會載入大型圖片。

切勿在主執行緒載入大型圖片

如要從網路非同步載入圖片,使用其中一個便利程式庫 (例如 CoilGlide) 或許是顯而易見的做法,但如果需要顯示應用程式內的大型圖片呢?

常見的 painterResource 可組合函式是在組合期間,從資源載入圖片/在執行緒載入圖片。換句話說,如果圖片很大,可能會阻斷主執行緒上的部分工作。

在本例中,您可以看到非同步圖片預留位置的問題。painterResource 可組合項載入一張預留位置圖片,約需 23 毫秒。

c83d22c3870655a7.jpeg

改善這項問題的方法有很多種,例如:

  • 非同步載入圖片。
  • 縮小圖片以加快載入速度。
  • 使用向量可繪項目,根據所需大小縮放。

如要解決這項效能問題,請按照下列步驟操作:

  1. 前往 AccelerateHeavyScreen.kt 檔案。
  2. 找出載入圖片的 imagePlaceholder() 可組合項。預留位置圖片的尺寸為 1600 x 1600 px,對顯示內容來說顯然太大。

53b34f358f2ff74.jpeg

  1. 將可繪項目變更為 R.drawable.placeholder_vector
@Composable
fun imagePlaceholder() =
    trace("ImagePlaceholder") { painterResource(R.drawable.placeholder_vector) }
  1. 再次執行 AccelerateHeavyScreenBenchmark 測試。這麼做會重建應用程式,並再次擷取系統追蹤記錄。
  2. 將系統追蹤記錄拖曳至 Perfetto 資訊主頁。

或者,您可以下載追蹤記錄:

  1. 搜尋 ImagePlaceholder 追蹤記錄區段,該區段會直接顯示經過改進的部分。

abac4ae93d599864.png

  1. 發現 ImagePlaceholder 函式不再嚴重阻斷主執行緒。

8e76941fca0ae63c.jpeg

就實際應用程式中的其他解決方案而言,造成問題的可能不是預留位置圖片,而是某張圖片。在這種情況下,您或許能使用 CoilrememberAsyncImage 可組合項,以非同步方式載入可組合項。在載入預留位置前,這項解決方案會顯示空格,因此請注意,您可能需要為這類圖片提供預留位置。

在下一步驟中,您將處理其他效能不佳的項目。

6. 將耗用大量資源的作業卸載至背景執行緒

如果繼續調查相同項目的其他問題,您會遇到多個名為 binder transaction 的區段,每個區段約耗時 1 毫秒。

5c08376b3824f33a.png

如有名為 binder transaction 的區段,表示您的程序和部分系統程序間發生處理序間通訊。這是從系統擷取資訊的正常做法,例如擷取系統服務。

許多與系統通訊的 API 均含有這類交易。舉例來說,使用 getSystemService 擷取系統服務時,會註冊廣播接收器,或要求 ConnectivityManager

遺憾的是,這類交易不會詳細說明自身要求的內容,因此您必須針對所提及的 API 使用情形分析程式碼,然後新增 trace 區段,確認有問題的部分。

如要改進繫結器交易,請按照下列步驟操作:

  1. 開啟 AccelerateHeavyScreen.kt 檔案。
  2. 找出 PublishedText 可組合函式。這個可組合項會設定含有目前時區的日期時間,並註冊可追蹤時區變化的 BroadcastReceiver 物件。此項目含有 currentTimeZone 狀態變數,初始值是預設的系統時區。另外,還含有針對時區變化註冊廣播接收器的 DisposableEffect。最後,這個可組合項使用 Text 顯示經過格式化的日期時間。本例很適合使用 DisposableEffect,因為您需要取消註冊廣播接收器,這項操作是在 onDispose lambda 中執行。不過,有問題的是 DisposableEffect 中阻斷主執行緒的程式碼:
@Composable
fun PublishedText(published: Instant, modifier: Modifier = Modifier) {
    val context = LocalContext.current
    var currentTimeZone: TimeZone by remember { mutableStateOf(TimeZone.currentSystemDefault()) }

    DisposableEffect(Unit) {
        val receiver = object : BroadcastReceiver() {
            override fun onReceive(context: Context?, intent: Intent?) {
                currentTimeZone = TimeZone.currentSystemDefault()
            }
        }

        // TODO Codelab task: Wrap with a custom trace section
        context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))

        onDispose { context.unregisterReceiver(receiver) }
    }

    Text(
        text = published.format(currentTimeZone),
        style = MaterialTheme.typography.labelMedium,
        modifier = modifier
    )
}
  1. 使用 trace 呼叫包裝 context.registerReceiver,確保該項目確實是造成所有 binder transactions 的原因:
trace("PublishDate.registerReceiver") {
    context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
}

一般而言,在主執行緒上長時間執行的程式碼可能不會造成多少麻煩,但為畫面上顯示的每個項目執行這項交易時,就可能導致問題。假設畫面上顯示六個項目,均需在第一個影格中組合。這類呼叫本身可能耗費 12 毫秒,幾乎達到顯示單一影格的時限。

為修正這項問題,您需要將廣播註冊作業卸載至其他執行緒,方法是使用協同程式。

  1. 取得與可組合項生命週期繫結的範圍 val scope = rememberCoroutineScope()
  2. 在效果內,於 Dispatchers.Main 以外的調度器啟動協同程式,例如本例中的 Dispatchers.IO。如此一來,廣播註冊作業不會阻斷主執行緒,但實際狀態 currentTimeZone 是保留在主執行緒。
val scope = rememberCoroutineScope()

DisposableEffect(Unit) {
    val receiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            currentTimeZone = TimeZone.currentSystemDefault()
        }
    }

    // launch the coroutine on Dispatchers.IO
    scope.launch(Dispatchers.IO) {
        trace("PublishDate.registerReceiver") {
            context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
        }
    }

    onDispose { context.unregisterReceiver(receiver) }
}

您還需採取一個改進步驟。由於並非每個清單項目都需要廣播接收器,只有一個項目需要,因此您應提升該項目!

方法可以是將時區參數傳遞至可組合項樹狀結構,或使用本機可組合項,畢竟該項目並非用於 UI 的多個位置。

在本程式碼研究室中,您要將廣播接收器納入可組合項樹狀結構。但在實際應用程式中,將廣播接收器納入獨立資料層或許是不錯的做法,可避免汙染 UI 程式碼。

  1. 使用預設系統時區定義本機組合:
val LocalTimeZone = compositionLocalOf { TimeZone.currentSystemDefault() }
  1. 更新採用 content lambda 的 ProvideCurrentTimeZone 可組合項,提供目前時區:
@Composable
fun ProvideCurrentTimeZone(content: @Composable () -> Unit) {
    var currentTimeZone = TODO()

    CompositionLocalProvider(
        value = LocalTimeZone provides currentTimeZone,
        content = content,
    )
}
  1. PublishedText 可組合項中的 DisposableEffect 移至新可組合項,藉此完成提升作業,並利用狀態和連帶效果替換 currentTimeZone
@Composable
fun ProvideCurrentTimeZone(content: @Composable () -> Unit) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    var currentTimeZone: TimeZone by remember { mutableStateOf(TimeZone.currentSystemDefault()) }

    DisposableEffect(Unit) {
        val receiver = object : BroadcastReceiver() {
            override fun onReceive(context: Context?, intent: Intent?) {
                currentTimeZone = TimeZone.currentSystemDefault()
            }
        }

        scope.launch(Dispatchers.IO) {
            trace("PublishDate.registerReceiver") {
                context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
            }
        }

        onDispose { context.unregisterReceiver(receiver) }
    }

    CompositionLocalProvider(
        value = LocalTimeZone provides currentTimeZone,
        content = content,
    )
}
  1. 在所需的本機組合生效位置,使用 ProvideCurrentTimeZone 包裝可組合項。您可以包裝整個 AccelerateHeavyScreen,如以下程式碼片段所示:
@Composable
fun AccelerateHeavyScreen(items: List<HeavyItem>, modifier: Modifier = Modifier) {
    // TODO: Codelab task: Wrap this with timezone provider
    ProvideCurrentTimeZone {
        Box(
            modifier = modifier
                .fillMaxSize()
                .padding(24.dp)
        ) {
            ScreenContent(items = items)

            if (items.isEmpty()) {
                CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
            }
        }
    }
}
  1. PublishedText 可組合項變更為只含有基本的格式化功能,並透過 LocalTimeZone.current 讀取本機組合目前的值:
@Composable
fun PublishedText(published: Instant, modifier: Modifier = Modifier) {
    Text(
        text = published.format(LocalTimeZone.current),
        style = MaterialTheme.typography.labelMedium,
        modifier = modifier
    )
}
  1. 重新執行基準測試,這會重建應用程式。

或者,您可以下載含有正確程式碼的系統追蹤記錄:

  1. 將追蹤記錄檔案拖曳至 Perfetto 資訊主頁。所有 binder transactions 區段都已移出主執行緒。
  2. 搜尋與上一步驟相似的區段名稱。在協同程式 (DefaultDispatch) 建立的任一其他執行緒中,即可找到該名稱:

87feee260f900a76.png

7. 移除非必要的子組合

您已移除主執行緒中耗用大量資源的程式碼,因此這些程式碼不會再阻斷組合作業,但仍有改進空間。您可以移除一些非必要的負擔,即每個項目中形式為 LazyRow 的可組合項。

在本例中,這類項目含有一列標記,如下圖醒目標示的部分:

e821c86604d3e670.png

這個列是使用 LazyRow 可組合項實作,因為這種撰寫方式很簡單。將項目傳遞至 LazyRow 可組合項,該可組合項就會處理其餘作業:

@Composable
fun ItemTags(tags: List<String>, modifier: Modifier = Modifier) {
    // TODO: remove unnecessary lazy layout
    LazyRow(
        modifier = modifier
            .padding(4.dp)
            .fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(2.dp)
    ) {
        items(tags) { ItemTag(it) }
    }
}

問題在於,如果版面配置的項目數量遠超過限制大小,的確適合使用 Lazy 版面配置,但會產生額外成本,這在不必採用延遲組合時就並非必要。

由於 Lazy 可組合項本身使用 SubcomposeLayout 可組合項,因此總是顯示大量工作,首先是容器,再來則是畫面目前顯示的項目。您也可以在系統追蹤記錄中找到 compose:lazylist:prefetch 記錄,瞭解有額外項目即將進入可視區域,因此系統已預先擷取這些項目,提前做好準備。

b3dc3662b5885a2e.jpeg

在本例中,為確認這項作業約需耗費的時間,請開啟相同的追蹤記錄檔案。您可以看到有些區段從父項脫離出來。每個項目均含有實際組合的項目,以及標記項目。如此一來,系統組合每個項目的時間約為 2.5 毫秒,若乘以顯示項目的數量,就又會產生大量工作。

a204721c80497e0f.jpeg

為修正此問題,請按照下列步驟操作:

  1. 前往 AccelerateHeavyScreen.kt 檔案,找出 ItemTags 可組合項。
  2. LazyRow 實作項目變更為 Row 可組合項,該函式會疊代 tags 清單,如下列程式碼片段所示:
@Composable
fun ItemTags(tags: List<String>, modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .padding(4.dp)
            .fillMaxWidth()
        horizontalArrangement = Arrangement.spacedBy(2.dp)
    ) {
        tags.forEach { ItemTag(it) }
    }
}
  1. 重新執行基準測試,這麼做也會建構應用程式。
  2. 選用:下載含有正確程式碼的系統追蹤:

  1. 找到 ItemTag 區段,觀察耗用時間是否減少,且使用相同的 Compose:recompose 根區段。

219cd2e961defd1.jpeg

使用 SubcomposeLayout 可組合項的其他容器也可能發生類似情形,例如 BoxWithConstraints 可組合項。這個項目建立作業橫跨多個 Compose:recompose 區段,雖然可能不會直接顯示為卡頓影格,但使用者看得到這項問題。如果可以,請嘗試避免在這類項目中使用 BoxWithConstraints 可組合項。或許只有在根據可用空間組合不同 UI 時,才需要採用這種做法。

在本節中,您已瞭解如何修正耗時過長的組合。

8. 與初始基準測試比較結果

現在您已完成畫面效能最佳化,應比較基準測試與初始評估的結果。

  1. 在 Android Studio 的執行窗格 667294bf641c8fc2.png 中,開啟「Test History」
  2. 針對不含任何修改的初始基準測試,選取最舊的執行作業,並比較 frameDurationCpuMsframeOverrunMs 指標。您看到的結果應類似下表:

變更前

AccelerateHeavyScreenBenchmark_accelerateHeavyScreenCompilationFull
ImagePlaceholderCount               min  20.0,   median  20.0,   max  20.0
ImagePlaceholderMs                  min  22.9,   median  22.9,   max  22.9
ItemTagCount                        min  80.0,   median  80.0,   max  80.0
ItemTagMs                           min   3.2,   median   3.2,   max   3.2
PublishDate.registerReceiverCount   min   1.0,   median   1.0,   max   1.0
PublishDate.registerReceiverMs      min   1.9,   median   1.9,   max   1.9
frameDurationCpuMs                  P50    5.4,   P90    9.0,   P95   10.5,   P99   57.5
frameOverrunMs                      P50   -4.2,   P90   -3.5,   P95   -3.2,   P99   74.9
Traces: Iteration 0
  1. 針對包含所有改進措施的基準測試,選取最新的執行作業。您看到的結果應類似下表:

變更後

AccelerateHeavyScreenBenchmark_accelerateHeavyScreenCompilationFull
ImagePlaceholderCount               min  20.0,   median  20.0,   max  20.0
ImagePlaceholderMs                  min   2.9,   median   2.9,   max   2.9
ItemTagCount                        min  80.0,   median  80.0,   max  80.0
ItemTagMs                           min   3.4,   median   3.4,   max   3.4
PublishDate.registerReceiverCount   min   1.0,   median   1.0,   max   1.0
PublishDate.registerReceiverMs      min   1.1,   median   1.1,   max   1.1
frameDurationCpuMs                  P50    4.3,   P90    7.7,   P95    8.8,   P99   33.1
frameOverrunMs                      P50  -11.4,   P90   -8.3,   P95   -7.3,   P99   41.8
Traces: Iteration 0

如果特別查看 frameOverrunMs 列,可以看到所有百分位數均有所提升:

P50

P90

P95

P99

變更前

-4.2

-3.5

-3.2

74.9

變更後

-11.4

-8.3

-7.3

41.8

提升幅度

171%

137%

128%

44%

在下一節中,您將瞭解如何修正過於頻繁的組合作業。

9. 避免非必要的重組

Compose 分為 3 個階段:

  • 「組合」階段會建構可組合項樹狀結構,決定要顯示的項目。
  • 「版面配置」階段會根據該樹狀結構,判斷可組合項在畫面上的顯示位置。
  • 「繪圖」階段則在畫面上繪製可組合項。

這些階段通常以相同順序執行,讓資料以單一方向從組合、版面配置流動到繪圖階段,產生 UI 畫面。

2147ae29192a1556.png

BoxWithConstraints、延遲版面配置 (例如 LazyColumnLazyVerticalGrid) 和所有以 SubcomposeLayout 可組合函式為基礎的版面配置都是明顯的例外狀況,因為子項的組合依附於父項的版面配置階段。

一般而言,組合階段需耗費最多資源,不僅工作量最大,也可能導致系統重組其他不相關的可組合函式。

大部分影格含有完整的三個階段,但如果某階段沒有需執行的工作,Compose 其實可以略過整個階段。您可以利用這項功能提升應用程式效能。

使用 lambda 修飾符延後組合階段

可組合函式是在組合階段執行。如要允許程式碼在不同時間執行,可以用 lambda 的形式提供程式碼,

步驟如下:

  1. 開啟 PhasesComposeLogo.kt 檔案。
  2. 前往應用程式中的「Task 2」畫面,您會看到標誌彈出畫面邊緣。
  3. 開啟版面配置檢查器,查看「Recomposition counts」。您會看到重組數量快速增加。

a9e52e8ccf0d31c1.png

  1. 選用:找出並執行 PhasesComposeLogoBenchmark.kt 檔案,以便擷取系統追蹤記錄,查看每個影格上 PhasesComposeLogo 追蹤記錄區段的組合。在追蹤記錄中,重組作業會顯示為名稱相同的重複區段。

4b6e72578c89b2c1.jpeg 7036a895a31138d3.png

  1. 如有需要,請關閉分析器和版面配置檢查器,然後返回程式碼。您會看到 PhaseComposeLogo 可組合項,如下所示:
@Composable
fun PhasesComposeLogo() = trace("PhasesComposeLogo") {
    val logo = painterResource(id = R.drawable.compose_logo)
    var size by remember { mutableStateOf(IntSize.Zero) }
    val logoPosition by logoPosition(size = size, logoSize = logo.intrinsicSize)

    Box(
        modifier = Modifier
            .fillMaxSize()
            .onPlaced {
                size = it.size
            }
    ) {
        with(LocalDensity.current) {
            Image(
                painter = logo,
                contentDescription = "logo",
                modifier = Modifier.offset(logoPosition.x.toDp(), logoPosition.y.toDp())
            )
        }
    }
}

logoPosition 可組合項含有隨影格變更狀態的邏輯,如下所示:

@Composable
fun logoPosition(size: IntSize, logoSize: Size): State<IntOffset> =
    produceState(initialValue = IntOffset.Zero, size, logoSize) {
        if (size == IntSize.Zero) {
            this.value = IntOffset.Zero
            return@produceState
        }

        var xDirection = 1
        var yDirection = 1

        while (true) {
            withFrameMillis {
                value += IntOffset(x = MOVE_SPEED * xDirection, y = MOVE_SPEED * yDirection)

                if (value.x <= 0 || value.x >= size.width - logoSize.width) {
                    xDirection *= -1
                }

                if (value.y <= 0 || value.y >= size.height - logoSize.height) {
                    yDirection *= -1
                }
            }
        }
    }

系統會在 PhasesComposeLogo 可組合項中使用 Modifier.offset(x.dp, y.dp) 修飾符讀取狀態,也就是在組合階段讀取。

應用程式之所以在這個動畫的每個影格重組,就是這個修飾符造成的。在本例中,可以採用簡易的替代方案:lambda 型 Offset 修飾符。

  1. Image 可組合項更新為使用 Modifier.offset 修飾符,該修飾符會接受傳回 IntOffset 物件的 lambda,如下列程式碼片段所示:
Image(
  painter = logo,
  contentDescription = "logo",
  modifier = Modifier.offset { IntOffset(logoPosition.x,  logoPosition.y) }
)
  1. 再次執行應用程式,並查看版面配置檢查器。您會看到動畫不再產生任何重組作業。

請注意,您不應單純為了調整畫面的版面配置而重組,尤其在捲動期間,重組可能會導致影格卡頓。捲動期間的重組幾乎都不必要,應當避免。

其他 lambda 修飾符

Modifier.offset 修飾符不是唯一具有 lambda 版本的修飾符。在下表中,您可以看到常見修飾符,這些修飾符每次都會重組,而在傳遞經常變更的狀態值時,可替換為延後替代版本:

常見修飾符

延後替代版本

.background(color)

.drawBehind { drawRect(color) }

.offset(0.dp, y)

.offset { IntOffset(0, y.roundToPx()) }

.alpha(a).rotate(r).scale(s)

.graphicsLayer { alpha = a; rotationZ = r; scaleX = s; scaleY = s}

10. 使用自訂版面配置延後 Compose 階段

如要避免組合階段失效,使用 lambda 型修飾符通常是最簡單的做法,但有時候沒有所需的 lambda 型修飾符。在這類情況下,您可以直接實作自訂版面配置,甚至實作直接進入繪圖階段的 Canvas 可組合項。若 Compose 狀態讀取作業是在自訂版面配置中執行,只會使版面配置階段失效,並略過重組作業。一般而言,如果只想調整版面配置或大小,而不新增或移除可組合項,通常不必使組合階段失效,就能達成所需效果。

步驟如下:

  1. 開啟 PhasesAnimatedShape.kt 檔案,然後執行應用程式。
  2. 前往「Task 3」畫面。您點選按鈕時,這個畫面中的形狀會變更大小。大小的值是採用 animateDpAsState Compose 動畫 API 加上動畫效果。

51dc23231ebd5f1a.gif

  1. 開啟版面配置檢查器。
  2. 點選「Toggle size」
  3. 觀察每個動畫影格中的形狀重組作業。

63d597a98fca1133.png

MyShape 可組合項會採用 size 物件做為參數,這屬於狀態讀取。也就是說,size 物件變更時,PhasesAnimatedShape 可組合項 (最近的重組範圍) 會重組,進而使 MyShape 可組合項重組,因為其輸入項目已變更。

如要略過重組,請按照下列步驟操作:

  1. size 參數變更為 lambda 函式,這樣在發生大小變更時,就不會直接重組 MyShape 可組合項:
@Composable
fun MyShape(
    size: () -> Dp,
    modifier: Modifier = Modifier
) {
  // ...
  1. 更新 PhasesAnimatedShape 可組合項中的呼叫位置,改為使用 lambda 函式:
MyShape(size = { size }, modifier = Modifier.align(Alignment.Center))

size 參數變更為 lambda 後,狀態讀取就會延遲,改在叫用 lambda 時執行。

  1. MyShape 可組合項的主體變更如下:
Box(
    modifier = modifier
        .background(color = Purple80, shape = CircleShape)
        .layout { measurable, _ ->
            val sizePx = size()
                .roundToPx()
                .coerceAtLeast(0)

            val constraints = Constraints.fixed(
                width = sizePx,
                height = sizePx,
            )

            val placeable = measurable.measure(constraints)
            layout(sizePx, sizePx) {
                placeable.place(0, 0)
            }
        }
)

layout 修飾符評估 lambda 的第一行,您可以看到系統叫用 size lambda。這發生在 layout 修飾符內,因此只會使版面配置階段失效,組合階段仍有效。

  1. 再次執行應用程式,前往「Task 3」,然後開啟版面配置檢查器。
  2. 點選「Toggle Size」,您會發現形狀動畫的大小和先前一樣,但 MyShape 可組合函式不會重組。

11. 使用穩定類別防止重組

如果所有輸入參數皆處於穩定狀態,且在先前的組合階段後未變更,Compose 就會產生程式碼,略過可組合項執行作業。穩定項目是指本身不可變動,或 Compose 引擎能知道其值是否已在重組之間變更。

如果 Compose 引擎不確定可組合項是否穩定,就會視為不穩定,且不會產生略過重組的程式碼邏輯,因此可組合項每次都會重組。這種情形發生在類別「並非」原始型別,且符合下列其中一種情況時:

  • 類別可以變動,例如含有可變動屬性。
  • 定義類別的是未使用 Compose 的 Gradle 模組。這類模組沒有 Compose 編譯器依附元件。
  • 類別含有不穩定屬性。

在某些情況下,這項行為可能不甚理想,不僅會造成效能問題,也可能在您執行下列動作時有所異動:

  • 啟用嚴格略過模式。
  • 使用 @Immutable@Stable 註解為參數加註。
  • 將類別新增至穩定性設定檔。

如要進一步瞭解穩定性,請參閱說明文件

在本工作中,您可以新增、移除或勾選清單項目,並需要確認項目不在非必要時重組。共有兩種交替出現的項目,一種會每次重建,另一種則不會。

每次重建的項目是為了模擬實際用途,其資料來自本機資料庫 (例如 RoomsqlDelight),或是遠端資料來源 (例如 API 要求或 Firestore 實體),且會在每次發生變更時傳回物件的新例項。

許多可組合項都有附加的 Modifier.recomposeHighlighter() 修飾符,可在 GitHub 存放區中找到。每當可組合項重組時,此修飾符會顯示邊界,可暫時做為版面配置檢查器的替代解決方案。

127f2e4a2fc1a381.gif

啟用嚴格略過模式

Jetpack Compose 編譯器 1.5.4 以上版本含有啟用嚴格略過模式的選項,因此即使可組合項含有不穩定參數,也能產生略過模式。這個模式有望大幅減少專案中不可略過的可組合項數量,因此不必修改程式碼即可提升效能。

若是不穩定參數,略過邏輯會比較「例項相等性」,也就是說,如果傳遞至可組合項的例項與上一個相同,系統就會略過參數。相較之下,系統決定穩定參數的略過邏輯時,是呼叫 Object.equals() 方法,根據「結構相等性」判斷。

除了能略過邏輯,嚴格略過模式也會自動記住可組合函式內的 lambda。換句話說,您「不需要」使用 remember 呼叫來包裝 lambda 函式,例如呼叫 ViewModel 方法的函式。

嚴格略過模式可在 Gradle 模組層級啟用。

如要啟用這個模式,請按照下列步驟操作:

  1. 開啟應用程式 build.gradle.kts 檔案。
  2. 使用下列程式碼片段更新 composeCompiler 區塊:
composeCompiler {
    // Not required in Kotlin 2.0 final release
    suppressKotlinVersionCompatibilityCheck = "2.0.0-RC1"

    // This settings enables strong-skipping mode for all module in this project.
    // As an effect, Compose can skip a composable even if it's unstable by comparing it's instance equality (===).
    enableExperimentalStrongSkippingMode = true
}

這麼做會將 experimentalStrongSkipping 編譯器引數新增至 Gradle 模組。

  1. 按一下 b8a9619d159a7d8e.png「Sync project with Gradle files」
  2. 重建專案。
  3. 開啟「Task 5」畫面,您會發現使用結構相等性的項目皆具有 EQU 圖示標記,且不會在您與清單項目互動時重組。

1de2fd2c42a1f04f.gif

然而,其他類型的項目仍會重組。您將在下一步驟中修正這些項目。

使用註解修正穩定性

如先前所述,啟用嚴格略過模式時,如果參數具有與先前組合階段中相同的例項,可組合項會略過自身的執行作業。然而,如果每次變更時都會提供不穩定類別的新例項,就無法略過執行作業。

在您的情況中,StabilityItem 類別含有不穩定的 LocalDateTime 屬性,因此不穩定。

如要修正此類別的穩定性問題,請按照下列步驟操作:

  1. 前往 StabilityViewModel.kt 檔案。
  2. 找出 StabilityItem 類別,並使用 @Immutable 註解加註:
// TODO Codelab task: make this class Stable
@Immutable
data class StabilityItem(
    val id: Int,
    val type: StabilityItemType,
    val name: String,
    val checked: Boolean,
    val created: LocalDateTime
)
  1. 重建應用程式。
  2. 前往「Task 5」畫面,您會發現清單項目皆不重組。

938aad77b78f7590.gif

此類別現在使用「結構相等性」檢查自身是否與先前組合不同,因此不會重組項目。

還需修改的是參考最新變更日期變化的可組合項,儘管您已採取多項措施,該項目仍持續重組。

使用設定檔修正穩定性問題

上一步驟適合您程式碼集內的類別,但您無法編輯可觸及範圍以外的類別,例如第三方程式庫或標準程式庫的類別。

您可以啟用穩定性設定檔,該設定檔採用系統視為穩定的類別 (可能有萬用字元)。

如要啟用此設定檔,請按照下列步驟操作:

  1. 前往應用程式 build.gradle.kts 檔案。
  2. stabilityConfigurationFile 選項新增至 composeCompiler 區塊:
composeCompiler {
    ...

    stabilityConfigurationFile = project.rootDir.resolve("stability_config.conf")
}
  1. 同步處理專案與 Gradle 檔案。
  2. 在本專案旁的 README.md 檔案旁,開啟根資料夾中的 stability_config.conf 檔案。
  3. 新增下列程式碼:
// TODO Codelab task: Make a java.time.LocalDate class stable.
java.time.LocalDate
  1. 重建應用程式。如果日期不變,LocalDateTime 類別就不會導致系統重組 Latest change was YYYY-MM-DD 可組合項。

332ab0b2c91617f2.gif

在您的應用程式中,您可以擴充檔案來納入模式,這樣就不必撰寫所有應視為穩定的類別。因此在您的案例中,您可以使用 java.time.* 萬用字元,這樣套件中的所有類別皆會視為穩定類別,例如 InstantLocalDateTimeZoneId,以及其他來自 java time 的類別。

完成上述步驟後,除了新增或互動過的項目會如預期重組之外,畫面上的項目都不會重組。

12. 恭喜

恭喜,您已提升 Compose 應用程式的效能!雖然本程式碼研究室只列舉一小部分可能遇到的應用程式效能問題,但您已瞭解如何檢查其他潛在問題,也學到相應的修正方法。

後續步驟

如果您尚未產生應用程式的基準設定檔,我們強烈建議完成這個步驟。

您可以參考「使用基準設定檔提升應用程式效能」程式碼研究室。如要進一步瞭解如何設定基準測試,可以參考「使用 Macrobenchmark 檢查應用程式效能」程式碼研究室。

瞭解詳情