[Demystify SwiftUI](揭開 SwiftUI 的神秘面紗)內容基於 《WWDC21: 10022-Session》
一、知識回顧
SwiftUI 從《WWDC19》發布到現在,大家或多或少都接觸過了。在講 Demystify SwiftUI 之前,我們先來簡單回顧一下 SwiftUI :
什么是 SwiftUI?
"SwiftUI is a declarative UI framework" -- Apple
"SwiftUI is an innovative, exceptionally simple way to build user interfaces across all Apple platforms with the power of Swift." -- Apple
我們最初認識 SwiftUI 這個詞的時候,第一正常反應就是會問,“什么是 SwiftUI?“ ,而 Apple 官方給出的解釋是:
- SwiftUI 是一個聲明式的 UI 框架。它基於 Swift,通過一種創新且特別簡單的方式去構建用戶界面,支持跨所有的 Apple 平台。
單純看這個簡短的描述,我們可能並不能在腦海中把它具象化。我們需要舉一個例子,來對這個解釋進行補充。聲明式簡單的來說就是描述式,我們要實現左下圖的界面,描述式可以是這樣的:
一個界面里有一個垂直的布局(VStack),垂直布局里面有一個開關(Taggle),開關狀態和 isOn 綁定,(isOn 就是記錄開關狀態的);然后布局里面還有一個文本描述(Text),本文內容也和 isOn 進行關聯,通過 isOn 的狀態來顯示開或關。
上面描述的每一句我們都能很直觀的從下面代碼中找到對應的代碼塊。我們寫的代碼有層次結構且越趨同於描述,越趨同於現實表達,這就是聲明式。
SwiftUI 基於 Swift,Swift 的語法已經很簡單便捷了,但是 SwiftUI 再此基礎上又進行深度封裝,語法更為簡練。以及結合強大的 Xcode,構建界面就如同搭積木一樣容易,並且代碼能與預覽界面實時同步,非常的簡單且富有創新。
當然 SwiftUI 驚艷之處還有一個就是可以跨所有 Apple 平台(iOS、ipadOS、tvOS、macOS、watchOS),只需要一套代碼就能多端運行。
二、揭開 SwiftUI 的神秘面紗
到這里我們對 SwiftUI 有了初步的認識,從上面小節我們知道,SwiftUI 是一個聲明式 UI 框架,我們在最上層通過 SwiftUI 去描述一個 App,體驗着 SwiftUI 給我們帶來的便捷,但 SwiftUI 在幕后所做的事情,我們還不甚了了。所以我們今天就來揭開 SwiftUI 的神秘面紗,從幕后窺探 SwiftUI 的三大核心要素:
- Identity (身份標識)- 在程序多次更新中,識別相同或不同視圖的方式
- Lifetime(生命周期)- SwiftUI 隨時追蹤視圖和數據存在的方式
- Dependencies (依賴項)- 使 SwiftUI 理解界面何時更新以及為何更新
我們就逐一討論一下這些概念。
Identity (身份標識)
下圖里有兩張可愛的小狗圖片,我們通過什么方式能辨別它們是同一只小狗照片,還是兩只不同的小狗照片?
事實上我們並不能通過這兩張圖片直接辨別出來他們是不是同一只小狗。
那如果我們在圖片下邊標識出小狗名字,他們用的是同一個名字,我們大致能猜出是同一只小狗。當然再嚴謹點就是給小狗辦身份證。
那如果圖片下面標識出小狗的名字不是同一個,那我們能肯定兩張圖片上面的小狗不是同一只。
這個就是身份標識的好處,SwiftUI 識別視圖方式也是一樣,但 SwiftUI 使用的身份標識有兩種類型:
Explicit Identity(顯式身份)
上面我們舉的例子,給小狗圖片分配名稱或者說是標識符,這是顯式身份的一種形式。而我們在 UIKit 和 AppKit 常用的顯式身份就是指針身份,下面是 UIKit 或 AppKit 的視圖層級結構,圖上的 UIView 和 NSView ,它們每個都有一個指向它們的內存分配的唯一指針,這個指針就是指針身份,也是顯式身份的一種形式。我們可以只使用它們的指針,來引用單個視圖。如果兩個視圖共享同一個指針我們確定這兩是視圖同一個視圖。
但是 SwiftUI 不使用指針,因為 SwiftUI 視圖是值類型。為什么使用值類型?一個是值類型相對而言更高效且節約性能,一個是可以使代碼更干凈且更好的隔離狀態。對這塊感興趣的同學可以看一下 《WWDC19: SwiftUI Essentials》這個 session。
雖然 SwiftUI 不使用指針身份,但 SwiftUI 依賴於其他形式的顯式身份。比如說 ForEach
的 id
參數是顯式標識的一種形式。我們可以通過自定義 id
明確對應的視圖。
ForEach(..., id: \.someProperty) { ... }
我們再看一個例子,下面是一個使用了 ScrollViewReader
的視圖,在底部有一個按鈕。頭部文本綁定我們自定義的標識符,點擊按鈕,按鈕直接回到頂部。從代碼很直觀的看到,我們將該標識符傳遞給滾動視圖代理的 scrollTo
方法,告訴 SwiftUI ,如果點擊了按鈕,就滾動到該指定視圖。
我們並不是都需要明確每個視圖的 id
,比如說 ScrollViewReader
、ScrollView
、Button
等視圖是不需要自定義 id
的,我們只需要給被其他地方引用的視圖添加上 id
。
當然不需要顯式身份並不意味着沒有身份標識,每個視圖都有一個身份標識,即使不是顯式身份,也都是有的。這時候引入 Struct Identity (結構身份)的概念。
Struct Identity(結構身份)
SwiftUI 使用的視圖層次結構,能自動為視圖生成隱式身份,也就是 Struct Identity(結構身份)。我們舉小狗的例子,下面是另外兩張小狗的圖片,假定我們無法知道他們的名字,這時候我們應該如何去辨別他們呢?我們可以通過他們坐的位置來識別他們,比如“左邊的狗” 和 “右邊的狗”。我們對這種相對排列區分它們的方式,叫做結構身份。
SwiftUI 在整個 API 中都是利用了結構身份。舉個常見的例子,我們使用 if...else...
條件語句時候,我們是能清晰的識別每個視圖。如下面代碼,第一個視圖僅在條件為真時顯示,而第二個視圖僅在條件為假時顯示。是不是跟上面小狗的例子很相似,上個例子是通過左右來標識小狗,而這次是通過真假的方式來確定視圖。
上面的寫的是 if...else...
,但 SwiftUI 內部看到的是確是右下圖的樣子。編譯器會把 if
語句轉譯為 _ConditionalContent
視圖。這種轉譯是通過 ViewBuilder
實現的,它是 Swift 中的一種結果構建器。View
協議默認將它的 body
屬性包裝在一個 ViewBuilder
中。
代碼中 body
屬性的 View
返回類型是一個占位符,代表這是一個靜態復合類型。使用這種泛型的類型, SwiftUI 可以明確區分兩個視圖。SwiftUI 也在幕后為它們各分配一個隱式身份。
這里官方也給出了一個建議,就是如果 if...else...
里是同一個 View,但是參數條件不同,我們直接使用三目運算符來代替。雖然兩種做法都可以,但使用三目運算符可以讓這兩個視圖保持同一個身份,這樣也能提供更流暢的過渡,也有助於保持視圖的生命周期和狀態。
// 官方不推薦寫法
if isGood {
PawView(tint: .green).frame(maxHeight: .infinity, alignment: .top)
Spacer()
} else {
Spacer()
PawView(tint: .red).frame(maxHeight: .infinity, alignment: .bottom)
}
// 官方推薦寫法
PawView(tint: isGood ? .green : .red)
.frame(maxHeight: .infinity, alignment: isGood ? .top : .bottom)
結構身份的宿敵(AnyView)
了解完結構身份,我們再來談談它的宿敵 - AnyView
。我們先來看看下面這段使用了 AnyView
的函數,這個函數需要返回一個單一類型,所以它用了 AnyView
來包裝各個不同視圖。這樣就會導致 SwiftUI 內部無法看到代碼的條件結構,只能看到一個 AnyView
的返回類型。因為 AnyView
隱藏了它所包裝的所有視圖的類型,也使代碼可讀性變差。
我們可以進行一番優化,如下。相對於上面的代碼而已,下面的代碼使得 SwiftUI 內部獲取 some View
的結構不再是單一而是變得清晰。這里應該注意的是要加@ViewBuilder
,body
屬性是默認(隱式)添加,但是我們自定義的方法,需要自行添加 @ViewBuilder
,不然會報錯。當然這里使用 switch
會更直觀一些。
所以我們要盡量避免使用 AnyView
,
AnyView
使用太多通常會使代碼更難閱讀和理解;AnyView
對編譯器隱藏了靜態的類型信息,導致一些有用的錯誤和警告不會提示;AnyView
某些情況會導致性能下降。比較合適做法就是使用泛型,來保留靜態類型信息。
下面我們來看一下第二要素 LifeTime (生命周期)。
Lifetime(生命周期)
我們人的生命周期是從出生到壽終,這期間是有酸甜苦辣的各種情緒狀態表達。視圖也是如此,視圖一旦被標識了身份,那它就存在一個生命周期,通過視圖值的變化,視圖在它的生命周期中也會有各種狀態。下面圖中有一個 bgView,在不同的時間點上有不同的視圖值(color),相對應 bgView 在這些點上呈現的狀態也不同。
這里需要注意的是,我們不能通過某個短暫的視圖值來當作視圖 bgView 的生命周期,視圖的生命周期必須是由視圖的身份決定的。當視圖的身份發生變化或者視圖被刪除時,這就意味它的生命周期結束。也就是說生命周期其實就是一個身份的持續時間,這個身份與視圖相關聯。且身份是唯一的,多個視圖就不能共享一個身份。所以這也體現了身份標識的穩定性至關重要:不穩定的身份會導致更短的視圖生命周期;而穩定的身份有助於提高性能,因為 SwiftUI 不需要一直為視圖創建存儲;
在上述視圖生命周期中,我們可以看到視圖是可以更改其狀態。例如我們最開始使用的例子,當我們滑動開關,該值由 true
變為 false
時,SwiftUI 會先保留舊視圖值的副本,執行比較后,再決定是否更新視圖。
struct SwitchView: View {
@State var isOn: Bool = true
var body: some View {
Toggle("Switch is \(isOn ? "On" : "Off")", isOn: $isOn)
}
}
視圖的狀態與生命周期是如何相關聯的呢?通過@State
和 @StateObject
與視圖身份相關聯,如 isOn
, 他們是持久化存儲視圖狀態的方式。在視圖標識身份時,也就是第一次創建時候,SwiftUI 會為 @State
和 @StateObject
分配內存中的存儲。
Dependencies (依賴項)
依賴項與視圖的聯系
我們先分析一下下面這段代碼的視圖結構。頂部有兩個屬性,一個是 dog(狗),一個是 treat(零食),這兩個屬性就是視圖的依賴項, 除了 body
是主體,其他的屬性都是依賴項。
我們將代碼轉化成圖表,我們可以更直觀看到,整個視圖與依賴項的關系。
雖然上面圖表結構是樹型結構,但是視圖與依賴項之間的關系並不只是如此,我們增加一些依賴項,並讓多個視圖與他們相關聯,就得到一張比之前更復雜的結構圖(左下)。我們重新排列它,以避免重疊線條,就能得到右下圖結構,我們稱之為 “依賴圖”。這個結構能幫助 SwiftUI 判斷哪些視圖的 body
需要更新,哪些不需要更新。
當某個依賴項發生變化時,將會給所有的視圖生成一個新的 body
值,然后把依賴項相關聯的視圖 body
值實例化,當然如果依賴變更不符合視圖更新條件,對應的視圖也不會更新。這個在我們的 Lifetime(生命周期)中也講到。
依賴項種類
除了普通結構體屬性外,依賴項還包括以下幾個屬性包裝器:
- @Binding
- @Environment
- @State
- @StateObject
- @ObservedObject
- @EnvironmentObject
由這些修飾的屬性都被稱為依賴項,但是前提是被視圖引用。