淺談 JSBox 介面編輯器的實現
# JSBox 介面編輯器
JSBox 是我的一個個人專案,如果你還不知道是什麼: https:// sspai.com/post/42361
從第一個版本開始,JSBox 就支援通過 JavaScript 來寫介面,類似這樣:
$ui.render({ views: [ { type: "button", props: { title: "Hey!" }, layout: (make, view) => { make.center.equalTo(view.super); make.size.equalTo($size(100, 36)); }, events: { tapped: () => { console.log("Hey!"); } } } ] });
這幾行程式碼會在螢幕中間建立一個按鈕,點選之後控制檯會輸出 Hey!
,很簡單。
最近幾個月,我花了一點時間來給 JSBox 的介面系統造了個視覺化的編輯器,不是什麼新鮮玩意,簡單說就是可以通過拖拽來構造介面:


你也可以理解成一個移動端簡化版本的 Interface Builder,這邊文章就是來介紹這個編輯器背後的一些基本原理。
# 資料儲存
這樣一個編輯器的本質是什麼?是一個 JSON 編輯器罷了(當然,很多的編輯器用 XML 做的資料儲存),我們要做的事情只不過是提供一個所見即所得的 JSON 編輯器,這個編輯器可以編輯一些檢視的屬性,比如說:
- 長寬和位置
- 繫結的事件
- 顏色、字型
- 特有的一些屬性,比如 button 會有 title
這個編輯器能解析包含上述內容的一個 JSON 檔案,用視覺化的方式進行編輯,編輯完之後能夠將上述內容序列化後存起來。
現在有一個問題,JSON 只支援下面幾種資料:
- string.
- Numeric types.
- object.
- array.
- boolean.
- null.
假設我們要把一個 UIColor 存在裡面,要做什麼?序列化唄,通過 JSON 支援的方式表達出來。序列化的思路有兩種:
- 易於人讀,例如 "RGBA(255, 255, 255, 1)"
- 易於機器讀,例如:
{ "$type": "$color", "$props": { "red": 238, "green": 241, "blue": 241, "alpha": 1 } }
當然,這樣一個檔案我是不希望使用者去讀寫的,他應該用於依賴於介面編輯器去操作他(回想下,除了解決衝突你應該不會去手動改一個 storyboard 檔案),甚至不需要了解這背後的儲存是什麼樣的,於是易於機器讀是我考慮的。在我的實踐裡面,就是把所有的物件都封裝成了包含 $type
和 $props
的一個 JSON,通過簡單的 JSON 解析和寫入,完成了所有型別的讀寫。

# 檢視樹
這是整個編輯器中最基本的概念,因為本質上一個 View 就是一棵樹:
{ views: [ { views: [ ] } ] }
他可以巢狀多層下去,所以在這麼一個編輯器中會出現很多樹的節點訪問操作,需要熟練掌握遞迴和非遞迴實現。有時候需要訪問比較深的一個節點,有時候需要獲取一個節點所有的子孩子。
# 編輯器 UI 部分
這樣一個編輯器框架其實是整個專案中最簡單的部分。最核心的部分,你需要實現一個拖動手勢用於:
- 移動一個 View 的位置
- 調整一個 View 的大小
如果不考慮 Auto Layout 的話,非常簡單(但 JSBox 支援 Auto Layout,我在後面會解釋這個問題),唯一比較麻煩的是實現輔助線效果:

這個效果的實現思路是這樣的:
- 拖動開始之後收集一下目前螢幕上所有的矩形
- 拖動過程中,檢測拖動的方向
- 根據方向列舉收集到的矩形,如果發現靠近某一條邊,就主動靠過去
這是一個最基本的模型,在實際工程中有很多優化的點,比如說根據距離來計算權值,進而決定靠近哪個檢視比較好。另外一個問題是,在支援 Auto Layout 的場景下,還需要提醒使用者當前的 Frame 和 Auto Layout 出來的結果是否一致,否則要提供警告。
# 屬性編輯器
這個編輯器可以這樣編輯每個 View 的屬性:

其實實現這些東西並沒有多少技術難度,但是卻極為繁瑣,基本可以認為是體力活,但我們有必要通過簡單的協議來實現所有的檢視型別,比如說現在 JSBox 支援的 20 幾種檢視:

假設你已經實現了這些子編輯器:
- 顏色選擇器
- 字型選擇器
- 日期選擇器
- ...
這些都是體力活,能夠編輯單一的屬性。現在我們要歸類所有的檢視支援的屬性,有哪些型別,比如:
- 整數
- 浮點數
- 字串
- 顏色
- 字型
- 日期
- 各種列舉值
- ...
把這些收集起來是為了造一個對映表,比如說對於 button 他可能有這樣一個表:
- title: string
- titleColor: color
- font: font
- image: image
- ...
然後我們在編輯某一個具體的檢視時,就可以通過對映表動態的生成屬性編輯器支援的屬性。在具體的工程實踐中,會有很多麻煩的問題,比如說有些屬性是複合結構:
- UISegmentedControl 支援一個字串陣列
- UITableView 的 cell 本身是個 View
當然這些困難都存在於實踐的層面,並沒有引入多少新的概念,所以不是什麼大問題,花點時間都可以克服。
# 事件繫結
在上面的圖中,點按鈕會彈出一個視窗,那是因為這個按鈕綁定了一個事件。這個事情有一點點複雜,首先的一件事情是,每個介面檔案都實際上包括兩個檔案:
- views.json 用於定義介面
- actions.js 用於定義行為
有點像 iOS 裡面的 storyboard 和 swift 檔案的關係,雖然現在 JSBox 不支援動態的繫結這兩個檔案,但原理大致是一樣的。假設使用者把 button 的 tapped
繫結到了 tapped
函式,那麼在 views.json 裡面會生成這樣一個節點:
{ "tapped": { "$type": "$event", "$props": { "selector": "tapped" } } }
解析器遇到這樣一個節點,會把他解析成一個事件,然後在 actions.js 裡面找到他完成呼叫,這裡還涉及到一些更復雜的話題,我就只簡單提一下。在 JavaScriptCore 裡面,如果你把所有的函式都寫在 Global 下面,例如:
function tapped() { }
如果這些函式都在 Global,你可以很方便的呼叫,但是 JSBox 是支援模組化引入 js 檔案的,很多函式在別的 scope,這件事就變得稍微麻煩一點了。我在實際實踐中,會把函式繫結在 exports 上面,然後通過 module loader 把他載入進來,最後通過 JavaScriptCore 把事件轉發過去完成呼叫:
exports.tapped = () => { }

實際呼叫類似於引入了一個模組:
const actions = require("actions.js"); actions.tapped();
當然,JavaScriptCore 是沒有 module loader 的,你需要自己寫一個。另外有一個提升使用者體驗的點,就是列出 actions.js 裡面已經有的函式,使用者可以直接把他繫結起來。
實現這樣一個邏輯,你需要一個 Parser 用來把 actions.js 解析成一個 AST,比如 Acorn 或 Esprima,在這裡可以玩一下: https:// astexplorer.net/
acorn 會把 actions.js 生成這樣的一個 AST:
{ "type": "Program", "start": 0, "end": 24, "body": [ { "type": "FunctionDeclaration", "start": 0, "end": 24, "id": { "type": "Identifier", "start": 9, "end": 15, "name": "tapped" }, "expression": false, "generator": false, "params": [], "body": { "type": "BlockStatement", "start": 18, "end": 24, "body": [] } } ], "sourceType": "module" }
最後,你需要從這個 AST 中找出最外層的所有函式,他們就是使用者可以繫結的函式。如何把這樣的 parser 跑在 JavaScriptCore 上面?這又是另一個比較大的話題,之後有空再介紹。
# Auto Layout
如果只是支援 Frame 佈局的話,實現這樣一個編輯器其實是比較簡單的,但 JSBox 還做了對 Auto Layout 的支援:

首先,Auto Layout 的本質是多個一次的方程或者不等式:
- view1.attr1 = view2.attr2 * multiplier1 + constant1
- view1.attr2 >= view2.attr3 * multiplier2 + constant2
- ...
iOS 通過某一時刻的一些已知量來求解儘可能滿足所有式子的一組解,當不能滿足所有方程時通過 break 掉一些優先順序低的式子。如果出現多個解,或者沒有解,我們稱之為有歧義的佈局。
上面說的這些式子,就是約束,或者更具體點,在 iOS 上面就是很多的 NSLayoutConstraint。Auto Layout 有很多的實現方式,我們需要的,其實恰恰是最臭名昭著的這個 API:
+(instancetype)constraintWithItem:(id)view1 attribute:(NSLayoutAttribute)attr1 relatedBy:(NSLayoutRelation)relation toItem:(nullable id)view2 attribute:(NSLayoutAttribute)attr2 multiplier:(CGFloat)multiplier constant:(CGFloat)c;
為什麼?因為其他的 API 其實都是一些封裝,但是這個 API 它本身就是我們上面說的那個方程,它符合 a = b * m + c 的定義,非常適合我們的編輯器去解析和描述他。所以我們只要歸納一下約束的型別以及約束關係,就能製作出這樣一個編輯介面:

當然有些麻煩的事情要解決,比如上面說的介面編輯器,上面的檢視其實是通過 frame 完成佈局的,否則將會碰到更多的麻煩。這就涉及到一個如何將 Auto Layout 對映到一個具體的 Frame 佈局的問題。
另外,但使用者拖動了一個有約束的檢視時,會造成錯位的問題,可以通過類似的方式找出錯位並提供解決方案:

最後一個問題,是發現有歧義的佈局,什麼樣的佈局有歧義?
- 當前的約束無法唯一的描述一個佈局,例如缺少的對寬度的約束
- 當前的約束存在對同一個屬性產生不同的結果的狀況,例如三個約束無法同時滿足
知道什麼樣的約束是有歧義的,就可以求解出一組約束是不是有歧義,同時 UIView 有這麼幾個功能也可以幫助你:
- hasAmbiguousLayout
- exerciseAmbiguityInLayout
- constraintsAffectingLayoutForAxis
# 總結
以上就是 JSBox 介面編輯器的一些原理,功能很早就上線了,歡迎在 App Store 上面下載體驗。當然,這樣一個巨大的工程是不可能一下子就做得很好的,目前也只是剛剛能跑的階段,還需要很多優化工作。
這篇講到這裡,謝謝。