Single Source of Truth:XCode + SwiftUI 的界面編輯的設計理念

語言: CN / TW / HK

       

本文為來自飛書 aPaaS Growth 研發團隊成員的文章,已授權 ELab 發佈。

aPaaS Growth 團隊專注在用户可感知的、宏觀的 aPaaS 應用的搭建流程,及租户、應用治理等產品路徑,致力於打造 aPaaS 平台流暢的 “應用交付” 流程和體驗,完善應用構建相關的生態,加強應用搭建的便捷性和可靠性,提升應用的整體性能,從而助力 aPaaS 的用户增長,與基礎團隊一起推進 aPaaS 在企業內外部的落地與提效。

背景 1:Define SSOT

Q: What is the meaning of Single Source of Truth (SSOT) in the context of SwiftUI?

A: With SwiftUI, you can either write the code pragmatically or use the design tool to edit the UI, which will also result in the SwiftUI code being modified. Essentially, you only have the source code, there's no separate design file (i.e. nib or Storyboard [1] (2016)), which means that there is no way your UI design and the code handling the UI can ever get out of sync (which was the case previously with nib files or storyboards).

http://stackoverflow.com/questions/58398373/what-is-the-meaning-of-single-source-of-truth-ssot-in-the-context-of-swiftui

背景 2:什麼是程序與語言

什麼是計算機程序

http://en.wikipedia.org/wiki/Computer_program

A computer program is a sequence or set of instructions in a programming language [2] for a computer [3] to [execute](http://en.wikipedia.org/wiki/Execution_(computing "execute")).

什麼是編譯型語言

http://zh.wikipedia.org/wiki/%E7%B7%A8%E8%AD%AF%E8%AA%9E%E8%A8%80

編譯語言(英語:Compiled language)是一種 編程語言 [4] 類型,通過 編譯器 [5] 來實現。它不像 解釋型語言 [6] 一樣,由解釋器將代碼一句一句執行,而是以編譯器,先將 代碼 [7] 編譯為 機器代碼 [8] ,再加以執行。理論上,任何編程語言都可以是編譯式,或直譯式的。它們之間的區別,僅與程序的應用有關。

什麼是解析型語言

http://zh.wikipedia.org/wiki/%E7%9B%B4%E8%AD%AF%E8%AA%9E%E8%A8%80

解釋型語言(英語:Interpreted language)是一種 編程語言 [9] 類型。這種類型的編程語言,會將代碼一句一句直接執行,不需要像 編譯語言 [10] (Compiled language)一樣,經過 編譯器 [11] 先行編譯為 機器代碼 [12] ,之後再執行。這種編程語言需要利用 解釋器 [13] ,在執行期,動態將代碼逐句解釋(interpret)為機器代碼,或是已經預先編譯為機器代碼的 子程序 [14] ,之後再執行。

編程語言應該符合的數學模型和編程語言的發展歷程

首先每門語言都應該至少具備圖靈完備性,至於什麼是圖靈完備可以參照我之前的一篇分享: 證明 JS 和 TS 類型編程是圖靈完備的 [15] ,簡而言之一門語言只要能實現三個基本函數和三個基本組合,那麼它就是圖靈完備的,一定能表達其它圖靈完備的語言所能表達的邏輯,注意這裏所説的是純邏輯,從數學的角度來看就是實現一個函數,輸入和輸出都屬於同一個域,有新的域必須在上下文增加輸入,例如設備/文件 IO 需要有機器指令支持。

編程語言都是形式語言( Formal Language [16] ),關於形式語言的研究早在計算機出現之前就由語言學家提出。

The first use of formal language is thought to be Gottlob Frege [17] 's 1879 Begriffsschrift [18] , meaning "concept writing", which described a "formal language, modeled upon that of arithmetic, for pure thought."([2])

要注意,圖靈是否完備跟語法是否簡單沒有直接關係,一門語法非常簡單但是圖靈完備的語言可以參照:http://esolangs.org/wiki/SNUSP ,一些我們常用的,我們看上去覺得表達力還不錯的語言它其實不是圖靈完備的(例如微信的 WXML,也例如 UIDL),簡單的原因是它們實現不了偏遞歸函數所必須具備的「最小化」操作,也就是它們實現不了 while 循環。

我們縱觀計算機語言的發展歷程,會發現計算機語言的語法是從簡單到複雜的一個過程,最開始我們用打孔卡來表示程序(http://en.wikipedia.org/wiki/Punched_card),這本質上就是一串數字,到後來發明了機器碼,基本上就是一些將數字和一些助記單詞一一對應,然後指令後面接內存地址這樣子來表達程序。再後來彙編添加了「過程」的概念,再後來我們有 Lisp 這樣的語言,語言裏添加了「表達式」的概念,後面 Lisp 的方言越來越多,添加了「函數」、「語句」的概念。再後來我們有較為正式的現代語言 C,裏面添加了塊、控制流這樣的概念。再後來,我們有 Java 這樣的面嚮對象語言,裏面添加了例如面向對象設計相關的概念(類、接口、封裝、多態、繼承),到現在我們有 ES 2022、Swift、Golang、Rust 多種多樣的語言,它們面向不同的領域有不同的語法和特性。

回顧整個過程,時候從簡單到複雜的過程,是從易學到難學的過程,是從表達力弱到表達力強的過程,是從不實用到實用的過程,是從易於解析到難以解析的過程。

以下純粹個人觀點:一門語言很難面面俱到,每門語言通常有它的適用場景,往往面臨類似 三元悖論 [19] 的場景(當然可以引入更多維度形成四五六七八九元悖論 ...),例如:

image.png

例如 Javascript 等解析型語言語法相對簡單,表達力又相對強的語言(支持面向對象編程,async await 語法,function as first citizen, ...),、但是它性能會相對弱,因為你不能控制鎖,無鎖就需要引入 eventloop,造成性能消耗,沒有內存控制就需要引入 GC,造成性能消耗。

又例如 Rust 引入了內存的 Ownership,語言的學習複雜度一下子上來了,但是性能上和在性能場景的表達力上也能做得更好。

背景 3:一個簡單的解析型語言實現

話説回來,再正式介紹 XCode + SwiftUI 之前,我需要介紹一門語言是如何解析的,我們用最簡單的語言之一的 Lisp 為例,用最簡單的自循環解析器來解釋。

image.png

假設我們有一個一段簡單的 Lisp 程序:

// 語義為:(60 * 9 / 5) + 32
(+ (* (/ 9 5) 60) 32)

那麼第一步解析器程序會執行 tokenize 程序,結果為:

[
"(",
"+",
"(",
"*",
"(",
"/",
"9",
"5",
")",
"60",
")",
"32",
")"
]

第二步執行 parse,將 token list 轉化為 AST:

[
"+",
[
"*",
[
"/",
9,
5
],
60
],
32
]

第三步就是解析 AST,解析的過程本質上是一個深度遞歸從底向上求值的過程,例如上述 AST 的求值過程是這樣子的:

// 輸入
[
"+", // <- 第一層指針,發現子屬性是數組,先求數組的值
[
"*", // <- 第二層指針,發現子屬性是數組,先求數組的值
[
"/", // <- 第二層指針,沒有子屬性是數組了,不需要遞歸了,在這裏求第一次值
9,
5
],
60
],
32
]

// 第一遍求值
[
"+",
[
"*",
1.8,
60
],
32
]

// 第二遍求值
[
"+",
108,
32
]

// 第三遍求值
140

一個完整的 Demo 可以參照:http://gist.github.com/Enichan/4a9fa87aef6405e13e1c072baa117beb

function interp(x, env) {
env = env || g;
if (typeof x === "string") { // symbol
return env.find(x)[x];
}
else if (!Array.isArray(x)) { // constant literal
return x;
}
else if (x[0] === "quote") { // (quote exp)
let exp = x[1];
return exp;
}
else if (x[0] === "if") { // (if test conseq alt)
let test = x[1], conseq = x[2], alt = x[3];
let exp = interp(test, env) ? conseq : alt;
return interp(exp, env);
}
else if (x[0] === "define") { // (define symbol exp)
let symbol = x[1], exp = x[2];
env[symbol] = interp(exp, env);
}
else if (x[0] === "set!") { // (set! symbol exp)
let symbol = x[1], exp = x[2];
return (env.find(symbol)[symbol] = interp(exp, env));
}
else if (x[0] === "eval") { // custom shenanigans
let exp = interp(x[1], env);
return interp(exp, env);
}
else if (x[0] === "lambda") { // (lambda (symbol...) body)
let parms = x[1], body = x[2];
return makeProc(parms, body, env);
}
else {
let proc = interp(x[0], env);
let args = x.slice(1).map(exp => interp(exp, env));
if (typeof proc !== "function") {
throw new Error("Expected function, got " + (proc || "").toString());
}
return proc.apply(proc, args);
}
}

同時,如果我們稍微加個 wrapper,就可以得到這個程序的調用棧了。

let stack = [];
function wrap(func) {
return function(...args) {
stack.push(args[0]);
let resp = func(...args);
stack.pop();
return resp;
};
}
interp = wrap(interp);

看來這裏,你知道 stackoverflow 異常是什麼東西了吧?本質意義上就是我們程序對 AST 做遞歸,遞歸的過程中不斷入棧,如果程序寫得不好,就會使得棧空間溢出。

怎麼樣,這種代碼的感覺是不是有種似曾相識的感覺?如果我們打開 kunlun-fe 的 parseComponentMeta.ts 這個文件,你會發現它也是個自循環解析器:

export function parseComponentMeta(
meta: ComponentMeta,
components: Components = {},
...
): JSX.Element {
const { name, type, children, events, selectors: selectorMeta } = meta;
...
const { props = {} } = meta;
const { key: propKey } = props;
if (propKey === undefined) {
// props = { ...props, key: name };
props.key = name;
}

props.__component_name__ = name;

let normalizedChildren = null;
if (typeof children === 'string') {
normalizedChildren = children;
} else if (Array.isArray(children) && children.length > 0) {
normalizedChildren = children. map ( ( childMeta ) =>
parseComponentMeta (
childMeta,
components,
connect,
stateKey,
payload,
componentCache,
selectors,
decorator,
),
);
}

if (isHostComponent(type)) {
return createElement(type, props, normalizedChildren);
}

let ComponentType = deepGet(components, type) as React.ComponentType;
if (ComponentType === undefined) {
window.console.error(type, ' is not found in components:', components);
ComponentType = NotFound;
}
if (events !== undefined && connect === undefined) {
throw new DangerousCustomErrorWithoutSensitiveMessage({
label: 'page-meta-engine',
message: '"connect" is required when "events" passed.',
});
}
if (selectorMeta !== undefined && connect === undefined) {
throw new DangerousCustomErrorWithoutSensitiveMessage({
label: 'page-meta-engine',
message: '"connect" is required when "selectors" passed.',
});
}
if (isInBlacklistOfConnect(type) || connect === undefined) {
return createElement(ComponentType, props, normalizedChildren);
}

// 做了一些 redux wrapping 相關的東西
return ...
}

換個角度來思考,kunlun 的 UI Meta 或者後續 UIDL 都是直接定義了一套 AST,然後實現了一個自循環解析器。

引申思考:

  1. 如果我現在需要用 UI Meta 或者 UIDL 來實現一個 infinite loading 的 list 組件,能實現嗎?如果你來擴展 UI Meta,不實現自定義 React Component 的話,你會引入什麼樣的 Meta 屬性,這些屬性的原子操作是怎麼樣的,你怎麼解析它?

  2. 如果我要為 UI Meta 添加條件渲染的功能,應該如何實現呢?

如果我們實現一個 JS 的解析器的話會不會很難呢?其實也不是很難,如果不考慮效率的話,實現 ES5 的語義我們只需要 1000 來行代碼就可以實現對 ES5 的 AST 的 eval。

http://github.com/axetroy/vm.js/blob/master/src/standard/es5.ts

我們 ES 的 AST 要比 Lisp 的細節豐富得多,且更易於理解,例如同一個表達式,ES 的表達式是這樣的:

http://astexplorer.net/#/gist/c40c85b756de9a4e10fb5bfe668fa000/ff2ab3d89b593035af82b97cddf72a68910dcab9

{
"type": "File",
},
"errors": [],
"program": {
"type": "Program",
},
"sourceType": "module",
"interpreter": null,
"body": [
{
"type": "ExpressionStatement",
},
"expression": {
"type": "BinaryExpression",
},
"left": {
"type": "BinaryExpression",
},
"left": {
"type": "BinaryExpression",
},
"left": {
"type": "NumericLiteral",
},
"extra": {
"rawValue": 60,
"raw": "60"
},
"value": 60
},
"operator": "*",
"right": {
"type": "NumericLiteral",
},
"extra": {
"rawValue": 9,
"raw": "9"
},
"value": 9
}
},
"operator": "/",
"right": {
"type": "NumericLiteral",
},
"extra": {
"rawValue": 5,
"raw": "5"
},
"value": 5
},
"extra": {
"parenthesized": true,
"parenStart": 0
}
},
"operator": "+",
"right": {
"type": "NumericLiteral",
},
"extra": {
"rawValue": 32,
"raw": "32"
},
"value": 32
}
}
}
],
"directives": []
},
"comments": []
}

XCode + SwiftUI 的界面設計的體驗

Screen Recording 2022-07-07 at 01.01.20.2022-09-01 21_30_32.gif

只要大家看完了背景一二三之後,理解它的原理起來應該還是相對簡單的:

  1. XCode 在編輯實現 protocal View 的結構體的時候,會在編輯的同時把代碼走一遍編譯流程然後丟到虛擬機運行,同時執行實現 protocal PreviewProvider 的結構體,在執行的過程中可以知道:

    1. AST

    2. 對應棧

  2. 在鼠標點擊對應的具體代碼的時候,通過 AST 的文本範圍信息可以反射出來是 AST 的哪個塊,也就找到了對應 UI 的實例的值和類型信息。

  3. 反之也是一樣的,點擊視圖組件的時候,可以反射出具體的 AST 塊,通過文本範圍信息也就找到了對應的代碼。

  4. 修改值的時候,通過 3 的對應邏輯修改 AST 的值,通過 Code Generator 輸入 AST 生成代碼刷新代碼。

  5. 在進行變更的時候都重新走一次 hot compile 和運行,實現預覽,這個速度比想象中的會快。

  1. Code Out:輸出的是代碼,能夠走編譯,不要看小基於 LLVM 架構的語言(Swift、Golang、C++,Rust),性能會比解析好的多得多。

  2. Swift as SSOT:

    1. 永遠可以手寫代碼,可視化編程的語義基於必然是永遠是現代化編程語言的子集,在遇到複雜場景永遠可以用手寫代碼作為 fallback。

    2. 一門語言描述所有的東西,包括界面,在假設上述 a 會發生的場景下,用户不需要學習兩套語法兩套設計模式,NoCode 和 ProCode 可以隨時切換,XCode 8 的 Storyboard(2016) 就是一個基於 XML 語法的的 NoCode 方案,但是今天 XCode 主推 SwiftUI 肯定有它的理由(有效市場假説)。

  3. 利於開放生態和與生態結合:

    1. 例如 Copilot 就很硬核。

    2. 例如各種 Dependency Analyzer

    3. 例如 http://marketplace.visualstudio.com/items?itemName=thankcreate.power-fsm-viewer

    4. ...

    5. 任何人可以基於你的語法開發包,你也可以引這些包,實現了 protocal View 都可以想用同樣的 UI Inspector/Editor,其實在遊戲行業的遊戲引擎這種操作是司空見慣的,可以參照 Unity/UE 5/Cocos 這些成熟的,支持插件和有良好反射機制的遊戲引擎。

    6. 任何人可以基於開源的 Lang Server 做進一步的擴展,添加更多的可視化編輯模式,例如狀態機的編輯模式、例如工作流的編輯模式,最終都是生成代碼,也同樣可以 Code Out 和 Compile。

    7. ...

參考資料

[1]

Storyboard: http://www.raywenderlich.com/5055364-ios-storyboards-getting-started

[2]

programming language: http://en.wikipedia.org/wiki/Programming_language

[3]

computer: http://en.wikipedia.org/wiki/Computer

[4]

編程語言: http://zh.wikipedia.org/wiki/%E7%A8%8B%E5%BC%8F%E8%AA%9E%E8%A8%80

[5]

編譯器: http://zh.wikipedia.org/wiki/%E7%B7%A8%E8%AD%AF%E5%99%A8

[6]

解釋型語言: http://zh.wikipedia.org/wiki/%E7%9B%B4%E8%AD%AF%E8%AA%9E%E8%A8%80

[7]

代碼: http://zh.wikipedia.org/wiki/%E7%A8%8B%E5%BC%8F%E7%A2%BC

[8]

機器代碼: http://zh.wikipedia.org/wiki/%E6%A9%9F%E5%99%A8%E7%A2%BC

[9]

編程語言: http://zh.wikipedia.org/wiki/%E7%BC%96%E7%A8%8B%E8%AF%AD%E8%A8%80

[10]

編譯語言: http://zh.wikipedia.org/wiki/%E7%B7%A8%E8%AD%AF%E8%AA%9E%E8%A8%80

[11]

編譯器: http://zh.wikipedia.org/wiki/%E7%B7%A8%E8%AD%AF%E5%99%A8

[12]

機器代碼: http://zh.wikipedia.org/wiki/%E6%A9%9F%E5%99%A8%E7%A2%BC

[13]

解釋器: http://zh.wikipedia.org/wiki/%E7%9B%B4%E8%AD%AF%E5%99%A8

[14]

子程序: http://zh.wikipedia.org/wiki/%E5%AD%90%E7%A8%8B%E5%BC%8F

[15]

證明 JS 和 TS 類型編程是圖靈完備的: http://bytedance.feishu.cn/docs/doccnRnXc5HMxIPzUKq91mbfRph

[16]

Formal Language: http://en.wikipedia.org/wiki/Formal_language

[17]

Gottlob Frege: http://en.wikipedia.org/wiki/Gottlob_Frege

[18]

Begriffsschrift: http://en.wikipedia.org/wiki/Begriffsschrift

[19]

三元悖論: http://zh.wikipedia.org/wiki/%E4%B8%89%E5%85%83%E6%82%96%E8%AE%BA

- END -

:heart: 謝謝支持

以上便是本次分享的全部內容,希望對你有所幫助^_^

喜歡的話別忘了 分享、點贊、收藏 三連哦~。

歡迎關注公眾號 ELab團隊 收貨大廠一手好文章~

aPaaS Growth 團隊專注在用户可感知的、宏觀的 aPaaS 應用的搭建流程,及租户、應用治理等產品路徑,致力於打造 aPaaS 平台流暢的 “應用交付” 流程和體驗,完善應用構建相關的生態,加強應用搭建的便捷性和可靠性,提升應用的整體性能,從而助力 aPaaS 的用户增長,與基礎團隊一起推進 aPaaS 在企業內外部的落地與提效。