敏捷扩展案例:声明式 UI

声明式 UI 是一种面向 UI 编程的开发范式,它使开发者只需要描述 UI 组件间的布局关系、以及 UI 组件与状态(即渲染所需要的数据)间的绑定关系,而不需要关心 UI 界面实际渲染刷新的实现细节,因而提高了开发者的开发效率。近几年业界 UI 框架开始采用 eDSL 的方式构建声明式 UI,本节以声明式 UI 为例,介绍如何使用仓颉的领域扩展能力来构建 UI eDSL。

UI 组件布局

UI eDSL 首先需要具备描述各种组件在二维平面如何排布的能力,能够清晰的表达组件的长、宽等配置信息,以及组件间的层次关系;同时期望 UI eDSL 能使代码结构与 UI 界面具备一定的相似性,达到“所见即所得”的效果;另外 UI eDSL 应该非常简洁,尽量减少 UI 描述以外的“噪音”。 假设要实现如下所示的 UI 界面,它由一段文本和一个按钮组成,文本和按钮需要纵向居中排列;同时需要为按钮设置点击事件处理逻辑: single

我们使用仓颉定义的 UI eDSL,可以通过如下代码来描述期望的 UI 界面,其中 Text 组件显示一段文本,Button 组件实现按钮功能,为了使它们纵向排列,把这两个组件嵌在一个 Column 布局组件中。

class CustomView {
    ...
    func build() {
        Column {
            Text("${count} times")
                .align( Center )
                .margin(top: 50.vp, bottom: 50.vp)
            Button("Click")
                .align( Center )
                .onClick { evt =>
                    count++
                }
        }.width(100.percent)
        .height(100.percent)
    }
    ...
}

作为对比,假如仓颉不提供相应的扩展能力,可能需要开发者写出如下代码。从可读性上,前者更为清晰简洁;从字符数统计上,后者相比前者需要开发者多写近 70% 的代码,这在更为复杂的页面上,将是非常可观的开销。

class CustomView {
    ...
    func build() {
        new Column ({ =>
            new Text("${count} times")
                .align(Alignment.Center)
                .margin(Length(50, Unit.vp), Length(0, Unit.vp), Length(50, Unit.vp), Length(0, Unit.vp))
            new Button("Click")
                .align(Alignment.Center)
                .onClick ({ evt =>
                    count++
                })
        }).width(Length(100, Unit.percent))
        .height(Length(100, Unit.percent))
    }
    ...
}

那么在使用仓颉语言定义如上的 eDSL 时,我们采用了以下特性:

  • 使用尾随 Lambda 来描述组件间的分层关系,比如 Column 作为 Text 和 Button 的父组件,决定了子组件的排列方式;同时尾随 Lambda 也可以省略“()”,使语法更简洁。

  • 使用命名参数和参数默认值的特性,使传参更清晰简洁,比如在设置 margin 时,只需要设置 top 和 bottom,未设置的参数选择默认值;同时命名参数使得开发者清晰的知道设置的是哪个参数,不用专门去记参数顺序,提高了代码可读性,不易犯错。

  • 通过类型扩展能力,为整数类型扩展出带有长度单位的表达能力,比如 100.percent 等价于“100%”,而 50.vp 等价于“50 vp”,其相比只用整数,提供了类型校验的保障;而相比使用 Length 类,语法更简洁,可读性更高。

  • 仓颉支持类实例化时省略“new”关键字,通过类型推断实现省略枚举前缀(比如直接用 Center 而不是 Alignment.Center),进一步增强了表达的简洁性。

UI 组件与状态绑定关系

状态是一组与界面关联的数据,在声明式 UI 下,通常使用view = f(state)来表达 UI 界面(view)与状态(state)的关系,其中 f 作为 view 与 state 之间的纽带,由框架实现,并向 UI 开发者隐藏。通常 f 被实现为一套响应式的机制,即:

  • 建立 state 到 view 中组件的绑定关系。
  • 捕获 state 修改,触发相应组件的刷新。

我们修改上面的例子实现一个计数器。我们为组件增加一个状态 count,同时为 Button 增加点击事件处理,每点击一次按钮,就使 count 自增 1。另外组件 Text 会显示当前的点击次数,即 count 值。

class CustomView {
    @State var count: Int64 = 0
    ...
    func build() {
        Column {
            Text("${count} times")
                .align( Center )
                .margin(top: 50.vp, bottom: 50.vp)
            Button("Click")
                .align( Center )
                .onClick { evt =>
                    count++
                }
        }.width(100.percent)
        .height(100.percent)
    }
    ...
}

我们通过为 count 变量增加 @State 宏修饰,使其具有响应式的能力,即可以捕获在点击事件中的改动,并触发 Text 组件的刷新,而这种实现机制都隐藏在 @State 的宏实现中。以下是一种示意的宏展开代码逻辑(实际上如前所述,宏展开发生在编译阶段,展开逻辑以 AST 形式存在):

class CustomView {
    private var count_ = State<Int64>(0)

    mut prop count: Int64 {
        get(): Int64 {
            count_.bindToComponent()
            count_.get()
        }
        set(newValue: Int64) {
            count_.set(newVaue)
            count_.notifyComponentChanges()
        }
    }
    ...
    func build() {
        Column {
            Text("${count} times")
                .align( Center )
                .margin(top: 50.vp, bottom: 50.vp)
            Button("Click")
                .align( Center )
                .onClick { evt =>
                    count++
                }
        }.width(100.percent)
        .height(100.percent)
    }
    ...
}

实现以上效果,我们采用了以下特性:

  • 定义用于状态管理的宏 @State,其展开生成相应的状态处理代码,从而减少开发者编写一些模板化和重复性的代码,简化了状态声明和管理的复杂度。
  • 采用属性机制,实现对实际状态数据的代理,对外保持读写 count 的形式,但在其内部实现中,通过 get 方法来捕获“读”操作,建立状态与组件的绑定关系;通过 set 方法捕获“写”操作,并通知其绑定的组件进行刷新。

以上通过声明式 UI 这个案例,展示了通过灵活使用仓颉的扩展能力,可以提高代码的可读性、简洁性和正确性,简化开发者负担,降低框架或者库的使用门槛,有利于生态推广。