WWDC 21 的 Keynote 看來格外平淡,但沒想到 session 公佈後卻意外地精彩絕倫,各種新東西、解密以及實務探討,我一眼就看到今年最不容錯過的 session - Demystify SwiftUI,姑且翻為 SwiftUI 解密,事後看完雖然沒有解多少密,但該注意的使用方式以避免效能或穩定性問題都有點出來。而且今年首度舉辦 Digital Lounge ,報名後一群開發者會被拉入 Slack,第二三天都有一起看的活動,其中第三天一起看的就是 SwiftUI 解密,重點是該 session 的講者也同時在 Slack 裡面即時回答大家的問題,事後也有留時間給大家額外提問、討論,在那裡又獲得不少寶藏啊!
相信此 session 絕不會在接下陸續發佈的 WWDC 精選解說文中缺席,這篇文就作為我個人的學習筆記,其中包含了 lounge 裡的討論與提問,和各位分享。
當有人和我討論 Swift 裡 value 與 reference type 選擇的準則是什麼,我的回答都是:「在一般情況下,以需要 identity 與否做為要不要用 reference type 的依據」。在 SwiftUI 中,我們知道 View 都是 value type ,而 identity 則是給 SwiftUI 使用的。對於一個 render view 的 framework,需要知道它要不要更新「這個」 view ,所以身份識別是必要的。識別的方式分別為開發者主動給予識別資訊的 Explicit identity
與 SwiftUI 從程式碼結構中取得的 Structural identity
補充:基本的元件如 Text Button 在 runtime 用 lldb 觀察可得知都是一個 UIView
的 subclass SwiftUI.DisplayList.ViewUpdate.Platform.CGDrawingView
為了讓 compiler 可以約束何時由開發者提供身份資訊 (identifier),因此定義了 Identifiable
這個 protocol ,它要求你提供一個名為 id
的 property,且此 property 必需為 Hashable
,不難想像這些東西會被 SwiftUI 放在 Dictionary 或 Set 這樣的集合中以在 O(1) 的時間複雜度下查找,實例如下:
值得注意的是,如果你直接送給 ForEach()
一個動態長度的 collection 且不給 ID 會得到 warning (以前會是個 error );另外你也可以使用 .id(xxx)
來主動給某個 view identifier,如 Text("headerSection").id(headerID)
透過 @ViewBuilder
這個 annotation ,可以在 compile time 知道有哪些 view 的組合,這也是若你去觀察 SwiftUI 的型別會發現很難閱讀的原因,因為都是一堆泛型的組成,這是在安全與效率下的 trade off ,比如最常見的 if-else branch :
注意 body 本身自帶 ViewBuilder ,只是沒有明示。
這樣的做法讓 view 在 runtime 有更多資訊知道誰是誰,畢竟「改變一個 view 的狀態」和「轉換成另一個 view」是兩種截然不同的成本。由此可見 AnyView
這樣的 type-erasure view 是要謹慎使用的,因為 SwiftUI 並無法在 compile time 確定 AnyView 背後到底是誰, Apple 工程師給出使用 AnyView 時機的準則是:「盡量用在包極少或完全不會變化的 view 上,若使用在 state 頻繁改變的場景可能引起 performance issue」。
首先 view, state 與 identifier 的關係是:
@State
與 @StateObject
)@State
不用是 Equatable ,SwiftUI 也能理解值有沒有變化,估計是在 runtime 時利用 Metadata 去挖 properties 做比較Dependencies 是從 view 外部來決定需不需要重 render 的準則,一旦變化了,會先重建該 view 的 body ,然後再 recursivley traverse 其所有 childe view 。SwiftUI 會利用 identity 與其 lifetime 來建立一個 dependency 的 directed acyclic graph (但從 code level 看整個 view 是個 tree),如此的做法可以避免過度的 render ,比如 view 的結構是 A -> B -> C,當唯 A C 共享的 dependency 改變時,僅 A C 會被 render ,而 B 不會。但其實當你若有把 property 從 A 傳到 B ,那也代表有 dependency ,所以常見情況應該都還是會全部都有 dependency 因而全都 re-render。
至於 dependency cycle 是不被允許發生的,一旦發生了會被 system trap,可以理解這個檢查是發生在 runtime 。
說這麼多,我們又不是 compiler 也不是 SwiftUI,我只是想知道到底什麼時候 view 被重畫,又為什麼會被重畫而已啊!你的心聲 Apple 工程師聽到了,現在只要加上 Self._printChanges()
在你的 content view 裡,就能夠看到 identity、dependency 與 state 是否有被設值以及是否有變化(以上任一變化都會發生 render),具體使用方式如下:
或者打個 breakpoint 在 body 裡並新增 action po Self._printChanges()
如此一來當發生變化時你會在 console 中看到
以上例子代表該 view 的 dependency (外部餵進 self 的參數)、identity 以及 state 都發生了變化,其實這裡就是第一次初始化後印出來的啦。
可以知道 identifier 這東西大大地決定了整個 view render 的時機,lifetime 與 state 的 lifetime ,誤用時可能造成災難,因此要注意兩種不同 identifier 的使用方式:
Explicit identifier 需要唯一且穩定
不同的 id 就會被視為不同 view 了,除了 overhead 外還有可能導致狀態的遺失,比如我們用 if else 控制同 view 的不同狀態
其中, if 裡的 FooView
和 else 裡的會被視為兩個不同的 view ,當 dayTime 為 true ,會建立一塊記憶體保存其 state (稱為 state F),當 dayTime 變 false,則又要了另一塊記憶體保存狀態 (state F’),當又回到 true branch 時,又一個新的記憶體建立( state F’‘),是故你的狀態可能因此遺失了。
另外常見的可能誤用是以 collection 的 index 做 id,這是不穩定的 id ,因為如果你插入一個元素,其往後的 index 全都會變化(+1),SwiftUI 自然也會把它們視為新 view 重新 render 和 reset state 了;若使用 hash value 也要注意你使用的 hash 是不是會同值但卻有不同的 hash value 。
使用 structural identifier 時先考慮能否換成 inert modifier
這個應易懂:
總之就是減少無意義的 branch 或用 inert modifier 代替,因為它成本極低。不過問題來了,哪些 modifier 才是 inert ?有在 lounge 提問,但這題並沒有被回答到,session 中只舉了三個例子: opacity()
、 padding(0)
與 transformEnvironemt(...) {}
,但其它的也不知道是還不是,這裡還有望知道的人指點。
@State
與 @StateObject
不同,@ObservedObject
的 lifetime 由 developer 掌控,它可以存活在任何地方@State
、@StateObject
或 @ObservedObject
哪些?為何?