看看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。

參考