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 的習慣,所以相當囉嗦。

在這段程式碼裡,可以看到陳述句都是滿滿的文字。雖然已經沒有甚麼多餘的資訊,但過多的文字還是不好閱讀。如果能用更簡潔、好懂的方式取代這個 API 的話,之後維護的工程師想必會更感謝你。

理想的語法

NSLayoutConstraint 的官方文件裡,其實就提供了一個用來描述約束條件的語法:

item1.attribute1 = multiplier × item2.attribute2 + constant

這個語法是用數學的等式,來表達兩個排版錨 (Layout anchor) 之間的關係。它用簡單的加號、乘號與等號等運算子取代文字描述,這樣除了減少語句長度之外,更可以突顯整個句子的目的。原本可能要靠搜尋來找跟排版有關的程式碼,現在只要掃一眼就可以辨認出來了。更棒的是,它已經被寫在官方的文件裡面了,所以學習的成本也降低許多。

要注意的是,這裡的單等號並不是指派運算子,而是描述左右比較關係的比較運算子。因此,我們需要把 = 改成 ==,像這樣:

由於整個句子不是命令句,而是描述句,所以我們的目標應該是用這個句子去建構約束條件,而不是去套用約束條件。

換句話說,整個 DSL 句子其實就是一種建構 NSLayoutConstraint 的方法。

那麼,要怎麼實作呢?

重載運算子

運算子的自訂是 Auto Layout DSL 的核心。而在跳到實作的部分之前,先來看看它的概念。

如果退一步來想的話,運算子本身其實就是為了 DSL 而存在的。比如說,1 + 2 其實是 add(1, 2) 的數學運算 DSL 寫法;"foo" + "bar" 則是 concatenate("foo", "bar") 的文字處理 DSL 寫法(註:非實際 Swift 原始碼)。我們所要做的,就只是再給 ==+* 等運算子新的意義(新的函數)而已。

由於要設計的 DSL 語法當中,用到的都是既有的運算子,所以我們並不需另外去定義運算子,只要重載它們就可以了。

首先,我們要使 == 這個運算子變成一個回傳 NSLayoutConstraint 的函數:

重載 == 之後,原本的:

就可以重寫成這樣了:

是不是很簡單呢?

但事情沒有這麼簡單就可以解決!除了 == 之外,我們還需要實作倍數與常數(*+)。問題是,現在 == 的參數是 NSLayoutAnchor,而它並無法儲存倍數與常數的資訊。所以,我們必須要找其他方式來實作。

建造者模式 (Builder pattern)

建造者模式是在 OOP 的一開始,由四人幫在他們的書中所提出的 (Gang of Four,1994,《Design Patterns: Elements of Reusable Object-Oriented Software》)。它跟工廠方法或建構式一樣,都是建構物件的方式。

一般在創造物件的時候,物件所需的資訊可以透過建構式輸入;或者等物件建構好之後,直接將資訊指派給物件的屬性。但當這些資訊變得複雜的時候,這兩種方法就會顯得很局限。

建造者模式提供了第三種做法:我們並不直接建構物件,而是先把所需的資訊集中到所謂的建造者上,再由建造者去建構物件。而由於建造者本身就只是簡單的資料結構,所以操作的彈性比直接操作物件要大得多。

用程式碼來說的話,大概是這樣:

Foundation 裡的 URLComponents 就是一個典型的創造者結構。我們把 schemehostpathqueryItems 等資訊餵給它,再用它的 url 屬性來從這些資訊創造出一個 URL 實體來。

用我們的 Auto Layout DSL 來說,== 相當於 build()item1.attribute1item2.attribute2 是建造者,而 *+ 則是用來更改建造者資訊的函數。所以,我們可以這樣寫:

接著,只要給 UIView 定義一些ConstraintBuilder 屬性:

就可以重現剛剛的寫法:

而現在,我們也可以實作 *+ 這兩個函數了:

如此一來,Auto Layout DSL 的語法已經完成了:

是不是很神奇呢?

啟動約束條件

現在,我們已經可以把這些 Auto Layout DSL 語句全部放進一個陣列裡,並一次啟動它們了:

或者直接把陣列寫在 NSLayoutConstraint.activate(_:) 的參數裡面,像這樣:

但方括號與括弧疊在一起,還是不夠美觀。有沒有辦法只寫一個括號呢?

可變數量參數 (Variadic parameters)

可變數量參數基本上就是一個陣列型別的參數,但當輸入引數 (Arguments) 的時候,我們用的不是陣列的寫法,而是把它當成不限數量、相同型別的參數來寫。

讓我們用可變數量參數,來定義一個新的 NSLayoutConstraint 靜態方法:

如此一來,就可以寫成這樣:

是不是更簡潔了呢?

不過,Apple 的工程師們仍有感於這種寫法的侷限(也覺得逗號有點多餘),所以提出了一個全新的功能——

建立者函數

建立者函數是一種特化的函數。它有回傳值,但使用者不用在函數裡回傳任何的值,因為它會自動去捕捉函數內所有沒有用到的值。邏輯上有點複雜,但用起來很簡單。比如說,如果有一個會搜集 NSLayoutConstraint 的建立者函數的話:

我們就可以用建造者函數,來改寫 activate(_:) 函數:

接著就可以把 DSL 寫成這樣:

這就是我們的 Auto Layout DSL 終極型態了。

建造者函數的功能還在測試階段,所以最後的模樣仍可能會改變。

結論

設計自己的 Swift DSL 是一個很有趣的過程。許多看似魔術一般的語法,實作起來其實並不會很複雜,重點在於要用合適的設計模式與語言功能。在本文的例子裡,我們就是用了運算子的重載功能、與建造者模式來做 DSL。我們用運算子取代函數,並透過暫時的結構體來傳遞資訊,就可以用最簡單的方式,實現理想中的 Auto Layout 語法。希望你在看完這篇文章後,能有更多實作各種 DSL 的靈感!


iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]

blog comments powered by Disqus
訂閲電子報

訂閲電子報

AppCoda致力於發佈優質iOS程式教學,你不必每天上站,輸入你的電子郵件地址訂閱網站的最新教學文章。每當有新文章發佈,我們會使用電子郵件通知你。

已收你的指示。請你檢查你的電郵,我們已寄出一封認證信,點擊信中鏈結才算完成訂閱。

Shares
Share This