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

3,369 阅读12分钟

起风了

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

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,至少一部分问题就可以豁然开朗了:

// 暴露的API
@frozen public struct VStack<Content> : 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<Content: View>: View {
...
		init(@ViewBuilder content: () -> Content) {
				self.content = content
		}
...
}

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

清风袭来

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

@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, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0 : View, C1 : View, C2 : View
}
...

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

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

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

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,使用起来和下面很类似:

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的,但是这种情况是有限的,如果有其他不同的类型,那将造成很大的麻烦
  • 如果改变了上述的层级中的某个元素,比如动态展示文本,那事情又将会变得复杂
division((useChapterTitles ? [header1("Chapter 1. Loomings.")] : []) +
    [paragraph(["Call me Ishmael. Some years ago"]),
     paragraph(["There is now your insular city"])])

……

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

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组合起来,如下:

// 初始源码
@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的实现时添加注解,是表明该注解直接作用在此方法上,实例如下:

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中通常我们使用的都是闭包。

// 作用在函数类型的方法参数上
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协议,用来声明字符串和字符属性,并将它们转化为富文本。

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])
    }
 }

基础版本

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

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

@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** 之后会将这个闭包中的代码转换如下:

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

支持if语句

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

NSAttributedString{
   Atext("老子").foregroundColor(UIColor.red)
	Atext("明天不上班").foregroundColor(UIColor.blue)
		
	if true {
		Atext("哈哈").foregroundColor(UIColor.black)
		Atext("哈哈").foregroundColor(UIColor.black)
	}
}

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

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的返回值类型,所以可以实现如下:

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。

参考