使用 Macrobenchmark 检查应用性能

1. 准备工作

在此 Codelab 中,您将学习使用 Macrobenchmark 库。您将测量应用启动时间(用户参与度的关键指标)和帧时间(可提示应用可能发生卡顿的位置)。

您需要准备什么

您将做什么

  • 向现有应用添加基准测试模块
  • 测量应用启动和帧时间

您将学到什么

  • 可靠地测量应用性能

2. 设置

首先,使用以下命令从命令行克隆 Github 仓库

$ git clone https://github.com/android/codelab-android-performance.git

另外,您也可以下载两个 zip 文件

在 Android Studio 中打开项目

  1. 在 Android Studio 欢迎窗口中选择 c01826594f360d94.png 打开现有项目
  2. 选择文件夹 [Download Location]/android-performance/benchmarking(提示:确保选择包含 build.gradlebenchmarking 目录)
  3. Android Studio 导入项目后,请确保您可以运行 app 模块来构建我们将要进行基准测试的示例应用。

3. Jetpack Macrobenchmark 简介

Jetpack Macrobenchmark 库用于衡量大型端用户交互的性能,例如应用启动、与 UI 交互以及动画。该库提供对您正在测试的性能环境的直接控制。它允许您控制应用的编译、启动和停止,从而直接衡量应用启动时间、帧时间以及跟踪的代码段。

借助 Jetpack Macrobenchmark,您可以

  • 通过确定的启动模式和滚动速度多次测量应用
  • 通过对多次测试运行的结果取平均值来平滑性能差异
  • 控制应用的编译状态 - 影响性能稳定性的主要因素
  • 通过本地复现 Google Play 商店执行的安装时优化来检查实际性能

使用此库的检测不会直接调用您的应用代码,而是像用户一样导航您的应用 - 轻触、点击、滑动等。测量是在设备上进行这些交互期间发生的。如果您想直接测量应用代码的某些部分,请参阅 Jetpack Microbenchmark

编写基准测试就像编写检测测试一样,只是您无需验证应用所处的状态。基准测试使用 JUnit 语法(@RunWith@Rule@Test 等),但测试在单独的进程中运行,以允许重新启动或预编译您的应用。这使我们能够在不干扰应用内部状态的情况下运行它,就像用户一样。我们通过使用 UiAutomator 与目标应用进行交互来做到这一点。

示例应用

在此 Codelab 中,您将使用 JetSnack 示例应用。这是一个使用 Jetpack Compose 的虚拟零食订购应用。要衡量应用的性能,您无需了解应用的架构细节。您需要了解的是应用的行为方式和 UI 结构,以便从基准测试中访问 UI 元素。运行应用并通过订购您选择的零食来熟悉基本屏幕。

a1f684feb2456079.png

4. 添加 Macrobenchmark 库

Macrobenchmark 要求向您的项目添加一个新的 Gradle 模块。将其添加到项目中最简单的方法是使用 Android Studio 模块向导。

打开新建模块对话框(例如,在项目面板中右键点击您的项目或模块,然后选择新建 > 模块)。

4ce8ef7fa59b41b8.png

模板窗格中选择 Benchmark,确保 Macrobenchmark 被选为基准测试模块类型,并检查详细信息是否符合您的预期

24884734761bcfed.png

  • 目标应用 – 将进行基准测试的应用
  • 模块名称 – 基准测试 Gradle 模块的名称
  • 包名称 – 基准测试的包名称
  • 最低 SDK – 需要 Android 6 (API 级别 23) 或更高版本

点击完成

模块向导所做的更改

模块向导会对您的项目进行一些更改。

它添加了一个名为 macrobenchmark 的 Gradle 模块(或者您在向导中选择的名称)。此模块使用 com.android.test 插件,该插件会告诉 Gradle 不要将其包含在您的应用中,因此它只能包含测试代码(或基准测试)。

向导还会更改您选择的目标应用模块。具体来说,它会向 :app 模块的 build.gradle.kts 添加新的 benchmark 构建类型,如以下代码段所示

create("benchmark") {
   initWith(buildTypes.getByName("release"))
   matchingFallbacks += listOf("release")
   isDebuggable = false
}

此构建类型应尽可能地模拟您的 release 构建类型。与 release 构建类型的区别在于 signingConfig 设置为 debug,这只是为了让您无需生产密钥库即可在本地构建应用。

但是,由于 debuggable 标志被禁用,向导会将 <profileable> 标签添加到您的 AndroidManifest.xml 中,以允许基准测试在发布性能下对您的应用进行性能分析。

<application>

  <profileable
     android:shell="true"
     tools:targetApi="29" />

</application>

要获取有关 <profileable> 功能的更多信息,请查看我们的文档

向导做的最后一件事是创建一个用于基准测试启动时间的脚手架(我们将在下一步中使用)。

现在您已准备好开始编写基准测试。

5. 测量应用启动时间

应用启动时间,即用户开始使用您的应用所需的时间,是影响用户参与度的关键指标。模块向导会创建一个 ExampleStartupBenchmark 测试类,该类能够测量您的应用启动时间,其代码如下

@RunWith(AndroidJUnit4::class)
class ExampleStartupBenchmark {
   @get:Rule
   val benchmarkRule = MacrobenchmarkRule()

   @Test
   fun startup() = benchmarkRule.measureRepeated(
       packageName = "com.example.macrobenchmark_codelab",
       metrics = listOf(StartupTimingMetric()),
       iterations = 5,
       startupMode = StartupMode.COLD,
   ){
        pressHome()
        startActivityAndWait()
   }
}

所有参数是什么意思?

编写基准测试时,入口点是 MacrobenchmarkRulemeasureRepeated 函数。此函数会处理基准测试的所有事务,但您需要指定这些参数

  • packageName – 基准测试与被测应用在单独的进程中运行,因此您需要指定要测量的应用。
  • metrics – 在基准测试期间您想要测量的信息类型。在我们的例子中,我们对应用启动时间感兴趣。有关其他类型的指标,请查看文档
  • iterations – 基准测试将重复多少次。迭代次数越多意味着结果越稳定,但会以更长的执行时间为代价。理想的次数取决于此特定指标对于您的应用的噪声程度。
  • startupMode – 允许您定义在基准测试开始时您的应用应如何启动。可用模式有 COLDWARMHOT。我们使用 COLD,因为它代表了应用必须执行的最大工作量。
  • measureBlock(最后一个 lambda 参数)– 在此函数中,您定义在基准测试期间要测量的操作(启动 Activity、点击 UI 元素、滚动、滑动等),macrobenchmark 将在此块期间收集定义的 metrics

如何编写基准测试操作

Macrobenchmark 将重新安装并重新启动您的应用。确保您编写的交互独立于应用的状态。Macrobenchmark 提供了几个有用的函数和参数来与您的应用进行交互。

最重要的函数是 startActivityAndWait()。此函数将启动您的默认 Activity 并等待直到它渲染第一个帧,然后才继续执行基准测试中的指令。如果您想启动不同的 Activity 或调整启动 Intent,可以使用可选的 intentblock 参数来执行此操作。

另一个有用的函数是 pressHome()。这允许您在每次迭代不杀死应用的情况下(例如,当您使用 StartupMode.HOT 时)将基准测试重置为基本条件。

对于任何其他交互,您可以使用 device 参数,该参数允许您查找 UI 元素、滚动、等待某些内容等。

好的,现在我们已经定义了一个启动基准测试,您将在下一步中运行它。

6. 运行基准测试

在运行基准测试之前,请确保您在 Android Studio 中选择了正确的构建变体

  1. 选择构建变体面板
  2. 活动构建变体更改为benchmark
  3. 等待 Android Studio 同步

4f47527b618433a.gif

如果您未执行此操作,则基准测试将在运行时失败,并出现错误,指示您不应对 debuggable 应用进行基准测试

java.lang.AssertionError: ERRORS (not suppressed): DEBUGGABLE
WARNINGS (suppressed):

ERROR: Debuggable Benchmark
Benchmark is running with debuggable=true, which drastically reduces
runtime performance in order to support debugging features. Run
benchmarks with debuggable=false. Debuggable affects execution speed
in ways that mean benchmark improvements might not carry over to a
real user's experience (or even regress release performance).

您可以使用检测参数 androidx.benchmark.suppressErrors = "DEBUGGABLE" 临时禁止此错误。您可以按照与在 Android 模拟器上运行基准测试步骤中相同的步骤进行操作。

现在,您可以运行基准测试了——就像运行检测测试一样。您可以使用代码旁边的 gutter 图标运行测试函数或整个类。

29c3b85f79816f17.png

确保您选择了物理设备,因为在 Android 模拟器上运行基准测试将在运行时失败,并发出警告,指出结果将不准确。虽然从技术上讲您可以在模拟器上运行它,但您实际上是在测量您的宿主机器性能——如果宿主机器负载很重,您的基准测试将表现得更慢,反之亦然。

335e1009223de4b3.png

运行基准测试后,您的应用将被重建,然后运行您的基准测试。基准测试将根据您定义的 iterations 次数多次启动、停止甚至重新安装您的应用。

7.(可选)在 Android 模拟器上运行基准测试

如果您没有物理设备但仍想运行基准测试,可以使用检测参数 androidx.benchmark.suppressErrors = "EMULATOR" 抑制运行时错误

要抑制此错误,请编辑您的运行配置

  1. 从运行菜单中选择“Edit Configurations...”:5ed4cbeb68ec4c97.png
  2. 在打开的窗口中,选择“Instrumentation arguments”旁边的“选项”图标 d628c071dd2bf454.png 89be2d51bf94c098.png
  3. 点击 ➕ 并输入详细信息来添加 Instrumentation extra param a06c7f6359d6b92c.png
  4. 点击 OK 确认选择。你应该会在“Instrumentation arguments”行中看到该参数 f0a8a7f54d47e5dc.png
  5. 点击 Ok 确认运行配置。

另外,如果你需要将其永久保留在代码库中,可以在 :macrobenchmark 模块的 build.gradle 文件中进行设置。

defaultConfig {
    // ...
    testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = 'EMULATOR'
}

8. 理解启动结果

基准测试运行完成后,它会在 Android Studio 中直接给出结果,如下面的截图所示。

a9bddcc8dced7879.png

你可以看到,在我们的例子中,Google Pixel 7a 上的启动时间,最小值是 312.5ms,中位数是 337.6ms,最大值是 390.1ms。请注意,在你的设备上运行相同的基准测试时,结果可能会有所不同。结果会受到许多因素的影响,例如:

  • 设备性能
  • 使用的系统版本
  • 后台运行的应用

正因为如此,在同一设备上比较结果非常重要,理想情况下应处于相同状态,否则可能会看到很大的差异。如果你无法保证相同的状态,你可能需要增加 iterations 的数量,以便更好地处理结果中的离群值。

为了便于调查,Macrobenchmark 库会在基准测试执行期间记录系统跟踪。为了方便起见,Android Studio 会将每次迭代和测量的时间标记为系统跟踪的链接,以便你可以轻松打开进行调查。

9.(可选练习)声明应用何时准备就绪可用

Macrobenchmark 可以自动测量应用渲染第一帧的时间(timeToInitialDisplay)。然而,应用内容通常在第一帧渲染后才加载完成,你可能想知道用户需要等待多久应用才能使用。这被称为完全显示时间 (time to full display) – 应用已完全加载内容,用户可以与它交互。Macrobenchmark 库可以自动检测这个时间点,但你需要修改应用,使用 Activity.reportFullyDrawn() 函数告知何时发生。

示例展示了一个简单的进度条,直到数据加载完毕。因此,你希望等待数据准备就绪且零食列表被布局并绘制完成。让我们修改示例应用并添加 reportFullyDrawn() 调用。

项目窗格中,打开 .ui.home 包下的 Feed.kt 文件。

fe64e7f9cb75beb4.png

在该文件中,找到负责组合零食列表的 SnackCollectionList Composable 函数。

你需要检查数据是否准备就绪。你知道在内容准备好之前,snackCollections 参数会返回一个空列表,所以你可以使用 ReportDrawnWhen Composable 函数,它会在谓词为 true 时负责报告。

ReportDrawnWhen { snackCollections.isNotEmpty() }

Box(modifier) {
   LazyColumn {
   // ...
}

此外,你也可以使用接受 suspend 函数并等待该函数完成的 ReportDrawnAfter{} Composable 函数。通过这种方式,你可以等待异步加载一些数据,或等待某个动画完成。

完成此步骤后,你需要调整 ExampleStartupBenchmark 以等待内容加载,否则基准测试将在渲染第一帧后完成,并可能遗漏该指标。

当前的启动基准测试仅等待渲染第一帧。等待本身包含在 startActivityAndWait() 函数中。

@Test
fun startup() = benchmarkRule.measureRepeated(
   packageName = "com.example.macrobenchmark_codelab",
   metrics = listOf(StartupTimingMetric()),
   iterations = 5,
   startupMode = StartupMode.COLD,
) {
   pressHome()
   startActivityAndWait()

   // TODO wait until content is ready
}

在我们的例子中,你可以等到内容列表有一些子项,因此添加 wait(),如下面的代码片段所示

@Test
fun startup() = benchmarkRule.measureRepeated(
   //...
) {
   pressHome()
   startActivityAndWait()

   val contentList = device.findObject(By.res("snack_list"))
   val searchCondition = Until.hasObject(By.res("snack_collection"))
   // Wait until a snack collection item within the list is rendered
   contentList.wait(searchCondition, 5_000)
}

解释一下代码片段中发生的情况:

  1. 我们通过 Modifier.testTag("snack_list") 找到零食列表
  2. 我们定义了搜索条件,使用 snack_collection 作为要等待的元素
  3. 我们使用 UiObject2.wait 函数在 UI 对象中等待条件满足,超时时间为 5 秒

现在,你可以再次运行基准测试,库将自动测量 timeToInitialDisplaytimeToFullDisplay,如下面的截图所示:

cbbff5648512369.png

你可以看到,在我们的例子中,TTID 和 TTFD 之间的差异是 389.8ms。这意味着即使你的用户在 392.9ms 时看到了渲染的第一帧,他们仍然需要额外等待 389.8ms 才能滚动列表。

10. 基准测试帧时间

用户进入应用后,遇到的第二个指标是应用的流畅度。或者用我们的术语来说,应用是否丢帧。为了衡量这一点,我们将使用 FrameTimingMetric

假设你想衡量列表项的滚动行为,并且不想测量该场景之前的任何内容。你需要将基准测试分为测量和未测量的交互。为此,我们将使用 setupBlock lambda 参数。

在未测量的交互(在 setupBlock 中定义)中,我们将启动默认的 Activity;在测量的交互(在 measureBlock 中定义)中,我们将找到 UI 列表元素,滚动列表并等待屏幕渲染内容。如果你没有将交互分为这两部分,你将无法区分应用启动期间生成的帧和列表滚动期间生成的帧。

创建帧时间基准测试

为了实现上述流程,让我们创建一个新的 ScrollBenchmarks 类,其中包含一个 scroll() 测试,该测试将包含滚动帧时间基准测试。首先,创建具有基准测试规则和空测试方法的测试类

@RunWith(AndroidJUnit4::class)
class ScrollBenchmarks {
   @get:Rule
   val benchmarkRule = MacrobenchmarkRule()

   @Test
   fun scroll() {
       // TODO implement scrolling benchmark
   }
}

然后,添加带有必需参数的基准测试框架。

@Test
fun scroll() {
   benchmarkRule.measureRepeated(
       packageName = "com.example.macrobenchmark_codelab",
       iterations = 5,
       metrics = listOf(FrameTimingMetric()),
       
       setupBlock = {
           // TODO Add not measured interactions.
       }
   ) {
       // TODO Add interactions to measure list scrolling.
   }
}

此基准测试使用与 startup 基准测试相同的参数,但 metrics 参数和 setupBlock 除外。FrameTimingMetric 收集应用生成的帧的时间。

现在,我们来填写 setupBlock。如前所述,在此 lambda 中,基准测试不会测量交互。你可以使用此块仅打开应用并等待渲染第一帧。

@Test
fun scroll() {
   benchmarkRule.measureRepeated(
       packageName = "com.example.macrobenchmark_codelab",
       iterations = 5,
       metrics = listOf(FrameTimingMetric()),
       startupMode = StartupMode.COLD,
       setupBlock = {
           // Start the default activity, but don't measure the frames yet
           pressHome()
           startActivityAndWait()
       }
   ) {
       // TODO Add interactions to measure list scrolling.
   }
}

现在,我们来编写 measureBlock(最后一个 lambda 参数)。首先,由于将项目提交到零食列表是一个异步操作,你应该等待内容准备就绪。

benchmarkRule.measureRepeated(
   // ...
) {
    val contentList = device.findObject(By.res("snack_list"))

    val searchCondition = Until.hasObject(By.res("snack_collection"))
    // Wait until a snack collection item within the list is rendered
    contentList.wait(searchCondition, 5_000)

   // TODO Scroll the list
}

可选地,如果你不关心测量初始布局设置,可以在 setupBlock 中等待内容准备就绪。

接下来,为零食列表设置手势边距。你需要这样做,否则应用可能会触发系统导航,并退出应用而不是滚动内容。

benchmarkRule.measureRepeated(
   // ...
) {
   val contentList = device.findObject(By.res("snack_list"))

   val searchCondition = Until.hasObject(By.res("snack_collection"))
   // Wait until a snack collection item within the list is rendered
   contentList.wait(searchCondition, 5_000)

   // Set gesture margin to avoid triggering system gesture navigation
   contentList.setGestureMargin(device.displayWidth / 5)

   // TODO Scroll the list
}

最后,你实际上使用 fling() 手势滚动列表(你也可以使用 scroll()swipe(),具体取决于你想滚动多少和多快),并等待 UI 变为空闲。

benchmarkRule.measureRepeated(
   // ...
) {
   val contentList = device.findObject(By.res("snack_list"))

   val searchCondition = Until.hasObject(By.res("snack_collection"))
   // Wait until a snack collection item within the list is rendered
   contentList.wait(searchCondition, 5_000)

   // Set gesture margin to avoid triggering gesture navigation
   contentList.setGestureMargin(device.displayWidth / 5)

   // Scroll down the list
   contentList.fling(Direction.DOWN)

   // Wait for the scroll to finish
   device.waitForIdle()
}

该库将在执行定义操作时测量应用生成的帧的时间。

现在你已经准备好运行基准测试了。

运行基准测试

你可以使用与启动基准测试相同的方式运行基准测试。点击测试旁边的 gutter 图标,然后选择 Run ‘scroll()'。

9be4fb8b07d4ce5b.png

如果你需要有关运行基准测试的更多信息,请查看运行基准测试步骤。

理解结果

FrameTimingMetric 以毫秒为单位输出帧持续时间(frameDurationCpuMs)的第 50、90、95 和 99 百分位数。在 Android 12(API level 31)及更高版本上,它还会返回帧超出限制的时间(frameOverrunMs)。该值可能为负,这意味着还有额外时间可以用于生成帧。

c88387d833116a42.png

从结果中可以看出,在 Google Pixel 7 上创建帧的中位数(P50)是 3.8ms,这比帧时间限制少 6.4ms。但也可能在超过 99 百分位数(P99)时出现了一些跳过的帧,因为这些帧耗时 35.7ms 来生成,这比限制多了 33.2ms。

与应用启动结果类似,你可以点击 iteration 打开基准测试期间记录的系统跟踪,并调查导致最终时间的原因

11. 恭喜

恭喜,你已成功完成本篇关于使用 Jetpack Macrobenchmark 测量性能的 Codelab!

下一步是什么?

请查阅使用基线配置文件提升应用性能 Codelab。此外,请查看我们的性能示例 Github 仓库,其中包含 Macrobenchmark 和其他性能示例。

参考文档