返回 Android
Android
15 分钟阅读

声明式 UI 布局

从命令式 update 到数据驱动渲染——Jetpack Compose 与 React 的声明式内核、重组/协调机制,以及实际开发里必须知道的稳定性与性能要点。

为什么写这篇

做 Android 很多年,习惯的路径是:XML 把页面搭好,findViewById / ViewBinding 拿到控件,数据到了再 text =visibility =adapter.notifyDataSetChanged()——UI 是客体,开发者亲手去改它

切到 Jetpack Compose(或顺带接触 React / SwiftUI)之后,范式反过来了:你描述「在某个状态下界面长什么样」,框架根据状态变化自己去更新屏幕。业务上仍常配合 MVVM,但 UI 层不再是一堆 imperative 的 patch,而是一张随数据流动的「视图函数」。

这篇想讲清楚三件事:

  1. 声明式 UI 到底是什么(和命令式差在哪)
  2. Compose 与 React 的内核——重组、Virtual DOM / Slot Table、跳过无效刷新的条件
  3. 实际开发要注意什么——稳定性、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 ComposeAndroid(CMP 可多端)@Composable + Kotlin
ReactWeb(React Native 桥到原生)函数组件 + JSX
SwiftUIApple 生态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 树)。

运行时做了什么

  1. Render 阶段:执行组件函数,得到新的 Element 树(纯计算,可中断——Fiber 架构)。
  2. Reconciliation:拿新树和上一帧对比,找出最小变更集(key 帮助列表项对齐)。
  3. Commit 阶段:把变更真正打到 DOM(或 React Native 的宿主视图)上。

可以把它理解成:每次 state 变了,重新跑一遍 render,框架帮你 diff,而不是你 document.getElementById 去改。

开发里要对齐的概念

概念含义实务
Re-render组件函数因 state/props 变化再执行不等于「整页重绘 DOM」,中间有 diff
key列表项身份标识错用 index 做 key,重排列表时状态错乱
React.memoprops 不变则跳过子树 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)如普通 ListMutableStateFlow 未包装容易整段重组
@Immutable公开属性皆 val 且类型稳定利于 skip

工程上常见写法:

@Immutable
data class UserItem(val id: String, val name: String)
 
@Stable
class UserUiState(val items: List<UserItem>, val loading: Boolean)

配合 rememberderivedStateOfkey {},控制「谁因为什么重组」。

与 React 的对照

ComposeReact作用
重组 RecompositionRe-render状态变 → 函数再执行
Skippable ComposableReact.memo参数相同则跳过
remember { }useRef / 实例缓存跨重组保留对象
derivedStateOfuseMemo派生状态,减少无效订阅
key(item.id) in LazyColumnkey={item.id}列表项身份,动画与状态正确
State<T> / collectAsStateWithLifecycleuseState / 外部 store 订阅状态入口
Slot Table + ComposerFiber + 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)
        }
    }
}

注意几个刻意为之的点:

  1. viewModel() 只在 Route 层调一次,不要把 viewModels() 散在每个子 Composable 里——否则生命周期、重组范围都难控。
  2. collectAsStateWithLifecycle() 订阅 Flow,不要 viewModel.uiState.value 硬读——后者不订阅,状态变了 UI 不更新。
  3. 子 UI 按状态拆文件SuccessList.ktErrorPanel.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 里传的 ListMap,优先不可变数据类 + @Immutable / @Stable
  • StateFlowstateIn 暴露只读快照;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 + alignConstraintLayoutandroidx.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 插件
如何减少重组rememberLazyColumn@StablederivedStateOfLayout Inspector Recomposition 计数
React 协调Virtual DOM diffkeymemoFiber 双阶段 render/commit
列表性能keyLazy*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,我多半也得翻官方文档——没关系的,工程上先把状态和重组边界守住,已经能写出可维护的声明式界面了。

相关文章

Android
公开
4 分钟
如何在Android开发中实现MVVM的架构
MVVM与Android的实践简要指导
Android
公开
26 分钟
Android 架构核心原则:单一数据源(SSOT)与单向数据流(UDF)实战指南
在Android开发中,随着应用复杂度提升,数据混乱、状态不一致、调试困难等问题频发,而单一数据源(Single Source of Truth, SSOT)与单向数据流(Unidirectional Data Flow, UDF)正是解决这些痛点的核心架构原则。
跨端开发
公开
14 分钟
Compose Multiplatform 初体验:从 KMP 原理到多端跑通第一个 App
声明式 UI 写到 Android 之外——Compose Multiplatform 初体验。
Android
公开
18 分钟
Android面试套路
面试里 Compose 重组、稳定性常作为进阶追问。