声明式 UI 布局
从命令式 update 到数据驱动渲染——Jetpack Compose 与 React 的声明式内核、重组/协调机制,以及实际开发里必须知道的稳定性与性能要点。
为什么写这篇
做 Android 很多年,习惯的路径是:XML 把页面搭好,findViewById / ViewBinding 拿到控件,数据到了再 text =、visibility =、adapter.notifyDataSetChanged()——UI 是客体,开发者亲手去改它。
切到 Jetpack Compose(或顺带接触 React / SwiftUI)之后,范式反过来了:你描述「在某个状态下界面长什么样」,框架根据状态变化自己去更新屏幕。业务上仍常配合 MVVM,但 UI 层不再是一堆 imperative 的 patch,而是一张随数据流动的「视图函数」。
这篇想讲清楚三件事:
- 声明式 UI 到底是什么(和命令式差在哪)
- Compose 与 React 的内核——重组、Virtual DOM / Slot Table、跳过无效刷新的条件
- 实际开发要注意什么——稳定性、
remember、重组范围、性能与协作
被问到时,我一般会先答这一句
声明式 UI = 用纯函数描述 UI = f(state);状态变了,框架重新执行描述并做最小化更新,而不是你手动改每一个 View。
| 维度 | 命令式(XML + View) | 声明式(Compose / React) |
|---|---|---|
| 心智模型 | 先有控件引用,再改属性 | 先有状态,再写「状态 → 界面」 |
| 状态与 UI | 易分裂:数据一份、界面一份,靠手动同步 | 理想情况:单一状态驱动(配合 SSOT / UDF) |
| 列表更新 | notifyItemChanged、DiffUtil | 换数据即可,框架 diff |
| 并发协作 | 同一文件里改 visibility,PR 易冲突 | 按状态拆 Composable / 组件文件,边界更清晰 |
| 学习成本 | 熟悉 View 体系即可 | 要理解重组 / reconcile,否则性能踩坑 |
声明式 UI 在生态里的位置
不只 Compose。同一套思想出现在:
| 框架 | 平台 | 描述 UI 的方式 |
|---|---|---|
| Jetpack Compose | Android(CMP 可多端) | @Composable + Kotlin |
| React | Web(React Native 桥到原生) | 函数组件 + JSX |
| SwiftUI | Apple 生态 | View + @State |
| Flutter | 多端 | Widget 树(声明式,但渲染管线自绘引擎,与 Compose/React 实现路径不同) |
下文重点展开 Compose 与 React——两者都是「函数式描述 + 运行时 diff + 原生渲染」,面试和工程里问得最多,也最容易对照理解。
一张总图:数据怎么流到像素
┌──────────────┐
│ State 状态 │ ← ViewModel / useState / Store
└──────┬───────┘
│ 变化
▼
┌──────────────┐
│ 重新描述 UI │ ← @Composable / render()
└──────┬───────┘
│
▼
┌──────────────┐
│ Diff / 协调 │ ← Compose 重组 + Slot Table
│ │ React Reconciliation + Fiber
└──────┬───────┘
│ 最小更新
▼
┌──────────────┐
│ 布局 & 绘制 │ ← Layout / Draw(Compose)
│ │ Commit 阶段改 DOM(React)
└──────────────┘命令式是你跳过中间层,直接改最后一格;声明式是只改状态,让框架走完这条链。
React 的内核:Virtual DOM 与协调(Reconciliation)
你在写什么
function ListPage({ status, items }) {
if (status === "error") return <ErrorView />;
if (items.length === 0) return <EmptyView />;
return <ItemList data={items} />;
}这不是「操作 DOM」,而是根据 props/state 返回一棵描述树(React Element 树)。
运行时做了什么
- Render 阶段:执行组件函数,得到新的 Element 树(纯计算,可中断——Fiber 架构)。
- Reconciliation:拿新树和上一帧对比,找出最小变更集(key 帮助列表项对齐)。
- Commit 阶段:把变更真正打到 DOM(或 React Native 的宿主视图)上。
可以把它理解成:每次 state 变了,重新跑一遍 render,框架帮你 diff,而不是你 document.getElementById 去改。
开发里要对齐的概念
| 概念 | 含义 | 实务 |
|---|---|---|
| Re-render | 组件函数因 state/props 变化再执行 | 不等于「整页重绘 DOM」,中间有 diff |
key | 列表项身份标识 | 错用 index 做 key,重排列表时状态错乱 |
React.memo | props 不变则跳过子树 render | 类似 Compose 的 skippable 重组 |
useMemo / useCallback | 稳定引用,配合 memo 子组件 | 别滥用,比较也有成本 |
| 状态下沉 / 提升 | 谁拥有 state,谁触发更新 | 避免无关子树跟着 re-render |
和 MVVM 的关系
React 常配 Redux / Zustand;Android 常配 ViewModel + StateFlow。本质都是 UDF:事件上行,状态下发,UI 只读状态。见 SSOT 与 UDF。
Jetpack Compose 的内核:编译期 + Slot Table + 重组
你在写什么
@Composable
fun ListPage(uiState: ListUiState) {
when (uiState) {
is ListUiState.Success -> SuccessList(uiState.items)
is ListUiState.Error -> ErrorPanel(uiState.message)
is ListUiState.Empty -> EmptyPanel()
is ListUiState.Loading -> LoadingSpinner()
}
}@Composable 不是普通函数。编译器会改写它,插入Composer 调用,在运行时维护一张 Slot Table(记录组合树、状态槽位、分组信息)。
三个阶段(比「重组」更完整)
| 阶段 | 做什么 | 典型耗时 |
|---|---|---|
| Composition 组合 | 执行 Composable,生成/更新 UI 树 | 重组发生在这里 |
| Layout 布局 | measure / place,算尺寸位置 | 状态没变但布局约束变了也会跑 |
| Draw 绘制 | 画到 Canvas / 显示列表 | 视觉相关变更 |
日常说的**重组(Recomposition)**特指 Composition 阶段:因读取的状态变了,重新执行受影响的 @Composable,再尽量跳过没变的部分。
重组怎么触发
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) } // 状态
Button(onClick = { count++ }) { // 事件上行
Text("Clicked $count") // 读 count → 订阅重组
}
}- Composable 执行时读取了某个
State,就订阅了它。 - 该
State变化 → 订阅它的 Composable 重组。 - 没读到变化状态的 Composable,有机会被跳过(skip)。
跳过重组的条件(性能核心)
Compose 会对 Composable 做**稳定性(Stability)**分析:
| 类型 | 说明 | 重组行为 |
|---|---|---|
| 稳定(Stable) | 类型标记为 @Stable,或编译器可证明实例不变则字段不变 | 参数未变可 skip |
| 不稳定(Unstable) | 如普通 List、MutableStateFlow 未包装 | 容易整段重组 |
@Immutable | 公开属性皆 val 且类型稳定 | 利于 skip |
工程上常见写法:
@Immutable
data class UserItem(val id: String, val name: String)
@Stable
class UserUiState(val items: List<UserItem>, val loading: Boolean)配合 remember、derivedStateOf、key {},控制「谁因为什么重组」。
与 React 的对照
| Compose | React | 作用 |
|---|---|---|
| 重组 Recomposition | Re-render | 状态变 → 函数再执行 |
| Skippable Composable | React.memo | 参数相同则跳过 |
remember { } | useRef / 实例缓存 | 跨重组保留对象 |
derivedStateOf | useMemo | 派生状态,减少无效订阅 |
key(item.id) in LazyColumn | key={item.id} | 列表项身份,动画与状态正确 |
State<T> / collectAsStateWithLifecycle | useState / 外部 store 订阅 | 状态入口 |
| Slot Table + Composer | Fiber + Hooks 链表 | 运行时骨架(实现不同,角色类似) |
实战对比:同一列表页,两种写法
命令式:XML + ViewBinding + LiveData
<FrameLayout>
<androidx.recyclerview.widget.RecyclerView android:id="@+id/rv_list" />
<include android:id="@+id/error_layout" layout="@layout/error" />
<include android:id="@+id/empty_layout" layout="@layout/empty" />
</FrameLayout>viewModel.uiState.observe(viewLifecycleOwner) { state ->
when (state) {
is Success -> {
binding.rvList.isVisible = true
binding.errorLayout.root.isVisible = false
binding.emptyLayout.root.isVisible = false
adapter.submitList(state.data)
}
is Failed -> { /* 三组 visibility 手动切 */ }
is Empty -> { /* 同上 */ }
}
}问题不在「能不能用」,而在:每一种 UI 态都要手写同步逻辑,漏改一个 visibility 就是 bug;多人改同一 Fragment,PR 容易撞车。
声明式:Compose + StateFlow(推荐形态)
@Composable
fun ListRoute(viewModel: ListViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ListPage(uiState = uiState)
}
@Composable
fun ListPage(uiState: ListUiState) {
when (uiState) {
is ListUiState.Success -> SuccessList(uiState.items)
is ListUiState.Error -> ErrorPanel(uiState.message)
is ListUiState.Empty -> EmptyPanel()
is ListUiState.Loading -> LoadingSpinner()
}
}
@Composable
fun SuccessList(items: List<UserItem>) {
LazyColumn {
items(items, key = { it.id }) { item ->
UserRow(item)
}
}
}注意几个刻意为之的点:
viewModel()只在 Route 层调一次,不要把viewModels()散在每个子 Composable 里——否则生命周期、重组范围都难控。- 用
collectAsStateWithLifecycle()订阅 Flow,不要viewModel.uiState.value硬读——后者不订阅,状态变了 UI 不更新。 - 子 UI 按状态拆文件(
SuccessList.kt、ErrorPanel.kt),Host 只负责when分发——团队协作时 Feature Owner 定结构,成员并行填子页面,减少 XML 时代同一 Fragment 里改 visibility 的合并冲突。
重组范围:开发里最容易忽略的一点
重组不是「全屏重画」,但作用域会向上蔓延到你读状态的那一层。
@Composable
fun BadExample() {
val list = remember { mutableStateListOf<Item>() }
Column {
list.forEach { item ->
Row {
Text(item.title) // 改一个 item,可能牵动整个 Column 重组
IconButton(onClick = { list.remove(item) }) { ... }
}
}
}
}更稳妥:
- 列表用
LazyColumn,单项包成独立@Composable,配合key。 - 大对象用
remember缓存,避免每次重组新建实例导致子组件 skip 失败。 - 滚动监听等用
derivedStateOf,避免每个 pixel 滚动都触发整页重组:
val showFab by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}经验法则:状态尽量下沉到最小需要它的 Composable;事件上浮到 ViewModel。这和 React 的 state colocation 是同一回事。
实际开发注意事项(清单)
1. 模型与稳定性
- 往 Composable 里传的
List、Map,优先不可变数据类 +@Immutable/@Stable。 StateFlow用stateIn暴露只读快照;UI 层用collectAsStateWithLifecycle。- 避免在 Composable 里
mutableListOf()当参数传来传去——引用不变但内容变了,编译器难以 skip。
2. 副作用放对地方
| API | 用途 |
|---|---|
LaunchedEffect(key) | 协程副作用,key 变才重跑 |
DisposableEffect | 注册/反注册监听 |
SideEffect | 每次成功重组后同步外部非 Compose 状态(少用) |
rememberCoroutineScope | 点按钮发协程,别在 Composable 里裸 GlobalScope |
不要在 Composable 函数体里直接做网络请求或写数据库——重组可能执行多次,副作用会重复。
3. 性能
- 长列表:
LazyColumn/LazyRow,别Column + forEach全展开。 - 图片:Compose 里用 Coil / Glide Compose 集成;列表要固定
size、合适contentScale,避免测量抖动。 - 我之前用
LazyVerticalStaggeredGrid+ Coil 做瀑布流相册,对比RecyclerView+ Glide,在极多图片、快速滑动时确实更容易掉帧——不是声明式原罪,多半是 item 未稳定 key、图片尺寸未约束、重组范围过大。Profiler 里看 Recomposition Count 往往比盲猜管用。 - 布局:Row / Column +
Modifier.weight对标 LinearLayout 权重;复杂约束用Box+align或ConstraintLayout(androidx.constraintlayout.compose)。
4. 与 MVVM 的分工
ViewModel:业务状态、事件处理、调 Repository Composable:纯展示 + 局部 UI 状态(动画、展开收起) 不要:在 Composable 里写业务规则、直接调 Repository
架构上和 MVVM 一致,只是 View 从 XML 换成了函数。
5. 迁移心态
- 不是「把 XML 一行行翻译成 Composable」——先梳理状态机(Loading / Empty / Error / Success),再写
when。 - 自定义 View 若很重,可先用
AndroidView桥接,再逐步 Compose 化。
面试 / 自查:分层答法
| 问题 | 基础层 | 进阶层 | 深入层 |
|---|---|---|---|
| 声明式 vs 命令式 | 描述 UI = f(state),不手动改 View | 单向数据流、状态驱动 | SSOT、可预测状态机 |
| Compose 重组 | 状态变 → 重新执行 Composable | 订阅机制、skip 与稳定性 | Slot Table、Compiler 插件 |
| 如何减少重组 | remember、LazyColumn | @Stable、derivedStateOf | Layout Inspector Recomposition 计数 |
| React 协调 | Virtual DOM diff | key、memo | Fiber 双阶段 render/commit |
| 列表性能 | key、Lazy* | item 类型稳定、避免匿名 lambda 捕获 | 与 RecyclerView 回收模型对比 |
小结
| 要点 | 一句话 |
|---|---|
| 声明式 | 写「什么状态下长什么样」,不写「怎么改控件」 |
| React 内核 | render → reconcile → commit DOM |
| Compose 内核 | Compiler + Slot Table;状态订阅触发重组 |
| 重组 | 不是全屏刷新;稳定性决定能否 skip |
| 工程实践 | Route 收状态、子 Composable 拆分、副作用进 Effect、列表用 Lazy + key |
收尾
声明式 UI 换的不是语法糖,而是所有权——状态归谁、UI 谁负责画、更新谁负责 diff。Compose 和 React 路径不同,但这个分工一致。
从 XML 转过来,最难的往往不是学不会 Row / Column,而是戒掉「数据到了再手动改界面」的习惯,转而问:这个界面有哪些互斥状态?哪一个字段是 SSOT?哪一层读它会触发重组?
能把重组范围和稳定性讲清楚,比背一百个 Composable API 更能拉开和普通「会用 Compose」之间的差距。若你问我 Slot Table 每一列存什么、Fiber 链表怎么挂 effect,我多半也得翻官方文档——没关系的,工程上先把状态和重组边界守住,已经能写出可维护的声明式界面了。