外观
UI 设计总览:Compose 的心智模型
这一页不教组件,教框架怎么想。插件界面写完之后能不能改得动、卡不卡、状态错乱不错乱,取决于你脑子里存的是哪种模型。模型对,后面所有页都是推论;模型错,每一条规则你都得背,背完还是写错。
先立总账,再分页细看:
| 要做的事 | 去哪 |
|---|---|
| 用哪个组件、有哪些参数 | → UI 组件库 /api/ui/ |
| 界面怎么写才对(布局 / 状态 / 列表 / 动效 / 主题 / 无障碍 / 性能 / 反模式) | 本套文档,从这一页开始 |
两条线不重叠。下面全部内容只在回答一个问题:同一件事,Compose 希望你按什么顺序、在什么位置做。
一、Compose 三阶段:先记住这个,别的都好办
一次界面刷新不是「重画一遍」,是三个串行的阶段:
text
State 变化
│
▼
① Composition ── 跑 @Composable 函数,产出 / 更新 UI 树(哪些节点在,长什么样)
│
▼
② Layout ── 量尺寸、定位置(每个父节点决定孩子的约束,孩子回报尺寸)
│
▼
③ Drawing ── 发绘制指令,把像素交给 GPU关键句:状态被「读」在哪一个阶段,那一阶段和它后面的阶段重跑;它前面的阶段不动。
| 状态在哪被读 | 实际重跑 | 代价 | 典型场景 |
|---|---|---|---|
@Composable 函数体里 | Composition → Layout → Drawing,整条链 | 最大:重跑函数、比对参数、可能重建节点 | 「显示几条数据」这种决定有没有这个组件的状态 |
layout { } / 测量回调里 | Layout → Drawing | 中:不跑 composable,只重新量、重新摆 | 「高度 / 位置取决于某个值」但组件集合不变 |
draw { } / 绘制 lambda 里 | 只 Drawing | 最小:不重建树、不重测量,只重发绘制指令 | 颜色、透明度、偏移这些只影响像素的值 |
这条不是优化技巧,是归属规则:一个值在哪被读,就是它影响的边界。读高了(该在 draw 读却读在 composition),你会得到一整棵树的白跑;读低了,界面不动。
1.1 Composition 阶段
干什么:执行 @Composable 函数,产出并更新节点树。谁在、谁不在、参数是什么、remember 的值挂在哪,都在这一阶段定下来。
状态读在函数体里 → 触发重组(recomposition),而这个 composable 里所有「读不到变化」的下游节点也跟着被重新求值、重新比对。所以函数体里的每一次状态读,都是在给自己加成本。
kotlin
// ❌ 滚动位置读在 composition:每一像素都重组整个列表头
val scroll = rememberScrollState()
Column(Modifier.verticalScroll(scroll)) {
TopTitleScaffold(title = "设置", onBack = onFinish) {
SectionRow(title = "已滚 ${scroll.value}px") // 每帧重组
}
}
// ✅ 只把「读」推迟到绘制阶段
Column(Modifier.verticalScroll(rememberScrollState())) {
TopTitleScaffold(title = "设置", onBack = onFinish) {
Box(
Modifier.drawWithContent {
drawContent() // 绘制 lambda 里才读 → 只重绘
// 例如按 scroll offset 画一条细化分割线
}
)
}
}两个还有用的结论:
- 重组是按需、局部的。只有「读了变化状态」的那些节点范围会重跑,不是整棵 App。但边界一旦画错,范围就从一个小
Box扩成整页。 - 参数要顺流而下。事件往上走(
onClick = { … }),数据往下走(value = state)。反向在子节点里改父节点的状态是能跑,但下一次重组你就说不清谁触发了谁。
1.2 Layout 阶段
干什么:父节点给孩子约束(constraints),孩子量完回报尺寸;父节点再决定孩子的坐标。这一阶段只碰尺寸和位置,不碰 composable。
状态读在测量代码里 → 只重新测量、重新摆放,composable 不重跑。这就是 Modifier.layout { } 存在的意义:让「位置 / 尺寸依赖某状态」不牵连 composition。
kotlin
// 「内容超过两行就折叠」的展开高度,只影响测量
Box(
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val h = if (expanded) placeable.height else minOf(placeable.height, 2 * lineHeightPx)
layout(constraints.maxWidth, h) { placeable.place(0, 0) }
}
)注意两件事:
- 测量可能跑不止一遍。父节点为了拿到
IntrinsicSize,或者有weight需要分配剩余空间,会先量一轮。测量代码里塞重活,代价乘 2。 - 同一子节点被量了两次、还拿到不同约束 → 直接抛异常。不要在外层给「已固定尺寸」的子节点再叠一层会改约束的修饰符。
layout 里读状态是合法的,但要清楚它属于哪个阶段:尺寸变了是重新摆,不是重新画。
1.3 Drawing 阶段
干什么:把这一帧要画的东西变成绘制指令。不重建树,不重新测量。
状态读在绘制 lambda 里 → 只重绘。这是性能上最便宜的一档,也是「视觉随滚动 / 随进度渐变」这类需求的正确落点:graphicsLayer { }、drawWithContent { }、Modifier.drawBehind { }。
kotlin
// 滚动进度只影响透明度与缩放 → 全部留在绘制阶段
Box(
Modifier.graphicsLayer {
alpha = (scrollFraction).coerceIn(0f, 1f)
translationY = -8f * scrollFraction
}
)滚动条、进度环、随滚动淡出的标题、呼吸动画,都该活在这一档。先把值挪到 draw,再谈优化 —— 大多数「列表一滚动就卡」的根因就是本来 draw 层的事被写在了 composition 层。
一句话记法
读在 composition = 重建,读在 layout = 重新量,读在 draw = 重新画。能往低了放的就别往高了放。
别把三阶段记成「优化套路」
先按正确性写代码,再用阶段归属做诊断:界面莫名重组 → 找函数体里的状态读;滚动卡 → 找本该在 draw 层的值;布局闪 → 找测量阶段的约束冲突。顺序反了只会到处加 remember。
二、六条核心原则
原则 1:重组频繁且廉价 —— 前提是每次重组足够小
重组是设计,不是 bug。Compose 的重组本来就是每帧都可能发生的事。真正让人写出烂代码的是误解成「重组很贵,所以要少触发」,于是到处塞缓存、到处加 remember,反而把状态搞乱。
正确姿势:不阻止重组,把每次重组的范围压小。
kotlin
// ❌ 状态放在页面顶层:输入一个字,整页(含长列表)全部重组
@Composable
fun App(onFinish: () -> Unit) {
var keyword by remember { mutableStateOf("") } // 每次击键都重组 App()
TopTitleScaffold(title = "搜索", onBack = onFinish) { ... }
}
// ✅ 状态放在真正需要它的最小节点上:只有输入框那一小块重组
@Composable
fun SearchBar(onQuery: (String) -> Unit) {
var keyword by remember { mutableStateOf("") }
TextField( // 组件库 TextField:placeholder 与 label 必须同时传
value = keyword,
placeholder = "输入关键词",
label = "搜索",
onValueChange = {
keyword = it
onQuery(it)
},
)
}配套的三条做法:
- 抽小 composable。每一次函数调用就是一个可跳过的边界,一个 500 行的 composable 是没有边界的。
- 别在 composable 体里做重活。排序、过滤、格式化要么放进
remember(...)按 key 缓存,要么用derivedStateOf;重活放体里,重组一次就白干一次。 - 参数尽量传值,不传新构造的对象。函数体里现写
listOf(...)/ 匿名对象当参数,等于每次都告诉子节点「变了」。
原则 2:Modifier 顺序有意义
Modifier 是一个有序的链,从外到内一层层包。padding 在外面就是留白在外,background 在外面就是背景铺满那块留白。
kotlin
Modifier.padding(16.dp).background(bg) // 背景只覆盖内容区,外面 16dp 是透明的
Modifier.background(bg).padding(16.dp) // 背景铺满整块,内边距在里面顺序错了不会报错,只是长得不对 —— 这类 bug 最难查,因为代码「看起来没问题」。同一条链上还有 clip(裁谁)、clickable(命中区多大)、border(边框跟谁同形)、size / fillMaxWidth(约束何时被固定)在互相影响。全部细节和自查清单在布局与 Modifier。
原则 3:状态尽量放低
状态的位置决定谁会被牵连重组。放得越高,重组范围越大;但提升得不够,兄弟节点就共享不了。
判定只有一句话:放到「所有读它的地方的最低共同父级」,然后停手。 只有一个组件读它,就 remember 在它自己身上;两个兄弟读同一份,提到共同父级;寿命超过一次页面存活的,才提到 App() 顶层或交给宿主的存储能力。
另外:别什么都往 ViewModel 里塞。插件里「要不要显示这个弹窗」是 UI 状态,跟业务数据不是一个层级的寿命,混在一起只会让状态同步变复杂。容器选型、rememberSaveable 的可序列化门槛、状态提升的完整判据在状态与副作用。
原则 4:副作用是声明式与命令式的桥
Composable 函数会在任意时刻被重跑,所以它必须是纯的:给同样的参数得出同样的界面,不对外界产生可观察的影响。可是你要发请求、要注册监听、要弹一次提示、要滚到顶 —— 这些都是命令式的、有次数的一次性动作。桥就在副作用(Effect)。
对号入座,别混:
| 你的意图 | 用 | 时机 |
|---|---|---|
| 进页面时跑一次、key 变了重跑 | LaunchedEffect(key) | 进入 / key 变化,退出时自动取消 |
| 注册需要成对解除的东西(监听、回调) | DisposableEffect(key) | 进入注册,离开或 key 变时解除 |
| 每次成功重组之后同步一次外部状态 | SideEffect | 每次重组后 |
| 响应用户事件启动协程(点击、下拉) | rememberCoroutineScope() | 事件发生时,在调用点启动 |
| 把状态变化转成事件流(如滚到底触发加载) | snapshotFlow { } | 值变化时发一次性通知 |
最常写错的两处:把事件当状态传(onClick 语义的东西写进 LaunchedEffect,导致每次重组重跑),以及该解除的没解除(监听泄漏在插件里会一直持有已经结束的 App 实例)。完整对照表与坑在状态与副作用。
原则 5:稳定性决定能否跳过
框架要跳过一棵子树的重组,必须能确定它的参数没变。参数类型是稳定的(String、Int、Boolean、data class 里全是稳定字段、ImmutableList 之类),比较就有意义,能跳过;参数类型不稳定或每次都换新实例,框架只能保守地重跑。
这就是「让每次重组变小」的落地机制,也只有两个动作:
- 让类型稳定:
data class字段全用不可变类型;不要在参数里现构造List/Map/ lambda 兜底默认值;集合能只读就别暴露可变实现。 - 读得晚:内部可变的状态(
State<T>、滚动状态、动画状态)当作参数传下去、在子节点内部才读。传进函数的只是那个持有对象,比较时不关心它内部变了没有,于是包着它的父节点可以整块被跳过。
kotlin
// ❌ 父节点自己读状态:值一变,父节点连同它下面整棵子树都要重新求值
Text("已选 ${selected.value}")
// ✅ 只在叶子里读:重组范围到此为止,父节点不受影响
Text("已选 ${selected.value}")性能的完整工具箱 —— 稳定性诊断、LazyColumn 的 key / contentType、derivedStateOf 的正确用途、baseline profile ——在性能。
原则 6:平台无关不等于可以忽略平台
Compose 的运行时(状态、重组、副作用调度)是纯逻辑、平台无关的,所以业务逻辑可以写得跟具体设备无关。但加载、生命周期、权限、系统深浅色、返回手势全都是 Android 的事,插件还多一层宿主约束:你的界面是宿主用 ProxyActivity 加载进来的,App(onFinish) 就是全部入口。
落地时守这三条:
- 主题别自己管。宿主已经用
PluginTheme包住App(),深浅色自动跟随用户设置;颜色一律从MaterialTheme.colorScheme和AppShapes取,不硬编码色值和圆角。 - 别抓平台对象进长命容器。
Context/Activity存进单例或长期存活的对象,必然泄漏。需要Context就用LocalContext在 composable 内取、当场用。 - 退出走
onFinish(),别自己动手finish()宿主 Activity。
主题只取 token、不建主题这套做法的细节在主题与设计基调,系统返回与生命周期相关的问题在无障碍和常见反模式。
三、本套文档导航
九页,按「你此刻卡在哪」挑,不用从头读:
| 页面 | 什么时候看 | 一句话内容 |
|---|---|---|
| 布局与 Modifier | 间距不对、背景铺过头、点击区太小、圆角没裁齐、滚不动或嵌套滚动崩了 | Modifier 链的顺序规则、对齐与 weight、尺寸约束、自定义 Layout、8dp 网格 |
| 状态与副作用 | 界面不更新、无限重组、请求重复发、监听泄漏、转屏丢数据 | 状态放哪、状态提升、rememberSaveable 门槛、derivedStateOf、五个副作用对号入座 |
| 列表、滚动与自动加载更多 | 做任何长列表、分页、筛选切换、滚动跟随 | LazyColumn 写法、key / contentType、滚动到底自动请求下一页、位 PillTabsRow 与列表联动 |
| 官方 Paging 3 分页 | 要做「滚到底自动加载更多」的分页列表 | PagingSource / Pager / collectAsLazyPagingItems、三态页脚(加载中 / 失败重试 / 没有更多了)、RemoteMediator 什么时候值得用 |
| 动效 | 想加过渡但不知道加什么、加了之后掉帧 | 该动什么、时长与缓动、animate*AsState / AnimatedVisibility、把动画留在绘制阶段 |
| 主题与设计基调 | 配色、字阶、圆角、深浅色、视觉层级 | 只用语义色与 token、字阶取 MaterialTheme.typography、间距走 8dp、靠留白和圆角做层级 |
| 无障碍 | 图标按钮没有文字、读屏读不出来、小字不友好、点击目标太小 | 语义描述、触控目标 ≥48dp、对比度、状态可感知(不靠颜色单独表达) |
| 性能 | 列表滑动掉帧、输入框敲字卡、进页面首帧慢 | 稳定性与跳过、阶段归属诊断、remember / derivedStateOf 的正确用途、列表与图片成本 |
| 结构反模式 | 接手别人的插件代码,或者自己越写越乱 | 该抽什么、巨型 App() 怎么拆、状态与副作用乱串的典型长相 |
还有两页在别处,别混进来:
| 页面 | 什么时候看 |
|---|---|
| 7 UI 设计和业务开发 | 你要的是插件整体骨架、App(onFinish) 怎么立、业务分几层 |
| UI 组件库 | 你要的是「有哪个组件、参数叫什么、长什么样」 |
四、和组件库的关系:分层,不重叠
这套文档和组件库是两层,各自只回答一个问题:
| 层 | 回答的问题 | 文档位置 |
|---|---|---|
| 组件层 | 用哪个组件、传什么参数 | /api/ui/ 组件库文档 |
| 设计层 | 界面怎么写才对 —— 顺序、层级、状态位置、性能、无障碍 | 本套文档 |
现实里两者经常会「看起来冲突」。比如本页说「状态放低、让重组范围最小」,而组件库某个组件内部已经替你管了展开态、滚动态、加载态 —— 你不需要再自己放一遍。这时候:
冲突时,组件库赢
组件的取舍是刻意的:参数边界、内部状态归属、默认尺寸和间距都是为了让插件之间看起来一致。你在这套文档里学到的原理,用来判断「该不该自己再包一层」,不是用来绕过组件的既定行为。
- 动手前先翻组件库文档:
TopTitleScaffold/CollapsingTitleScaffold已经处理了标题栏与 inset,SectionRow/ListItem已经定了行高与间距,LoadingState/ErrorState/EmptyHint已经覆盖了非内容态 —— 你再排一遍只会「差一点」。 - 本套文档不规定用哪个组件,遇到需要落地成具体组件的地方,一律以组件库为准。
- 组件库没有的,才是你的活:真正的业务布局、业务状态、业务交互。这时才按本页的三阶段和六条原则来设计。
三条项目硬规范,本套文档所有页面都按它对:
- 列表做自动加载更多(滚到底自动请求下一页),不要「点按钮加载更多」。实现优先用官方 Paging 3(官方 Paging 3 分页)。
- 需要搜索时:页面里画搜索框或标题栏画图标按钮,二选一,不要别的花样。
- 用组件库
TextField/TextArea时必须同时传placeholder和label。
落笔前再确认一遍自己站对了阶段:这段逻辑是在决定有没有这个组件(composition),决定它多大多高(layout),还是只决定它长什么样(draw)。答对了,剩下的每一页都只是这张图上的细节。