Android 启动优化:从 1.8 秒到 0.9 秒我都做了什么
一次真实的冷启动优化记录:怎么度量启动时间、找出启动路径上的隐形耗时、用 Baseline Profile 让首屏更快,以及哪些优化其实是白费力。
我们的 App 冷启动在中端机上大概 1.8 秒。用户没抱怨,但数据上看,启动超过 1.5 秒的那批人,次日留存明显低一截。
于是花了两周做了一轮启动优化,最后落到 0.9 秒左右。这篇记录一下有效的手段和踩过的坑。
先定义「启动完成」
优化最怕目标不清。我们取三个指标:
| 指标 | 含义 | 采集方式 |
|---|---|---|
timeToInitialDisplay |
首帧可见 | Activity.reportFullyDrawn() 之前的一个系统埋点 |
timeToFullDisplay |
首屏内容真正可用 | reportFullyDrawn() |
| 自定义 TTFF | 用户看到有意义内容 | 首屏数据渲染完成的埋点 |
只看 timeToInitialDisplay 会自欺欺人——把首帧画成一个白底加 loading,数字会很好看,但用户还是要等。
用 Trace 找时间去哪了
不要凭感觉。Perfetto 或者 Android Studio 的 System Trace 都能直接看到主线程在干什么。
第一次看 trace 时最意外的发现是:启动路径上有 300 多毫秒花在了初始化第三方 SDK 上,而它们大多不是首屏需要的。
class App : Application() {
override fun onCreate() {
super.onCreate()
// ❌ 全都在主线程串行执行
CrashReporter.init(this) // 120ms
Analytics.init(this) // 90ms
PushService.init(this) // 110ms
runBlocking { ConfigRepo.load() }
}
}
手段一:把初始化从主线程挪走
ContentProvider 是很多人忽略的一环。三方 SDK 为了免初始化,常用 ContentProvider 自动注册,而这些 provider 会在 Application.onCreate 之前就创建。合并后的 Manifest 里能一眼看到:
<provider
android:name="com.thirdparty.InitProvider"
android:authorities="${applicationId}.thirdparty-init"
android:exported="false" />
能移除的移除,不能移除的(比如 Firebase)就在启动阶段先用空实现占位,真正用到时再延迟初始化。
自己写的初始化改成懒加载 + 按需触发:
class App : Application() {
override fun onCreate() {
super.onCreate()
// 首屏必须的,放主线程
ConfigRepo.loadBlocking()
// 其余丢到后台,用不到的模块永远不会初始化
lifecycleScope.launch(Dispatchers.Default) {
CrashReporter.init(this@App)
Analytics.init(this@App)
}
}
}
同时加上 启动任务依赖调度:Analytics 依赖 ConfigRepo,那就让 Analytics 等待 ConfigRepo 完成,而不是一起排队。
手段二:Baseline Profile
这是投入产出比最高的一项。ART 在安装时只做一次简单 AOT 编译,热点代码仍然靠 JIT,而 JIT 需要在运行时收集信息、重新编译。Baseline Profile 就是把「启动和首屏会用到的热点方法」提前编译好。
plugins {
id("androidx.baselineprofile")
}
baselineProfile {
saveInSrc = true
}
然后用 Macrobenchmark 录制启动路径:
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD,
) {
pressHome()
startActivityAndWait()
}
}
自动生成的 profile 往往不够「像真实用户」。我们的做法是手动补一段声明式的关键路径:
@Test
fun generate() = baselineProfileRule.collect(packageName = "com.example.app") {
pressHome()
startActivityAndWait()
// 走一遍真实用户会做的动作,让这些方法也进 profile
device.findObject(By.res("home_feed")).wait(Until.hasObject(By.res("feed_item")), 3_000)
device.findObject(By.res("home_feed")).fling(Direction.DOWN)
}
效果:中端机冷启动又快了大约 200ms,而且滚动首屏列表的卡顿也明显改善。
这一项要记得加进 CI。profile 是跟着代码版本走的,业务代码改了几个版本之后旧的 profile 就基本失效了。
手段三:别再「先画个空壳」
之前首屏是这样的:先显示标题栏 + 骨架屏,等接口回来再填内容。骨架屏确实让 timeToInitialDisplay 变好看了,但用户感知的启动时间没变。
改成本地缓存优先之后,真正有了质变:
suspend fun loadFeed(): Flow<FeedState> = flow {
// 1. 先吐缓存,通常 10ms 内返回
cache.get()?.let { emit(FeedState.Content(it, fromCache = true)) }
// 2. 再拉网络,回来之后静默替换
runCatching { api.feed() }
.onSuccess { emit(FeedState.Content(it, fromCache = false)) }
.onFailure { emit(FeedState.Error(it)) }
}
用户看到的是「打开就有内容」,而不是「打开等一会儿才有内容」。这一条对留存的影响比前面所有技术手段加起来都大。
走过弯路的地方
- 过度追求
Application.onCreate里一行代码都没有。 结果初始化时机四处散落,反而更难排查。真正该做的是分清「必须」和「可以等」。 - 把
SplashScreen的退出时机提前。 数字好看了,但闪一下空白,体验更差。 - 盲目上
App Startup库。 它解决的是「初始化顺序」问题,如果耗时本身来自 SDK 内部,换调度框架没有用。 - 只在旗舰机上测。 旗舰机上 300ms 的差异感知不到,中端机上就是能不能忍的区别。测性能一定要用低端机。
复盘
优化前我一直以为瓶颈在布局层级。实际做完发现,真正的大头是:
- 主线程上的第三方 SDK 初始化(约 400ms)
- 缺少 Baseline Profile,首屏代码全是解释执行(约 300ms)
- 首屏非要等网络,缓存没用起来(用户感知层面影响最大)
顺序很重要:先砍掉不该做的事,再让剩下的跑得更快。 反过来做,就是在优化本来就不该存在的代码。