Compose Strong Skipping Mode 的真相:它并不会让你的类型变 Stable

开篇:一个让我困惑了三天的 Bug

上周五我在项目里遇到一件诡异的事------一个列表页面的 item 明明传了相同的数据,却在每次滚动时都触发 recomposition。

我第一反应是:"不可能啊,我开了 Strong Skipping Mode 啊。"

然后打开 Layout Inspector,发现 recomposition count 疯涨。接着我又看了下 Compose Compiler Metrics 的报告,豁然开朗------我的 data class 根本不是 Stable 的。

等等,Strong Skipping Mode 不是应该"让所有参数都能被跳过"吗?它不是解决了 Stability 问题吗?

不是

这个误解在社区里流传之广,甚至让很多团队关掉了 Stability 优化的代码审查流程。今天我们就来彻底聊清楚:Strong Skipping Mode 到底做了什么,以及它没做什么。

什么是 Strong Skipping Mode?

先回到基础。Compose 的核心优化机制之一是 Skipping:如果一个 Composable 函数的参数没有变化,编译器就可以跳过它的 recomposition。

但在 Strong Skipping Mode 出现之前,编译器只会在参数类型被标记为 @Stable@Immutable 时才会生成跳过逻辑。如果你传入了一个"不稳定"的类型(比如包含 var 属性或外部模块的类),编译器直接放弃------每次 recomposition 都会执行这个 Composable。

Strong Skipping Mode(从 Compose Compiler 1.5.4 开始实验,2026年已默认开启)改变了规则:

• 对于 Stable 类型 的参数:使用 equals() 比较(和以前一样)

• 对于 Unstable 类型 的参数:使用 ===(引用相等/identity)比较

也就是说,即使参数类型是 Unstable 的,只要你传入的是同一个对象引用,Compose 照样可以跳过 recomposition。

误解在哪里?

关键问题来了。很多人的理解是:

"开了 Strong Skipping Mode = 所有类型都自动变成 Stable = 不需要再关心 Stability"

真相是:

"Strong Skipping Mode 只是改变了比较策略,让 Unstable 类型也有机会被跳过------但用的是引用比较,不是值比较"

这区别太大了。来看一个具体例子:

kotlin 复制代码
// 这个 data class 在外部模块定义,Compose 编译器推断它为 Unstable
data class UserProfile(
    val name: String,
    val avatar: String,
    val lastSeen: Instant  // java.time.Instant 来自外部模块
)

@Composable
fun ProfileCard(profile: UserProfile) {
    // ...
}

在没有 Strong Skipping Mode 时,ProfileCard 永远不会被跳过。

有了 Strong Skipping Mode 后,编译器生成的逻辑变成:

scss 复制代码
// 伪代码:编译器生成的跳过判断
if (profile === previousProfile) {  // 注意:是 === 不是 ==
    skip()
}

问题在于:在实际业务中,你几乎不可能每次传入同一个引用。一旦你从 ViewModel 中 collect 一个 StateFlow,每次 emit 新值时,即使内容完全相同,也是一个新对象:

kotlin 复制代码
// ViewModel 中
private val _profile = MutableStateFlow(UserProfile(...))

fun refreshProfile() {
    // 即使数据没变,copy() 也会创建新引用
    _profile.value = _profile.value.copy()
    // 或者从网络重新拉取,即使数据一样也是新对象
    _profile.value = api.getProfile()
}

这就是为什么我的列表在开了 Strong Skipping Mode 后还是疯狂 recompose------每个 item 的数据虽然值相同,但引用不同。

equals() vs === :性能差异有多大?

你可能会想:"那为什么不对所有类型都用 equals() 呢?"

因为 Compose 团队无法信任你的 equals() 实现。

@Stable 注解是一个契约,它承诺:

• 如果两个实例的 equals() 返回 true,那么它们对 Composition 的影响完全相同

• 如果属性发生变化,Composition 一定会被通知到(通过 State/MutableState)

没有这个契约,贸然用 equals() 可能导致 UI 不一致------一个被篡改了内部状态但 equals 仍返回 true 的对象,会让 Compose 错误地跳过更新。

举个极端例子:

kotlin 复制代码
// 危险!不满足 @Stable 契约
class BadUser(val name: String) {
    var cachedBitmap: Bitmap? = null  // 可变字段,但不参与 equals

    override fun equals(other: Any?): Boolean {
        return other is BadUser && other.name == name
    }
}

如果 Compose 信任这个 equals(),当 cachedBitmap 变化时 UI 就不会更新。所以对于 Unstable 类型,用 === 是唯一安全的选择。

实际影响:Strong Skipping Mode 帮了谁?

说了半天它"没做到什么",那它到底帮了谁?

场景一:Lambda 参数

这是 Strong Skipping Mode 最大的受益者。在旧模式下,每个 lambda 在 recomposition 时都会被视为"新值"(因为 lambda 每次都会创建新实例)。Strong Skipping Mode 引入了 lambda 的 remember 优化

scss 复制代码
// 你写的代码
Button(onClick = { viewModel.doSomething() }) {
    Text("Click")
}

// 开启 Strong Skipping 后,编译器自动包装
Button(onClick = remember { { viewModel.doSomething() } }) {
    Text("Click")
}

这意味着你不再需要手动 remember 每个 lambda 来避免子组件 recompose。这是一个巨大的质量提升。

场景二:单例对象或长期持有的引用

scss 复制代码
// 这种场景下 === 比较有效
val theme = LocalMyTheme.current  // CompositionLocal 提供的同一个引用
MyWidget(config = theme.widgetConfig)  // 只要 theme 没变,就不会 recompose

场景三:remember 包裹的对象

scss 复制代码
val decorationStyle = remember { DecorationStyle(color = Red, width = 2.dp) }
// DecorationStyle 是 Unstable 的,但因为引用不变,Strong Skipping 有效
DecoratedBox(style = decorationStyle)

那我们该怎么做?2026 年的 Stability 最佳实践

既然 Strong Skipping Mode 不是银弹,我们在 2026 年应该怎么正确处理 Stability?

1. 继续关注 Compose Compiler Metrics

别因为开了 Strong Skipping 就关掉性能分析。在 build.gradle.kts 中配置:

ini 复制代码
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_reports")
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
}

然后定期审查 *-classes.txt 文件中的 Unstable 标记,尤其是出现在列表/高频 recomposition 路径上的类。

2. 对高频路径的数据类做 Stability 优化

不需要对所有类都加 @Stable------这是过度优化。重点关注:

• LazyColumn/LazyGrid 的 item 数据类

• 频繁更新的 UI 状态类

• 作为参数传递给多个子 Composable 的 "大对象"

优化方式有几种:

kotlin 复制代码
// 方式一:直接标注(确保你满足契约)
@Stable
data class UserProfile(
    val name: String,
    val avatar: String,
    val lastSeen: Instant
)

// 方式二:使用 Stability Configuration File(推荐)
// compose-stability.conf
java.time.Instant
java.time.LocalDate
kotlinx.datetime.*

3. Stability Configuration File 是 2026 年的首选方案

比起在代码中到处加注解,使用配置文件更加优雅和可维护:

arduino 复制代码
// build.gradle.kts
composeCompiler {
    stabilityConfigurationFile = rootProject.layout.projectDirectory
        .file("compose-stability.conf")
}

// compose-stability.conf 内容
// 通配符表示该包下所有类视为 Stable
java.time.*
kotlinx.datetime.*
com.myapp.domain.model.*

这样你可以把外部模块的类型"声明"为 Stable,而不需要修改源码或添加注解。

4. 利用 derivedStateOf 和 remember 管理引用稳定性

kotlin 复制代码
// 错误:每次 recomposition 都创建新的 filteredList
@Composable
fun UserList(users: List<User>, query: String) {
    val filtered = users.filter { it.name.contains(query) }
    LazyColumn {
        items(filtered) { UserRow(it) }
    }
}

// 正确:使用 derivedStateOf 保持引用稳定
@Composable
fun UserList(users: List<User>, query: String) {
    val filtered by remember(users, query) {
        derivedStateOf { users.filter { it.name.contains(query) } }
    }
    LazyColumn {
        items(filtered) { UserRow(it) }
    }
}

5. 状态提升时注意粒度

这是最近 ProAndroidDev 上讨论的热点。状态放在哪一层直接影响 recomposition 的范围:

kotlin 复制代码
// 粗粒度:整个 Screen 都会 recompose
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    // state 变化 → 整个 Screen recompose
    Header(state.name, state.avatar)
    Stats(state.followers, state.posts)
    Bio(state.bio)
}

// 细粒度:只有变化的部分 recompose
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
    val name by viewModel.name.collectAsStateWithLifecycle()
    val avatar by viewModel.avatar.collectAsStateWithLifecycle()
    val followers by viewModel.followers.collectAsStateWithLifecycle()
    // 每个 State 独立,只影响对应的子 Composable
    Header(name, avatar)
    Stats(followers, viewModel.posts.collectAsStateWithLifecycle().value)
    Bio(viewModel.bio.collectAsStateWithLifecycle().value)
}

当然,过细的粒度会让代码变复杂。我的建议是:对高频变化的字段做拆分,低频的合在一起就好

实战:用 Layout Inspector 验证优化效果

说了这么多理论,来看看怎么验证。Android Studio Panda 4 的 Layout Inspector 现在可以直接显示 recomposition count 和 skip count:

• 打开 Layout Inspector → 选择你的 Compose 界面

• 顶部开启 "Show Recomposition Counts"

• 操作 UI(滚动列表、切换页面)

• 观察数字:高 recomposition + 低 skip = 需要优化

另外,新版 Studio 的 Composition Tracing 也很好用------它能直接在 System Trace 中标注每个 Composable 的执行时长,让你精确定位性能瓶颈。

一张图总结决策路径

当你遇到 recomposition 性能问题时,按这个顺序排查:

第一步:确认 Strong Skipping Mode 已开启(2026年默认开启,但老项目可能没有)

第二步:用 Compose Compiler Metrics 找到 Unstable 的类

第三步:判断这些类是否在高频路径上

第四步:如果是------用 Stability Config File 或 @Stable 注解修复

第五步:检查是否有不必要的对象创建(remember / derivedStateOf)

第六步:考虑状态粒度拆分

写在最后

Strong Skipping Mode 是一个非常好的"兜底"优化。它把 Compose 的性能下限提高了一大截------特别是在 lambda 处理方面,几乎消除了一整类的性能陷阱。

但它不是你放弃思考 Stability 的理由。

在列表场景、高频 UI 更新、复杂状态管理这些真正需要性能的地方,理解 ===equals() 的区别、知道什么时候该介入优化,仍然是 Compose 开发者的必修课。

工具在进步,但对底层机制的理解永远不会过时。

下次有人跟你说"开了 Strong Skipping 就不用管 Stability 了",你可以把这篇文章甩给他 :)

如果这篇文章对你有帮助,欢迎点赞、收藏、转发。关注我,获取更多 Android 新技术深度解析。

相关推荐
shaoming37766 小时前
浏览器动作开发:地址栏图标点击事件、弹出页面设计
android·mysql·adb
赏金术士6 小时前
Kotlin 协程与挂起函数(Coroutines & suspend)入门到实战
android·开发语言·kotlin
泡泡以安8 小时前
Unidbg学习笔记(十三):固定随机干扰项
android·逆向
泡泡以安8 小时前
Unidbg学习笔记(十六):Console Debugger
android·逆向
赏金术士8 小时前
Room + Flow 完整教程(现代 Android 官方方案)
android·kotlin·room·compose
泡泡以安8 小时前
Unidbg学习笔记(八):文件系统层补环境
android·逆向
泡泡以安8 小时前
Unidbg学习笔记(六):补环境的思维框架
android·逆向
通往曙光的路上9 小时前
mysql2
android·adb
木易 士心9 小时前
会见SDK文档
android