看看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。
參考
- 我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿。