F副作用边界

这一章不教「多几个 API」,而是画一条线:线内只描述 UI线外才做事(请求、监听、导航、弹 Snackbar)。线就叫边界。术语见术语表

为啥叫「副作用边界」

概念

副作用(Side Effect):除了「返回一段 UI」之外,对外界产生的影响——发网络、注册传感器、改 Window、navigate、showSnackbar。官方文档就叫 Side Effects。

边界是本手册加的教学词。XML 时代,这条线画在生命周期回调上:onCreate 里 inflate,onResume 里请求,onPause 里卸监听。Compose 的 Composable 不是生命周期回调,它会被重组反复执行。所以必须另画一条线:

和 XML 对照

XMLCompose
描述界面layout XML + onCreate inflate@Composable 函数体
做事的时机onStart / onResume / 点击Effect API / 回调 / ViewModel
离开时清理onStop / onDestroy离开组合 → 取消协程 / onDispose
章名不叫「副作用 API 清单」,是因为选错边比记错函数名更常见:把请求写进 Composable 体,重组就会连打 N 枪。

按真实场景选型

常用业务页几乎天天写 偶尔对上场景再用
真实场景放哪边API频率
列表/详情拉数、提交登录ViewModel(init / 用户事件)viewModelScope + StateFlow常用
界面收集当前快照组合内读 State(不是 Effect)collectAsStateWithLifecycle常用
登录成功跳转、弹一次 SnackbarUI Effect 收集一次性事件LaunchedEffect + SharedFlow常用
id 变了滚到顶部、请求焦点对齐「进入组合 / key 变」LaunchedEffect(key)常用
点击后延时、动画、关弹窗回调里启动协程rememberCoroutineScope常用
系统返回键进入组合时接管BackHandler常用
传感器 / 广播 / 定位监听成对注册与拆除DisposableEffect偶尔
回到前台再刷(对齐 RESUMED)生命周期 EffectLifecycleResumeEffect偶尔
长 Effect 里要最新回调读最新 lambdarememberUpdatedState偶尔
把 Compose 状态同步给 View/Window每次成功组合之后SideEffect偶尔
无 VM 的小组件异步组合内收成 StateproduceState(见状态偶尔
口诀:业务真相 → VM当前值给 UI 渲染 → collectAsState一次性事件 / 跟组合绑定的动作 → LaunchedEffect点击才跑 → rememberCoroutineScope成对清理 → DisposableEffect

线内禁止: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 还在,离开再进组合会再刷;该放 VM init 或明确的用户下拉。
  • 把导航写进 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 / LifecycleStartEffectlifecycle-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,主讲放在状态与数据流

对着选(防选错边)

我想做的事不要
进页拉列表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 注册忘 onPauseDisposableEffect + onDispose
从后台回前台再刷以为 LaunchedEffect(Unit) 会重跑LifecycleResumeEffect
下一章:导航与多页面。navigate 属于线外动作,通常由本页的事件 Effect 触发。
Android Compose 现代开发知识体系 · 主线必读 / 扩展选读。
术语表常驻;组件与属性先看分讲,再查两张总表。案例默认收起。