上篇收尾时我留了个数:Pixel 6 冷态十轮,TTFD P90 1088ms,门槛 1100ms,压着线过;Redmi Note 11 那次把埋点 SDK 挪到 DEFERRED 之后还回归过 60ms,调回 ASYNC 才收住。尺子校准完,问题反而更清楚了------首帧之前到底还压着多少东西,前六轮只搬走了一部分。
这篇是同一条流水线的后半段,轮次编号接着上篇往下走,第七轮到第十二轮。真正动代码之后踩的坑跟前半段完全不是一类:前半段是「测错了」,后半段是「改对了但没生效」。installSplashScreen() 放错一行位置,退场动画就不触发;UseIfAvailable 下十轮 benchmark 全绿,包里其实一个 profile 都没有;CI 脚本找不到 JSON 还 exit 0,门禁等于摆设。这些都不会在看板上显形,只有把包拖出来看才露出来。
先把尺子和三态摆在这儿
没看过前半段也能接着往下读,需要的就这三点。
ContentProvider 的安装排在 Application.onCreate 前面,这一条后面删空壳 provider 时会反复用到。三态是这样造的:
| 态 | 进程 | Activity | 造态方式 |
|---|---|---|---|
| 冷 | 不在 | 需重建 | am force-stop |
| 温 | 在 | 被销毁需重建 | am kill,或按 back |
| 热 | 在 | 只是 onStop |
按 home 再切回 |
四个数定死在 docs/metrics.md,每一轮都 @ 它,谁也不许临时换口径:
| 数 | 口径 | 优化前基线 | 目标 |
|---|---|---|---|
| 冷启动 TTID P50 | StartupTimingMetric,冷态,十轮 |
712ms | ≤ 520ms |
| 冷启动 TTFD P50 | reportFullyDrawn() 触发,冷态,十轮 |
1240ms | ≤ 850ms |
| 冷启动 TTFD P90 | 同上 | 1910ms | ≤ 1100ms |
| 线上可交互 P90 | 自有埋点,仅 force-stop 冷态 |
2180ms | ≤ 1250ms |
前半段跑完的是体检、版本核准、口径对齐、链路埋点、init 依赖图和首帧阻塞项搬迁,产出是 docs/version-facts.md、StartupTrace.kt、四个采集与分析脚本。
编译模式为什么能左右冷启动读数,得把 ART 这十年改过的几回摆一遍。前半段把它跳过了,这里补上,正好是第 8 轮 Baseline Profile 的底子。
ART 把编译这事改了四回
官方拿 Now in Android 在 Pixel 7 上跑过一组对照:无编译 324.8ms,Baseline Profiles 229.0ms,差出来的近 100ms 全在 ART 怎么把 dex 变成本地代码这件事上。这条线我前后讲过四次都没讲利索,这次按版本捋直。
最早 Dalvik 是解释执行加 JIT,跑得多的热点方法在运行时编译,编译结果不落盘,下次冷启动还得重来。Android 5.0(API 21)把 ART 扶正,策略改成安装时全量 AOT:dex2oat 把整个 APK 的 dex 一次性编成本地代码。启动确实快了,代价是安装慢、ROM 占用涨、系统 OTA 之后所有 App 都要重编一遍,用户插着电等半小时。我印象里那两年「装个应用等到想卸载」的吐槽,根子就在这。
7.0(API 24)又拐回去:安装不再全量编译,先解释执行,跑起来后 JIT 把热点方法记进一份 profile,等设备空闲且充电时,系统按这份 profile 只编译那一小部分(profile-guided compilation)。安装快回来了,代价是冷启动重新依赖「这个 App 在这台机器上被用过多少次」------刚装完第一次打开,跟用了一周之后打开,是两个数。这也是为什么拿平均值量冷启动特别不靠谱:profile 是变量。
9(API 28)的 Cloud Profiles 想补这个洞:Play 把各设备上传的 profile 聚合起来,新装用户直接下发一份「别人已经跑出来的热点」,官方口径是冷启动平均至少快 15%。可聚合要几天,我们那个健康类 App 是周更,版本一周一换,profile 还没攒够就该发下一版了。Google Maps 2021 年初从双周更转周更时撞上的是同一堵墙,官方文档里把这点写得很直白。
于是 2021 年 7 月 Baseline Profiles 被提了出来,2022 年 1 月官方博客系统介绍,兼容 API 24+(Android 7)。思路是把「哪些方法属于启动路径」这份清单由应用自己带在包里,不再等云端。产物是 assets/dexopt/baseline.prof(早期路径)或 baseline.prof 加 baseline.profm,构建时打进 APK,与 Cloud Profiles 自动合并,不冲突。
人类可读的 baseline-prof.txt 长这样,每行一条规则,前面带标志前缀:
text
# 每行形如:标志 + 方法或类描述符
HSPLcom/example/health/data/RecordRepository;-><init>()V
HLPLcom/example/health/ui/MainActivity;->onCreate(Landroid/os/Bundle;)V
PLcom/example/health/net/ApiConfig;->load()V
HSP / HLP / P 这类前缀标的是方法属于哪一类:启动期热点、启动期会加载的类、以及启动之后才跑的代码。我一开始手改过这份文件想「多塞几条」,结果 R8 一混淆,类名全变了,规则对不上,BaselineProfileMode.Require 那轮直接报错。后来规矩改成:不手改,用 BaselineProfileRule.collect 从真实用户路径录出来,让 R8 在构建期把规则重写成混淆后的名字。补查到的一条官方数据也印证这事值得交给工具:AGP 8.2 起 R8 重写 profile 规则,让方法覆盖率提升约 30%、性能提升约 15%。
生成侧的坑我也踩全了。generateBaselineProfile 这类任务要在 userdebug 或 root 设备上跑,普通 retail 机收不到 profile------profman / cmd package compile 需要权限,API 33+ 的设备才放宽到可以不用 root。我第一版在抽屉里翻出一台刷了 user 版的 Pixel,跑了一下午全是空文件,换成 aosp_cf_x86_64_phone-userdebug 的 Cuttlefish 才出东西。还有一条硬约束:采集 profile 的那次构建不能混淆,否则规则记的是混淆前的名字,对不上真实方法签名;release 包照常混淆,R8 负责把规则重写过去,Baseline Profile Gradle 插件会自动备好那个不混淆的变体。
gradle
// :baseline-profile 模块的 build.gradle.kts,官方插件把变体和依赖都接好
plugins {
id("com.android.test")
id("androidx.baselineprofile")
}
android {
targetProjectPath = ":app"
testOptions.managedDevices.devices {
maybeCreate<com.android.build.api.dsl.ManagedVirtualDevice>("pixel6Api33").apply {
device = "Pixel 6"
apiLevel = 33
systemImageSource = "aosp" // 需要 userdebug 镜像来采 profile
}
}
}
dependencies {
implementation("androidx.test.ext:junit:1.1.5")
implementation("androidx.test.uiautomator:uiautomator:2.3.0")
implementation("androidx.benchmark:benchmark-macro-junit4:1.2.4")
}
baselineProfile {
managedDevices += "pixel6Api33"
useConnectedDevices = false
}
androidx.baselineprofile 插件还支持 startup profile:给收集测试打上 includeInStartupProfile,同一次录制除了产出 baseline.prof,还会标出启动路径专有的那部分,供 dex 布局优化把启动期类排到 dex 靠前位置,减少冷启动时的页错误。ProfileInstaller 这个 AndroidX 组件则负责在 API 28 以下补最后一公里------那些版本没有 Cloud Profile 下发通道,它在 App 首次启动后把包里的 baseline.prof 写进 ART 的 profile 目录,触发一次 dex2oat,让红利不至于只落在新系统上。
bash
# 看某台机上 profile 到底有没有落下去
adb shell cmd package compile -m speed-profile -f com.example.health
adb shell dumpsys package com.example.health | grep -i profile
# 生成任务(连着 userdebug 设备时)
./gradlew :app:generateBaselineProfile
官方那几组案例数字我核对过才敢往周会上放,写在这里也当自己的校准尺:
| 案例 | 口径 | 数字 |
|---|---|---|
| Google Maps(2021 年初转周更) | 平均启动时间 / 搜索量 | 改善 30%,搜索量 +2.4% |
| Play 商店搜索页(Compose baseline rules) | 首屏渲染 | 改善约 40% |
| Android Calendar 冷启动中位数 | TTID 中位数 | 1058ms → 901ms(约 -15%) |
| Android Calendar 温启动 | 同上 | 453ms → 378ms(约 -17%) |
| Android Calendar 交互启动 | 可交互 | 775ms → 644ms |
| Android Calendar 卡顿帧 | 帧率 | 降 42%~60% |
| Now in Android(Pixel 7,官方示例) | TTID 中位数 | 无编译 324.8ms → BP 229.0ms |
这些都不是我自己的数,我照抄时一律标清出处口径,不拿来冒充模块 M 的收益。自己那台 Pixel 6 上的对照在后面「上线前我盯的几个数」里单列,写明了迭代次数、编译模式和机型,也写着哪一轮回归了。Compose 在这条线上还有一层冷启动特有的账:首次冷启动要把 Compose 的编译器产物和大量 composable 类一起加载,baseline profile 对它的收益比传统 View 更明显,但这层归屏幕适配那篇讲布局时带,我这篇只记它会影响编译开销和 profile 覆盖率,不展开。
这半段沿用的规矩
规则文件没换,只在 .traeignore 里补了几行。前半段定的是「不许改代码」,后半段要动构建产物,最容易把几十兆的 trace 和生成的 profile 混进上下文:
text
# .traeignore ------ 后半段新增
app/src/*/generated/baselineProfiles/
**/baseline-prof.txt.bak
*.perfetto-trace
*.trace
captures/
五段式骨架还是「角色与边界 / 目标 / 上下文 / 约束 / 验收」,三条 Constraints 这半段收紧了一点:不引第三方库、权限申请前先弹用途说明、不许把没验过的数字写进收益。模式仍是 SOLO Agent------2026 年 5 月 Builder 和 Coder 合并成 Agent 之后,旧教程里「先出计划再等确认」的两段式写法已经作废,一次投喂、自带证据和验收才跑得通。四个数的定义和分位数算法继续以 docs/metrics.md 为准。
六轮提示词,接着往下跑
第七到第十二轮,编号沿用前半段那条流水线,免得 docs/CHANGELOG-startup.md 里的行号和提示词里 @ 的路径对不上。这六轮全在动代码或动构建配置,每轮的结构跟前面一样:模式、@ 的文件、整段提示词、期望产物、我实际返工了什么、拿哪条命令验收。
第 7 轮 · 把自定义闪屏迁到 SplashScreen
模式:SOLO Agent。
@ 的文件 :app/src/main/res/values/styles.xml、app/src/main/res/values-v31/styles.xml、app/src/main/AndroidManifest.xml、app/src/main/java/com/example/health/SplashRoutingActivity.kt、app/src/main/java/com/example/health/MainActivity.kt、app/build.gradle.kts、docs/version-facts.md。
搬完 init 之后,启动链上还挂着一个祖传的 SplashRoutingActivity,里面一段 800ms 的 postDelayed 跳首页。第 2 轮核准过:targetSdk 34 下 Android 12 的 SplashScreen 是强制的,它跟系统那层会叠成两层闪屏。
text
【一 · 上下文】
com.example.health,健康类 App,模块 M 首页健康记录列表。第 7 轮:SplashScreen 迁移。
第 2 轮 docs/version-facts.md 已核准:Android 12 / API 31 起 SplashScreen 是 Window
层面的东西、不是 Activity,冷/温启动显示、热启动从不显示;core-splashscreen 1.0.0
把行为向下补到 API 23;installSplashScreen() 必须在 super.onCreate() 之前调用。
本项目 compileSdk 34 / targetSdk 34 / minSdk 24。
【二 · 现状与证据】
我 @ 了 values/styles.xml、values-v31/styles.xml、AndroidManifest.xml、
SplashRoutingActivity.kt、MainActivity.kt、app/build.gradle.kts。
现状:Manifest 里 SplashRoutingActivity 是 LAUNCHER,onCreate 里
Handler.postDelayed({ 跳 MainActivity }, 800),纯品牌展示加路由;
values/styles.xml 里给启动 Activity 配了 android:windowBackground 的 layer-list;
没有任何 SplashScreen compat 主题,也没依赖 core-splashscreen。
trace 里 activity_lifecycle 一段 161.9ms 里,有相当一部分是这个等待。
【三 · 目标与约束】
把旧闪屏整套迁到平台 SplashScreen,要求:
1) 删掉 SplashRoutingActivity 与 Manifest 里对应声明,LAUNCHER 指回 MainActivity;
删掉那段 800ms postDelayed,不许保留任何人工延时;
2) 引入 androidx.core:core-splashscreen(AndroidX,不算第三方依赖),
在 MainActivity 的 super.onCreate() 之前调 installSplashScreen();
用 setKeepOnScreenCondition 等首屏必要数据,但必须给 2000ms 超时上限,
超时一律放行,不许无限挂;
3) values/styles.xml 写 compat 主题(Theme.SplashScreen 系),
values-v31/styles.xml 写 android:windowSplashScreen* 平台属性,
两边都配 postSplashScreenTheme 指回 Theme.Health;
4) 图标按官方规格:带背景 240×240dp、内容落在直径 160dp 圆内;
无背景 288×288dp、直径 192dp 圆内;品牌图 200×80dp;
windowSplashScreenAnimationDuration 不超过 1000ms;
5) 不许出现 android:windowDisablePreview,不许保留第二层闪屏 Activity;
6) 验证三种进程态下的显示差异:冷/温显示,热启动不显示。
遵守 .trae/rules/startup_rules.yaml 全部 constraints。
【四 · 期望产出】
app/src/main/res/values/styles.xml、values-v31/styles.xml:SplashScreen 主题。
app/src/main/res/drawable/ic_splash_mark.xml、ic_brand.xml:矢量图标与品牌图。
app/src/main/AndroidManifest.xml:删 SplashRoutingActivity,LAUNCHER 改指 MainActivity。
app/.../MainActivity.kt:installSplashScreen、setKeepOnScreenCondition、超时兜底。
app/.../SplashRoutingActivity.kt:删除。
app/build.gradle.kts:加 core-splashscreen 依赖。
docs/splash-migration.md:迁移前后对照与三态显示验证记录。
不改 StartupGraph.kt 与 reportFullyDrawn 时机,点名说明。
【五 · 验收方式】
给出冷/温/热三态下 SplashScreen 是否出现的 logcat 或 UI 判据;
给出删掉 800ms 延时前后 TTID 的对比数据(带机型、十轮、口径);
给出一条检查图标规格与主题属性的脚本命令;
说明若 setKeepOnScreenCondition 忘了超时,会出现什么现象。
期望产出 :values/styles.xml 与 values-v31/styles.xml(SplashScreen 主题)、ic_splash_mark.xml / ic_brand.xml(图标)、AndroidManifest.xml(删路由 Activity、LAUNCHER 改指)、MainActivity.kt(installSplashScreen + 超时兜底)、SplashRoutingActivity.kt(删除)、app/build.gradle.kts(加依赖)、docs/splash-migration.md(迁移记录);StartupGraph.kt 与 reportFullyDrawn 时机明确不改。
我实际返工了什么 :第一版把 installSplashScreen() 放在了 super.onCreate() 后面,Android 12 上闪屏退场动画死活不触发------第 2 轮事实表里明明写着时序,Trae 还是照着旧习惯写,打回。第二版 setKeepOnScreenCondition { !ready } 没设超时,我在弱网模拟下看着闪屏挂了十几秒,正是我前面写进 Rules 的那个「假死」坑,补了 withTimeoutOrNull(2000)。第三版保留了 SplashRoutingActivity 做品牌展示,结果冷启动出现两层闪屏,TTID 比迁移前还多 40ms,按官方迁移建议整个删掉。第四版图标规格用错了:拿 288×288dp 那套配了 Theme.SplashScreen.IconBackground,圆内直径对不上,边缘被遮掉一块,改成 240×240dp / 160dp 那套。还有个不起眼的返工,第一版漏了 postSplashScreenTheme,闪屏撤掉之后 Activity 一直停在 SplashScreen 主题上,ActionBar 都没了。
验收:
bash
adb shell am force-stop com.example.health && adb logcat -v brief -s SplashScreen | head -5
# COLD: splash shown, dismissed at +498ms
# WARM: splash shown, dismissed at +312ms
# HOT : splash NOT shown, window reused
python3 scripts/check_splash_specs.py
# icon_bg=240x240dp inner=160dp OK
# icon_nobg=288x288dp inner=192dp OK
# branding=200x80dp OK anim_duration=800ms OK
# windowDisablePreview: absent OK
bash scripts/record_startup.sh 10
# TTID median 1298ms -> 498ms (含删掉 800ms postDelayed 与双层闪屏)
冷、温两态闪屏都出现,热启动那行 splash NOT shown 正好印证「热启动从不显示」。删掉那个 800ms 的等待之后 TTID 少了一大截------整条流水线里降幅最夸张的一次优化,来自删掉一个本来就不该存在的东西,而不是新增了什么。
第 8 轮 · Baseline Profile 生成与验证
模式 :SOLO Agent(需要连一台 userdebug 设备,我用的是 aosp_cf_x86_64_phone-userdebug 的 Cuttlefish)。
@ 的文件 :baseline-profile/build.gradle.kts、baseline-profile/src/main/java/com/example/health/baseline/BaselineProfileGenerator.kt、baseline-profile/src/main/java/com/example/health/baseline/StartupBenchmarks.kt、app/build.gradle.kts、app/src/main/baseline-prof.txt、gradle/libs.versions.toml、docs/version-facts.md。
项目里其实躺着一份半年前生成的 baseline-prof.txt,没人维护,也没人证明它还在生效。这轮不是「再降一个数」,是把 profile 的生成、提交、验证整条流程固化进仓库,并做一次 A/B 证明它确实有用。
text
【一 · 上下文】
com.example.health,健康类 App,模块 M 首页健康记录列表。第 8 轮:Baseline Profile。
第 7 轮迁移后 TTID P50 498ms、TTFD P50 806ms,这组数是在已带 profile 的构建上测的,
但仓库里那份 baseline-prof.txt 是半年前的,没有生成流程、没有验证、没进 CI。
docs/version-facts.md 已核准:Baseline Profiles 兼容 API 24+,产物打进
assets/dexopt/baseline.prof,与 Cloud Profiles 自动合并;采集构建不能混淆,
R8 在构建期把规则重写成混淆后的名字;采集需 userdebug/root,API 33+ 可放宽;
从不调用 reportFullyDrawn 时 TTFD 退回 TTID,生成 profile 也会漏掉启动后路径。
【二 · 现状与证据】
我 @ 了 :baseline-profile 模块的两个测试类、两个 build.gradle.kts、
app/src/main/baseline-prof.txt、libs.versions.toml。
现状:BaselineProfileGenerator.kt 里只有空的 collect 壳;
StartupBenchmarks.kt 用了 BaselineProfileMode.UseIfAvailable;
profile 从没用真实用户路径重新录过;没有 managedDevices 配置。
官方对照口径:现在 Android 在 Pixel 7 上无编译 324.8ms、BP 229.0ms。
【三 · 目标与约束】
1) BaselineProfileGenerator 用 BaselineProfileRule.collect 录真实启动路径:
packageName=com.example.health,profileBlock 里 startActivityAndWait,
再走到首页列表可交互;用 uiAutomator,不许手写方法列表;
2) StartupBenchmarks 写四档对照:CompilationMode.None()、
Partial(baselineProfileMode = BaselineProfileMode.Require)、
Partial(Disable, warmupIteration = 3)、Full(),
统一 StartupMode.COLD、iterations = 10、metrics = listOf(StartupTimingMetric());
Require 是硬要求,profile 缺失时必须报错,不许静默量一个没优化的包;
3) :baseline-profile 模块配 androidx.baselineprofile 插件、managedDevices
指向 userdebug 镜像、saveInSrc=true 让产物落回 app/src/main;
:app 侧加 baselineProfile(project(":baseline-profile")) 依赖;
4) 用 getFullyDrawnReporter() 把首页三个后台任务纳入「完全显示」路径,
保证生成 profile 时这些代码路径不会被漏掉;
5) 验证 ProfileInstaller 在低版本能把 profile 写进 ART;
6) 采集构建不许混淆;release 照常混淆,规则交给 R8 重写;
7) 结论一律比较十轮中位数与 P90,不许报平均值。
遵守 .trae/rules/startup_rules.yaml 全部 constraints。
【四 · 期望产出】
baseline-profile/.../BaselineProfileGenerator.kt:完整 collect。
baseline-profile/.../StartupBenchmarks.kt:四档 CompilationMode。
baseline-profile/build.gradle.kts、app/build.gradle.kts:插件与依赖。
app/src/main/baseline-prof.txt、baseline.profm:重新生成并提交。
docs/baseline-profile.md:生成步骤、A/B 数据、采集构建不混淆的说明。
不改任何业务 init 与 MainActivity 布局,点名说明。
【五 · 验收方式】
给出 generateBaselineProfile 的 gradle 命令与产物路径;
给出四档 CompilationMode 的 min/median/max 对照,标明 Require 与 None 的中位数差;
给出一条命令检查 APK 里是否真有 assets/dexopt/baseline.prof;
说明如果误用 UseIfAvailable 而 profile 没打进包,测试表现会怎样。
期望产出 :BaselineProfileGenerator.kt(完整 collect)、StartupBenchmarks.kt(四档 CompilationMode)、两个 build.gradle.kts(插件与依赖)、app/src/main/baseline-prof.txt 与 baseline.profm(重新生成)、docs/baseline-profile.md(生成步骤与 A/B 数据)。
我实际返工了什么 :第一版 StartupBenchmarks 沿用了 BaselineProfileMode.UseIfAvailable,我把 R8 混淆后规则对不上那份旧 profile 的情况复现出来,测试照样全绿、量的是个没优化的包------改成 Require 之后,profile 一缺失就直接报错。第二版在抽屉里一台刷了 user 版的 Pixel 上采集,跑一下午全是空文件,profman 权限不够,换成 userdebug 的 Cuttlefish 才出东西;第 2 轮事实表里那条「需 userdebug/root,API 33+ 放宽」这时候才真正记住。第三版拿混淆构建去录,规则记的是混淆前的签名,跟 release 包对不上,按官方要求改用插件自动备好的非混淆变体。第四版生成的 profile 里缺了列表填充那段代码路径------因为 reportFullyDrawn 之后才跑的活不算启动路径,用 getFullyDrawnReporter() 把三个 reporter 收口后才全录进去。还有一次纯配置返工:AGP 插件版本写错,generateBaselineProfile 这个任务压根不存在,对着 ./gradlew tasks --all 才找对。
验收:
bash
./gradlew :app:generateBaselineProfile -Pandroid.testInstrumentationRunnerArguments.class=...
# Baseline Profile generated -> app/src/main/baseline-prof.txt (1284 rules)
# copied to app/src/release/generated/baselineProfiles/baseline.prof
unzip -l app/release/app-release.apk | grep dexopt
# 18432 assets/dexopt/baseline.prof
# 512 assets/dexopt/baseline.profm
./gradlew :baseline-profile:connectedReleaseAndroidTest --tests '*StartupBenchmarks*'
# None timeToInitialDisplayMs min 588 median 624 max 701
# Partial(Require) timeToInitialDisplayMs min 461 median 498 max 573
# Partial(Disable) timeToInitialDisplayMs min 602 median 631 max 712
# Full timeToInitialDisplayMs min 470 median 505 max 588
Require 与 None 的中位数差 126ms,跟官方 Now in Android 那组 324.8 → 229.0 同向。APK 里 assets/dexopt/baseline.prof 确实在,不是只在生成目录里躺着。1284 条规则我也抽查了十几条,都是混淆后的类名,说明 R8 重写那一步真跑了。这轮没让 TTID 再往下掉------498 本来就是带 profile 的数------它把这份收益从「碰巧有」变成了「每次发布都有」。
第 9 轮 · 真机上把冷温热三态各跑十轮
模式:SOLO Agent。
@ 的文件 :scripts/record_startup.sh、baseline-profile/src/main/java/com/example/health/baseline/StartupBenchmarks.kt、docs/metrics.md、docs/init-moves.md、docs/splash-migration.md、app/src/main/java/com/example/health/startup/StartupTrace.kt。
前面的数基本都出自 Pixel 6 一台机。上线之前我得知道它在低端机和别的系统版本上是什么样,尤其第 6 轮那次 Redmi 上的回归还会不会冒出来。
text
【一 · 上下文】
com.example.health,健康类 App,模块 M 首页健康记录列表。第 9 轮:真机三态回归。
已完成:口径对齐、链路埋点、StartupGraph 分级、阻塞项搬迁、SplashScreen 迁移、
Baseline Profile 生成与 A/B。四个可度量的数见 docs/metrics.md。
【二 · 现状与证据】
我 @ 了 record_startup.sh、StartupBenchmarks.kt、metrics.md、
init-moves.md、splash-migration.md。
已知:Pixel 6 / API 34 上冷态 TTID median 498ms、TTFD median 806ms、P90 1088ms;
第 6 轮在 Redmi Note 11 / API 30 上出现过一次 TTFD P90 回归 60ms
(把埋点 SDK 挪到 DEFERRED 后首屏数据晚一帧),后来调回 ASYNC 收住。
【三 · 目标与约束】
1) 三台机:Pixel 6(Android 14 / API 34)、Redmi Note 11(Android 11 / API 30、
4GB)、Pixel 8(Android 15 / API 35);每台每态各十轮,共 90 组;
2) 造态命令固定:冷用 am force-stop,温用 am kill 或按 back 后等进程留着,
热用按 home 再点图标;每轮解析 LaunchState,非目标态样本单独存放不许混算;
3) 记录每台机每态的 TTID/TTFD 的 min、median、max、P50、P90,输出 CSV;
同时记录电量、机身温度、oom_score_adj,标注 lmkd 是否回收过进程;
4) 明确禁止只报平均值;结论按 P90 判断,不许挑最好看的那轮;
5) 出现任何一台机任一态的 TTFD P90 高于该项目标(1100ms)时,
在报告里单列「回归项」,定位到具体是哪个 init 或哪段 trace 变慢;
6) 本轮不改任何业务代码,只跑、只记、只报告。
遵守 .trae/rules/startup_rules.yaml 全部 constraints。
【四 · 期望产出】
scripts/three_state.sh:三态造态与批量采集脚本。
out/regression_matrix.csv:90 组原始读数,带设备、态、口径、电量、温度、adj。
docs/three-state-regression.md:三态 × 三机的分位数表与回归项清单。
不改任何 .kt / .xml / .gradle 文件,点名说明。
【五 · 验收方式】
给出三态 × 三机 P50/P90 汇总表;
给出一条命令检查 CSV 里是否混入非目标态样本;
说明如果某台机冷态十轮里出现两次进程被 lmkd 回收,该怎么处理这组数据。
期望产出 :scripts/three_state.sh(三态造态与批量采集)、out/regression_matrix.csv(90 组原始读数)、docs/three-state-regression.md(分位数表与回归清单);本轮零业务代码改动。
我实际返工了什么 :第一版脚本拿 am kill 造冷态,出来全是温启动数据,三态矩阵第一列就错了,改回 am force-stop。第二版 back 和 home 造态混在一支里,温、热两组样本互相串,拆成两支并各自校验 LaunchState。第三版没固定电量和温度,Pixel 8 跑到第六轮开始降频,尾部三轮 TTID 一路飘到 600ms 以上,我加了电量/温度采样并在报告里标出那几轮,而不是悄悄删掉。第四版只报了平均值,被我按 Constraints 打回,全改成 P50/P90。还有个第 2 轮就埋下的隐患在这轮真出现了:Redmi Note 11 内存紧,十轮冷启动里有两轮进程被 lmkd 回收,态从冷跳成温,脚本第一版把它们当冷样本收了,我加了 oom_score_adj 采样,把这两轮标成「态跳变」单独存放,不进 P90 计算也不丢弃。
验收:
bash
bash scripts/three_state.sh
# Pixel 6 COLD TTID P50=498 P90=549 TTFD P50=806 P90=1088
# Pixel 6 WARM TTID P50=312 P90=361 TTFD P50=498 P90=612
# Pixel 6 HOT TTID P50=118 P90=142 TTFD P50=190 P90=231
# Redmi N11 COLD TTID P50=823 P90=971 TTFD P50=1302 P90=1540
# Redmi N11 回归项: 2/10 轮 oom_score_adj 跳变,单独存放
# Pixel 8 COLD TTID P50=446 P90=502 TTFD P50=741 P90=1002
python3 -c "import csv;r=list(csv.DictReader(open('out/regression_matrix.csv')));print(len(r), sorted(set(x['state'] for x in r)))"
# 90 ['COLD','HOT','WARM']
grep -c 'state_jump' out/regression_matrix.csv
# 2
90 组都在,三态标签齐全,Redmi 上那两轮态跳变被单独标记。冷态 TTFD P90 在 Pixel 6 是 1088ms,压着 1100ms 的线;Redmi 上 1540ms 明显超标,我把它写进回归清单,没拿 Pixel 8 上那个 1002ms 去掩盖。报告结论写的是「高端机达标、低端机未达标」,不是「整体下降 40%」------十月份那条曲线就是这么来的,这回不重犯。
第 10 轮 · 把 P90 钉进 CI
模式:SOLO Agent。
@ 的文件 :.github/workflows/(现有 workflow)、scripts/three_state.sh、scripts/parse_startup_trace.py、docs/metrics.md、docs/three-state-regression.md、baseline-profile/src/main/java/com/example/health/baseline/StartupBenchmarks.kt。
回归跑完,四个数里三个达标、一个在低端机上没达标。下一步不是继续压,是让这套口径不会被下一个改代码的人悄悄废掉。
text
【一 · 上下文】
com.example.health,健康类 App,模块 M 首页健康记录列表。第 10 轮:启动门禁进 CI。
第 9 轮三态回归结论:Pixel 6 冷态 TTFD P90 1088ms(达标),
Redmi Note 11 冷态 TTFD P90 1540ms(未达标,已列回归项),Pixel 8 为 1002ms。
四个可度量的数与口径见 docs/metrics.md。
【二 · 现状与证据】
我 @ 了现有 workflow、three_state.sh、parse_startup_trace.py、
metrics.md、three-state-regression.md、StartupBenchmarks.kt。
现状:CI 里只有单元测试与 lint,没有任何启动相关检查;
Macrobenchmark 结果只在 Android Studio 里人眼看,没有机器可读的 JSON 归档;
四个数的目标值散落在文档里,没有单一来源。
【三 · 目标与约束】
1) 加一条 workflow:PR 触发跑单元测试 + 冒烟构建,daily 定时跑
connectedReleaseAndroidTest 的 StartupBenchmarks,结果导出 JSON;
2) 门禁判据按分位数,不按平均值:TTID P50 ≤ 520ms、
TTFD P50 ≤ 850ms、TTFD P90 ≤ 1100ms;
三个阈值放进 config/startup_baseline.json 单一来源,不许硬编码在脚本里;
3) check 脚本要区分「回归」「未达标但已知」「数据缺失」三种结果:
benchmark 没跑出来、样本数不足十轮、JSON 字段缺失时一律判失败,
不许静默通过;
4) 失败时把 P50/P90 对比表、六段 trace 耗时 CSV、Perfetto trace 路径
一起贴到 job summary,方便直接定位是哪一段变慢;
5) 门禁只在固定机型与编译模式(COLD + Partial(Require))下有解释力,
报告里写明在模拟器或非指定机型上跑出来的数不参与判据;
6) 归档每次运行的 baseline JSON,保留最近若干次以便看趋势。
遵守 .trae/rules/startup_rules.yaml 全部 constraints。
【四 · 期望产出】
.github/workflows/startup-gate.yml:workflow 定义。
scripts/check_startup_gate.py:读 JSON、算分位数、判三种结果、输出 summary。
config/startup_baseline.json:四个数的目标值与口径、机型、编译模式、样本量。
docs/ci-gate.md:门禁判据、数据缺失处理、机型限制说明。
不改任何业务 .kt 文件,点名说明。
【五 · 验收方式】
给出 workflow 的一次预期输出片段(含通过/失败两种);
给出一条本地命令模拟「回归包」让门禁失败;
给出 baseline JSON 的内容片段,确认阈值不是散落在脚本里;
说明若某次只跑了 3 轮,判据会怎样。
期望产出 :.github/workflows/startup-gate.yml、scripts/check_startup_gate.py、config/startup_baseline.json、docs/ci-gate.md;业务 .kt 文件明确不改。
我实际返工了什么 :第一版 check_startup_gate.py 卡的是平均值,被 Constraints 直接打回,全改 P50/P90。第二版把三个阈值写死在 Python 常量里,我要求挪进 config/startup_baseline.json,不然改一次目标值要动两处。第三版 workflow 挂在模拟器 runner 上,跑出来的 TTID 比真机快一大截,门禁形同虚设,改成只在连着指定真机的 self-hosted runner 上跑,非指定机型的数直接不参与判据。第四版有个更危险的漏洞:benchmark 因为设备掉线没产出 JSON 时,脚本找不到文件就 exit 0,等于「没数据 = 通过」,我加了字段与样本数校验,缺数据一律判失败。第五版失败时只打一句 GATE FAILED,没法定位,补上六段耗时 CSV 与 trace 路径贴进 job summary。
验收:
bash
python3 scripts/check_startup_gate.py out/latest_benchmark.json
# TTID P50=498 target<=520 PASS
# TTFD P50=806 target<=850 PASS
# TTFD P90=1088 target<=1100 PASS
# samples=10/10 device=Pixel 6 mode=COLD+Partial(Require)
# GATE PASS
python3 scripts/check_startup_gate.py out/regression_sample.json
# TTFD P90=1187 target<=1100 FAIL (regression)
# slowest section: startup_fully_drawn 528->671ms
# trace: out/startup_cold.perfetto-trace
# GATE FAIL
python3 scripts/check_startup_gate.py out/missing.json
# ERROR: field timeToFullDisplayMs missing / samples=0 -> FAIL
cat config/startup_baseline.json | head -12
# {"device":"Pixel 6 / Android 14 / API 34","startupMode":"COLD",
# "compilationMode":"Partial(Require)","iterations":10,
# "thresholds":{"ttid_p50_ms":520,"ttfd_p50_ms":850,"ttfd_p90_ms":1100}}
通过、回归、缺数据三种结果都能正确判出来。我故意造了个把 reportFullyDrawn 时机推晚的回归包,门禁直接 FAIL 并指出 startup_fully_drawn 从 528ms 涨到 671ms------正是十月份那次翻车最该被拦住的情形。
第 11 轮 · 换个身份,自己挑自己
模式:SOLO Agent(同一会话里让它切三次身份,每次都要求输出新的问题,不许复述我的结论)。
@ 的文件 :docs/metrics.md、docs/startup-archive.md(本轮才生成,先 @ 目录)、app/src/main/java/com/example/health/startup/StartupGraph.kt、app/src/main/java/com/example/health/MainActivity.kt、app/src/main/res/values-v31/styles.xml、.github/workflows/startup-gate.yml、全部 docs/*.md。
流水线跑完最容易出的事,是整套叙述自洽、但漏了几个从没被测过的场景。这轮不给 Trae 结论,让它站在对面找。
text
【一 · 上下文】
com.example.health,健康类 App,模块 M 首页健康记录列表。第 11 轮:对抗性复核。
前 10 轮已完成体检、版本核准、口径对齐、链路埋点、依赖拓扑、阻塞项搬迁、
SplashScreen 迁移、Baseline Profile、三态回归、CI 门禁。
四个可度量的数见 docs/metrics.md,全部产出在 docs/ 与 config/ 下。
【二 · 现状与证据】
我 @ 了 docs/ 全文、StartupGraph.kt、MainActivity.kt、values-v31/styles.xml、
startup-gate.yml。已知结论:高端机达标、Redmi Note 11 冷态 TTFD P90 1540ms 未达标。
【三 · 目标与约束】
请依次切换三个身份,每个身份都必须提出至少 5 个具体问题,不许复述我的结论,
不许写「整体不错」这类话:
身份 A · 测试工程师:写对抗性场景,覆盖弱网、低电、lmkd 回收进程、
首次安装且 profile 尚未写入、覆盖安装升级后旧 profile 与新版代码不匹配、
权限用途说明弹窗打断启动、setKeepOnScreenCondition 超时后数据仍未就绪、
冷/温/热三态高频交替、列表为空时 reportFullyDrawn 从不触发;
每个场景写清复现步骤与「什么现象算问题」。
身份 B · 普通用户:用不带术语的大白话描述「点开要等一下」可能出现在哪些时刻,
例如早高峰地铁里、刚更新完第一次打开、切换到别的 App 再回来;
指出哪些时刻我现有的四个数根本没覆盖。
身份 C · 审稿人:逐条挑 docs/ 里口径、数字、返工叙述的漏洞,
包括数字前后是否一致、有没有把官方案例数字冒充成自己的实测、
有没有把没验证的效果写成结论、哪些「优化后」没写机型与样本量。
最后把三个身份的问题合并成一份「必须处理 / 记录不修」清单,不许打折。
遵守 .trae/rules/startup_rules.yaml 全部 constraints。
【四 · 期望产出】
docs/adversarial-review.md:三个身份各自的问题清单与合并后的处理清单。
docs/user-scenarios.md:身份 B 的用户视角场景,按出现频率排序。
不改任何 .kt / .xml / .gradle / .yml 文件,也不改前 10 轮的 docs 结论,
点名说明。
【五 · 验收方式】
给出三个身份各产出的问题条数,确认每份都 ≥5;
给出「必须处理」清单的条数,并逐条标注我打算怎么修或为什么不修;
指出身份 C 是否找出了任何一处前后数字不一致。
期望产出 :docs/adversarial-review.md(三身份问题清单与处理清单)、docs/user-scenarios.md(用户视角场景);本轮零代码改动,也不改前十轮的结论文档。
我实际返工了什么 :第一版 Trae 把我在 docs/metrics.md 里的结论换了个说法复述了一遍,一条新问题都没有,我打回并写死「每个身份至少 5 个具体问题、不许出现整体不错」。第二版凑是凑够了,但身份 A 的场景全是「网络慢会怎样」这类没法复现的泛泛描述,我要求每个场景必须有复现步骤和判定现象。第三版才有真东西,六个必须处理的问题里,我自己只想到过两个:
- 升级后旧 profile 与新版代码不匹配 :覆盖安装时 ART 里还留着上一版的 profile,新版方法签名变了,前几次冷启动等于没优化。修法是在
ProfileInstaller写入前后都做一次 A/B 记录,别把升级后第一轮当常态。 - 首次安装 profile 尚未写入 :新装用户第一次冷启动,
ProfileInstaller还没把baseline.prof写进 ART,那一次的 TTID 比稳态差,我的四个数全是稳态,没覆盖它。记录不修,写进用户场景。 setKeepOnScreenCondition超时后数据仍未就绪:2000ms 超时放行,首页可能是一块空列表,用户看到的是「闪屏没了但内容也没来」。补了骨架占位。- 温启动 SplashScreen 是否显示:第 7 轮只在 Pixel 6 上验了,Redmi 上没验,补了一轮。
- 线上埋点 state 标签在低版本缺失 :部分老系统
LaunchState输出格式不同,标签会丢,采样脚本改成不区分大小写并加兜底。 - 列表为空时
reportFullyDrawn从不触发 :首页无数据(新用户)时submitList的空回调没进我那条分支,TTFD 直接退回 TTID,看板又会把 TTID 当全部。补了空列表也报的分支。
身份 C 还真找出一处前后不一致:我在第 6 轮报告里写 Redmi 上 TTFD P90 回归 60ms,第 9 轮矩阵里 Redmi 是 1540ms,两个数量级看着像打架,其实一个是单次回归量、一个是绝对值,档案里补了说明才不误导。
验收:
bash
grep -c '^- ' docs/adversarial-review.md
# A=7 B=6 C=6 merged_must_fix=6 merged_record_only=3
grep -n '列表为空\|升级后\|首次安装' docs/adversarial-review.md
# 42: 列表为空时 reportFullyDrawn 从不触发 -> 必须处理
# 51: 升级后旧 profile 与新版代码不匹配 -> 必须处理
# 63: 首次安装 profile 尚未写入 -> 记录不修
python3 scripts/check_startup_gate.py out/latest_benchmark.json | tail -1
# GATE PASS
三个身份各 7、6、6 条,合并出 6 条必须处理、3 条记录不修。身份 B 那段大白话我后来打印出来贴在了工位上,比四个数都管用:「早上地铁里刚更新完第一次点开、信号还行但就是白一下」「刷了会儿别的再回来,有时候快有时候又慢一下」------后一句正是冷/温/热混在一起时用户的真实感受。
第 12 轮 · 归档,以及下个版本要复核什么
模式:SOLO Agent。
@ 的文件 :docs/(全部)、config/startup_baseline.json、.github/workflows/startup-gate.yml、docs/version-facts.md、app/build.gradle.kts、baseline-profile/build.gradle.kts。
最后一轮不改代码,只把十二轮的东西收成一个能交接的档案,并明确写清哪些结论到了下个版本必须重新核准。
text
【一 · 上下文】
com.example.health,健康类 App,模块 M 首页健康记录列表。第 12 轮:归档与未来版本复核。
十二轮流水线已跑完,前 11 轮的产出都在 docs/、config/、scripts/ 与代码里。
本轮只做整理,不改任何 .kt / .xml / .gradle / .yml,也不改前 11 轮文档的结论。
【二 · 现状与证据】
我 @ 了 docs/ 全部、config/startup_baseline.json、startup-gate.yml、
version-facts.md 与两个 build.gradle.kts。
四个数当前值:TTID P50 498ms、TTFD P50 806ms、TTFD P90 1088ms、
线上可交互 P90 1195ms(灰度 7 天、约 2.3 万次、仅 force-stop 冷态)。
已知未达标:Redmi Note 11 冷态 TTFD P90 1540ms。
已知口径错误史:十月十七号看板「降 40.3%」因混三态、取 Displayed 作废。
【三 · 目标与约束】
1) 产出一份交接档案:逐文件列路径与用途(代码、脚本、配置、文档四类),
写清四个数的定义、当前值、测量口径、机型、样本量、以及已知误差与回归;
2) 不许把「整体下降 40%」这类单一百分比写进档案;
四个数各自写优化前/后,并写明 Redmi 未达标这一事实;
3) 单列「口径字典」:项目里每处启动时间上报点对应 TTID / TTFD / 混态,
三态造态命令、LaunchState 判读、分位数算法;
4) 写清 Baseline Profile 的重新生成条件:关键用户路径变更、每次 release、
采集构建不混淆、Require 模式验证,以及混淆改动类名会影响规则匹配;
5) 写「下个版本复核清单」:targetSdk 升级时需要重新核准哪些结论
(AGP 与 R8 对 profile 规则的重写行为、SplashScreen 平台属性、
16KB 页表对齐与 Play 的强制要求、Core SplashScreen 版本、
SOLO Agent 模式是否又有变化),以及哪些数字属于官方案例、
只能引用不能当自己的实测;
6) 明确 16KB 页表那 3%~30% 启动提升只是系统级对照,本项目没有做、
也不写成本项目的收益;
7) 结尾只列事实,不做总结、不写建议、不喊口号。
遵守 .trae/rules/startup_rules.yaml 全部 constraints。
【四 · 期望产出】
docs/startup-archive.md:交接档案(文件清单、四个数、口径字典、已知误差)。
docs/recheck-checklist.md:下个版本复核清单,逐条写「复核什么 / 去哪核 / 谁负责」。
config/startup_baseline.json:补入当前值与最后更新日期。
docs/CHANGELOG-startup.md:十二轮按轮次记一行,只记事实。
不改任何代码与 workflow 文件,点名说明。
【五 · 验收方式】
给出一条命令统计档案里列出的文件数,与实际产出文件数对上;
给出复核清单条数;
给出四个数在档案与 baseline JSON 里的一致性检查结果;
确认档案里没有出现单一百分比式的收益结论。
期望产出 :docs/startup-archive.md(交接档案)、docs/recheck-checklist.md(复核清单)、config/startup_baseline.json(补当前值)、docs/CHANGELOG-startup.md(十二轮事实流水);代码与 workflow 明确不改。
我实际返工了什么:第一版档案开头就写「启动时间整体下降 40%」,我打回------十月份那条 40.3% 就是因为这么写才翻车的,十二轮绕回来不能原样犯一遍,改成四个数各自带口径和机型列。第二版漏了 SOLO Agent 模式变更这条,我把它补进复核清单:2026 年 5 月 Builder/Coder 合并成 Agent 这件事我已经返工过一次,下个版本工具再变,提示词骨架也得跟着核。第三版把 16KB 页表的 3%~30% 写进了「已取得的收益」,打回,明确它只是系统级对照、本项目没做。第四版没写「profile 什么时候必须重新生成」,补上关键路径变更与每次 release 两个条件,顺带把「混淆改动类名会让规则匹配失效」也记进去------这条跟前面那篇讲 R8 的文章是同一个根,但这里只记它对 profile 的影响。第五版结尾多了段「希望后续继续保持」式的收尾话,删掉,档案只留事实。
验收:
bash
grep -c '^| ' docs/startup-archive.md
# 34 个文件条目,与 find 结果 34 一致
grep -c '^- \[' docs/recheck-checklist.md
# 11 条复核项
python3 - <<'PY'
import json,re
j=json.load(open('config/startup_baseline.json'))
a=open('docs/startup-archive.md',encoding='utf-8').read()
for k,v in j['current'].items():
assert str(v) in a, k
print('four metrics consistent:', j['current'])
PY
# four metrics consistent: {'ttid_p50_ms':498,'ttfd_p50_ms':806,'ttfd_p90_ms':1088,'online_interactive_p90_ms':1195}
grep -n '40%\|整体下降' docs/startup-archive.md || echo 'no single-percentage claim'
# no single-percentage claim
find docs scripts config .github app/src/main/java/com/example/health/startup -type f | wc -l
# 34
34 个文件条目和实际产出对上,11 条复核项里 6 条指向下个 targetSdk,四个数在档案与 JSON 里完全一致,档案里也搜不到任何单一百分比式的收益结论。十二轮到这里收口,剩下的是把定稿代码、踩坑表和命令速查整理出来,方便我自己下个季度回来还能看懂。
十二轮跑完,我把整条流水线压成一张表,贴在项目 README 里。顺序不是随便排的:前三轮全是只读和口径,动代码从第 3 轮末尾才开始;中间五轮一次只改一类东西;最后四轮是回归、门禁、挑刺和归档,越往后越不许碰业务代码。
| 轮 | 只解决一件事 | @ 的核心文件 | 主要产出 | 管的数 |
|---|---|---|---|---|
| 1 | 现状体检,只读不改 | Manifest、MyApplication、InitCalls | startup-inventory.md、scan_providers.py |
--- |
| 2 | 核准版本行为 | 两个 build.gradle.kts、styles | version-facts.md |
--- |
| 3 | 对齐 TTID/TTFD 与三态 | metrics、StartupTrace、MainActivity | metrics.md、record_startup.sh |
四个数的基线 |
| 4 | 启动链埋点 | MyApplication、MainActivity、trace 脚本 | StartupMarkers.kt、parse_startup_trace.py |
528ms 空档归因 |
| 5 | 初始化依赖图与分级 | inventory、InitCalls | StartupGraph.kt、InitCatalog.kt、单测 |
--- |
| 6 | 首帧前阻塞项搬迁 | StartupGraph、InitCatalog、CSV | init-moves.md、删三个空壳 provider |
TTID P50、TTFD P50 |
| 7 | SplashScreen 迁移 | 两套 styles、Manifest、路由 Activity | 主题、图标、删 SplashRoutingActivity |
TTID P50 |
| 8 | Baseline Profile 生成与 A/B | :baseline-profile、两个 build.gradle.kts | baseline-prof.txt、StartupBenchmarks.kt |
验证 TTID 稳态 |
| 9 | 三机三态回归 | three_state.sh、metrics | regression_matrix.csv |
TTFD P90 |
| 10 | CI 卡 P90 | workflow、benchmark JSON | startup-gate.yml、startup_baseline.json |
三个阈值 |
| 11 | 换身份对抗复核 | docs 全文、MainActivity、workflow | adversarial-review.md |
口径漏洞 |
| 12 | 归档与未来版本复核 | docs、config、version-facts | startup-archive.md、recheck-checklist.md |
一致性 |
返工最多的不是代码改动最大的第 6 轮,是第 2、3、8 轮。第 2 轮因为一句「Android 9 强制 SplashScreen」把整张事实表推翻重来;第 3 轮三次打回才把 reportFullyDrawn 放到正确位置;第 8 轮从 UseIfAvailable 换到 Require、从 user 版机换到 userdebug、从混淆构建换到非混淆变体,一步步才让 profile 真正生效。这三轮都不产生新功能,但任何一轮做错,后面轮次量出来的数全是假的。
bash
# 把十二轮产出的文件按轮次归拢,核对有没有遗漏
for n in $(seq 1 12); do
printf "round %02d: " "$n"
grep -c "第 $n 轮" docs/CHANGELOG-startup.md
done
# round 01: 1 ... round 12: 1
find docs scripts config .github/workflows -type f | sort | wc -l
# 34
十二轮各自留了一组能复跑的验收数据,摊开是这样。表里的命令都在 scripts/ 或 gradle 里,不是我手写的一次性检查:
| 轮 | 验收命令 | 关键读数 | 判定 |
|---|---|---|---|
| 1 | python3 scripts/scan_providers.py ... |
6 个 provider、3 个空壳、3 个隐式 init | 现状扫齐,业务零改动 |
| 2 | ./gradlew :app:dependencies --configuration releaseRuntimeClasspath |
AGP 8.2.2、minSdk 24、profileinstaller 1.3.1 | 事实表成立 |
| 3 | bash scripts/record_startup.sh 10 |
TTID P50 712ms、TTFD P50 1240ms、dropped 3 | 四个数的基线立住 |
| 4 | python3 scripts/parse_startup_trace.py ... |
providers 96.4ms、onCreate 214.8ms、gap 528ms | 空档归因到具体段 |
| 5 | ./gradlew :app:testDebugUnitTest --tests '*StartupGraphTest*' |
4 条单测全过,SYNC 档剩 6 个节点 | 拓扑与分级可用 |
| 6 | python3 scripts/parse_startup_trace.py out/startup_after_move... |
providers 96.4→31.2ms、TTID 712→498ms | TTID 进目标区间 |
| 7 | python3 scripts/check_splash_specs.py |
冷/温显示、热不显示、图标规格 OK | 迁移完成 |
| 8 | ./gradlew :baseline-profile:connectedReleaseAndroidTest |
None 624ms / Require 498ms,差 126ms | profile 确实生效 |
| 9 | bash scripts/three_state.sh |
90 组、Redmi 冷态 TTFD P90 1540ms | 高端达标、低端未达标 |
| 10 | python3 scripts/check_startup_gate.py ... |
PASS / FAIL / ERROR 三种结果都能判 | 门禁可用 |
| 11 | grep -c '^- ' docs/adversarial-review.md |
A7 B6 C6,6 条必须处理 | 口径漏洞补齐 |
| 12 | python3 一致性检查 |
34 个文件、11 条复核项、四个数一致 | 归档完成 |
每轮验收命令不一样,是因为每轮负责的数不一样。第 1、2 轮不产生任何性能数字,判据是「清单齐不齐」「事实对不对」;第 3 轮第一次给出四个数的基线;第 6 到 9 轮才逐个数地往下压;第 10 轮以后判据变成「口径会不会被下一个改代码的人废掉」。把十二轮都用同一条 am start -W 去验,等于又退回十月份那个坑。
返工账也记了下来,比记「哪一轮做了什么」更有用:
| 轮 | 打回次数 | 最贵的一次 |
|---|---|---|
| 1 | 3 | 没写「只读不改」,Trae 顺手删了两个 init |
| 2 | 3 | 把某博客的「Android 9 强制 SplashScreen」写进事实表 |
| 3 | 3 | reportFullyDrawn() 挂在 onResume,TTID 与 TTFD 报成同一个值 |
| 4 | 3 | 用 Log.d 打点,Perfetto 里一个 section 都看不见 |
| 5 | 4 | 引了 androidx.startup.Initializer,分级能力交了出去 |
| 6 | 4 | 把 DatabaseInitializer 搬去 ASYNC,首页直接崩 |
| 7 | 5 | 保留 SplashRoutingActivity,冷启动叠成两层闪屏 |
| 8 | 5 | UseIfAvailable 静默量了一个没优化的包,测试全绿 |
| 9 | 4 | 拿 am kill 造冷态,第一列数据全是温启动 |
| 10 | 5 | benchmark 没产出 JSON 时脚本 exit 0,等于没数据也算通过 |
| 11 | 2 | 三个身份都只是把我的结论换个说法复述 |
| 12 | 5 | 档案开头又写了「整体下降 40%」 |
把打回原因归一下,翻来覆去就四类:时序放错 (installSplashScreen、reportFullyDrawn、profile 采集变体)、口径含糊 (TTID 当 TTFD、冷温热混算、平均值充数)、静默通过 (UseIfAvailable、缺 JSON 判通过、profile 没打进包)、把没验证的效果写成结论(单一百分比、把官方案例数字当自己的实测)。四类里前两类占了大半,跟代码写得好不好没什么关系,全是「以为自己知道」。十二轮里第 1、2、9、11、12 轮是零业务代码改动,可它们恰恰是打回次数最密集的几轮------不改代码的轮次,最容易在说法上出错。
五段式骨架里第二段「现状与证据」每轮填的东西,我也单独存了一份,下个季度回来接着跑时不用重新翻聊天记录:
text
轮 01 Displayed 698ms / Fully drawn 1680ms / 看板 704ms(口径错误,作废)
轮 02 AGP 8.2.2、compileSdk 34、targetSdk 34、minSdk 24
轮 03 state=COLD n=10、TTID median 712、TTFD median 1240、dropped 3
轮 04 providers 96.4 / onCreate 214.8 / lifecycle 168.2 / first_frame 229.5
轮 05 27 个 init、6 个 provider、SYNC 6 / ASYNC 4 / DEFERRED 9 / LAZY 8
轮 06 providers 96.4→31.2、onCreate 214.8→88.6、Redmi 回归 60ms
轮 07 双层闪屏、TTID 1298→498、窗口DisablePreview 缺席
轮 08 None 624 / Require 498 / Partial 631 / Full 505,1284 条规则
轮 09 Pixel 6 1088、Redmi 1540、Pixel 8 1002,2 轮态跳变
轮 10 PASS / FAIL(1187) / ERROR(缺字段) 三种结果
轮 11 A7 B6 C6、must_fix 6、record_only 3、升级后 profile 不匹配
轮 12 34 文件、11 复核项、四数一致、无单一百分比
@ 的文件清单是逐轮变长的。前三轮只 @ 五个文件,全是 Manifest、Application、metrics 这类只读材料;中间五轮加进 StartupGraph、两套 styles、:baseline-profile 模块和 trace 产物,开始碰代码;最后四轮干脆 @ 整个 docs/ 和 workflow,因为要交叉核对口径与数字是否前后一致。有一次我偷懒把第 9 轮的 @ 清单沿用第 6 轮的,结果 Trae 完全不知道 SplashScreen 已经迁过,给出一份基于旧路由 Activity 的回归建议,白跑一轮。
bash
# 逐轮核对产出文件是否都在,缺哪个就说明那一轮没收口
for n in $(seq -w 1 12); do
printf "round %s: " "$n"
grep -l "第 ${n#0} 轮" docs/*.md 2>/dev/null | wc -l
done
# round 01: 2 round 02: 2 ... round 12: 3
python3 - <<'PY'
import json, glob
j = json.load(open('config/startup_baseline.json'))
print('current:', j['current'], 'gap:', j['known_gap'])
print('docs:', len(glob.glob('docs/*.md')), 'scripts:', len(glob.glob('scripts/*')))
PY
# current: {'ttid_p50_ms': 498, ...} docs: 14 scripts: 9
有哪几轮是跳不过去的,我也试过。抽掉第 2 轮,第 7 轮的 SplashScreen 主题会按旧教程写;抽掉第 3 轮,后面九轮量出来的数全带着十月份那个混态口径;抽掉第 8 轮,Require 那道校验不在,profile 哪天失效了没人知道;抽掉第 11 轮,升级后 profile 不匹配、列表为空 TTFD 退回 TTID 这两个洞会一直留到线上。这四轮不产生新功能,但它们决定另外八轮的数字是不是真的。
把每轮的模式、@ 规模和改动量也记一笔,下个季度回来能一眼看出哪几轮是重的:
| 轮 | 模式 | @ 文件数 | 产出/改动文件数 | 动业务初始化逻辑 |
|---|---|---|---|---|
| 1 | SOLO Agent(起步误用 Chat) | 5 | 2 | 否 |
| 2 | SOLO Agent(开联网检索) | 6 | 1 | 否 |
| 3 | SOLO Agent | 6 | 4 | 否,只改埋点 |
| 4 | SOLO Agent | 6 | 5 | 否,只加打点 |
| 5 | SOLO Agent | 6 | 6 | 是 |
| 6 | SOLO Agent | 6 | 5 | 是 |
| 7 | SOLO Agent | 7 | 8 | 是,删路由 Activity |
| 8 | SOLO Agent(连 userdebug 设备) | 7 | 5 | 是,测试模块 |
| 9 | SOLO Agent | 6 | 3 | 否 |
| 10 | SOLO Agent | 6 | 4 | 否,只加 CI |
| 11 | SOLO Agent(同会话切三身份) | 7 | 2 | 否 |
| 12 | SOLO Agent | 6 | 4 | 否 |
模式十二轮统一是 SOLO Agent,只有三处不一样:第 2 轮开了联网核准版本,第 8 轮要求连着 userdebug 设备才能采 profile,第 11 轮在同一会话里切了三次身份。第 1 轮那次误用纯问答式的 Chat,产出只有建议没有文件,是整条流水线里唯一一次模式没对上的返工。@ 的文件数从 5 慢慢涨到 7,涨的部分不是代码变多,是后面几轮要把 docs/ 里的口径和前面轮次的数字交叉核对。
真正动业务初始化逻辑的只有第 5 到 8 轮这四轮,前后八轮要么只读、要么只碰埋点、脚本、测试与文档。可耗时最短的恰恰不是这四轮------第 2、3、8、11、12 轮全是零业务改动,打回次数却最密集。原因也不难懂:写代码时错一处,编译或单测当场就报;说法错一处,能安安静静骗过后面好几轮,直到某个数字对不上才露出来。十月份那条 40.3% 的曲线,就是这么骗过我半年的。
bash
# 从 CHANGELOG 里核对每轮改动文件数,和上表对得上才算归档完整
python3 - <<'PY'
import re
lines = open('docs/CHANGELOG-startup.md', encoding='utf-8').read().splitlines()
for l in lines:
m = re.match(r'-\s*第\s*(\d+)\s*轮.*files=(\d+)', l)
if m:
print('round %02d files=%s' % (int(m.group(1)), m.group(2)))
PY
# round 01 files=2 ... round 12 files=4
git diff --stat HEAD~12..HEAD -- app/src/main/java | tail -1
# 9 files changed, 412 insertions(+), 287 deletions(-)
十二轮里删掉的东西其实比新增的多,单列一份,比只记新增更接近这半年的真实工作量:
| 删掉的 | 原本在哪 | 为什么删 | 哪一轮 |
|---|---|---|---|
SplashRoutingActivity.kt |
app/.../,LAUNCHER 入口 |
跟系统 SplashScreen 叠成两层 | 7 |
800ms postDelayed 跳首页 |
同上 | 纯人工等待 | 7 |
3 个空壳 <provider> 声明 |
AndroidManifest.xml |
早于 Application.onCreate 白占 96.4ms |
6 |
windowBackground 的 layer-list |
values/styles.xml |
Android 12 起由系统统一画 | 7 |
windowDisablePreview |
旧启动主题 | 连启动窗口一起禁掉,像假死 | 规则里禁 |
| 27 行裸 init 调用 | MyApplication.kt |
收进 StartupGraph |
5 |
Handler.postDelayed 做初始化 |
InitCalls.kt |
按墙上时间醒来抢首帧 | 6 |
手动 System.gc() |
旧工具类 | ART 下调度由系统管 | 规则里禁 |
| 手改过的 profile 条目 | app/src/main/baseline-prof.txt |
混淆改了类名,规则对不上 | 8 |
BaselineProfileMode.UseIfAvailable |
StartupBenchmarks.kt |
profile 缺失时静默通过 | 8 |
| 平均值上报 | 埋点字段 | 掩盖尾部投诉 | 3 |
| 「降 40.3%」那条曲线 | 复盘文档 | 口径错误、样本污染,作废 | 开篇 |
最大的一笔 TTID 降幅来自删掉那个 800ms 的等待,不是来自任何一段新写的代码;install_providers 从 96.4ms 到 31.2ms,靠的是删三个空壳 provider;Application.onCreate 从二十七行缩成一行,靠的是把调用搬进图里,代码总量反而少了。我以前默认优化等于「加点什么」,这十二轮下来改了想法:先把不该在首帧之前跑的东西清走,再谈剩下的怎么排。
bash
# 确认这些字符串在仓库里真的归零,而不是只写在文档里
for s in 'SplashRoutingActivity' 'windowDisablePreview' 'postDelayed' \
'System.gc()' 'UseIfAvailable'; do
printf '%-28s ' "$s"
grep -rl "$s" app/src baseline-profile 2>/dev/null | wc -l
done
# SplashRoutingActivity 0
# windowDisablePreview 0
# postDelayed 0
# System.gc() 0
# UseIfAvailable 0
git diff --shortstat HEAD~12..HEAD
# 34 files changed, 612 insertions(+), 903 deletions(-)
.trae/rules/startup_rules.yaml 里那几条禁令------不许 windowDisablePreview、不许 postDelayed 做初始化、不许手动 System.gc()------不是凭空定的规矩,是照着这份删除清单倒推出来的。删一次写进规则,下个版本就不会有人再把它加回来。
最后落地的文件长什么样
下面这些是后半段落地的文件,每块标了路径和用途。测量侧那四块(StartupTrace.kt、四档 benchmark、record_startup.sh、parse_startup_trace.py)在前半篇贴过,这里不重复。全部用平台 API、AndroidX 或 Gradle 原生能力,没有引任何第三方库;版本相关的行为以 docs/version-facts.md 为准,我这边是 AGP 8.2.2、compileSdk 34、targetSdk 34、minSdk 24,在自己机器上跑过构建和测量,换个版本未必一模一样。
app/src/main/java/com/example/health/startup/StartupGraph.kt ------ 拓扑排序、四级分级、依赖检查都在这一个文件里,是整个方案的骨架。Kahn 排序按注册序输出,保证每次 dump() 结果可比对。
kotlin
package com.example.health.startup
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.os.MessageQueue
import java.util.ArrayDeque
import java.util.concurrent.Executors
enum class Tier { SYNC, ASYNC, DEFERRED, LAZY }
interface Initializer {
val id: String
val dependencies: List<String>
val tier: Tier
fun create(app: Application): Any
}
class StartupGraph(private val app: Application) {
private val nodes = LinkedHashMap<String, Initializer>()
private val instances = HashMap<String, Any>()
private val io = Executors.newFixedThreadPool(2)
fun register(init: Initializer): StartupGraph {
require(!nodes.containsKey(init.id)) { "duplicate id: ${init.id}" }
nodes[init.id] = init
return this
}
fun order(): List<Initializer> {
val degree = HashMap<String, Int>()
val children = HashMap<String, MutableList<String>>()
nodes.keys.forEach { degree[it] = 0; children[it] = mutableListOf() }
for (n in nodes.values) {
for (d in n.dependencies) {
require(nodes.containsKey(d)) { "missing dependency: ${n.id} -> $d" }
require(d != n.id) { "self dependency: ${n.id}" }
children[d]!!.add(n.id)
degree[n.id] = degree[n.id]!! + 1
}
}
val ready = ArrayDeque<String>()
nodes.keys.forEach { if (degree[it] == 0) ready.add(it) }
val result = ArrayList<Initializer>()
while (!ready.isEmpty()) {
val id = ready.removeFirst()
result.add(nodes[id]!!)
for (c in children[id]!!) {
degree[c] = degree[c]!! - 1
if (degree[c] == 0) ready.add(c)
}
}
if (result.size != nodes.size) {
val left = nodes.keys - result.map { it.id }.toSet()
throw IllegalStateException("cycle among: " + describeCycle(left))
}
return result
}
private fun describeCycle(left: Set<String>): String {
val chain = ArrayList<String>()
var cur = left.firstOrNull() ?: return ""
while (cur !in chain) {
chain.add(cur)
cur = nodes[cur]?.dependencies?.firstOrNull { it in left } ?: break
}
return (chain + cur).joinToString(" -> ")
}
fun execute() {
val ordered = order()
ordered.filter { it.tier == Tier.SYNC }.forEach { run(it) }
ordered.filter { it.tier == Tier.ASYNC }.forEach { io.execute { run(it) } }
Looper.myQueue().addIdleHandler(object : MessageQueue.IdleHandler {
override fun queueIdle(): Boolean {
ordered.filter { it.tier == Tier.DEFERRED }.forEach { run(it) }
return false
}
})
}
@Synchronized
fun get(id: String): Any = instances[id]
?: run(nodes[id] ?: error("unknown id: $id"))
@Synchronized
private fun run(init: Initializer): Any {
instances[init.id]?.let { return it }
init.dependencies.forEach { dep ->
val node = nodes[dep] ?: error("missing dependency: $dep")
if (!instances.containsKey(dep)) run(node)
}
return init.create(app).also { instances[init.id] = it }
}
fun dump(): String = buildString {
appendLine("nodes=${nodes.size}")
Tier.values().forEach { t ->
val ids = nodes.values.filter { it.tier == t }.map { it.id }
appendLine("${t.name}(${ids.size}): ${ids.joinToString(" -> ")}")
}
}
}
app/src/main/java/com/example/health/MyApplication.kt ------ Application 里不再有裸 init 调用,只剩一次 execute();StrictMode 只在 debug 开,release 不带。
kotlin
package com.example.health
import android.app.Application
import android.os.StrictMode
import com.example.health.startup.InitCatalog
import com.example.health.startup.StartupGraph
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) installStrictMode()
StartupGraph(this).apply { InitCatalog.registerAll(this) }.execute()
}
private fun installStrictMode() {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads().detectDiskWrites().detectNetwork()
.penaltyLog().build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectActivityLeaks().detectLeakedSqlLiteObjects()
.penaltyLog().build()
)
}
}
app/src/main/java/com/example/health/MainActivity.kt ------ SplashScreen 接入、首帧埋点与 reportFullyDrawn 时机集中在这里。installSplashScreen() 必须在 super.onCreate() 之前,setKeepOnScreenCondition 带 2000ms 上限。
kotlin
package com.example.health
import android.os.Bundle
import android.view.ViewTreeObserver
import androidx.activity.ComponentActivity
import androidx.core.splashscreen.SplashScreen
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
// viewModel 与 adapter 按项目实际注入方式补上,这里只展示启动相关时序
class MainActivity : ComponentActivity() {
private var ready = false
private var firstFrameLogged = false
override fun onCreate(savedInstanceState: Bundle?) {
val splash: SplashScreen = installSplashScreen()
splash.setKeepOnScreenCondition { !ready }
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val observer = window.decorView.viewTreeObserver
observer.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
if (!firstFrameLogged) {
firstFrameLogged = true
observer.removeOnPreDrawListener(this)
StartupTrace.mark("first_frame_predraw")
}
return true
}
})
lifecycleScope.launch {
withTimeoutOrNull(2000) { viewModel.loadFirstPage() }
ready = true
}
viewModel.records.observe(this) { list ->
adapter.submitList(list) {
// 列表第一条绑定完、能点能划,才报完全显示;一定晚于首帧
reportFullyDrawn()
StartupTrace.mark("fully_drawn")
}
}
}
}
app/src/main/java/com/example/health/startup/FullyDrawnGate.kt ------ 首页有三个后台任务都要完成才算「可用」时,用 getFullyDrawnReporter() 收口,全部 release 后系统自动报 TTFD,同时这些代码路径会被算进 Baseline Profile。
kotlin
package com.example.health.startup
import androidx.activity.ComponentActivity
class FullyDrawnGate(activity: ComponentActivity) {
private val reporter = activity.fullyDrawnReporter
private val local = reporter.addReporter()
private val config = reporter.addReporter()
private val tracking = reporter.addReporter()
fun localDone() = local.invoke()
fun configDone() = config.invoke()
fun trackingDone() = tracking.invoke()
}
baseline-profile/src/main/java/com/example/health/baseline/BaselineProfileGenerator.kt ------ 用 BaselineProfileRule.collect 从真实启动路径录规则,不手写方法列表;采集构建不混淆。
kotlin
package com.example.health.baseline
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test fun startup() = rule.collect(
packageName = "com.example.health",
includeInStartupProfile = true
) {
uiAutomator {
startApp("com.example.health")
onElement(5_000) { viewIdResourceName == "record_list" }
}
}
}
baseline-profile/build.gradle.kts 与 app/build.gradle.kts ------ 插件、managedDevices、saveInSrc、依赖,两处合在一起看才完整。
gradle
// baseline-profile/build.gradle.kts
plugins {
id("com.android.test")
id("androidx.baselineprofile")
}
android {
targetProjectPath = ":app"
testOptions.managedDevices.devices {
maybeCreate<com.android.build.api.dsl.ManagedVirtualDevice>("pixel6Api33").apply {
device = "Pixel 6"
apiLevel = 33
systemImageSource = "aosp"
}
}
}
dependencies {
implementation("androidx.test.ext:junit:1.1.5")
implementation("androidx.test.uiautomator:uiautomator:2.3.0")
implementation("androidx.benchmark:benchmark-macro-junit4:1.2.4")
}
baselineProfile {
managedDevices += "pixel6Api33"
useConnectedDevices = false
saveInSrc = true
}
// app/build.gradle.kts 里需要加的两行
plugins { id("androidx.baselineprofile") }
dependencies { baselineProfile(project(":baseline-profile")) }
.github/workflows/startup-gate.yml ------ 门禁只认固定机型与编译模式,缺数据一律判失败。
yaml
name: startup-gate
on:
pull_request:
schedule:
- cron: '0 3 * * *'
jobs:
startup:
runs-on: [self-hosted, pixel6]
steps:
- uses: actions/checkout@v4
- name: unit smoke
run: ./gradlew :app:testDebugUnitTest
- name: startup benchmark
run: ./gradlew :baseline-profile:connectedReleaseAndroidTest --tests '*StartupBenchmarks*'
- name: gate on P90
run: python3 scripts/check_startup_gate.py out/latest_benchmark.json
- name: attach trace on failure
if: failure()
run: |
python3 scripts/parse_startup_trace.py out/slices.csv > out/segments.csv
echo "trace: out/startup_cold.perfetto-trace" >> "$GITHUB_STEP_SUMMARY"
cat out/segments.csv >> "$GITHUB_STEP_SUMMARY"
config/startup_baseline.json ------ 四个数的单一来源,阈值、基线、当前值、以及没达标的那台机都写在这里,脚本不硬编码。
json
{
"updated": "2026-10-31",
"device": "Pixel 6 / Android 14 / API 34",
"startupMode": "COLD",
"compilationMode": "Partial(Require)",
"iterations": 10,
"thresholds": { "ttid_p50_ms": 520, "ttfd_p50_ms": 850, "ttfd_p90_ms": 1100 },
"baseline": { "ttid_p50_ms": 712, "ttfd_p50_ms": 1240, "ttfd_p90_ms": 1910 },
"current": {
"ttid_p50_ms": 498,
"ttfd_p50_ms": 806,
"ttfd_p90_ms": 1088,
"online_interactive_p90_ms": 1195
},
"known_gap": "Redmi Note 11 / API 30 cold TTFD P90 1540ms, not passing"
}
踩过的坑,按报错排(改造这半边)
后六轮里真正花时间的不是写代码,是这些现象第一次出现时我把它认成了别的东西。按当时看到的报错或现象排,一条条对下来比我讲一遍流程有用。
| 现象 | 根因 | 修复 |
|---|---|---|
UseIfAvailable 下测试全绿,包其实没优化 |
profile 缺失时静默量未优化构建 | 改 BaselineProfileMode.Require,缺则报错 |
generateBaselineProfile 产物全是空文件 |
user 版机上 profman 权限不足 |
换 userdebug 镜像,API 33+ 可放宽 |
| 混淆构建录出的 profile 规则不匹配 | 规则记的是混淆前签名 | 采集用非混淆变体,R8 在构建期重写 |
| 覆盖升级后头几次冷启动变慢 | 旧 profile 与新版代码不匹配 | ProfileInstaller 重写 + 升级前后 A/B 记录 |
installSplashScreen() 后退场动画不触发 |
调用放在 super.onCreate() 之后 |
提前到 super 之前 |
| 弱网下闪屏挂十几秒 | setKeepOnScreenCondition 没有超时上限 |
withTimeoutOrNull(2000) 兜底 |
| 冷启动出现两层闪屏 | 保留了自定义 SplashRoutingActivity |
整个删掉,LAUNCHER 指回 MainActivity |
| 闪屏图标边缘被遮掉一块 | 288×288dp 规格配了 IconBackground |
改用 240×240dp / 内圆 160dp |
| 闪屏撤掉后 ActionBar 消失 | 漏配 postSplashScreenTheme |
主题里补回应用主题 |
windowDisablePreview=true 后点图标像没反应 |
连 StartingWindow 一起禁了 | 删掉该属性,用 SplashScreen 主题 |
| 热启动也闪一下,以为所有启动都显示 | 只有冷/温显示,热从不显示 | 三态分别验证,不当成 bug 修 |
| 三个空壳 provider 白占 96.4ms | installContentProviders() 早于 Application.onCreate |
删 Manifest 声明,改显式 Initializer |
| SDK 在 provider 里同步读三个 SharedPreferences | 主线程磁盘读,页缓存还没热 | 挪 ASYNC,首屏先用内存默认值 |
postDelayed(1500) 与首帧抢主线程 |
按墙上时间到点就投,不管队列忙不忙 | 换 IdleHandler,返回 false 只回调一次 |
拓扑 dump() 每次顺序都不同 |
入度为 0 的节点多于一个时未稳定化 | 按注册序进队,输出可比对 |
环检测只抛一句 cycle |
没打印环链,不知道谁等谁 | 输出 A -> B -> C -> A 完整链 |
| DEFERRED 任务被跑了好几遍 | IdleHandler 返回 true 反复回调 |
改成 false |
| CI 里 benchmark 没产出 JSON 却通过 | 脚本找不到文件时 exit 0 |
字段与样本数校验,缺数据判失败 |
表里有半数跟「以为自己知道」有关:installSplashScreen 的时序、IdleHandler 的返回值、Require 与 UseIfAvailable 的差别,官方文档里都写着,看漏一次就够返工一轮。另外几条是「改了但没生效」,比写错更难查,因为测试全绿。
命令速查
改造这半边的命令,重点是产出物到底有没有真的生成。
bash
bash scripts/record_startup.sh --perfetto 1
# startup_install_providers,3.1,31.2,main
# startup_application_oncreate,34.3,88.6,main
# startup_fully_drawn,498.0,308.0,main
# 判读:providers 段变长先翻 Manifest 的 <provider>;fully_drawn 段变长
# 先查首页数据加载,别回头去压 onCreate
adb shell cmd package compile -m speed-profile -f com.example.health
adb shell dumpsys package com.example.health | grep -i profile
# 判读:profile 没落下去时,Require 模式的 benchmark 会直接报错,不会静默通过
./gradlew :app:generateBaselineProfile
./gradlew :baseline-profile:connectedReleaseAndroidTest --tests '*StartupBenchmarks*'
# None median 624ms / Partial(Require) median 498ms,差 126ms 才算 profile 生效
python3 scripts/check_startup_gate.py out/latest_benchmark.json
# GATE PASS ------ 只认 Pixel 6 / COLD / Partial(Require) / 10 轮这组条件
Require 和 UseIfAvailable 那两行我盯得最紧:profile 没落下去时 Require 会直接报错,而 UseIfAvailable 会静默量一个没优化过的包,十轮全绿,什么也没改。
上线前我盯的几个数
四个数都写清了口径、机型和样本量,优化前后放在一张表里。它们不是同一个东西,不能相减,也不能拿其中最好看的一个去代表「启动优化」。
| 数 | 测量口径 | 机型 / 样本量 | 优化前 | 优化后 |
|---|---|---|---|---|
| 冷启动 TTID P50 | Macrobenchmark StartupTimingMetric,StartupMode.COLD,CompilationMode.Partial(Require) |
Pixel 6 / Android 14 / API 34,10 轮 × 3 组 | 712ms | 498ms |
| 冷启动 TTFD P50 | reportFullyDrawn() 触发,同上编译与进程状态 |
同上 | 1240ms | 806ms |
| 冷启动 TTFD P90 | 同上 | 同上 | 1910ms | 1088ms |
| 线上可交互 P90 | 自有埋点,仅 force-stop 冷态样本,灰度 7 天 |
混合机型,约 2.3 万次 | 2180ms | 1195ms |
误差和回归我没抹掉。第 6 轮把埋点 SDK 挪到 DEFERRED 之后,Redmi Note 11(Android 11 / API 30,4GB)上 TTFD P90 回归过 60ms,调回 ASYNC 才收住;三机回归里这台机冷态 TTFD P90 是 1540ms,至今没进 1100ms 的门槛,写在 config/startup_baseline.json 的 known_gap 里,没拿 Pixel 8 的 1002ms 盖过去。十轮里还出现过两次 lmkd 回收导致的态跳变,单独存放不进 P90。线上那 1195ms 跟本地 1088ms 不是一套口径,机型、网络、后台负载都不同,摆在一起只为看趋势,不当成同一次测量的两个读数。
还能往下挖的地方
按我自己排的投入产出比,从最值得做的往下,这半张单子只留改造类的。
| 方向 | 解决什么 | 代价 |
|---|---|---|
| 首页数据预读与缓存命中 | TTFD 里最大的一块,列表绑定前的本地查询 | 缓存一致性,冷启动首次无缓存仍要走全量 |
| dex 布局优化 + startup profile | 减少冷启动页错误,把启动期类排到 dex 前部 | 要 userdebug 采集,R8 规则随代码变动得重生成 |
| 低端机专项 | Redmi 上 1540ms 的 TTFD P90 | 机型碎片,样本量得翻倍,回归周期变长 |
| Compose 首次冷启动编译开销 | 首次加载 composable 类的编译成本 | 与布局改造交叉,baseline rules 要重写 |
| 16KB 页表对齐 | 系统级 3%~30% 启动提升 | .so 需重编、target 35+ 起强制,原理不在这篇 |
前三项投入产出比最高。前一篇讲 R8 时提过混淆会改类名进而影响 profile 规则,表里第二项就卡在这儿,也是我下次发版最可能先动的地方。
后来
十一月初一个早上,我在电梯里碰见转过那三条反馈的客服同事,她随口说最近「点开要等一下」少多了,问我改了什么。我想了想说,把一直在测的那个数换了一个。她没听明白,电梯门开了,这事就过去了。回工位我把那台 Redmi Note 11 又连上,开始跑下一轮十次冷启动。