软件工程师
Jetpack Compose 可让您轻松设计高质量的界面,但可能很难跟踪影响界面性能的问题。这些问题可能会导致用户注意到一些问题,例如启动延迟,以及表现为滚动卡顿或动画不流畅的丢帧。
在本演讲中,我们将介绍一些工具,帮助您发现影响界面性能的因素,重点是 Perfetto 和(重)组跟踪。然后,我们将探讨您可能正在使用的特定反模式,这些反模式会暗中增加您的成本,我们还会介绍如何在代码中找到它们,以及可以用哪些正模式来替换它们。通过本次演讲,您将了解如何以性能为导向,运用 Compose 的思维方式,以及如何在应用中分析、剖析和验证性能优化。
转写文稿
您好!
大家好
希望您度过了一个愉快的假期。
希望您已经喝过咖啡,补过水,一切准备就绪。
今天,我们将进行更深入的探讨。
是的,我们开始吧。
今天,我们将深入探讨。
我是 Andrei Shikov,今天与我同台演讲的是 Andrew Bailey。
我们都在 Compose 编译器运行时团队工作,但也负责许多其他工作,包括性能。
今天的主题是回答大家关于性能的问题,因为 Jetpack Compose 已成为 Android 开发的默认界面工具包。
现在,很多人都在问,如何确保 Compose 界面尽可能高效。
所以今天,我们将深入探究,正如我之前所说。
此外,我们还将介绍一些工具,帮助您在 Compose 中超越自我。
首先,我想谈谈为什么性能对我们很重要。
在考虑质量和构建高质量应用时,您通常会考虑动画,我们会考虑丰富的功能集,有时会忽略性能,但用户感知确实取决于应用的性能。
因此,如果动画卡顿,用户体验会立即下降。
因此,我们今天将提到的所有指标和数字,实际上都是我们选择的代理指标,用于表示用户对应用的感受,以及他们对所有这些动画和屏幕上发生的所有事情的感受。
因此,从根本上说,优化应用就是优化体验。
今天,我们将深入探讨许多主题,我希望大家能保持这样的心态。
现在,当您开始查看应用的性能时,我希望您快速完成三件事,三件非常简单的事。
首先,如果您要衡量性能,请在发布模式下进行衡量,这样就不会有大量可调试信息,也不会导致应用运行缓慢。
第二件事,启用 R8 或其他缩减器。
就在今年,我们引入了一项优化,可将协程性能提升两倍,此外我们还做了许多其他工作。
在优化 Compose 时,我们非常依赖 R8。
最后一点,尤其是对于启动指标,您需要确保使用基准配置文件。
因此,您在启动时加载的类会在应用启动时预编译和预加载。
如您所见,这与应用的可调试 build 相比,性能提升了大约 6 倍。
当您衡量所有这些因素时,它们会与您在代码中取得的其他成果叠加。
现在,您已经完成了所有这些操作,但应用仍然有点卡顿,或者仍然存在其他性能问题,这时您就需要进入这个性能循环。
您想开始查看一些特定场景的指标。
例如,假设您的应用加载时间过长,您可能需要重置应用,像往常一样从启动器启动应用,然后检查应用加载到第一个数据屏幕所需的时间。
或者,应用可能有点卡顿,因此您滚动应用,并确保这种滚动是可重复且稳定的。
因此,此幻灯片中提到的稳定环境非常重要,因为在您稍后进行优化时,您需要确保看到指标有所提升,并确认这些优化确实达到了预期效果。
基准测试非常适合这种情况,因为基准测试已经捕获了大量数据,它们会进行多次迭代,以确保设备中没有太多噪声,并且它们还会进行大量设备设置,这在某种程度上对您有所帮助。
此外,基准测试默认也是可重复的场景,这也是我们在这里努力实现的目标。
但如果您愿意,也可以使用一系列脚本来完成。
最后,您需要进行性能分析,改进应用中的某些内容,查看数据,然后再次重复,并可能针对应用遇到问题的不同场景指标再次重复。
在查看数据时,您首先需要熟悉的是 Perfetto 跟踪记录,这也是我们今天介绍的第一个工具。
Perfetto 跟踪记录会记录您录制该跟踪记录时设备上发生的所有情况,例如,在 Macrobenchmark 运行期间,或者您正在录制的某个脚本运行期间。
这对每位致力于提升 Android 性能的工程师来说都是巨大的帮助。
对我来说,现在我每天的大部分工作时间都在查看 Perfetto 跟踪记录。
它们看起来像这样。
数据量非常大。
正如我所说,它们会捕获设备上发生的所有事件。
因此,您一开始可能很难找到应用的确切位置,建议您使用这个便捷的搜索栏。
搜索 Compose、搜索应用软件包等。
只需专注于熟悉的事物。
当您找到应用进程后,您会看到帧时间线以及应用中许多操作所花费的时间。
如果我们单独观察其中一个,就会看到类似这样的情况。
第一种是主线程工作。
Compose 的大部分工作都是在这里完成的。
然后是 RenderThread。
在这里,我们会处理画布命令,并将其编码到 GPU。
然后 GPU 执行其工作,接着系统进行合成。
今天我们只关注主线程。
因为这是 Compose 运行的地方。
我们讨论的是 Compose,因此只关注 Compose 是合理的。
当我们在主线程上隔离该帧时,实际上现在开始看到 Compose 切片。
我为您挑选了其中一些。
就像组合、测量、布局、绘制。
如果您之前了解过 Compose 阶段,并查看过性能,那么您可能会看到这些阶段。
因此,这些阶段在 Perfetto 轨迹中都有各自的切片,您可以轻松找出每个阶段所用的时间,以及在每个特定帧中运行的阶段。
我们已经详细讨论了如何优化这些工作,您需要确保在正确的阶段执行正确的工作。
例如,如果您有动画更新盒子的颜色或类似内容,您只想使绘制阶段失效,而不是组合和布局等。
这是性能基础知识,今天我们将更深入地探讨一些内容,特别是重点关注组合。
现在,有请 Andrew 为我们详细介绍。
谢谢,Andrei。
正如 Andrei 所说,我们之前花了很多时间在性能方面讨论优化,例如避免向后读取、确保将状态读取推迟到组合生命周期中的正确阶段,以及优化并确保可组合项按预期失效,这样您就不必在每一帧上做太多工作。
这是一个非常好的起点,但我们已经有很多关于这方面的资料,我们希望进一步推进这一讨论。
因此,今天我们将探讨在完成这些优化后,组合中还剩下什么,以及可组合项本身在做什么。
遗憾的是,Compose 代码中的所有内容,以及应用中与性能相关的所有内容,都会产生相关成本。
应用中发布的所有代码都会产生开销,即使是读取状态和在组合中组合附带效应等琐碎操作,也会产生一些实际开销。因此,请务必注意这些开销,尤其是在您开始剖析可组合项的实际作用并开始在应用本身中提升性能时。
为了展示在最微观尺度上的实际情况,这里列出了一些您可以在代码中的任何位置(尤其是在可组合项中)执行的最小操作。在这样的分辨率下,我们讨论的是纳秒级的时间尺度,以便您对这些操作的大致开销有一个概念。
如果您想提升性能,有两种选择。
您可以移除大量低成本操作,也可以移除少量高成本操作。
具体到此图表,就这些操作而言,协程属于开销较大的操作,因此可以作为很好的切入点。因为如果我们移除其中一些实例,就能更快地在应用中看到更明显的好处。
关于协程,还有一点需要注意,正如我在幻灯片中所说,不仅启动协程会产生相关开销,取消协程也会产生开销。
取消操作的开销尤其值得注意,因为这种情况主要发生在您处置内容并可能移至应用内其他区域时。
在许多应用中,离开当前页面是一种非常耗费资源的操作,您需要充分利用帧时间,并确保充分利用现有资源。
因此,请务必注意这两项费用。
无论我们关注的是哪一方面,所有这些因素都会累加起来,最终达到大约 1,600 万纳秒的规模。
因此,我们在这里有一些需要削减的预算,而协程就是一个很好的例子。
在 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 会非常有用。
在这里,在同一个媒体播放器示例中,我有一个示例,它会在可组合项存在时将 activity 的屏幕方向锁定为横向,然后在可组合项稍后消失时解除该屏幕方向锁定。
如果您希望实现 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() }
}如果您之前出于方便而使用 LaunchedEffect,或者出于一次性效果的必要性而使用 DisposableEffect,那么 with keys API 是一个很好的替代方案。导航或迁移到 SideEffect with keys API 可以实现相同的效果,但开销更低,性能更佳,尤其是在整个应用生命周期内多次迁移的情况下。
最后,最便宜的 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,每个都会对某个视图模型进行异步调用。
如果我将它们替换为单个 LaunchedEffect,然后在其中添加其他 launch 调用,即使我在这里运行的协程数量仍然相同,开销也会更少,因为这样可以将工作从组合中移出,转而放入协程中。
@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) { ... }因此,这里的屏幕模型包含一个标题屏幕,然后是所有联系人列表,这些联系人列表会通过几个可组合项,最终调用一个延迟列表,然后通过一些项目单元格将它们显示在屏幕上。
此代码的组合层次结构大致如下所示:我们再次从联系人屏幕开始,稍微展开一下,然后让其他一些可组合项调用并呈现我们的项。
如果查看数据的传递方式,我们会从顶部的模型开始。
我们将其解构为一个字符串,然后解构为一个列表,该列表随后会转换为延迟列表的 items lambda,然后延迟列表会组合屏幕上显示的所有项。
我们来看看修改此列表中的最后一个联系人时会发生什么。
我们希望仅使列表中的最后一项失效,但实际情况并非如此。
如果我们查看此重组的组合轨迹,它会像这样,我们从联系人屏幕开始,然后最终向下移动到此列表项。
而且这种方式的成本相当高。
我们还可以看到,在轨迹中,这些部分似乎都没有发生任何事情。
虽然这里不明显,但实际上,可组合项就是在这里决定是否要跳过。
在此示例中,由于延迟列表重组的方式,只有两个可组合项会被跳过。
当您为其提供新的内容 lambda 时,它会使它拥有的所有项失效。
因此,除了发生更改的联系人之外,它还会使未发生更改的两个联系人失效,而这两个联系人是此处唯一可以跳过的项。
其他所有内容都会执行此跳过相等性比较,只是为了意识到它实际上必须执行。
如果对象是稳定的,那么这种比较实际上可能相当昂贵。
如果您强制我们遍历所有数据结构,这实际上会增加成本,而且仅仅为了判断两个对象是否相等,成本可能不容忽视。
更糟糕的是,由于我们对列表进行了解构,实际上必须对列表执行两次相同的比较。
如果您让编译器采用默认行为并使用强跳过模式,则仍然可以跳过,并且仍然可以进行检查以有条件地跳过可组合项,但它们会采用更快的路线,即使用对象身份等式而不是结构等式。
这样一来,我们的计算速度会快得多,即使我们在此帧上重组相同的项(实际上,快速进入该帧非常重要),我们也不会浪费太多时间来确定我们实际上无法跳过这些可组合项。
这也是我们强调使用基准和跟踪记录等工具来了解应用内部情况,而不是直接使用重组计数的原因。
它们可以作为有用的代理,但在优化应用的过程中,不一定要过度关注或过度依赖它们。
请记住,不要尝试跳过可组合项,而是从一开始就避免使其失效。
在此示例中,我们看到组合轨迹可以提供大量有关所发生情况的信息,但它们也不一定能显示完整情况。
为此,Andrei 将向我们介绍方法轨迹。
好了
正如 Andrew 所说,组合跟踪并不总是能告诉您所有情况,因为它只对可组合函数进行注解,在某些情况下,您可能只会在组合跟踪中看到空白,虽然它已经增强了 Perfetto 跟踪,但仍然无法显示其中发生的所有情况。
有时,这可能是 Compose 的问题,有时可能是代码的问题,仅通过查看此处的组合轨迹很难找出原因。
现在,您需要获取方法轨迹。
Android Studio 的性能分析器中有一个选项,或者您也可以使用 ADB 命令来获取跟踪记录,这样您就可以获取 Java 环境中发生的每个函数调用。
它包含 Compose 编译器在其中编码的所有内容、Compose 运行时执行的所有操作以及您的代码执行的所有操作,对于跟踪、调查确切执行的内容以及在该空间中调用的函数非常有用。
有一点,我想-- 看到中间的这个商品。
我想指出的是,方法轨迹的开销非常高。
开销会增加 10 到 20 倍,具体取决于函数树的复杂程度,因为每次函数调用都会增加一点开销。
为了说明这一点,我们可以看一下屏幕截图。
应用启动时的方法轨迹通常是这样的,您可能会问:“我该如何理解这些信息?”
通常,您会从组合轨迹开始,查看此处列出的函数。
因此,这就像简化的组合轨迹,其中包含 ScreenContent、一些 Button 以及其他一些函数。
假设我们想了解 ScreenContent 中发生了什么。
Perfetto 有一个搜索栏。
在 Perfetto 查看器中加载方法轨迹,搜索与此处相同的函数 ScreenContent,就会得到类似的内容,即屏幕的方法轨迹。
您可以看到,这里有一些 Composer.startGroup 和群组通话。
这就是运行时和编译器在后台为您执行的操作,目的是确保您的组合能够真正发挥其魔力,但这里有趣的调用是 Composer.changed。
这就是我们检查参数在可组合项的上一次调用和当前调用之间是否发生变化的方式。
其中大部分Composer.changed由ScreenModel.equals主导。
因此,ScreenModel 可能是某种大数据类,我们可能为了帮助减少重组次数而将其设为稳定,所以它可能出现在编译器报告中。
在这种特定情况下,移除此 stable 注解可能有助于提升性能。
因此,您需要寻找这类情况,而组合方法轨迹是发现这类情况的最佳工具。
好了
今天我们探讨了许多内容,现在请 Andrew 为我们做总结。
最后,我们来简要总结一下今天讨论的所有内容。在设计可组合项以及优化现有应用时,请注意以下几点。
首先,请记住,开销较低的可组合项和您未失效的可组合项比您跳过的可组合项更好。
请注意,在设计可组合项时,最好考虑使用更小、更易于比较的实参,并尽可能重复使用与其他函数调用的比较结果,以便充分利用这项优化。
在可组合项中使用 API 时,请注意,性能或帧时间将取决于 API 本身的开销以及您使用它们的频率。
请注意这些费用的累加方式,并注意可能存在与这些费用相关的拆解费用,您可能会在应用最不合适的时间遇到这些费用。
此外,请记住,在应用本身内部进行优化时,务必做一个“好公民”。
首先,进行基准测试,以便做出明智的决策;对应用进行性能分析,确保您针对的是代码的正确区域;验证您是否正在进行更改,并查看您期望从基准测试和性能分析所做的修改中获得的结果。
此外,请注意,您可能需要在应用的可维护性和性能之间进行权衡。
因此,请选择最适合您和您团队的做法,确保不会过度优化,从而在可维护性方面陷入困境。
此外,您还可以使用另一个工具来获取更多帮助,那就是您已有的 AI 智能体。
我们已在 Android CLI 中推出了一项 Jetpack Compose 性能技能,其中包含我们今天讨论的许多信息,可以检查和审核您的应用,并针对此类优化提供建议。
您可以使用 Android CLI 将其安装到现有代理中。
android skills add jetpack-compose-performance如需详细了解 Android CLI,请访问 developer.android.com。
您还可以在我们的 GitHub 代码库中详细了解我们的技能。
最后,祝您安全地进行基准测试,愉快地使用 Compose。
@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