歡迎光臨
每天分享高質量文章

從 SwiftUI 談宣告式 UI 與型別系統

來自公眾號:知識小集

作者 | Cyandev,阿裡巴巴釘釘事業部,主要負責 Mac 端開發

Hello, SwiftUI

Apple 在 WWDC19 上正式發佈了 Project Catalyst(原 Marzipan),使得開發者能夠將 iPadOS app 移植到 macOS 上。同時 SwiftUI 也壓軸亮相,正式統一了 Apple 全平臺的 UI 開發解決方案。恰逢前些時候,Google 在其 I/O 大會上亮相了 Jetpack Compose —— 一個全新的 Android 原生 UI 開發框架,標志著兩大移動操作系統陣營全面擁抱宣告式 UI 開發樣式。

宣告式 UI 的前世今生

其實宣告式 UI 並不是什麼新技術,早在 2006 年,微軟就已經發佈了其新一代界面開發框架 WPF,其採用了 XAML 標記語言,支持雙向資料系結、可復用模板等特性。

2010 年,由諾基亞領導的 Qt 團隊也正式發佈了其下一代界面解決方案 Qt Quick,同樣也是宣告式,甚至 Qt Quick 起初的名字就是 Qt Declarative。QML 語言同樣支持資料系結、模塊化等特性,此外還支持內置 JavaScript,開發者只用 QML 就可以開發出簡單的帶交互的原型應用。

宣告式 UI 框架近年來飛速發展,並且被 Web 開髮帶向高潮。React 更是為宣告式 UI 奠定了堅實基礎並一直引領其未來的發展。隨後 Flutter 的發佈也將宣告式 UI 的思想成功帶到移動端開發領域…

宣告式到底是什麼

想象我們要實現下麵這個界面:

打開開關就讓下麵的 label 顯示 on,反之顯示 off。如果我們要用非宣告式的方式實現,即命令式,那麼需要:

  1. 創建一個 UISwitch,設置它的 change 事件 handler

  2. 創建一個 UILabel

  3. 創建一個 UIStackView,設置方向為垂直

  4. 將 1、2 創建的兩個視圖添加到 UIStackView 中

  5. change 事件觸發時讀取開關的當前狀態,設置相應字串到 label 中

這樣做面對一個狀態,我們尚且能夠正確處理,但隨著應用日漸複雜,狀態也越來越多並且錯綜複雜,狀態變化的順序甚至也能影響應用邏輯的正確性,因為我們對每個事件的處理都是對界面的增量修改。一旦前一個狀態有錯誤,後面就會錯上加錯,接下來多執行緒混入,然後 boom,你的應用可能就 crash 了。

宣告式的意思就是讓我們描述我們需要一個什麼樣的界面,而不是告訴計算機一步一步乾什麼。那麼上面的例子用宣告式就是這樣:

“我需要一個界面,它是一個 VStack(垂直佈局),裡面有一個開關,開關的值與 switchValue 的布林值系結,VStack 里接下來是一個 Text,它的值當 switchValue 為 true 時是 foo,否則是 bar”

我們可以發現,全文沒有命令,都是在描述界面是怎樣的。switchValue 我們稱之為 “The Source of Truth”,Toggle 的狀態、Text 的文本內容都與它相系結。狀態變化時,界面按照先前描述的重新“渲染”即可得到狀態絕對正確的界面。這正是宣告式的優勢所在,降低狀態增加時界面維護的複雜度。

SwiftUI 與其他框架的異同

SwiftUI 自亮相以來,全網就在討論其與 React、Flutter 之間的關係云云。經過這兩天的研究,我想簡單談談我的觀點:(免責宣告:沒有看過原始碼,也沒有參與現場 Lab,一切都是個人想法)

首先是與 Flutter 的對比,Flutter 的思路是從 0 開始,即語言、基礎庫、渲染引擎、排版引擎即框架本身全部由自己實現,其渲染引擎 Skia 只需要操作系統為止提供一個 GL Context 便可以完成所有圖形渲染,這使得其跨平臺性變得十分強大,到目前為止 Windows、Linux、macOS、Fuchsia 都已經得到了 Flutter 官方的支持。

這種做法我認為有利有弊,首先好處是所有平臺下行為一致,不管是滾動視圖、Material Design 控制元件還是模糊效果這些在其他平臺沒有的都得到了全平臺的支持,開發者並不需要為這些去做平臺間的適配,反觀 React Native… 當然缺點也是存在的,Flutter 這種做法類似於游戲引擎,平臺提供的 UI 特性它一概不用,因此 Flutter View 與原生視圖的交互就沒有那麼容易了,同時新的 Dart 語言貌似也不是非常受社區和開發者喜愛。

SwiftUI 沒有像 Flutter 那樣從頭再來,這個全新的框架依舊使用了 UIKit、AppKit 等作為基礎。但它並不是一個 UIKit 的宣告式封裝,通過 Xcode 的除錯視圖可以看出這一點:

許多基礎組件,像 Text、Button 等都並不是直接使用 UILabel、UIButton 而是一個名為 DisplayList.ViewUpdater.Platform.CGDrawingView 的 UIView 子類。它們使用了自定義繪製,但又承載於 UIKit 的環境中,因此我猜測 SwiftUI 只提供了組件的自定義渲染和佈局引擎,它使用到的底層技術還是 Core Animation、Core Graphics、Core Text 等。使用自定義繪製去實現組件可以理解成為跨平臺提供便利,畢竟一個按鈕還要區分 UIButton、NSButton 來實現未免有些麻煩。但是部分複雜的控制元件還是採用了 UIKit 中已有的類,比如 UISwitch 等。由於未脫離 UIKit 體系,嵌入一個 UIView 非常容易,你不需要搞什麼外部紋理(Flutter 需要),因為它們的背景關係是同一個,坐標系也是同一個。

所以我認為 SwiftUI 更加類似 React Native,使用系統框架提供的組件,只不過繪製和佈局可以自己來實現,這在 SwiftUI 之前也有相關的框架這樣實踐的,比如 Yoga、ComponentKit 等。

SwiftUI 的型別系統

Flutter、React 的型別系統並不是強約束,一個界面里有一個 Text 和有兩個 Text 型別是一樣的,React 使用 JavaScript 更是無型別。SwiftUI 與它們不同,它使用了強型別約束。舉個例子:

VStack {
  Text(“Hello”)
}


VStack {
  Text(“Hello”)
  Text(“World”)
}


VStack {
  Text(“Hello”)
      .color(Color.red)
}


型別都是不同的。首先上面這種語法叫做 Function Builders,是 Apple “私自”夾帶到 Swift 里的私貨。上面這些運算式最後都會得到一個實現了 View 協議的具體型別,SwiftUI 里基本使用的都是具體型別,而不是協議型別,首先 VStack 是一個 struct 同時也是一個具體型別,它的構造方法里接受一個閉包,這個閉包使用了通過 @functionBuilder 修飾的 ViewBuilder 結構體作為 builder,因此上面的第二段代碼在編譯時會被轉化成:

VStack {
  let v1 = ViewBuilder.buildExpression(Text(“Hello”))
  let v2 = ViewBuilder.buildExpression(Text(“World”))

  return ViewBuilder.buildBlock(v1, v2)
}


然後我們看一下上面這個 ViewBuilder.buildBlock 多載的簽名:

static func buildBlock(_ c0: C0, _ c1: C1) -> TupleView where C0 : View, C1 : View


所以一個 Text 和兩個 Text,它們的父容器 VStack 的型別都是不同的!另外提一下,buildBlock 的範型引數最多有 10 個:

劃重點:也就是你的一個視圖層級(目前)不能有超過 10 個子視圖。且超過後編譯器的錯誤提示絲毫不會體現這一點,瞭解這個將會非常節約你的時間!
不同的狀態對應的視圖也不同,但是它們的型別是相同的,這意味著什麼呢?那就是,不需要 Diff-Patch 了

我們想象下麵的場景:

VStack {
  if something {
      Text(“something is true”)
  }
  Text(“something else”)
  if !something {
      Text(“something is not true”)
  }
}


當 something 變化時,視圖應該怎麼變化?對於 React、Flutter 來說,它們沒有型別的概念,每次只能拿到兩個快照(一個當前狀態的,一個新狀態的)。它們有兩個選擇去完成界面的更新:

  • 把老的視圖全部移除,重新添加新視圖

  • 找出它們的差異,根據差異去修改視圖

第一種方法最簡單,但是性能很差,且不能儲存視圖自身的狀態。第二種方法需要高效的演算法加持,看起來能解決我們的問題,但是它不是必要的。
SwiftUI 的做法是根據型別來更新界面,上面這段代碼的型別是:

VStack>


有了型別框架就能做靜態優化,這類似前端框架 Svelte 和 Vue.js 3.0 所做的一些優化,可以稱之為 AOT。

在沒有型別的情況下,每次狀態變化,界面中都只有兩個 Text,只不過內容不一樣,這時候框架通過 diff 認為界面中的 Text 控制元件本身沒變,只是內容變了,於是給它們設置了新的內容。

但事實並不是這樣,something 變化時,界面顯示的 Text 是不同的,中間的 Text 始終顯示 “something else”,變化的是它上下兩個相鄰的 Text。框架拿到新視圖時就可以按範型引數的順序去檢查他們的差異:

// Before update:
VStack(TupleView(Text(…), Text(…), nil))

// After update:
VStack(TupleView(nil, Text(…), Text(…)))


它們的相對位置寫在了型別中,這樣就能避免中間的視圖被修改,沒有型別信息或其他元信息,這點是絕對做不到的。

SwiftUI 對於型別做得其實更多,所有的字體調整、位置調整等操作在 SwiftUI 中都是通過 ViewModifier 實現的,調整後的視圖型別為 View.Modified,因此有無這些引數調整的視圖,型別也是不同的,這些都將有助於框架去做一些靜態優化。

關於 SwiftUI 的詳細使用方面,我之後可能還會再更新文章,本文就是簡單談談我對框架宏觀層面的理解。祝大家 WWDC 周玩得開心~

參考

[1]https://github.com/apple/swift-evolution/blob/9992cf3c11c2d5e0ea20bee98657d93902d5b174/proposals/XXXX-function-builders.md
[2]
https://github.com/apple/swift-evolution/blob/master/proposals/0258-property-delegates.md
[3]
https://github.com/apple/swift-evolution/blob/master/proposals/0244-opaque-result-types.md
[4
]https://svelte.dev/
[5]https://forums.swift.org/t/pitch-function-builders/25167
[6]
https://forums.swift.org/t/important-evolution-discussion-of-the-new-dsl-feature-behind-swiftui/25168
[7]
https://developer.apple.com/videos/play/wwdc2019/402/
[8]
https://twitter.com/unixzii/status/1136330564582092800?s=20

已同步到看一看
赞(0)

分享創造快樂