Skip to content

性能与线上稳定性 ​

这一页只解决两件事:卡(该跳过的重组没跳过、该在 draw 读的状态读在了 composition)和崩(线上真会带走进程的那几种写法)。挑组件、配主题看 UI 组件库;这里全是「同一段界面,怎么写才不白干、不炸」。

每条结论后面都跟一句「怎么验证」。 别信结论,跑一遍 —— 性能问题最难的不是不知道规则,是不知道自己的页面犯了哪条。

一、重组什么时候发生 ​

1.1 能触发重组的只有这四种 ​

触发源波及范围
状态被写(mutableStateOf / mutableIntStateOf / State<T> 的值变了)只有读了这个状态的 scope
参数变了(equals 为 false)参数真变了的那个节点;能跳过的子树不重跑
CompositionLocal 变了(主题、LocalDensity、LocalConfiguration)所有读了它的节点
父 scope 重组子节点自己跳得掉就不重跑

关键认知:重组本身不贵,Compose 的前提就是「每帧都可能重组」。贵的从来不是次数,是每次的范围有多大 —— 后面每条都在压范围。

→ 怎么验证:Layout Inspector → Show Composition Counts,做一次交互,看哪个节点在涨、涨了几次。

1.2 Scope 边界:状态读在哪,边界就画在哪 ​

重组的最小单位不是「函数」,是读了那个状态的 scope。一句话:状态在哪一层被读,那一层和它下面就被重跑;上面一层完全不知道发生了事。

kotlin
// ❌ 读在页面顶层:敲一个字,整页(含长列表)跟着重组
@Composable
fun App(onFinish: () -> Unit) {
    var keyword by remember { mutableStateOf("") }
    TopTitleScaffold(title = "搜索", onBack = onFinish) {
        TextField(value = keyword, onValueChange = { keyword = it },
            placeholder = "输入关键词", label = "搜索")
        ResultList()                 // 不读 keyword,也白跑一遍
    }
}

// ✅ 状态放低:只有输入框那一小块重组
@Composable
private fun SearchBar(onQuery: (String) -> Unit) {
    var keyword by remember { mutableStateOf("") }
    TextField(value = keyword, onValueChange = { keyword = it; onQuery(it) },
        placeholder = "输入关键词", label = "搜索")
}

三条配套:抽小 composable(每次函数调用就是一个可跳过的边界,500 行的 App() 没有边界);状态放到「所有读它的地方的最低共同父级」(只有一个兄弟读就 remember 在自己身上,两个兄弟读才往上提,判据见状态与副作用);别在父层读子层要用的状态。

→ 怎么验证:在 App() 和 SearchBar() 各挂一个显示重组次数的 Text,敲字时看谁在涨。涨在 App() 就是边界画高了。

1.3 ★ 把状态读取推迟到最内层 —— 收益最大的一招 ​

比「状态放哪」更狠:把状态对象本身传下去,让真正要用它的那个最内层节点去读。

State<T>(含 ScrollState / LazyListState / animate*AsState 的返回值)本身是稳定的 —— 内部值怎么变,包装它的对象引用不变。父层只传不读,就永远不会因为内部值变化而重组。

kotlin
// ❌ 父层读值:selected 一变,父层连同整棵子树重跑
fun Picker(items: List<String>, selected: State<String>) {
    val current = selected.value
    Column { items.forEach { PillTab(label = it, selected = it == current, onClick = {}) } }
}

// ✅ 父层只传不读:只有真读它的那个叶子重组
fun Picker(items: List<String>, selected: State<String>) {
    Column { items.forEach { l -> PillTab(label = l, selected = l == selected.value, onClick = {}) } }
}
想动的只是别在 composition 读改成
位置 / 偏移Modifier.offset(x.dp, 0.dp)Modifier.offset { IntOffset(x, 0) }(读在 layout)
透明度 / 缩放 / 旋转Modifier.alpha(v) / scale(v) / rotate(v)Modifier.graphicsLayer { alpha = v }(读在 draw)

「滚一下标题就淡出」「下拉时缩一下」全部该活在 draw 阶段,细节见动效。

→ 怎么验证:把 offset 换成 lambda 形式,Layout Inspector 里滚动时该节点 composition count 不再增长,只重绘。

二、稳定性:能不能跳过由它决定 ​

重组时要跳过一棵子树,前提是框架能确定参数没变:比较 equals 有意义 → 能跳过;类型不稳或每次都换新实例 → 只能保守重跑。

2.1 判稳表(背这张就够) ​

类型稳不稳原因
Int / Long / Float / Boolean / String稳不可变,equals 有意义
State<T> / ScrollState / LazyListState稳引用不变,内部值变不影响参数比较
List / Map / Set(接口)不稳可能被可变实现撑着,编译器不敢保证内容不变
data class 字段全稳定类型且全 val稳编译器自动推断
data class 里有 List / Map / Set 字段不稳一个不稳字段传染整个类
class 里有 var 字段不稳想稳得用 @Stable 手动承诺,且必须真的通知变化
ImmutableList(kotlinx.collections.immutable)稳专门给 Compose 用的不可变集合接口
函数类型 () -> Unit见第四节强跳过下「捕获的变量都稳」才稳
kotlin
data class Video(val id: Long, val title: String)              // ✅ 稳

data class HomeState(val videos: List<Video> = emptyList(),     // ❌ 不稳:List 是接口
                     val loading: Boolean = false)

@Immutable
data class HomeState(val videos: ImmutableList<Video> = persistentListOf(),   // ✅
                     val loading: Boolean = false)

2.2 不稳的后果 ​

参数不稳的节点,编译器不给它生成 skippable,父层每次重组它都完整重跑一遍 —— 里面每一次计算、每一个新建对象、每一层子调用都要重付。

强跳过(strong skipping,AGP 8.0+ / Compose 编译器 1.5.0+ 默认开)会兜一层:不稳定参数改用**实例相等(===)**判断。所以同一个实例传下去还能跳过;但你每次重组都 listOf(...) / .filter {} / sortedBy {} 造新实例,就彻底跳不过。

→ 怎么验证:./gradlew assembleDebug 后看 build/compose_reports/*_composables.txt,签名前没有 skippable 的就是它。(插件只出 debug 包,不要用 assembleRelease。)

2.3 三种修法(代价从低到高) ​

  1. 传值,别在参数位现构造。 listOf(...) / .filter { } / 匿名对象当默认值,全是每帧新实例。
  2. 不可变集合。 ImmutableList / persistentListOf()。老代码只改「传进 composable 的那些集合」即可。
  3. @Immutable 标注。 只有确定字段构造后永不变化时才标。
kotlin
val top3 = state.videos.take(3)                              // ❌ 体里 / 参数位现算:每帧新实例
val top3 = remember(state.videos) { state.videos.take(3) }    // ✅ 算一次、记住再往下传

→ 怎么验证:临时把 ImmutableList 换回 List,报告里对应的 skippable 会消失 —— 直接证明是它在拖。

2.4 @Immutable 和 @Stable 不是同义词 ​

标注承诺用在哪
@Immutable构造后所有属性永不变网络模型、UI 快照(data class 全 val)
@Stable属性可以变,但变化会通知到 Compose自己写的状态持有类、暴露 State 的包装

最危险的误用:给带 var 的类标 @Immutable。 编译器信了你,这棵子树永久跳过 —— 界面不更新,而且查不出「为什么改状态没用」。

kotlin
@Immutable data class Row(val title: String) { var expanded = false }        // ❌ 谎报:改了界面不动

@Immutable data class Row(val title: String, val expanded: Boolean)          // ✅ 真不可变

@Stable class RowState(title: String) { var title by mutableStateOf(title) } // ✅ 真会通知

→ 怎么验证:给这类类写测试,构造后改属性再断言 equals;内容会变而 equals 不动(或反过来)就是标注在撒谎。

三、别在 composable 体里做重活 ​

composable 体每次重组都重跑,里面每一行计算都是一笔重复支付的账。判据只有一句:结果只跟参数有关,就把它挪出函数体。

体里的活该放哪
字符串 / 日期格式化(轻)留在体里 —— 为它套 remember 反而更亏
格式化 / 拼接(重,长列表逐条)remember(key) { … },见列表页
排序 / 过滤 / 分组上一层算好传进来,或 remember(items, query) { … }(否则 O(n log n) × 重组次数)
derivedStateOf 派生见第五节:只有边界上才值
网络 / 磁盘 / 数据库LaunchedEffect(key) { … } 或事件里的 scope.launch { … }(写在体里 = 每帧发一次请求)
新建对象 / 集合 / OkHttpClient / 拦截器顶层 val、remember { },或上一层算好
从 LocalConfiguration / LocalDensity 派生的值remember(configValue) { … } —— key 必须带配置源,否则转屏 / 分屏 / 折叠屏展开后是过期值
kotlin
// ❌ 每重组一次:排一次序、建一个客户端、发一次请求
@Composable
fun Home(raw: List<Video>, query: String, id: Long) {
    val sorted = raw.sortedByDescending { it.views }
    val client = OkHttpClient()
    LaunchedEffect(Unit) { client.newCall(Request.Builder().url(api(id)).build()).execute() }
}

// ✅ 排序 remember 住、客户端只建一次、请求只在 id 变化时重发
@Composable
fun Home(raw: List<Video>, query: String, id: Long) {
    val sorted = remember(raw, query) { raw.filter { query in it.title }.sortedByDescending { it.views } }
    val client = remember { OkHttpClient() }
    LaunchedEffect(id) { /* 请求 + try/catch,见 8.4 */ }
}

→ 怎么验证:在体里插一个计数器(SideEffect { count++ }),敲一次字、滚一次列表,看它涨的次数;大于「数据真变了的次数」就是在白干活。

四、lambda 与 remember ​

4.1 每次新建的 lambda 为什么不破坏跳过 ​

onClick = { onClick(item) } 每次重组都新建实例,但现代 Compose 基本不用管:编译器把捕获变量提升成参数、用 remember 包起来(lambda memoization);强跳过下捕获变量全稳 → lambda 就稳,不稳才退回实例相等判断。

「每次传 lambda 导致重组」是旧版本 Compose 的经验,不该成为到处写 remember { { … } } 的理由。

→ 怎么验证:报告里参数是 Function0<Unit> 的 composable 照样带 skippable。

4.2 只有三种情况才需要 remember 一个 lambda ​

情况写法
捕获了不稳定值(体里现造的 List、可变对象),又要传给重组件remember(items) { { onPick(items) } }
它要当 DisposableEffect(key) / LaunchedEffect(key) 的 key必须是稳定引用,否则每次重注册 / 重跑
传给非 Compose 的外部注册接口,对方按引用去重(setOnClickListener、SDK 回调表)remember { { … } } + DisposableEffect 注销
kotlin
ListItem(title = "清理缓存", onClick = remember { { clean() } })                // ❌ 无捕获,多余
val done: () -> Unit = remember(sessionId) { { finishSession(sessionId) } }     // ✅ 当 key 用
DisposableEffect(done) { host.register(done); onDispose { host.unregister(done) } }

反过来也要防:remember 不是越多越好。 remember { "Hello" } / remember { Color.Black } / remember { 12.sp } 这类既不贵、也不是可变状态,只是白占一个状态槽、多一次比较。只有「可变状态」和「真的贵」两件事值得 remember —— 把多余的删掉、Layout Inspector 计数不变,就是白占。

五、derivedStateOf 的误用 ​

它只有一个正当用途:输入是别的状态,派生值的读取方比输入的变化频率低得多。

kotlin
val showTop by remember { derivedStateOf { listState.canScrollBackward } }   // ✅ 每屏只翻两次

它不省计算(输入一变照样算一遍),更不是万能加速器:

误用会发生什么
包便宜的判断(derivedStateOf { a + b }),读取方本来就随输入重组多一层快照订阅和对象分配,一次没省
包常量 / 只算一次的值等价于直接算,纯噪音
拿来算数量,再 items(count) 索引另一个集合派生值和源集合可能不同快照 → IndexOutOfBoundsException(8.6 / 8.7)
下游读取方并不比输入更少重组输入一变下游就重组,等于没包
当节流 / 去抖用它同步求值,不做时间窗口
kotlin
// ✅ 要渲染列表就派生「整个列表」,让「有几条」和「是哪条」来自同一次计算
val shown by remember(items, query) { derivedStateOf { items.filter { query in it.title } } }
LazyColumn { items(shown, key = { it.id }, contentType = { "row" }) { VideoRow(it) } }

→ 怎么验证:换成普通局部变量跑同样的交互,Layout Inspector 计数没变少 → 这层多余,删掉。

六、modifier 放第一个可选参数 ​

kotlin
@Composable
fun TitleRow(title: String, modifier: Modifier = Modifier, onClick: () -> Unit) { ... }

三条理由,全都跟性能无关:调用方要对最外层负责(外层尺寸 / 间距 / 对齐只能由使用点决定,不给就得在外面套 Box,多一层布局);链式覆盖的落点必须固定(传进来的 padding 是「整块缩进去」,组件内部接的是「内容缩进去」,语义不同);入口位置一致才好接手。

别神话它:「modifier 位置影响重组」「不写 modifier 参数就不能跳过」都是错的 —— 它不参与重组触发判断,跳过只看参数类型稳不稳。真正影响性能的是 modifier 里写了什么:Modifier.offset { } / Modifier.graphicsLayer { } 把状态读推到 layout / draw 阶段,Modifier.offset(x.dp, 0.dp) / Modifier.alpha(v) 读在 composition;padding 只是链的顺序影响布局结果,与重组无关(对照 1.3 那张表)。

→ 怎么验证:把 modifier 挪到参数表最后或者删掉它,跑一遍 —— 计数不变,只有调用点写法变别扭。

七、什么时候值得翻编译器指标 / baseline profile ​

默认不用看。 只在三个信号出现时才看,否则优化的是感觉不是数据:冷启动首帧慢(→ baseline profile + ReportDrawnWhen)、长列表滚动掉帧不跟手(→ frame timing + 重组计数)、release 明显比 debug 卡(→ 看 skippable,release 没有 JIT 兜底)。

kotlin
// build.gradle.kts(Kotlin 2.0+ 的 compose 编译器插件)
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_reports")
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
text
restartable skippable fun VideoRow(video: Video, onClick: Function0<Unit>)   // ✅
restartable fun VideoRow(videos: List<Video>)                               // ❌ 跳不过 → 查 List
  • *_composables.txt:签名前没有 skippable 的就是在拖的那个,修法只有 2.3 那三种。
  • *_classes.txt:哪个类被判 unstable,以及是哪个字段干的。
  • 判据:只修「每次重组都会跑到」的那几个(列表项、常驻头部、输入框周边)。全量刷绿没收益,只会逼你把 var 硬包起来。

baseline profile 让 R8 预编译热路径,收益主要在启动不在滚动:用 Macrobenchmark 的 measureRepeated(metrics = listOf(StartupTimings.FIRST_FRAME, FrameTimingMetric()))录一段真实操作,跑出来的就是 baseline-prof.txt。

配套:ReportDrawnWhen { ready } 让系统知道「有效内容已出现」,TTID 才有意义;release 开 isMinifyEnabled + isShrinkResources,@Preview 会被自动剥掉。另外三个少用但值得知道的手段:movableContentOf { }(同一块内容在两个布局位置之间搬,不重建状态)、trace("Name") { }(Perfetto / systrace 里认出自己这段)、@Suppress("ComposeUnstableCollections")(老代码就一个 List 参数,局部放过)。

判据:只在冷启动被吐槽时做。 滚动卡先查 1.3 和第三节 —— 那才是绝大多数掉帧的真因。

八、★ 线上崩溃清单 ​

八条真会崩的地方,每条按「症状 → 根因 → 正确写法」。

8.1 LazyColumn 重复 key ​

  • 症状:IllegalArgumentException: Key "42" was already used。常见于 WebSocket 重连 / 分页接口返回重复 id,或同一实体同时出现在「置顶」和「全部」两个 items 里。
  • 根因:key 是列表项身份,框架要求全列表唯一,后端数据保证不了。
  • 正确写法:跨数据源加前缀;真会重复就先去重或补去重序号。
kotlin
items(pinned, key = { "pinned_${it.id}" }) { PinnedRow(it) }
items(all, key = { "all_${it.id}" }) { VideoRow(it) }

val unique = remember(raw) { raw.withDedupIndex() }        // dedupIndex 不在主构造里
items(unique, key = { "${it.id}_${it.dedupIndex}" }) { VideoRow(it) }

→ 怎么验证:单测把同一份数据喂给 key 函数两遍,断言产出的 key 集合大小等于条数。更细的写法见列表页。

8.2 零尺寸 DrawScope ​

  • 症状:首帧或折叠屏展开瞬间崩在 Canvas / drawBehind 里,堆栈里有 NaN / Infinity。
  • 根因:第一帧布局还没完成,size = Size(0, 0);size.minDimension / 2、360f / size.width 这类算式产出非法值污染绘制路径。
  • 正确写法:Canvas 永远给显式高度,DrawScope 永远守卫。
kotlin
Canvas(modifier = Modifier.fillMaxSize()) {                       // ❌ 第一帧就是 Size.Zero
    drawCircle(radius = size.minDimension / 2)
}
Canvas(modifier = Modifier.fillMaxWidth().height(160.dp)) {       // ✅ 显式尺寸 + 守卫
    if (size.minDimension <= 0f) return@Canvas
    drawCircle(radius = size.minDimension / 2)
}

Modifier.drawBehind { } / drawWithContent { } 同样是这套;动态尺寸用 BoxWithConstraints,先确认 maxWidth > 0.dp 再画。

→ 怎么验证:把外层固定高度临时换成 Modifier.fillMaxWidth()(不给高度),真机转屏 / 折叠屏展开复现第一帧;有守卫就不崩。

8.3 remember 存 Activity / Context 造成泄漏 ​

  • 症状:插件退出后宿主页面不释放(LeakCanary 点名 ProxyActivity),反复进出插件内存持续上涨。
  • 根因:remember 住的对象又被更长寿的东西持有(单例、SDK 回调表、长命拦截器)时,宿主页面就被钉住了。插件里 LocalContext.current 是 PluginContext 不是 Activity,更不能当进程级 Context。
  • 正确写法:
kotlin
// ❌ 宿主页面被长命拦截器钉住
val client = remember { OkHttpClient().newBuilder().addInterceptor(Auth(LocalContext.current)).build() }

// ✅ 长期持有用进程级 Context;Activity 能力(权限 / 跳转)从入口回调进来,别 as Activity
val app = LocalContext.current.applicationContext
val client = remember { OkHttpClient().newBuilder().addInterceptor(Auth(app)).build() }

→ 怎么验证:LeakCanary 进出插件 3 次看是否报 ProxyActivity leaked;没装就 adb shell dumpsys meminfo <包名> 连续进出,看 Activities 与 Java Heap 是否单调上涨。

8.4 未捕获的协程异常(launch 里抛异常会带走 App) ​

  • 症状:超时 / 断网 / JSON 解析失败时整个 App 闪退,堆栈是网络或解析异常,看着跟界面无关。
  • 根因:rememberCoroutineScope() 与 LaunchedEffect 里的异常没人接,会一路抛到主线程默认处理器。没有 try/catch 的 execute() / await() / 解析调用就是定时炸弹。
  • 正确写法:自己启动的协程里做边界处理,并且原样重抛 CancellationException(吞掉它会让取消失效、页面退出后协程继续跑)。
kotlin
scope.launch {
    try {
        val body = withContext(Dispatchers.IO) { client.newCall(req).execute().use { it.body?.string() } }
        state = UiState.Ok(parse(body.orEmpty()))
    } catch (e: CancellationException) { throw e                       // ★ 必须重抛
    } catch (e: Exception) { state = UiState.Err(e.message ?: "网络异常") }
}

失败要落成 ErrorState(message = …, onRetry = { … }) 这种可重试的界面,别拿 CoroutineExceptionHandler 静默吞掉 —— 至少上报。

→ 怎么验证:把域名临时改成不可达地址(或飞行模式断网)跑一遍全流程,看是停在错误态还是闪退。

8.5 状态在重组中被回写 → 无限重组 ​

  • 症状:页面卡死 / ANR、CPU 打满,Layout Inspector 里某个节点 composition count 每秒涨几百次。
  • 根因:在 composable 体里给状态赋值。每次重组都拿新实例写回去,新实例与旧值永远不相等 → 再触发重组 → 循环。
kotlin
@Composable
fun Bad(items: List<Video>) {
    var sorted by remember { mutableStateOf(emptyList<Video>()) }
    sorted = items.sortedBy { it.rank }              // ❌ 每帧新实例 → 无限重组
    LazyColumn { items(sorted, key = { it.id }) { VideoRow(it) } }
}

@Composable
fun Good(items: List<Video>) {
    val sorted = remember(items) { items.sortedBy { it.rank } }   // ✅ 计算留在记忆里
    LazyColumn { items(sorted, key = { it.id }) { VideoRow(it) } }
}

两种变形:A 的状态写在体里、B 读它、B 又写回 A 读的状态(两个节点互相踢);把回写塞进 SideEffect { }(它每次重组后都跑,一样循环)。写状态只允许三处:事件回调、LaunchedEffect、状态持有类自己的方法。

→ 怎么验证:Layout Inspector 看计数是否持续增长;或把体里那行赋值注释掉重跑,不涨即为它。

8.6 derivedStateOf 读到过期快照 ​

  • 症状:偶发用旧值判断(数据已经变了,条件还是老的);更硬的一种配 8.7 直接 IndexOutOfBoundsException。
  • 根因:calculate 是惰性的,读取点和写入点不在同一快照时可能拿到上一次的结果 —— 把派生值当索引去访问另一个集合必然踩雷。
kotlin
val count by remember { derivedStateOf { visible.size } }        // ❌ 派生值和源集合可能不同快照
LazyColumn { items(count) { i -> VideoRow(visible[i]) } }

val shown by remember(visible, query) {                          // ✅ 派生整个集合
    derivedStateOf { visible.filter { query in it.title } }
}
LazyColumn { items(shown, key = { it.id }) { VideoRow(it) } }

→ 怎么验证:写测试,setState 之后立刻读派生值并断言它与源集合一致(连跑 100 次);渲染一律走「派生集合」写法,不给索引留机会。

8.7 列表下标越界 ​

  • 症状:IndexOutOfBoundsException,堆栈落在 items { } 里或 scrollToItem / animateScrollToItem 附近。
  • 根因:三种组合 —— items(count) 配 list[i];在 items { } 里用 list.indexOf(item)(对象被重建时返回 -1,下一句 list[-1 + 1] 就炸);切筛选后列表变短,滚动位置还停在旧下标。
  • 正确写法:
kotlin
LazyColumn { itemsIndexed(items, key = { _, it -> it.id }) { i, item ->   // ✅ 要索引就用它
    VideoRow(item, isEven = i % 2 == 0)
} }

val detail = list.getOrNull(target) ?: return                             // ✅ 用下标前先夹取

LaunchedEffect(query) {                                                   // ✅ 切筛选后修正滚动位
    listState.animateScrollToItem(listState.firstVisibleItemIndex.coerceAtMost(lastIndex))
}

→ 怎么验证:测试里把列表数据在 1 条 / 0 条之间来回切,看滚动与详情页会不会炸。

8.8 网络 / 图片回调回来时页面已销毁 ​

  • 症状:退出插件后偶发 NullPointerException / WindowManager$BadTokenException、往已 detach 的 View 上写值;或者插件关了内存不降、回调还在跑。
  • 根因:请求的存活期比页面长,回调里直接碰 Activity / View / finish()。
  • 正确写法:协程跟着 composition 走,手工注册的回调成对注销。
kotlin
LaunchedEffect(detailId) {                                   // ✅ 离开页面自动取消,下面不会再执行
    val data = withContext(Dispatchers.IO) { api.fetch(detailId) }
    detail = data
}

DisposableEffect(host) {                                     // ✅ 注册与注销成对
    val listener: (String) -> Unit = { msg -> log = msg }
    host.register(listener)
    onDispose { host.unregister(listener) }
}

图片别自己写「回调回来就往 View 上贴」的加载器 —— 用组件库自带图片能力的组件(比如 ListItem 的 iconUrl,走 Coil,离开组合会自己取消请求)。Context 现取现用(8.3),退出插件只调 onFinish()。

→ 怎么验证:进详情页、数据还没回来就按返回退出,连做 20 次,看 logcat 有没有异常、LeakCanary 有没有报页面泄漏。

交付前自查 ​

  • [ ] 状态放在「所有读它的地方的最低共同父级」;只影响位置 / 透明度 / 缩放的值走 Modifier.offset { } / graphicsLayer { }
  • [ ] 传进 composable 的集合是 ImmutableList 或在上一层 remember 过;编译器报告里列表项与常驻头部带 skippable
  • [ ] 体里没有排序 / 过滤 / 建客户端 / 发请求;remember 的 key 带上了配置来源(LocalConfiguration、LocalDensity)
  • [ ] derivedStateOf 只用于下游重组更少的派生值,没有用来算 items(count)
  • [ ] LazyColumn 的 key 稳定唯一(跨数据源带前缀),多类型列表给了 contentType
  • [ ] Canvas / DrawScope 有显式尺寸 + 零尺寸守卫
  • [ ] 自己 launch 的协程都 catch 了异常并重抛 CancellationException,失败落成 ErrorState
  • [ ] 没有把 LocalContext / Activity 存进单例、长命拦截器或 SDK 回调;手工注册的回调在 onDispose 注销了
  • [ ] 函数体里没有给状态赋值(写状态只在事件回调、LaunchedEffect、状态持有类的方法里)