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

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

相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo2 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077002 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼2 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone2 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen2 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone2 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui