Skip to content

状态与副作用 ​

Compose 里只有两类「会动的东西」:状态(State) 告诉框架「这里变了,重画」;副作用(Effect) 告诉外部世界「这里进了 / 出了组合」。状态写错的后果是界面不更新或无限重组,副作用写错的后果是请求重复发、监听泄漏、回调打在已经消失的页面上。

判断顺序永远是这三个问题,答完用哪个 API 是唯一解:这份数据属于谁(一个组件用,还是几个兄弟一起用)、要活多久(一次重组 / 一次配置变化 / 跨进程)、谁负责改它(用户事件、进页面时、还是从别的状态推导出来)。顺序反了就会写出「拿 LaunchedEffect 当点击回调」「把 Context 存进单例」这类代码。

本页只讲原理和写法,组件参数看 UI 组件库。

一、状态放哪里 ​

1.1 先挑对容器 ​

存什么用寿命 / 触发重组的条件
一般值:String、自定义 data class、可空对象remember { mutableStateOf(v) }本次组合;value 变了
Int / Long / Float / DoublemutableIntStateOf / mutableLongStateOf / mutableFloatStateOf / mutableDoubleStateOf本次组合;同上,且不装箱
BooleanmutableStateOf(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;后者只在「频繁就地增删改」时才划算。