Android Compose 可组合项的生命周期

王者杯·14天创作挑战营·第6期 10w+人浏览 658人参与

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 步,非常简单:

  1. 进入组合:第一次被加入组合树(比如界面刚打开时,Text 被显示出来);
  2. 0 次或多次重组:如果状态变化影响到它,就会被重组(比如 Text 显示的文字从“Hello”改成“Hi”);如果状态没影响它,就不重组;
  3. 退出组合:从组合树中消失(比如界面关闭、列表项被删除)。

在这里插入图片描述

几个关键提醒:

  • 可组合项的生命周期比 Activity、Fragment 简单多了!如果需要处理复杂的外部资源(比如联网、注册监听),别自己硬扛,用 Compose 提供的“效应(Effect)”工具(文档后面会讲,这里先知道就行)。
  • 同一个可组合项调用多次,就会生成多个“分身”,每个“分身”有自己的生命周期。比如:
    @Composable
    fun MyScreen() {
      Column {
        Text("你好") // 分身 1:有自己的生命周期
        Text("世界") // 分身 2:另一个独立的生命周期
      }
    }
    
    这两个 Text 虽然代码一样,但在组合树里是两个不同的位置,所以是独立的。

在这里插入图片描述

二、组合中可组合项的“身份标识”:调用点

Compose 怎么区分不同的可组合项“分身”?关键看 调用点——也就是你在代码里“调用这个可组合项的位置”。

比如登录界面的例子:

@Composable
fun LoginScreen(showError: Boolean) {
  if (showError) {
    LoginError() // 调用点 1:只有 showError 为 true 时才执行
  }
  LoginInput() // 调用点 2:每次都执行
}
  • LoginError 的调用点在 if 里面,只有 showError=true 时才会进入组合树;
  • LoginInput 的调用点在 if 外面,每次都会在组合树里,而且不管 showError 怎么变,它的调用点没变,所以只要它的参数没改,重组时就会被“跳过”(不重新执行)。

核心逻辑:

重组时,Compose 会对比“这次的设计图”和“上次的设计图”:

  1. 先看“调用点”——如果某个可组合项的调用点没变(比如 LoginInput 一直在 if 外面);
  2. 再看“输入参数”——如果参数也没变(比如 LoginInput 没有依赖 showError);
  3. 满足以上两点,就直接复用上次的结果,不重新执行(跳过重组)。

三、解决列表“乱重组”:给可组合项加个“身份证”(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 靠“唯一标识”识别,而不是靠“顺序”。比如用电影的 idkey

@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(比如 11 肯定相等,“苹果”和“苹果”肯定相等);
  • 「变了会通知」:如果实例的内容变了,Compose 能知道(比如 MutableState,值变了会自动通知界面)。

2. 哪些类型天生是稳定的?

不用额外操作,Compose 自动认:

  • 基本类型:IntBooleanLongFloat 等(比如 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 后,只要 UiStatevalueexception 没变,用它当参数的可组合项就会跳过重组。

4. 最终结论:

只有满足以下两个条件,Compose 才会跳过重组:

  1. 可组合项的所有输入参数都是稳定类型
  2. 这些参数的值和上次相比没有变化(用 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 个核心点

  1. 可组合项的一生很简单:进入组合 → 可能重组多次 → 退出组合;
  2. 区分“分身”靠两点:调用点(代码里的位置)+ key(唯一标识,列表必用);
  3. 跳过重组有条件:输入参数是稳定类型 + 值没变化。

掌握这些,就能理解 Compose 为什么高效 : 它只更新该更新的部分,不做无用功。

参考 : 官方文档

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

氦客

你的鼓励是我创作最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值