最近,SwiftUI 正如火如荼地在全世界進行公開測試。如果你也有經意或不經意地接觸到 SwiftUI,那你可能會發現,它在設定 View
性質的語法上,跟我們以前學過的很不一樣。
一般在設定物件的時候,我們通常是這樣寫的:
let imageView = UIImageView(image: myImage)
imageView.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
imageView.backgroundColor = .white
imageView.alpha = 0.5
但是在 SwiftUI 裡,我們卻得這樣寫:
Image(uiImage: myImage)
.frame(width: 100, height: 100)
.background(Color.white)
.opacity(0.5)
差別很大對不對?先不要管句型的細節,光就排版的美感來說,後者不只比前者簡潔許多,它的縮排也很清楚地告訴讀者:後面三行都是第一行的子句,重點還是在第一行。反之,前者的每一行縮排都一樣,看不出重點,而且每一句還都得重複寫一次 imageView
這個變數名,相當累贅。很明顯的,後者的可讀性比較高。
SwiftUI 所採用的這種寫法並不是什麼新的發明。事實上,著名的程式設計架構專家 Martin Fowler 早在 2005 年的時候就已經給它一個名字了,叫做 Fluent Interface ,或者流暢界面。為什麼這樣稱呼呢?首先,它是一種操作物件的界面,跟屬性界面一樣,是一種寫作風格,而非一種全面性的架構。再來,這種風格的重點就在於它的易讀性與流暢性。因為它不需要用一個暫時的變數來操作物件,所以寫出來的程式碼雜音很少。
在 Swift 5.1 以前
流暢界面本身其實是針對物件導向程式語言的寫作風格,所以不管是 Objective-C,還是一開始的 Swift 都可以把物件的界面設計成流暢界面。不過,這些語言並沒有像對屬性界面的支援一樣,特別去支援流暢界面。所以,如果要支援流暢界面的話,我們必須要手動去改每一個屬性。
舉例來說,如果原本的型別界面長這個樣子:
class Scene {
var title: String { get set }
// ...
}
那我們必須手動給每個屬性加上這樣的方法:
extension Scene {
func title(_ title: String) -> Scene {
self.title = title
return self
}
}
為什麼要回傳 self
呢?因為如此才能實現方法鏈 (Method Chaining),一個方法接一個方法地呼叫下去,而不用開新的陳述句。
// 不斷行
Scene().title("Scene 1").backgroundColor(.yellow)
// 斷行
Scene()
.title("Scene 1")
.backgroundColor(.yellow)
這樣的做法對開發者來說負擔不小,因為每次屬性界面一改,我們就得跟著去改流暢界面的方法。或許有天 Xcode 會內建自動產生流暢界面的功能,又或者某位熱心人士會寫出相關的 Xcode 擴充套件,但目前,我們還是得手動去同步兩種不同的界面。
但如果你用的 Swift 版本是 5.1 版以上的話,你還有另一個選擇。
Dynamic Member Lookup
在 4.2 版的時候,Swift 新增了一個比較少被提到的功能:Dynamic Member Lookup,或者動態成員查詢。這個功能基本上就是讓我們能夠用屬性語法,去存取原本需要用字串存取的值。比如說,如果原本的型別定義是這樣:
struct Person {
var info: [String: Any]
}
實作動態成員查詢的方式是這樣的:
// 1. 在型別定義前加上 @dynamicMemberLookup 關鍵字
@dynamicMemberLookup
struct Person {
var info: [String: Any]
// 2. 新增一個名為 subscript(dynamicMember:) 的下標方法
subscript(dynamicMember infoKey: String) -> Any? {
get {
return info[infoKey]
}
set {
info[infoKey] = newValue
}
}
}
接著,我們就可以像直接存取 Person
實體的屬性一樣,存取它的 info
屬性內容:
person.name = "Emilia" // 等於寫 personA.info["name"] = "Emilia"
print(person.name) // 等於寫 print(personA.info["name"])
這個功能主要是設計來支援跟 Python、Javascript、或 Ruby 等動態語言的互通性 (interoperatibility),算是比較少見的案例,所以也不多人談。但為什麼我會在這邊提到呢?
因為在 Swift 5.1 裡,這個功能升級了。
Swift 5.1 的 Key Path Member Lookup
在 Swift 5.1 中,除了字串之外,現在也可以用 key path 來當作動態成員查詢的媒介。
假設我們把 Person
的定義改成這樣:
struct Person {
struct Info {
var name: String
}
var info: Info
}
加上 Key path member lookup 的方式如下:
// 1. 一樣使用 @dynamicMemberLookup 關鍵字
@dynamicMemberLookup
struct Person {
struct Info {
var name: String
}
var info: Info
// 2. 把下標 subscript(dynamicMember:) 的參數型別改成 KeyPath
// 這邊因為要使界面是讀寫皆可,所以使用 WritableKeyPath 通用型別
// 通用型別 Value 指的則是查詢目標的型別
subscript<Value>(dynamicMember keyPath: WritableKeyPath<Info, Value>) -> Value {
get {
return info[keyPath: keyPath]
}
set {
info[keyPath: keyPath] = newValue
}
}
}
現在,我們可以這樣存取 person.info.name
了:
person.name = "Jackson"
print(person.name) // Jackson
Wrapper Type
如果你有開 Xcode 照著實作的話,可能會發現一件事:打出「person.
」之後,「name
」也會出現在自動完成的清單裡面!這是因為編譯器現在可以從 Key path 去查詢所有的目標、以及它們的型別了。正是因為如此,Key path 成員查詢的應用範圍比字串成員查詢還要大很多。
舉例來說,它最主要的設計目標,就是拿來給所謂的包裝型別 (Wrapper Type) 用:
// 基本上就是把 Person 的 Info 換成一個通用型別(Content)。
@dynamicMemberLookup
struct Wrapper<Content> {
var content: Content
subscript<Value>(dynamicMember keyPath: WritableKeyPath<Content, Value>) -> Value {
get {
return content[keyPath: keyPath]
}
set {
content[keyPath: keyPath] = newValue
}
}
}
// 可以直接把 Wrapper<Scene> 當成 Scene 來存取屬性
var scene2 = Wrapper(content: Scene())
scene2.title = "Scene 2"
你可能會問:這樣有什麼意義呢?
關鍵在於 subscript(dynamicMember:)
這個下標方法。當我們用 key path 成員查詢來存取 content
的任何屬性時,每次都會經過 subscript(dynamicMember:)
,所以我們有機會在這裡做額外的處理。另外一點很重要的是,subscript(dynamicMember:)
的回傳值是沒有限制型別的。綜合這兩點來說,所謂的包裝型別,其實就等於是一個轉換器。
比如說,我們可以定義一個專門回傳屬性型別的包裝型別,類似 type(of:)
的功能:
@dynamicMemberLookup
struct PropertyTypeInspector<Subject> {
let subject: Subject
// 這裡的回傳型別是一個 Metatype
subscript<Value>(dynamicMember keyPath: KeyPath<Subject, Value>) -> Value.Type {
return type(of: subject[keyPath: keyPath])
}
}
let inspector = PropertyTypeInspector(subject: Scene())
print(inspector.title) // String
其它的運用包括把值語義(Value Semantics) 轉換成參照語義 (Reference Semantics) 等,不過那已經超出本文的主題了。
等一下!本文的主題不是流暢界面嗎?Key path 成員查詢與包裝型別又跟流暢界面有什麼關係呢?
Wrapper Type 與 Fluent Interface
其實講到這邊,只剩下一步就可以把兩件事串起來了。
讓我們回頭看看流暢界面的實作方式:
class Scene {
// 屬性界面
var title: String { get set }
}
extension Scene {
// 流暢界面
func title(_ title: String) -> Scene {
self.title = title
return self
}
}
簡單來說,就是把屬性界面轉換成一個回傳 Self
的 Setter 方法。
而包裝型別的本質是什麼?轉換。
也就是說,我們可以設計一個包裝型別,去把所有可寫入的屬性都轉換成回傳 Self
的 Setter 方法:
@dynamicMemberLookup
struct FluentInterface<Subject> {
let subject: Subject
// 因為要動到 subject 的屬性,所以 keyPath 的型別必須是 WritableKeyPath
// 回傳值是一個 Setter 方法
subscript<Value>(dynamicMember keyPath: WritableKeyPath<Subject, Value>) -> ((Value) -> FluentInterface<Subject>) {
// 因為在要回傳的 Setter 方法裡不能更改 self,所以要把 subject 從 self 取出來用
var subject = self.subject
// subject 實體的 Setter 方法
return { value in
// 把 value 指派給 subject 的屬性
subject[keyPath: keyPath] = value
// 回傳的型別是 FluentInterface<Subject> 而不是 Subject
// 因為現在的流暢界面是用 FluentInterface 型別來串,而不是 Subject 本身
return FluentInterface(subject: subject)
}
}
}
接著,只要把任何實體用 FluentInterface
包起來,它的所有可寫入屬性就都會變成流暢界面了:
FluentInterface(subject: Scene()) // 把 Scene() 包進去
.title("Scene 3") // 流暢界面
.subject // 讀取更改後的內容物
怎麼辦到的?拆開來看就知道了:
let interface = FluentInterface(subject: Scene())
interface // 型別為 FluentInterface<Scene>
interface.title // (String) -> FluentInterface<Scene>
interface.title("Scene 3") // FluentInterface<Scene>
interface.title("Scene 3").subject // Scene
拿來改寫一開始的例子的話:
import UIKit
FluentInterface(subject: UIView())
.frame(CGRect(x: 0, y: 0, width: 100, height: 100))
.backgroundColor(.white)
.alpha(0.5)
.subject
我們只寫了一個型別,就省下了手動實作的許多麻煩,是不是心情都流暢起來了呢?
但說實在的,每次要用流暢界面的時候,都要把物件包起來再解開來,還是有點不舒爽。這兩個步驟雖然基本上是無法避免的,但我們可以想辦法讓它更流暢一點。
自訂運算子與你
自Swift 推出以來,就有自訂運算子 (Custom Operator) 這個頗有爭議的功能。它帶給開發者自行設計語法的自由度,但一旦被濫用的話,程式碼很容易變得一團亂,而且難以維護。簡單來說,它是一個需要小心使用的功能。
運算子可以拿來幹嘛呢?其實運算子不只包含加減乘除、或比較大小等數學操作,也可以拿來做任何其它事情。比如說,我們就可以用 !
來強制解開 Optional
。
同樣的道理,我們也可以設計用來包裝與解開 FluentInterface
的運算子。我的選擇是 +
與 -
,但你也可以嘗試別的組合。
// 原本 + 只被用在 infix,所以需要另外宣告為 postfix 運算子
postfix operator +
// 把任何實體用 FluentInterface 包裝起來的函數
postfix func + <T>(lhs: T) -> FluentInterface<T> {
return FluentInterface(subject: lhs)
}
// 同上
postfix operator -
// 把 FluentInterface 的內容取出的函數
// 也可以宣告成 FluentInterface 的 static 方法
postfix func - <T>(lhs: FluentInterface<T>) -> T {
return lhs.subject
}
如此宣告之後,一開始的例子就可以改成這樣了:
UIView()+ // 把 UIView 實體包進 FluentInterface 結構體
.frame(CGRect(x: 0, y: 0, width: 100, height: 100))
.backgroundColor(.white)
.alpha(0.5)- // 從 FluentInterface 結構體中取出 UIView 實體
是不是跟 SwiftUI 的風格幾乎一樣了呢?