本指南完整记录并整理了我们关于 第 12 章《重组与性能优化原理(新时代实战版)》 课程设计的深度讨论,包括现代 R8 优化、强跳过模式(Strong Skipping)下的真实运行行为、@Stable@Immutable 的底层异同,以及 Baseline Profiles 在中国无 Google Play 生态下的闭环运行机制与 AGP 9.2.1 的踩坑修复。


📌 课程大纲重构:《第 12 章:重组与性能优化(新时代实战版)》

经过我们对 100% 实用主义Compose 最新版本特性(1.11+ / Kotlin 2.x) 的深度融合,第 12 章的最优实战大纲被精简和升华为以下 5 个极具落地价值的课节:

开篇:别怀疑,Compose 的性能其实非常好

第一部分:时代变了(编译器帮你做的事)

第二部分:你唯一需要手写的优化(状态延迟读取)

第三部分:工具与冷启动(解决最后的死角)


📖 课程精讲专栏:12.1 性能测试第一军规:别在 Debug 模式下测卡顿!

如果你问一个刚刚从 View 系统转型写 Jetpack Compose 的 Android 程序员:「你对 Compose 的第一印象是什么?」 很大一部分人会告诉你:「卡!太卡了!」

特别是当他们在本地直接按 Run(运行 Debug 变体),在手机上滑动一个稍微复杂点的 LazyColumn,或者拖动一个高频更新的自定义 Slider 时,那种肉眼可见的掉帧和阻尼感,会让他们瞬间陷入怀疑:「我是不是选错技术栈了?难道 Google 吹上天的声明式 UI 只是个玩具?」

🚨核心军规

本节课的黄金法则是:别在 Debug 模式下测试 Compose 的任何流畅度!

这是 Compose 性能测试的第一军规。在 Debug 模式下测量卡顿,是在和一个被插满了「调试管子」的虚弱版 Compose 战斗。它不仅不代表生产环境的真实性能,甚至会给你带来完全错误的重构和设计误导。

为了把这个道理讲透,我们需要剥开 JVM、Android 操作系统以及 Compose 编译器的内核,来看看 Debug 和 Release 之间,到底差了多少道物理鸿沟。


一、 经典对决:为什么 View 系统的 Debug 包感觉不卡,而 Compose 的 Debug 包极其卡顿?

很多开发者会理所当然地认为:「不管是用 XML 写的 View 还是用 Kotlin 写的 Compose,大家都是在 Debug 模式下跑,底层的虚拟机也一样。如果 View 系统不卡,你 Compose �

📖 课程精讲专栏:12.2 时代变了!拥抱 Strong Skipping 强跳过,我们现在到底还要不要手写 @Stable 和 @Immutable?

在 Compose 早期(1.7 以前的旧石器时代),性能优化对开发者来说简直就是一门「玄学」或者「苦力活」。 各种性能规范和博客都在苦口婆心地劝诫你:

这些战战兢兢的规训,让原本极其简洁优雅的声明式代码变得面目可憎,到处堆满了 remember 和奇形怪状的注解。

但是今天,我要明确地告诉你:时代变了! 随着 Strong Skipping Mode (强跳过模式) 被 Compose 编译器在 1.7.0+ / Kotlin 2.x 中作为默认选项开启,编译器已经比你想象的聪明了数倍。

在现代 Compose 开发中,你已经不需要盲目、无脑地到处手写 @Stable@Immutable,也不需要手动去缓存每一个 onClick。 那么,强跳过的底层原理是什么?它到底帮我们解决了什么问题?它又有哪些不可逾越的「物理软肋」?我们又在什么场景下,才必须重新请出 @Stable/@Immutable 这两尊大佛?

我们今天用一堂课,把它们彻底讲个透!


一、 强跳过模式 (Strong Skipping Mode) 到底强在哪里?

1. 自动 Lambda 缓存(Lambda Memoization)

在旧时代,如果你这样写代码:

Button(onClick = { viewModel.doSomething() }) { ... }

Compose 编译器在编译后,会把这个 { viewModel.doSomething() } 翻译为一个新的匿名内部类实例。这意味着,每次外层 Composable 重组,都会在堆内存中重新创建一个全新的 Lambda 对象实例。 由于这个 Lambda 实例的内存地址变了(instance1 !== instance2),哪怕 Button 的其他参数完全没变,也无法跳过(Skip)重组。

现在: 强跳过模式下,编译器会自动对你所有的 Composable Lambda 进行 自动 remember 缓存! 上面的代码在被编译器重写后,实际上变成了:

val tempLambda = remember(viewModel) { { viewModel.doSomething() } }
Button(onClick = tempLambda) { ... }

除非你捕获的 viewModel 实例本身变了,否则这个 Lambda 对象的引用在多次重组中将绝对保持一致!你不需要再手写任何 remember 去包装普通的点击回调,编译器已经帮你全部无感做好了。

2. Unstable(不稳定)参数,也可以「跳过重组」!

这是强跳过模式最颠覆性的地方。 在以前,Compose 编译器会对 Composable 的每一个参数进行「稳定性(Stability)」评估。如果参数类型 is Unstable(比如最常见的 List,或者包含非 final 属性的普通 class),该 Composable 函数就会被标记为 Non-skippable(不可跳过)。 只要父级有任何风吹草动,该组件必须强制无条件参与重组,即使传进来的 List 内容完全一样。

现在: 开启强跳过模式后,哪怕传进来的参数是 Unstable 类型的,这个 Composable 也是可跳过的(Skippable)! 但重点来了:它是怎么判定跳过的呢?

这使得 90% 的普通开发场景中,哪怕你不给参数加任何注解,只要实例没重建,列表和组件也是极其流畅、完美跳过重组的。


二、 强跳过的物理软肋:你一 copy(),它就彻底歇菜了!

听上去强跳过已经完美解决了所有的重组卡顿,那我们是不是就可以彻底把 @Stable@Immutable 扔进历史的垃圾堆了?

答案是:绝对不行。

因为引用比较(===)是非常脆弱的。在现代 Android 开发的黄金架构体系(MVI / Unidirectional Data Flow 单向数据流)中,我们通常采用状态的 不变量(Immutable State)与浅拷贝(copy() 来通知 UI 刷新:

// 1. 这是一个 Unstable 的状态类(因为内部持有了 Unstable 的 List)
data class UserListState(
val users: List<String>,
val isLoading: Boolean
)
@Composable
fun UserListScreen(state: UserListState) {
// ...
}

现在,假设我们更新了 isLoading 状态:

// 2. 浅拷贝生成了一个全新的状态对象
state = state.copy(isLoading = false)

这个时候,虽然 users 列表的内容和地址都没有发生任何变化:

后果: UserListScreen 的强跳过机制瞬间被击穿!即使你的 users 列表里躺着 1 万个用户且毫无变化,整个屏幕的列表也会被迫进行一次非常昂贵的全量重组。


三、 终极救赎:重新请出 @Stable@Immutable

要修复上面这个致命问题,就需要用到 @Stable@Immutable 注解。

当我们给类加上注解时:

@Immutable
data class UserListState(
val users: List<String>,
val isLoading: Boolean
)

我们是在向 Compose 编译器立下了一份「免死金牌契约」:

「亲爱的编译器,虽然 List 默认是 Unstable 的,但我保证我绝对不会在运行时去偷偷调用 (users as ArrayList).add()。请把 UserListState 标记为 Stable 类型!」

编译器采信了你的契约。从此,UserListState就拥有了贵族身份:

这就是为什么你依然必须写注解的核心原因:只有赋予类 Stable 身份,编译器才会从引用比较升级为值/内容比较,从而阻止因为浅拷贝(copy)或新对象生成而带来的无效全屏重组。


四、 底层事实:在现代 Compose 编译器中,@Stable@Immutable 有区别吗?

这是一个绝大多数中高级 Android 面试官和文章都搞错过的问题。

那么在实际教研中,我们怎么给学生讲? 虽然编译器一视同仁,但在语义层面和代码可读性上,我们要给出清晰的界定:

  1. 数据实体类/UI 状态类 (Entity, State Class):全部使用 val 且代表只读数据的,语义上无脑选择 @Immutable
  2. 状态持有者/业务控制器 (State Holder, Presenter, ViewModel 辅助类):内部含有 mutableStateOf 状态可变属性、或者有方法可以主动改变状态的类,语义上无脑选择 @Stable

五、 终极实战黄金铁律:这俩注解到底还写不写?怎么写?

为了让团队和学生在实际开发中不需要每次都纠结,我们提炼出一套极简的 「写与不写」日常决策树。请务必让大家背下来:

🚫 规则 1:绝对不写(编译器已经能自动识别 Stable)

如果你的 data class 内部只包含了以下稳定字段,千万不要写任何注解,编译器会自动帮你推断为 Stable:

⚠️ 规则 2:必须写(强跳过无法解决值拷贝的场景)

如果你的类内部包含了以下任何一个元素,并且这个类会在单向数据流(如 copy())中高频传输:

  1. 持有了任何 Java 集合接口:如 List, Set, Map
    • 原因:标准 Java/Kotlin 集合在编译器眼中永远是 Unstable 的(因为编译器不知道传进来的是不是一个可以随时 addArrayList)。
  2. 持有了第三方的 Unstable 类(如未适配 Compose 的普通三方库的类、或者包含 var 的类)。

💡 怎么写最优雅?


💡教师备课金句

「强跳过模式是 Compose 编译器送给所有开发者的『保底盔甲』,它保证了新手即便写出不及格的依赖,应用也不会轻易卡死。但要想在复杂的多层级 UI 中达到绝对丝滑的流畅度,@Stable@Immutable 就是我们主动指引编译器进行精准内容比对的『手术刀』。」


📖 课程精讲专栏:12.3 终极杀招:状态延迟读取 (Defer State Reads) 与一括号之差的性能跃迁

在手写性能优化里,如果说 @Stable@Immutable 是为了解决「不该发生的重组」,那么 状态延迟读取 (Defer State Reads) 就是为了解决「不可避免的高频重组」。

设想一下这个极其经典的 Android 实战场景: 你要做一个精美的「折叠式 AppBar」或者「知乎同款滑动头图」。随着底部的 LazyColumn 向上滑动,顶部的头图需要流畅地缩小、改变透明度,并产生视差位移。 你写下了如下代码,并在 Release 包中满怀期待地运行。然而,当你的手指开始在屏幕上滑动时,界面不仅没有想象中的丝滑,反而伴随着一阵阵肉眼可见的细微抖动和掉帧。

打开 Layout Inspector 一看,你直接惊呆了:随着你手指滑动,头图组件的重组(Recomposition)计数器在以每秒 60 到 120 次的恐怖速度疯狂飙升!

🚨终极核心法则

高频变动状态,必须延迟读取!

在拖拽、滑动、手势交互或高频属性动画中,状态(State)的值在以每帧(通常是 8.3ms 或 16.6ms)为单位发生改变。如果我们在这个时候触发了重组,就相当于让 CPU 在每帧都去重新执行一次全量的方法组合(Composition),这无异于杀鸡用牛刀。

解决这个痛点的终极杀招只有八个字:一括号之差,重组归零


一、 核心底座:Compose 渲染三阶段与状态追踪机制的「时间差」

要理解为什么通过给 Modifier 加一个大括号 {} 就能让重组次数暴降为零,我们必须重新审视 Compose 极其优雅的 渲染三阶段(The Three Phases)

Compose 的声明式设计中,有一个非常精妙的底层设计:状态(State)是精准按需追踪(Scope-based State Tracking)的

这这就产生了一个巨大的 「时间差」 优化空间: 如果我们能在 1. 组合阶段 强行不读取状态的值,而是将读取行为包装成一个 Lambda 闭包(即一个方法引用),传递给下层。直到 2. 布局阶段3. 绘制阶段,该闭包才被真正运行(即状态才被真正读取)。 那么,状态变化时,组合阶段(Composition)将完全不被波及(零重组!),系统只会轻量级地重新运行布局(Layout)或绘制(Draw)作用域。这就像直接绕过了「设计师」,直接让「泥水匠」在墙上刷漆一样,性能发生了成百上千倍的跃迁!


二、 经典对决:一括号之差,重组次数瞬间暴降为零

在很多新手教程或者不严谨的自学代码中,大家在演示「普通传参」和「延迟读取」对比时,极易写出这样的代码:

// ❌ 极其常见的单位混淆 Bug
.offset(y = scrollState.value.dp)
🚨🚨 敲黑板:这里隐藏着一个致命的物理单位 Bug!

ScrollState.value 返回的数值单位是「物理像素」(Pixels / px),类型是 Int 如果你在小括号版本的 offset(y: Dp) 里直接写 scrollState.value.dp,实际上是直接把物理像素的数量当作 DP 传了进去。 在 3x 像素密度(XXHDPI)的设备上,300px 的滑动实际距离本应该相当于 100dp。但如果你直接写 300.dp,在屏幕上就会位移 300dp(相当于 900px),导致滑动速度直接被放大了 3 倍!这是一个极其典型的单位混淆 Bug!

因此,如果想让不加括号的版本在「物理运行行为」上正确,我们必须引入 LocalDensity 进行显式单位转换。

现在,我们将真正物理正确的「转换后普通版本」与「延迟读取闭包版本」进行最终降维对决:

1. 战败方案:直接读取状态(混淆单位或引发全屏雪崩重组)

在项目 ui/components/BadParallaxHeader.kt 中:

package com.rengwuxian.coursecompose.ui.components
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
/**
* ❌ 负面教材:引发全屏雪崩式高频重组的视差组件
*/
@Composable
fun BadParallaxHeader(
scrollState: ScrollState,
modifier: Modifier = Modifier
) {
// ❌ 即使我们用了正确的单位转换:with(LocalDensity.current) { scrollState.value.toDp() }
// 本质上依然是在组合(Composition)阶段直接读取了 scrollState.value。
// Modifier.offset(y: Dp) 接收普通值,Kotlin 必须在组合阶段求出具体值。
// 当 scrollState 随着滚动以每秒 60-120 像素变化时,整个 BadParallaxHeader 被迫高频重组!
val density = LocalDensity.current
val offset = with(density) { scrollState.value.toDp() } // ❌ 状态在组合阶段被高频读取!
Box(
modifier = modifier
.offset(y = offset)
.fillMaxWidth()
.height(200.dp)
.background(Color.Blue)
)
}

2. 战胜方案:延迟读取(大括号的降维打击)

在项目 ui/components/GoodParallaxHeader.kt 中:

package com.rengwuxian.coursecompose.ui.components
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.IntOffset
/**
* 🚀 正确示范:使用大括号 Lambda 延迟状态读取
*/
@Composable
fun GoodParallaxHeader(
scrollState: ScrollState,
modifier: Modifier = Modifier
) {
// 🚀 战胜方案:调用 offset(offset: Density.() -> IntOffset)
// 1. 完美解决重组:传入的是一个 Lambda 闭包(大括号),组合阶段只传递了闭包引用,并没有读取状态值!
// 状态读取延迟到了布局测量阶段(Layout Phase),重组计数彻底归零。
// 2. 完美匹配单位:Modifier.offset { ... } 闭包内接收的 IntOffset 本身就是【像素(px)】单位!
// 我们直接传入 scrollState.value,连 LocalDensity 的 toDp() 运算都省掉了,堪称天作之合!
Box(
modifier = modifier
.offset { IntOffset(0, scrollState.value) } // 🚀 大括号魔法:像素对像素,状态读取延迟至布局!
.fillMaxWidth()
.height(200.dp)
.background(Color.Blue)
)
}

📊 性能数据全面对决

维度Modifier.offset(y = density.toDp()) (普通传值写法)Modifier.offset { IntOffset(0, state.value) } (拥抱闭包)
状态读取发生阶段组合阶段 (Composition)布局阶段 (Layout/Placement)
滑动时的重组计数 (Recomposition)无限飙升 (每帧都重组,全屏雪崩)绝对为 0 ! (全场静默)
单位转换开销需要通过 toDp() 频繁进行密度乘除运算零开销 (像素直达,无多余运算)
布局阶段开销触发全量重新测量 (Measure) 与重新摆放 (Place)跳过测量 (Measure),仅重新摆放 (Place)
CPU 占用率极极高 (多核负荷严重,导致发烫和阻尼掉帧)极低 (近乎不占 CPU,流畅度突破天际)
适用场景仅适用于静态、一次性初始化的静态布局强力推荐用于滑动、手势、高频动画场景

三、 极致通道:graphicsLayer { ... } —— 零测量、零重组的 GPU 物理加速通道

如果我们要在滑动时改变的不是位移(Offset),而是 透明度(Alpha)、缩放(Scale)、旋转(Rotation)或者高斯模糊(RenderEffect) 呢?

答案是:我们有比 Offset 还要快上数倍的物理外挂通道 —— graphicsLayer

⚠️列表滚动性能大坑

拒绝在性能优化章节使用 Column + verticalScroll 许多新手教程在演示折叠头图时,会简单粗暴地使用 Column(Modifier.verticalScroll) 来实现。这是一个极具破坏性的反优化性能巨坑!因为 Column 没有子节点回收机制,当你的列表有成百上千项时,哪怕你通过延迟读取把重组降低到了 0,无回收的组件测量、实例化开销也会彻底卡死内存和 CPU。

在工业级生产中,我们必须无脑拥抱 LazyColumn (延迟列表)。但在 LazyColumn 下,LazyListState 的滚动偏移量计算(如 firstVisibleItemScrollOffset)极高频且敏感,极易引发全屏雪崩重组。 因此,下面的高级实战将向你展示:如何完美打通 LazyColumn 高性能内存回收,与 graphicsLayer 零重组 Draw 阶段延迟读取的双神融合。

以下是一套完整的、可直接在课程或生产中使用的折叠式视差头图组件,完美运用了 graphicsLayer { ... } 零测量、零重组通道:

在项目 ui/screens/ParallaxCollapsibleScreen.kt 中:

package com.rengwuxian.coursecompose.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rengwuxian.coursecompose.R
/**
* 🚀 生产级折叠视差头图极致滑动流畅度示例(全面适配 LazyColumn 懒加载高性能列表)
*/
@Composable
fun ParallaxCollapsibleLazyScreen(
modifier: Modifier = Modifier
) {
// 1. 创建高性能延迟列表滚动状态
val lazyListState = rememberLazyListState()
Box(modifier = modifier.fillMaxSize()) {
// 🚀 头图置于底部:使用 graphicsLayer 零重组、零测量 GPU 加速
// 我们将高性能 lazyListState 单向透传给头图组件
ParallaxImageHeader(lazyListState = lazyListState)
// 2. 高性能延迟加载列表:覆盖在头图之上,支持大容量 Item 节点复用与内存回收
LazyColumn(
state = lazyListState,
modifier = Modifier.fillMaxSize()
) {
// 💡 重点设计:第一项放置一个透明 Spacer,充当头图所需的折叠占位高度(250dp)
item {
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(250.dp)
)
}
// 真实正文内容面板区
item {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color.White)
.padding(24.dp)
) {
Text(
text = "Android 终极优化:视差折叠正文",
fontSize = 22.sp,
modifier = Modifier.padding(bottom = 16.dp)
)
}
}
// 3. 高性能大量列表 Item 复用区
items(100) { index ->
Text(
text = "这是第 ${index + 1} 个 Feed 列表项。顶部的风景图在拖拽滑动中会自动变淡、缩小并产生视差平移。由于将 LazyListState 的读取延迟至了 graphicsLayer 的 Draw 阶段,整个页面没有触发哪怕 1 次 Composition重组,真正达到了工业级的流畅表现!",
fontSize = 15.sp,
color = Color.Gray,
modifier = Modifier
.fillMaxWidth()
.background(Color.White)
.padding(horizontal = 24.dp, vertical = 10.dp)
)
}
}
}
}
@Composable
fun ParallaxImageHeader(
lazyListState: LazyListState,
modifier: Modifier = Modifier
) {
Image(
painter = painterResource(id = R.drawable.header_bg), // 请确保有此图片资源
contentDescription = "Parallax Header Image",
contentScale = ContentScale.Crop,
modifier = modifier
.fillMaxWidth()
.height(250.dp)
// 🚀 核心魔法:graphicsLayer { ... } 闭包
// 延迟读取 LazyListState 的滑动偏移量至 Draw(绘制)阶段!
// 滚动列表时,组合(Composition)与布局(Layout)阶段保持完全静默,
// 任何高频变化的位移、透明度、缩放操作被直接转化为系统底层的 RenderNode 属性,交付 GPU 物理加速完成。
.graphicsLayer {
// 💡 延迟状态读取:从 lazyListState 中仅当第一项(Spacer 占位)可见时计算像素位移
val scrollOffset = if (lazyListState.firstVisibleItemIndex == 0) {
lazyListState.firstVisibleItemScrollOffset.toFloat()
} else {
1200f // 只要第一项已被划走,直接判定为划满
}
// 计算当前滚动的比率(最大滑动 500 像素)
val progress = (scrollOffset / 500f).coerceIn(0f, 1f)
alpha = 1f - progress // 实现变淡
scaleX = 1f - progress * 0.15f // 实现横向轻微收缩
scaleY = 1f - progress * 0.15f // 实现纵向轻微收缩
// 2. 产生 0.5 倍速的位移视差
translationY = scrollOffset * 0.5f
// 3. 开启后台离屏隔离渲染(RenderNode 隔离)
clip = true
}
)
}

四、 日常开发避坑黄金铁律:如何快速发现「非 Lambda 不规范写法」?

很多开发者在听完这堂课后会有些迷茫:「Compose 里的 Modifier 这么多,我怎么知道哪些 Modifier 支持 Lambda,哪些不支持?我总不能每次都去翻源码吧?」

别慌,我们为你提炼了一套极简的 「快速避坑与重构公式」,让你和团队一眼就能识别并干掉高频性能杀手:

📋 黄金重构备忘录:

变动属性❌ 拖后腿写法 (高频重组)🚀 满帧优化写法 (零重组延迟读取)
位移变动 (Scroll Offset)Modifier.offset(y = state.value.dp)Modifier.offset { IntOffset(0, state.value) }
大小与宽高变动Modifier.size(state.value.dp)Modifier.layout { measurable, constraints -> ... } (或使用 offset)
透明度变动 (Alpha)Modifier.alpha(alphaState.value)Modifier.graphicsLayer { alpha = alphaState.value }
缩放/旋转 (Scale/Rotate)Modifier.scale(scaleState.value)Modifier.graphicsLayer { scaleX = ...; rotationZ = ... }
背景色/渐变色变动Modifier.background(colorState.value)Modifier.drawBehind { drawRect(colorState.value) }

🔍 备课排查三步法:

  1. 看频次:这个属性是不是由滑动(Scroll)、动画(Animator)、手势(Drag)或传感器高频驱动?如果是,进入第二步。
  2. 找大括号:检查它使用的 Modifier 是否是普通的传值方法?有没有带大括号 {} 的 Lambda 变体?
  3. 闭包化
    • 如果有 Lambda 变体(如 offset),无脑改成 Lambda 写法
    • 如果是视觉动画相关(透明度、缩放、裁剪、阴影、矩阵),统统塞进 graphicsLayer { ... } 闭包
    • 如果是自定义背景或边框,统统塞进 drawBehind { ... }drawWithContent { ... } 闭包

💡教师备课金句

「在 Compose 的世界里,大括号 {} 不仅仅是语法的边界,更是幕后的性能防火墙。那多出来的一对大括号,就相当于给极其昂贵的『组合阶段』挂上了一块『请勿打扰』的牌子,把所有高频变化的状态,精准制导、延迟发射到了布局和绘制阶段。」

��也可以「跳过重组」!


📖 课程精讲专栏:12.4 性能排查工具双剑合璧:Layout Inspector 动态诊断 + 编译器报告静态捉鬼

在开发大型、复杂、高频交互的 Jetpack Compose 项目时,性能泄露或卡顿往往隐藏在错综复杂的 Composable 调用链路深处。 如果仅凭经验「肉眼看代码」去盲猜哪个组件在卡顿、哪个参数是不稳定的(Unstable),无异于盲人摸象。 在这一节课中,我们将手把手教你如何将 Layout Inspector(动态诊断)Compose Compiler Metrics(静态报告) 这两把神器联合起来,构筑一套闭环的、无死角的「捉鬼」流水线。

⚠️性能排查核心铁律

不要猜!用数据说话!

排查 Compose 性能问题应当像医生看病一样严谨:

  1. Layout Inspector 进行「动态听诊」,精准定位是哪个组件在什么操作下发生了异常频次的重组。
  2. Compose Compiler Metrics 进行「静态化验」,深度剖析该组件中是哪个参数导致了它不能被编译器「跳过」(Not Skippable)。

一、 动态利刃:Layout Inspector 运行时精准抓取重组元凶

Layout Inspector 是 Android Studio 官方提供的最强运行时 UI 诊断工具。自 Electric Eel 版本之后,它内置了对 Compose 重组次数 (Recomposition Count)跳过次数 (Skipped Count) 的原生支持。

🚀 运行时排查「三步法」:

  1. 准备环境:在本地运行一个经过混淆优化的 Release 变体,或者带有 profileable 标记 of Profiler 专属包(关闭调试开销,同时保留性能分析接口)。
  2. 启动面板:依次点击 Tools -> Layout Inspector,在进程列表中选中你的 App 进程。
  3. 滑动捕获:在手机上执行高频卡顿操作(如快速上下滑动列表),同时双眼紧盯 Layout Inspector 表格中的两列数据:
    • Compositions:代表该 Composable 函数被执行的累积次数。
    • Skipped:代表该 Composable 函数在重组中被成功跳过、直接复用上一次结果的累积次数。

🔍 红色警报:怎样的数据指标代表存在性能漏洞?


二、 静态神盾:Compose Compiler Metrics 导出报告无死角静态捉鬼

当你通过 Layout Inspector 发现了 fun MyListItem 这个函数存在重组飙升的问题后,你通常无法一眼看出来究竟是哪个参数在搞鬼。 这时,我们需要请出 Compose 编译器的最底层元数据报告 —— Compiler Metrics

🛠️ 在现代 Kotlin 2.x / AGP 9.2.1 中一键开启报告

在以前,我们需要在 freeCompilerArgs 里面拼接一长串像乱码一样的 -P plugin:androidx.compose... 参数。 现在在现代 Kotlin 2.x 中,我们推荐在主模块的 app/build.gradle.kts 中使用最官方、最优雅的 composeCompiler 闭包 进行配置:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
// 现代 Compose 编译器插件(自 Kotlin 2.0 起作为核心官方组件)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.rengwuxian.coursecompose"
compileSdk = 35
defaultConfig {
applicationId = "com.rengwuxian.coursecompose"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true // 测流畅度必备:开启 R8 代码裁剪与混淆
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
buildFeatures {
compose = true
}
}
// 🚀 一键开启 Compose 静态化验编译器报告报告配置
composeCompiler {
// 自动将报告输出并保存到 build/compose_compiler 目录下
reportsDestination.set(layout.buildDirectory.dir("compose_compiler"))
metricsDestination.set(layout.buildDirectory.dir("compose_compiler"))
// 强跳过模式(Strong Skipping)通常在这里一并显式开启
enableStrongSkippingMode.set(true)
}

配置完成后,在 AS 终端运行编译命令:

终端
./gradlew :app:assembleRelease

编译成功后,在 app/build/compose_compiler/ 目录下,你会得到三个核心的结构化文件:

  1. app_release-classes.txt:分析所有的类结构,标注它们是 stable 还是 unstable
  2. app_release-composables.txt:分析所有的 Composable 函数,标注它们是 restartable 还是 skippable
  3. app_release-composables.csv:以 CSV 表格形式整理所有方法,适合大型项目进行脚本自动化扫描。

💡 报告怎么读?

我们要要在 *-composables.txt 中,使用 Ctrl+F 搜索你刚刚锁定在 Layout Inspector 中疯狂重组的方法名,例如 MyListItem

// ❌ 典型的「重组狂魔」函数在报告中的状态
restartable fun MyListItem(
unstable item: FeedItem, // 凶手在这里!因为 unstable,导致整个函数无法 skippable
stable modifier: Modifier? = @static Companion
)

接着,我们打开 *-classes.txt,搜索 class FeedItem

unstable class FeedItem {
stable val id: String
unstable val tags: List<String> // 凶手是 List!
}

真相大白!正是因为包含了默认被判定为 unstable 的 List,导致整个 FeedItem 变成 unstable,进而让 MyListItem 失去了 skippable 资格,引发了运行时的滑动掉帧!


三、 双剑合璧:一个高频重组的真实捉鬼闭环演练

为了让学生和团队在日常开发中形成规范的排查思维,我们推荐以下 「五步捉鬼法」 黄金排查闭环:

以下是完整的解决方案,包含不稳定的负面实体两种极致完美的优化修复代码以及高性能的 LazyColumn 列表渲染展示

1. 精准化验:模型稳定性声明与彻底修复(数据层)

在项目 model/FeedModels.kt 中:

package com.rengwuxian.coursecompose.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import kotlinx.collections.immutable.ImmutableList
// ==================== ❌ 负面教材:引发性能雪崩的数据实体 ====================
data class FeedItem(
val id: String,
val title: String,
// ❌ 罪魁祸首:List 是普通接口,其底层具体实现(如 ArrayList)可能在外部被动态增删,
// Compose 编译器无法保证其内容的绝对不改变,因此默认判定该字段为 unstable,进而拖累整个类变成 unstable。
val tags: List<String>
)
// ==================== 🚀 修复方案 ①:使用 Kotlin 官方 immutable 集合(强力推荐) ====================
data class StableFeedItem(
val id: String,
val title: String,
// 🚀 正确:通过 kotlinx.collections.immutable.ImmutableList,
// 在类型签名层面就明确断绝了其可变性。Compose 编译器可以 100% 信任它,整个 StableFeedItem 被判为 stable!
val tags: ImmutableList<String>
)
// ==================== 🚀 修复方案 ②:使用 @Immutable 强行背书作保 ====================
@Immutable
data class AnnotatedFeedItem(
val id: String,
val title: String,
// 🚀 正确:即使内部使用了常规的 mutable List,但因为开发者给数据类声明了 @Immutable 注解,
// 相当于向编译器立下军令状。编译器无脑信任其不可变,将其拉回 stable 阵营,完美支持 skippable!
val tags: List<String>
)

2. 精准听诊:高性能 LazyColumn 列表渲染(UI 表现层)

在项目 ui/components/FeedListScreen.kt 中:

package com.rengwuxian.coursecompose.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rengwuxian.coursecompose.model.StableFeedItem
/**
* 🚀 生产级高性能 Feed 流列表
*/
@Composable
fun FeedListScreen(
itemsList: List<StableFeedItem>,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(
items = itemsList,
// 💡 黄金法则:强制在 items 里声明唯一的 key。
// 这能够极大帮助 Compose 进行列表节点位置映射与局部高效重用,是滑动优化不可割裂的根基。
key = { item -> item.id }
) { item ->
// 🚀 因为 item 已经由 ImmutableList 优化成了稳定的 (Stable),
// StableFeedItemRow 函数被自动标记为 skippable。滑动时,未变项绝对不会触发任何重组!
StableFeedItemRow(item = item)
}
}
}
@Composable
fun StableFeedItemRow(
item: StableFeedItem,
modifier: Modifier = Modifier
) {
Card(
shape = RoundedCornerShape(12.dp),
modifier = modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = item.title, fontSize = 18.sp, color = Color.Black)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
item.tags.forEach { tag ->
Box(
modifier = Modifier
.background(Color.LightGray, RoundedCornerShape(4.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(text = tag, fontSize = 12.sp, color = Color.DarkGray)
}
}
}
}
}
}

💡教师备课金句

「动态听诊,静态化验。Layout Inspector 告诉你『哪里卡了』,Compose Compiler Metrics 告诉你『为什么卡』。双剑合璧,方能以数据为引,精准切除卡顿肿瘤,实现从『盲目猜疑』到『指标制导』的技术思维跃迁。」


📖 课程精讲专栏:12.5 终极护航:冷启动慢?Baseline Profiles 终极冷启动优化

「Compose 写出来的 App,不仅运行时滑动卡,冷启动加载也慢。」 很多刚刚尝试 Compose 的团队在没有做任何优化时,经常会面临这种来自测试、老板甚至是用户的严厉质问。

这背后的真实底层原因并不是 Compose 本身的运行速度慢,而是因为:

  1. JIT(即时编译)的先天短板:经典的 View 系统组件预置在手机系统的 ROM 内部,出厂时就已经被 AOT(运行前编译)成了机器码。而 Compose 是作为第三方库跟着你的 APK 一起分发、打包安装的。在 App 首次启动时,绝大多数 Compose 类和 Composable 语法完全是依赖解释器或运行时的 JIT(即时编译)边解释边运行的,必然带来巨额的 CPU 额外开销。
  2. 类加载的延迟阻碍:Compose 庞大的 Runtime 在首次启动时,涉及到大量的底层类加载、快照系统初始化以及节点构建,这会严重抢占冷启动黄金时间。

为了彻底击碎这一冷启动瓶颈,Google 推出了终极核武 —— Baseline Profiles(基准配置文件)

🚨冷启动性能红线

冷启动是用户体验的生死线!

Baseline Profiles 是将用户冷启动和核心操作路径上的所有类 and 方法,直接打包进 APK 发布。在设备闲置或安装时,系统会提前将这些最耗时的代码 AOT 编译成纯二进制机器码,从而将 冷启动速度暴增 20% - 40%,把卡顿扼杀在摇篮里!


一、 致命痛点:为什么 Compose 应用冷启动白屏、首帧加载慢?

当我们开发普通的 XML 布局应用时,启动速度通常不是问题。这是因为 TextViewImageView 等基础组件是预装在 Android 手机系统的原生镜像(ROM)里的。它们在系统打包出厂时,就已经通过全量 AOT 编译成了极速的纯二进制机器码。

但 Compose 的世界完全不同。Jetpack Compose 是一个独立运行于 APK 内部的三方库。

当用户首次下载并打开一个 Compose 写的 App 时,Android 系统对这个全新的 UI 框架是完全陌生的。系统没办法预先加载它,必须在首帧启动的黄金数十毫秒内,在 JIT(即时编译器)中边解释边执行。由于 Compose 的 Runtime(运行时)非常庞大,包含大量的快照状态管理、局部重组机制和类加载逻辑,这使得大量的 CPU 运算堆积在启动首帧,不可避免地导致了白屏和视觉掉帧。

这就是我们需要 Baseline Profiles 的根本原因。


二、 历史的必然:Android 编译模型的演进史与 Baseline Profiles 的诞生

为了让学生和团队深刻理解 Baseline Profiles 的本质,我们不能孤立地学习这套工具。我们需要把时钟拨回 Android 诞生之初,去看看系统底层的编译模型经历了怎样波澜壮阔的演进,你就会明白 Baseline Profiles 的诞生是历史的必然

Android 版本编译模型阶段核心机制与痛点演进
Android 5.0Pure AOT 时代全量 AOT 编译成 OAT 机器码
(安装极慢、空间占用暴增、OTA 开机卡死)
Android 6.0Pure AOT + Filters引入 Compiler Filters (quicken/speed)
(依旧是全量 AOT 大框架)
Android 7.0Hybrid 混合编译秒装 / 运行时 JIT 录制 Profile / 夜间 BackgroundDexOpt 局部 AOT
(首次启动 Warm-up Gap 卡顿)
Android 8.0Hybrid + VDEX引入 .vdex 格式,将字节码与机器码解耦
(大幅缩短 OTA 与二次编译时间)
Android 9.0Cloud Profiles聚合多设备 Profile 随包下发
(非 Play 生态无效、新版本发布存在滞后期)
Android 12.0+Baseline Profiles开发者预打包基准 / 完美适配国内夜间编译
(首刷即享 AOT 物理加速,完美闭环)

1. Android 5.0 & 6.0:Pure AOT 时代(全量预编译的阵痛)

在 Android 4.4 (KitKat) 之前,Dalvik 虚拟机完全采用解释器 + 运行时 JIT(即时编译)机制,运行效率极其低下。为了实现飞跃,Google 在 Android 5.0 (Lollipop) 正式确立了 ART(Android Runtime) 的统治地位,推出了强力的 纯 AOT(Ahead-Of-Time)模式

2. Android 7.0 & 8.0:JIT + AOT 混合编译时代(PGO 的引入与 .vdex 架构革新)

全量 AOT 的阵痛让 Google 决定做出务实的退让。在 Android 7.0 (Nougat) 中,混合编译(JIT + AOT + PGO)闪亮登场,这代表着编译系统走向了智能化:

3. Android 9.0:Cloud Profiles 时代(云端 crowdsourcing 的局限)

为了填补新安装用户前几天的 Warm-up 性能黑洞,Google 在 Android 9.0 (Pie) 推出了 Cloud Profiles(云端基准)

4. Android 12.0+:Baseline Profiles 时代(开发者拥有绝对主控权)

为了彻底闭合这一性能死角,Google 做出了最务实的决策:既然云端收集太慢,那就让开发者在发布 App 前,自己提前录制好这一份黄金热点路径!

这就是 Baseline Profiles(基准配置文件)


⏱️ 一图流总结:Android 编译模型演进速查表

Android 版本编译模型 / 名字安装速度存储开销核心引入点与改进历史局限 / 痛点
5.0 (Lollipop)Pure AOT极慢 (几分钟)极大 (机器码膨胀)引入 ART 虚拟机,全量 AOT 编译为 .odex/.oat安装极慢,系统升级开机需要卡死半小时
6.0 (Marshmallow)Pure AOT + Filters较慢引入 Compiler Filters(如 quicken/speed)进行微调仍旧是纯 AOT 大框架,无法解决安装/OTA 痛点
7.0 (Nougat)JIT + AOT Hybrid极快 (秒装)极小重新带回 JIT 编译器 + 夜间后台定时 AOT 编译 (PGO)Warm-up Gap(新安装或升级的前几次启动裸奔卡顿)
8.0 (Oreo)Hybrid + VDEX极快极小引入 .vdex 文件,将字节码与机器码解耦,大幅缩短 OTA 重优化时间依旧存在新装用户首次运行的 Warm-up Gap
9.0 (Pie)Cloud Profiles极快极小Google Play 云端聚合 Profile 随包下发,消灭暖机黑洞中国国内完全失效,且新版本发布头几天依然裸奔
12.0+Baseline Profiles极快极小开发者预打包基准 Profile 进 APK,国内大厂夜间 AOT 完美闭环无。性能、包体积、安装速度及国情适配的最佳平衡

三、 三大核心引擎:Baseline Profiles 的底层运作机制

要让这套基准优化机制行之有效,Google 构筑了三驾马车,它们在整个开发与运行生命周期中各司其职,无缝咬合:

  1. 采血针:Macrobenchmark (宏观基准测试) 传统的 Microbenchmark 用于测试微观的单个函数性能(例如一个算法跑了多少纳秒)。而 Macrobenchmark 则是面向宏观场景的,它能拉起真实的 App 进程,利用 UI Automator 驱动手机执行物理滑动、点击和跳转。在执行的同时,它会在底层捕获 Linux 内核级的 Tracing 信息,记录下这段路径上到底加载了哪些类、执行了哪些方法,最终将其导出为人类可读的纯文本 baseline-prof.txt

  2. 打包厂:AGP 打包插件与 profgen 编译引擎 人类可读的 baseline-prof.txt(包含类名和方法签名)不能直接被 ART 虚拟机读取。当你执行 ./gradlew :app:assembleRelease 打包时,AGP(Android Gradle 插件)内置的 profgen(Profile Generator)编译引擎会自动启动。它会自动搜寻并合并所有的 baseline-prof.txt,将其转译压缩为极度紧凑的、专为虚拟机定制的二进制基准文件 baseline.prof,最终将其塞入最终 APK 内部的 /assets/dexopt/ 目录下。

  3. 搬运工:ProfileInstaller 库(与 Google Play 的双轨制) 操作系统底层的安装器(PMS)是个“死心眼”,它从来不会主动去读取 APK 内部的 assets/ 目录。为了让系统拿到这份基准文件,诞生了双轨机制:

    • Google Play 专属路线:Play 商店会在云端将基准提取并独立打包成 .dm (Dex Metadata) 文件。下载时,把 APK 和 .dm 作为组合拳喂给系统安装器,直接触发安装时编译(Install-time AOT),首开即巅峰。
    • 国内/非 Play 生态的救场(ProfileInstaller):系统只拿到了干瘪的 APK,直接跳过编译。为了拯救国内生态,Jetpack 推出了 androidx.profileinstaller 库。在 App 首次启动且完成首帧渲染后,该库会在后台苏醒,强行把 APK 肚子里的 baseline.prof 解压,伪装成运行时热点记录,写入系统底层的专属缓存目录/data/misc/profiles/cur/...)。既然系统不管饭,那就靠 App 启动后自己把干粮掏出来塞进系统嘴里,从而保送后续深夜的 BackgroundDexOpt 编译。

四、 中国特色生态:Baseline Profiles 在无 Google Play 生态下有用吗?

“国内应用市场没有 Google Play,这个基准文件是不是完全成了摆设?” 这是几乎所有国内开发者在面对 Baseline Profiles 时的头号疑虑。

结论是:100% 确定能用!而且在国内厂商高度定制的操作系统上,其运作甚至比国外纯原生系统更为强悍和彻底。

让我们下潜到 PMS(Package Manager Service)和 ART Runtime 的底层,来看看它是如何在完全没有 Google 服务支持的国内手机中完成物理闭环的:

  1. 一键注入底盘:伴随 App 的安装和首次运行,ProfileInstaller 库完成了解压与注入,成功向系统的 PMS “报备”了当前 App 的热点方法清单。

  2. 夜间守护神 (BackgroundDexOpt) 的最终裁决: 在几乎所有国内魔改 ROM(如小米 HyperOS、华为 HarmonyOS、OPPO ColorOS、VIVO OriginOS 等)的系统内部,都毫无保留地保留了 Android 核心基建 —— BackgroundDexOptService(后台 DEX 优化服务)。 当用户在深夜入睡,且手机满足以下三个硬性条件:

    • 手机接通电源并正在充电
    • 手机屏幕熄灭,且处于完全空闲状态
    • 电池电量充足且连接了 Wi-Fi

    系统的后台优化服务就会自动苏醒。由于它属于拥有 Root 权限的系统级服务,国内任何手机管家、杀后台、省电优化都绝对不敢、也无法干预和杀掉它

  3. 完成 AOT 预编译: 该服务会读取上一步注入的 Profile 基准文件,强制调用系统的 dex2oat 编译器,针对你基准清单中标记的核心冷启动和滑动方法,将它们从普通的 DEX 字节码编译并物化为极致物理加速的 AOT 机器码

  4. 全血复活的清晨: 第二天清晨,用户再次点开你的 App 时,冷启动和首刷滑动便开始调用预编译好的物理 AOT 机器码,彻底下岗了慢速的 JIT 解释器,流畅度突破天际!


五、 手把手实战配置:打通 Gradle 9.x 与 AGP 9.2.1 的环境任督二脉

要在最新的现代化开发环境(Gradle 9.x / AGP 9.2.1 / Kotlin 2.x)中跑通 Baseline Profiles,最大的拦路虎往往是构建系统的环境兼容性。

请紧跟以下最严谨的现代化多模块项目配置:

1. 依赖与版本声明(Version Catalog)

在项目目录的 gradle/libs.versions.toml 中配置支持全新 AGP 9.x 架构的现代版依赖:

[versions]
# 核心依赖版本
baselineprofile = "1.4.1"
benchmark-macro-junit4 = "1.4.1"
uiautomator = "2.3.0"
junit = "4.13.2"
androidx-test-ext-junit = "1.2.1"
[libraries]
# Benchmark 及自动化测试依赖包
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmark-macro-junit4" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins]
# Baseline Profile Gradle 插件
baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" }

2. 解决两大致命底座环境兼容性大坑

坑 ①:Extension of type 'TestExtension' does not exist 异常
坑 ②:Foojay Toolchain Resolver 异常导致构建阻断

3. 主 App 消费者模块配置

在你的宿主模块 app/build.gradle.kts 中引用插件,并宣告将其与后续建立的 :baselineprofile 生成器模块进行单向数据通道依赖关联:

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
// 1. 应用 Baseline Profile 消费者端插件
id("androidx.baselineprofile")
}
android {
namespace = "com.rengwuxian.coursecompose"
compileSdk = 35
// ...
}
dependencies {
// 2. 专用数据通道单向绑定:
// 这行配置告诉构建系统:后续生成的基准二进制资产,应当由 `:baselineprofile` 模块供给并吸收到本包体下
baselineProfile(project(":baselineprofile"))
}

4. 生成器模块独立配置

我们必须在项目中新建一个纯测试专用模块,例如 :baselineprofile(它使用 com.android.test 插件)。

在新建模块的 baselineprofile/build.gradle.kts 中:

plugins {
// 1. 使用 com.android.test 插件,标识该模块是一个专门在物理设备上跑测试/录制的模块
alias(libs.plugins.android.test)
alias(libs.plugins.kotlin.android)
// 2. 引入 Baseline Profile 的生成器插件
alias(libs.plugins.baselineprofile)
}
android {
namespace = "com.rengwuxian.coursecompose.baselineprofile"
compileSdk = 35
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
defaultConfig {
minSdk = 24
targetSdk = 35
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
// 🚀 核心关键声明:指向你要优化的目标 App 的 Gradle 模块绝对路径
targetProjectPath = ":app"
}
dependencies {
// 3. 完美导入测试与录制所需的全部基建底层依赖
implementation(libs.androidx.benchmark.macro.junit4)
implementation(libs.androidx.test.ext.junit)
implementation(libs.androidx.uiautomator)
}

六、 极致录制:编写黄金启动路径的 Baseline 自动化脚本

录制脚本的编写直接决定了最终生成的基准配置文件(baseline-prof.txt)的覆盖度和含金量。我们需要聚焦于用户核心启动路径高频流畅度敏感交互,避免将低频、无关紧要的静态页面加入编译,浪费有限的安全 AOT 编译空间。

我们强烈推荐以下黄金路径录制脚本:

package com.rengwuxian.coursecompose.baselineprofile
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* 🚀 生产级 Baseline 基准自动化录制类
*/
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
// 1. 申领 BaselineProfileRule 测试特权
@get:Rule
val baselineRule = BaselineProfileRule()
@Test
fun generate() {
// 使用 BaselineProfileRule 收集系统底层的调用信息
baselineRule.collect(
packageName = "com.rengwuxian.coursecompose",
maxIterations = 10, // 重启并执行 10 次,获取最稳定、最精确的方法调用交集
stableIterations = 2 // 抛弃前两轮的波动缓存,确保生成的数据 100% 纯净与稳定
) {
// ==================== 1. 模拟物理冷启动 ====================
pressHome()
startActivityAndWait() // 自动拉起宿主 App 并等待其首帧冷启动完全渲染结束
// ==================== 2. 模拟真实用户的黄金路径深度滑动 ====================
// 使用 UI Automator 高阶工具链:在当前设备屏幕上等待并捕获「支持滚动」的 Feed 列表组件
val list = device.wait(Until.findObject(By.scrollable(true)), 5000)
if (list != null) {
// 模拟向下快速滑动 3 次,把列表中各 Item 的测量、摆放、绘制和节点构造代码通通跑一遍,
// 确保它们 100% 触发 AOT 编译:
list.scroll(Direction.DOWN, 1.0f)
device.waitForIdle() // 重点:等待过渡动画静止,防止轨迹残缺
list.scroll(Direction.DOWN, 1.0f)
device.waitForIdle()
list.scroll(Direction.DOWN, 1.0f)
device.waitForIdle()
}
}
}
}
⚠️录制精度红线

为什么是 10 次(maxIterations)?

录制次数越少,数据偶然性越大。重启 10 次可以让 Macrobenchmark 充分捕获到因为线程调度、冷起延时、JIT 抢占带来的各种底层方法轨迹。 通过 device.waitForIdle() 确保每一次滑动的过渡动画都已经完全静止,从而将底层所有的测量(Measure)、摆放(Place)、绘制(Draw)以及 Compose 节点构建方法全部网罗进基准文本。


七、 物理加速:一键生成并自动编译二进制基准文件

当我们的录制脚本编写完毕,且运行环境(物理真机AOSP 无 Google Play 服务的模拟器,且确保已开启 USB 调试ADB 权限)准备就绪后,我们可以通过一条 Gradle 指令一键开启生成流程:

在 Android Studio 底部的 Terminal 终端运行:

终端
./gradlew :app:generateReleaseBaselineProfile

此时,你会在手机屏幕上看到神奇的一幕:

  1. 构建系统会自动编译并安装一个针对性能收集的特殊混淆包。
  2. 手机会自动打开、关闭、打开你的 App 并模拟滑动,精准执行 10 次。
  3. 收集完成后,会在主 app 模块的以下路径自动写出生成的纯文本基准数据: app/src/release/generated/baselineProfiles/baseline-prof.txt

这个文本内部大概长这样:

HSPLcom/rengwuxian/coursecompose/MainActivity;->onCreate(Landroid/os/Bundle;)V
HSPLandroidx/compose/runtime/ComposerImpl;->startGroup(ILjava/lang/Object;)V
HSPLandroidx/compose/foundation/lazy/LazyDslKt;->LazyColumn(...)V
...
💡构建大成

生成后,开发者不需要进行任何手动拷贝!

你只需要直接像平常一样执行打包命令:

终端
./gradlew :app:assembleRelease

在打包 release 变体时,Android Gradle Plugin (AGP) 会自动合并、编译这些文本,将其转译为系统内核直接识别的二进制基准格式,并无感嵌入到最终的 APK/AAB 内。


八、 🧭 深度源码解密:打包 Release 时,/app 模块是如何自动感知并读取 prof.txt 的?

很多新手在配完 Baseline Profile 之后,最困惑的问题就是:「我只是在 baselineProfiles 目录下看到了两个普通的文本文件,我的 /app 模块在打包 Release 时,到底是通过什么机制、怎么知道去哪里把它们取出来的?」

其实,这一机制完全不需要开发者手动配置路径,其背后是一整套由 Android 团队精心编排的构建流闭环机制,我们可以通过以下流程图直观理解:

💡 构建期核心机制深度剖析:

  1. 变体源集(Source Set)的自动注册: 在 Gradle 配置阶段,androidx.baselineprofile 消费端插件会直接干预 AGP 的变体构建流,将 src/release/generated/baselineProfiles 注册为 AGP 认可的内置资产目录(Assets Dir)。这意味着即使你觉得这只是个普通文件夹,但对 AGP 而言,它已经和 src/main/assets 拥有了同等重要的法定地位。

  2. profgen 编译器的文本二进制化: 在打包流程中的 :app:mergeReleaseBaselineProfile 核心任务里,系统会将收集到的多份 baseline-prof.txt 合并。随后,AGP 会唤醒内置的 profgen(Profile Generator)编译引擎。它由 Kotlin 编写,能将人类可读的方法描述(如包名、类名、方法签名、描述符)高效地转译为系统内核直接读取的 baseline.prof 二进制流,从而大幅缩减最终包体体积并加快系统的读取速度。

  3. 包体结构内的最终呈现: 如果你使用 Android Studio 的 Build -> Analyze APK 分析最终打出的 Release 包,你会惊奇地发现:

    • 在你的二进制包体内,默默多出了一个 /assets/dexopt/baseline.prof 文件。
    • 同时伴随着一个 /assets/dexopt/prerelease.prof 文件,这是针对低版本系统的向下兼容过渡配置。 有了这两个文件,应用在安装后就能立刻无缝享受 Android 系统的极致 AOT 预编译红利!

💡教师备课金句

「Baseline Profiles 的精髓,在于打通了『开发阶段、构建打包、运行监控、系统底层 AOT 编译』的完整链路。它让我们的 Compose 代码不依赖 Google Play 的云端,也能借助中国手机大厂系统的『夜间守护任务』完美爆发。它让你的 App 在第二天清晨满血复活,实现首帧冷启动的降维打击。」