軟體工程師
Jetpack Compose 可讓您輕鬆設計高品質的使用者介面,但可能難以追蹤影響 UI 效能的問題。這類問題可能會導致使用者注意到問題,例如啟動延遲,以及顯示為捲動卡頓或動畫不流暢的影格遺失。
在這場講座中,我們將介紹一些工具,協助您找出影響 UI 效能的因素,重點在於 Perfetto 和 (重新) 組合追蹤。接著,我們會探討您可能正在使用哪些反模式,導致成本在不知不覺中增加,以及如何在程式碼中找出這些反模式,並改用哪些正模式。聽完這場演講後,您將瞭解如何以效能為考量,在 Compose 中思考,以及如何在應用程式中剖析、分析及驗證效能最佳化。
轉錄稿
您好!
大家好。
希望你有充分休息。
希望你已經喝過咖啡、做好保濕,一切準備就緒。
今天我們會深入探討。
沒錯,我們開始吧。
今天會深入探討。
我是 Andrei Shikov,今天與 Andrew Bailey 一起上台。
我們都在 Compose 編譯器執行階段團隊工作,但也負責許多其他工作,包括效能。
今天的主題是回答各位對效能的問題,因為 Jetpack Compose 已成為 Android 開發人員的預設 UI 工具包。
現在,許多人都在詢問如何確保 Compose UI 盡可能發揮效能。
如前所述,今天我們會深入探討。
此外,我們也會介紹一些工具,協助您在 Compose 中發揮更出色的表現。
首先,我想談談為什麼效能對我們很重要。
在思考品質和打造優質應用程式時,您通常會想到動畫,我們則會想到豐富的功能集,有時會忽略效能,但使用者觀感確實取決於應用程式的效能。
因此,如果動畫卡頓,使用者對應用程式的觀感會立即下降。
因此,我們今天提到的所有指標和數字,實際上都是我們選取的代理指標,用來代表使用者對應用程式、所有動畫以及畫面上發生的所有事情的感受。
因此,最佳化應用程式基本上就是最佳化使用者體驗。
今天我們會深入探討許多主題,希望大家能抱持這種心態。
現在,當你開始查看應用程式的效能時,我希望你快速完成三件事,三件快速的事。
首先,如要評估效能,請在發布模式下進行,這樣就不會有大量可偵錯資訊,也不會拖慢應用程式速度。
第二,啟用 R8 或其他縮小工具。
今年,我們推出一項最佳化功能,可將協同程式效能提升兩倍,此外也做了許多其他努力。
最佳化 Compose 時,我們非常仰賴 R8。
最後,請務必使用基準設定檔,尤其是啟動指標。
因此,在應用程式啟動時,系統會預先編譯並預先載入您在啟動時載入的類別。
如您所見,這與應用程式的一般可偵錯版本相差 6 倍。
當您評估所有這些項目時,它們會與您在程式碼中執行的其他勝出項目結合。
現在,您已完成所有操作,但應用程式仍有點延遲,或仍有其他效能問題,這時您就需要進入這個效能迴圈。
您想開始查看特定情境的一些指標。
舉例來說,假設應用程式載入時間過長,您可能需要重設應用程式,從一般啟動器啟動,並檢查應用程式需要多久時間才能顯示第一個資料畫面。
或者,應用程式可能有點卡頓,因此您捲動畫面,並確保捲動操作可重複且穩定。
因此,本投影片中的穩定環境非常重要,因為在後續進行最佳化時,您需要確保指標有所改善,並確認這些最佳化措施確實能達到預期效果。
基準測試非常適合用於這項作業,因為基準測試已經擷取大量資料,並執行多次疊代,確保裝置中沒有太多雜訊,而且還會執行許多裝置設定,對您很有幫助。
此外,基準測試預設也是可重複執行的情境,這正是我們在這裡努力達成的目標。
但您也可以視需要使用多個指令碼。
最後,您要剖析應用程式、改善應用程式中的某些內容、查看資料,然後再次重複,並可能針對應用程式遇到困難的不同情境指標再次重複。
查看資料時,今天介紹的第一項工具是 Perfetto 追蹤記錄,請務必熟悉這項工具。
Perfetto 追蹤記錄會記錄追蹤期間裝置上發生的所有事件,例如執行 Macrobenchmark 或錄製指令碼時。
對所有致力提升 Android 效能的工程師來說,這項工具非常有幫助。
我現在大部分的工作時間都在查看 Perfetto 追蹤記錄。
看起來像這樣。
資料量相當龐大。
如前所述,這類記錄會擷取裝置上發生的所有事件。
因此,一開始您可能很難找到應用程式,建議您使用這個方便的搜尋列。
搜尋 Compose、搜尋應用程式套件等。
只要專注於熟悉的事物,就能讓自己平靜下來。
當您找到應用程式程序後,就會看到影格時間軸,以及應用程式中許多作業所需的時間。
如果我們隔離其中一個,您會看到類似這樣的內容。
第一項是主執行緒工作。
Compose 的大部分作業都在這裡進行。
接著是 RenderThread。
我們在這裡處理畫布指令,並將其編碼至 GPU。
接著,GPU 會執行工作,然後系統會進行合成。
今天我們只會探討主執行緒。
因為這是 Compose 運作的地方。
我們討論的是 Compose,因此只著重於這點是合理的。
當我們在主執行緒上隔離該影格時,實際上會開始看到 Compose 切片。
我為你挑選了幾張。
就像組合、測量、版面配置、繪圖。
如果您聽過 Compose 階段,並曾查看成效,可能會看到這些階段。
因此,Perfetto 追蹤記錄中會有個別切片,您可以輕鬆找出每個階段所需的時間,以及在每個特定影格中執行的階段。
我們已經詳細討論過如何最佳化部分工作,您需要確保在正確的階段執行正確的工作。
舉例來說,如果您有動畫會更新方塊顏色或類似內容,您只需要撤銷繪製階段,而不是組合結構和版面配置等。
這是效能 101,今天我們會深入探討一些內容,特別是組合結構。
現在請 Andrew 為我們說明。
謝謝 Andrei。
正如 Andrei 所說,我們之前花了很多時間討論效能最佳化,例如避免向後讀取、確保將狀態讀取延遲到組合生命週期的正確階段,以及最佳化和確保可組合項按照預期失效,這樣就不必在每個影格上執行太多工作。
這是很好的起點,但我們已經有很多相關資料,因此想進一步探討這個主題。
因此,今天我們將探討在完成這些最佳化作業後,組合中還剩下什麼,以及可組合項本身在做什麼。
Compose 程式碼中的所有內容,以及應用程式隨附的所有內容,在效能方面都有一個不幸的事實,那就是所有內容都有相關成本。
應用程式中提供的程式碼都不是免費的,即使是讀取狀態等微不足道的事,以及在組合結構中組合副作用等,都會產生一些實際成本,因此請務必留意這些成本,尤其是在開始剖析可組合函式實際執行的作業,並開始在應用程式本身中擠出更多效能時。
為了在最微觀的尺度上呈現實際情況,以下列出一些您可以在程式碼中任何位置執行的最小作業,特別是在可組合函式中,而在這個解析度下,我們談論的是奈秒尺度,也就是這些作業大致上需要多少成本。
如要提升成效,有兩種做法。
您可以移除大量低成本作業,或少量高成本作業。
具體來說,在這張圖表中,協同程式是這些作業中成本較高的部分,因此很適合做為開始尋找的範例,因為如果我們移除其中幾個執行個體,就能更快在應用程式中看到更具體的效益。
關於協同程式,還有一點要特別注意,如投影片所示,啟動協同程式會產生費用,取消協同程式也會產生費用。
取消成本特別值得注意,因為這主要發生在您處置內容,以及可能移至應用程式內其他區域時。
在許多應用程式中,離開頁面是非常耗費資源的情況,您需要盡可能縮短影格時間,並確保充分利用現有資源。
因此,請務必留意這兩項費用。
無論我們看的是什麼,所有這些都會在這個大約 1600 萬奈秒的規模上累加。
因此,我們需要減少一些預算,而協同程式是很好的起點。
在 Compose 中,協同程式最常與 Effects API 搭配使用。
這裡有一系列 API,可與 SideEffects 搭配使用,從高功能到低延遲都有。
在這個光譜中,功能最齊全的是 LaunchedEffect,其中包含非同步工作的所有內容,尤其是動畫,以及任何需要協同程式範圍的內容。
如果不需要這項功能,且能以較便宜的 API 完成工作,則可考慮使用,前提是符合您的用途需求。
為了實際展示,我準備了一個媒體播放器可組合項,用來示範所有不同的 SideEffect,以及這些效果可能出現的時機。
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Asynchronous work and animations
LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}我們可以先從 LaunchedEffect 開始。
在這個範例中,這個方法用於在媒體播放器中非同步載入資料。
如果您需要協同程式範圍,或一般需要協同程式或發出暫停呼叫,請使用 LaunchedEffect。
雖然這項操作仍屬於較昂貴的操作,且協同程式會產生費用,但只要您能善用並證明其合理性,請使用合適的 API 執行工作。
不過,如果您發現自己使用 LaunchedEffect,但未運用協同程式,建議您考慮使用其他 API,這些 API 可以執行相同操作,但成本較低。
因此,我們可以在光譜中看到其他副作用,例如 DisposableEffect。
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val activity = LocalActivity.current
// Setup and teardown
DisposableEffect(Unit) {
activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
onDispose {
activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
}
}
}如果您的效果有相關聯的設定和拆解程序,DisposableEffect 會非常實用。
同樣以媒體播放器為例,我有一個範例,會在可組合項存在時,將活動方向鎖定為橫向,並在可組合項消失後解除方向鎖定。
如果您想使用 LaunchedEffect 和 DisposableEffect 的一次性效果,但不需要協同程式,也沒有 dispose 效果,也可以使用我們在 Compose 1.12 中推出的新 SideEffect with keys API。
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// One-shot events
SideEffect(Unit) { mediaPlayer.play() }
}如果您已使用其他兩個 API,這個 API 是很好的替代方案。無論是為了方便而使用 LaunchedEffect,還是為了單次效果而使用 DisposableEffect,使用金鑰 API 導覽或遷移至 SideEffect 都能達到相同效果,而且負擔更低、效能更佳,尤其是在整個應用程式中多次遷移時。
最後,最便宜的 API 是標準 SideEffect API。
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Synchronize the outside world to the state of composition
SideEffect { mediaPlayer.playbackRate = playbackRate }
}如果想將外部狀態與組合中的狀態同步,這個方法最合適。
同樣以媒體播放器為例,我還有另一個範例,說明如何將部分播放狀態與組合結構接收到的部分參數同步。
請注意,如果您決定稽核附帶效果的使用情形,並改用成本較低的效果,除了我們討論過的其他行為和用途外,附帶效果的排序保證也有所不同。
所有可處置的效果都會先執行,接著是一般副作用 (無論是否帶有索引鍵),然後在影格完成後,所有啟動的效果都會執行,因為涉及調度。
如果您需要依據這個時間點啟動效果,請在考慮遷移時留意這一點。
無論進行何種遷移,請務必測試程式碼,確保不依賴任何時間保證,且程式碼在遷移後仍可正常運作。
如果無法降低附帶影響本身的複雜度,您也可以將附帶影響合併,減少附帶影響的數量。
@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) { viewModel.loadCart() }
LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
CartContent(...)
}如果有多個效果使用同一個金鑰,就特別需要考量這一點。
由於這兩項工作可以同時執行,因此很適合合併。
在這個範例中,我有多個啟動效果,每個效果都會對某個檢視模型發出非同步呼叫。
如果我將這些函式替換為單一 LaunchedEffect,然後在其中加入其他啟動呼叫,即使這裡執行的協同程式數量相同,負擔仍會減少,因為這會將工作從組合結構中移出,並移至協同程式中。
@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) {
launch { viewModel.loadCart() }
launch { viewModel.loadPaymentMethods() }
launch { viewModel.loadDeliveryAddresses() }
}
CartContent(...)
}因此,在這種情況下,建議您考慮在適當位置移入,盡可能減少效果數量。
效果和協同程式只是您在應用程式中可能稽核的一個例子,但每個可組合項和每個應用程式都會有所不同,且其中會有不同的機會。
為了瞭解哪些內容與您的應用程式相關,我們需要討論 Andrei 接下來要介紹的工具,也就是組合追蹤。
現在我們來做個小測驗
因此,組合追蹤功能 (如果您查看 Perfetto 框架的忠實重現,我們之前在不同的投影片中看過) 會提供不同階段的時間資訊,但不會提供太多關於發生情況的詳細資料。
什麼?
沒錯,就是組合中執行的可組合函式。
而組合追蹤功能可以協助我們。
因此,我們在 Compose 編譯器中引入一些標記,基本上會為每個函式加上註解,並在 Perfetto 追蹤記錄中顯示,以便您查看每個函式所需的時間。
您可以使用這項功能,找出確切重組的函式。
不過,這會產生一些負擔,因此如果您想擷取最準確的成效資料,建議使用一般 Perfetto,或在不擷取 Perfetto 的情況下執行,並使用其他指標。
不過,如果您想瞭解每個可組合項所需的時間,組合追蹤功能就非常實用。
有時您也想瞭解為何會重新組成,這正是我們在下一個 Compose Tracing 執行階段追蹤版本 (即 1.13) 中嘗試解決的問題。
我們現在推出重組追蹤功能,可為導致重組的每個狀態讀取和狀態寫入加上註解。
因此,您會在 Perfetto 追蹤記錄中看到類似這樣的內容,其中會顯示每個狀態寫入,以及寫入和讀取發生的呼叫堆疊,以便您將這些狀態與程式碼中存取或修改這些狀態的實際行對應。
如要進一步瞭解,請點選這裡的連結。
其中包含組合追蹤和重組追蹤的所有說明文件。
現在,Andrew 將說明如何更有效率地使用這項功能,避免狀態寫入。
謝謝 Andrei。
根據這項調查的結果,您可能會發現組合結構中有無效項目,這些項目對應用程式沒有用處,而且一開始就可以避免。
舉例來說,最常見的例子是「捲動至頂端」按鈕,其顯示與否取決於可見度或捲動進度。
@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton = scrollState.value > 0
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}在這裡,由於我正在讀取組合中的捲動狀態值,因此這是一個非常頻繁變更的狀態,可能會在每個影格中變更一次,這會導致許多可避免的重組,因為這個條件,即使捲動狀態本身正在變更,我們在這裡關注的實際條件不會經常變更。
在這種情況下,建議您使用運算狀態等功能,避免應用程式因不重要的狀態變更而失效。
在這個範例中,我將計算本身包裝在計算狀態中,當我們讀取這個狀態時,系統不會根據捲動位置本身撤銷,而是會根據整個條件有條件地撤銷這個可組合項,並從其他狀態本身衍生。
@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}如果您曾看過這個模式,可能也看過它與衍生狀態搭配使用。
ComputedState 是 Compose 1.13 的新功能,雖然 ComputedState 和 DerivedState 的目標相似,都是在計算結果變更時有條件地失效,但兩者的做法有細微差異。
DerivedState 是 Compose 推出以來就有的功能,可快取計算出的值並重複使用,直到依附元件變更為止。
使用 ComputedState 時,每次讀取值都會重新計算。
快取聽起來很有用,但實際上在許多情況下,快取的費用比直接讀取結果還要高。
為了瞭解這種情況,我們可以舉例說明如何在應用程式中使用這些狀態,並深入探討每個狀態的實際作用。
這裡我有一個狀態,大致反映了我們在「捲動至頂端」按鈕中的操作,其中有一些條件是基於其他狀態,我同時將其設為 DerivedState 和 ComputedState。
var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
// First read
derivedState.value
computedState.value
// Second read
derivedState.value
computedState.value接著,我會讀取狀態兩次,兩次都會取得相同的值,但兩者在幕後執行的作業不同。
為了簡化 Perfetto 等工具中實際顯示的追蹤記錄,我們可以檢視讀取這兩種狀態時實際發生的情況。
首先,我們呼叫 computedState.value。
系統會執行計算 lambda,然後讀取狀態值,並進行一些維護作業,通知系統已讀取。
Compose 中的所有快照狀態都是如此。
第二次讀取的行為與第一次讀取完全相同,且全程具有相同的效能特性。
相較之下,DerivedState 的第一次讀取會執行類似的操作,仍須呼叫計算 lambda 並執行其他通知,但會產生一些快取管理負擔,第一次執行時會稍微短路,因為沒有快取可檢查。
因此,它會對快取本身進行一些維護,然後在稍長的時間內完成相同的工作。
不過,DerivedState 的第二次讀取確實比 ComputedState 的第二次讀取快,即使這兩個狀態都相當簡單。
如前幾張投影片所述,在某些情況下,快照讀取的成本不容忽視,而從快取讀取實際上比執行讀取本身更快,這就是為什麼我們看到 DerivedState 的第二次讀取本身略有優勢。
但實際有趣的情況是,當狀態需要失效時會發生什麼事。
這就是所謂的「寫入後讀取」情境,也就是更新計算中使用的其中一個狀態,且必須再次執行計算。
在程式碼中,這看起來就像我們修改 a,可能遞增它,然後再次讀取值或重新組合。
var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
...
// Read-after-write
a++
derivedState.value
computedState.value在實際圖表中,ComputedState 的外觀也完全相同。
它沒有要管理的快取,無論是第幾次讀取,看起來都完全一樣。
不過,DerivedState 的任務可不輕鬆。
它會經過一條非常昂貴的路徑,在更新和呼叫計算本身之前,先確認快取無效,然後必須進行依附元件追蹤,並管理大量依附元件和觀察,才能寫回快取值,並瞭解所有這些值實際上都已變更,並管理維護本身。
所有這些因素加起來,與 ComputedState 相比,撤銷 DerivedState 的成本非常高。
為了用數字佐證上述所有說法,ComputedState 首次讀取的速度通常會快約 200 奈秒。
在 Pixel 6 上,ComputedState 在特定基準測試條件下,會慢約 350 奈秒。
因此,在第二次讀取時,ComputedState 的效能會略低於 DerivedState。
不過,在我們實際關注的情況下,也就是我們撤銷的情況,以及您可能需要在影格上執行工作的狀況,ComputedState 的速度幾乎是 DerivedState 的兩倍以上。
因此,視狀態的用途而定,ComputedState 是非常適合的首選。
一般來說,使用 DerivedState 和 ComputedState 時,仍須做出決定。
我們不會淘汰 ComputedState,兩者仍有用途。
一般來說,如果不確定要選擇哪一個,預設選擇運算狀態會是個好選擇。
衍生狀態最實用的地方在於,您實際上可以利用快取,也就是說,您可以執行一些非一般作業,例如複雜的演算法、一些大型分配、許多狀態讀取,這些作業實際上都受益於這裡的快取,或者任何非一般作業或取決於物件相等性和物件身分相等性等事物,您可以在其中彌補差異,並獲得衍生狀態提供的快取優勢。
另一個我想談論的明顯問題是,衍生狀態和運算狀態都試圖避免使可組合項本身失效。
這與略過可組合函式不同,後者通常是指有條件地撤銷,或不執行可組合函式內的工作。
我想在這裡揭露的真相是,雖然 Compose 內部有很多關於穩定性和略過的討論,但這並非最終目標,也不一定是最佳化的正確做法。
如前所述,組合中的所有項目都會產生一些相關成本,包括略過。
// What you write
@Composable fun ContactListItem(
contact: Contact
) { ... }
// What you get
@Composable fun ContactListItem(
contact: Contact,
$composer: Composer?
$changed: Int
) {
val $dirty = $changed or
$composer.changed(contact)
if ($dirty) { /* Your function body */ }
else $composer.skipToGroupEnd()
}略過不一定代表不重要。
我們必須進行大量維護工作,才能啟用這項功能。
我們必須執行一些操作,例如儲存所有參數,並在初始組合結構中為這些參數尋找空間,然後在重新組合時更新該記錄。
我們也必須比較您的值與先前的值,瞭解可組合項是否應略過。
接著,所有這些都只是 if 檢查的功能,可略過程式碼或不略過。
視實際情況而定,這項作業可能相當昂貴。
編譯器在幕後進行了最佳化,如果您傳遞相同的參數,我們可以重複使用相等檢查,但這並非萬能,而且很容易被擊敗。
如要查看略過機制不盡如人意的範例,可以參考一個非常經典的範例,也就是捲動清單,其中包含一些大型物件,內含要在畫面上組成的所有資料。
data class ContactsModel(
val header: String,
val contacts: List<Contact>
)
data class Contact(...)
@Composable
fun ContactsScreen(model: ContactsModel) {
Column {
TopAppBar(title = { Text(model.header) })
ContactsList(model.contacts)
}
}
@Composable
fun ContactsList(contacts: List<Contact>) {
LazyColumn {
items(contacts) { ContactListItem(it) }
}
}
@Composable
fun ContactListItem(contact: Contact) { ... }因此,這裡的畫面模型包含標題畫面等內容,以及所有聯絡人清單,然後透過幾個可組合項傳遞,最終呼叫延遲清單,然後透過一些項目儲存格將其顯示在畫面上。
這段程式碼的組合結構階層大致如下:同樣從聯絡人畫面開始,稍微展開,然後有一些其他可組合項會呼叫並顯示項目。
如果觀察資料傳遞方式,會發現資料是從頂層的模型開始傳遞。
我們將其解構為字串,然後是清單,然後將該清單轉換為延遲清單的項目 lambda,然後延遲清單將組成畫面上顯示的所有項目。
我們來看看修改這份清單中最後一個聯絡人時,會發生什麼情況。
我們希望只撤銷清單中的最後一個項目,但實際情況並非如此。
如果查看這項重組作業的組合追蹤記錄,會看到類似這樣的內容:從聯絡人畫面開始,最後向下捲動至這個清單項目。
而且這種方式相當昂貴。
我們也可以看到,追蹤記錄中似乎有許多區段沒有任何活動。
雖然這裡並不明顯,但這實際上是可組合項決定是否要略過的地方。
在這個範例中,由於 Lazy 清單重組的方式,只有兩個可組合項會略過。
當您提供新的內容 lambda 時,系統會撤銷其擁有的所有項目。
因此,除了變更的聯絡人外,未變更的兩個聯絡人也會失效,而這兩個聯絡人是這裡唯一可以略過的項目。
其他所有項目都會略過等式比較,只為了瞭解實際執行的內容。
如果物件穩定,這項比較作業實際上可能相當耗費資源。
如果強制我們檢查所有資料結構,實際成本可能會增加,而且只是為了判斷兩個物件是否相等,就可能需要付出不小的代價。
更糟的是,由於我們已解構,實際上必須對清單進行兩次相同的比較。
如果讓編譯器採用預設行為並使用強略過模式,您仍可略過可組合項,並檢查是否要依條件略過可組合項,但系統會採用更快速的方法,使用物件身分相等性,而非結構相等性。
這樣一來,我們就能更快計算,即使我們在這個影格上重新組合相同的項目,但實際上我們能快速進入,不會浪費太多時間來確定我們無法略過這些可組合項。
這也是為什麼我們強調使用基準和追蹤記錄等工具,瞭解應用程式內部情況,而不是直接重組計數。
雖然這些指標是不錯的參考依據,但最佳化應用程式時,不一定要過度關注或過度重視。
請記住,與其嘗試略過可組合函式,不如一開始就避免使其失效。
在這個範例中,我們發現組合追蹤記錄可以提供許多有關發生情況的資訊,但也不一定能顯示完整情況。
Andrei 將為我們介紹方法追蹤記錄。
現在我們來做個小測驗
正如 Andrew 所說,Composition Tracing 不一定能提供所有資訊,因為它只會標註可組合函式,而且在某些情況下,Composition Trace 中只會顯示空白空間,雖然這已經擴充了 Perfetto Trace,但仍無法顯示所有資訊。
有時可能是 Compose 造成的,有時可能是程式碼中的問題,光看這裡的組合追蹤記錄,真的很難找出原因。
現在,您要擷取方法追蹤記錄。
Android Studio 的分析器中有個項目,或者你也可以使用 ADB 指令擷取追蹤記錄,這樣就能取得 Java 環境中發生的每個函式呼叫。
包括 Compose 編譯器在其中編碼的所有內容、Compose 執行階段執行的所有內容,以及程式碼執行的所有內容,這對於追蹤、調查實際執行的內容,以及在該空間中呼叫的函式非常有用。
我想請大家看看中間這個項目。
我想提醒大家,方法追蹤記錄的負擔非常高。
根據函式樹狀結構的複雜程度,負擔會增加 10 到 20 倍,因為每次函式呼叫都會增加一點負擔。
請看螢幕截圖。
這通常是應用程式啟動時的方法追蹤記錄,您可能會問:「如何解讀這項資訊?」
一般來說,您會先查看這裡的函式,開始追蹤組合結構。
這就像簡化的組合追蹤,其中包含 ScreenContent、一些按鈕和其他函式。
假設我們想瞭解 ScreenContent 中發生的情況。
Perfetto 有搜尋列。
您在 Perfetto 檢視器中載入方法追蹤記錄,搜尋與此處相同的函式 ScreenContent,就會看到類似的內容,也就是畫面的方法追蹤記錄。
如你所見,這裡有 Composer.startGroup 和群組通話。
執行階段和編譯器會在背景執行這項作業,確保組合結構能實際發揮作用,但這裡有趣的呼叫是 Composer.changed。
這樣一來,我們就能檢查可組合函式在先前和目前叫用之間,參數是否有所變更。
而這些Composer.changed中,大部分都是由這個ScreenModel.equals主導。
因此,ScreenModel 可能是某種大型資料類別,我們可能將其設為穩定,以協助重組計數,因此它可能會出現在編譯器報告中。
在這個特定情況下,移除這個穩定註解可能有助於提升效能。
因此,您需要找出這類問題,而組合方法追蹤記錄是找出這類問題的最佳工具。
現在我們來做個小測驗
今天我們討論了許多內容,現在請 Andrew 為我們總結。
最後,我們來總結一下今天談到的所有內容。在設計可組合項和最佳化現有應用程式時,請務必留意以下幾點。
首先,請記住,成本較低的可組合項和未失效的可組合項,比略過的可組合項更好。
請注意,設計可組合項時,最好考慮使用較小且更容易比較的引數,並盡可能重複使用與其他函式呼叫的比較,以充分利用最佳化功能。
在可組合函式中使用 API 時,請注意,效能或影格時間會受到 API 本身的成本和使用頻率影響。
請留意這些成本的累積方式,並注意可能會有相關的拆除成本,而這些成本可能會在最不適合應用程式的時間點產生。
此外,請務必遵守規範,在應用程式內進行最佳化。
首先,請進行基準測試,以便做出明智決策,並剖析應用程式,確保您指定正確的程式碼區域,並驗證您正在進行變更,並查看您預期的修改結果,這些修改是基準測試和剖析的結果。
此外,請注意,應用程式的可維護性和效能之間可能需要取捨。
因此,請採取最適合您和團隊的做法,確保不會過度最佳化,導致維護工作陷入困境。
此外,您還可以使用現有的 AI 代理,取得更多協助。
我們已推出 Android CLI,並在其中提供 Jetpack Compose 效能技能,其中包含我們今天談到的許多資訊,可協助您檢查及稽核應用程式,並針對這類最佳化提供建議。
您可以使用 Android CLI,在現有代理中安裝。
android skills add jetpack-compose-performance如要進一步瞭解 Android CLI,請前往 developer.android.com。
您也可以前往 GitHub 存放區,進一步瞭解我們的技能。
最後,祝您安全地進行基準測試,並愉快地編寫程式碼。
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Asynchronous work and animations
LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val activity = LocalActivity.current
// Setup and teardown
DisposableEffect(Unit) {
activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
onDispose {
activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
}
}
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// One-shot events
SideEffect(Unit) { mediaPlayer.play() }
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Synchronize the outside world to the state of composition
SideEffect { mediaPlayer.playbackRate = playbackRate }
}@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) { viewModel.loadCart() }
LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
CartContent(...)
}@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) {
launch { viewModel.loadCart() }
launch { viewModel.loadPaymentMethods() }
launch { viewModel.loadDeliveryAddresses() }
}
CartContent(...)
}@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton = scrollState.value > 0
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
// First read
derivedState.value
computedState.value
// Second read
derivedState.value
computedState.valuevar a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
...
// Read-after-write
a++
derivedState.value
computedState.value// What you write
@Composable fun ContactListItem(
contact: Contact
) { ... }
// What you get
@Composable fun ContactListItem(
contact: Contact,
$composer: Composer?
$changed: Int
) {
val $dirty = $changed or
$composer.changed(contact)
if ($dirty) { /* Your function body */ }
else $composer.skipToGroupEnd()
}data class ContactsModel(
val header: String,
val contacts: List<Contact>
)
data class Contact(...)
@Composable
fun ContactsScreen(model: ContactsModel) {
Column {
TopAppBar(title = { Text(model.header) })
ContactsList(model.contacts)
}
}
@Composable
fun ContactsList(contacts: List<Contact>) {
LazyColumn {
items(contacts) { ContactListItem(it) }
}
}
@Composable
fun ContactListItem(contact: Contact) { ... }android skills add jetpack-compose-performance