Auto Layout

Swift DSL 實作:利用 Swift UI 寫出簡單又明瞭的 Auto Layout DSL

Swift DSL 實作:利用 Swift UI 寫出簡單又明瞭的 Auto Layout DSL
Swift DSL 實作:利用 Swift UI 寫出簡單又明瞭的 Auto Layout DSL
In: Auto Layout, iOS App 程式開發, Swift 程式語言, SwiftUI 框架

今年可以說是 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 就是一個典型的創造者結構。我們把 schemehostpathqueryItems 等資訊餵給它,再用它的 url 屬性來從這些資訊創造出一個 URL 實體來。

用我們的 Auto Layout DSL 來說,== 相當於 build()item1.attribute1item2.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 的靈感!

作者
Hsu Li-Heng
iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。