SwiftUI:原理

原創:有趣知識點摸索型文章
創作不易,請珍惜,之后會持續更新,不斷完善
個人比較喜歡做筆記和寫總結,畢竟好記性不如爛筆頭哈哈,這些文章記錄了我的IOS成長歷程,希望能與大家一起進步
溫馨提示:由于簡書不支持目錄跳轉,大家可通過command + F 輸入目錄標題后迅速尋找到你所需要的內容

目錄

  • 一、聲明式的界面開發方式
  • 二、預覽
  • 三、some關鍵詞的解釋
  • 四、ViewBuilder的解釋
  • 五、鏈式調用修改 View 的屬性原理
  • 六、List的解釋
  • 七、@State的解釋
  • 八、Animating的解釋
  • 九、生命周期
  • Demo
  • 參考文獻

簡介

SwiftUI 的最低支持的版本是iOS 13,可能想要在實際項目中使用,還需要等待一兩年時間。在 view的描述表現力上和與 app 的結合方面,SwiftUI 要勝過 FlutterDart的組合很多。Swift雖然開源了,但是 Apple對它的掌控并沒有減弱。Swift 的很多特性幾乎可以說都是為了SwiftUI量身定制的。

另外,Apple 在背后使用Combine.framework 這個響應式編程框架來對 SwiftUI.framework進行驅動和數據綁定,相比于現有的RxSwift/RxCocoa或者是ReactiveSwift 的方案來說,得到了語言和編譯器層級的大力支持。


一、聲明式的界面開發方式

描述「UI 應該是什么樣子」而不再是用一句句的代碼來指導「要怎樣構建 UI」。比如傳統的 UIKit,我們會使用這樣的代碼來添加一個 Hello World 的標簽,它負責創建 label,設置文字并將其添加到 view 上。

func viewDidLoad() 
{
     super.viewDidLoad()

     let label = UILabel()
     label.text = "Hello World"
     view.addSubview(label)
     // 省略了布局的代碼
 }

而相對起來,使用SwiftUI我們只需要告訴SDK需要一個文字標簽。

var body: some View 
{
     Text("Hello World")
}

接下來,框架內部讀取這些view 的聲明,負責將它們以合適的方式繪制渲染。注意,這些 view的聲明只是純數據結構的描述,而不是實際顯示出來的視圖,因此這些結構的創建并不會帶來太多性能損耗。相對來說,將描述性的語言進行渲染繪制的部分是最慢的,這部分工作將交由框架以黑盒的方式為我們完成。

如果 View 需要根據某個狀態 (state) 進行改變,那我們將這個狀態存儲在變量中,并在聲明view時使用它。

@State var name: String = "Tom"
var body: some View
{
    Text("Hello \(name)")
}

狀態發生改變時,框架重新調用聲明部分的代碼,計算出新的view 聲明,并和原來的 view 進行比較,之后框架負責對變更的部分進行高效的重新繪制。


二、預覽

SwiftUIPreviewApple 用來對標 FlutterHot Reloading 的開發工具。Xcode 將對代碼進行靜態分析 (得益于 SwiftSyntax 框架),找到所有遵守 PreviewProvider 協議的類型進行預覽渲染。另外,你可以為這些預覽提供合適的數據,這甚至可以讓整個界面開發流程不需要實際運行 app 就能進行。

這套開發方式帶來的效率提升相比 Hot Reloading 要更大。Hot Reloading 需要你有一個大致界面和準備相應數據,然后運行 app,停在要開發的界面,再進行調整。如果數據狀態發生變化,你還需要restart app才能反應。SwiftUI 的 Preview 相比起來,不需要運行app并且可以提供任何的假數據,在開發效率上更勝一籌。


三、some關鍵詞的解釋

struct ContentView: View
{
    var body: some View
    {
        Text("Hello World")
    }
}

一眼看上去可能會對 some 比較陌生,為了講明白這件事,我們先從 View 說起。ViewSwiftUI 的一個最核心的協議,代表了一個屏幕上元素的描述。這個協議中含有一個associatedtype

public protocol View : _View
{
    associatedtype Body : View
    var body: Self.Body { get }
}

這種帶有 associatedtype 的協議不能作為類型來使用,而只能作為類型約束使用。

Error
func createView() -> View
{
    ...
}
OK
func createView<T: View>() -> T
{
   ...
}

想要 Swift 幫助自動推斷出 View.Body 的類型的話,我們需要明確地指出body的真正的類型。在這里,body 的實際類型是 Text

struct ContentView: View
{
    var body: Text
    {
        Text("Hello World")
    }
}

當然我們可以明確指定出 body的類型,但是這帶來一些麻煩。每次修改body 的返回時我們都需要手動去更改相應的類型。新建一個View 的時候,我們都需要去考慮會是什么類型。

其實我們只關心返回的是不是一個 View,而對實際上它是什么類型并不感興趣。some View 這種寫法使用了 Swift 的新特性 Opaque return types 。它向編譯器作出保證,每次 body 得到的一定是某一個確定的遵守 View 協議的類型,但是請編譯器網開一面,不要再細究具體的類型。返回類型確定單一這個條件十分重要,比如,下面的代碼也是無法通過的。

var body: some View
{
    if someCondition
    {
        // 這個分支返回 Text
        return Text("Hello World")
    }
    else
    {
        // 這個分支返回 Button,和 if 分支的類型不統一
        return Button(action: {}) {
            Text("Tap me")
        }
    }
}

這是一個編譯期間的特性,在保證associatedtype protocol的功能的前提下,使用 some 可以抹消具體的類型。


四、ViewBuilder的解釋

創建 Stack 的語法很有趣。

VStack(alignment: .leading)
{
    Text("Turtle Rock")
        .font(.title)
    Text("Joshua Tree National Park")
        .font(.subheadline)
}

一開始看起來好像我們給出了兩個 Text,似乎是構成的是一個類似數組形式的 [View],但實際上并不是這么一回事。這里調用了 VStack 類型的初始化方法。

public struct VStack<Content> where Content : View
{
    init(
        alignment: HorizontalAlignment = .center,
        spacing: Length? = nil,
        @ViewBuilder content: () -> Content)
}

前面的 alignmentspacing 沒啥好說,最后一個 content 比較有意思。看簽名的話,它是一個() -> Content類型,但是我們在創建這個VStack 時所提供的代碼只是簡單列舉了兩個 Text,并沒有實際返回一個可用的 Content

這里使用了 Swift 5.1 的另一個新特性 Funtion builders。如果你實際觀察 VStack 這個初始化方法的簽名,會發現content前面其實有一個@ViewBuilder標記,而 ViewBuilder則是一個由 @_functionBuilder 進行標記的 struct

@_functionBuilder public struct ViewBuilder { /* */ }

使用 @_functionBuilder 進行標記的類型 (這里的 ViewBuilder),可以被用來對其他內容進行標記 (這里用 @ViewBuildercontent 進行標記)。被用function builder標記過的 ViewBuilder 標記以后,content 這個輸入的 function 在被使用前,會按照 ViewBuilder 中合適的 buildBlock 進行 build后再使用。如果你閱讀 ViewBuilder 的文檔,會發現有很多接受不同個數參數的 buildBlock 方法,它們將負責把閉包中一一列舉的 Text和其他可能的 View 轉換為一個 TupleView并返回。由此,content 的簽名() -> Content可以得到滿足。實際上構建這個 VStack 的代碼會被轉換為類似下面這樣的等效偽代碼(不能實際編譯)。

VStack(alignment: .leading)
{ viewBuilder -> Content in
    let text1 = Text("Turtle Rock").font(.title)
    let text2 = Text("Joshua Tree National Park").font(.subheadline)
    return viewBuilder.buildBlock(text1, text2)
}

當然這種基于 funtion builder 的方式是有一定限制的。比如ViewBuilder 就只實現了最多十個參數的 buildBlock,因此如果你在一個 VStack中放超過十個View的話,編譯器就會不太高興。不過對于正常的 UI 構建,十個參數應該足夠了。如果還不行的話,你也可以考慮直接使用 TupleView 來用多元組的方式合并 View

TupleView<(Text, Text)>
(
    (Text("Hello"), Text("Hello"))
)

除了按順序接受和構建 ViewbuildBlock 以外,ViewBuilder 還實現了兩個特殊的方法:buildEitherbuildIf。它們分別對應 block 中的 if...else 的語法和 if 的語法。也就是說,你可以在 VStack里寫這樣的代碼。

var someCondition: Bool

VStack(alignment: .leading)
{
    Text("Turtle Rock")
        .font(.title)
    Text("Joshua Tree National Park")
        .font(.subheadline)
    
    if someCondition
    {
        Text("Condition")
    }
    else
    {
        Text("Not Condition")
    }
}

其他的命令式的代碼在 VStackcontent 閉包里是不被接受的,比如下面這樣就不行。let 語句無法通過 function builder 創建合適的輸出。

VStack(alignment: .leading)
{
    let someCondition = model.condition

    if someCondition
    {
        Text("Condition")
    }
    else
    {
        Text("Not Condition")
    }
}

五、鏈式調用修改 View 的屬性原理

var body: some View
{
    Image("turtlerock")
        .clipShape(Circle())
        .overlay(
            Circle().stroke(Color.white, lineWidth: 4))
        .shadow(radius: 10)
}

可以試想一下,在 UIKit 中要動手形成這個效果的困難程度。我大概可以保證,99%的開發者很難在不借助文檔或者 copy paste 的前提下完成這些事情,但是在SwiftUI中簡直信手拈來,這點和Flutter很像。在創建 View 之后,用鏈式調用的方式,可以將View 轉換為一個含有變更后內容的對象。比如復原一下上面的代碼。

let image: Image = Image("turtlerock")
let modified: _ModifiedContent<Image, _ShadowEffect> = image.shadow(radius: 10)

image 通過一個 .shadowmodifiermodified 變量的類型將轉變為_ModifiedContent<Image, _ShadowEffect>。如果你查看 View 上的 shadow 的定義,它是這樣的。

extension View
{
    func shadow(
        color: Color = Color(.sRGBLinear, white: 0, opacity: 0.33),
        radius: Length, x: Length = 0, y: Length = 0)
    -> Self.Modified<_ShadowEffect>
}

ModifiedView 上的一個typealias,在struct Image: View的實現里,我們有:

public typealias Modified<T> = _ModifiedContent<Self, T>

_ModifiedContent 是一個SwiftUI的私有類型,它存儲了待變更的內容,以及用來實施變更的 Modifier

struct _ModifiedContent<Content, Modifier> 
{
    var content: Content
    var modifier: Modifier
}

Content 遵守 ViewModifier遵守 ViewModifier 的情況下,_ModifiedContent 也將遵守 View,這是我們能夠通過 View 的各個 modifier extension 進行鏈式調用的基礎。

extension _ModifiedContent : _View 
    where Content : View, Modifier : ViewModifier 
{
    ...
}

shadow 的例子中,SwiftUI 內部會使用 _ShadowEffect這個 ViewModifier,并把image自身和 _ShadowEffect 實例存放到_ModifiedContent 里。不論是 image 還是 modifier,都只是對未來實際視圖的描述,而不是直接對渲染進行的操作。在最終渲染前,ViewModifierbody(content: Self.Content) -> Self.Body將被調用,以給出最終渲染層所需要的各個屬性。


六、List的解釋

a、靜態List

這里的 ListHStack 或者 VStack 之類的容器很相似,接受一個view builder并采用聲明的方式列舉了兩個 LandmarkRow。這種方式構建了對應著UITableView的靜態cell的組織方式。

var body: some View
{
    List
    {
        LandmarkRow(landmark: landmarkData[0])
        LandmarkRow(landmark: landmarkData[1])
    }
}

我們可以運行 app,并使用XcodeView Hierarchy 工具來觀察 UI,結果可能會讓你覺得很眼熟。實際上在屏幕上繪制的 UpdateCoalesingTableView 是一個 UITableView 的子類,而兩個 ListCoreCellHost也是 UITableViewCell 的子類。對于 List 來說,SwiftUI 底層直接使用了成熟的UITableView 的一套實現邏輯,而并非重新進行繪制。

不過在使用 SwiftUI 時,我們首先需要做的就是跳出 UIKit 的思維方式,不應該去關心背后的繪制和實現。使用 UITableView 來表達List也許只是權宜之計,也許在未來也會被另外更高效的繪制方式取代。由于SwiftUI層只是 View 描述的數據抽象,因此和FlutterWidget 一樣,背后的具體繪制方式是完全解耦合,并且可以進行替換的。這為今后 SwiftUI更進一步留出了足夠的可能性。


b、動態 List
List(landmarkData.identified(by: \.id))
{ landmark in
    LandmarkRow(landmark: landmark)
}

除了靜態方式以外,List 當然也可以接受動態方式的輸入,這時使用的初始化方法和上面靜態的情況不一樣。

public struct List<Selection, Content> where Selection : SelectionManager, Content : View
{
    public init<Data, RowContent>(
        _ data: Data, action: @escaping (Data.Element.IdentifiedValue) -> Void,
        rowContent: @escaping (Data.Element.IdentifiedValue) -> RowContent)
    where
        Content == ForEach<Data, Button<HStack<RowContent>>>,
        Data : RandomAccessCollection,
        RowContent : View,
        Data.Element : Identifiable
        
    //...
}
Content == ForEach<Data, Button<HStack<RowContent>>>

因為這個函數簽名中并沒有出現 ContentContent 僅只 List<Selection, Content> 的類型聲明中有定義,所以在這與其說是一個約束,不如說是一個用來反向確定 List 實際類型的描述。

Data : RandomAccessCollection

這基本上等同于要求第一個輸入參數是 Array

RowContent : View

對于構建每一行的 rowContent 來說,需要返回是 View 是很正常的事情。注意 rowContent 其實也是被 @ViewBuilder 標記的,因此你也可以把 LandmarkRow 的內容展開寫進去。不過一般我們會更希望盡可能拆小 UI 部件,而不是把東西堆在一起。

Data.Element : Identifiable

要求 Data.Element (也就是數組元素的類型) 上存在一個可以辨別出某個實例的滿足 Hashableid。這個要求將在數據變更時快速定位到變化的數據所對應的 cell,并進行 UI 刷新。


c、List : View的困惑

在下面的代碼中,我們期望 List 的初始化方法生成的是某個類型的 View。但是你看遍 List 的文檔,都找不到 List : View 之類的聲明。

var body: some View
{
    List
    {
        //...
    }
}

難道是因為 SwiftUI 做了什么手腳,讓本來沒有滿足 View 的類型都可以充當一個 View 嗎?當然不是這樣…如果你在運行時暫定 app 并用lldb打印一下List的類型信息,可以看到下面的信息。

(lldb) type lookup List
...
struct List<Selection, Content> : SwiftUI._UnaryView where ...

SwiftUI視圖_UnaryView協議雖然是滿足 View 的,但它被隱藏起來了,而滿足它的 List雖然是 public的,但是卻可以把這個協議鏈的信息也作為內部信息隱藏起來。這是Swift內部框架的特權,第三方的開發者無法這樣在在兩個public的聲明之間插入一個私有聲明。


七、@State的解釋

這里出現了兩個以前在 Swift 里沒有的特性:@State$showFavoritesOnly

@State var showFavoritesOnly = true

Toggle(isOn: $showFavoritesOnly)
{
    Text("Favorites only")
}

如果你點到 State 的定義里面,可以看到它其實是一個特殊的struct

@propertyWrapper public struct State<Value> : DynamicViewProperty, BindingConvertible
{
    /// Initialize with the provided initial value.
    public init(initialValue value: Value)

    /// The current state value.
    public var value: Value { get nonmutating set }

    /// Returns a binding referencing the state value.
    public var binding: Binding<Value> { get }

    /// Produces the binding referencing this state value
    public var delegateValue: Binding<Value> { get }
}

@propertyWrapper標注和@_functionBuilder 類似,它修飾的struct可以變成一個新的修飾符并作用在其他代碼上,來改變這些代碼默認的行為。這里 @propertyWrapper修飾的 State被用做了 @State 修飾符,并用來修飾 View中的 showFavoritesOnly 變量。

@_functionBuilder 負責按照規矩重新構造函數的作用不同,@propertyWrapper 的修飾符最終會作用在屬性上,將屬性包裹起來,以達到控制某個屬性的讀寫行為的目的。如果將這部分代碼展開,它實際上是這個樣子的。

// @State var showFavoritesOnly = true
var showFavoritesOnly = State(initialValue: true)
    
// Toggle(isOn: $showFavoritesOnly)
Toggle(isOn: showFavoritesOnly.binding)

// if !self.showFavoritesOnly
if !self.showFavoritesOnly.value

把變化之前的部分注釋了一下,并且在后面一行寫上了展開后的結果。可以看到 @State 只是聲明State struct的一種簡寫方式而已。State 里對具體要如何讀寫屬性的規則進行了定義。對于讀取,非常簡單,使用 showFavoritesOnly.value 就能拿到 State 中存儲的實際值。而原代碼中 $showFavoritesOnly 的寫法也只不過是 showFavoritesOnly.binding 的簡化。binding 將創建一個 showFavoritesOnly 的引用,并將它傳遞給 Toggle。再次強調,這個 binding 是一個引用類型,所以 Toggle 中對它的修改,會直接反應到當前 ViewshowFavoritesOnly 去設置它的 value。而 Statevalue didSet 將觸發 body 的刷新,從而完成 State -> View的綁定。

SwiftUI 中還有幾個常見的 @ 開頭的修飾,比如 @Binding@Environment@EnvironmentObject等,原理上和 @State 都一樣,只不過它們所對應的 struct中定義讀寫方式有區別。它們共同構成了 SwiftUI數據流的最基本的單元。


八、Animating的解釋

直接在 View 上使用 .animation或者使用 withAnimation { }來控制某個 State,進而觸發動畫。

button(action: {
    self.showDetail.toggle()
}) {
    Image(systemName: "chevron.right.circle")
        .imageScale(.large)
        .rotationEffect(.degrees(showDetail ? 90 : 0))
        .animation(nil)
        .scaleEffect(showDetail ? 1.5 : 1)
        .padding()
        .animation(.spring())
}

對于只需要對單個 View 做動畫的時候,animation(_:)要更方便一些,它和其他各類 modifier 并沒有太大不同,返回的是一個包裝了對象View 和對應的動畫類型的新的 Viewanimation(_:)接受的參數 Animation并不是直接定義 View 上的動畫的數值內容的,它是描述的是動畫所使用的時間曲線、動畫的延遲等這些和 View 無關的東西。具體和 View 有關的,想要進行動畫的數值方面的變更,由其他的諸如 rotationEffectscaleEffect 這樣的 modifier來描述。

要注意,SwiftUImodifier 是有順序的。在我們調用 animation(_:)時,SwiftUI做的事情等效于是把之前的所有 modifier 檢查一遍,然后找出所有滿足 Animatable 協議的view上的數值變化,比如角度、位置、尺寸等,然后將這些變化打個包,創建一個事物(Transaction)并提交給底層渲染去做動畫。在上面的代碼中,.rotationEffect后的 .animation(nil)rotation的動畫提交,因為指定了nil所以這里沒有實際的動畫。在最后,.rotationEffect已經被處理了,所以末行的.animation(.spring()) 提交的只有.scaleEffect

withAnimation { } 閉包內部,我們一般會觸發某個 State 的變化,并讓View.body進行重新計算.

Button(action: {
    withAnimation
    {
        self.showDetail.toggle()
    }
}) {
  //...
}

如果需要,你也可以為它指定一個具體的 Animation。這個方法相當于把一個animation設置到 View 數值變化的 Transaction 上,并提交給底層渲染去做動畫。從原理上來說,withAnimation 是統一控制單個的 Transaction,而針對不同 Viewanimation(_:)調用則可能對應多個不同的 Transaction

withAnimation(.basic())
{
    self.showDetail.toggle()
}

九、生命周期

UIKit 開發時,我們經常會接觸一些像是 viewDidLoadviewWillAppear這樣的生命周期的方法,并在里面進行一些配置。SwiftUI里也有一部分這類生命周期的方法,比如.onAppear.onDisappear,它們也被統一在了 modifier 這面大旗下。

但是相對于UIKit來說,SwiftUI中能使用的生命周期方法比較少,而且相對要通用一些。本身在生命周期中做操作這種方式就和聲明式的編程理念有些相悖。個人比較期待ViewCombine能再深度結合一些,把依賴生命周期的操作也用綁定的方式搞定。

相比于.onAppear.onDisappear,更通用的事件響應是.onReceive(_:perform:),它定義了一個可以響應目標 Publisher的任意的 View,一旦訂閱的 Publisher發出新的事件時,onReceive就將被調用。


Demo

Demo在我的Github上,歡迎下載。
SwiftUIDemo

參考文獻

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
禁止轉載,如需轉載請通過簡信或評論聯系作者。