Jetpack Compose 可组合项的生命周期
如果你刚接触 Compose,可能会对“可组合项生命周期”、“重组”这些词感到困惑。其实它们的核心逻辑很简单——本质是 Compose 如何“聪明地更新界面”,避免做无用功。
一、生命周期概览:可组合项的“一生”
首先要明确三个基础概念:
组合(Composition):是 Compose 根据你编写的可组合项代码,最终构建出的 “界面完整描述”。它代表了 “当前界面应该由哪些元素组成、这些元素如何关联、依赖哪些状态” 的整体逻辑。
组合树(Composition Tree) : 是实现组合的具体数据结构,它代表了 UI 的层次结构。这是一个内存中的树形数据结构,描述了应用中所有可组合项的布局和关系。
@Composable
fun MyScreen() {
Column { // ← 树的根节点
Text("Hello") // ← 子节点
Button( // ← 子节点
onClick = { /* ... */ }
) {
Text("Click me") // ← 孙子节点
}
}
}
对应的组合树结构:
Column
├── Text("Hello")
└── Button
└── Text("Click me")
重组(Recomposition):当界面状态变了(比如按钮被点击、数据加载完成),Compose 不会重新画整个组合树,而是只重新画“需要变的部分”,这个局部修改的过程就是重组。
可组合项的“一生”只有 3 步,非常简单:
- 进入组合:第一次被加入组合树(比如界面刚打开时,Text 被显示出来);
- 0 次或多次重组:如果状态变化影响到它,就会被重组(比如 Text 显示的文字从“Hello”改成“Hi”);如果状态没影响它,就不重组;
- 退出组合:从组合树中消失(比如界面关闭、列表项被删除)。
几个关键提醒:
- 可组合项的生命周期比 Activity、Fragment 简单多了!如果需要处理复杂的外部资源(比如联网、注册监听),别自己硬扛,用 Compose 提供的“效应(Effect)”工具(文档后面会讲,这里先知道就行)。
- 同一个可组合项调用多次,就会生成多个“分身”,每个“分身”有自己的生命周期。比如:
这两个 Text 虽然代码一样,但在组合树里是两个不同的位置,所以是独立的。@Composable fun MyScreen() { Column { Text("你好") // 分身 1:有自己的生命周期 Text("世界") // 分身 2:另一个独立的生命周期 } }
二、组合中可组合项的“身份标识”:调用点
Compose 怎么区分不同的可组合项“分身”?关键看 调用点——也就是你在代码里“调用这个可组合项的位置”。
比如登录界面的例子:
@Composable
fun LoginScreen(showError: Boolean) {
if (showError) {
LoginError() // 调用点 1:只有 showError 为 true 时才执行
}
LoginInput() // 调用点 2:每次都执行
}
LoginError
的调用点在if
里面,只有showError=true
时才会进入组合树;LoginInput
的调用点在if
外面,每次都会在组合树里,而且不管showError
怎么变,它的调用点没变,所以只要它的参数没改,重组时就会被“跳过”(不重新执行)。
核心逻辑:
重组时,Compose 会对比“这次的设计图”和“上次的设计图”:
- 先看“调用点”——如果某个可组合项的调用点没变(比如
LoginInput
一直在if
外面); - 再看“输入参数”——如果参数也没变(比如
LoginInput
没有依赖showError
); - 满足以上两点,就直接复用上次的结果,不重新执行(跳过重组)。
三、解决列表“乱重组”:给可组合项加个“身份证”(key)
列表是最容易出问题的场景。比如电影列表,没加 key
时会怎样?
问题场景:
@Composable
fun MoviesScreen(movies: List<Movie>) {
Column {
for (movie in movies) {
MovieOverview(movie) // 靠“循环顺序”识别:第 1 个、第 2 个...
}
}
}
如果在列表顶部加一部新电影:
- Compose 原本靠“顺序”识别——原来的第 1 部电影,现在变成了第 2 部;
- Compose 会以为“原来的第 1 部没了,新来了一个第 1 部”,于是让所有电影的
MovieOverview
都重组; - 如果
MovieOverview
里有“加载图片”这类操作(叫“附带效应”),就会被中断、重新加载,既浪费资源又影响体验。
解决办法:用 key
给每个可组合项加“身份证”
key
就像身份证号,让 Compose 靠“唯一标识”识别,而不是靠“顺序”。比如用电影的 id
当 key
:
@Composable
fun MoviesScreenWithKey(movies: List<Movie>) {
Column {
for (movie in movies) {
key(movie.id) { // 用电影的唯一 id 当“身份证”
MovieOverview(movie)
}
}
}
}
现在再在顶部加新电影:
- Compose 会看
key
(电影 id)——原来的电影 id 都还在,只是多了一个新 id; - 所以只会新创建“新电影”的
MovieOverview
,原来的电影完全复用,加载图片的操作也不会中断。
小技巧:
像 LazyColumn
(列表懒加载)这类常用组件,已经内置了 key
的支持,不用自己写循环:
@Composable
fun MoviesScreenLazy(movies: List<Movie>) {
LazyColumn {
// 直接在 items 里指定 key 为 movie.id
items(movies, key = { movie -> movie.id }) { movie ->
MovieOverview(movie)
}
}
}
四、什么时候能“跳过重组”:稳定类型 + 输入未变
前面提到“输入参数没变就跳过重组”,但这里有个前提:参数的类型必须是“稳定类型”。
1. 什么是“稳定类型”?
简单说,稳定类型要满足两个条件:
- 「不变则不变」:如果两个实例的内容一样,用
equals
比较一定返回true
(比如1
和1
肯定相等,“苹果”和“苹果”肯定相等); - 「变了会通知」:如果实例的内容变了,Compose 能知道(比如
MutableState
,值变了会自动通知界面)。
2. 哪些类型天生是稳定的?
不用额外操作,Compose 自动认:
- 基本类型:
Int
、Boolean
、Long
、Float
等(比如1
不会突然变成2
,变了也能直接看出来); String
:比如“Hello”不会自己变成“Hi”;- 函数/lambda:比如你传的点击事件
onClick = { ... }
,只要没重新定义,就是同一个; MutableState
:Compose 专门设计的状态类,值变了会主动通知重组(比如var name by remember { mutableStateOf("") }
)。
3. 类型不稳定怎么办?用 @Stable
注解
比如接口类型,Compose 默认觉得它“不稳定”(因为不知道实现类会不会偷偷改值)。这时候可以给接口加 @Stable
注解,告诉 Compose:“放心,这个类型是稳定的,变了会通知你”。
例子:
// 给接口加 @Stable,告诉 Compose 它是稳定的
@Stable
interface UiState<T : Result<T>> {
val value: T? // 数据值
val exception: Throwable? // 错误信息
val hasError: Boolean get() = exception != null // 计算属性,靠 exception 决定
}
加了 @Stable
后,只要 UiState
的 value
和 exception
没变,用它当参数的可组合项就会跳过重组。
4. 最终结论:
只有满足以下两个条件,Compose 才会跳过重组:
- 可组合项的所有输入参数都是稳定类型;
- 这些参数的值和上次相比没有变化(用
equals
判断)。
五、如果不是稳定类型,为什么输入未变,也不会跳过重组 ?
本质是因为 Compose 无法 “信任” 不稳定类型的 “值未变” : 它无法可靠判断这类类型的内部状态是否真的没变化,所以会被迫触发重组以避免漏更。
要理解这个结论,需要先明确两个关键前提
1. 稳定类型 vs 不稳定类型的核心区别
Compose 对 “是否跳过重组” 的判断,本质是看它能否确定参数的值是否真的未变化
- 稳定类型:Compose 能 100% 确认值的变化(比如 Int、String 这类不可变类型,或 MutableState 这类 “变了会主动通知” 的类型)。只要值没改,就敢跳过重组;
- 不稳定类型:Compose 无法确认值的变化(比如 List 接口、含 var 属性的类、未注解的自定义类)—— 即使表面上 “值没变”,Compose 也担心其内部状态可能偷偷修改(且没通知自己),所以不敢跳过重组。
2. 不稳定类型为何 “输入未变也不跳过”
当可组合项包含 “不稳定参数” 时,Compose 每次重组它的父组件时,都会强制重组这个可组合项 —— 无论参数的实际值是否变化。
原因很简单:
不稳定类型的 “值是否变化” 对 Compose 是 “黑箱”。比如:
- 你传一个 List 作为参数(List 是接口,默认不稳定):即使列表内容没改,Compose 也无法确定你有没有在别处偷偷调用 list.add()(虽然你没这么做,但 Compose 无法预判);
- 你传一个含 var 属性的类 data class User(var name: String)(默认不稳定):即使当前 name 没改,Compose 也担心你可能在其他地方修改 user.name(且没通知它)。
为了避免 “值变了但没重组导致 UI 不一致” 的 bug,Compose 对不稳定参数采取 “宁错杀、不遗漏” 的策略 —— 只要父组件重组,就强制重组这个可组合项,哪怕输入实际没变化。
3. 举个例子:不稳定类型导致 “无效重组”
比如一个显示用户列表的组件,用普通 List(不稳定类型)作为参数:
// 1. 不稳定的参数类型:List 是接口,默认不稳定
@Composable
fun UserList(users: List<User>) {
LazyColumn {
items(users) { user ->
UserItem(user) // 只要父组件重组,即使 users 内容没改,UserItem 也会重组
}
}
}
// 2. 父组件:有一个无关的状态变化(比如按钮点击计数)
@Composable
fun ParentScreen() {
var clickCount by remember { mutableStateOf(0) } // 无关状态
val users = remember { listOf(User("张三"), User("李四")) } // 内容不变的列表
Column {
Button(onClick = { clickCount++ }) { // 点击按钮会触发父组件重组
Text("点击计数:$clickCount")
}
UserList(users) // users 是 List(不稳定),即使内容没改,也会跟着重组
}
}
- 当你点击 “计数按钮” 时,clickCount 变化会触发 ParentScreen 重组;
- 虽然 users 的内容完全没变化,但因为 users 是 List(不稳定类型),UserList 和里面的 UserItem 都会被强制重组 —— 这就是 “无效重组”,会浪费性能。
4. 如何解决:把不稳定类型改成稳定类型
如果想让 “输入未变时跳过重组”,只需将参数类型改为稳定类型,比如:
- 用不可变集合替代普通集合:用 kotlinx.collections.immutable.ImmutableList(稳定类型)替代 List;
- 给自定义类加稳定性注解:用 @Immutable 标记不可变类,或用 @Stable 标记 “可变但会通知变化” 的类;
- 用 Compose 自带的稳定状态类:比如 MutableState<List>(稳定类型,因为变化会通知)。
修改后的例子(稳定类型)
// 1. 稳定的参数类型:ImmutableList(不可变集合,稳定)
@Composable
fun UserList(users: ImmutableList<User>) {
LazyColumn {
items(users) { user ->
UserItem(user) // 只要 users 内容没改,就会跳过重组
}
}
}
// 2. 父组件:使用 ImmutableList
@Composable
fun ParentScreen() {
var clickCount by remember { mutableStateOf(0) }
// 用 ImmutableList(稳定类型),内容不变
val users = remember { immutableListOf(User("张三"), User("李四")) }
Column {
Button(onClick = { clickCount++ }) {
Text("点击计数:$clickCount")
}
UserList(users) // users 是稳定类型,内容没改,会跳过重组
}
}
这时再点击计数按钮,UserList 和 UserItem 因为参数是稳定类型且值未变,会被 Compose 跳过重组,避免无效性能消耗。
总结:记住 3 个核心点
- 可组合项的一生很简单:进入组合 → 可能重组多次 → 退出组合;
- 区分“分身”靠两点:调用点(代码里的位置)+ key(唯一标识,列表必用);
- 跳过重组有条件:输入参数是稳定类型 + 值没变化。
掌握这些,就能理解 Compose 为什么高效 : 它只更新该更新的部分,不做无用功。
参考 : 官方文档