外观
状态与副作用
Compose 里只有两类「会动的东西」:状态(State) 告诉框架「这里变了,重画」;副作用(Effect) 告诉外部世界「这里进了 / 出了组合」。状态写错的后果是界面不更新或无限重组,副作用写错的后果是请求重复发、监听泄漏、回调打在已经消失的页面上。
判断顺序永远是这三个问题,答完用哪个 API 是唯一解:这份数据属于谁(一个组件用,还是几个兄弟一起用)、要活多久(一次重组 / 一次配置变化 / 跨进程)、谁负责改它(用户事件、进页面时、还是从别的状态推导出来)。顺序反了就会写出「拿 LaunchedEffect 当点击回调」「把 Context 存进单例」这类代码。
本页只讲原理和写法,组件参数看 UI 组件库。
一、状态放哪里
1.1 先挑对容器
| 存什么 | 用 | 寿命 / 触发重组的条件 |
|---|---|---|
一般值:String、自定义 data class、可空对象 | remember { mutableStateOf(v) } | 本次组合;value 变了 |
Int / Long / Float / Double | mutableIntStateOf / mutableLongStateOf / mutableFloatStateOf / mutableDoubleStateOf | 本次组合;同上,且不装箱 |
Boolean | mutableStateOf(false) | Boolean 没有特化版本 |
| 进程被回收后还要恢复的东西 | rememberSaveable { mutableStateOf(v) } | 宿主 Activity 的 saved state;值必须能进 Bundle |
| 列表 / 映射的增删改 | remember { mutableStateListOf<T>() } / mutableStateMapOf<K, V>() | 本次组合;结构变了才触发 |
kotlin
var count by remember { mutableIntStateOf(0) } // ✅ 基本类型用特化版本,读写都不装箱
var expanded by remember { mutableStateOf(false) } // Boolean 没有特化版本
// ❌ var count by remember { mutableStateOf(0) }:Int 被包成 Integer,每次读写都装箱rememberSaveable 只该装「用户下次回来还要在」的东西:输入框内容、当前页签、页码、滚动位置、用户拨过的筛选开关。
「可序列化」是硬门槛
默认 saver 只能往 Bundle 里塞 Bundle 支持的类型:String、Int / Long / Float / Double / Boolean / Char、CharSequence、它们的数组、Parcelable、Serializable、ArrayList、Bundle。自己的 data class 不在名单里 —— 塞进去,转屏或进程恢复时直接崩。
kotlin
// ❌ 自定义对象直塞:IllegalArgumentException ... cannot be saved ...
var draft by rememberSaveable { mutableStateOf(Draft(0L, "", "")) }
// ✅ 首选:只存原始字段或 id,对象恢复时按 id 从 KV / 缓存取回来
var draftId by rememberSaveable { mutableStateOf<Long?>(null) }
// ✅ 确实要存对象:给一个 save / restore 对称的 Saver
val DraftSaver = listSaver<Draft, Any>(
save = { listOf(it.id, it.title, it.body) },
restore = { Draft(it[0] as Long, it[1] as String, it[2] as String) },
)
var draft by rememberSaveable(stateSaver = DraftSaver) { mutableStateOf(Draft(0L, "", "")) }长列表、大段文本不要进 saveable:Bundle 走 Binder,上限 1MB 量级,超了就是 TransactionTooLargeException,内容该进 PluginKv / PluginRecords。也别在每个小组件上挂它,保存和恢复都有成本,只用在页面级的关键状态上。最后,onFinish() 之后重新进入是全新实例,saveable 留不下 —— 要跨会话必须落库。
1.2 状态提升:提到「读它的最低共同父级」,不再往上
| 情况 | 放哪 | 信号 |
|---|---|---|
| 只有一个组件用(标题栏展开态、某个弹窗开关、局部 loading) | 就地 remember 在它自己身上 | 没有兄弟、没有父级需要读它 |
| 多个兄弟要读同一份(筛选条件、选中项、分页游标) | 提到共同父级:父级持有 var,兄弟收 value + onValueChange | 两个以上地方读,或一处写、另一处读 |
| 要跨页面分区存活 / 要落库 / 要跟网络对齐 | 提到 App() 顶层,或抽成 state holder | 数据的寿命 > 单个组件的寿命 |
kotlin
// ✅ 分类切换和列表是两个兄弟,都要这份数据 —— 所以它属于 App()
@Composable
fun App(onFinish: () -> Unit) {
var category by rememberSaveable { mutableStateOf(0) }
TopTitleScaffold(title = "全部收藏", onBack = onFinish) {
Column {
PillTabsRow( // 组件库 PillTabsRow
labels = listOf("推荐", "科幻", "悬疑"),
selectedIndex = category,
onSelect = { category = it }, // 只有一个地方写
)
ContentList(category = category) // 可以有多个地方读
}
}
}
// ContentList(category: Int) 是无状态的:只认参数,能复用,不会出现第二份不同步的数据
// ❌ 每个兄弟各记一份:FilterTabs() 和 ContentList() 里各来一个 remember { mutableIntStateOf(0) },永远同步不了
// ❌ 提过头:只有标题栏读的展开态放到 App() 顶层,改它整个 App() 都重组,回调还得穿过四层什么时候必须上 App() 顶层
只有三种:要跨多个页面分区同时可见;寿命比某个组件长(比如「已保存草稿」要活到退出);要参与落库 / 网络请求。除此之外,越靠近用它的地方越好。
一个组件里超过五六份互相关联的状态、还配一堆改它们的函数时,收进 @Stable 的 state holder,用 rememberXxxState() 拿,别让参数和回调成对膨胀:
kotlin
@Stable
class SearchState {
var query by mutableStateOf("")
private set // 改动点收敛在这一个类里
var page by mutableStateOf(1)
fun onQueryChange(value: String) { query = value; page = 1 } // 换关键词必须重置页码
}
@Composable fun rememberSearchState(): SearchState = remember { SearchState() }1.3 derivedStateOf 和 snapshotFlow:只在边界上才有价值
derivedStateOf 只有一个适用场景:输入是别的 State,且这个派生值会被「比输入更不容易重组的读取者」读。
kotlin
// ❌ 白加一层:取模本来就不贵。直接 val isEven = count % 2 == 0 就行
val isEven by remember { derivedStateOf { count % 2 == 0 } }
val listState = rememberLazyListState() // 输入:滚动位置(每帧都变)
// ❌ 滚动一像素,读 showTop 的组件就重组一次(`firstVisibleItemIndex > 0` 还会在滚一点点又滚回来时抖)
val showTop = listState.firstVisibleItemIndex > 0
// ✅ 只有 true / false 真翻转,读它的组件才重组;显隐判断本身用组件库认可的 canScrollBackward
val showTop by remember { derivedStateOf { listState.canScrollBackward } }
if (showTop) ScrollToTopButton(onClick = { scope.launch { listState.animateScrollToItem(0) } })- 它不缓存非 State 的东西:里面读到的普通变量、函数参数变了,它不会重算 —— 那种情况该用
remember(key)。 - 「包一层就能少重组」是错的:输入变了结果也变,读结果的组件照样重组;它省下的只是「输入变了但结果没变」的重复计算。没实测过就别加,更别当成万能性能优化。
snapshotFlow 是把 Compose 状态送出去的桥,用在「要把状态变化交给一个协程流程」时(输入去抖后请求、滚动位置上报、把选中项同步给 KV)。两个必守细节:必须在 LaunchedEffect 里 collect(不 collect 什么都不发生);它一定会先发一次当前值,后面必配 distinctUntilChanged()。分工上:derivedStateOf 是 State → State 且读取方还是组件;snapshotFlow 是 State → Flow,读取方是协程。
kotlin
var query by rememberSaveable { mutableStateOf("") }
LaunchedEffect(Unit) {
snapshotFlow { query } // 追踪读取过的 State
.debounce(400) // 输入停 400ms 才发(debounce 需要 @OptIn(FlowPreview::class))
.distinctUntilChanged()
.collect { keyword -> if (keyword.isNotBlank()) search(keyword) }
}
LaunchedEffect(Unit) { search(query) } // ❌ key = Unit:读到的 query 永远是进入组合时的那个
LaunchedEffect(query) { search(query) } // ❌ 换成 query 当 key:每敲一个字就重启,去抖等于没做
val debounced by remember { derivedStateOf { query } } // ❌ derivedStateOf 不做节流,它只是同步计算列表自动加载就是 snapshotFlow 的用法,完整写法见列表、滚动与自动加载更多。
1.4 StateFlow 收进 UI
kotlin
// ViewModel 里:private val _state = MutableStateFlow<UiState>(UiState.Loading)
// val state: StateFlow<UiState> = _state.asStateFlow()
@Composable
fun FavoritesScreen(vm: FavoritesViewModel) {
val state by vm.state.collectAsStateWithLifecycle()
when (val s = state) { // 捕获成 val 再 when,避免不安全的强转
UiState.Loading -> LoadingState() // 组件库 LoadingState
is UiState.Error -> ErrorState(message = s.message, onRetry = { vm.reload() })
is UiState.Ok -> if (s.items.isEmpty()) EmptyHint(text = "还没有内容") else FavoritesList(s.items)
}
}- import 是
androidx.lifecycle.compose.collectAsStateWithLifecycle,依赖androidx.lifecycle:lifecycle-runtime-compose;by委托记得import androidx.compose.runtime.getValue。 - 用
collectAsStateWithLifecycle(),不要用collectAsState():前者在页面 STOPPED 后停止收集,后者不会 —— 插件压到后台还在为一个没人看的界面干活。也别自己在 UI 里LaunchedEffect(flow) { flow.collect { state = it } },那正是它干的活,还少了生命周期保护。 StateFlow是热流、会重放最后一个值:页面重建拿到最新数据是好事,但也意味着它不能当一次性事件用(见坑 3)。- 简单插件不必强上 ViewModel,一个
App()里用remember/rememberSaveable+rememberCoroutineScope就够。值得上的信号:状态要活过组合、要跟页面之外的东西对齐(后台任务、下载进度)、要多个分区读同一份。
二、副作用
2.1 五个副作用对照表 ★
| API | 什么时候跑 | 用来干什么 | 用错会怎样 |
|---|---|---|---|
LaunchedEffect(key) | 进组合跑一次;任意 key 变化 → 先取消上一个协程,再用新 key 重跑 | 挂起逻辑:进页面拉数据、收集 Flow、snapshotFlow 自动加载 | key 选错 → 反复重启或永不更新;key 用了每帧都不同的值 → 无限重启 |
SideEffect { } | 每次成功重组之后都跑 | 把 Compose 状态单向同步给非 Compose 的对象 | 拿它发请求 / 分配资源 → 重组几次就几次;在里面写状态 → 无限重组 |
DisposableEffect(key) { … onDispose { } } | 进组合时 setup;key 变化或离开组合时先 onDispose | 必须配对的注册 / 注销:监听、回调、定时器、动画 | 漏 onDispose → 监听泄漏,回调打在已销毁的页面上 |
rememberUpdatedState(v) | 不执行代码,只把「最新值」包成一个 State | 长命协程要用最新 lambda / 参数,但不想重启副作用 | 不用 → 闭包拿到过期 lambda:用旧 id 调接口、调已失效的回调 |
rememberCoroutineScope() | 只给一个跟组合同寿命的 scope,不自动启动任何东西 | 事件回调(点击、下拉刷新、手势)里启动协程 | 当「进页面就跑」用 → 什么都不发生;拿它跑长任务 → 离开页面就被取消 |
选择顺序:进 / 出页面要做的事 → LaunchedEffect / DisposableEffect;用户操作了才发生的事 → rememberCoroutineScope;重组之后要把值推给外部 → SideEffect;长协程要用最新值 → rememberUpdatedState。
LaunchedEffect(key):key 就是「这件事的输入」
kotlin
LaunchedEffect(Unit) { results = search(query) } // ❌ key = Unit:query 变了也不重跑,永远是第一次的结果
LaunchedEffect(query) { results = search(query) } // ✅ 参与运算的东西就是 key
LaunchedEffect(userId, page) { pageData = load(userId, page) } // ✅ 多个 key:任意一个变都重跑(旧的先被取消)
// ❌ onLoaded 是父级传下来的 lambda:父级每次重组都是新实例 → key 每次都「变了」→ 无限重启
LaunchedEffect(onLoaded) { loadOnce(); onLoaded() }
LaunchedEffect(System.currentTimeMillis()) { retry() } // ❌ 每帧都不同的值,永远不相等
LaunchedEffect(articleId, keyword) { reload(articleId, keyword) } // ✅ key 用可比较的身份key 变化会取消上一个协程,两个后果都得处理。其一,取消是协作式的:delay、withContext 会在挂起点抛 CancellationException,后面的代码不再执行;但阻塞式调用(例如 OkHttp 的 execute())不会被打断,所以写状态前确认自己还活着:
kotlin
LaunchedEffect(category) {
val data = fetch(category)
ensureActive() // 这次 effect 已被换掉就别往下写
items = data
}其二,别把取消异常一起吞掉 —— 这是「退出页面后还在写状态」「旧请求盖掉新请求」的根因:
kotlin
// ❌ catch (Exception) 把 CancellationException 也吞了:协程该结束了,却还在这里写界面状态
try { items = withContext(Dispatchers.IO) { fetch(category) } } catch (e: Exception) { error = e.message }
// ✅ 取消异常原样抛出
try { items = withContext(Dispatchers.IO) { fetch(category) } }
catch (e: CancellationException) { throw e }
catch (e: Exception) { error = e.message }SideEffect:每次重组之后同步一次
只用一种场景:把 Compose 状态单向推给非 Compose 对象(上报、外部 View、宿主侧持有者)。它没有 key、没有清理,每次成功重组都必跑。
kotlin
val tracker = remember { ScreenTracker() }
SideEffect { tracker.current = title } // ✅ 没有资源要注销,每次重组推最新值就够
SideEffect { scope.launch { load() } } // ❌ 当「进入页面时初始化」用:重组 5 次 = 请求 5 次
SideEffect { count++ } // ❌ 在里面写 Compose 状态:写 → 重组 → 又写,无限循环「进页面跑一次」用 LaunchedEffect;分配 / 注销资源用 DisposableEffect。
DisposableEffect:有注册就必须有注销
kotlin
DisposableEffect(host) {
host.addListener(listener) // setup:进入组合 / key 变化时执行
onDispose { host.removeListener(listener) } // ★ 缺这一行就是泄漏
}
DisposableEffect(Unit) { // 定时器不 cancel:退出页面后任务还在跑
val timer = Timer(); timer.scheduleAtFixedRate(task, 0, 1000L)
onDispose { timer.cancel() }
}必须成对处理的清单:传感器 / 广播接收器、生命周期 observer、宿主注册的回调、Timer、Handler.postDelayed、动画 job、取消订阅。key 变化时的顺序是先 onDispose 旧的、再 setup 新的,所以「换了一个要监听的对象」不会重复注册。最后一句必须是 onDispose { },不写编译不过。
rememberUpdatedState:长协程要拿到最新的 lambda
kotlin
// ❌ 闭包被定格在「进入组合那一刻」:onTimeout 是当时那个实例,intervalMs 也停在第一帧
LaunchedEffect(Unit) { delay(intervalMs); onTimeout() }
@Composable
fun AutoClose(intervalMs: Long, onTimeout: () -> Unit) {
val currentOnTimeout by rememberUpdatedState(onTimeout) // 要「最新」的
LaunchedEffect(intervalMs) { // 要「重来」的放 key
delay(intervalMs)
currentOnTimeout() // lambda 变了,下次调用拿到新的就行
}
}一句话判断:「变了要让整件事重来」→ 放 key;「变了只要下次用到时拿新的」→ rememberUpdatedState。 长任务轮询是最典型的场景 —— 轮询跑几分钟,只有 onDone 每次都要是最新的,而 effect 不该重启。
rememberCoroutineScope:点击里启动协程
kotlin
// ❌ 把点击当状态喂给 LaunchedEffect:首次组合就会跑一次(clicked=false 也进去了),
// 还多了一份只用来传递事件的状态
var clicked by remember { mutableStateOf(false) }
LaunchedEffect(clicked) { if (clicked) { submit(); clicked = false } } // 首次组合就会跑一次
onClick = { runBlocking { submit() } } // ❌ 直接调 suspend:编译不过;runBlocking 则卡死主线程(ANR)kotlin
// ✅ 点了才发生的事,就在事件回调里 launch
val scope = rememberCoroutineScope()
var isSubmitting by remember { mutableStateOf(false) }
FilledButton( // 组件库 FilledButton
text = "提交",
onClick = {
scope.launch {
isSubmitting = true
try { submit() } finally { isSubmitting = false } // 失败也要复位,否则按钮永远转圈
}
},
)同样适用于下拉刷新的 onRefresh、ScrollToTopButton 的滚动、手势回调。scope 的寿命等于组合,页面离开协程就被取消 —— 这正合「点一下就请求」的意。要活过页面的活(落盘、上传、上报)放到 ViewModel 的 scope 或宿主前台服务里。
它不启动任何东西。写了 rememberCoroutineScope() 却不 launch,就是什么都不发生 —— 「进页面要跑」属于 LaunchedEffect。
2.2 CompositionLocal:隐式传参,以及插件里的 LocalContext
CompositionLocal 是组合树上的一个「隐式参数槽」:谁提供,下面所有子孙都能读,不用逐层传参数。你其实一直在用它 —— LocalContext、LocalConfiguration / LocalDensity / LocalLayoutDirection、LocalLifecycleOwner,以及主题 / 字阶 / 形状那一套(PluginTheme { } 一包,下面所有组件就拿到正确颜色,这就是主题不用你管的原因)。
kotlin
val LocalEditMode = compositionLocalOf { false } // 顶层定义,不能写在 @Composable 里
CompositionLocalProvider(LocalEditMode provides true) { EditorBody() } // 提供;下面所有子孙都能读
@Composable private fun EditorBody() { val editMode = LocalEditMode.current } // 读取,不用参数什么时候值得自定义:值要穿过 3 层以上,而且属于「环境 / 配置」性质(页面级编辑模式、页面级宿主能力包装)。什么时候不要:
| 不要 | 换成 |
|---|---|
| 只有一两层要用 | 直接当参数传 —— 隐式依赖后面的人 grep 不到 |
| 值变得很频繁 | 状态提升或 ViewModel:local 一变整棵子树都重组,粒度太粗 |
| 当依赖注入容器 | 参数 / 构造注入:容器式 local 让依赖隐形,测试里也换不掉 |
想塞 mutableStateOf(...) 本身 | 传值,状态留在父组件里 |
compositionLocalOf 和 staticCompositionLocalOf 只差一件事:值变化时前者会让所有读取者重组,后者不会。后者只适合真正不变的东西(版本号、常量配置);装会变的值,症状是界面永远不刷新,而且极难查。
插件里 LocalContext 不是 Activity
插件的 LocalContext.current 拿到的是 PluginContext(框架把宿主 ProxyActivity 包成「普通 Activity」加的那一层),不是 Activity。LocalContext.current as Activity 直接 ClassCastException,「逐层解包到 Activity」也是错路(多 Activity 插件里会拿错实例)。需要真 Activity 的能力(退出插件、跳转、权限申请)时,在 MainActivity 里把 activity 的能力以回调传进 App(),UI 层只负责调回调。
kotlin
val activity = LocalContext.current as Activity // ❌ 崩:PluginContext 不能转成 Activity
@Composable fun App(onFinish: () -> Unit) { /* ✅ 入口传回调,UI 只调用 */ }三、插件里最常见的状态坑
坑 1:把 Context / Activity 抓进长命对象
症状:进出插件几次,宿主内存不降;反复打开插件,Activity 实例只增不减;偶发「回调打在一个已经 finish 的页面上」,有时伴随 ClassCastException。
根因:remember 本身不泄漏(它跟组合一起死)。泄漏发生在这份 Context 被交给了比组合活得久的东西:单例、静态字段、宿主注册的全局回调、应用级 scope 里的长命协程。插件的 Context 是 PluginContext,内部包着宿主 ProxyActivity —— 钉住一个,等于钉住宿主页面。
正确写法:
kotlin
Singleton.holder = LocalContext.current // ❌ 交给长命对象保管
GlobalCallbackRegistry.on("theme") { update(LocalContext.current) } // ❌ 同上,一样钉住宿主页面
val client = remember { OkHttpClient() } // ✅ 不持有 context,可以活很久
val resolver = LocalContext.current.contentResolver // ✅ 现取现用
val appContext = LocalContext.current.applicationContext // ✅ 真要长期持有就用进程级 Context自查口径:凡是把东西交给别人保管的地方(addListener / register / observe / setCallback),必须配 onDispose;凡是活得比组合久的东西(单例、静态、应用级 scope),不许出现 Context。
坑 2:在 composable 体里直接改状态
症状:界面卡死、CPU 打满、日志刷屏;有时表现为界面疯狂闪、一直重复重组。
根因:组合体每跑一次就写一次状态;状态变了要求重组;重组又跑到同一行 —— 自激循环。组合体是「描述」,不是「事件」,里面不该有副作用。
正确写法:
kotlin
@Composable fun Bad() { // ❌ 无限重组
var count by remember { mutableIntStateOf(0) }
count++ // 写状态 → 请求重组 → 又走到这行
Text("$count")
}
// ❌ 「按状态推导出另一个状态再写回去」同样自激:
// var empty by remember { mutableStateOf(true) } 然后 empty = items.isEmpty() —— 值取决于输入,每轮都在写kotlin
// ✅ 状态只在这三个地方改:effect、事件回调、明确的生命周期回调
@Composable
fun Good() {
var count by remember { mutableIntStateOf(0) }
var inited by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { inited = true } // 进页面一次的初始化
FilledButton(text = "+1", onClick = { count++ }) // 事件
Text("$count")
}
val isEmpty = items.isEmpty() // 能推导的就不要存状态需要「首次进入时算一次」的东西用 remember { ... } 的初始化块,不要用组合里的赋值。
坑 3:把事件当状态传
症状:轻提示弹两次、返回再进又弹一次、转屏又弹一次;「跳转」被重复执行。
根因:状态会被重放。State 在每个新读取者那里读到的都是同一个值,StateFlow 会给每个新收集者补发最后一个值。事件是「发生过一次的事」,和「当前是什么」是两种语义,混用必然重复消费。
正确写法:
kotlin
private val _message = MutableStateFlow<String?>(null) // ❌ 状态语义 → 会被重放:得手动置回 null,漏一次或顺序一变就弹两次,转屏还会再弹
private val _events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 1) // ✅ 无重放
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
fun onSaved() = _events.tryEmit(UiEvent.Toast("已保存"))
LaunchedEffect(Unit) {
vm.events.collect { e ->
when (e) {
is UiEvent.Toast -> showToast(e.text) // 内部用组件库 PopupPrompt
is UiEvent.Finish -> onFinish()
}
}
}优先级:能直接在事件回调里做的事就直接做 —— 父级把 onClick 传下去,子组件调它就完了,不需要经过任何状态或 Flow。只有事件产生在 UI 之外的层(ViewModel、后台任务)才需要 Flow 通道。
坑 4:rememberSaveable 存不可序列化对象
症状:转屏或进程恢复时崩,报 IllegalArgumentException ... cannot be saved using the current SaveableStateRegistry;或者存进去、恢复出来变成另一份数据。
根因:默认 saver 只能往 Bundle 里塞 Bundle 支持的类型(清单见 1.1)。自定义对象、带 State 的类都不在其中。
正确写法:只存原始字段或 id,对象按 id 取回来;非要存对象就给 Saver(写法与 listSaver 示例见 1.1)。另外两条:大内容不进 saveable,Bundle 1MB 量级上限,超了 TransactionTooLargeException,内容走 PluginKv / PluginRecords;onFinish() 之后重新进入是全新实例,saveable 救不了,跨会话必须落库。
坑 5:改 mutableStateListOf 里元素的属性,界面不刷新
症状:items[0].name = "新名字" 之后列表项文字没变;只有增删一行才刷新。
根因:mutableStateListOf 只观察列表结构(增、删、换下标)。元素本身是普通对象,改它内部字段等于改了一个没人监听的字段。
正确写法:
kotlin
val items = remember { mutableStateListOf<Item>() }
items[0].name = "新名字" // ❌ 就地改属性:结构没变,没有任何观察者被通知
items[0] = items[0].copy(name = "新名字") // ✅ 换成新对象(结构变化 → 触发重组)
class Item(val id: Long) { var name by mutableStateOf("") } // ✅ 或者让元素自己持有 State
items[0].name = "新名字" // 改属性就是改一个被读过的 State,这个可以再补一层选择:列表如果是整体替换的(网络返回一整个新列表、筛选后重算),直接用 var items by remember { mutableStateOf<List<Item>>(emptyList()) },不必上 mutableStateListOf;后者只在「频繁就地增删改」时才划算。