给移动开发者的声明式 UI 入门手册

3,435 阅读6分钟

前言

我会用两篇文章来讲透声明式 UI,分别是《给移动开发者的声明式 UI 入门手册》,《UI 开发的革命,声明式 UI 到底好在哪里?》,今天这篇文章是第一篇,第二篇什么时候能发出还不确定,欢迎关注本公众号:FlutterFirst 以及时接收推送。

命令式 UI 的由来

和声明式 UI 相对应的是命令式 UI,也就是我们传统的 UI 编程模式。

在声明式 UI(响应式 UI)这个概念还没出现之前,我们似乎并没有将其称作命令式 UI。它好像是为了甄别两者的不同而凭空造出的概念。

在我看来,命令式 UI 是由面向对象编程思想自然而然的演化出来的。面向对象编程思想讲究封装,继承和多态。这三大特性在命令式 UI 中表现得淋漓尽致。我们来分析一下:

封装

任何 UI 系统的核心职责是测量、布局、绘制和事件反馈。测量是为了计算出 UI 元素的大小,布局是为了计算出 UI 元素在屏幕中的摆放位置。绘制是为了将 UI 元素真正呈现到屏幕上,事件反馈是为了监听来自 UI 系统内部或外部的事件来更新 UI,串联用户的交互流程以完成用户的工作。

我们将界面中的元素封装成 View,让其承担上述四个职责。比如在 Android 中,onMeasure、onLayout、onDraw、onTouchEvent 都是 View 类的方法。

继承

UI 元素的种类是丰富的,比如按钮、文本、图片、进度条、单选框、复选框、SeekBar、下拉刷新、列表等等。我们不可能让一个 View 承担所有的功能,因此我们通过继承 View,重写部分核心职责方法来让不同的 View 承担不同的功能,基本上做到一个 View 只干一件事情。

这里仍然会涉及到封装,因为不同的 View 会有不同的属性。这些属性体现在成员变量上。

多态

多态在 UI 系统中体现得不多,最普遍的场景是对于某个 View,当对它进行测量时,如果它还有子 View,那么子 View 也会递归的被测量,而子 View 的类型可能是多种多样的,因此不同的子 View 对于同样的测量事件,会给予不同的反馈。

总结

当我们将 UI 元素封装成 View 以后,自然而然的会使用 Setter 来更新它的状态,使用 Getter 来获取它的状态:

TextView textView = new TextView();
textView.setText("hello world");
String text = textView.getText();

这就是命令式的,你对 Setter 方法的每一次调用就好像是对 View 发出一个个命令一样。你始终在直接操作承担渲染的 View 对象。这其实就是命令式 UI 和声明式 UI 的本质区别:

命令式 UI 直接操作渲染对象,而声明式 UI 不直接操作渲染对象。大家先记住这个核心结论,我们接着往下分析。实际上远没有这么简单。虽然只是操作对象发生了变化,但这却带来了革命性的转变。

什么是声明式 UI

为了更好的向大家阐释清楚声明式 UI 的原理,我发明了两个词,渲染前端渲染后端

由于声明式 UI 不直接操作渲染对象,而是操作渲染对象的描述,这个描述即 Widget。它是轻量级的 UI 的蓝图。这里的渲染前端就是 Widget 树,而渲染后端则是 View 树(weiV)或 RenderObject 树(Flutter)。渲染后端由渲染前端生成,它负责 UI 元素的测量、布局、绘制、事件反馈。

总结下来就是:

在命令式 UI 中,渲染前端和渲染后端都由 View 树承载。而在声明式 UI 中,渲染前端由 Widget 树承载,渲染后端由 View(RenderObject) 树承载

我举一个形象的例子:

{
	"nickName": "hackware",
	"realName": "陈方兵",
	"age": 29,
	"sex": "男"
}

这段 JSON 文本是对一个 Person 的描述,它并不是真正的 Person 对象,我们可以使用以下的代码将其转换成真正的 Person 对象:

Person person = new Gson().fromJson(personDesc, Person.class);

这段 JSON 文本就相当于 Widget,而 Person 对象就相当于 View(RenderObject)。懂了吧?

那为什么不直接操作 View,而是操作它的描述 Widget 呢,这样做的好处是什么?

这个问题值得深入的展开讨论,因此我打算在后期的《UI 开发的革命,声明式 UI 到底好在哪里?》这篇文章中来细讲。今天我们只做个初探,先给出最明显的两个好处:

不再需要 findViewById

由于你始终操作的是 UI 的描述,每当需要更新 UI 时,只需重新生成一份新的描述(一颗新的 Widget 树)即可。新的 Widget 树会和旧的 Widget 树作比对(Virtual DOM Diff)并只把变化的部分同步到渲染后端。

以 weiV Counter 为例:

class WeiVCounterKotlinActivity : WeiVActivity() {
    private var count = 0
    private val maxCount = 5
    private val minCount = 0

    override fun build(buildCount: Int) = WeiV {
        Flex {
            it.orientation = FlexDirection.VERTICAL

            Button(text = "Add count", enable = count < maxCount, onClick = {
                setState {
                    count++
                }
            })

            Button(text = "Sub count", enable = count > minCount, onClick = {
                setState {
                    count--
                }
            })

            Text(text = "count = $count")
        }
    }
}

点击 Add count 或 Sub count 按钮时 Text 的文本就会发生变化,这里并没有 findViewById 和 setText。

调用 setState 方法会先执行 Lambda 表达式将数据改变,这里的数据称为 State。而后 build 方法会重新执行以生成新的 Widget 树,新旧 Widget 树做比对并对 Text 所对应的 TextView 调用 setText。当 count 达到最大值时,比对会导致 Add count 按钮被调用 setEnable(false),当 count 达到最小值时,比对会导致 Sub count 按钮被调用 setEnable(false)。

极其灵活的组织子 View

在 Android 中,在 XML 里只能静态的组织子 View。虽然 DataBinding 出现以后我们可以在 XML 使用简单的表达式,但仍不够灵活。我们先来看看声明式 UI 下组织子 View 的灵活性吧:

class WeiVCounterKotlinActivity : WeiVActivity() {
    private var count = 0
    private val maxCount = 5
    private val minCount = 0

    override fun build(buildCount: Int) = WeiV {
        Flex {
            it.orientation = FlexDirection.VERTICAL

            Button(text = "Add count", enable = count < maxCount, onClick = {
                setState {
                    count++
                }
            })

            Button(text = "Sub count", enable = count > minCount, onClick = {
                setState {
                    count--
                }
            })

            Text(text = "count = $count")

            repeat(count) {
                Text(text = "$it")
            }

            if (count % 2 == 0) {
                Text(text = "偶数")
            }
        }
    }
}

这个 Demo 是不是很神奇,你可以使用通用编程语言的任何语法来组织子 View。我想不需要我再做解释了吧。

当然声明式 UI 的好处还不止这些,我们后面再深入探讨,接下来我们简单讲一下声明式 UI 的原理。

声明式 UI 的原理

回到声明式 UI这个词本身,现在你也许对它的概念已经明朗了。

我们不是在使用 Setter 来直接更新 UI,而是在需要更新 UI 时,创建一颗完整(或部分)的 Widget 树来声明出 UI 该是什么样子。这就是声明式 UI 的本质。

声明式 UI 的原理可以简单概括为一个公式:

UI = F(State)

和 UI 相关的数据被称为状态,UI 总是根据状态生成。

声明式 UI 的核心运行原理就在于公式中的 F 函数,主要是 Virtual DOM Diff 算法,大家有兴趣可以去看看 Flutter 或 weiV 的 Diff 算法(只有 240 行代码 )

Virtual DOM Diff 的核心流程(同级 Diff)如下:

  1. 新旧 Widget 都不为 null 时,如果新旧 Widget 类型和 Key 相同,则使用新的 Widget 中的数据更新旧的渲染对象
  2. 新旧 Widget 都不为 null 时,如果新旧 Widget 类型或 Key 不同,则移除旧的渲染对象,创建新的渲染对象
  3. 如果旧的 Widget 为 null,新的 Widget 不为 null,则创建新的渲染对象
  4. 如果旧的 Widget 不为 null,新的 Widget 为 null,则删除旧的渲染对象

结束语

好了,洋洋洒洒两千多字,希望能对你理解声明式 UI 有所帮助。以上仅仅代表我个人的理解,它不权威也可能存在谬误,还望指正。

我是中国第一位 Android & Flutter 双料 GDFE,关注我的公众号:FlutterFirst,带你起飞!我们下期见。

过去几十年以来,硬件的性能每 18 个月翻一倍,但软件的进步却慢得多,声明式 UI 是在 UI 开发这个领域难得的一次革命性的飞跃。---- 尼古拉斯 · 方兵