Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

App 启动路径、监控与度量

应用启动从用户或系统请求开始,经过进程创建、bindApplication、组件生命周期和首帧显示。本章围绕这条路径讲两件事:先从应用进程一侧把启动拆成可测阶段,说清 Trace(带时间轴的执行跟踪) 中一段区间究竟代表什么;再讲线上监控怎样记录启动类型、起止点、关键阶段和设备状态,避免只上报一个总时长。

分析部分要确认系统什么时候把执行权交给应用、关键路径包含哪些阶段。系统侧的进程创建、任务与窗口管理另见 8.2 节;初始化依赖治理、Baseline Profile 和首帧渲染分别在后续章节展开。

源码基线:Android 17 / API 37,AOSP android-17.0.0_r1;AndroidX 与 Perfetto(Android 系统级时间线分析工具) 独立发布,涉及之处会单独注明版本要求。

进程创建、初始化与首帧阶段

1. 冷、温、热描述的是启动前状态

Android 应用启动文档把启动分为 cold、warm、hot。我们先从这套分类看起:它概括的是启动前进程、Activity 和 UI 对象是否仍然存在,生命周期回调也会随任务状态变化。

类型启动前状态应用侧通常需要完成的工作
冷启动目标应用进程不存在创建进程和 Application,安装 Provider,创建 Activity,生成第一帧
温启动复用了一部分状态,但 Activity 或进程中的部分对象需要重建常见情况是进程仍在而 Activity 重建;恢复路径取决于任务和保存状态
热启动进程与目标 Activity 仍在内存中将已有 Activity 带回前台,处理生命周期与必要的重绘

同一个入口在不同时间可能落入不同类型:用户从桌面点击、通知跳转、深链、最近任务恢复,也可能命中不同的 Activity 和任务栈。要判断平台记录的启动类型,光看 onCreate() 是否调用还不够。

工程上要区分两件事:

  • 实验分类:Macrobenchmark(AndroidX 宏基准测试库) 的 StartupMode.COLD/WARM/HOT(实验启动状态) 用固定前置条件构造可比较样本。
  • 线上分类:使用平台或 Play 的启动指标,并按入口、进程、版本和设备分组;Android 15+ 的 ApplicationStartInfo 可提供系统记录的启动类型,应用自有“进程首次启动”标记只能辅助解释。采集方法见后文「启动类型、时间点与线上指标」一节。

“温启动一定是冷启动的某个百分比”“热启动一定小于一帧”这类经验比例当不了基线:后台回收、配置变化、首屏数据、CPU 调频和页面重绘都会改变成本。

2. 冷启动的 App 侧路径

2.1 从启动请求到应用主线程

冷启动开始时,系统解析启动请求、准备任务与 starting window(应用首帧完成前由系统显示的启动窗口),并向 Zygote 请求 fork 应用进程。Zygote 是预加载常用框架类的进程模板,fork 会从它派生新的 Linux 应用进程。这个阶段已经计入用户看到的启动等待,而应用自己的 Application 埋点还没有运行。

子进程进入 ActivityThread.main() 后,先准备主线程 Looper(按顺序取出并分发消息的事件循环)、创建 ActivityThread,再通过 Binder(Android 跨进程调用机制) 向 system_server(承载主要系统服务的进程) 登记,后续的绑定信息和生命周期事务再送回应用主线程。对写应用的我们来说,这段路径有两个含义:

系统请求与进程创建已经计入等待,早于应用内埋点

图:Application.attachBaseContext不是完整冷启动起点;进程前的系统工作与调度需平台记录或系统Trace。

  1. Application.attachBaseContext() 不是整个冷启动的起点,早于它的系统与进程初始化同样消耗 TTID(Time To Initial Display)。
  2. 只汇总应用内埋点会漏掉进程创建、调度等待、系统服务和跨进程通信时间。

2.2 ContentProvider(内容提供者) 早于 Application.onCreate()

ActivityThread.handleBindApplication()按以下关键顺序执行:

创建并 attach Application
        ↓
installContentProviders(app, data.providers)
        ↓
Instrumentation.onCreate(...)
        ↓
Instrumentation.callApplicationOnCreate(app)

这段顺序针对普通应用绑定:固定实现会在 restrictedBackupMode 中跳过 Provider 安装,Provider 列表为空时也不会执行安装。实际自动初始化 Provider 的 onCreate() 仍早于应用 Application.onCreate();Instrumentation 则是框架创建和驱动应用组件的入口。所以只给 Application.onCreate() 计时,会漏掉 App Startup、第三方 SDK(Software Development Kit,软件开发工具包) 和业务 Provider 的成本。

Application.attachBaseContext() 到 Application.onCreate() 之间可能包含:

  • Provider 类加载、静态初始化与 onCreate();
  • 配置和资源初始化;
  • instrumentation 初始化;
  • 主线程上的 Binder、文件、数据库和锁等待。

Provider 治理见 ContentProvider 启动优化。多进程应用还要核对每个 Provider 的 android:process:默认进程的 Provider 不会自动在每个子进程各运行一次,声明到某个进程的组件只随对应进程安装。

2.3 Application 阶段

Application.onCreate() 在主线程同步执行。适合保留在这里的工作应同时满足:

  • 当前进程需要;
  • 当前入口需要;
  • 在 Activity 或首帧之前必须完成;
  • 能在给定主线程预算内结束;
  • 不依赖不受控的网络或长时间锁等待。

常见长任务包括 SDK 初始化、同步数据库打开、反序列化大配置、原生库加载和批量对象构造。把它们全部提交到后台线程也可能延迟启动:主线程若随后等待结果,关键路径没有缩短;如果不等待,后台 CPU、I/O(存储读写) 和类加载仍会与首帧争用资源。

任务移到后台后,主线程等待仍可能留在关键路径

图:等待结果时端到端路径没有消失;即使不等待,后台CPU/I/O/类加载也可能与首帧竞争。

对于 minSdkVersion >= 21 的应用,平台原生支持从多个 DEX 加载类,不需要在 Application 中调用旧版 MultiDex.install()。只有仍支持 API 20 及以下的应用才需要单独审视这条兼容路径。

2.4 Activity 创建与 UI 构建

应用收到启动 Activity 的 lifecycle transaction(系统发给应用主线程的一组生命周期操作)后,框架创建 Activity,并按目标状态执行 onCreate()、onStart()、onResume() 等回调。这串回调只是常见组合之一:已有 Activity 回前台、配置变化和任务恢复会走不同的组合。

View UI 的 setContentView() 会触发布局 XML 解析、View 创建和属性解析。Compose UI 则在 setContent 后经历初始 composition(执行 Composable 并建立 UI 结构)、layout 和 draw。两种 UI 技术都要关注:

  • 是否在主线程读取磁盘或等待 Binder;
  • 图片解码、字体和资源是否进入首帧关键路径;
  • 首屏不可见区域是否提前创建;
  • 自定义 View 构造、onMeasure()、onLayout()、onDraw() 是否执行重活;
  • Compose 的首轮 composition 是否创建过多对象或读取未准备好的状态。

View 数量、布局深度或 Composable 数量都没有通用的危险阈值,优化依据是 Trace 中的耗时和调用路径。ViewStub(需要时才展开布局的占位 View)、条件 composition、懒加载、异步 inflate(在允许范围内把 View 创建移出主线程)各有语义限制,采用前要验证线程安全、状态恢复和首屏交互。

2.5 第一帧从 traversal 到系统确认绘制

主线程在 VSync(显示刷新节奏信号) 驱动下进入 Choreographer#doFrame,执行 traversal,也就是对 View 树完成 measure、layout 和 draw。硬件加速路径中,UI 线程记录绘制命令,RenderThread 与 GPU 继续处理渲染,图形 buffer 再交给系统合成路径。

ViewRootImpl在首次绘制请求完成后走 reportDrawFinished(...) 路径,窗口系统据此结束对应的启动绘制等待。我们在这里要区分几个相邻观测点:

观测点可以说明什么不能说明什么
performTraversals 结束本轮主线程 traversal 返回GPU、buffer 提交和系统确认均已结束
RenderThread DrawFrame渲染线程处理这一帧像素已经被面板扫描显示
frame commit callback硬件渲染内容已提交到 swap chain(等待显示的图形缓冲队列)用户已经看到该帧;该回调只适用于硬件渲染
TTID系统记录的首帧启动指标页面主要内容已经可用

帧预算取决于刷新率、这一帧在 FrameTimeline 里的 deadline 以及渲染流水线阶段,一律写死 16 ms 会把问题简化过头;120 Hz 显示器的节奏与 60 Hz 不同,同一段工作的可用时间也随之变化。一次 traversal 跨过某个 VSync 时,我们还要对照目标时间线和实际时间线,判断是否错过 deadline。

3. TTID 与 TTFD(Time To Full Display)回答不同问题

3.1 TTID:首个 UI 帧

TTID 从系统收到启动请求开始,到目标 Activity 的第一帧被记录为已显示为止,全称 Time To Initial Display。冷启动时它包含进程创建,冷/温启动时还包含 Activity 创建和首帧工作。

TTID 很短,只说明用户很快看到一个应用帧;这一帧可能还是骨架、空列表或占位内容。系统 SplashScreen 的持续时间、应用第一帧、主要内容可用时间是三件事,要分开度量。

Logcat 中的 Displayed ... +...、adb shell am start -W 和 Perfetto 都能帮助本地诊断。命令行结果会受到任务栈、是否 force-stop、编译状态和设备状态影响,单次 am start -W 更适合当线索,而不是发布门禁。

Android vitals(Google Play 汇总的真实设备质量指标) 当前把冷启动 5 秒、温启动 2 秒、热启动 1.5 秒及以上列为 excessive。这三条是 Play 的告警门槛,不是优秀体验的目标值;内部预算怎么设,见后文「Android Vitals 怎样对标」。

3.2 TTFD:由应用声明“主要内容可用”

TTFD 的平台报告以 reportFullyDrawn() 表达应用完成条件;系统记录会处理过早上报与首帧下限。Macrobenchmark 的 timeToFullDisplayMs 另以包含该调用或其后的第一帧完成渲染为终点。两种观测相关,但不能直接把方法调用时刻、ApplicationStartInfo.FULLY_DRAWN 时间戳与基准渲染完成点视为同一瞬间。StartupTimingMetric 的 timeToFullDisplayMs 从 API 29 起可用,更早版本可能拿不到这个指标。

平台不知道每个产品何时“可用”,因此团队必须先定义完成条件。例如:

  • 首页主列表已经展示本地缓存或网络数据;
  • 关键按钮可点击,必要依赖已经准备好;
  • 错误态或离线态也已形成可操作界面;
  • 不影响首要任务的推荐、广告或二级卡片不进入条件。

下面的示例把“关键数据就绪”和“主内容完成布局”作为报告条件:

class MainActivity : ComponentActivity() {
    private var criticalDataReady = false
    private var mainContentLaidOut = false
    private var fullyDrawnReported = false

    private fun maybeReportFullyDrawn() {
        if (criticalDataReady &&
            mainContentLaidOut &&
            !fullyDrawnReported
        ) {
            fullyDrawnReported = true
            reportFullyDrawn()
        }
    }
}

criticalDataReady 和 mainContentLaidOut 应由对应状态与布局回调更新,每次更新后调用 maybeReportFullyDrawn()。报告只发送一次;完成条件应覆盖成功、离线和可恢复错误,避免网络失败后永远没有 TTFD。若调用发生在首帧之前,平台会以系统检测到的首帧时间为下限,因此提前调用不会生成比 TTID 更早的有效 TTFD。

AndroidX ComponentActivity 的 Fully Drawn 支持可协调多个组件或异步条件。无论使用平台方法还是 AndroidX 封装,团队都要把“fully drawn”定义写进测试,避免不同页面各自解释。

3.3 自定义里程碑:解释 TTID/TTFD 内部时间

TTID 和 TTFD 是端到端指标,回答不了“慢在哪一段”;要解释端到端内部的时间,应用可以补充少量稳定里程碑:

  • Application attach;
  • 每个必要初始化器的开始和结束;
  • Activity 创建、UI 状态提交;
  • 关键数据可用;
  • 主要内容完成布局;
  • reportFullyDrawn() 请求。

区间计时使用单调时钟。SystemClock.uptimeNanos() 从 API 35 起公开,不计深度休眠,但包含线程等待和未获 CPU 的时间,因此不是 CPU 消耗;API 29–34 的应用局部区间可使用 uptimeMillis() 或选定的 System.nanoTime() 差值。elapsedRealtimeNanos() 计入深度休眠,提供本次开机的时基;不同类型时基不能直接相减。CPU 实际运行时间需线程 CPU 计数或调度证据。

埋点名称要稳定,可能出现的名称数量也要受控;监控系统把这种取值数量称为基数。不要把用户 ID、URL 或动态参数写入 Trace 名称。线上事件还需采样,并记录启动入口、应用版本、设备档位、编译状态和进程名。

4. 用 Macrobenchmark(固定条件的基准测试) 建立启动基线

里程碑解释了端到端内部的时间;要判断“这次改动有没有把启动变慢”,还需要固定条件下的端到端基准。Macrobenchmark从目标应用进程外驱动启动,并为每轮采集 Perfetto Trace。它比在 Application 内自行计时更适合端到端回归。目标使用非 debuggable 的可 profile 发布变体,在 <application> 内声明 <profileable android:shell="true" />,并核对合并 Manifest。profileable 是元素,不是 android:profileable 属性。

下面的基准用于测量带有 Baseline Profile 的冷启动:

@LargeTest
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
    @get:Rule
    val rule = MacrobenchmarkRule()

    @Test
    fun coldStart() = rule.measureRepeated(
        packageName = TARGET_PACKAGE,
        metrics = listOf(StartupTimingMetric()),
        compilationMode = CompilationMode.Partial(
            baselineProfileMode = BaselineProfileMode.Require,
        ),
        startupMode = StartupMode.COLD,
        iterations = 10,
        setupBlock = {
            pressHome()
        },
    ) {
        startActivityAndWait()
    }
}

StartupTimingMetric 输出 timeToInitialDisplayMs;应用正确报告 fully drawn 时,还会输出 timeToFullDisplayMs。

十次迭代只是一组起始配置,应根据噪声、设备数量和需要检测的回归幅度调整。

每次对比必须固定:

  • 同一 release 配置和构建工具链;
  • 相同 CompilationMode(ART 预编译状态) 与 Baseline Profile 条件;
  • 相同入口 Intent、账号、数据和网络桩(返回固定结果的测试替身);
  • 相同设备型号、系统版本、电量与温度范围;
  • 相同冷/温/热模式。

Debug 包、模拟器、低电量或温度降频样本不适合建立发布阈值。遇到 profileable、Baseline Profile 或编译模式配置错误时,应让测试失败并修正配置,而不是忽略错误后继续使用结果。

冷、温、热三组结果要分别保存。页面结构或首屏数据变化时,我们还要同时检查 TTFD 和帧指标,防止用更早展示空壳换取较短 TTID。

5. 自定义 Trace 与业务切片

应用控制的初始化入口可以用 androidx.tracing 补充切片,切片是时间轴上带开始、结束和名称的一段区间。下面的示例用于区分配置解析与崩溃监控初始化:

fun initializeRequiredComponents() {
    trace("startup/config") {
        configStore.loadLocalSnapshot()
    }
    trace("startup/crash-reporter") {
        crashReporter.start()
    }
}

Perfetto 中会显示两个命名切片。切片要包住同步工作本身;代码若只提交异步任务,切片结束就只说明“已提交”,说明不了初始化已经完成。等待异步结果时,应在生产者和消费者两端记录可关联的 async trace,或用 flow(连接两段切片的因果箭头)标出任务从提交到执行的关系,并控制关联 ID 的数量。

提交切片结束只证明已提交,异步工作要关联执行方

图:同步trace包住真实同步工作;异步提交到完成跨线程时,用受控cookie或flow关联生产者与消费者。

Trace 本身有开销,切片要克制:别给每个小方法都加,单个总切片也定位不了责任。较好的层级是:

  1. 启动端到端指标;
  2. Application、Provider、Activity、首帧等阶段;
  3. 少量可行动的业务或 SDK 分界;
  4. 出现回归后再采集方法栈、Binder 或 I/O 细节。

6. Perfetto 启动分析实战

6.1 获取一条可复现的 Trace

我们推荐先运行 Macrobenchmark,再打开某次迭代生成的 .perfetto-trace,这样启动模式、编译模式和操作脚本与指标结果属于同一个样本。

需要手工捕获时,可以在 Perfetto UI 或设备“系统跟踪”中启用 sched、freq、am、wm、gfx、view、binder_driver、dalvik 等设备支持的类别,并指定目标应用。这些类别分别提供调度、CPU 频率、Activity/Window、图形、View、Binder 和 ART 等事件;应用自定义 Trace 切片还要通过 atrace_apps 指定允许采集的包名。

下面的 pbtxt(Protocol Buffer 文本配置)是一份诊断起点,目标包名应替换为被测应用:

duration_ms: 15000
buffers {
  size_kb: 65536
  fill_policy: RING_BUFFER
}
data_sources {
  config {
    name: "linux.ftrace"
    ftrace_config {
      ftrace_events: "sched/sched_switch"
      ftrace_events: "sched/sched_wakeup"
      ftrace_events: "power/cpu_frequency"
      ftrace_events: "binder/binder_transaction"
      ftrace_events: "binder/binder_transaction_received"
      atrace_categories: "am"
      atrace_categories: "wm"
      atrace_categories: "gfx"
      atrace_categories: "view"
      atrace_categories: "dalvik"
      atrace_apps: "com.example.app"
    }
  }
}

把配置通过 adb shell perfetto --txt -c - -o /data/misc/perfetto-traces/startup.perfetto-trace 传入后,在另一终端按明确前置条件启动应用,再 pull Trace。pbtxt 文本模式适合诊断;Perfetto 文档提示它不是面向生产的稳定接口,自动化基准优先使用 Macrobenchmark。

类别和 tracepoint(内核或系统组件预先定义的跟踪事件)受设备内核及版本影响。采集前用 Perfetto UI 或设备支持列表确认,缺少某个事件时不要把它解释为对应工作没有发生。

6.2 先选中 Android App Startups

打开 Trace 后,先找到 Android App Startups 派生轨道(Trace Processor 从原始事件算出的轨道),选中目标包的启动 slice,再固定这个时间窗口;它给出的启动区间比手工搜索第一个 performTraversals 更可靠。

随后按这条顺序阅读:

  1. system_server(承载 Android 核心系统服务的进程):启动请求、进程创建、任务和窗口事件。
  2. 应用主线程:bindApplication、Provider/Application、自定义切片、Activity lifecycle。
  3. 线程状态:Running、Runnable、Sleeping、Blocked 以及对应唤醒者。
  4. RenderThread 与 FrameTimeline:首帧的 CPU/GPU 工作和 deadline。
  5. 相关线程/进程:Binder 对端、I/O、编译、GC(垃圾回收) 或 SDK 工作线程。

平台 slice 名称是诊断实现,不是公开 API。名字在不同 Android/Perfetto 版本间可能变化,自动化查询应优先使用 Trace Processor 标准库和自有稳定切片。

6.3 分解主线程的“运行”和“等待”

看到一段长主线程 slice 后,先展开 thread state:

  • Running:线程正在 CPU 上运行,继续查看调用栈、类加载或计算。
  • Runnable:线程可运行但未获 CPU,检查 CPU 竞争、优先级、频率与其他活跃线程。
  • Sleeping:可能在等待 Binder、futex(内核提供的用户态同步等待机制)、I/O 或条件变量,要沿唤醒关系找生产者。
  • Uninterruptible Sleep:常与内核 I/O 等待有关,需要结合块设备和文件事件。

“Runnable 低于 70% 就是异常”这类通用判据并不存在:主线程同步等待 5 ms 可能就卡住关键路径,后台线程消耗大量 CPU 也可能让主线程长时间处于 Runnable。我们判断的依据,是关键路径实际经过的时间,以及任务之间的依赖关系。

6.4 阅读 bind、Activity 和首帧

bindApplication 较长时,依次检查:

  • Provider 自动初始化;
  • Application attach/onCreate 自定义切片;
  • 类加载、验证、静态初始化;
  • GC、锁等待和 Binder;
  • 主线程文件 I/O;
  • 同期后台线程的 CPU/I/O 竞争。

Activity 阶段较长时,检查 View inflate 或 Compose 初始 composition、图片/字体、同步状态恢复和首屏数据绑定。单看 Activity.onCreate() 总时间不够,应该把可以动手改的业务环节标出来。

首帧阶段同时观察主线程 Choreographer#doFrame/traversal、RenderThread 和 FrameTimeline。performTraversals 结束只代表主线程 traversal 告一段落,TTID 终点要看系统记录;第一次同名 slice 也未必属于目标窗口,先核对时间再下结论。

6.5 用标准库批量列出启动

本轮固定 Trace Processor v57.2 核对 android.startup.startups 模块;标准库与 Android 版本独立发布。标准库是随 trace_processor 版本发布的一组 PerfettoSQL 表、视图和函数。下面的查询用于列出目标包在一条 Trace 中的启动类型和持续时间:

INCLUDE PERFETTO MODULE android.startup.startups;

SELECT
  startup_id,
  package,
  startup_type,
  dur / 1e6 AS duration_ms
FROM android_startups
WHERE package = 'com.example.app'
ORDER BY ts;

这个查询使用派生启动表,不依赖 bindApplication 等裸 slice 是否存在。结果是 Trace Processor 对启动窗口的解析,仍应与 Macrobenchmark 报告和界面中的目标启动核对。

下面的查询用于查看目标启动窗口内耗时较长的主线程 slice:

INCLUDE PERFETTO MODULE android.startup.startups;

SELECT
  s.startup_id,
  s.slice_name,
  s.slice_dur / 1e6 AS duration_ms
FROM android_thread_slices_for_all_startups AS s
JOIN android_startups AS a USING (startup_id)
WHERE a.package = 'com.example.app'
  AND s.is_main_thread
  AND s.slice_dur >= 5e6
ORDER BY s.startup_id, s.slice_dur DESC;

5 ms 只是查询过滤器,不是性能合格线。v57.2 该视图按 slice 的起点是否落在启动窗内选择,slice_dur 保留原始整段时长,不会裁到启动结束点,也会保留嵌套 slice;不能把这些值相加当作窗口 CPU 时间或去重后的关键路径。当前标准库文档已把 android_thread_slices_for_all_startups 标为底层视图、建议避免直接使用;上面的查询适合固定 trace_processor_shell 版本后的临时排查。长期 CI 应优先采用 android_slices_for_startup_and_slice_name(...)、android_startup_opinionated_breakdown 等公开函数或表,并为查询 schema(列名与类型约定)编写测试。

启动视图按 slice 起点选取,原始时长未裁剪且可嵌套

图:v57.2低层视图slice.ts BETWEEN启动起止,返回整段slice.dur;5ms仅查询过滤条件。

7. ClassLoader(按名称查找并装入类定义的加载器)、编译状态与 DEX 布局

7.1 类首次使用仍可能进入启动关键路径

应用类通常由 ClassLoader 按需加载:首次主动使用一个类时,运行时可能要查找定义、加载、验证、解析,并在需要时执行 <clinit>(类的静态初始化方法)。安装期 AOT(提前编译)、运行时缓存或已有编译产物能减少其中一部分工作,但静态初始化中的应用代码仍会执行。

启动阶段要关注:

  • 反射或 ServiceLoader 扫描大量类;
  • 一个入口触发长依赖链的类初始化;
  • <clinit> 读取文件、创建线程或加载 native library;
  • 多个 SDK 同时加载重复的框架与序列化类型;
  • 编译条件不同导致解释执行、JIT(运行时即时编译) 或 AOT 路径不同。

ApplicationLoaders 的缓存属于进程内状态,冷启动意味着旧应用进程不存在,这份缓存指望不上。多次冷启动越来越快,更可能来自 Linux page cache(内核保留的文件页缓存)、ART 编译产物、应用磁盘缓存、设备频率或数据状态变化,所以基准必须控制编译与缓存条件。

7.2 Baseline Profile 与 Startup Profile 分工

两类 profile 解决的问题不同:

  • Baseline Profile向 ART 提供关键类和方法,帮助安装/后台优化时进行 AOT 编译,减少关键路径上的解释与 JIT。
  • Startup Profile面向 DEX 布局,把启动所需代码组织到主 DEX 的相邻区域;读取相邻数据所需的文件页更集中,这就是这里的读取局部性。

Baseline Profile 跳过不了业务初始化,也消除不了磁盘、Binder、锁或网络等待;Startup Profile 影响的也只是 DEX 文件组织和读取局部性,把类放到文件前面并不会让它们自动进入 CPU cache。收益要用固定编译模式的启动基准验证。

两者的实践统一见 Baseline Profile 与 Startup Profile 实战。启动期 ART/GC 行为见 ART GC 启动期开销与分配治理。

8. 从 Trace 到修复的判断顺序

面对一条慢启动样本,我们可以按以下顺序推进:

  1. 确认启动类型、入口、编译模式、设备状态和 TTID/TTFD。
  2. 选中 Android App Startups 时间窗,判断延迟位于系统、bind、Activity 还是首帧。
  3. 查看主线程是 Running、Runnable 还是等待,并追到依赖线程或 Binder 对端。
  4. 用自定义切片、调用栈、I/O 或 GC 数据缩小到可修改代码。
  5. 判断工作是否属于首屏必要条件;能移除就移除,能按需就按需,必须保留才考虑并发和局部优化。
  6. 运行同配置 A/B Macrobenchmark,并检查 TTID、TTFD、帧和功能正确性。
  7. 在小流量发布的线上数据中,按入口、设备、版本和启动类型确认回归消失。

启动优化的目标不止于更早撤掉 SplashScreen:用户要的是更快看到可理解、可操作且状态正确的内容,所以 TTID、TTFD 和关键页面帧要一起看。

启动类型、时间点与线上指标

上一节把启动拆成了可测阶段;这一节我们换到监控系统一侧,看数据怎样稳定采集:冷、温、热启动和版本要分层,TTID 与 TTFD 也要分开。

为什么启动监控需要独立设计

启动监控要回答两个问题:改动发布后,用户启动体验是否变差;出现变化后,怎样定位到版本、入口、设备和初始化任务。

只在 Application.onCreate() 入口与出口记录时间,覆盖的只是中间一小段:系统收到启动请求、创建进程、调用 bindApplication、安装 ContentProvider、显示首帧,都落在这个区间之外,首屏核心内容何时可用同样读不出来。完整方案需要三类数据互相校准:

数据回答的问题典型来源
平台启动记录系统何时收到请求、进程属于冷启动/温启动/热启动、由什么原因与组件触发Android 15+ ApplicationStartInfo、Logcat(系统日志)、Perfetto
应用阶段事件哪个 Provider、初始化任务、页面或数据依赖消耗时间单调时钟事件、自定义 trace(时间线标记)
用户体验指标第一帧何时显示,核心内容何时可交互TTID、reportFullyDrawn() 对应的 TTFD、业务 ready(业务就绪点)

三种启动状态走过的代码不同,线上必须分开统计。

三类工具各管一段:线上监控发现耗时分布的变化,Macrobenchmark 在固定设备和条件下重复对比,Perfetto 解释一次启动里线程、Binder、I/O 和调度层面发生了什么。线上 P90(90% 的样本不超过的耗时) 曲线要还原单次启动,还得回到时间线;反过来,一次时间线也撑不起全量用户的耗时分布。

1. 先固定测量契约

我们先把测量契约固定下来。这里的“测量契约”指一项指标固定不变的定义:起点、终点、适用启动类型、缺失条件和失败样本处理方式都要写清;名称相同而起止不同的数据,放进同一条曲线只会污染结论。

1.1 TTID、TTFD 与业务 ready

TTID 是 Time to Initial Display,即从启动请求到首次显示;TTFD 是 Time to Full Display,即从启动请求到 App 声明主要内容已完整可用。它们都是 Android 平台定义的指标,“业务 ready”则是团队为某个页面定义的可用条件。

指标起点终点平台语义使用方式
TTID系统收到启动请求Activity 窗口第一帧绘制并显示Android framework(平台框架)自动测量判断用户何时看到应用 UI
TTFD系统收到启动请求应用调用 reportFullyDrawn(),且不早于 TTID由应用声明“核心 UI 已完整绘制并可用”判断首屏何时达到约定的可用状态
content_ready与 TTID/TTFD 相同的启动记录,或明确的应用起点首屏关键数据和交互状态满足产品约定应用自定义解释 TTFD,支持无法直接读取系统 TTFD 的版本
app_on_create_costApplication.onCreate() 方法入口方法返回前应用局部阶段只解释 App 初始化,不能称为总启动耗时

TTID 只说明第一帧出现,骨架屏、空列表或不可点击的占位页也可能已经有 TTID。TTFD 则要我们给每个首屏入口定义“可用”:例如首页主导航可操作且首批必要数据已展示,支付页已完成本地安全状态检查,拍摄页预览已可用。广告、推荐流后续分页和不影响首个操作的后台刷新,通常都要留在 TTFD 之外。

Activity.reportFullyDrawn() 是一次性启动信号。平台的 Activity 实现由内部标记 mDoReportFullyDrawn 控制:第一次有效调用经 ActivityClient 报给系统,后续调用被忽略。若调用发生在系统确认第一帧之前,平台会把 TTFD 记为 TTID;过早上报会让两个值相同,丢掉“内容完成”的区分能力。

1.2 使用同一种时钟

耗时计算使用单调时钟,即只向前推进、不受用户改时间或时区变化影响的计时源。平台的 ApplicationStartInfo 系统时间戳,在 Activity launch、fork、bindApplication、首帧和 fully drawn 路径中都取自 SystemClock.uptimeNanos();我们通过 ActivityManager.addStartInfoTimestamp() 写入同一启动记录的自定义点时,也要用同一时基。App 自有遥测里计算同设备会话耗时,可以另用 SystemClock.elapsedRealtimeNanos(),只是它和平台启动记录里的 uptimeNanos() 节点属于不同时基,直接相减得到的数没有意义。System.nanoTime() 只保证用于计算同一运行环境中的时间差,绝对起点没有稳定含义。

System.currentTimeMillis() 是 UTC epoch 墙钟时间,可因用户改时或校时跳变,不适合测量启动区间;单独改变显示时区并不会改变这个 epoch 值。墙钟用于事件日期与发布对齐,不等同于真实经过的单调时长。一个事件可以同时保存:

  • event_wall_ms:用于版本发布、灰度放量和配置变更对齐;
  • event_elapsed_ns:用于同一台设备本次启动内的阶段耗时;
  • duration_ms:端侧完成起止点检查后生成,服务端不跨设备相减单调时钟。

1.3 启动样本的身份

同一设备可能由桌面图标、deep link(直接打开特定页面的链接)、通知、桌面小组件 Widget、Service、Broadcast 或 Provider 启动;多进程 App 还会产生多个进程启动记录。每条样本至少携带:

维度建议字段
构建versionName、versionCode、渠道、构建 ID、监控 schema(字段结构与解释规则)版本
启动cold/warm/hot、入口枚举、首屏路由、是否新任务
进程processName、pid、主/远程进程角色
安装状态首次安装后启动、升级后启动、普通启动、数据库迁移版本
设备Android 版本、ABI(应用二进制使用的 CPU 指令集)、RAM 内存档位、SoC(片上系统)/机型分组、低电量与过热状态
编译与配置Baseline Profile(预先告知 ART 启动热路径的配置)状态、实验组、远程配置版本
结果TTID、TTFD、content ready、超时、退出或上报缺失

入口 URL、Intent extras(附加参数)、用户 ID 和页面原始参数要留在端上。路由、设备和任务名应转换成有限的受控枚举;确需用哈希关联事件时,应使用服务端可轮换密钥生成的伪匿名标识,并评估它是否仍属于个人数据——普通哈希当不成匿名化。更完整的采集规则见 性能指标采集与上报。

2. 端侧埋点怎样放

2.1 最早的埋点点位

埋点想放得早,就会碰到启动路径本身的约束:ContentProvider 会在 Application.onCreate() 之前安装,若为了“尽早监控”新增自动初始化 Provider,监控 SDK 的类加载和初始化也会随之进入每次冷启动的关键路径。已有 Provider 即使负责记录最早入口,也只应保存一个时间戳和必要身份;序列化、压缩、网络发送与扩展设备信息都应延后。

推荐点位如下:

点位位置能说明什么不能说明什么
process_observed已有的最早轻量入口或 Application.attachBaseContext()App 代码能看到进程的时间系统启动请求与 fork(从 Zygote 派生进程)的精确时间
application_on_create_enter/exitApplication.onCreate()Application 阶段代码耗时Provider 和系统进程创建耗时
activity_create/start/resume入口 Activity 生命周期页面创建路径和生命周期等待第一帧已显示
初始化 task 事件启动任务执行器任务从开始到结束的耗时、线程和依赖CPU 消耗或锁等待原因,除非再配 trace
content_ready首屏状态机(页面从加载、成功、失败等状态间转换的规则)核心数据和交互就绪SurfaceFlinger(负责屏幕图层合成的系统服务)已完成显示
fully_drawn_reportedreportFullyDrawn() 调用处应用声明的 TTFD 边界每次页面恢复的完成时间

onResume() 只表示生命周期走到前台,本身说明不了首帧显示。ViewTreeObserver.OnPreDrawListener 发生在即将绘制之前,Choreographer.FrameCallback 是 UI 帧调度回调,两个时点都证明不了 SurfaceFlinger 已经把像素显示到屏幕。App 可以把它们当作自定义近似点,但事件名要写成 pre_draw 或 frame_callback,别冒充平台 TTID。

2.2 准确上报 fully drawn

在多处直接调用 reportFullyDrawn(),很容易由最早完成的模块提前上报。ComponentActivity 的 FullyDrawnReporter 可以登记多个 reporter(“尚未就绪”的占位计数);所有 reporter 都释放后,它才在下一动画帧调用平台 API。至少使用一次 addReporter() 或 reportWhenComplete(),这个协调器才会代为上报。

下面的 ViewModel(跨配置变化保存页面状态的 Jetpack 组件)示例用于等待首屏核心数据完成,再由 FullyDrawnReporter 安排上报:

class HomeActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_home)

        lifecycleScope.launch {
            fullyDrawnReporter.reportWhenComplete {
                viewModel.uiState
                    .filterIsInstance<HomeUiState.Ready>()
                    .first()
            }
        }
    }
}

reportWhenComplete 会在挂起任务执行期间持有一个 reporter,等到 Ready 后释放;全部 reporter 都释放后,FullyDrawnReporter 在下一动画帧调用 reportFullyDrawn()。这里仅演示正常等到 Ready 的路径。固定 Activity 1.11.0 的 reportWhenComplete() 在 finally 中调用 removeReporter(),异常或取消也会释放计数;它协调的是任务结束,不会验证页面成功或是否形成可操作错误态。生产代码应先把成功、合法空态、离线和可恢复错误归一为可报告的终态,再配合 UI 提交/布局条件;未形成终态的失败不能靠释放 reporter 伪装成完成。一直加载、退出与缺失仍要单独统计。

Jetpack Compose 首屏可以把同一条 ready 规则放进 ReportDrawnWhen;它会在条件为 true 前延迟 fully drawn 上报:

@Composable
fun HomeRoute(state: HomeUiState) {
    ReportDrawnWhen {
        state is HomeUiState.Ready
    }

    HomeScreen(state)
}

这个条件应表示用户已能完成首个核心操作。示例约定 Ready 包含已提交的合法空态与可操作降级态;不要额外使用 isNotEmpty() 排除合法空列表。若业务模型单独定义 Empty/Error/Offline,也应把可操作终态纳入同一条规则。

2.3 慢任务的时间线记录

每个启动任务应记录受控的任务名、调度线程、依赖、开始/结束、结果和是否位于首帧前。对高频短任务,记录动作本身可能比任务还耗时,可以只保留汇总计数,或只在被抽中的慢启动诊断样本里记录细节。

自定义 trace 与统计事件分工如下:

  • 统计事件保留每次启动的稳定字段与耗时,用于计算全体样本的分布;
  • Trace.beginSection() / Trace.endSection() 或 AndroidX Tracing 在系统时间线上标记阶段,用于在 Perfetto 中对齐主线程、Binder、I/O 和调度。

标记阶段时,section 必须严格配对,名称集合要有上限;不上传任意类名、SQL、URL 或用户内容作为 trace 名。若把用户输入或 URL 拼进名称,会产生近乎无限的不同值,使存储和聚合失控。

采集器自身要轻量:首帧前不创建大线程池、不扫描完整设备信息、不同步写日志文件。启动事件先写入内存队列或受控的小型本地记录,首帧后批量编码;网络发送交给既有后台上报机制。

3. ApplicationStartInfo 与系统启动记录

应用侧埋点补齐了阶段细节,起点和分类仍要靠系统记录。ApplicationStartInfo 由 Android 15 引入,是一份系统逐步补全的进程启动记录,App 可通过 ActivityManager 查询。到 Android 17 / API 37,这组 API 能给出 App 自己难以准确记录的系统侧信息:

  • getProcessName()、PID(进程编号) 与 UID(Linux 用户身份);
  • getReason():启动 Activity、Service、Provider、Broadcast、Job、Push、闹钟、备份等触发原因;
  • getStartType():cold、warm、hot;
  • getStartupState():记录仍在启动、发生错误或已经画出首帧;
  • getStartupTimestamps():launch、fork、bindApplication、Application.onCreate、first frame、fully drawn 等单调时间戳;
  • getStartComponent():Android 16(API 36)加入,用于区分 Activity、Service、Broadcast 和 Provider。
  • getLaunchMode()、wasForceStopped() 与去除 extras 的启动 Intent:用于补充 Activity 任务和进程状态;原始 Intent、URI 与 referrer(来源页面)默认不进入遥测。

reason 与 start component 各说各的事:前者是触发原因,后者说明哪类组件引发进程创建。原因枚举对不上 Activity、Service、Broadcast、Provider 四大组件分类,Android 16 起判断组件类型要用 getStartComponent()。

3.1 Android 15、16、17 的能力范围

这组 API 的主体在 Android 15 已经提供。本文用 Android 17 的源码核对实现,核对的是实现行为;各项能力的归属仍按引入版本算,Android 15 已有的不算 17 的新增功能。

能力API 35 / Android 15API 36 / Android 16API 37 / Android 17
ApplicationStartInfo、历史查询、首帧完成监听加入保留保留
8 个系统时间戳、21–30 开发者时间戳加入保留保留
getLaunchMode()、wasForceStopped()、去除 extras 的启动 Intent加入保留保留
getStartComponent() 与组件常量无加入保留

API 35 没有 getStartComponent(),调用前必须检查系统版本。官方文档还注明,Service 启动的 LAUNCH 时间戳在 Android 16(Baklava / API 36)及更早版本可能不准确;Android 17 不在这个已知限制范围内。

3.2 记录形成与状态

system_server 中的 AppStartInfoTracker 先建立启动记录,随后由进程创建、应用绑定、首帧和 fully drawn 等路径逐步补入字段。App 通过 ActivityManager 读到的是某一时刻的记录副本,也就是快照;它不会自动更新,也不包含线程调度、Binder 和 I/O 的完整时间线。

getStartupState()含义可依赖的节点
STARTUP_STATE_STARTED启动仍在进行LAUNCH
STARTUP_STATE_ERROR启动失败,记录不会继续完整化只使用已经存在的节点
STARTUP_STATE_FIRST_FRAME_DRAWNActivity 已走到首帧LAUNCH、BIND_APPLICATION、APPLICATION_ONCREATE、FIRST_FRAME

平台没有 FULLY_DRAWN 状态,fully drawn 只是依赖 App 调用 reportFullyDrawn() 的可选时间戳;完成监听里的“complete”指记录进入 FIRST_FRAME_DRAWN,TTFD 是否完成要看后续的 FULLY_DRAWN 时间戳。固定 tracker 的完成触发点在加入 FIRST_FRAME 时,纯 Service、Broadcast 或 Provider 启动若没有 Activity 首帧,就不能依赖它;反过来,没等到回调也说明不了启动失败。

八个系统时间戳的常量编号只是编号,发生顺序要看实际时间值:

首帧完成不等于 fully drawn,后者只是可选时间戳

图:FIRST_FRAME_DRAWN公开契约提供相应系统点;FULLY_DRAWN依赖应用报告,副本还可能缺失该点。

时间戳语义与限制
LAUNCH系统组件启动起点,不一定是桌面图标点击
FORK可选的进程 fork 节点,不表示进程初始化完成
BIND_APPLICATION系统调用应用绑定的节点
APPLICATION_ONCREATE调用 Application.onCreate() 前的节点
FIRST_FRAME首帧绘制,不单独证明 SurfaceFlinger 已呈现或页面可交互
FULLY_DRAWN应用定义并上报的完成边界,始终可能缺失
INITIAL_RENDERTHREAD_FRAME可选的初始 RenderThread(负责部分渲染工作的线程)帧
SURFACEFLINGER_COMPOSITION_COMPLETE可选的合成完成节点,仍不等于业务 ready

3.3 时间戳的缺失与逐项判断

首帧完成状态保证可获得 LAUNCH、BIND_APPLICATION、APPLICATION_ONCREATE 和 FIRST_FRAME,其余时间戳要逐项判断。FULLY_DRAWN 依赖 App 调用 reportFullyDrawn(),任何版本都要按“可能缺失”处理。addApplicationStartInfoCompletionListener() 在首帧完成时异步回调,不等待 fully drawn,所以回调里的快照经常没有 FULLY_DRAWN;我们要拿 TTFD 时,得在上报后重新查询历史记录、取新副本。

跨版本还有一个限制要记下:官方 API 文档说明,Service 触发的 START_TIMESTAMP_LAUNCH 在 Android 16 及以下可能不准确。本书的 Android 17 基线已越过这个限制;但分析 Android 15–16 存量设备时仍要标记该样本,精确的 Service 启动回归要换用别的数据。

3.4 读取当前进程的正确记录

getHistoricalProcessStartReasons(maxNum) 返回保存在有限容量历史队列中的启动记录,也可能包含尚未完成的记录。读取前要检查 getStartupState(),读取时间戳 Map 时也要检查 key 是否存在;“字段尚未写入”是缺失,跟数值 0 是两回事,混用会算出错误的区间。

历史列表还会混入同一 App 近期的其他进程或相邻启动。选择当前记录时至少按 PID、进程名过滤,再按 LAUNCH 取最新项;列表顺序保证不了第 0 项恰好是当前这次启动。

@RequiresApi(35)
fun latestCurrentProcessStart(
    activityManager: ActivityManager,
    maxRecords: Int = 16,
): ApplicationStartInfo? {
    require(maxRecords > 0)
    return activityManager
        .getHistoricalProcessStartReasons(maxRecords)
        .asSequence()
        .filter { info ->
            info.pid == Process.myPid() &&
                info.processName == Application.getProcessName()
        }
        .maxByOrNull { info ->
            info.startupTimestamps[
                ApplicationStartInfo.START_TIMESTAMP_LAUNCH
            ] ?: Long.MIN_VALUE
        }
}

生产采集还应核对 launch 时间与当前本地启动会话;同一 PID/进程名可以有相邻的 Activity 启动,PID 也可在进程结束后复用。过滤得到的只是候选,不自动证明就是当前入口。需要 FULLY_DRAWN 时,在业务条件满足并调用 reportFullyDrawn() 后重新查询;首帧回调中的旧副本通常没有这个节点。

3.5 把业务点写进平台启动记录

API 35 起,ActivityManager.addStartInfoTimestamp() 允许 App 使用 21~30 的保留 key 添加自定义单调时间戳。它可以向系统启动记录提交 route_resolved(路由决定完成)之类业务点,但不能把 API 正常返回当作已写入当前 PID 记录的证明。固定 AMS 按包名与 UID 寻找容器,tracker 再取 mInfos.get(0) 最新项,未按调用 PID 指定目标;并发多进程或相邻启动时需回读核对记录身份,并保留应用自己的原始业务事件。

这里存在公开契约与 Android 17 r1 实现的差异。当前 ActivityManager API 文档写明:相同 key 会覆盖旧值,只有在 reportFullyDrawn() 之后添加的时间戳才会被丢弃;但 android-17.0.0_r1 的 AppStartInfoTracker 会拒绝重复 key,并在记录进入 FIRST_FRAME_DRAWN 后拒绝开发者 key。

把某个版本的内部 tracker 行为当成长期 API 保证是靠不住的,兼容两种行为的写法是:每个开发者 key 在首帧前只写一次,回读时若该 key 缺失就按缺失处理。通常发生在首帧后的 content_ready 继续走应用遥测(App 自己采集并上报的事件),约定的完成时点交给 reportFullyDrawn() 表达。

下面的代码注册首帧完成监听,并用保留区第一个 key 写入首帧前的路由决策完成点:

@RequiresApi(35)
class PlatformStartInfoCollector(
    private val activityManager: ActivityManager,
    private val callbackExecutor: Executor,
) {
    companion object {
        const val TIMESTAMP_ROUTE_RESOLVED =
            ApplicationStartInfo
                .START_TIMESTAMP_RESERVED_RANGE_DEVELOPER_START
    }

    // 每个本地启动会话使用一个实例;这些状态不跨会话复用。
    private val registered = java.util.concurrent.atomic.AtomicBoolean()
    private val routeSubmitted = java.util.concurrent.atomic.AtomicBoolean()
    private val deliveredLaunches =
        java.util.concurrent.ConcurrentHashMap.newKeySet<Long>()

    fun register(
        onFirstFrameCandidate: (ApplicationStartInfo) -> Unit,
    ) {
        if (!registered.compareAndSet(false, true)) return

        fun deliver(info: ApplicationStartInfo) {
            if (info.pid != Process.myPid() ||
                info.processName != Application.getProcessName() ||
                info.startupState !=
                    ApplicationStartInfo.STARTUP_STATE_FIRST_FRAME_DRAWN
            ) return
            val launchNs = info.startupTimestamps[
                ApplicationStartInfo.START_TIMESTAMP_LAUNCH
            ] ?: return
            if (deliveredLaunches.add(launchNs)) {
                onFirstFrameCandidate(info) // 外层仍核对本地会话与入口。
            }
        }

        activityManager.addApplicationStartInfoCompletionListener(
            callbackExecutor,
        ) { info -> deliver(info) }

        // 注册后查询,补充已完成记录;与异步回调按 launch 去重。
        latestCurrentProcessRecord()?.let { info ->
            callbackExecutor.execute { deliver(info) }
        }
    }

    fun markRouteResolved() {
        if (!routeSubmitted.compareAndSet(false, true)) return
        activityManager.addStartInfoTimestamp(
            TIMESTAMP_ROUTE_RESOLVED,
            SystemClock.uptimeNanos(),
        ) // 正常返回仍可能被 tracker 忽略;需要回读确认。
    }

    fun latestCurrentProcessRecord(): ApplicationStartInfo? =
        latestCurrentProcessStart(activityManager, maxRecords = 16)
}

公开契约称已完成的记录会立即投递到指定 Executor;但固定 Android 17 r1 的 tracker 登记路径只按 UID 保存回调,没有补发已完成记录的分支,完成分发也以 real UID 为键。示例因此过滤 PID/进程名/状态,注册后读取历史候选并去重。一次回读仍可能太早,同 UID 其他进程还可能先消耗这次监听;生产应在受控业务里程碑有限次回读,始终保留应用事件作为后备。监听与回读结果均只复制必要字段交采集队列,采集异常由上层处理,不进入首帧关键路径。历史列表覆盖 App 近期多个进程启动,取记录要像示例那样按当前 PID 和进程名过滤,生产代码还应核对最新 launch 时间与当前启动会话。业务 key 的编号和含义要随监控 schema 固定,避免不同版本把同一个 key 解释成不同事件。

监听与历史回读并用,按身份去重仍要保留缺失

图:固定登记路径未见已完成回放,UID其他进程可先消费监听;一次回读也不保证补齐,生产在里程碑有限刷新。

ApplicationStartInfo 适合校准系统起点和启动分类,Android 10–14 仍需兼容自建埋点。完整 API 设计见ApplicationStartInfo。

3.6 阶段耗时的缺失与乱序处理

不要把缺失 key 补成 0,也不要按常量编号排序。只有起止节点都存在且终点不早于起点时,区间才有效:

@RequiresApi(35)
fun startupDurationMs(
    info: ApplicationStartInfo,
    fromKey: Int,
    toKey: Int,
): Double? {
    val startNs = info.startupTimestamps[fromKey] ?: return null
    val endNs = info.startupTimestamps[toKey] ?: return null
    if (startNs < 0L || endNs < startNs) return null
    return (endNs - startNs) / 1_000_000.0
}

采集端应保留原始节点、字段是否存在以及 startupState。这样即使 schema 调整或后续发现平台差异,服务端仍能重新计算区间,无须依赖端侧已经算好的单一耗时。

4. 线上聚合与分位数解读

我们先看分布形态。启动耗时通常呈右偏长尾分布:多数样本集中在较短区间,少数慢样本向更长耗时方向拖出一条“尾巴”。低端设备、升级迁移、磁盘繁忙、首次读取配置和编译状态都会制造这类慢样本。均值容易被少数极慢值拉高,也可能被大量热启动样本稀释。

常用分位数回答不同问题:

分位数解释使用提醒
P50一半样本不超过该耗时观察主路径和整体平移
P75较慢但仍常见的样本观察普通长尾是否扩大
P90/P95慢启动群体适合作为版本门禁候选指标
P99极端尾部需要较大样本量,易受异常设备与数据质量影响

分位数必须从同一 cohort(版本、启动类型等条件一致的一组样本)的原始数据或可合并分布摘要计算:每台设备先算 P90 再把各设备 P90 求平均,或把每日 P90 平均成周 P90,都会失真。数据量大时可用直方图,或 t-digest、KLL 这类近似分位数摘要;无论选哪种,都要固定直方图区间边界或算法版本,并保留样本数、最小值、最大值和缺失率。

客户端或每日分位数不能直接平均成总体分位数

图:固定直方图区间或摘要算法版本,再合并同cohort分布;设备与每日P90平均都不能还原总体P90。

4.1 先分组,再看分位数

同样一条分位数曲线,混进不同条件的样本就会失真;看分位数之前,最低限度要按以下维度拆分:

  • cold、warm、hot;
  • 首屏路由与入口来源;
  • 普通启动、首次安装启动、升级后启动;
  • App 版本、实验组与远程配置版本;
  • Android 版本、ABI、设备性能档位;
  • 主进程与远程进程;
  • Profile 安装/编译状态能够可靠获得时,单独分组。

分组过细会让每组样本太少。看板应支持从“版本 × 启动类型”逐级查看页面和设备,告警只选择样本量足够、含义长期稳定的 cohort。

4.2 把缺失和退出当成结果

TTFD 上报容易出现幸存者偏差,即只看到了成功完成启动的会话:启动期间退出、崩溃、ANR(Application Not Responding,应用无响应)、进程被杀或长期 loading(一直停留在加载态) 的会话都没有 TTFD 数值。只统计成功上报的样本,等于把慢到中途放弃的会话排除在曲线之外,严重问题反而可能让 TTFD 曲线看起来更好。

每个窗口应同时展示:

  • 启动请求或可观测会话数;
  • TTID 有效样本数;
  • TTFD 有效样本数与完成率;
  • fully drawn 超时数;
  • 首屏前崩溃、ANR、主动退出和进程死亡;
  • 采样率、上传成功率、去重率与 schema 版本。

采样策略也要进入总样本数的解释。普通样本可以做稳定随机采样,慢样本与失败样本可以提高诊断采样率;后者被采到的概率更高,两类数据直接混算总体分位数会带偏结果。用于告警的指标数据和用于定位的高采样诊断数据应分开保存;若必须合并,应按每类样本被采中的概率加权。

5. 回归检测与归因

分位数看板回答“变没变慢”;发布拦截还要再回答一层:这次变化是真回归,还是日常噪声。固定写死“P90 上升 15% 就拦截发布”,会在基线很低、样本很少或日常波动明显时产生误报。发布门槛应同时考虑增加了多少毫秒、相对增幅、样本量和统计不确定性。

一条可执行的规则可以写成:

同 cohort、同统计窗口:
  样本量达到该指标的最低要求
  AND P90 绝对增量超过产品预算
  AND P90 相对增量超过历史噪声带
  AND 差异的置信区间不跨过“无影响”边界
  AND 连续两个窗口成立
=> 暂停灰度并进入归因

这段规则中的“预算”是产品允许增加的毫秒数,“历史噪声带”是指标在没有代码变化时通常波动的范围。高流量版本可以用 bootstrap(从现有样本反复有放回抽样)估计置信区间(变化可能落入的范围);低流量灰度可先看中位数、MAD(各样本与中位数偏差的中位数)、样本明细和线下 benchmark,避免把样本不足的 P99 当成发布结论。实验统计细节见 性能实验统计。

5.1 归因顺序

发现回归后,我们按以下顺序缩小范围:

  1. 核对 schema、采样率、TTFD 完成率与启动类型占比,排除测量变化。
  2. 对齐灰度开始、构建发布时间、远程配置、服务端接口和实验开关。
  3. 按入口、页面、设备档位、Android 版本、ABI、安装状态分组。
  4. 比较初始化 task 出现率和耗时,检查是否新增 Provider、SDK 或主线程 I/O。
  5. 在可复现设备上运行同编译模式的 Macrobenchmark,并打开 Perfetto。
  6. 把长耗时区间映射到具体提交和模块所有者,修复后按同一 cohort 回看。

常见信号可以这样解释:

现象优先检查
LAUNCH -> FORK 变长系统负载、进程创建竞争,以及是否集中在特定设备或 ROM(厂商系统版本)
BIND_APPLICATION -> APPLICATION_ONCREATE 变长Provider、类加载、Instrumentation(测试或监控注入层)与应用绑定阶段
Application 阶段变长新 SDK、同步 I/O、锁、线程池和任务依赖
TTID 变长但 Application 稳定Activity 创建、布局/Compose 首次 composition(根据状态生成 UI 树)、资源加载与首帧调度
TTID 稳定但 TTFD 变长首屏数据、数据库、网络、缓存和 ready 条件
TTFD 数值变好但完成率下降超时、退出、崩溃或上报丢失造成幸存者偏差
只在升级后启动变慢数据库迁移、缓存重建、Profile/编译状态和版本迁移任务

ApplicationStartInfo 的系统节点用来确定区间,应用 task 事件说明责任模块,Perfetto 用来确认线程当时在运行、睡眠、I/O 还是锁等待。后两层凑不齐时,单凭一个长区间还定不了根因。

6. Android Vitals 怎样对标

自建监控之外还有一个现成的外部基准:Google Play Android Vitals 使用 TTID 判断 excessive startup,即启动耗时达到平台定义的“过慢”范围。当前 Android vitals 专页公开阈值为:

启动类型excessive 阈值
cold5 秒及以上
warm2 秒及以上
hot1.5 秒及以上

这些数值是 Play 用来识别明显过慢启动的风险线,距理想体验还有距离;团队内部预算通常要更严格,并按核心入口、设备档位和用户任务设置。Google 的性能测量总览还提供更积极的目标参考,但统一的毫秒数承诺要建立在对页面复杂度、设备和编译条件的具体分析上。

Vitals 与自建监控应同时保留:

维度Android Vitals自建监控
分发覆盖满足 Play 采集条件的发布用户可覆盖灰度、内测和非 Play 渠道
核心口径平台 TTID 与 excessive 比例TTID 近似/平台校准、TTFD、业务 ready、任务阶段
维度Play 提供的版本与设备等维度页面、入口、实验、任务和业务状态
时效适合版本趋势与外部质量观察可接入团队的发布流程,提供更快告警
主要用途外部质量基线发布门禁与内部归因

两边数据不一致时,检查版本覆盖、启动类型、统计窗口、渠道、设备分布、采样条件和 TTFD 完成率。不要通过乘一个固定系数把自建 TTID“换算”为 Vitals。

Android Vitals 的专项限制和 Play Console 使用方式见Android Vitals 与 Play Console。

7. 线下与线上怎样互证

一条启动问题从发现到验收,我们建议保留四层证据:

层级工具产物
发布前回归Macrobenchmark StartupTimingMetric(启动时间测量项)固定设备、启动模式、编译模式和重复次数下的 TTID/TTFD 分布
单次诊断Perfetto / Android Studio ProfilerAndroid App Startups、主线程、RenderThread、Binder、I/O 与调度时间线
平台校准ApplicationStartInfo系统起点、启动类型、原因、组件与阶段时间戳
线上验证自建监控 + Android Vitals同版本、同启动类型样本组的分位数、完成率、失败率和长期趋势

Macrobenchmark 必须记录 StartupMode、CompilationMode、设备、温度、电量和重复次数。固定 Macrobenchmark 1.4.1 的 StartupMode.COLD 在每轮 setup 中依次执行用户 setup、dropShaderCache()、killProcess() 与 dropKernelPageCache();不能把它描述为默认保留内核页缓存。API 31+ 走平台清缓存路径,失败会抛异常;旧版非 root 设备可能只记录失败日志,必须在报告中保存库版本、设备能力与清理结果。单纯手动杀进程也不等于清掉页缓存,这与基准库的完整 COLD 协议应分开。Baseline Profile 实验还要区分 Profile 是否安装,避免把编译差异误归给业务代码。

Benchmark 1.4.1 的 COLD 协议同时处理进程和缓存

图:源码在每轮COLD setup中执行这些步骤;仅手动杀进程与完整库协议不同。

Perfetto 中先找 Android App Startups 派生轨道(Trace Processor 根据原始事件计算出的分析结果),再与 App 的自定义 trace 对齐。我们在时间线上看到一个任务从开始到结束持续 80 ms 时,先别急着按 80 ms 记 CPU 成本——线程可能在等待 Binder、锁、I/O 或 CPU 调度,实际占用要由对应时间线证明。下面的 PerfettoSQL 会加载官方 android.startup.startups 标准库模块,列出 trace 中识别出的启动;dur / 1e6 把纳秒转换成毫秒。取得目标 startup_id 后,再查询对应线程、Binder、I/O、GC 和帧事件:

INCLUDE PERFETTO MODULE android.startup.startups;

SELECT startup_id, package, startup_type, dur / 1e6 AS duration_ms
FROM android_startups
ORDER BY ts;

Android 15+ 还可用 ProfilingManager 请求 system trace 或 stack sampling(定期采集线程调用栈)。请求会被系统限流,也不保证每次执行;结果会写入 App 数据目录,并按平台规则做脱敏。它比读取 ApplicationStartInfo 成本高,只适合问题版本或受控样本,还要限制采集频率、设备开销和上传范围。官方也建议大多数 App 优先使用能正确构造参数的 AndroidX 高层封装。

8. 用同一份模板记录复盘

复盘时,我们把现象、证据、假设、改动和结果分开,避免把一次“同时发生”写成因果关系。下面的最小模板可直接用于 PR(Pull Request,代码合并请求) 或性能专项。模板里的 commit 是代码提交标识,release variant 是用于发布的构建变体,R8 是 Android 代码优化与压缩工具,owner 是后续事项负责人;这些工程字段保留原名,便于和构建系统、代码仓库对照。

# <入口 / 问题> 启动优化复盘

## 1. 范围
- App commit / versionCode、release variant、R8、编译模式:
- 设备 / Android / RAM / ABI / 温度与电量:
- 入口 / 账号 / 数据 / 网络、cold / warm / hot 定义:
- 统计窗口、样本量、采样率:

## 2. 用户症状
- TTID / TTFD 的 P50、P90 与完成率:
- 首屏前 ANR / Crash / 退出、受影响 cohort:

## 3. 证据
- ApplicationStartInfo 区间、Macrobenchmark、Perfetto:
- task / Provider / manifest、首次出现的版本或配置:

## 4. 根因假设
- 假设、支持证据、反证、仍未知:

## 5. 单变量改动
- 改动、目标区间、风险、灰度与回滚开关:

## 6. 验证
- 线下 before / after 分布:
- 线上同 cohort 的 TTID / TTFD / frame / ANR / Crash / 业务护栏:
- 结果是否超过历史噪声与产品预算:

## 7. 后续
- 门禁、owner、截止时间、回滚条件:

若写不出一条能推翻当前假设的证据,结论多半还停留在猜测。“单变量改动”指一次验证尽量只改变一个待检验因素;当代码、Profile、服务端配置和样本结构同时改变时,结果只说明整个版本组合变了,收益归不到其中单项头上。

检查清单

  • TTID、TTFD、content ready 和 Application 局部耗时各有独立名称与起止定义。
  • 耗时使用单调时钟,墙钟只用于事件对齐。
  • 没有为监控新增自动初始化 Provider 或首帧前重型 SDK。
  • onResume、pre-draw 与 frame callback 没有被命名成平台 TTID。
  • 每个首屏入口都定义了可测试的 fully drawn 条件和超时/降级。
  • TTFD 缺失、启动中退出、崩溃与 ANR 进入分母和数据质量看板。
  • cold/warm/hot、安装状态、入口和设备 cohort 分开统计。
  • 分位数从可合并分布计算,没有平均客户端或每日分位数。
  • 告警同时检查绝对差、相对差、样本量、不确定性和连续窗口。
  • Android 15–16 Service 启动的 LAUNCH 时间戳限制已标记。
  • ApplicationStartInfo completion callback 没有被当成 fully drawn callback。
  • Vitals 阈值只作外部风险线,内部预算由产品基线确定。
  • Macrobenchmark、Perfetto、平台时间戳和线上样本可以按同一启动入口互相对齐。

小结

启动分析先把系统请求、进程创建、Provider/Application、Activity 和首帧放进同一条时间线,再根据线程运行/等待状态与业务切片定位关键路径。启动监控则依赖一份稳定的测量契约:平台 TTID 告诉我们第一帧何时显示,reportFullyDrawn() 给出约定的首屏可用时点,ApplicationStartInfo 补齐 Android 15+ 的系统起点、启动分类和触发原因,App 事件负责解释任务与业务状态。服务端再按条件一致的样本组计算分位数、完成率和失败率,用绝对预算与统计不确定性共同判断是否变慢。

当一条告警能够回答“哪个版本、哪种启动、哪个入口、哪类设备、哪个阶段开始变慢”,启动监控才具备工程价值。

参考资料