6、状态管理(下):StateFlow 与 collectAsState,Compose 中的副作用

好,咱们接着聊状态管理。上一章我们把 Compose 里的基础状态讲透了,这一章要上点硬菜——StateFlow副作用。这两个东西,说白了就是帮你把「异步数据」和「生命周期」管明白的。

我个人习惯把 StateFlow 理解成「升级版的 LiveData」。为什么这么说?因为它天生支持协程,而且没有 LiveData 那些奇奇怪怪的粘性事件问题。我在项目中遇到过好几次,用 LiveData 做搜索框时,屏幕旋转后老数据又冒出来了,烦得很。换成 StateFlow 后,世界清净了。

核心结论:StateFlow + collectAsState 是 Compose 官方推荐的异步状态方案。副作用 API 则是你操控「时机」的瑞士军刀。

6.1 StateFlow 与 collectAsState

先看一个最简单的例子。假设你有一个 ViewModel,里面有个倒计时功能:

class TimerViewModel : ViewModel() {
    private val _seconds = MutableStateFlow(0)
    val seconds: StateFlow<Int> = _seconds.asStateFlow()

    fun startTimer() {
        viewModelScope.launch {
            while (true) {
                delay(1000)
                _seconds.value++
            }
        }
    }
}

在 Compose 里怎么用?一句话的事:

@Composable
fun TimerScreen(viewModel: TimerViewModel = viewModel()) {
    val seconds by viewModel.seconds.collectAsState()

    Text("已过去:$seconds 秒")
}

嗯,这里要注意:collectAsState() 默认会帮你处理生命周期。屏幕可见时收集,不可见时自动取消。你想想看,这比手写 LiveData 的 observe 省了多少事?

6.1.1 为什么不用 LiveData?

我曾经在项目里做过一个对比测试。同样的数据流,LiveData 在配置变更后会有 200ms 左右的「粘性事件」窗口期。而 StateFlow 因为基于协程,天然就是冷流 + 去重设计。

说白了,StateFlow 有三大优势:

  • 无粘性事件:新订阅者不会收到旧数据
  • 协程友好:可以跟 Flow 的各种操作符无缝衔接
  • 去重:只有值真正变化时才通知,避免无谓重组
小技巧:如果你需要「粘性」行为(比如新页面打开时立即显示最新数据),用 stateIn(SharingStarted.WhileSubscribed(5000)) 替代 MutableStateFlow。

6.2 Compose 中的副作用

副作用这个词,听着挺唬人。其实说白了就是:在 Composable 函数里干「不纯」的事。比如网络请求、数据库写入、注册监听器——这些都不能直接写在 Composable 函数体里,因为 Compose 会反复执行。

那怎么办?官方给了三个宝贝:LaunchedEffect、DisposableEffect、rememberCoroutineScope。

6.2.1 LaunchedEffect:启动协程,自动取消

这个最常用。它会在 Composable 进入组合时启动一个协程,离开时自动取消。我习惯用它来做「一次性」的初始化工作:

@Composable
fun UserProfile(userId: String) {
    var userData by remember { mutableStateOf<User?>(null) }

    LaunchedEffect(userId) {
        // 当 userId 变化时,重新请求
        userData = api.fetchUser(userId)
    }

    // 渲染 UI...
}

注意看,我把 userId 作为 key 传给了 LaunchedEffect。这意味着:只有 userId 变化时,协程才会重启。如果 userId 没变,即使重组也不会重新执行。

避坑指南:我曾经犯过一个错——把 LaunchedEffect 的 key 写成固定值(比如 Unit),结果页面切回来时数据不刷新。记住:key 决定了「什么时候重新执行」。

6.2.2 DisposableEffect:有借有还

这个跟 LaunchedEffect 很像,但多了一个「清理」回调。适合注册/反注册的场景:

@Composable
fun SensorDisplay() {
    val context = LocalContext.current
    val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager

    DisposableEffect(Unit) {
        val listener = object : SensorEventListener {
            override fun onSensorChanged(event: SensorEvent?) { /* 处理数据 */ }
            override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
        }
        sensorManager.registerListener(listener, ...)

        // 这个 lambda 就是清理函数
        onDispose {
            sensorManager.unregisterListener(listener)
        }
    }
}

你想想看,如果不用 DisposableEffect,你很可能忘记在 Composable 离开时取消注册。那就会造成内存泄漏。嗯,我早期做项目时就吃过这个亏,后来养成了习惯:只要涉及注册,就用 DisposableEffect

6.2.3 rememberCoroutineScope:手动控制协程

有时候你需要在 Composable 外部启动协程,比如按钮点击事件里。这时候就需要 rememberCoroutineScope:

@Composable
fun LoginScreen() {
    val scope = rememberCoroutineScope()
    var loading by remember { mutableStateOf(false) }

    Button(
        onClick = {
            scope.launch {
                loading = true
                try {
                    loginApi.login()
                } finally {
                    loading = false
                }
            }
        }
    ) {
        Text(if (loading) "登录中..." else "登录")
    }
}

这里有个细节:rememberCoroutineScope 返回的 scope 会跟随 Composable 的生命周期。如果 Composable 被移出组合,scope 会自动取消所有未完成的协程。所以不用担心泄漏问题。

我的选择标准:
- 需要自动跟随生命周期?用 LaunchedEffect
- 需要清理资源?用 DisposableEffect
- 需要手动控制时机(比如点击事件)?用 rememberCoroutineScope

6.3 知识体系总览

下面这张图,是我自己梳理的「状态管理 + 副作用」知识结构。你可以把它当作一个速查表:

Compose 状态管理 & 副作用体系 状态管理 副作用 StateFlow collectAsState MutableStateFlow asStateFlow() LaunchedEffect DisposableEffect rememberCoroutineScope SideEffect 核心原则 1. 状态尽量上提,副作用尽量下沉 2. 用 LaunchedEffect 做异步,用 DisposableEffect 做清理 3. StateFlow + collectAsState 是 ViewModel 层的最佳拍档

6.4 实战:一个完整的搜索页面

最后,我把上面这些知识点串起来,写一个搜索页面的核心逻辑。你感受一下:

class SearchViewModel : ViewModel() {
    private val _query = MutableStateFlow("")
    val query: StateFlow<String> = _query.asStateFlow()

    private val _results = MutableStateFlow<List<String>>(emptyList())
    val results: StateFlow<List<String>> = _results.asStateFlow()

    fun onQueryChanged(newQuery: String) {
        _query.value = newQuery
    }

    fun search() {
        viewModelScope.launch {
            _results.value = api.search(_query.value)
        }
    }
}

@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
    val query by viewModel.query.collectAsState()
    val results by viewModel.results.collectAsState()

    Column(modifier = Modifier.padding(16.dp)) {
        OutlinedTextField(
            value = query,
            onValueChange = { viewModel.onQueryChanged(it) },
            label = { Text("搜索") }
        )

        Spacer(modifier = Modifier.height(8.dp))

        Button(onClick = { viewModel.search() }) {
            Text("搜索")
        }

        // 防抖处理:用户停止输入 500ms 后自动搜索
        LaunchedEffect(query) {
            if (query.isBlank()) return@LaunchedEffect
            delay(500)
            viewModel.search()
        }

        LazyColumn {
            items(results) { item ->
                Text(item, modifier = Modifier.padding(8.dp))
            }
        }
    }
}

你看,这里用到了:

  • StateFlow 管理查询词和结果
  • collectAsState 在 Compose 中收集
  • LaunchedEffect 实现防抖
  • rememberCoroutineScope 其实没用到——因为搜索按钮直接调用了 ViewModel 的方法,不需要手动启动协程
注意:防抖逻辑放在 LaunchedEffect 里时,要小心 key 的变化。每次 query 变化都会取消上一个协程,所以 delay 500ms 的效果是「最后一次输入后等 500ms」。这正是我们想要的。

好了,这一章的内容就到这里。StateFlow 和副作用 API 是 Compose 开发中的高频知识点,建议你多写几个小例子练练手。尤其是 DisposableEffect 的清理逻辑,很容易漏掉——我曾经就因为忘了在 onDispose 里取消网络请求,导致页面切走后还在回调里更新 UI,直接崩溃了。从那以后,我写任何注册逻辑都会条件反射地加上 onDispose。


公众号:蓝海资料掘金营,微信deep3321