看看SwiftUI通过什么来实现的:Result Builder

语言: CN / TW / HK

起风了

书接上文,上篇文章中,我们已经知道了@State 是属性包装器了,SwiftUI通过@State声明的属性来进行视图和数据的绑定。我们写SwiftUI的代码的时候,经常会有如下类似代码:

```swift struct SwiftUITest: View { @State var numbers: [Int] = []

var body: some View {
    VStack {
        Text("标题").font(.largeTitle)
        Spacer().frame(height: 20)
        Text("内容")
    }
}

} ```

我们单独拎出VStack,看着它的代码不由得产生了两个疑问:

  • 这似乎是一个初始化方法,参数是一个尾随闭包,确定吗?
  • 初始化了不同的变量,但是都没有参数名,而且也无需使用标点符号分割,return的是三个的组合值吗?

所以接下来我们通过Xcode进入到VStack 查看对外暴露的API,至少一部分问题就可以豁然开朗了:

```swift // 暴露的API @frozen public struct VStack : View where Content : View { @inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)

    public typealias Body = Never

} // 实际实现方式(来源WWDC21 Session 10253) struct VStack: View { ... init(@ViewBuilder content: () -> Content) { self.content = content } ... } ```

没错,确实是我们熟悉的初始化方法,传入了一个闭包content用来初始化,可以看到关键在于闭包参数使用了@ViewBuilder 修饰符,看来对闭包中的不同属性进行合并的操作是该修饰符的特性。所以我们接下来继续去探索

清风袭来

那么什么是@ViewBuilder呢?我们继续往下探索,在SwiftUI的源码中得到了它的API:

```swift @resultBuilder public struct ViewBuilder { static func buildBlock -> EmptyView

        static func buildBlock<Content>(_ content: Content) -> Content where Content: View

} ... extension ViewBuilder { public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0 : View, C1 : View, C2 : View } ... ```

看来关键就是@resultBuilder了,同时我们通过ViewBuilder 对外提供的方法,可以继续将VStack的初始化方法补全:

```swift VStack.init(content: { Text("标题").font(.largeTitle) Spacer().frame(height: 20) Text("内容")

return // 此处必然使用了View Builder获取返回值

} ```

```swift VStack.init(content: { let v0 = Text("标题").font(.largeTitle) let v1 = Spacer().frame(height: 20) let v2 = Text("内容")

return ViewBuilder.buildBlock(v0, v1, v2)

}) ```

ViewBuilder将创建的每一个元素的值都合并成为了一个值并返回作为VStack中的内容。所以说最后它还是转化为了我们所熟悉的Swift的语法,这是合乎逻辑的,保证了统一性,而SwiftUI只是基于Swift的嵌入式DSL,这一点是要明确的。回到ViewBuilder,它也只是通过@resultBuilder实现的一个特殊类型,所以真正要弄明白的还是resultBuilder结果生成器)

Result Builder的历史

Result Builder最初是在Swift的提案SE-0289 中提出的,是随着Swift5.4出来的,这个版本是它的第二版本,而在它的最初版本中,它的名字还不是Result Builder,而是Function Builder,所以现在去看关于SwiftUI方面的文章,相当一部分文章还是使用Function Builder这个词。而Function Builder自Swift 5.1以来就一种是一个隐藏的特性,而使用它的最出名的Library,就是我们一直提到的SwiftUI

%E6%88%AA%E5%B1%8F2022-07-20_11.47.41.png

Result Builder的由来

作为iOS开发者使用UIKIt已经很久了,但是在处理复杂UI的时候一直都是我们的痛点,首先是布局复杂,所以出现了一大批如SnapKit、PureLayOut等等这些优秀的布局框架,然后是用户交互或者数据改变的时候,又得管理多种状态的更新,又得手动去刷新对应的UI视图等等,总之是非常繁琐,而且还没法跨平台,iOS上是UIKit,Mac OS上是AppKit,Watch OS上是WatchKit。

这个时候看着隔壁的Google掏出了Flutter这一更现代化的声明式UI框架,不仅是跨平台的,Dart语言也使得它简单上手,如果你是Apple的开发者,一方面要解决历史遗留的问题,一方面需要对竞争对手做出回应,你会怎么做呢?

且不谈跨平台,那需要操作系统底层的配合,单说开发一个更现代化的UI框架,解决这一类问题通常自定义一门编程语言也就是领域特定语言(DSL)是更容易的,比如HTML & CSS来解决了Web界面的结构语义和样式的描述。当然我们也可以不去使用DSL,而是基于现有的封装语法封装一个声明式的UI框架,比如Facebook开源的ComponentKit,使用起来和下面很类似:

jsx return body([ division([ header1("Chapter 1. Loomings."), paragraph(["Call me Ishmael. Some years ago"]), paragraph(["There is now your insular city"]) ]), division([ header1("Chapter 2. The Carpet-Bag."), paragraph(["I stuffed a shirt or two"]) ]) ])

我们可以看到它虽然使用各种辅助函数提供了一种声明式的方式,但是实际上还有很多问题:

  • 这里依然有很多标点符号:逗号、括号和方括号。虽然这个问题很简单,但是不可避免会给开发者带来困扰,最好是可以避免它。
  • 这里为children使用了数组类型,而实际上类型选择器要求它的元素拥有一样的类型,虽然这个实例是OK的,但是这种情况是有限的,如果有其他不同的类型,那将造成很大的麻烦
  • 如果改变了上述的层级中的某个元素,比如动态展示文本,那事情又将会变得复杂

jsx division((useChapterTitles ? [header1("Chapter 1. Loomings.")] : []) + [paragraph(["Call me Ishmael. Some years ago"]), paragraph(["There is now your insular city"])])

……

简而言之,它依旧不是一个很现代化的UI框架,固然有它的特定,可是仍然不如Flutter使用那么丝滑,因为上述问题它无法很好的解决这些问题,而实际上一个现代化的UI框架使用起来应该如下:

jsx return body { let chapter = spellOutChapter ? "Chapter " : "" division { if useChapterTitles { header1(chapter + "1. Loomings.") } paragraph { "Call me Ishmael. Some years ago" } paragraph { "There is now your insular city" } } division { if useChapterTitles { header1(chapter + "2. The Carpet-Bag.") } paragraph { "I stuffed a shirt or two" } } }

上述的这种实现如果创建一个传统的DSL,那我们需要重新实现一套新的语法,需要重写编译器来解析语法树,同时现有的Swift开发者必然会感到困惑,因为这不符合Swift用户的期望,相当于一门要去掌握一门新的语言了(从Swift1~Swift5我们已经掌握了好多门新语言了),所以嵌入式DSLDSL详文下篇描述)就是一个必然的选择了,也就是将上述类型的实现以某种形式嵌入到我们的Swift中。

嵌入式的DSL使用了宿主语言的抽象能力,并且省去了复杂语法分析器(Parser)的过程,不需要重新实现模块、变量等特性。而在0289提案中,Apple这样描述Swift:

Swift is designed with rich affordances for building expressive, type-safe interfaces for libraries. In some cases, a library's interface is distinct enough and rich enough to form its own miniature language within Swift. We refer to this as a Domain Specific Language  (DSL), because it lets you better describe solutions within a particular problem domain.

Swift在设计之初,就让它有足够的能力为Library设计富有表现力的、类型安全的接口,足以在Swift中形成自己的微型语言(DSL),也就是基于Swift的嵌入式DSL。而为了实现这个嵌入式的DSL,并解决上述的那些问题,Apple发布了ResultBuilder提案。

Result Builder的定义

那说来说去,什么是Result Builder呢?

This proposal describes result builders, a new feature which allows certain functions (specially-annotated, often via context) to implicitly build up a result value from a sequence of components. (它允许某些函数从一系列组件中隐式的创建结果)

基本的思想就是将该方法中不同语句的结果使用一个builder type组合起来,如下:

```swift // 初始源码 @TupleBuilder func build() -> (Int, Int, Int) { 1 2 3 }

// 实际上会转化为如下代码 func build() -> (Int, Int, Int) { let _a = TupleBuilder.buildExpression(1) let _b = TupleBuilder.buildExpression(2) let _c = TupleBuilder.buildExpression(3) return TupleBuilder.buildBlock(_a, _b, _c) } ```

Result Builder作用于特定类型的接口,该类接口涉及列表和树结构的声明,所以在很多的问题领域(problem domains)它都很有用,比如生成结构化的数据(如XML或者JSON),比如视图层级(如SwiftUI)。

Result Builder的使用

使用Result Builder,首要的就是去创建一个Result Builder类型(类似上述的ViewBuilder),它需要满足两个基本的要求:

  • 它必须使用 @resultBuilder 进行注解
  • 它必须至少提供一种静态的buildBlock的结果方法

一旦成功创建一个Result Builder类型之后,可以在两种不同位置的地方使用该注解:

一、第一种是funcvarsubscript的声明上。

而对于var以及subscript 必须要定义一个getter方法,该注解其实就是直接作用在get方法上的,同样在func的实现时添加注解,是表明该注解直接作用在此方法上,实例如下:

```swift class BBB { // 1、作用在var属性上 @NSAttributedStringBuilder var text: NSAttributedString { get { Atext("--").foregroundColor(Color.red) Atext("hah") } }

    // 2、直接作用在方法上
@NSAttributedStringBuilder func aaa() -> NSAttributedString {
    Atext("----").foregroundColor(Color.red)
    Atext("hah")
}

    // 3、直接作用在下标上
@NSAttributedStringBuilder subscript(index: Int) -> NSAttributedString {
    get {
        Atext("haah")
        Atext("hah")
    }
}

} ```

这里我使用了ResultBuilder 提案中推荐的案例NSAttributedStringBuilder 来做演示,上述案例中Atext可以理解为一个NSAttributedString,当该注解@NSAttributedStringBuilder作用到以上三者上时,其实代表的是作用到三者对应的方法上,将方法体中的每一句描述对应的结果都组合起来。

二、作用在方法的参数上。

但是在测试的时候,编译器会给出明确的提示:

Result builder attribute 'NSAttributedStringBuilder' can only be applied to a parameter of function type.

也就是说ResultBuilder的注解只能用于函数类型的参数上!在Swift中通常我们使用的都是闭包。

swift // 作用在函数类型的方法参数上 public extension NSAttributedString { @discardableResult convenience init(@NSAttributedStringBuilder _ builer: () -> NSAttributedString) { self.init(attributedString: builer()) } }

那么如何实现这个Result Builder呢?当我们创建一个Result Builder 类型的时候,我们其实只是创建了一个静态方法的容器。而这些静态方法就是作用于注解的方法体中的语句的,所以首先就需要看看,Result Builder 类型的静态方法有哪些?

  • buildBlock(_ components: Component...) -> Component 这是每一个ResultBuilder都必须包含一个静态方法,负责将方法中的语句块结果组合起来。
  • buildOptional(_ component: Component?) -> Component 用来在一个可能存在也可能不存在的结果时,当该静态方法的容器提供该函数时,被作用的方法中的语句可以使用包含if不包含else的选择语句
  • buildEither(first: Component) → Component 以及 buildEither(second: Component) → Component 用于在选择语句从不同路径产生不同结果时,当该静态方法的容器提供该函数时,被作用的方法中的语句就可以使用if-else语句,以及switch语句
  • buildArray(_ components: [Component]) → Component 用于在循环中产生结果,当该静态方法的容器提供该函数时,被作用的方法中的语句可以使用for…in语句
  • buildExpression(_ expression: Expression) -> Component 用于作用于方法中的语句,将作用后的返回值作为buildBlock 方法的参数。
  • buildLimitedAvailability(_ component: Component) -> Component 作用于有限可用性上下文。比如if #available
  • buildFinalResult(_ component: Component) -> FinalResult 作用于buildBlock顶层函数体最外层调用所产生的结果。

Result Builder的实现

上面只是简单的介绍各个方法的含义,但是实际如何使用还是让人心生疑惑,所以接下来我会以一个实例入手,我们使用一个提案上的案例NSAttributedStringBuilder 来自定义一个简易描述NSAttributedString的DSL。

首先我们需要定义一个Component协议,用来声明字符串和字符属性,并将它们转化为富文本。

```swift typealias Font = UIFont typealias Color = UIColor typealias Attributes = [NSAttributedString.Key: Any]

protocol Component { var string: String { get } var attributes: Attributes { get } var attributedString: NSAttributedString { get } }

extension Component { var attributedString: NSAttributedString { return NSAttributedString.init(string: string, attributes: attributes) } }

// 创建一个继承该协议的结构体 // 实例为NSAttributed extension NSAttributedString { struct AttrText: Component { let string: String let attributes: Attributes

    init(_ string: String, attributes: Attributes = [:]) {
        self.string = string
        self.attributes = attributes
    }
}

}

// 添加一点简单的添加属性的方法 typealias Atext = NSAttributedString.AttrText

extension Component { func addAttributes(_ newAttributes: Attributes) -> Component { var attributes = self.attributes for attribute in newAttributes { attributes[attribute.key] = attribute.value } return Atext(string, attributes: attributes) }

func foregroundColor(_ color: Color) -> Component {
    addAttributes([.foregroundColor: color])
}

func font(_ font: Font) -> Component {
    addAttributes([.font: font])
}

} ```

基础版本

swift NSAttributedString{ Atext("老子").foregroundColor(UIColor.red) Atext("明天不上班").foregroundColor(UIColor.blue) }

上述是我们的基础版本,而要实现这个,我们需要添加上必须的buildBlock 方法,来将方法中的描述语句都结合起来。

```swift @resultBuilder enum NSAttributedStringBuilder { static func buildBlock(_ components: Component...) -> NSAttributedString { let mas = NSMutableAttributedString.init(string: "") components.forEach { mas.append($0.attributedString) } return mas } }

// 然后我们的初始化方法 extension NSAttributedString { convenience init(@NSAttributedStringBuilder _ builder: () -> NSAttributedString) { self.init(attributedString: builder()) } } ```

实际上运用@NSAttributedStringBuilder 之后会将这个闭包中的代码转换如下:

swift NSAttributedString { let a0 = Atext("老子").foregroundColor(UIColor.red) let a1 = Atext("明天不上班").foregroundColor(UIColor.blue) return NSAttributedStringBuilder.buildBlock(a0,a1) }

支持if语句

如果需要我们的DSL支持基本的条件判断呢?比如说如下:

```swift NSAttributedString{ Atext("老子").foregroundColor(UIColor.red) Atext("明天不上班").foregroundColor(UIColor.blue)

if true {
    Atext("哈哈").foregroundColor(UIColor.black)
    Atext("哈哈").foregroundColor(UIColor.black)
}

} ```

根据Apple上述提供的方法簇,我们需要实现buildOptional 方法。要注意的是buildBlock会直接作用在选择语句中,作用之后如下:

```swift NSAttributedString { let v0 = Atext("老子").foregroundColor(UIColor.red) let v1 = Atext("明天不上班").foregroundColor(UIColor.blue)

let v2: Component

if true {
    let v2_0 = Atext("哈哈").foregroundColor(UIColor.black)
    let v2_1 = Atext("哈哈").foregroundColor(UIColor.black)
    let v2_block = NSAttributedStringBuilder.buildBlock(v2_0,v2_1)
    v2 = NSAttributedStringBuilder.buildOptional(v2_block)
} else {
    v2 = NSAttributedStringBuilder.buildOptional(nil)
}
return NSAttributedStringBuilder.buildBlock(v0,v1,2)

} ```

所以buildOptional的参数类型必须是buildBlock的返回值类型,所以可以实现如下:

swift static func buildOptional(_ component: NSAttributedString?) -> Component { if component == nil { return Atext("") } else { return Atext(component!.string, attributes: component!.attributes(at: 0, effectiveRange: nil)) } }

通过这种方式,就可以在我们自定义的DSL中使用if语句了,可是使用if-else选择语句,选择switch语句,以及for-in循环语句都会添加相应的静态方法,接下来的就不详细赘述了,希望大家可以写代码实践一波。

风停了

说了这么多,就聊了聊ResultBuilder,后面本来还想聊一聊DSL的,可是最近没有很多时间,这个小系列暂时这样吧,后面有时间再聊聊DSL。

参考