看看SwiftUI通过什么来实现的:Result Builder
起风了
书接上文,上篇文章中,我们已经知道了@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
public typealias Body = Never
}
// 实际实现方式(来源WWDC21 Session 10253)
struct VStack
没错,确实是我们熟悉的初始化方法,传入了一个闭包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
看来关键就是@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。
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我们已经掌握了好多门新语言了),所以嵌入式DSL(DSL详文下篇描述)就是一个必然的选择了,也就是将上述类型的实现以某种形式嵌入到我们的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类型之后,可以在两种不同位置的地方使用该注解:
一、第一种是func
,var
,subscript
的声明上。
而对于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 #availablebuildFinalResult(_ 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。
参考
- 我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。