本文译自「One Tap, Three Machines: How Android Views, Flutter, and Compose Actually Render Your UI」,原文链接medium.com/proandroidd...,由Angelina Andronova发布于2026年7月16日。

_Android Native vs Flutter vs KMP --- 本系列文章对比 了三种移动技术栈如何以不同的方式解决相同的问题。第一部分:渲染,围绕每个 UI 框架都必须回答的一个问题展开。
UI 框架经常被比作功能目录:这个框架支持热重载,那个框架提供原生外观和体验,第三个框架共享代码。记住这些功能并不等同于理解它们。更深层次的问题始终如一:当状态改变时,框架敢于重做多少屏幕内容?
每一种渲染架构都是对这个问题的答案,而每个答案都会在某些方面提升效率,并在其他方面付出代价。UI 渲染没有免费的午餐。
本系列文章是我对三大移动技术栈的个人研究------追踪它们的渲染管线,并比较它们对相同问题的解答。追踪一次状态变更在机制中的运行过程,这三种架构可以归结为三种策略:
Android Views: 原地修改树状结构。
Flutter: 快速重建所有内容,进行差异比较和修补。
Compose: 订阅状态,仅重新运行读取器。
本文将追踪一次状态变更------点击后计数器递增------在每种架构中的运行过程:哪些工作被执行,哪些工作被跳过,以及每种设计旨在避免哪些故障。
第一部分 --- Android Views:可变树状结构
最古老的答案往往是最直接的。Views UI 是一棵由动态的、有状态的对象组成的树状结构------每个 TextView 和 LinearLayout 都是一个长期存在的实例,保存着自身的当前状态。渲染并非发生在树状结构上;树状结构本身就是屏幕。
以下是 Views 风格的计数器示例------一个用 XML 声明的树状结构,以及访问该树状结构的代码:
xml
<LinearLayout ...>
<TextView
android:id="@+id/counterText"
android:text="0" ... />
<Button
android:id="@+id/incrementButton"
android:text="+" ... />
</LinearLayout>
kotlin
val counterText = findViewById<TextView>(R.id.counterText)
val incrementButton = findViewById<Button>(R.id.incrementButton)
var count = 0
incrementButton.setOnClickListener {
count++
// manual sync --- forget this line, ship a bug
counterText.text = count.toString()
}
请注意它的形状:UI 位于一个文件中,状态位于另一个文件中,并且人类承诺保持它们相等。因此,当我们的计数器发生变化时,没有人会重建任何东西。你进入树并改变它------这个到达有一个规范的名字,findViewById,这是每个 Android 开发者学过的第一个 API。这是平台内置的一个小告白:用户界面位于其他地方,你必须去找到它。 (视图绑定和 Kotlin 合成多年来软化了语法;底层的架构从未改变。)
"TextView"将自身标记为"脏",并安排与 Choreographer(与显示器同步滴答作响的指挥)一起工作。在下一帧上,系统对树的受影响部分运行最多三遍:测量 (所有东西想要多大?)、布局 (它去哪里?)和绘制。纯文本重绘可能会跳过前两个;大小变化会沿着树向上走并触发它们。
这可以防止失败: 浪费施工。从来没有任何东西被重新创造。对于那个时代------单一屏幕、适度的层次结构------这是最便宜的答案。
它收取的回报: 树和你的数据逐渐分开。每个突变都是模型所说的内容与某些视图对象当前持有的内容之间的手动同步,并且每个被遗忘的分配都是编译器无法捕获的错误。框架渲染效率高;保持渲染_真实_完全是你的工作。十年来的模式------MVP、数据绑定、MVVM、MVI------的存在主要是为了解决这个弱点。
还有一个更安静的负担:深层层次结构可以在每帧中多次测量子级(经典的"RelativeLayout"双重测量),这就是性能指南多年来鼓吹平面布局的原因。
第二部分------Flutter:重建、差异、补丁
Flutter 考虑了同步问题,并做出了一个激进的赌注:如果保持可变树真实是困难的部分,那就停止变异。每次从状态描述整个 UI,并让框架找到差异。
这个赌注之所以有效,是因为 Flutter 将 UI 分成了三棵树:
小部件 --- 不可变的描述,自由重建。从设计上来说,创建它们的成本很低;它们是配置,而不是机械。 元素 - 幸存下来的持久中间层会重建并保持状态。 渲染对象 --- 用于测量、布局和绘制的昂贵机器。
相同的计数器,Flutter 风格:
dart
class _CounterScreenState extends State<CounterScreen> {
int count = 0;
@override
Widget build(BuildContext context) {
return Column(children: [ Text('$count'),
ElevatedButton(
onPressed: () => setState(() => count++),
child: const Text('+'),
),
]);
}
}
没有任何视图可以进入。 "build"方法是同步:状态和 UI 不能不一致,因为 UI 每次都会根据状态重新计算。
我们的水龙头调用"setState"。该框架为该小部件的子树重新运行"build()",生成一个新的小部件树 - 包括新的"Text('1')"描述。然后元素树将新的与旧的进行比较:相同的运行时类型和键 → 保留现有的 Element 和 RenderObject,只需更新它们即可;不同→拆除并重新创建。这个一位数的变化结束了它的旅程,作为对现有渲染对象的一个小更新,就像视图世界一样------但没有人必须记住去做它。
这可以防止失败: 状态 UI 漂移。每次更改时屏幕都会从模型中重新派生,因此它_不能_默默地不同意它。而且,由于 Flutter 通过自己的引擎(现在是 Impeller,历史上是 Skia)自行绘制每个像素,因此同一棵树在 Android、iOS 及其他平台上的渲染效果相同------通过完全拥有画布解决了碎片问题。
**它的回报是:**乐观的工作。无论是否发生任何变化,重建都会发生,并且差异的存在是为了在事后扔掉废物。在预算紧张的情况下------长清单、低端设备------无纪律的重建会变得很糟糕,而社区的工具箱("const"构造函数、细粒度的"BlocBuilder"放置、键)在很大程度上是一个手动缩小爆炸半径的工具箱。拥有每一个像素还意味着重新实现操作系统免费提供的功能:文本编辑、滚动物理、可访问性桥梁------永远追逐平台的发展。
第三部分 --- 撰写:订阅和补丁
Jetpack Compose 也看到了同样的问题,并拒绝缴纳不同的税。它的赌注是:不要乐观地重建并丢弃废物 - 精确跟踪谁依赖什么,并且根本不做浪费的工作。
上次在 Compose 中使用相同的计数器 - 请注意,这个确切的代码也在 Compose Multiplatform 下的 iOS 和桌面上运行:
kotlin
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Column {
Text("$count")
Button(onClick = { count++ }) { Text("+") }
}
}
它读起来像 Flutter --- 从状态声明 UI --- 但没有 setState,根本没有重建命令。写入"count"就是通知,因为"count"是可观察状态,并且读取它的"Text"已被订阅。
可组合函数不是对象;而是对象。它不会留下任何小部件。相反,当它执行时,运行时会记录两件事。首先,执行跟踪 - 插槽表 - 记住运行的内容、输入的内容以及生成的 UI 节点。其次,订阅:每次读取可观察状态寄存器_这个确切的范围读取那个确切的对象_。
我们的点击会增加一个"MutableState"。没有重建树,也没有运行 diff。运行时已经从订阅日志中知道读取"count"的精确范围,并且在下一帧上它仅重新执行这些函数,以锁步方式遍历槽表:通过比较缓存的输入来跳过未更改的子项,并且更改的"Text"更新其现有节点。爆炸半径由"读取状态的位置"定义,而不是由更改状态的位置定义。
如果 Flutter 的 Element diff 和 Compose 的跳过感觉像表兄弟,那么它们确实是------两者的存在都是为了保护昂贵的层免受不必要的工作。区别在于何时做出决定:Flutter 在重建后决定(比较结果),Compose 在重新运行之前决定(比较输入)。
**这可以防止失败:**同时发生前两个失败 - 无需手动同步_且_无需乐观重建。
**它的回报是:**信任编译器和你的类型。仅当 Compose 可以证明参数不能在背后更改时,跳过才有效 - 数据类中的一个"var",签名中的一个裸露的"List",并且运行时会默默地停止跳过该函数,并在每次传递时使用相同的数据重新运行它。没有任何中断,也没有任何警告;该应用程序只是比应有的速度慢,分析器在几个月后才发现它。 Compose 用_不可见_稳定合约取代了 Flutter _可见_重建成本------只有当你知道该合约存在时才是公平交易。
那么 Kotlin 多平台呢? Compose Multiplatform 是相同的运行时、相同的槽表、相同的订阅 - 渲染后端交换了:在 Android 上,它通过本机管道进行绘制;在 iOS 和桌面上,它带有自己的画布(Skia via Skiko)。从架构上来说,Compose 的大脑将 Flutter 的每一个像素都押注在非 Android 平台上------这两种理念从相反的方向汇聚在一起。
决策指南
跟踪所有三台机器的一位数变化,并将策略压缩到一个表中:

当突变很少且层次结构稳定时,Views 是正确的机器 - 并且它仍然是每个 Android 开发人员必须了解的世界,因为这两种新机器都可以解决其弱点。
当产品要求像素相同的多平台 UI,并且你的团队对重建范围有严格要求时,Flutter 是合适的机器。
当你希望框架承担真实性负担和效率负担,并且愿意学习它悄悄要求你遵守的稳定性合同时,Compose 是合适的机器。
生产中的大多数渲染性能问题不是由于选择了错误的机器而引起的,而是由于使用一台机器而精神上却生活在另一台机器中------像 Flutter 中的 Views 开发人员一样变异,或者像 Compose 中的 Flutter 开发人员一样提升状态读取。这些框架的不同之处并不在于它们可以做什么,而在于它们默默地期望你做什么。
本系列的下一篇:状态管理 ---"LiveData"、"Bloc"、"StateFlow",以及为什么每个生态系统从不同方向汇聚到流上。
本系列是作者对 Android Native、KMP 和 Flutter 的个人研究------它们在幕后如何工作、它们的不同之处以及它们各自对你的期望。
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!