F副作用边界
这一章不教「多几个 API」,而是画一条线:线内只描述 UI,线外才做事(请求、监听、导航、弹 Snackbar)。线就叫边界。术语见术语表。
为啥叫「副作用边界」
概念
副作用(Side Effect):除了「返回一段 UI」之外,对外界产生的影响——发网络、注册传感器、改 Window、navigate、showSnackbar。官方文档就叫 Side Effects。
边界是本手册加的教学词。XML 时代,这条线画在生命周期回调上:onCreate 里 inflate,onResume 里请求,onPause 里卸监听。Compose 的 Composable 不是生命周期回调,它会被重组反复执行。所以必须另画一条线:
和 XML 对照
| XML | Compose | |
|---|---|---|
| 描述界面 | layout XML + onCreate inflate | @Composable 函数体 |
| 做事的时机 | onStart / onResume / 点击 | Effect API / 回调 / ViewModel |
| 离开时清理 | onStop / onDestroy | 离开组合 → 取消协程 / onDispose |
按真实场景选型
| 真实场景 | 放哪边 | API | 频率 |
|---|---|---|---|
| 列表/详情拉数、提交登录 | ViewModel(init / 用户事件) | viewModelScope + StateFlow | 常用 |
| 界面收集当前快照 | 组合内读 State(不是 Effect) | collectAsStateWithLifecycle | 常用 |
| 登录成功跳转、弹一次 Snackbar | UI Effect 收集一次性事件 | LaunchedEffect + SharedFlow | 常用 |
| id 变了滚到顶部、请求焦点 | 对齐「进入组合 / key 变」 | LaunchedEffect(key) | 常用 |
| 点击后延时、动画、关弹窗 | 回调里启动协程 | rememberCoroutineScope | 常用 |
| 系统返回键 | 进入组合时接管 | BackHandler | 常用 |
| 传感器 / 广播 / 定位监听 | 成对注册与拆除 | DisposableEffect | 偶尔 |
| 回到前台再刷(对齐 RESUMED) | 生命周期 Effect | LifecycleResumeEffect | 偶尔 |
| 长 Effect 里要最新回调 | 读最新 lambda | rememberUpdatedState | 偶尔 |
| 把 Compose 状态同步给 View/Window | 每次成功组合之后 | SideEffect | 偶尔 |
| 无 VM 的小组件异步 | 组合内收成 State | produceState(见状态) | 偶尔 |
线内禁止:Composable 体里直接做事
概念
函数体每次重组都会再跑一遍。写在这里的 launch、注册监听、navigate,都会重复执行,而且往往取消不掉。
@Composable
fun WrongFeed(vm: FeedViewModel = viewModel()) {
vm.refresh() // 重组再刷一次
viewModelScope.launch { } // 这里根本没有这个 scope;即便有也会乱飞
}
易错点
- 在 Composable 顶层写
scope.launch { }而无 Effect。 - 在组合里直接
nav.navigate(...)—— 条件稍变就连跳。 - 用
collectAsState()收集「只消费一次」的事件(旋转/重组会再吃)。事件走 SharedFlow + Effect。
和 ViewModel 怎么分工 常用
概念
真实项目里 80% 的「副作用」根本不该写在 Compose Effect 里:网络、数据库、校验属于 ViewModel / 仓库。界面只做两件事:渲染快照、把 UI 事件转出去。
怎么用(★ 业务页标准结构)
class FeedViewModel : ViewModel() {
private val _ui = MutableStateFlow(FeedUi())
val ui: StateFlow<FeedUi> = _ui.asStateFlow()
private val _events = MutableSharedFlow<FeedEvent>()
val events = _events.asSharedFlow()
init { refresh() } // 进页拉数:跟 VM 存活绑定,不跟重组绑
fun refresh() = viewModelScope.launch { /* 仓库 → _ui */ }
fun onItemClick(id: String) = viewModelScope.launch {
_events.emit(FeedEvent.OpenDetail(id)) // 一次性
}
}
@Composable
fun FeedRoute(
vm: FeedViewModel = viewModel(),
onOpenDetail: (String) -> Unit
) {
val ui by vm.ui.collectAsStateWithLifecycle()
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(vm) {
vm.events.collect { e ->
when (e) {
is FeedEvent.OpenDetail -> onOpenDetail(e.id)
is FeedEvent.Toast -> snackbar.showSnackbar(e.msg)
}
}
}
FeedScreen(ui, vm::refresh, vm::onItemClick, snackbar)
}
易错点
- 用
LaunchedEffect(Unit) { vm.refresh() }当「进页拉数」:旋转后 VM 还在,离开再进组合会再刷;该放 VMinit或明确的用户下拉。 - 把导航写进 VM(持有 NavController)→ 难测、泄漏。VM 只 emit 事件,Route 层 navigate。
- 一次性事件放进 StateFlow:配置变化会再消费一次。用 SharedFlow / Channel。
▶ 登录成功:VM 发事件,界面 Effect 里 navigate
LaunchedEffect(vm) {
vm.events.collect { e ->
if (e is LoginEvent.Ok) nav.navigate("home") {
popUpTo("login") { inclusive = true }
}
}
}
不要在 if (ui.success) nav.navigate(...) 写在组合体里——success 为 true 的每一帧都可能跳。
LaunchedEffect 常用
概念
进入组合时启动协程;离开组合或 key 变化时取消旧协程再开新的。适合「跟这块 UI 活着绑在一起」的挂起工作:收集 SharedFlow、滚动、延时聚焦,而不是替代 ViewModel 发业务请求。
和 XML 对照
≈「这段界面在屏幕上时跑、拿掉就停」;不是简单等于 onResume(回到前台但界面一直在组合里,LaunchedEffect(Unit) 不会重跑)。
怎么用
@Composable
fun DetailScreen(itemId: String, listState: LazyListState) {
// key 变 → 取消旧任务:换详情时滚回顶
LaunchedEffect(itemId) {
listState.scrollToItem(0)
}
}
key 怎么选
| 写法 | 何时重启 | 典型 |
|---|---|---|
LaunchedEffect(Unit) / (true) | 仅进入/离开组合 | 整段生命周期收集事件 |
LaunchedEffect(userId) | userId 变 | 换人滚顶、换人重新订阅 |
LaunchedEffect(vm) | vm 实例变 | 收集该 VM 的 SharedFlow |
易错点
- key 写成每次重组都新的对象(不稳定 lambda、新 List)→ Effect 不停重启。
- 在 Effect 里改 State,而这个 State 又是 key → 无限重组。
- 用 LaunchedEffect 收集
StateFlow来渲染列表 → 用collectAsStateWithLifecycle,别手写 collect(后台生命周期不感知)。
▶ key 变化会取消旧协程
rememberCoroutineScope 常用
概念
在组合内拿到一个随界面离开而取消的作用域,供点击、滑动等回调里 launch。不是「进页即跑」——进页用 VM 或 LaunchedEffect。
和 XML 对照
≈ 写在 setOnClickListener 里的 lifecycleScope.launch。
怎么用
@Composable
fun SaveButton(snackbar: SnackbarHostState, onSave: suspend () -> Unit) {
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch {
onSave()
snackbar.showSnackbar("已保存")
}
}) { Text("保存") }
}
易错点
- 在 Composable 体(非回调)里直接
scope.launch→ 仍随重组乱飞。 - 能放 VM 的业务请求不要堆在点击 scope 里(难测、配置变化后回调还在飞)。点击只
vm.submit(),结果走 State / 事件。
▶ 点击后延时关闭弹层
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch {
delay(800)
onClose()
}
}) { Text("稍后关闭") }
BackHandler 常用
概念
进入组合时接管系统返回;离开组合自动卸掉。内部就是一层 DisposableEffect + OnBackPressedDispatcher。
@Composable
fun EditorScreen(dirty: Boolean, onQuit: () -> Unit) {
var ask by remember { mutableStateOf(false) }
BackHandler(enabled = dirty) { ask = true }
if (ask) {
AlertDialog(
onDismissRequest = { ask = false },
title = { Text("放弃修改?") },
confirmButton = {
TextButton(onClick = onQuit) { Text("放弃") }
}
)
}
}
易错点
多层都 BackHandler(true) 时,谁在组合树更「近用户」谁先吃。对话框打开时记得让下层 enabled = false 或由 Dialog 自己处理。
DisposableEffect 偶尔
概念
需要成对「注册 / 反注册」且不一定是协程时用;onDispose 必写。传感器、广播、给旧 View 加 Listener、把回调挂到非 Compose 对象,都走这里。
和 XML 对照
≈ onStart 注册、onStop 反注册。
怎么用
@Composable
fun KeepScreenOn() {
val window = (LocalContext.current as Activity).window
DisposableEffect(window) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
onDispose {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
易错点
忘记 onDispose → 泄漏;key 选成每次重组都变的对象 → 疯狂注册注销。
▶ 注册传感器,离开组合必须拆掉
DisposableEffect(listener) {
sensorManager.registerListener(listener, sensor, /* … */)
onDispose { sensorManager.unregisterListener(listener) }
}
LifecycleResumeEffect 偶尔
概念
LaunchedEffect 对齐的是组合(在不在树上),不是 Activity onResume。Home 键出去再回来,界面往往仍在组合里,LaunchedEffect(Unit) 不会重跑。要对齐前台:用 LifecycleResumeEffect / LifecycleStartEffect(lifecycle-runtime-compose)。
@Composable
fun TrackVisible(vm: FeedViewModel) {
LifecycleResumeEffect(Unit) {
vm.onForeground() // 回前台刷新、开始埋点时长
onPauseOrDispose {
vm.onBackground()
}
}
}
怎么选
| 要对齐的时机 | 用 |
|---|---|
| 这块 UI 出现在树上 / 消失 | LaunchedEffect / DisposableEffect |
| 进程到前台 / 后台(界面可能一直在) | LifecycleResumeEffect |
| STARTED(含分屏未聚焦等) | LifecycleStartEffect |
rememberUpdatedState 偶尔
概念
长生命周期 Effect(LaunchedEffect(Unit))会捕获创建时的 lambda。父级每次重组可能传入新回调,Effect 却不该重启。用 rememberUpdatedState 读最新值。细节与示例见状态章。
val current by rememberUpdatedState(onTick)
LaunchedEffect(Unit) {
while (true) {
delay(1000)
current()
}
}
SideEffect 偶尔
概念
在每次成功完成组合之后同步执行(非协程)。用来把 Compose 里已经确认的状态,写给非 Compose 对象(Analytics 属性、旧 View 的某个字段)。不要拿它发网络。
@Composable
fun TrackUser(userId: String) {
SideEffect {
analytics.setUserId(userId)
}
}
和 LaunchedEffect 的差别
SideEffect:同步、每帧组合成功都可能跑。LaunchedEffect:协程、按 key 启动/取消。写外部对象用前者;挂起工作用后者。
和状态章的衔接
下列 API 也在「边界」上,但产物是 State,主讲放在状态与数据流:
collectAsStateWithLifecycle:快照 → 渲染(不是一次性事件)snapshotFlow:State → Flow,收集仍放 EffectproduceState:轻量岛异步收成 State;正式页仍用 VM
对着选(防选错边)
| 我想做的事 | 不要 | 要 |
|---|---|---|
| 进页拉列表 | Composable 体 / 仅用 Effect 当仓库 | VM init 或下拉 vm.refresh() |
| 渲染 VM 当前值 | LaunchedEffect { stateFlow.collect } | collectAsStateWithLifecycle |
| 登录成功只跳一次 | if (ok) navigate 写在组合体 | SharedFlow + LaunchedEffect collect |
| 点击保存后 toast | 组合体 launch | 点击里 vm.save(),事件 Effect 里 showSnackbar;或短任务 rememberCoroutineScope |
| 换详情 id 滚到顶 | 在列表 item 里乱滚 | LaunchedEffect(id) { scrollTo(0) } |
| 拦截返回 | 自己找 dispatcher 忘了卸 | BackHandler |
| 亮屏常亮 / 传感器 | onResume 注册忘 onPause | DisposableEffect + onDispose |
| 从后台回前台再刷 | 以为 LaunchedEffect(Unit) 会重跑 | LifecycleResumeEffect |