求Jetpack Compose可组合函数不可依赖执行副作用的示例代码
绝对不要依赖执行可组合函数时产生的副作用,因为函数的重组可能被跳过,否则用户会在你的应用中遇到奇怪且不可预期的行为。副作用指对应用其余部分可见的任何变更,以下行为都属于危险的副作用:
- 写入共享对象的属性
- 更新ViewModel中的可观察对象
- 更新SharedPreferences
重组被跳过的3种典型场景示例
场景1:可组合项的输入为稳定类型且未发生变化
Compose会判断可组合项的所有输入参数是否是稳定类型、且和上一次重组时的值完全相等,如果满足就会跳过重组。
// 稳定类:所有属性都是val,无隐含可变状态 @Immutable data class User(val name: String, val age: Int) @Composable fun UserInfoCard(user: User) { // 只有当user实例变化时才会重组,否则直接跳过 Text(text = "${user.name},年龄:${user.age}") }
如果外部传入的user没有变化,这个可组合项的重组会直接被跳过,如果你在里面写了修改全局变量的代码,这段代码就不会执行,导致逻辑异常。
场景2:可组合项未读取发生变化的状态值
如果某个状态更新了,但当前可组合项根本没有读取这个状态,就不会触发这个可组合项的重组。
val count = remember { mutableIntStateOf(0) } Column { // 只在点击按钮时更新count,但是按钮文案没有读取count Button(onClick = { count.intValue++ }) { Text("点击计数") } // 这个Text没有读取count的值,count变化时该可组合项重组会被跳过 Text(text = "静态文本") }
如果你在Text("静态文本")对应的可组合逻辑里写了依赖count变化的副作用代码,这段代码永远不会跟着count更新执行。
场景3:父重组时子可组合项输入无变化
父布局重组时,会检查所有子可组合项的输入是否有变化,输入无变化的子项会直接跳过重组。
@Composable fun ParentContainer(isDarkMode: Boolean) { // isDarkMode变化时父容器触发重组 Column( modifier = Modifier.background(if (isDarkMode) Color.Black else Color.White) ) { // 子项输入固定为字符串,无变化,每次父重组都会跳过该子项的重组 StaticTip() } } @Composable fun StaticTip() { Text(text = "固定提示文案") }
如果在StaticTip里写了统计页面曝光的代码,只有第一次进入会执行,父布局因为主题切换重组时,StaticTip重组被跳过,曝光统计就会漏报。
反例验证:依赖重组执行副作用的问题
下面这个例子直接在可组合函数里修改ViewModel的计数器,会出现计数不准的问题:
class HomeViewModel : ViewModel() { val visitCount = MutableLiveData<Int>(0) } @Composable fun HomePage(viewModel: HomeViewModel = viewModel()) { val isDarkMode = remember { mutableStateOf(false) } // 错误写法:直接在可组合函数作用域内更新可观察对象,属于危险副作用 viewModel.visitCount.value = viewModel.visitCount.value?.plus(1) ?: 1 Column { Button(onClick = { isDarkMode.value = !isDarkMode.value }) { Text("切换主题") } Text(text = "页面访问次数:${viewModel.visitCount.observeAsState().value}") } }
预期行为是每次打开页面visitCount加1,实际运行时你点击切换主题触发父重组,visitCount会额外增加;如果你把visitCount的更新放到静态子组件里,切换主题时子组件重组被跳过,计数又不会增加,完全不符合预期。
LiveData转State场景与副作用规则的关联
你用LiveData.observeAsState()时UI自动更新,和副作用规则完全不冲突,因为这个方法内部已经用了官方规范的副作用API处理监听逻辑,核心实现简化如下:
@Composable fun <T> LiveData<T>.observeAsState(): State<T?> { val state = remember { mutableStateOf(value) } // 用DisposableEffect处理监听的注册与解绑,属于官方合规的副作用API DisposableEffect(this) { val observer = Observer<T> { state.value = it } observeForever(observer) onDispose { removeObserver(observer) } } return state }
DisposableEffect只会在参数变化、或者可组合项进入/退出组合时执行,不会被普通重组跳过,也不会随重组反复执行,因此监听逻辑是可靠的,不属于在可组合函数里随便写的无保护副作用。
正确的副作用处理方式
所有需要和重组生命周期绑定的副作用,都必须使用官方提供的副作用API:
- 随可组合项进入组合执行、参数变化时重启的逻辑,用
LaunchedEffect - 需要在可组合项退出组合时清理资源的逻辑,用
DisposableEffect - 需要每次重组完成后执行的逻辑,用
SideEffect
内容的提问来源于stack exchange,提问作者HelloCW

