这篇博客目前写得还不够满意,因为对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还没遍历完,会怎样?
结果就会掉帧。正在遍历的这一帧的内容就不会显示出来,而是继续显示旧帧。