首帧渲染优化:从白屏到内容可见的最后一公里

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 卡点机制,让每一次劣化都能在合入代码前被拦截。

相关推荐
AI玫瑰助手2 小时前
Python基础:字符串的常用内置方法(查找替换分割)
android·开发语言·python
xiangxiongfly9153 小时前
Android 使用WebSocket通信
android·websocket·网络协议·okhttp
su_ym81104 小时前
Android属性系统
android·framework·property
明天就是Friday4 小时前
Android实战项目③ Room+Clean Architecture开发待办事项App 完整源码详解
android
没有了遇见4 小时前
《彻底搞懂 ViewModel:作用、原理与源码分析》
android
Fate_I_C4 小时前
Kotlin 协程:串行/并行请求、async/await、coroutineScope 管理并发、重试机制
android·代码规范
山河梧念5 小时前
【保姆级教程】VMware虚拟机安装全流程
android·java·数据库
常利兵5 小时前
Kotlin类型魔法:Any、Unit、Nothing 深度探秘
android·开发语言·kotlin
y小花5 小时前
安卓vold服务
android·linux·运维