Skip to content

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 之类),比较就有意义,能跳过;参数类型不稳定或每次都换新实例,框架只能保守地重跑。

这就是「让每次重组变小」的落地机制,也只有两个动作:

  1. 让类型稳定:data class 字段全用不可变类型;不要在参数里现构造 List / Map / lambda 兜底默认值;集合能只读就别暴露可变实现。
  2. 读得晚:内部可变的状态(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 已经覆盖了非内容态 —— 你再排一遍只会「差一点」。
  • 本套文档不规定用哪个组件,遇到需要落地成具体组件的地方,一律以组件库为准。
  • 组件库没有的,才是你的活:真正的业务布局、业务状态、业务交互。这时才按本页的三阶段和六条原则来设计。

三条项目硬规范,本套文档所有页面都按它对:

  1. 列表做自动加载更多(滚到底自动请求下一页),不要「点按钮加载更多」。实现优先用官方 Paging 3(官方 Paging 3 分页)。
  2. 需要搜索时:页面里画搜索框或标题栏画图标按钮,二选一,不要别的花样。
  3. 用组件库 TextField / TextArea 时必须同时传 placeholder 和 label。

落笔前再确认一遍自己站对了阶段:这段逻辑是在决定有没有这个组件(composition),决定它多大多高(layout),还是只决定它长什么样(draw)。答对了,剩下的每一页都只是这张图上的细节。