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

從 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)

分享創造快樂