Swift DSL 實作:利用 Swift UI 寫出簡單又明瞭的 Auto Layout DSL
今年可以說是 Swift DSL 元年,建造者函數 (Builder functions) 與 SwiftUI 讓開發者們看到在 Swift 內設計內嵌 DSL 的各種可能性。雖然這樣說,但 Swift 一直以來都提供了許多 DSL 實作的功能,只是還沒有出現在官方框架而已。舉例來說,我們可以利用自訂運算子 (Operators) 與下標 (Subscripts) 等功能,來寫出簡單又明瞭的 DSL。
DSL:領域專用語言 (Domain-specific Language),指針對某個問題領域特化的語言。比如說 HTML 就是針對網頁結構的語言,JSON 是針對資料結構的語言等等。相較於通用目的語言 (GPL 或 General-purpose Language,如 Swift 本身),DSL 的語法會更簡潔,邏輯更簡單。
那麼,甚麼地方會用得到 DSL 呢?其實任何的問題領域 (Problem domain) 都有使用 DSL 的潛力,像處理排版問題的 Auto Layout 就是一個好例子。
問題:囉唆的 Auto Layout API
Auto Layout 本身其實就是一套宣告式的系統。透過定義一個個的約束條件,它會自動去處理所有的狀態變更事件。但是,它的 API 是延續自 Objective-C 的習慣,所以相當囉嗦。
// 創造 viewA 與 viewB 之間的約束條件。 let constraints = [ viewA.topAnchor.constraint(equalTo: viewB.topAnchor, constant: 8), // ... ] // 啟動約束條件。 NSLayoutConstraint.activate(constraints)
在這段程式碼裡,可以看到陳述句都是滿滿的文字。雖然已經沒有甚麼多餘的資訊,但過多的文字還是不好閱讀。如果能用更簡潔、好懂的方式取代這個 API 的話,之後維護的工程師想必會更感謝你。
理想的語法
在 NSLayoutConstraint
的官方文件裡,其實就提供了一個用來描述約束條件的語法:
item1.attribute1 = multiplier × item2.attribute2 + constant
這個語法是用數學的等式,來表達兩個排版錨 (Layout anchor) 之間的關係。它用簡單的加號、乘號與等號等運算子取代文字描述,這樣除了減少語句長度之外,更可以突顯整個句子的目的。原本可能要靠搜尋來找跟排版有關的程式碼,現在只要掃一眼就可以辨認出來了。更棒的是,它已經被寫在官方的文件裡面了,所以學習的成本也降低許多。
要注意的是,這裡的單等號並不是指派運算子,而是描述左右比較關係的比較運算子。因此,我們需要把 =
改成 ==
,像這樣:
item1.attribute1 == multiplier × item2.attribute2 + constant
由於整個句子不是命令句,而是描述句,所以我們的目標應該是用這個句子去建構約束條件,而不是去套用約束條件。
// 僅創造出一個 NSLayoutConstraint 而沒有啟動它。 let constraint = (item1.attribute1 == multiplier × item2.attribute2 + constant)
換句話說,整個 DSL 句子其實就是一種建構 NSLayoutConstraint
的方法。
那麼,要怎麼實作呢?
重載運算子
運算子的自訂是 Auto Layout DSL 的核心。而在跳到實作的部分之前,先來看看它的概念。
如果退一步來想的話,運算子本身其實就是為了 DSL 而存在的。比如說,1 + 2
其實是 add(1, 2)
的數學運算 DSL 寫法;"foo" + "bar"
則是 concatenate("foo", "bar")
的文字處理 DSL 寫法(註:非實際 Swift 原始碼)。我們所要做的,就只是再給 ==
、+
與 *
等運算子新的意義(新的函數)而已。
由於要設計的 DSL 語法當中,用到的都是既有的運算子,所以我們並不需另外去定義運算子,只要重載它們就可以了。
首先,我們要使 ==
這個運算子變成一個回傳 NSLayoutConstraint
的函數:
// 從 == 左邊與右邊的排版錨產生約束條件。 func == <T>(lhs: NSLayoutAnchor<T>, rhs: NSLayoutAnchor<T>) -> NSLayoutConstraint { return lhs.constraint(equalTo: rhs) }
重載 ==
之後,原本的:
viewA.topAnchor.constraint(equalTo: viewB.topAnchor) // 產生約束條件
就可以重寫成這樣了:
viewA.topAnchor == viewB.topAnchor // 產生約束條件
是不是很簡單呢?
但事情沒有這麼簡單就可以解決!除了 ==
之外,我們還需要實作倍數與常數(*
與 +
)。問題是,現在 ==
的參數是 NSLayoutAnchor
,而它並無法儲存倍數與常數的資訊。所以,我們必須要找其他方式來實作。
建造者模式 (Builder pattern)
建造者模式是在 OOP 的一開始,由四人幫在他們的書中所提出的 (Gang of Four,1994,《Design Patterns: Elements of Reusable Object-Oriented Software》)。它跟工廠方法或建構式一樣,都是建構物件的方式。
一般在創造物件的時候,物件所需的資訊可以透過建構式輸入;或者等物件建構好之後,直接將資訊指派給物件的屬性。但當這些資訊變得複雜的時候,這兩種方法就會顯得很局限。
建造者模式提供了第三種做法:我們並不直接建構物件,而是先把所需的資訊集中到所謂的建造者上,再由建造者去建構物件。而由於建造者本身就只是簡單的資料結構,所以操作的彈性比直接操作物件要大得多。
用程式碼來說的話,大概是這樣:
// 產生一個 view 的建造者。 var builder = ViewBuilder() // 給建造者資訊。 builder.frame = rect builder.backgroundColor = .white // 由建造者建構 view。 let view = builder.build()
在
Foundation
裡的URLComponents
就是一個典型的創造者結構。我們把scheme
、host
、path
與queryItems
等資訊餵給它,再用它的url
屬性來從這些資訊創造出一個URL
實體來。
用我們的 Auto Layout DSL 來說,==
相當於 build()
,item1.attribute1
與 item2.attribute2
是建造者,而 *
與 +
則是用來更改建造者資訊的函數。所以,我們可以這樣寫:
// 用 AnchorType 來限制錨之間只有同種類才能互動。 struct ConstraintBuilder<AnchorType> { var item: UIView var attribute: NSLayoutConstraint.Attribute var constant: CGFloat var multiplier: CGFloat } // 由 == 兩邊的 ConstraintBuilder 建構出一個約束條件。 func == <T>(lhs: ConstraintBuilder<T>, rhs: ConstraintBuilder<T>) -> NSLayoutConstraint { return NSLayoutConstraint( item: lhs.item, attribute: lhs.attribute, relatedBy: .equal, toItem: rhs.item, attribute: rhs.attribute, multiplier: rhs.multiplier / lhs.multiplier, constant: rhs.constant - lhs.constant ) }
接著,只要給 UIView
定義一些ConstraintBuilder
屬性:
extension UIView { var top: ConstraintBuilder<NSLayoutYAxisAnchor> { return ConstraintBuilder( item: self, attribute: .top, constant: 0, multiplier: 1 ) } }
就可以重現剛剛的寫法:
viewA.top == viewB.top // 產生約束條件
而現在,我們也可以實作 *
與 +
這兩個函數了:
// 更動 * 右邊的 ConstraintBuilder。 func * <T>(lhs: CGFloat, rhs: ConstraintBuilder<T>) -> ConstraintBuilder<T> { var builder = rhs builder.multiplier *= lhs return builder } // 更動 + 左邊的 ConstraintBuilder。 func + <T>(lhs: ConstraintBuilder<T>, rhs: CGFloat) -> ConstraintBuilder<T> { var builder = lhs builder.constant += rhs return builder }
如此一來,Auto Layout DSL 的語法已經完成了:
viewA.top == 2.0 * viewB.top + 20.0 // 產生一個 NSLayoutConstraint 實體。
是不是很神奇呢?
啟動約束條件
現在,我們已經可以把這些 Auto Layout DSL 語句全部放進一個陣列裡,並一次啟動它們了:
let constraints = [ viewA.top == viewB.top + 20, viewA.leading == viewB.leading + 8, viewB.bottom == viewA.bottom + 20, viewB.trailing == viewA.trailing + 8 ] NSLayoutConstraint.activate(constraints)
或者直接把陣列寫在 NSLayoutConstraint.activate(_:)
的參數裡面,像這樣:
NSLayoutConstraint.activate([ viewA.top == viewB.top + 20, viewA.leading == viewB.leading + 8, viewB.bottom == viewA.bottom + 20, viewB.trailing == viewA.trailing + 8 ])
但方括號與括弧疊在一起,還是不夠美觀。有沒有辦法只寫一個括號呢?
可變數量參數 (Variadic parameters)
可變數量參數基本上就是一個陣列型別的參數,但當輸入引數 (Arguments) 的時候,我們用的不是陣列的寫法,而是把它當成不限數量、相同型別的參數來寫。
讓我們用可變數量參數,來定義一個新的 NSLayoutConstraint
靜態方法:
// 使用可變數量參數。 func activate(_ constraints: NSLayoutConstraint...) { // 確保所有有參與的 view 都會使用自訂的約束條件。 constraints.forEach { ($0.firstItem as? UIView)?.translatesAutoresizingMaskIntoConstraints = false ($0.secondItem as? UIView)?.translatesAutoresizingMaskIntoConstraints = false } // 啟動所有約束條件 NSLayoutConstraint.activate(constraints) }
如此一來,就可以寫成這樣:
activate( viewA.top == viewB.top + 20, viewA.leading == viewB.leading + 8, viewB.bottom == viewA.bottom + 20, viewB.trailing == viewA.trailing + 8 )
是不是更簡潔了呢?
不過,Apple 的工程師們仍有感於這種寫法的侷限(也覺得逗號有點多餘),所以提出了一個全新的功能——
建立者函數
建立者函數是一種特化的函數。它有回傳值,但使用者不用在函數裡回傳任何的值,因為它會自動去捕捉函數內所有沒有用到的值。邏輯上有點複雜,但用起來很簡單。比如說,如果有一個會搜集 NSLayoutConstraint
的建立者函數的話:
@ConstraintBuilder func build() -> [NSLayoutConstraint] { // 不必寫 return 等關鍵字,這些 NSLayoutConstraint 就會被捕捉起來,轉成一個 [NSLayoutConstraint]。 constraint1 constraint2 constraint3 } build() // 產生 [constraint1, constraint2, constraint3]。
我們就可以用建造者函數,來改寫 activate(_:)
函數:
func activate(@ConstraintBuilder makeConstraints: () -> [NSLayoutConstraint]) { // ... let constraints = makeConstraints() NSLayoutConstraint.activate(constraints) }
接著就可以把 DSL 寫成這樣:
// 閉包裡每一行產生的 NSLayoutConstraint 都會被捕捉起來。 activate { viewA.top == viewB.top + 20 viewA.leading == viewB.leading + 8 viewB.bottom == viewA.bottom + 20 viewB.trailing == viewA.trailing + 8 }
這就是我們的 Auto Layout DSL 終極型態了。
建造者函數的功能還在測試階段,所以最後的模樣仍可能會改變。
結論
設計自己的 Swift DSL 是一個很有趣的過程。許多看似魔術一般的語法,實作起來其實並不會很複雜,重點在於要用合適的設計模式與語言功能。在本文的例子裡,我們就是用了運算子的重載功能、與建造者模式來做 DSL。我們用運算子取代函數,並透過暫時的結構體來傳遞資訊,就可以用最簡單的方式,實現理想中的 Auto Layout 語法。希望你在看完這篇文章後,能有更多實作各種 DSL 的靈感!