Jetpack Compose 副作用

一、前言

自2008年发布第一版Android开始,Android 已经发展了 18年之久,从 XMLViewBinding,再到如今的 Jetpack Compose,最大的思维颠覆之一就是副作用管理 。早期的 Android View 时代,我们可以在 onCreate 里调用 loadWeatherData(),在按钮的setOnClickListener{}里显示 Toast,这些操作都是司空见惯的。但进入 Compose 时代一切不一样了,如果不能及时扭转思维和代码习惯,等待你的将是无尽的BUG!

本篇代码示例环境:

Kotlin 2.2.10 + AGP 9.1.1 + Android Studio Panda 4

二、什么是副作用

先看官方给出的概念:

A side-effect is a change to the state of the app that happens outside the scope of a composable function. Due to composables' lifecycle and properties such as unpredictable recompositions, executing recompositions of composables in different orders, or recompositions that can be discarded, composables should ideally be side-effect free.

副作用是指对应用状态的改变,且这种改变发生在可组合函数的作用域之外。鉴于可组合函数的生命周期及其固有特性------例如重组的不可预测性、重组执行顺序的不确定性,以及重组可能被丢弃等情况------可组合函数在理想情况下应当是无副作用的。

概念简单说就是:在Compose中,副作用(Side-Effect)就是指那些"额外的事"。比如:

  • 网络请求
  • 数据库读写
  • 日志打印
  • 注册/注销监听器
  • 弹出Toast
  • 导航跳转
  • ...

由于 Composable 函数随时可能因为状态变化 发生重组(Recomposition) ,这些操作如果我们直接写在了 Composable 函数体里,会面临一个严重问题:无法预测它会被执行多少次 。每当你点一下按钮,count 状态变了,Composable整体重跑,里面的网络请求又被触发一次------这就是副作用的典型灾难。

解决这个问题的办法就是使用副作用 API(Effect API),将副作用锚定到正确的生命周期上。

三、认识生命周期

要理解副作用,必须先理解Compose的生命周期。部分初学者把 Composable 的生命周期等同于 Activity/Fragment 的生命周期,这是灾难的开始。必须建立新的心智模型:Composable 的生命周期是跟随"组合树节点"的,并非页面,其中每个 Composable 组件都对应UI树的一个节点。

一个 Composable 函数只有三个核心阶段

  1. 进入组合(Enter Composition) :Composable函数首次被调用,对应的UI节点加入组合树。
  2. 重组(Recomposition) :Composable读取的 State 发生变化时,框架重新执行该函数以刷新UI。
  3. 离开组合(Leave Composition):Composable从UI树中被移除,通常是条件分支不再渲染或者导航到了别的页面(如列表项滑出屏幕)。

传统 Android View 体系中,onResume() 意味着用户可见,但在 Compose 中,一个Composable 函数处于 "组合中" 并不代表它一定在屏幕上可见。

重组可能发生在任何时刻且可能被跳过或取消。

如果把传统 Android View 比作"盖房子"------onCreate盖好,onDestroy拆掉,那么Compose更像"流水线"------同一份蓝图(函数体)可能在任意时刻被重跑。这意味着一行看似无害的 Log.d() 语句,在重组时会被反复打印。

用代码直观感受下:

kotlin 复制代码
@Composable
fun ProblematicScreen() {
    var count by remember { mutableStateOf(0) }

    // ❌危险:每次重组都会打印,包括初始进入 + 每次点击按钮
    Log.d("Problematic", ">>>>>> 我被执行了,count = $count")

    Column {
        Text("点击次数:$count")
        Button(onClick = { count++ }) {
            Text("点我")
        }
    }
}

点击一次按钮,count++ 触发重组,Log.d 再次执行。如果你在这里放的是网络请求,每点一下就重新请求一次------显然不是我们想要的行为。

理解了这三个阶段,你会发现所有副作用API本质上都是在回答同一个问题:这个副作用应该在哪个生命周期节点执行,以及什么时候应该被取消?

四、副作用核心API

Compose 提供了一系列 Effect API,帮助在受控的生命周期节点执行副作用,下面就来看看如何使用它们。

4.1 LaunchedEffect

LaunchedEffect 是最高频使用的副作用API。它的机制很简洁:进入组合时启动一个协程,当key发生变化或离开组合时,自动取消上一个协程并重新启动。

key的设计是这个API的精髓。看一个搜索防抖的例子:

kotlin 复制代码
@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }
    var results by remember { mutableStateOf(emptyList<String>()) }
	Log.d(TAG, ">>>>>> query = " + query)
    
    // key = query:每次搜索词变化,取消上一次请求,发起新请求
    LaunchedEffect(query) {
        if (query.isNotBlank()) {
            delay(300) // 防抖
            results = simulatedSearch(query)
        }
    }

    Column {
        OutlinedTextField(value = query, onValueChange = { query = it })
        results.forEach { Text(it) }
    }
}

suspend fun simulatedSearch(keyword: String): List<String> {
    delay(1000)
    return listOf("${keyword}结果1", "${keyword}结果2")
}

每次用户输入新字符,query 改变,LaunchedEffect自动取消上一次搜索协程,起一个新的。防抖300ms和自动取消旧请求一气呵成------这在View时代需要手动管理一堆 JobHandler,而现在只需一个API。

我的建议LaunchedEffect 适合"状态驱动"的异步操作------某个状态变了,自动触发副作用。

4.2 rememberCoroutineScope

LaunchedEffect 只能自动执行 ,但很多时候我们需要在用户交互中 启动协程,比如点击按钮后才去请求数据。这时就需要rememberCoroutineScope

kotlin 复制代码
@Composable
fun ProfileScreen() {
    val scope = rememberCoroutineScope()
    val snackbarHostState = remember { SnackbarHostState() }

    Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) {
        Column(modifier = Modifier.padding(it)) {
            Button(onClick = {
                scope.launch {
                    val result = simulatedLoadProfile() // 模拟请求
                    snackbarHostState.showSnackbar(result)
                }
            }) {
                Text("加载用户信息")
            }
        }
    }
}

suspend fun simulatedLoadProfile(): String {
    delay(1500) // 模拟网络延迟
    return "用户信息加载成功"
}

注意:这个 scope 绑定到当前 Composable 的组合点,离开组合时会自动取消其中所有协程,不会泄漏。

关键区别LaunchedEffect = "状态变化,自动触发",rememberCoroutineScope = "用户操作,手动触发"。

4.3 DisposableEffect

当副作用需要"配对管理"时------有注册就必须有注销------DisposableEffect 是最佳选择。它要求在 onDispose 块中写明清理逻辑:

kotlin 复制代码
@Composable
fun LifecycleAwareScreen(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current) {
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_START -> Log.d("Lifecycle", ">>>>>> started")
                Lifecycle.Event.ON_STOP -> Log.d("Lifecycle", ">>>>>> stopped")
                else -> {}
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
            Log.d("Lifecycle", ">>>>>> call onDispose")
        }
    }
}

Compose离开组合时自动调用 onDispose,资源回收一条龙。这和 Android View 体系里的"在 onStart 注册、onStop 注销"是一脉相承的思路,只是用声明式重新表达了一遍。

4.4 SideEffect

SideEffect 是六个API里最"轻量"的一个:每次成功重组后执行 。"成功"二字很关键------如果重组因为某些原因被丢弃,SideEffect 就不会跑。

它主要用于把Compose的内部状态同步给非Compose代码,比如更新 Toolbar 标题、同步分析埋点:

kotlin 复制代码
@Composable
fun AnalyticsScreen() {
    var currentPage by remember { mutableStateOf("首页") }

    Column {
        Button(onClick = { currentPage = "详情页" }) { Text("跳转") }
        Text("当前页: $currentPage")
    }

    SideEffect {
        // 每次重组成功后,将当前页面名同步给埋点SDK
        AnalyticsTracker.trackPageView(currentPage)
    }
}

object AnalyticsTracker {
    fun trackPageView(page: String) {
        Log.d("Analytics", ">>>>>> 页面曝光: $page")
    }
}

大多数场景下你不需要 SideEffect,但当你需要确保某个操作在每次成功渲染后都执行时,它是最合适的。

4.5 derivedStateOf与snapshotFlow

这两个API解决同一类问题:当某个状态由其他状态推导而来时,如何避免不必要的重组

derivedStateOf 在组合中使用:

kotlin 复制代码
@Composable
fun FilteredList(rawList: List<String>, keyword: String) {
    // 只有rawList或keyword真正变化时,filtered才重新计算
    val filtered by remember {
        derivedStateOf {
            rawList.filter { it.contains(keyword, ignoreCase = true) }
        }
    }

    LazyColumn {
        items(filtered) { Text(it) }
    }
}

snapshotFlow 则是把Compose的Snapshot状态转换成Kotlin Flow,适合在ViewModel中使用:

kotlin 复制代码
class FilterViewModel : ViewModel() {
    private val _keyword = MutableStateFlow("")

    val filteredResults = snapshotFlow { _keyword.value }
        .debounce(300)
        .mapLatest { performSearch(it) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    fun onKeywordChange(keyword: String) { _keyword.value = keyword }
}

derivedStateOf 是"在组合里减少计算",snapshotFlow 是"把Compose状态带到ViewModel的Flow世界"。

4.6 一图看清API

六个API各有分工,用一张表快速对比:

API 触发时机 典型场景 自动清理 一句话总结
LaunchedEffect 进入组合 / key变化 搜索防抖、页面初始化加载 协程自动取消 状态变了,自动干活
rememberCoroutineScope 手动调用 launch 按钮点击请求、下拉刷新 离开组合时取消 用户点了,我才干活
DisposableEffect 进入组合 / key变化 注册监听器、绑定Observer onDispose 回调 进来注册,走时注销
SideEffect 每次成功重组后 埋点上报、同步Toolbar标题 无需清理 重组跑完,顺手通知外部
derivedStateOf 依赖的状态变化 列表过滤、复合状态计算 无需清理 靠别人算出来的,别白算
snapshotFlow 状态快照变化时发射 ViewModel中观察Compose状态 Flow收集者取消 把Compose状态送进Flow世界

五、陷阱提醒

以下是刚接触Compose副作用时最容易踩的坑:

陷阱1:在 Composable 函数体顶层写副作用。 前面 ProblematicScreen 的例子已经展示了------每重组一次就执行一次。记住:函数体里只做声明式UI描述,副作用一律交给 Effect API。

陷阱2:LaunchedEffect 的key用常量。 比如 LaunchedEffect(Unit),这在某些场景下是对的(比如进入页面只请求一次),但如果你希望状态变化后重新触发,必须把该状态放入key。用错了轻则逻辑不更新,重则内存泄漏。

陷阱3:DisposableEffect 忘记写 onDispose。 编译器不会报错,但监听器、广播接收器会一直持有引用,导致对象无法被GC。这是Compose 里最容易被忽视的泄漏来源。

陷阱4:在remember块里启动协程。 remember 只在首次组合执行,在重组时返回缓存值。在 remember 里启动协程意味着你无法在重组时重启它,也拿不到正确的生命周期取消。

六、实践准则

经过几个项目的踩坑,我总结了六条准则:

  1. 凡是"额外的事"(网络、IO、日志、注册监听)一律放进Effect API,绝不裸写在函数体里。
  2. LaunchedEffect 用于"状态驱动的异步操作",比如关键词变化触发搜索请求、页面初始化数据加载。
  3. rememberCoroutineScope 用于"用户事件驱动的异步操作",比如点赞、提交表单、下拉刷新。
  4. DisposableEffect 用于"需要清理的资源绑定",比如注册BroadcastReceiver、添加Observer、绑定Service。
  5. SideEffect 用于"重组后同步状态到非Compose世界",比如埋点上报、ActionBar标题同步。绝大多数场景你不需要它。
  6. derivedStateOf 用在"组合内部减少不必要的派生计算",snapshotFlow用在"ViewModel中将Compose状态桥接到Flow体系"。

七、结语

副作用 管理是 Compose 和 Android View 体系最根本的思维差异之一。View 时代我们习惯了"在生命周期回调里做任何事",而 Compose 要求我们把副作用和UI声明做明确分离。刚开始会觉得很别扭,但一旦适应,你会发现代码更清晰:UI长什么样是一段代码,UI该做什么副作用是另一段代码 ,二者通过 Effect API 泾渭分明。

回过头再看,Compose的这些设计不是在限制你,而是在保护你------保护你不会因为重组的不可预测性写出连自己都调不通的代码。

八、参考资料

十、往期系列文章

相关推荐
Jomurphys9 小时前
Compose 适配 - 自适应布局(窗口大小类 WindowSizeClasses、列表详情、辅助窗格)
android·compose
Jomurphys11 小时前
Compose 适配 - 分辨率(密度)
android·compose
换元不配限6 天前
Jetpack Compose 状态管理指南
android·状态管理·compose·状态·单向数据流·jetpack compose·状态容器
Android-Flutter16 天前
android compose shadow 阴影 使用
android·kotlin·compose
Android-Flutter17 天前
android compose 自定义Painter绘制图形 使用
android·kotlin·compose
Android-Flutter17 天前
android compose Brush 渐变和着色器 使用
android·kotlin·compose
至乐活着1 个月前
Docker Compose多服务编排实战:从零搭建Node.js+MySQL+Redis全栈应用
docker·微服务·devops·容器编排·compose
le1616162 个月前
Android Compose——尺寸修饰符的调用顺序构成的不同尺寸约束效果
android·compose·modifier
le1616162 个月前
Android Compose Modifier修饰符
android·compose·modifier