应用启动时间

用户期望应用加载速度快且响应迅速。启动时间缓慢的应用无法满足此期望,可能会让用户感到失望。这种糟糕的体验会导致用户在 Play 商店中对您的应用评分较低,甚至完全放弃您的应用。

此页面提供有关如何优化应用启动时间的信息,包括启动过程内部机制的概述、如何分析启动性能以及一些常见的启动时间问题以及如何解决这些问题的提示。

了解不同的应用启动状态

应用启动可以处于三种状态之一:冷启动、温启动或热启动。每种状态都会影响应用可见于用户所需的时间。在冷启动中,您的应用从头开始。在其他状态下,系统需要将正在运行的应用从后台带到前台。

我们建议您始终基于冷启动的假设进行优化。这样做也可以提高温启动和热启动的性能。

为了优化应用以实现快速启动,了解系统和应用级别发生的情况以及它们在每种状态下的交互方式非常有用。

确定应用启动的两个重要指标是 初始显示时间 (TTID)完全绘制时间 (TTFD)。TTID 是显示第一帧所需的时间,而 TTFD 是应用完全可交互所需的时间。两者同样重要,因为 TTID 让用户知道应用正在加载,而 TTFD 是应用真正可用时。如果这两个指标中的任何一个时间过长,用户可能会在应用完全加载之前退出应用。

冷启动

冷启动是指应用从头开始。这意味着在启动之前,系统的进程会创建应用的进程。冷启动发生在以下情况下,例如您的应用自设备启动以来或自系统终止应用以来首次启动。

这种启动方式对最小化启动时间提出了最大的挑战,因为系统和应用需要完成比其他启动状态下更多的工作。

在冷启动开始时,系统有以下三个任务:

  1. 加载并启动应用。
  2. 在启动后立即显示应用的空白启动窗口。
  3. 创建应用进程

一旦系统创建了应用进程,应用进程就负责后续阶段:

  1. 创建应用对象。
  2. 启动主线程。
  3. 创建主 Activity。
  4. 加载视图。
  5. 布局屏幕。
  6. 执行初始绘制。

当应用进程完成第一次绘制时,系统进程会替换显示的背景窗口,将其替换为主 Activity。此时,用户可以开始使用应用。

图 1 显示了系统和应用进程之间如何传递工作。

图 1. 冷应用启动的重要部分的可视化表示。

在创建应用和创建 Activity 期间可能会出现性能问题。

应用创建

当您的应用启动时,空白启动窗口会保留在屏幕上,直到系统第一次完成应用绘制。此时,系统进程会将启动窗口替换为您的应用,让用户与应用交互。

如果您在自己的应用中重写了Application.onCreate(),则系统会在您的应用对象上调用onCreate()方法。之后,应用会生成主线程(也称为UI 线程),并将其分配给创建您的主 Activity 的任务。

从这一点开始,系统级和应用级进程将根据应用生命周期阶段进行。

Activity 创建

应用进程创建您的 Activity 后,Activity 会执行以下操作:

  1. 初始化值。
  2. 调用构造函数。
  3. 调用回调方法,例如Activity.onCreate(),该方法适用于 Activity 的当前生命周期状态。

通常,onCreate()方法对加载时间的影响最大,因为它执行开销最高的工作:加载和加载视图以及初始化 Activity 运行所需的物件。

温启动

温启动包含冷启动期间发生的一部分操作。同时,它也比热启动具有更高的开销。有许多潜在的状态可以被视为温启动,例如:

  • 用户退出您的应用,然后重新启动它。进程可能会继续运行,但应用必须使用对onCreate()的调用从头开始重新创建 Activity。

  • 系统将您的应用从内存中逐出,然后用户重新启动它。进程和 Activity 需要重新启动,但任务可以从传递到onCreate()的已保存实例状态捆绑包中获得一些好处。

热启动

应用的热启动比冷启动的开销更低。在热启动中,系统会将您的 Activity 带到前台。如果您的应用的所有 Activity 仍然驻留在内存中,则应用可以避免重复对象初始化、布局加载和渲染。

但是,如果响应内存调整事件(例如onTrimMemory())而清除了一些内存,则需要重新创建这些对象以响应热启动事件。

热启动显示与冷启动场景相同的屏幕行为。系统进程会显示一个空白屏幕,直到应用完成 Activity 的渲染。

图 2. 一个包含各种启动状态及其各自进程的图表,每个状态都从绘制的第一帧开始。

如何在 Perfetto 中识别应用启动

为了调试应用启动问题,确定应用启动阶段中究竟包含哪些内容非常有用。要在Perfetto中识别整个应用启动阶段,请按照以下步骤操作:

  1. 在 Perfetto 中,找到包含 Android 应用启动派生指标的行。如果您没有看到它,请尝试使用设备上系统跟踪应用捕获跟踪。

    图 3. Perfetto 中的 Android 应用启动派生指标切片。
  2. 单击关联的切片并按m选择切片。切片周围会出现括号,并指示它花费了多长时间。持续时间也会显示在当前选择选项卡中。

  3. 通过单击固定图标(当您将指针悬停在行上时可见)来固定 Android 应用启动行。

  4. 滚动到包含相关应用的行,然后单击第一个单元格以展开行。

  5. 通常,通过按w放大主线程(分别按s, a, d缩小、向左移动和向右移动)。

    图 4. Android 应用启动派生指标切片位于应用的主线程旁边。
  6. 派生指标切片使更容易查看应用启动中究竟包含哪些内容,因此您可以继续更详细地进行调试。

使用指标检查和改进启动

要正确诊断启动时间性能,您可以跟踪显示应用启动所需时间的指标。Android 提供了几种方法来向您显示您的应用存在问题,并帮助您诊断它。Android 指标可以提醒您出现问题,而诊断工具可以帮助您诊断问题。

利用启动指标的好处

Android 使用初始显示时间 (TTID)完全显示时间 (TTFD)指标来优化冷启动和温启动。Android 运行时 (ART) 使用这些指标的数据来有效地预编译代码,以优化未来的启动。

更快的启动速度会导致用户与您的应用进行更持久的交互,从而减少早期退出、重新启动实例或导航到其他应用的次数。

Android 指标

当您的应用启动时间过长时,Android 指标可以通过在Play 控制台上提醒您来帮助提高应用的性能。

Android 指标认为以下应用启动时间过长:

  • 启动需要 5 秒或更长时间。
  • 启动需要 2 秒或更长时间。
  • 启动需要 1.5 秒或更长时间。

Android 指标使用初始显示时间 (TTID)指标。有关 Google Play 如何收集 Android 指标数据的信息,请参阅Play 控制台文档

初始显示时间

初始显示时间 (TTID) 是显示应用 UI 的第一帧所需的时间。此指标衡量应用生成第一帧所需的时间,包括冷启动期间的进程初始化、冷启动或温启动期间的 Activity 创建以及显示第一帧。保持应用的 TTID 较低有助于改善用户体验,使用户能够快速看到您的应用启动。TTID 由 Android 框架自动报告。在优化应用启动时,我们建议实现reportFullyDrawn以获取最多TTFD的信息。

TTID 测量为一个时间值,表示总的经过时间,其中包括以下事件序列:

  • 启动进程。
  • 初始化对象。
  • 创建和初始化 Activity。
  • 加载布局。
  • 第一次绘制应用。

检索 TTID

要查找 TTID,请在Logcat 命令行工具中搜索包含名为Displayed的值的输出行。此值是 TTID,看起来类似于以下示例,其中 TTID 为 3s534ms:

ActivityManager: Displayed com.android.myexample/.StartupTiming: +3s534ms

要在 Android Studio 中查找 TTID,请从过滤器下拉菜单中禁用 Logcat 视图中的过滤器,然后查找Displayed时间,如 5 图所示。禁用过滤器是必要的,因为系统服务器(而不是应用本身)提供此日志。

图 5. 禁用的过滤器和 Logcat 中的Displayed值。

Logcat 输出中的Displayed指标不一定捕获直到所有资源加载和显示的时间量。它省略了布局文件中未引用的资源或应用作为对象初始化的一部分创建的资源。它排除了这些资源,因为加载它们是一个内联过程,并且不会阻止应用的初始显示。

有时,Logcat 输出中的Displayed行包含总时间的附加字段,例如:

ActivityManager: Displayed com.android.myexample/.StartupTiming: +3s534ms (total +1m22s643ms)

在这种情况下,第一次测量仅针对第一个绘制的 Activity。 total时间测量从应用进程开始,并且可能包含另一个首先启动但未在屏幕上显示任何内容的 Activity。total时间测量仅在单个 Activity 和总启动时间之间存在差异时显示。

我们建议在 Android Studio 中使用 Logcat,但如果您没有使用 Android Studio,也可以通过使用adb shell activity manager 命令运行您的应用来测量 TTID。这是一个例子:

adb [-d|-e|-s <serialNumber>] shell am start -S -W
com.example.app/.MainActivity
-c android.intent.category.LAUNCHER
-a android.intent.action.MAIN

Displayed指标如前所示出现在 Logcat 输出中。您的终端窗口将显示以下内容:

Starting: Intent
Activity: com.example.app/.MainActivity
ThisTime: 2044
TotalTime: 2044
WaitTime: 2054
Complete

-c-a参数是可选的,允许您指定<category><action>

完全显示时间

完全显示时间 (TTFD) 是应用对用户变得可交互所需的时间。它报告为显示应用 UI 的第一帧所需的时间,以及在显示初始帧后异步加载的内容。通常,这是从网络或磁盘加载的主要内容,由应用报告。换句话说,TTFD 包括 TTID 以及应用变得可用所需的时间。保持应用的 TTFD 较低有助于改善用户体验,使用户能够快速与您的应用交互。

Choreographer调用 Activity 的onDraw()方法时,系统会确定 TTID,并且当它知道第一次调用此方法时。但是,系统不知道何时确定 TTFD,因为每个应用的行为都不同。要确定 TTFD,应用需要在达到完全绘制状态时向系统发出信号。

检索 TTFD

要查找 TTFD,请通过调用reportFullyDrawn()方法来发出完全绘制状态信号。ComponentActivityreportFullyDrawn方法报告应用何时完全绘制并处于可用状态。TTFD 是从系统接收应用启动意图到调用reportFullyDrawn()之间经过的时间。如果您不调用reportFullyDrawn(),则不会报告任何 TTFD 值。

要测量 TTFD,在完全绘制 UI 和所有数据后,调用 reportFullyDrawn()。请勿在第一个 Activity 的窗口首次绘制并显示(由系统测量)之前调用 reportFullyDrawn(),因为那样系统会报告系统测量的时长。换句话说,如果您在系统检测到 TTID 之前调用 reportFullyDrawn(),则系统会将 TTID 和 TTFD 报告为相同的值,并且此值是 TTID 值。

当您使用 reportFullyDrawn() 时,Logcat 会显示如下所示的输出,其中 TTFD 为 1 秒 54 毫秒。

system_process I/ActivityManager: Fully drawn {package}/.MainActivity: +1s54ms

Logcat 输出有时包含 total 时间,如 初始显示时间 中所述。

如果您的显示时间比您想要的慢,您可以尝试确定启动过程中的瓶颈。

在您知道已达到完全绘制状态的基本情况下,可以使用 reportFullyDrawn() 来表示完全绘制状态。但是,在后台线程必须完成后台工作才能达到完全绘制状态的情况下,您需要延迟 reportFullyDrawn() 以获得更准确的 TTFD 测量结果。要了解如何延迟 reportFullyDrawn(),请参阅下一节。

提高启动时间精度

如果您的应用正在执行延迟加载,并且初始显示不包含所有资源(例如,当您的应用从网络获取图像时),您可能希望延迟调用 reportFullyDrawn,直到您的应用可用,以便您可以将列表填充作为基准时间的一部分。

例如,如果 UI 包含动态列表,例如 RecyclerView 或延迟加载列表,则可能由在列表首次绘制后(因此,在 UI 被标记为完全绘制后)完成的后台任务填充。在这种情况下,列表填充不包含在基准测试中。

要将列表填充作为基准时间的一部分,请使用 getFullyDrawnReporter() 获取 FullyDrawnReporter,并在您的应用代码中添加一个报告器。在后台任务完成填充列表后释放报告器。

FullyDrawnReporter 只有在所有添加的报告器都释放后才会调用 reportFullyDrawn() 方法。通过在后台进程完成之前添加报告器,时间也包括在启动时间数据中填充列表所需的时间。这不会改变用户对应用的行为,但它允许启动时间数据包含填充列表所需的时间。无论顺序如何,reportFullyDrawn() 只有在所有任务完成后才会被调用。

以下示例演示了如何同时运行多个后台任务,每个任务都注册自己的报告器。

Kotlin

class MainActivity : ComponentActivity() {

    sealed interface ActivityState {
        data object LOADING : ActivityState
        data object LOADED : ActivityState
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            var activityState by remember {
                mutableStateOf(ActivityState.LOADING as ActivityState)
            }
            fullyDrawnReporter.addOnReportDrawnListener {
                activityState = ActivityState.LOADED
            }
            ReportFullyDrawnTheme {
                when(activityState) {
                    is ActivityState.LOADING -> {
                        // Display the loading UI.
                    }
                    is ActivityState.LOADED -> {
                        // Display the full UI.
                    }
                }
            }
            SideEffect {
                lifecycleScope.launch(Dispatchers.IO) {
                    fullyDrawnReporter.addReporter()

                    // Perform the background operation.

                    fullyDrawnReporter.removeReporter()
                }
                lifecycleScope.launch(Dispatchers.IO) {
                    fullyDrawnReporter.addReporter()

                    // Perform the background operation.

                    fullyDrawnReporter.removeReporter()
                }
            }
        }
    }
}

Java

public class MainActivity extends ComponentActivity {
    private FullyDrawnReporter fullyDrawnReporter;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        fullyDrawnReporter = getFullyDrawnReporter();
        fullyDrawnReporter.addOnReportDrawnListener(() -> {
            // Trigger the UI update.
            return Unit.INSTANCE;
        });

        new Thread(new Runnable() {
            @Override
            public void run() {
                fullyDrawnReporter.addReporter();

                // Do the background work.

               fullyDrawnReporter.removeReporter();
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                fullyDrawnReporter.addReporter();

                // Do the background work.

                fullyDrawnReporter.removeReporter();
            }
        }).start();
    }
}

如果您的应用使用 Jetpack Compose,您可以使用以下 API 来指示完全绘制状态。

  • ReportDrawn:表示您的可组合项已立即准备好进行交互。
  • ReportDrawnWhen:接受一个谓词,例如 list.count > 0,以指示您的可组合项何时准备好进行交互。
  • ReportDrawnAfter:接受一个挂起方法,该方法一旦完成,就表示您的可组合项已准备好进行交互。
识别瓶颈

要查找瓶颈,您可以使用 Android Studio CPU Profiler。有关更多信息,请参阅 使用 CPU Profiler 检查 CPU 活动

您还可以通过应用和 Activity 的 onCreate() 方法内的内联跟踪深入了解潜在的瓶颈。要了解内联跟踪,请参阅 Trace 函数的文档和 系统跟踪概述

解决常见问题

本节讨论了经常影响应用启动性能的几个问题。这些问题主要涉及初始化应用和 Activity 对象,以及加载屏幕。

繁重的应用初始化

当您的代码覆盖 Application 对象并在初始化该对象时执行繁重的工作或复杂的逻辑时,启动性能可能会受到影响。如果您的 Application 子类执行尚不需要执行的初始化,则您的应用可能会在启动时浪费时间。

某些初始化可能是完全不必要的,例如,当应用实际上是在响应意图启动时初始化主 Activity 的状态信息。使用意图时,应用仅使用先前初始化的状态数据的一部分。

应用初始化期间的其他挑战包括影响较大或数量众多的垃圾回收事件,或者与初始化同时发生的磁盘 I/O,这进一步阻塞了初始化过程。垃圾回收尤其需要考虑 Dalvik 运行时;Android 运行时 (ART) 并发执行垃圾回收,从而最大程度地减少了该操作的影响。

诊断问题

您可以使用方法跟踪或内联跟踪来尝试诊断问题。

方法跟踪

运行 CPU Profiler 显示 callApplicationOnCreate() 方法最终会调用您的 com.example.customApplication.onCreate 方法。如果工具显示这些方法需要很长时间才能完成执行,请进一步探索以查看在那里发生了什么工作。

内联跟踪

使用内联跟踪来调查可能的罪魁祸首,包括以下内容。

  • 您的应用的初始 onCreate() 函数。
  • 您的应用初始化的任何全局单例对象。
  • 在瓶颈期间可能发生的任何磁盘 I/O、反序列化或紧密循环。

问题的解决方案

无论问题在于不必要的初始化还是磁盘 I/O,解决方案都是延迟初始化。换句话说,仅初始化立即需要的对象。不要创建全局静态对象,而是切换到单例模式,其中应用仅在首次需要对象时才初始化它们。

此外,请考虑使用像 Hilt 这样的依赖项注入框架,该框架在首次注入对象和依赖项时创建它们。

如果您的应用使用内容提供程序在启动时初始化应用组件,请考虑改用 应用启动库

繁重的 Activity 初始化

Activity 创建通常需要大量开销很大的工作。通常,存在优化此工作以实现性能改进的机会。此类常见问题包括以下内容。

  • 膨胀大型或复杂的布局。
  • 阻止磁盘或网络 I/O 上的屏幕绘制。
  • 加载和解码位图。
  • 栅格化 VectorDrawable 对象。
  • 初始化 Activity 的其他子系统。

诊断问题

在这种情况下,方法跟踪和内联跟踪也同样有用。

方法跟踪

使用 CPU Profiler 时,请注意您的应用的 Application 子类构造函数和 com.example.customApplication.onCreate() 方法。

如果工具显示这些方法需要很长时间才能完成执行,请进一步探索以查看在那里发生了什么工作。

内联跟踪

使用内联跟踪来调查可能的罪魁祸首,包括以下内容。

  • 您的应用的初始 onCreate() 函数。
  • 它初始化的任何全局单例对象。
  • 在瓶颈期间可能发生的任何磁盘 I/O、反序列化或紧密循环。

问题的解决方案

存在许多潜在的瓶颈,但两个常见问题和补救措施如下所示。

  • 您的视图层次结构越大,应用膨胀它所需的时间就越长。您可以采取以下两个步骤来解决此问题。
    • 通过减少冗余或嵌套布局来扁平化您的视图层次结构。
    • 不要膨胀启动期间不需要可见的 UI 部分。相反,使用 ViewStub 对象作为应用可以在更合适的时间膨胀的子层次结构的占位符。
  • 在主线程上进行所有资源初始化也会降低启动速度。您可以按如下方式解决此问题。
    • 移动所有资源初始化,以便应用可以在不同的线程上延迟执行它。
    • 让应用加载并显示您的视图,然后稍后更新依赖于位图和其他资源的可视属性。

自定义启动画面

如果您以前使用以下方法之一在 Android 11(API 级别 30)或更早版本中实现自定义启动画面,您可能会看到启动期间添加了额外的时间。

  • 使用 windowDisablePreview 主题属性关闭系统在启动期间绘制的初始空白屏幕。
  • 使用专用的 Activity

从 Android 12 开始,需要迁移到 SplashScreen API。此 API 可实现更快的启动时间,并允许您以以下方式调整启动画面。

此外,兼容库将 SplashScreen API 向后移植,以启用向后兼容性,并在所有 Android 版本中为启动画面显示创建一致的外观和风格。

有关详细信息,请参阅 启动画面迁移指南