Android UI为什么由XML转向Compose

这篇博客目前写得还不够满意,因为对Compose的理解还不够深,但是深挖的话实在太耗时间了,以后慢慢再补。

XML方式实现UI是一种命令式UI实现方式,也就是需要开发者通过明确的指令一步步告诉程序如何去创建、更新和销毁界面元素。先来看下

XML这种传统方式的缺点

1. 状态管理易混乱

以一个登录按钮为例。实现功能:未输入内容时置灰,输入手机号后变为可点击。

用传统方式实现:

监听输入框的文本变化。

在回调里拿到文本,判断是否为空。

找到按钮的引用,调用 button.isEnabled = true/false。

如果页面上有多个按钮、控件,而且它们之间还会互相影响(比如账号文本框输入文字后登录按钮才会变为可点击),就会看到成片的很多findViewById 和setXXX散落在各个回调、各处方法里------ 状态逻辑和视图更新代码混在一起,难以保持一致,容易遗漏,尤其在复杂的UI界面上可能有多处业务逻辑会修改同一控件,造成状态与视图的混乱感,很难追踪。

2. 性能差

系统需要解析XML文件然后通过大量反射创建出各View,各View的属性赋值也大量使用了反射,最终才形成了View树。View 树的测量阶段可能因为 MeasureSpec.UNSPECIFIED 或 WRAP_CONTENT 嵌套而导致多次 measure(例如 RelativeLayout 需要两次 measure 才能确定所有子视图位置)。当某个 View 的属性变化(如文字大小、可见性)需要重新布局时,整个 View 树从该 View 向上 mark 脏,然后全量触发 measure/layout/draw。即使只有一个小按钮的文字变了,它的父容器、乃至整个 Activity 的视图树都可能重新计算尺寸和位置,造成过度更新的情形。这样的实现机制在Android初期界面和业务都很简单时,性能问题并不突出,随着后来业务、UI越来越复杂,性能问题日益凸显出来。

深度思考:一个TextView,当它的文字变化时,可能会引起父容器甚至整个View树刷新吗?

有可能。如果TextView没有设置固定的宽高,那么文字变化就可能会引起TextView尺寸变化,进而导致父容器和其他组件受到影响,如果要计算它引起的精确变化当然也是可以的,但是计算过程的复杂程度可能远比直接更新整棵View树还要高,所以综合权衡下,Android采用了(触发**requestLayout**)更新整棵View树的方法。

也有可能不会。如果TextView设置了固定的宽高,那么它的尺寸就一直都不会变,就没有必要刷新整棵View树了,这种情况下就只会触发TextView的重绘,不会导致View树重新测量、重新布局。

Compose

Compose是一种声明式UI,即 开发者只需要描述 在某某状态下界面应该长什么样,而不需要关心如何一步步地创建和更新界面。框架会自动根据状态的变化,高效地更新界面以匹配最新描述。在Compose出现之前,Google其实已经在向声明式UI做了一些靠拢,比如Databinding框架,但由于其实现仍旧需要依靠XML文件,所以并不彻底,但是它让开发者接触到了状态驱动的概念。

实践过Compose的就会知道,界面完全就是依赖状态,当状态被修改了,UI会自动更新。

1. Compose的原理
1.1 组合

compose由大量Composable函数实现,但是这些函数其实并不是UI的具体组件,它不像XML传统布局方式中那样,写一个Button,那里就真的会有一个Button。Composable函数只是 描述此处UI应该长什么样

下面举个例子来讲

复制代码
// 这样的UI代码其实没有任何UI组件,它们只是一串函数调用。
// 像常用到的Text()、Button()等其实都是普通函数,
// 与XML传统布局方式中的Button、TextView等组件不一样。
// 这也恰好体现出了声明式UI的特点:声明UI应该是什么样,但自己并不直接去实现UI。
@Composable
fun CounterApp() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Clicked $count times")
    }
}

在被compose编译器编译后就会变成类似如下的代码

复制代码
public static final void CounterApp(@Nullable Composer $composer, int $changed) {
        Object obj;
        Object obj2;
        Composer $composer2 = $composer.startRestartGroup(-1279504432);
        ComposerKt.sourceInformation($composer2, "C(CounterApp)40@1460L30,41@1512L11,41@1525L44,41@1495L74:MainActivity.kt#n3mw1h");
        if ($changed == 0 && $composer2.getSkipping()) {
            $composer2.skipToGroupEnd();
        } else {
            if (ComposerKt.isTraceInProgress()) {
                ComposerKt.traceEventStart(-1279504432, $changed, -1, "com.book.show.CounterApp (MainActivity.kt:39)");
            }
            $composer2.startReplaceGroup(-1428904292);
            ComposerKt.sourceInformation($composer2, "CC(remember):MainActivity.kt#9igjgp");
            Object objRememberedValue = $composer2.rememberedValue();
            if (objRememberedValue == Composer.Companion.getEmpty()) {
                Object objMutableStateOf$default = SnapshotStateKt.mutableStateOf$default(0, (SnapshotMutationPolicy) null, 2, (Object) null);
                $composer2.updateRememberedValue(objMutableStateOf$default);
                obj = objMutableStateOf$default;
            } else {
                obj = objRememberedValue;
            }
            MutableState count$delegate = (MutableState) obj;
            $composer2.endReplaceGroup();
            $composer2.startReplaceGroup(-1428902647);
            ComposerKt.sourceInformation($composer2, "CC(remember):MainActivity.kt#9igjgp");
            Object objRememberedValue2 = $composer2.rememberedValue();
            if (objRememberedValue2 == Composer.Companion.getEmpty()) {
                Object obj3 = () -> {
                    return CounterApp$lambda$4$lambda$3(r0);
                };
                $composer2.updateRememberedValue(obj3);
                obj2 = obj3;
            } else {
                obj2 = objRememberedValue2;
            }
            $composer2.endReplaceGroup();
            ButtonKt.Button((Function0) obj2, (Modifier) null, false, (Shape) null, (ButtonColors) null, (ButtonElevation) null, (BorderStroke) null, (PaddingValues) null, (MutableInteractionSource) null, ComposableLambdaKt.rememberComposableLambda(-1129161792, true, new CounterApp.2(count$delegate), $composer2, 54), $composer2, 805306374, 510);
            if (ComposerKt.isTraceInProgress()) {
                ComposerKt.traceEventEnd();
            }
        }
        //更新域
        ScopeUpdateScope scopeUpdateScopeEndRestartGroup = $composer2.endRestartGroup();
        if (scopeUpdateScopeEndRestartGroup != null) {
            scopeUpdateScopeEndRestartGroup.updateScope((v1, v2) -> {
                return CounterApp$lambda$5(r1, v1, v2);
            });
        }
    }

我们可以看到上面的代码中有一个参数changed,它使用位来表示某个参数是否有变化,以此来表示对应的组件是否需要更新。上面代码中只是示意,实际代码中更复杂,例如参数很多时,一个int(32位)不够用,就会使用多个int来表示。

每个独立的 Compose UI 树(比如一个 Activity 中的setContent(),或者 Compose View 嵌入到传统 View 体系中)都会创建一个Composition对象,而每个Composition对象里又都有一个Composer。Composable函数经过 Compose Compiler 编译后会变成接收 Composer 、changed 等参数的普通 Kotlin 函数。上面的代码中可以看到有多处XXXGroup。Group可以理解为一段代码的代表,代表从XX行到YY行的一段代码。每个Composable函数至少会对应着一个Group,如果Composable内部逻辑复杂(比如有if分支),该Composable内部也可能会被拆分成多个子Group。Group是Compose局部更新机制的基石,是局部刷新的最小执行单元。

Compose Runtime执行这些编译后的函数时,composer就会遍历整个调用链,构建 Composition 即 Group 树,并将数据写入 SlotTable。SlotTable是一个线性数组,它存有:

(1)Group元信息。如Group的id,Group的层级关系,是否可重组等。

(2)Slot数据。如各种State数据(比如mutableStateOf(0)),LayoutNode的引用,重组作用域(即RecomposeScope ,标记哪些代码块依赖哪些状态,便于精准触发重组)。其中LayoutNode 代表一个UI组件,如Text、Button等,但它不像XML传统布局中的那样的TextView、Button是一个真实的控件,而是对UI组件的一种描述,例如,它描述:某某处,一个文本框,宽100,高50。

(3)辅助信息。如跳过标志等。

同时,composer也会生成一棵LayoutNodeTree。SlotTable中存有LayoutNode的引用,所以LayoutNodeTree也就和SlotTable产生了关联。至此,其实已经可以看到大致的轮廓了:Composition中存有状态和依赖关系,LayoutNodeTree中存有UI的描述,两者又有关联,更精简地讲就是一方存有原始数据和依赖关系,一方存有UI描述,接下来该展示了

AndroidComposeView :一个真正的 ViewGroup(继承自 ViewGroup),它作为 Compose UI 的"根容器"被添加到 Activity / Fragment 的 DecorView 中。

ComposeView本质上就是一个包裹了 AndroidComposeView 的容器。

在渲染阶段,会遍历LayoutNodeTree,与XML传统方式一样都要经历 计算各组件的尺寸、位置的过程,在最后的绘制阶段与传统的View方式又有些不同。传统 View 是每个控件在自己的onDraw里拿着系统给的 Canvas 各自画;Compose 是只留一个根 View(AndroidComposeView)拿到 Canvas,然后遍历整个 LayoutNodeTree,让所有LayoutNode都在同一个 Canvas 上按顺序画完,最后交还给系统显示。

测量、布局、绘制阶段不要再读取SlotTable?那么展示的数据哪来的?

通常不需要。SlotTable里存储的是原始的State数据,在组合阶段生成LayoutNode时已经被转化成了常规数据存放在LayoutNode里,这样当展示需要时就可以直接读取而不必再花销资源去SlotTable读取了,既高效又避免了数据同步问题。

1.2 局部更新

每个 State 对象(如 mutableStateOf(0))内部都维护着一个 弱引用列表,记录依赖它的 RecomposeScope()。当组合执行时,Composer 会为每个 可重启组(RestartGroup) 创建一个 RecomposeScope 对象,并记录当前正在读哪个 State。当State的值变化后,State 对象会遍历自己的依赖列表,通知每个 RecomposeScope"你依赖的值变了"。每个 RecomposeScope 知道自己是哪个 Group 以及该 Group 在 SlotTable 中的位置。每个 RecomposeScope 被通知后,不会立即执行重组,而是被加入 Recomposer 的一个 待处理集合(pendingScopes)。Recomposer 会在下一帧(或通过 State 写入后的 snapshot.sendApplyNotifications())开始处理这个集合。

Recomposer 按优先级(通常是同步或异步)遍历 pendingScopes,对每个 RecomposeScope 调用scope.compose()触发重组。这个调用最终会进入Composer并传入该RecomposeScope在 SlotTable 中的起始位置,Composer拿到位置后定位到目标Group,Composer对比Group新旧数据差异,如果无差别则直接跳过,如果有差别则覆盖旧Group并修改对应的LayoutNode的值,再将LayoutNode标记为dirty,所有LyaoutNode处理结束后会向上通知到根节点,最终调用 AndroidComposeView.scheduleMeasureAndLayout(),再次触发测量→布局→绘制 阶段,且只处理 dirty 的LayoutNode 子树。

State 变化后,仅通知依赖它的 RecomposeScope(代码块),这些 Scope 在下一帧重新执行,只更新受影响的最小 UI 区域。

如果在下一帧到来时,LayoutNodeTree还没遍历完,会怎样?

结果就会掉帧。正在遍历的这一帧的内容就不会显示出来,而是继续显示旧帧。

相关推荐
CedarQR9 小时前
VS Code Copilot Chat 配置 MCP Server 全攻略(以 Next AI Draw.io 为例)
人工智能·ui·json
console.log('npc')11 小时前
网页移动端UI/UX Pro Max - 设计智能skill
前端·人工智能·ui·ux
工业HMI实战笔记14 小时前
HMI性能优化技巧:告别卡顿,打造流畅体验
前端·ui·性能优化·自动化·交互
解局易否结局14 小时前
鸿蒙多设备形态适配实战:从手表到车机的差异化 UI 设计
ui·华为·harmonyos
ltqvibe15 小时前
TokUI怎么让AI输出从文字墙变成可交互UI
人工智能·ui·交互
玖玥拾1 天前
C# 语言进阶(十四)Unity UI界面开发 + 网络消息联动
服务器·开发语言·网络·ui·unity·c#·游戏引擎
JustNow_Man2 天前
【Claude Code】 中给 Python + XML 项目建立可靠验证体系
xml·linux·python
不羁的木木2 天前
HarmonyOS APP实战-画图APP - 第6篇:形状切换与UI优化
ui·华为·harmonyos
anling_li2 天前
《图片华容道》三、HarmonyOS_ArkTS开发调试实战_崩溃修复全记录
ui·华为·harmonyos
anling_li2 天前
《随机乱序键盘》四、简约风格随机乱序键盘案例指南
ui·华为·harmonyos