在 Jetpack Compose 中解决性能问题的实用方法

1. 准备工作

在此 Codelab 中,您将学习如何提升 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:包含该项目的起始代码,您将在其中做出更改来完成此 Codelab。
  • end:包含此 Codelab 的解决方案代码。

我们建议您从 main 分支开始,按照自己的节奏逐步完成此 Codelab。

  1. 如果您想查看解决方案代码,请运行以下命令:
$ git clone -b end https://github.com/android/codelab-android-compose.git

或者,您也可以下载解决方案代码:

可选:此 Codelab 中使用的系统轨迹

在此 Codelab 的学习过程中,您将运行多项基准测试来捕获系统轨迹。

如果您无法运行这些基准测试,可以改为下载下列系统轨迹:

3. 解决性能问题的方法

您只需用肉眼浏览应用,就能发现运行缓慢、性能不佳的界面。但是,在根据自己的假设开始修正代码之前,您应该衡量代码的性能,了解所做的更改是否会产生效果。

在开发期间使用应用的 debuggable build 时,您也许会注意到某些功能的性能不尽如人意,因而可能会想着手解决这个问题。但是,debuggable 应用的性能并不一定能反映用户实际使用时的表现,因此请务必使用 non-debuggable 应用来验证问题是否确实存在。在 debuggable 应用中,所有代码都必须由运行时解释。

就 Compose 中的性能而言,并没有什么硬性规定是您在实现特定功能时必须遵循的。您不应过早执行以下操作:

  • 紧盯并修正每个潜入代码中的不稳定参数。
  • 移除会导致该可组合函数重组的动画。
  • 凭直觉进行难以理解的优化。

您应利用现有工具明智地做出修改,确保这些修改能够解决性能问题。

处理性能问题时,您应遵循以下科学方法:

  1. 通过衡量确定初始性能。
  2. 观察导致问题的原因。
  3. 根据观察结果修改代码。
  4. 衡量性能并与初始性能进行对比。
  5. 重复执行。

如果您未遵循任何结构化方法,其中一些更改可能会提升性能,而另一些更改可能会降低性能,最终您可能会陷入原地踏步的境地。

建议您观看以下关于使用 Compose 增强应用性能的视频,其中详细介绍了修复性能问题的过程,甚至还给出了一些提升性能的技巧。

生成基准配置文件

在深入调查性能问题之前,请为您的应用生成基准配置文件。在 Android 6(API 级别 23)及更高版本中,应用运行的代码是在运行时解释,并在安装时进行即时编译 (JIT) 和预先 (AOT) 编译。经过解释和 JIT 编译的代码的运行速度比 AOT 慢,但占用的磁盘空间和内存空间更少,因此并非所有代码都应该进行 AOT 编译。

通过实现基准配置文件,您可以将应用启动时间缩短 30%,并将在运行时以 JIT 模式运行的代码减少 8 倍,如下图(基于 Now in Android 示例应用)所示:

b51455a2ca65ea8.png

如需详细了解基准配置文件,请参阅以下资源:

衡量性能

为了衡量性能,建议使用 Jetpack Macrobenchmark 来设置和编写基准测试。Macrobenchmark 是一种插桩测试,可像用户一样与应用互动,同时监控应用的性能。这意味着它们不会用测试代码污染应用代码,因此能够提供可靠的性能信息。

在此 Codelab 中,我们已经设置了代码库并编写了基准测试,以便直接专注于解决性能问题。如果您不确定如何在项目中设置和使用 Macrobenchmark,请参阅以下资源:

使用 Macrobenchmark 时,您可以选择以下编译模式之一:

  • None:重置编译状态,并在 JIT 模式下运行所有代码。
  • Partial:使用基准配置文件和/或预热迭代预编译应用,并在 JIT 模式下运行。
  • Full:预编译整个应用代码,因此没有代码在 JIT 模式下运行。

在此 Codelab 中,您只将 CompilationMode.Full() 模式用于基准测试,因为您只关心您在代码中所做的更改,而不是应用的编译状态。这种方法可让您减少代码在 JIT 模式下运行时产生的差异(在实现自定义基准配置文件时应该会减少)。请注意,Full 模式可能会对应用启动产生负面影响,因此请勿将其用于衡量应用启动性能的基准测试,而应仅将其用于衡量运行时性能改进的基准测试。

完成性能提升后,如果您想检查用户安装应用时的性能表现,请使用采用基准配置文件的 CompilationMode.Partial() 模式。

在下一部分中,您将学习如何读取轨迹以找出性能问题。

4. 使用系统跟踪分析性能

对于应用的 debuggable build,您可以使用布局检查器和组合计数来快速了解什么情况下的重组过于频繁。

b7edfea340674732.gif

不过,这只是整体性能调查的一部分,因为您只会获得代理衡量结果,而不是这些可组合函数的实际渲染时间。如果总时长不到 1 毫秒,内容重组 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. :measure 模块的 build.gradle 文件中添加 androidx.benchmark.fullTracing.enable=true 插桩参数:
defaultConfig {
    // ...
    testInstrumentationRunnerArguments["androidx.benchmark.fullTracing.enable"] = "true"
}

如需详细了解如何设置组合跟踪(例如,如何从终端使用组合跟踪),请参阅相关文档

使用 Macrobenchmark 捕获初始性能

您可以通过多种方式检索系统轨迹文件。例如,您可以使用 Android Studio 性能分析器进行记录在设备上进行捕获,或者检索使用 Macrobenchmark 记录的系统轨迹。在此 Codelab 中,您将使用 Macrobenchmark 库获取的轨迹。

此项目包含了位于 :measure 模块中的基准测试,您可以运行这些基准测试来获取性能衡量结果。为了在此 Codelab 期间节省时间,该项目中的基准测试设置为仅运行一次迭代。在实际应用中,如果输出差异较大,建议至少运行十次迭代。

如需捕获初始性能,请使用可滚动第一个任务界面的 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 查询等功能更深入地调查系统上运行的所有进程。在此 Codelab 中,您将使用 Perfetto 分析系统轨迹。

  1. 打开 Perfetto 网站,加载该工具的信息中心。
  2. 在主机文件系统上找到由 Macrobenchmark 捕获的系统轨迹,它们保存在 [module]/outputs/connected_android_test_additional_output/benchmarkRelease/connected/[device]/ 文件夹中。每次基准测试迭代都会记录一个单独的轨迹文件,每个轨迹文件都包含相同的应用互动。

51589f24d9da28be.png

  1. AccelerateHeavyScreenBenchmark_...iter000...perfetto-trace 文件拖动到 Perfetto 界面,然后等待其加载轨迹文件。
  2. 可选:如果您无法运行基准测试并生成轨迹文件,请下载轨迹文件并将其拖动到 Perfetto:

547507cdf63ae73.gif

  1. 找到应用的进程,即 com.compose.performance。通常,前台应用位于硬件信息通道和几个系统通道的下方。
  2. 打开包含应用进程名称的下拉菜单。您会看到应用中正在运行的线程的列表。请让轨迹文件保持打开状态,因为下一步会用到它。

582b71388fa7e8b.gif

如需找出应用中的性能问题,您可以利用应用线程列表顶部的“Expected Timeline”和“Actual Timeline”:

1bd6170d6642427e.png

“Expected Timeline”会显示系统期望应用生成的帧何时显示流畅、性能良好的界面。在本例中就是 16 毫秒和 600 微秒(1,000 毫秒/60)。“Actual Timeline”会显示应用生成的帧的实际时长,包括 GPU 工作。

您可能会看到不同的颜色,它们分别表示以下情况:

  • 绿色帧:准时生成的帧。
  • 红色帧:卡顿帧,用时比预期更长。您应调查在这些帧中完成的工作,以防止出现性能问题。
  • 浅绿色帧:帧是在规定时间内生成的,但呈现较晚,导致输入延迟时间增加。
  • 黄色帧:帧卡顿,但应用不是原因所在。

当界面渲染在屏幕上时,更改所需的时间必须短于设备预计创建帧的时长。过去,由于显示屏刷新率为 60Hz,这一时间约为 16.6 毫秒;但对于现代 Android 设备而言,由于显示屏刷新率为 90Hz 或更快,因此这一时间可能约为 11 毫秒或更短。由于刷新率不同,每帧的刷新频率也可能不同。

例如,如果界面由 16 个项目组成,那么每个项目的创建时间大约 1 毫秒,以防止跳帧。另一方面,如果您只有一个项目(如视频播放器),则可以花费长达 16 毫秒的时间进行组合,而不会出现卡顿。

了解系统跟踪调用图表

下图是一个简化版的系统轨迹示例,显示了重组。

8f16db803ca19a7d.png

自上而下的每个条形图都是其下方条形图的总时间,条形图也对应于被调用函数的代码部分。Compose 调用会在组合层次结构中重组。第一个可组合函数是 MaterialThemeMaterialTheme 内是一个提供主题设置信息的本地组合。然后,将调用 HomeScreen 可组合函数。主屏幕可组合函数会在其组合过程中调用 MyImageMyButton 可组合函数。

系统轨迹中的间隙源于运行了未跟踪的代码,因为系统轨迹只会显示标记为需要跟踪的代码。如果代码是在调用 MyImage 之后但在调用 MyButton 之前运行的,所占用的时间即间隙大小。

在下一步中,您将分析在上一步获取的轨迹。

5. 加快处理高负载的可组合函数

在尝试优化应用性能时,首先应该在主线程上寻找所有高负载的可组合函数或长时间运行的任务。长时间运行的工作可能意味着不同的情形,具体取决于界面的复杂程度以及有多少时间来组合界面。

因此,如果丢帧,您需要确定哪些可组合函数的用时过长,并通过分流主线程或跳过它们在主线程中执行的一些工作来加快其速度。

如需分析从 AccelerateHeavyScreenBenchmark 测试中获取的轨迹,请按以下步骤操作:

  1. 打开您在上一步中获取的系统轨迹。
  2. 放大第一个长帧,其中包含数据加载后的界面初始化。该帧的内容类似于下图:

838787b87b14bbaf.png

在轨迹中,您可以看到一帧内发生了许多情形,这些都可以在 Choreographer#doFrame 部分下找到。从图片中可以看出,最大块的工作来自包含 ImagePlaceholder 部分的可组合函数,该部分会加载大图片。

不要在主线程上加载大图片

虽然使用 CoilGlide 等便捷库从网络异步加载图片是一种常见做法,但如果应用本地有一张需要显示的大图片,该怎么办呢?

从资源加载图片的常用 painterResource 可组合函数会在组合期间在主线程上加载图片。这意味着,如果图片很大,就会阻塞主线程上的一些工作。

在本例中,您可以看到异步图片占位符存在问题。painterResource 可组合函数会加载占位图片,大约需要 23 毫秒。

c83d22c3870655a7.jpeg

有几种方法可以改善此问题,其中包括:

  • 异步加载图片。
  • 缩小图片,以便加快加载速度。
  • 使用根据所需尺寸缩放的矢量可绘制对象。

如需解决此性能问题,请按以下步骤操作:

  1. 找到 AccelerateHeavyScreen.kt 文件。
  2. 找到用于加载图片的 imagePlaceholder() 可组合函数。该占位图片的尺寸为 1600x1600 像素,对于显示内容而言,这显然太大了。

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

在实际应用中,造成性能问题的可能并不是占位图片,而是一些效果图。在这种情况下,您可以使用 Coil 的 rememberAsyncImage 可组合函数来异步加载可组合函数。在占位符加载之前,此解决方案会显示空白区域,因此请注意,您可能需要为此类图片设置占位符。

在下一步中,我们还将解决其他一些性能不佳的问题。

6. 将繁重操作分流到后台线程

如果继续调查同一项目是否存在其他问题,您会看到名为 binder transaction 的部分,每个部分大约需要 1 毫秒。

5c08376b3824f33a.png

名为 binder transaction 的部分会显示您的进程和某个系统进程之间发生了进程间通信。这是从系统中检索某些信息(例如检索系统服务)的常规方法。

与系统通信的许多 API 都包含这些事务。例如,使用 getSystemService 检索系统服务、注册广播接收器或请求 ConnectivityManager 时。

遗憾的是,这些事务不会提供太多关于其请求内容的信息,因此您必须分析代码中提到的 API 使用情况,然后添加自定义 trace 部分,以确保这是有问题的部分。

如需改进 binder 事务,请按以下步骤操作:

  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))
}

一般来说,在主线程上这样长时间运行的代码可能不会引发很多问题,但是由于界面上每个可见的项目都需要运行此事务,因此可能会导致问题。假设界面上有 6 个可见项目,都需要在第一帧进行组合。仅这些调用就需要 12 毫秒的时间,这几乎占到了单个帧的全部时限。

要解决此问题,您需要将广播注册分流到其他线程。您可以使用协程来实现此目的。

  1. 获取与可组合函数生命周期绑定的作用域 val scope = rememberCoroutineScope()
  2. 在 Effect 内部,使用 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) }
}

还有一个优化步骤。您不需要为列表中的每个项目都创建广播接收器,一个即可。您应该提升该广播接收器!

您可以提升广播接收器,然后沿可组合函数树向下传递时区参数;或者,如果界面中使用该时区参数的位置不多,那么您可以使用本地组合。

在此 Codelab 中,您需要将广播接收器保留为可组合函数树的一部分。不过,在实际应用中,将其分离到数据层中可能会有好处,以免污染界面代码。

  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. DisposableEffectPublishedText 可组合函数移至新的可组合函数,以便将其提升到此位置;并将 currentTimeZone 替换为具有以下状态和附带效应的 TimeZone:
@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 实现更改为用于迭代 tags 列表的 Row 可组合函数,如以下代码段所示:
@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 可组合函数,因为只有在根据可用空间组合不同的界面时才会需要此可组合函数。

在这一部分中,您学习了如何修正耗时过长的组合。

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 个阶段:

  • 组合:通过构建可组合函数树来确定要显示的内容。
  • 布局:采用该树状结构,并确定可组合函数在屏幕上的显示位置。
  • 绘制:在屏幕上绘制可组合函数。

这些阶段的顺序大致相同,允许数据沿一个方向(从组合到布局再到绘制)流动,从而生成界面帧。

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 Animation 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 方法的 lambda 函数。

强力跳过模式可在 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 应用的性能!虽然只展示了应用中可能会遇到的一小部分性能问题,但您学习了如何查看其他潜在问题以及如何解决这些问题。

后续操作

如果您还没有为您的应用生成基准配置文件,我们强烈建议您执行此操作。

您可以参照使用基准配置文件提升应用性能 Codelab。如需详细了解如何设置基准测试,请参阅使用 Macrobenchmark 检查应用性能 Codelab。

了解更多内容