Android启动优化系列 · 第4/5篇
从冷启动8秒到秒开的工程实战
第1篇:Android启动全景图:一次冷启动背后到底发生了什么
第2篇:启动瓶颈定位实战:Perfetto + Macrobenchmark 一套组合拳
第3篇:异步初始化框架设计:用拓扑排序干掉启动串行瓶颈
第4篇:首帧渲染优化:从白屏到内容可见的最后一公里
⏳ 第5篇:线上监控与防劣化:让启动优化成果不再回退
上一篇我们用 DAG 调度器把 Application.onCreate() 里的串行初始化压缩了 58%。启动 trace 上那一排长长的火车车厢终于被拆成了并行的流水线。Macrobenchmark 跑下来,TTID(Time To Initial Display)从 2.1 秒降到了 0.9 秒,你觉得差不多可以收工了。
然后你把手机递给产品经理。
"怎么还是白屏?"
你凑过去看--确实,点击图标后先是一段白屏,然后才闪出首页内容。大概 400ms。数字上很快了,但人眼对白屏的感知跟对品牌闪屏的感知完全不同:同样的等待时长,白屏让人焦虑,品牌屏让人觉得"还行"。
这就是启动优化的"最后一公里"。Application 初始化完成 ≠ 用户看到内容,Activity 创建完成 ≠ 屏幕上有东西。从 Activity.onCreate() 到第一帧内容真正渲染到屏幕上,中间还有一段被很多人忽略的路:布局 inflate、measure/layout/draw、图片解码、首屏数据获取。这段时间里,用户盯着的就是一块白(或者你设的 windowBackground 颜色)。
今天这篇就来解决这个问题。从 SplashScreen API、布局层级优化、图片预加载,到 Compose 场景下的首帧优化,一个一个说清楚。
一、白屏到底是什么:理解首帧渲染管线
在动手优化之前,先搞清楚"白屏"的本质。
当系统启动一个 Activity 时,窗口管理器(WindowManager)会先创建一个 Window,在 Activity 的布局还没准备好之前,这个 Window 显示的是 android:windowBackground 指定的 Drawable。默认情况下,这就是一个纯白(或纯黑,取决于主题)的背景。这就是所谓的"启动白屏"。
完整的首帧渲染流程是这样的:
首帧渲染时间线
ActivityThread.handleLaunchActivity → Activity.onCreate → setContentView
Inflate XML → Measure / Layout → Draw → 首帧可见
用户感知到的"白屏时间" = 从 Window 创建到首帧 Draw 完成之间的全部时间。这段时间里的每一个环节都可以优化,但效果最显著的是三个地方:
• windowBackground 配置--消除视觉上的白屏感知
• 布局 inflate 优化--减少首帧需要处理的 View 数量
• 图片和数据预加载--让首帧有内容可渲染
我们逐个击破。
二、SplashScreen API:用品牌感消灭白屏感知
Android 12 引入了 SplashScreen API(androidx.core.splashscreen 向下兼容到 API 21),这是 Google 对"启动白屏"问题给的官方解答。核心思路很简单:既然白屏这段时间无法完全消除,那就把它变成品牌展示时间。
2.1 基础接入
先在 build.gradle 里加依赖:
scss
implementation(
"androidx.core:core-splashscreen:1.0.1"
)
然后定义 SplashScreen 主题:
xml
<!-- res/values/themes.xml -->
<style
name="Theme.App.Splash"
parent="Theme.SplashScreen">
<!-- 背景色 -->
<item name=
"windowSplashScreenBackground">
@color/brand_green
</item>
<!-- 中央图标 -->
<item name=
"windowSplashScreenAnimatedIcon">
@drawable/ic_splash_logo
</item>
<!-- 图标动画时长 -->
<item name=
"windowSplashScreenAnimationDuration">
300
</item>
<!-- 退出后的正式主题 -->
<item name=
"postSplashScreenTheme">
@style/Theme.App.Main
</item>
</style>
在 AndroidManifest 里把启动 Activity 的 theme 设成 Splash 主题:
xml
<activity
android:name=".MainActivity"
android:theme=
"@style/Theme.App.Splash">
<intent-filter>
<action android:name=
"android.intent.action.MAIN" />
<category android:name=
"android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
最后在 Activity 的 onCreate() 里调用 installSplashScreen():
kotlin
class MainActivity :
ComponentActivity() {
override fun onCreate(
savedInstanceState: Bundle?
) {
val splashScreen =
installSplashScreen()
super.onCreate(savedInstanceState)
// 关键:控制 Splash 何时退出
splashScreen.setKeepOnScreenCondition {
!viewModel.isReady.value
}
}
}
setKeepOnScreenCondition 是一个非常关键的 API。它让你可以精确控制"Splash 展示到什么时候消失"。只要条件返回 true,SplashScreen 就继续显示;返回 false 时才执行退出动画。
2.2 配合 DAG 调度器使用
上一篇我们做了 DAG 异步初始化调度器,SplashScreen 跟它的配合方式是这样的:
kotlin
class MainViewModel(
private val startupGraph:
TaskGraph
) : ViewModel() {
private val _isReady =
MutableStateFlow(false)
val isReady:
StateFlow<Boolean> =
_isReady.asStateFlow()
init {
viewModelScope.launch {
// 等待关键路径任务完成
startupGraph
.awaitCriticalPath()
_isReady.value = true
}
}
}
这里的 awaitCriticalPath() 只等那些标记了 mustBeforeFirstFrame = true 的任务。其他任务在后台继续跑,不阻塞 Splash 退出。
实战坑:SplashScreen 有一个 1000ms 的最大动画时长限制(Android 12+)。如果你的关键路径任务需要 2 秒才能完成,Splash 会被系统强制退出。所以
setKeepOnScreenCondition不是万能的--你得确保关键路径真的很快(
三、布局层级优化:让首帧 inflate 更快
Splash 退出后,系统开始渲染你的真实布局。setContentView() 触发 XML inflate,然后 measure → layout → draw。这个过程的时间跟你的布局复杂度直接相关。
我见过最夸张的一个首页布局,Layout Inspector 打开一看:17 层嵌套,287 个 View。光 inflate 就花了 120ms,measure 又花了 80ms。这还没算上 RecyclerView 的 item 创建和绑定。
3.1 ViewStub:延迟加载非首屏内容
首帧优化的第一原则:首帧只渲染用户能看到的内容。屏幕下方用户需要滚动才能看到的部分、错误状态布局、加载状态布局、底部弹窗--这些在首帧的时候根本不需要 inflate。
ViewStub 是 Android 提供的延迟加载机制。它本身是一个轻量级的 View(不参与 measure/layout/draw),只有在你调用 inflate() 或 setVisibility(VISIBLE) 的时候,才会真正 inflate 它指向的布局。
xml
<!-- activity_main.xml -->
<FrameLayout
xmlns:android=
"http://schemas.android.com/apk/res/android"
android:layout_width=
"match_parent"
android:layout_height=
"match_parent">
<!-- 首屏内容:立即 inflate -->
<include layout=
"@layout/layout_home_feed" />
<!-- 错误页:延迟 inflate -->
<ViewStub
android:id="@+id/stub_error"
android:layout=
"@layout/layout_error"
android:layout_width=
"match_parent"
android:layout_height=
"match_parent" />
<!-- 底部面板:延迟 inflate -->
<ViewStub
android:id=
"@+id/stub_bottom_sheet"
android:layout=
"@layout/layout_bottom_sheet"
android:layout_width=
"match_parent"
android:layout_height=
"wrap_content" />
</FrameLayout>
在代码里按需 inflate:
kotlin
// 出错时才 inflate 错误布局
fun showError(msg: String) {
val errorView =
findViewById<ViewStub>(
R.id.stub_error
)?.inflate()
errorView?.findViewById<
TextView
>(R.id.tv_error)?.text = msg
}
在我之前优化的一个项目里,首页有 4 个 ViewStub(错误页、空状态、底部弹窗、引导浮层),把它们从直接 include 改成 ViewStub 后,首帧 inflate 时间从 110ms 降到了 65ms。省了 40% 多的时间,改动量不到 20 行。
3.2 减少布局层级:ConstraintLayout 和 merge
布局层级每多一层,measure 和 layout 就要多遍历一轮。特别是 LinearLayout 嵌套 RelativeLayout 再嵌套 LinearLayout 这种写法,每一层的 weight 计算和 relativeLayout 的两次 measure 都在消耗时间。
两个立竿见影的手段:
第一,用 ConstraintLayout 扁平化嵌套。一个典型的 header + content + footer 三段式布局,用 LinearLayout 嵌套至少 3 层,用 ConstraintLayout 可以打平到 1 层。ConstraintLayout 内部用的是 Cassowary 约束求解算法,虽然单次 measure 比 FrameLayout 慢一点,但胜在层级少,总体时间反而更短。
第二,用 merge 标签消除冗余的根布局 。当你的 include 布局的根节点和外层容器是同一类型时,用 <merge> 替代,可以减少一层无用嵌套。
xml
<!-- 优化前:多了一层 FrameLayout -->
<FrameLayout> <!-- 外层 -->
<include layout=
"@layout/toolbar_content"/>
</FrameLayout>
<!-- toolbar_content.xml (优化前) -->
<FrameLayout> <!-- 冗余! -->
<ImageView ... />
<TextView ... />
</FrameLayout>
<!-- toolbar_content.xml (优化后) -->
<merge>
<ImageView ... />
<TextView ... />
</merge>
3.3 异步 Inflate:把 XML 解析搬到子线程
如果你的首页布局实在复杂,inflate 本身就要 80~100ms,那可以考虑 AsyncLayoutInflater。它在子线程做 XML 解析和 View 创建,完成后回调到主线程添加到 Window。
kotlin
class MainActivity :
ComponentActivity() {
override fun onCreate(
savedInstanceState: Bundle?
) {
super.onCreate(savedInstanceState)
AsyncLayoutInflater(this)
.inflate(
R.layout.activity_main,
null
) { view, _, _ ->
setContentView(view)
initViews(view)
}
}
}
注意事项:AsyncLayoutInflater 有几个限制。第一,布局里不能使用需要在主线程创建的 View(比如 SurfaceView、TextureView)。第二,LayoutInflater.Factory 和 Factory2 在异步线程上的行为可能跟主线程不一致。第三,AppCompat 的 View 替换(AppCompatTextView 替代 TextView)在异步场景下可能失效。实际使用前一定要充分测试。
四、图片预加载:让首帧有内容可画
布局 inflate 快了,measure/layout 也快了,但首帧渲染出来一看:一堆灰色占位图。图片还在网络上飞呢。
对于首屏图片,最好的策略是在 Application 初始化阶段就开始预热图片库和预加载关键图片。
4.1 Coil 预热
如果你用的是 Coil(Kotlin-first 的图片库,现在基本是 Compose 项目的标配),预热非常简单:
kotlin
class ImagePreloadTask :
StartupTask() {
override val name =
"image_preload"
override val dispatcher =
TaskDispatcher.IO
override val
mustBeforeFirstFrame = false
override suspend fun execute(
context: Context
) {
val imageLoader = context
.imageLoader
// 预加载首页 banner 图片
val bannerUrls =
HomeRepository.getCachedBanners()
.map { it.imageUrl }
bannerUrls.forEach { url ->
val request = ImageRequest
.Builder(context)
.data(url)
// 只做磁盘缓存,不解码
.size(
Size.ORIGINAL
)
.memoryCachePolicy(
CachePolicy.DISABLED
)
.build()
imageLoader.enqueue(request)
}
}
}
注意这里有个取舍:预加载任务设置了 mustBeforeFirstFrame = false,因为图片预加载是网络操作,不能保证在首帧前完成。它的作用是"尽早开始",这样当用户看到首页的时候,图片可能已经在磁盘缓存里了。
4.2 Glide 预热
如果你用的是 Glide,思路一样,API 稍有不同:
kotlin
// Glide 预热:预加载到内存缓存
fun preloadWithGlide(
context: Context,
urls: List<String>
) {
urls.forEach { url ->
Glide.with(context)
.load(url)
.diskCacheStrategy(
DiskCacheStrategy.ALL
)
.preload()
}
}
// Glide 独有:预热 ViewTarget 的尺寸
// 避免首次加载时的 measure 等待
fun preloadWithSize(
context: Context,
url: String,
width: Int,
height: Int
) {
Glide.with(context)
.load(url)
.override(width, height)
.preload()
}
4.3 本地缓存策略:让首帧永远有数据
图片预加载解决了"图片慢"的问题,但还有一个更根本的问题:首页的数据从哪来?
如果你的首页数据全靠网络请求,那首帧渲染的时候 RecyclerView 里是空的,用户看到的就是一个加载动画。这就是为什么很多 App 即使做了 SplashScreen + 布局优化,首帧看起来还是"空"的。
解决方案是缓存策略:先展示上一次的数据,后台刷新后再更新。
kotlin
class HomeRepository(
private val api: HomeApi,
private val cache:
HomeCacheDao
) {
fun getHomeFeed():
Flow<UiState<HomeFeed>> =
flow {
// 第一步:立即发射缓存数据
val cached = cache.getLatest()
if (cached != null) {
emit(
UiState.Success(cached)
)
}
// 第二步:后台请求最新数据
try {
val fresh = api.getHomeFeed()
cache.save(fresh)
emit(
UiState.Success(fresh)
)
} catch (e: Exception) {
if (cached == null) {
emit(
UiState.Error(e)
)
}
// 有缓存就静默失败
}
}
}
这个模式叫 Cache-then-Network(也有人叫 Stale-While-Revalidate),几乎所有主流 App 的首页都在用。配合 Room 或 DataStore 做本地持久化,首帧永远有数据可展示。
五、Compose 场景下的首帧优化
如果你的项目已经迁移到 Jetpack Compose,首帧优化的思路有一些不同。Compose 没有 XML inflate 的过程,但它有自己的"首次组合"开销。
5.1 Compose 首帧的性能特征
Compose 的首帧渲染大致经历这些阶段:
• setContent{} 启动 Composition
• 首次 Composition:执行所有 Composable 函数,构建 SlotTable
• Layout:计算每个节点的位置和大小
• Draw:在 Canvas 上绘制
Compose 的首次 Composition 通常比 XML inflate 更快(因为不需要反射创建 View),但有两个隐藏的性能陷阱:
陷阱一:首次编译开销。Compose 运行时在首次遇到一个 Composable 时,需要做一些初始化工作(SlotTable 分配、remember 块执行等)。如果你的首页有大量 remember 计算和 LaunchedEffect,这些都在首帧执行。
陷阱二:LazyColumn 的首屏 item 创建。LazyColumn 在首帧会创建可见区域内的所有 item。如果你的首页 feed 的每个 item 都很复杂(嵌套 Row/Column、图片加载、动画),首帧创建 5~8 个 item 的开销会很可观。
5.2 优化手段
手段一:用 derivedStateOf 减少不必要的重组。
kotlin
// 避免每次 scroll offset 变化
// 都触发重组
val showFab by remember {
derivedStateOf {
listState
.firstVisibleItemIndex > 2
}
}
手段二:给 LazyColumn 的 item 添加稳定的 key。
ini
LazyColumn {
items(
items = feedItems,
key = { it.id } // 稳定的 key
) { item ->
FeedCard(item)
}
}
有了稳定的 key,当数据从缓存切换到网络最新数据时,Compose 可以精确判断哪些 item 没变、哪些是新增的,避免全量重组。
手段三:用 @Stable 和 @Immutable 标注数据类。
kotlin
@Immutable
data class FeedItem(
val id: String,
val title: String,
val imageUrl: String,
val timestamp: Long
)
@Immutable 告诉 Compose 编译器:这个类的所有属性都不会变。编译器会跳过对这个对象的 equals 检查,直接认为"如果引用没变,内容就没变"。对于首屏渲染,这意味着更少的比较开销。
5.3 Compose 的 Baseline Profile
这是一个经常被忽略但效果显著的优化手段。Android Runtime(ART)在首次运行 App 时是解释执行的,JIT 编译需要时间积累热点。Baseline Profile 可以在安装时就把关键路径的代码预编译成机器码,绕过解释执行阶段。
这对 Compose 尤其重要,因为 Compose 运行时本身有大量的函数调用(Composer、SlotTable 操作等),这些代码如果是解释执行的,首帧会明显变慢。
scss
// build.gradle.kts
plugins {
id("androidx.baselineprofile")
}
dependencies {
baselineProfile(
project(
":benchmark"
)
)
}
// benchmark/src/.../BaselineProfileGenerator.kt
@get:Rule
val rule =
BaselineProfileRule()
@Test
fun generateProfile() {
rule.collect(
packageName =
"com.example.app"
) {
// 模拟用户的启动路径
pressHome()
startActivityAndWait()
// 滚动首页列表
device.findObject(
By.res("feed_list")
).scroll(
Direction.DOWN, 3f
)
}
}
根据 Google 的官方数据,Baseline Profile 通常能带来 20~40% 的首帧渲染提速。在 Compose 项目上效果尤其明显,因为 Compose 运行时代码量大,JIT 预热时间长。
六、实战案例:一次完整的首帧优化过程
把上面所有技巧串起来,看一个完整的优化过程。
背景:一个电商 App,首页是 feed 流,冷启动首帧时间 1200ms(从 Activity onCreate 到 TTFD 报告)。
优化路线与效果
| 优化项 | 手段 | 节省 |
|---|---|---|
| SplashScreen | 品牌 Splash 覆盖白屏 | 感知 -400ms |
| ViewStub | 4 个非首屏布局延迟加载 | -45ms |
| 布局扁平化 | ConstraintLayout + merge | -35ms |
| 图片预加载 | Glide preload + 磁盘缓存 | -120ms |
| 缓存策略 | Cache-then-Network | -280ms |
| Baseline Profile | 关键路径预编译 | -150ms |
| 合计 | TTFD: 1200ms → 570ms |
其中效果最大的两项是缓存策略 和 Baseline Profile。缓存策略让首帧直接有数据可渲染,省掉了网络等待;Baseline Profile 让 Compose 运行时代码一上来就是编译好的机器码,不用等 JIT。
加上 SplashScreen 的视觉优化,用户感知到的"白屏"从 400ms 降到了接近 0。TTFD 从 1200ms 降到了 570ms,压缩了 52%。产品经理终于不再说"怎么还是白屏"了。
但这里有一个残酷的现实:这些数字是你在开发机上测的。线上用户的设备分布从骁龙 8 Gen 3 到五年前的联发科 Helio G85,存储从 UFS 4.0 到 eMMC 5.1。你的 570ms 在低端机上可能是 1500ms。没有线上监控,你根本不知道优化对真实用户有没有效。
七、首帧优化检查清单
最后整理一份 checklist,方便你在自己的项目里对照检查:
回顾一下这个系列的进度:第1篇理解了冷启动全流程,第2篇学会了用 Perfetto 定位瓶颈,第3篇用 DAG 调度器解决了初始化串行问题,这一篇把首帧白屏也干掉了。但启动优化最难的不是"做到",而是"保住"。下一篇《线上监控与防劣化:让启动优化成果不再回退》,也是本系列的最终篇,我们来搭建启动性能的最后一道防线:自动化埋点 + Perfetto 上报 + CI 卡点机制,让每一次劣化都能在合入代码前被拦截。