先看结论与判断条件
- 完全擦除、刷入相同系统镜像并固定温控是唯一可复现的硬件基线。
- 加固后的首次冷启动必须排除 dex2oat 等运行时编译影响,使用稳定迭代数据。
- 只报告均值会掩盖尾部劣化,必须基于 P50/P90/P99 等分位数做出判定。
- 结果对比门禁应由环境变量注入退化预算,避免硬编码性能阈值。
- 虚拟设备通过不能替代真实用户物理设备的 Native 路径验证。
- 基线必须与测量运行在同一个设备身份和相同测试模式下取得,否则偏差不可归因。
固定硬件与系统状态:建立唯一可比较的物理基线
不同设备的 SoC、散热结构、固件调度与驱动版本对冷启动路径耗时影响显著,哪怕同一型号不同批次都可能引入毫秒级偏差。因此,加固前后对比必须使用同一台物理设备,并且通过全量刷机回退到完全相同的工厂镜像,包括 system、vendor、boot 分区和 radio 固件。凡更换设备或仅恢复出厂设置而不重刷镜像,均无法保证底层执行环境一致。
系统服务和后台进程同样会干扰测量。必须固定系统版本、安全补丁级别和 Google Play 服务版本,关闭所有非必要同步、定位和后台数据上报。Wi‑Fi 与蜂窝网络应设为飞行模式,仅保留 adb 连接。亮度固定为 50% 且关闭自动亮度,充电器拔除以消除快充产生的热限频及电力管理策略影响。任何一项未固定,冷启动尾部长尾就可能被误读为加固带来的退化。
温度是极容易被忽略但影响力最大的变量。设备温度从室温升至 35℃ 时,CPU 大核频率可能被持续压制,导致 TTID/TTFD 增加数十至上百毫秒,远超加固自身在正常条件下可能产生的差值。因此在每一轮测试前必须监控电池温度,确保低于 32℃ 后再启动测试,否则该轮数据应标记为非基线条件并排除。此类排除必须记录在测试报告中,不得无声丢弃。
| 条件项 | 固定方式 | 未固定导致的风险 | 检测方法 |
|---|---|---|---|
| 操作系统的分区镜像 | fastboot 刷入同一 factory image | 内核、调度、驱动版本差异引入无法归因的启动耗时变化 | 对比 ro.build.fingerprint |
| 充电与热状态 | 拔除充电线,电池温度 ≤32℃ 开始测试 | 快充、热节流会强制降低 CPU 频率,虚构加固劣化 | adb shell dumpsys battery 获取温度 |
| 屏幕亮度与自动调节 | adb shell settings put system screen_brightness 128,关闭自动亮度 | 不同背光等级影响 GPU 合成路径和部分 UI 渲染回调,干扰 TTFD | 未平均亮度会导致 display 帧提交节奏波动 |
| 网络与同步 | 飞行模式开启,关闭蓝牙、NFC、位置 | 后台同步与网络请求抢占 I/O 和 CPU,放大冷启动方差 | adb shell dumpsys connectivity 确认数据连接已断开 |
固定安装与首次启动状态:排除运行时编译对测量的干扰
加固通常会改变 dex 文件结构和 Native 库加载顺序,在首次安装后系统会触发 dex2oat 或 JIT 编译,产生额外的 CPU 与 I/O 负载。如果测量对象是安装后第一次冷启动,得到的时间几乎必然包含这部分一次性开销,不能代表用户日常启动体验。因此加固前后的冷启动测量必须排除首次安装后的编译状态,每个测试序列都应在应用程序已被完全编译并处于停止状态后执行。
实现真正可重复的冷启动需要以下步骤:通过 pm clear 清除包数据、am force-stop 强制停止应用进程,然后使用 adb shell am start-activity 或 Macrobenchmark 发起冷启动。pm clear 会删除所有运行时编译产物,因此清理后首轮冷启动将再次触发编译,故第一轮数据必须丢弃。后续轮次的编译文件已被重新生成并缓存,才能视为稳态冷启动数据。
对于带有动态加载模块或插件化架构的应用,加固还可能改变其第一次加载特定代码路径的成本。即使稳定迭代之后,若代码预热策略不同,加固前后的测量也不可直接比较。这种差异需要在基线构建阶段就识别出来,通过单独放置 warm-up 迭代或放宽首批迭代剔除数量来降低影响。若未采取此类措施,门禁便会错误地将预热阶段差异判定为性能退化。
- 首次安装后已运行至少一轮完整冷启动并完成强制停止
- 每次测量前执行 pm clear 与 am force-stop,并丢弃 PM Clear 后的第一轮数据
- 使用 dumpsys package 确认 dexopt 状态为 status=NO_DEXOPT_NEEDED
- 确认无后台进程持有应用组件锁,避免因 service 重启而变为温启动
样本身份与统计口径:为何分位数比均值更适合判定退化
Android 应用启动过程受进程调度、内存压力和 I/O 争用的影响,单次测量结果天然存在显著波动。若仅凭有限次数(如 5 次)的平均值进行加固前后对比,极易将正常的系统噪声误判为加固引入的劣化,或将真实的尾部退化隐藏在均值之下。在固定设备上跑大量迭代并看分位数,才能得到稳定信号。
项目中对 TTID/TTFD 的对比应当上报 P50、P90 和 P95 三项分位数。P50 反映典型冷启动感,P90 和 P95 则暴露在 CPU 竞争、GC 暂停等场景下用户是否明显感知到延迟。加固可能导致少数冷启动因额外校验或抗篡改逻辑而出现较长的尾部,若只对比 P50,这部分劣化会被遗漏;若只对比最大值,则又会被偶发系统卡顿污染。因此使用分位数能平衡敏感度与噪声。
异常值的剔除规则同样需要预先固定。工程上可以使用四分位距法:将所有冷启动样本按升序排列,低于 Q1,1.5×IQR 或高于 Q3+1.5×IQR 的值视为离群点。但必须注意,如果加固自身策略导致某些启动呈现稳定的长尾,这些点不应被排除。离群判定标准必须在门禁脚本中以环境变量形式注入,避免在代码中硬编码数值,并在报告中逐条记录被剔除的迭代及其原因,确保可审计。
| 统计指标 | 抵抗噪声能力 | 暴露尾部劣化能力 | 适用门禁判断 |
|---|---|---|---|
| 算术平均值 | 弱,易受极值影响 | 极弱,尾部被平均化 | 不适用于加固对比门禁 |
| 中位数 P50 | 强,典型值 | 弱,忽略大部分尾部 | 可作为参考,但不足以单独判定 |
| P90 | 较强,剔除极端点后的高阶分位 | 强,能捕捉多数用户可感知的缓慢启动 | 建议作为主判定指标之一 |
| P95 或 P99 | 受极值影响较大,需配合离群处理 | 最强,能暴露极少数的严重延迟 | 用于早期预警,不应设为主门禁,避免误拦截 |
使用 Macrobenchmark 捕获冷启动 TTID 与 TTFD 的标准场景
Macrobenchmark 库以独立测试进程运行,避免了被测应用进程中的任何监测开销对启动路径的干扰。启动场景定义为冷启动时,测试框架通过 am start-activity 触发指定 Activity,并利用 FrameMetrics API 和 Activity 生命周期回调捕获 TTID 与 TTFD 的时间点。这一设计符合 Android 官方关于启动指标的定义,并可在非 root 的 user 构建上重复运行。
要测量加固前后的冷启动,必须在同一个 Macrobenchmark 配置中固定 startupMode 为 COLD,且不得改变 compilationMode。如果加固导致 APK 签名或包结构变化,必须确保测试 APK 与基线 APK 处于相同签名和安装来源。Macrobenchmark 的迭代次数应通过 iterations 参数指定,同时配合 measureBlock 只测量目标 Activity 的启动,避免其他初始化路径的干扰。
输出 JSON 报告中提供了 timeToInitialDisplayMs 和 timeToFullDisplayMs 的原始值、分位数与百分位数组,但不会自动剔除因温控或后台干扰引起的极端值。因此,测试脚本必须额外采集 dumpsys thermalservice 和 dumpsys cpuinfo 的快照,在每次迭代结束时记录设备温度与负载,便于后处理时结合统计剔除规则进行清洗。缺少这些环境记录,就无法区分加固影响与硬件限频。
| 参数 | 取值建议 | 对 TTID/TTFD 的影响 | 固定要求 |
|---|---|---|---|
| startupMode | COLD | 决定启动类型,必须为冷启动才能代表用户从无到有的感知 | 加固前后必须一致 |
| compilationMode | 无特殊设置时使用系统默认 | 影响 dex 编译状态,若设为 Partial 或 Full 会改变预热粒度 | 基线构建阶段明确记录实际模式 |
| iterations | 不少于 20,具体根据方差收敛判断 | 迭代过少导致分位数估计不可靠 | 同一组对比必须使用相同迭代数 |
| measureBlock | 仅包含目标 Activity 的 startActivityAndWait() | 若包含额外操作会引入无关耗时 | 加固前后 measureBlock 代码不得变化 |
结果对比门禁:从原始数据到通过/失败判定的决策链
基线的构建必须在未加固的 APK 上,严格按照与加固后完全相同的设备、系统版本、安装状态、迭代次数和统计处理方法执行。基线数据一旦固定,应当与 APK 版本绑定存入版本控制,不得在后续测试中随意更新。任何基线更新都需要重新触发完整的测量流程并记录原因,否则基线的漂移会让门禁逐渐失效。
退化判定不能仅依赖一个指标超过另一个的绝对值,而应当基于分位数间比较和环境变量注入的退化容忍预算。例如,若当前测量的 P90 高出基线 P90 的某一比例(该比例由 MAX_P90_INCREASE_RATIO 环境变量控制),则认为冷启动体验发生不可接受的劣化。同时,也必须检查 P50 和 P95 的变化方向,如果 P50 改善但 P95 显著恶化,说明有尾部扩散,同样应失败,否则可能将劣化视为优化。
门禁应作为 CI/CD 流水线的一个强制阶段,在构建出加固包后自动触发。失败时流水线必须阻塞发布,并输出包含具体超限分位数、基线值与当前值的对比报告。成功时也应记录各项差值以积累长期趋势。门禁仅验证实验室条件,不能代表线上全量用户。
| 退化模式 | 判定依据 | 处理动作 | 边界说明 |
|---|---|---|---|
| P90 增长超过允许比例 | 当前 P90 > 基线 P90 × (1 + 容忍比例) | 流水线失败,阻止发布 | 容忍比例由环境变量提供,项目证据尚未接入 |
| P50 未恶化但 P95 增长超过容忍值 | 当前 P95 超限且 P90 也同步上升 | 流水线失败并标记为尾部扩散 | 需要排查加固是否引入偶发长时间校验 |
| P50 与 P90 均在容忍范围内,P95 超出 | 单一极端分位超限 | 发出警告但不阻断发布 | 检查是否由个别迭代的设备温控事件造成 |
| 所有分位数均未超限 | 满足门槛 | 允许通过 | 持续记录差异值,建立长期监控趋势 |
代码实现:自动化基线校验与退化报警脚本
本节紧随其后的 Bash 代码块使用 jq 读取 JSON 分位数,再由 bc 计算比例。输入缺失、变量未配置或结果越界时都会返回非零状态。运行前需要显式设置 MAX_P90_INCREASE_RATIO 和 MAX_P95_INCREASE_RATIO;示例没有替项目决定容忍值。
该脚本可以进一步扩展,加入 P50 检查、离群剔除规则应用以及将详细对比数据写入机器可读的 JSON 文件供外部系统消费。但在核心门禁逻辑上,保持输入仅限两个 JSON 文件和明确的容忍预算变量,能防止脚本与特定项目性能数据耦合,便于跨项目复用且不引入主观阈值。
在集成到 Gradle 或 CI 流程时,应将脚本调用紧跟在 Macrobenchmark 执行之后,并通过 Shell 测试退出来阻断后续的签名或分发步骤。脚本本身不发起任何测试执行,只专注于结果校验,便于在脱离完整工具链的离线场景下对历史数据进行回溯验证。
#!/bin/bash
set -euo pipefail
BASELINE="$1"
CURRENT="$2"
# 检查环境变量,未设置即退出
if [ -z "${MAX_P90_INCREASE_RATIO:-}" ]; then
echo "ERROR: MAX_P90_INCREASE_RATIO is not set. Define allowed increase ratio (e.g., 0.05)." >&2
exit 1
fi
if [ -z "${MAX_P95_INCREASE_RATIO:-}" ]; then
echo "ERROR: MAX_P95_INCREASE_RATIO is not set." >&2
exit 1
fi
# 检查输入文件
if [ ! -f "$BASELINE" ] || [ ! -f "$CURRENT" ]; then
echo "ERROR: Baseline or current JSON report not found." >&2
exit 1
fi
# 提取 P90 与 P95
p90_base=$(jq '.metrics.timeToFullDisplayMs.p90' "$BASELINE")
p90_curr=$(jq '.metrics.timeToFullDisplayMs.p90' "$CURRENT")
p95_base=$(jq '.metrics.timeToFullDisplayMs.p95' "$BASELINE")
p95_curr=$(jq '.metrics.timeToFullDisplayMs.p95' "$CURRENT")
if [ -z "$p90_base" ] || [ -z "$p90_curr" ]; then
echo "ERROR: Required percentiles missing in reports." >&2
exit 1
fi
# 计算增长比例
p90_ratio=$(echo "scale=6; $p90_curr / $p90_base - 1" | bc -l)
p95_ratio=$(echo "scale=6; $p95_curr / $p95_base - 1" | bc -l)
# 判定
bad=0
if (( $(echo "$p90_ratio > $MAX_P90_INCREASE_RATIO" | bc -l) )); then
echo "FAIL: P90 increase ${p90_ratio} exceeds limit ${MAX_P90_INCREASE_RATIO}." >&2
bad=1
fi
if (( $(echo "$p95_ratio > $MAX_P95_INCREASE_RATIO" | bc -l) )); then
echo "FAIL: P95 increase ${p95_ratio} exceeds limit ${MAX_P95_INCREASE_RATIO}." >&2
bad=1
fi
if [ "$bad" -eq 1 ]; then
exit 1
else
echo "PASS: P90 ratio=$p90_ratio, P95 ratio=$p95_ratio"
exit 0
fi结合 Android vitals 与云真机矩阵进行交叉验证
Macrobenchmark 的受控测量解决了可重复性问题,但其结论局限在单一设备与实验室环境中,不能直接代表分布在实际用户设备上的冷启动体验。Android vitals 提供了来自 Play 商店样本的启动时间、ANR 率与崩溃率的聚合数据,但由于样本仅来自同意分享的用户,且安装来源、设备型号分布均不可控,这些数据只能作为辅助信号。
Firebase Test Lab 提供的云真机矩阵可以补充多个设备型号和 OS 版本的覆盖,但其设备目录、稳定性及容量会动态变化,且虚拟设备的 ABI 和图形管线存在明确限制。尤其对于依赖 Native 加解密或反调试逻辑的加固方案,虚拟设备缺少真实的 TrustZone 或硬件密钥存储环境,有可能造成关键 Native 路径未被执行,导致误判为无性能退化。
建议分三层验证:先用受控 Macrobenchmark 冷启动作为发布门禁;再用 Test Lab 在选定的 3 至 5 台实体设备上执行同样的冷启动场景,检查是否存在规律性的设备相关退化;最后看 Android vitals 的启动分位数变化,并设置内部告警水位线。一旦受控门禁通过但线上信号出现异常偏移,必须立即冻结发布并回到受控实验室复现。
| 验证手段 | 数据来源 | 主要作用 | 局限性 |
|---|---|---|---|
| 受控 Macrobenchmark | 同一部设备、实验室固定条件 | 发布门禁,回答可归因劣化 | 不反映设备多样性和真实用户场景 |
| Firebase Test Lab 云真机 | 选定的实体设备,可配置 OS/语言 | 检测设备特定退化 | 设备容量可能变化,部分 Native 路径可能未覆盖 |
| Android vitals 线上指标 | Play 商店用户同意共享的聚合数据 | 观察真实世界的冷启动偏移 | 样本偏差大,无法拆分加固与其他变更的影响 |
| 团队设备回归矩阵 | 固定型号、系统版本与构建指纹的自有设备 | 复现云端异常并保留可调试的本地证据 | 覆盖面取决于实际设备库存,不能替代线上聚合观测 |
核查清单与常见导致无效结论的失败边界
在每次开启冷启动对比前,应执行系统性环境核查:确认设备刷入了固定系统镜像且签名一致;使用 dumpsys 检查电池温度与充电状态;确认飞行模式已启用、亮度手动固定;确认包管理器已执行过 clear 和 force-stop 并且 dexopt 状态表明无待编译任务。任何一项未满足,本次测量结果都不能进入对比数据集,否则产生的结论可能完全归因于环境因素。
常见导致结论失效的情况包括:未排除首次启动的 dex2oat 干扰,使得加固后的启动看起来比实际慢得多;设备在测试中途因后台任务或测温触发限频而在少数迭代中出现异常高值,却没有加温控标记或剔除;统计口径只使用平均值,使得加固造成的尾部劣化被稀释。任何一种情况都会让团队误判加固为性能劣化的元凶,或者遗漏真实的性能退化。
最后,无论门禁脚本设计得多严密,如果基线与当前测量的设备身份、安装状态、迭代数和数据清洗逻辑出现任何不一致,退化判定就不可归因。凡是无法在相同条件下重新生成基线的,都不应做出“加固造成性能变化”的结论。保持完整的测量环境记录与原始数据存档,是支撑后续审计与供应商沟通的关键手段。
- 设备已刷入指定 factory image,ro.build.fingerprint 与基线记录一致
- 电池温度 ≤32℃,充电线已断开
- 飞行模式开启,亮度手动固定且数值与基线测试一致
- 被测应用已 force-stop,并确认无后台服务驻留
- 首轮冷启动数据已从数据集中显式移除
- 所有迭代的温度快照与 CPU 负载快照已存入报告附件
- 环境变量 MAX_P90_INCREASE_RATIO 等已显式配置并记录在测试计划中
- 基线 JSON 与当前测试 JSON 均被哈希保存并上传至可审计存储
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 启动与关键路径对比应在独立测试进程和可重复设备条件下执行并记录迭代。 | Android Macrobenchmark | 基准框架不提供任何加固前后的预设性能结论。 |
| 冷启动应区分 TTID 与 TTFD,并记录启动状态和测量条件。 | Android app startup time | 单次启动耗时不能代表分位数或加固造成的因果变化。 |
| Android vitals 提供崩溃、ANR、启动和设备分布等线上质量信号。 | Android vitals | Play 样本受安装来源、用户同意和统计口径限制。 |
| 设备矩阵由型号、OS、方向和 locale 组成,任一执行失败会影响整矩阵判断。 | Firebase Test Lab Android matrices | 云真机矩阵仍需依据真实用户与最低支持范围选择。 |
| 设备目录、稳定性和容量会变化,测试计划应记录实际型号和 OS 回执。 | Test Lab available devices | 设备可用不代表它覆盖目标业务的全部厂商特性。 |
| 虚拟设备在 ABI、图形、Play Store 和旧 API 上存在明确限制。 | Firebase Test Lab AVD limits | 虚拟设备通过不能替代关键 Native 路径的物理设备验收。 |
| 独立测试进程可避免其他应用干扰测量结果。 | Android Macrobenchmark | 进程隔离不能消除系统服务或硬件调度引入的系统级噪声。 |
| 测量时必须记录温控、充电、亮度等设备条件。 | Android app startup time | 条件记录本身无法替代真实用户场景的所有变异。 |
工程常见问题
为什么不能只比较加固前后单次冷启动的时间?
单次冷启动受系统后台活动、CPU 调度和温度等因素影响,结果波动很大,不足以代表加固引入的真实变化。必须采用至少 20 次迭代并比较 P50、P90 等分位数,才能过滤噪声并暴露尾部劣化。
加固后首次安装的冷启动时间能直接对比吗?
不能。首次安装会触发 dex2oat 等一次性编译行为,产生额外耗时,这部分开销不代表日常使用场景。必须剔除首次安装后的第一轮冷启动数据,待编译缓存稳定后才开始采集有效样本。
门禁脚本中的容忍比例应该设多少?
本节代码块没有写死比例,而是从 MAX_P90_INCREASE_RATIO 等环境变量读取。实际值要参考同一设备和同一测试条件下的历史方差,再结合业务对启动延迟的容忍范围确定;当前没有项目历史数据,因此不提供统一阈值。
是否可以用虚拟机或模拟器来测量加固后的冷启动?
虚拟设备在 ABI、图形管线和硬件安全模块方面存在明确限制,无法覆盖加固中关键的 Native 加解密、反调试路径,因此只能作为辅助参考,不能替代物理真机上的受控测量。
如果门禁通过但线上 Android vitals 显示启动变慢怎么办?
门禁只证明在严格受控条件下未发现可归因的劣化,不能代表所有真实用户场景。此时应立即冻结发布,回到受控实验室尝试在更多设备条件下复现异常,并结合 vitals 的样本特征定位是否与特定设备或系统版本相关。
能否直接用 Play 后台的冷启动分位数作为加固前后对比依据?
不能。Play 数据样本受用户同意、安装来源和设备分布限制,且无法隔离其他应用或系统更新带来的影响。它适合作为长期监控信号,但不具备归因能力,不能替代固定条件测量。