SwiftUI 最厲害的地方是其與 Xcode 深度集成,可以實時刷新預覽,這將會改變 UI 的開發方式。另外其聲明式語法寫起來也挺方便。SwiftUI 的聲明式語法,本身就是 Swift 的語法,屬於語言內部 DSL。用了一些不太常見的語法特性,乍一看讓人覺得很神奇。DSL(Domain Specific Language) 的概念見附錄 1。

本文討論 SwiftUI 所用到的不太常見語法特性。只討論語法本身,SwiftUI 的意義,View 內部具體是如何渲染,之類的問題不會涉及。各小節內容如下,

  • some View
  • 省略 return
  • 鏈式調用
  • 屬性(Attribute)
  • @State,Property Delegates
  • 尾隨閉包(Trailing closure)
  • Function Builders
  • 附錄 1,DSL
  • 附錄 2,@dynamicMemberLookup 的實現流程
  • 參考

some View

參考了 SwiftUI 的一些初步探索 (一) 的一個小節。為了文章完整,我將其思路用自己語言重新寫了一遍,有洗稿嫌疑,特此註明。

struct ContentView : View {
var body: some View {
Text("Hello World")
}
}

SwiftUI 的 View 是對於 UI 應該是如何展示的一個數據描述,並非真正用於顯示的 View。現在的 iOS,底層會用 UIKit 實現,最終從數據描述的 View 生成真正的 UIView。

每個 View 的內容,就是其 body 屬性。返回值為 some View,這裡的 some 需要解釋一下。

public protocol View : _View {
associatedtype Body : View
var body: Self.Body { get }
}

SwiftUI 的 View 實現成協議,使用 associatedtype 關聯了一個 Body 類型。根據 Swift 的語法,帶有 associatedtype 的協議不能直接返回,只能作為類型約束。

// Error
// Protocol View can only be used as a generic constraint
// because it has Self or associated type requirements
func createView() -> View {
}

// OK
func createView<T: View>() -> T {
}

在 Swift 5.1 之前,要繞開 associatedtype 的限制,需要明確寫出真實類型。body 需要寫成。

struct ContentView : View {
var body: Text {
Text("Hello World")
}
}

但這樣寫的話,每次修改了 body 的返回值(比如將 Text 修改成 Button),就需要手動修改相應的類型,會很麻煩。其實我們真實關心返回值是否是 View, 不關心到底是 Text 還是 Button,不能這樣寫只是語法的限制。

為繞開這個限制,Swift 5.1 引入了 Opaque Result Types。寫成 some View 就保證返回值是個確定的 View,讓編譯器網開一面。值得注意的是,返回類型必須是確定的,比如下面分支代碼,不同分支返回不同的類型,就會編譯出錯。

// Error
// Function declares an opaque return type, but the return
// statements in its body do not have matching underlying types
let someCondition: Bool = false

var body: some View {
if someCondition {
return Text("Hello World")
} else {
return Button(action: {}) {
Text("Tap me")
}
}
}

省略 return

struct ContentView : View {
var body: some View {
Text("Hello World")
}
}

上面 body 的寫法還有個小細節,body 的返回值為 View,但是代碼當中並沒有寫 return, 似乎並沒有返回值。

這是 Swift 的語法特性,見 SE-0255。當函數體中只有單獨一個表達式,就會自動添加一個 return,返回這個表達式的值。上述代碼,相當於

struct ContentView : View {
var body: some View {
return Text("Hello World")
}
}

注意,只有函數體中是單獨表達式,才會自動添加 return。下面的代碼有兩個表達式,是錯的。

// Error
// Function declares an opaque return type, but has
// no return statements in its body from which to infer an underlying type
struct ContentView : View {
var body: some View {
Text("Hello World")
Text("Hello World")
}
}

另外單獨的表達式並不代表就只有一行代碼。例子中 ZStack、VStack、HStack 常寫成多行,但只是一個表達式。

假如用語法樹來說,就是函數體的語法樹只有一個子節點元素。見 ParseDecl.cpp

// If the body consists of a single expression, turn it into a return
// statement.
//
// But dont do this transformation during code completion, as the source
// may be incomplete and the type mismatch in return statement will just
// confuse the type checker.
if (!Body.hasCodeCompletion() && BS->getNumElements() == 1) {

鏈式調用

struct ContentView : View {
var body: some View {
Text("Hello World")
.bold()
.font(.title)
}
}

上述寫法連續用 bold, font 等函數來修改 Text 的值,這種寫法叫鏈式調用。實現的方式類似下面,通常是修改了數據後,返回自身。

struct MyText {
init(_ str: String) {
print("init")
}

func bold() -> MyText {
print("change bold")
return self
}

func font(_ font: Font?) -> MyText {
print("change font")
return self
}
}

// 可以寫成
MyText("Hello World").bold().font(.title)

OC 也可以實現這種點語法的連續調用,但會麻煩很多。其實不一定返回自身,只要返回一個對象就可以使用點語法了,只是返回自身很常見。比如下面代碼返回不同對象:

extension Int {
var hours : Date {
return Date(timeIntervalSinceNow: TimeInterval(self) * 3600.0)
}

var days : Date {
return Date(timeIntervalSinceNow: TimeInterval(self) * 3600.0 * 24.0)
}
}

extension Date {
var ago : Date {
return Date(timeIntervalSinceNow: -self.timeIntervalSinceNow);
}
}

// 可以寫成
3.days.ago
3.hours.ago

屬性(Attribute)

接下來,我們應該講述下面代碼中 @State 那個語法。

struct RoomDetail : View {
@State var zoomed = false
}

但講述前,先岔開一下,說一下 Attribute。在中文中,Property 和 Attribute 都被翻譯成屬性了。但兩者在 Swift 中是不同的概念。

struct FixedLengthRange {
var firstValue: Int // Property
let length: Int
}

@available(iOS 10.0, macOS 10.12, *) // Attribute
class MyClass {
// class definition
}

在 Swift 中,Property 是指對象中,firstValue、length 這種語法。而 Attribute 是指 @ 字元開頭的,類似 @available 這種語法。為了不產生誤導,下文會使用英文術語。

Swift 中有各種 Attribute,比如

@dynamicCallable
@dynamicMemberLookup
@available
@objc

Swift 的 Attribute 語法可以放到類型定義或者函數定義的前面,是對類型和函數的一種標記。

下面大致描述 Attribute 的原理,具體的實現細節可能會有出入。

編譯 Swift 源代碼時,在解析階段(Prase), 會生成一個抽象語法樹(AST,Abstract Syntax Tree)。語法樹生成時,所有的 Attribute 統一處理,生成 Attribute 節點。之後在語義分析階段(semantic analysis),會有可能觸發 Attribute 節點,使其對語法樹本身產生影響。

不同的 Attribute 對語法樹可以有不同的影響。比如 @available 會根據系統對語法樹的函數調用進行可行性檢查,不修改語法樹本身。而 @dynamicMemberLookup,@dynamicCallable 進行檢查後,可能會直接修改語法樹本身,從而轉調某些根據規則命名好的類或者函數。

Attribute 是種元編程(Metaprogramming)手段,Attribute 語法會被編譯成語法樹節點,而 Attribute 又可以反過來修改語法樹本身。在類定義或函數定義前添加不同的 Attribute,可以不同的方式修改語法樹,從而實現某些常規方式難以實現的語法。其實很好理解,既然都可以修改語法樹了,自然就可以通過 Attribute 實現神奇的語法。

假如修改 Swift 的源碼,可以根據不同的場合,很容易添加自定義 Attribute。比如 @UIApplicationMain 就是一個自定義 Attribute 擴展,為語法樹添加了入口 main 函數。因而用 swift 寫 iOS App, 是不用自己寫 main 函數的。

本文附錄 2,有 @dynamicMemberLookup 的實現流程,跟 Swift 語法本身沒有太大關係,但可增加對 Attribute 語法的理解。

@State,Property Delegates

struct RoomDetail : View {
@State var zoomed = false
}

現在可以來討論 @State 這個語法了,SwiftUI 用 @State 來維護狀態,狀態改變後,會自動更新 UI。類似的語法還有 @Binding,@@Environment 等。

這個語法特性看起來很神奇,叫 Property Delegates。

State 其實只是個自定義類,用 @propertyDelegate 修飾,將 zoomed 的讀寫轉到 State 實現了。其餘的 @Binding,@Environment 一樣的道理,將 Property 讀寫轉到 Binding 和 Environment 類實現了。

@propertyDelegate public struct State<Value>
@propertyDelegate public struct Binding<Value>
@propertyDelegate public struct Environment<Value>

我們先來弄明白為什麼需要 Property Delegates。

舉個例子,假設我有個 App, 需要在程序首次啟動時,顯示一個幫助信息。我將是否首次啟動記錄在 UserDefaults 中。為了防止寫錯 Key, 我可以這樣實現。

struct GlobalSettings {
static var isFirstLanch: Bool {
get {
return UserDefaults.standard.object(forKey: "isFirstLanch") as? Bool ?? false
} set {
UserDefaults.standard.set(newValue, forKey: "isFirstBoot")
}
}
}

GlobalSettings.isFirstLanch 調用了 UserDefaults.standard 的實現。這時我又想在 UserDefaults 保存另一個信息會怎麼辦呢,比如字體大小。複製粘貼修改,我們可以寫成

struct GlobalSettings {
static var uiFontValue: Float {
get {
return UserDefaults.standard.object(forKey: "uiFontValue") as? Float ?? 14
} set {
UserDefaults.standard.set(newValue, forKey: "uiFontValue")
}
}
}

可以看到 GlobalSettings.isFirstLanch 跟 GlobalSettings.uiFontValue 兩者代碼重複了。假如要保存多個值,就會重複 多次。為了避免重複代碼,可以將相同的行為指派某個代理對象去做,為此引入 Property Delegates。

@propertyDelegate
struct UserDefault<T> {
let key: String
let defaultValue: T

var value: T {
get {
return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}

struct GlobalSettings {
@UserDefault(key: "isFirstLanch", defaultValue: false)
static var isFirstLanch: Bool

@UserDefault(key: "uiFontValue", defaultValue: 14)
static var uiFontValue: Float
}

使用 @propertyDelegate 修飾了 UserDefault<T>, 它就可以作為代理對象。之後使用 @UserDefault 去修飾 Property,會自動定義出這個代理對象,將實現轉到這個對象了。將 isFirstLanch 展開,相當於:

struct GlobalSettings {
static var $isFirstLanch = UserDefault<Bool>(key: "isFirstLanch", defaultValue: false)
static var isFirstLanch: Bool {
get {
return $isFirstLanch.value
}
set {
$isFirstLanch.value = newValue
}
}
}

同理 @State 這個語法,也是 Property Delegates,將下面代碼展開,會變成下面樣子

struct RoomDetail : View {
@State var zoomed = false
}

// 展開相當於
struct RoomDetail : View {
var $zoomed = State<Bool>(initialValue: false)
var zoomed : Bool {
get {
return $zoomed.value
}
set {
$zoomed.value = newValue
}
}
}

使用 @State 修飾的狀態發生改變,SwiftUI 會再次調用 body, 處理界面的更新。這些具體實現都可以隱藏到 State<T> 的 value 讀寫當中。

手寫的代碼是不可能定義出 $zoomed 這種變數名字的。但在 @propertyDelegate 的實現當中,編譯器可以任意修改語法樹,插入任意節點,$ 開頭的變數名字讓編譯器用了。當 @State 修飾了 zoomed,就自動多了名字為 $zoomed,類型為 State<Bool> 的變數。這個變數可以跟 Toggle 之類的 View 綁定。

Toggle(isOn: $zoomed) {
Text("Favorites only")
}

尾隨閉包(Trailing closure)

var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Hello World")
Text("Hello SwiftUI")
Text("Hello Friends")
}
}

上面的代碼應用了有兩個語法特性。一個是尾隨閉包,另一個是 Function Builders。看 VStack 的定義

public struct VStack<Content> where Content : View {
@inlinable public init(alignment: HorizontalAlignment = .center,
spacing: Length? = nil,
content: () -> Content)
}

它的 init 函數,最後的參數是個 closure。Swift 的語法中,假如函數最後參數是個 closure,可以提到圓括弧外面。上述代碼相當於

VStack(alignment: .leading, spacing: 10, content: {
Text("Hello World")
Text("Hello SwiftUI")
Text("Hello Friends")
})

實際調用了 init 函數,生成一個 VStack 對象。另外 Swift 中,假如函數調用中只有一個參數,並且這個參數是個 closure,可以省略函數調用的圓括弧。於是

VStack {
Text("Hello World")
Text("Hello SwiftUI")
Text("Hello Friends")
}

實際上也是調用了 VStack 的 init 函數。注意 VStack 的 init 函數,某些參數有默認值,因而某些參數可以省略不寫。同理

ForEach(romes) { rom in
xxx
}

這種語法也是調用了 ForEach 的 init 函數,生成一個 ForEach 對象,ForEach 也是個 View。

Function Builders

public struct VStack<Content> where Content : View {
@inlinable public init(alignment: HorizontalAlignment = .center,
spacing: Length? = nil,
content: () -> Content)

VStack 的 init 函數中,closure 需要返回一個 Content。但是下面代碼根本就沒有返回值,為什麼編譯成功呢?

VStack {
Text("Hello World")
Text("Hello SwiftUI")
Text("Hello Friends")
}

這裡,明明有三個表達式,也不能省略 return。另外加入這樣寫,會編譯錯誤

// Error
// Closure containing a declaration cannot be used with function builder ViewBuilder
VStack {
Text("Hello World")
let a = 1
Text("Hello SwiftUI")
Text("Hello Friends")
}

錯誤信息中出現了 ViewBuilder,是什麼東西?

這個語法特性叫 Function Builders,還沒有正式添加到 Swift 語言中,只有草案。蘋果直接修改了 Swift 編譯器,SwiftUI 已經在用這個語法特性了。

VStack 的 init 介面中,其實缺少了@ViewBuilder,將其補充完整,實際是這樣。

@_functionBuilder public struct ViewBuilder {
public static func buildBlock() -> EmptyView
public static func buildBlock(_ content: Content) -> Content where Content : View
}

public struct VStack<Content> where Content : View {
public init(..., content: @ViewBuilder () -> Content)

ViewBuilder 結構使用了 @_functionBuilder 來修飾。而閉包使用 @ViewBuilder 來修飾,就會修改語法樹,轉調 ViewBuilder 的 buildBlock 函數。於是

VStack {
Text("Hello World")
Text("Hello SwiftUI")
Text("Hello Friends")
}

就相當於

VStack {
return ViewBuilder.builcblock(
Text("Hello World"),
Text("Hello SwiftUI"),
Text("Hello Friends")
)
}

Attribute 可以修改語法樹,幾乎什麼神奇的語法都可以實現。就算現存的語法特性不能實現想要的寫法,也可以添加新 Attribute,修改 Swift 編譯器,讓其實現。添加新 Attribute,修改語法樹這種大殺器,現在還只能通過修改 Swift 編譯器來實現。假如將這大殺器在語法層次暴露出去,讓開發者去自定義,幾乎就無所不能,可以讓 Swift 寫得面目全非,但也會引起混亂。

@_functionBuilder 這個語言特性,平時開發邏輯業務是不會用到的,但在寫 DSL 就會很有用。比如下面定義,

@_functionBuilder public struct HtmlBuilder {
public static func buildBlock() -> EmptyView
public static func buildBlock(_ content: Content) -> Content where Content : View
}

public div(..., content: @HtmlBuilder () -> HtmlNode)
public html(..., content: @HtmlBuilder () -> HtmlNode)
public p(..., content: @HtmlBuilder () -> HtmlNode)
public Text(_ str: String) -> HtmlNode

就可以寫出類似的代碼

hmtl {
div {
Text("Hello World")
Text("Hello World")

p {
Text("Hello World")
}
p {
Text("Hello World")
}
}
}

注: 已經有類似的 Html DSL 庫了,見Vaux。

附錄 1,DSL

DSL 是 Domain Specific Language (領域特定語言) 的縮寫。

DSL 是為解決某一個特定任務(領域),專門設計的計算機語言。比如字元串匹配是一個特定任務(有正則表達式),資料庫查找是一個特定任務(有 SQL),都可以設計專門的語言。DSL 選擇接近於特定任務的概念,概念甚至直接對應於某個關鍵字,因而解決特定領域的問題十分高效。但也正因為 DSL 選擇特定的概念,當超出了領域範圍時,在通用問題上反而表達能力有限。

DSL 通常跟某個宿主語言配合使用。宿主語言集成一個解釋器,解釋調用 DSL,特定的問題就使用 DSL 來描述。

考慮 DSL 和宿主語言的關係,有兩種不同的 DSL。假如 DSL 跟宿主語言是不同的,這個 DSL 就需要專門寫一個解釋器,稱為外部 DSL。SQL、正則表達式都是外部 DSL。而假如 DSL 和宿主語言是相同的語言,有著相同的語法(或者 DSL 語法是宿主語言的子集),這種 DSL 稱為叫內部 DSL。

內部 DSL 語法跟宿主語言一樣,語法上受到限制。但好處是不用專門去寫解釋器,跟宿主語言混寫,直接使用其編譯器(或者解釋器),會很方便。通常沒有特別註明,說到 DSL 都是指內部 DSL。

有些編程語言,本身語法很靈活(詭異),特別適合於寫內部 DSL。SwfitUI 的聲明式語法就是 DSL,本身是 Swift 的語法。除了 Swift 語言,比較適合寫內部 DSL 的語言還有。

  • C++, 比如 boost 中的一些庫。
  • Ruby, 比如 CocoaPods 用的 Podfile。
  • Lua, 比如 bgfx 的介面描述, 見bgfxidl。
  • Lisp,我不熟悉。

任何語言都可以寫 DSL,只是某些語言寫起來麻煩一些。比如 Objective-C,語法算很規矩(死板)了,也可以寫 Masonry 這種布局庫。

附錄 2,@dynamicMemberLookup 的實現流程

拿 @dynamicMemberLookup 為例,在 Attr.def 文件有這一行

SIMPLE_DECL_ATTR(dynamicMemberLookup, DynamicMemberLookup,
OnNominalType,
9)

SIMPLE_DECL_ATTR 表示 @dynamicMemberLookup 沒有參數,不像 @available 那樣可以有參數。第二個 DynamicMemberLookup 是名字,會將其解析稱 DynamicMemberLookupAttr 成。第三個參數就是表示 Attribute 可標記的位置,OnNominalType 表示在 struct、class、enum、protocol 的前面。9 是編號。

這樣加了一行後,就多了一個 Attribute,解析階段就可以順利進行,正確解析出節點。參見 ParseDecl.cpp

Parser::parseDecl
Parser::parseDeclAttributeList
Parser::parseDeclAttribute

之後在語義分析階段,會進行可行性檢查,檢查不過就編譯失敗。見TypeCheckAttr.cpp

AttributeChecker::visitDynamicMemberLookupAttr(DynamicMemberLookupAttr *attr)

同理 @available 就會觸發 visitAvailableAttr 函數。來到這裡,Attribute 的標準處理流程就結束了。不同的 Attribute 可以在任意不同的地方產生作用。具體到 @dynamicMemberLookup,就在 CSSimplify.cpp

ConstraintSystem::performMemberLookup() {
xxx
// Recursively look up `subscript(dynamicMember:)` methods in this type.
auto subscriptName =
DeclName(ctx, DeclBaseName::createSubscript(), ctx.Id_dynamicMember);
}

修改了語法樹節點,創建了一個下標函數節點,將點語法轉成了下標函數 subscript(dynamicMember member: String)。這樣就用 @dynamicMemberLookup 實現了一個高級的語法糖。

參考

  • SwiftUI 的一些初步探索 (一)
  • How @dynamicMemberLookup Works Internally in Swif
  • Inside SwiftUIs Declarative Syntaxs Compiler Magic

推薦閱讀:

相关文章