從實際問題看 SwiftUI 和 Combine 程式設計

PJHubs發表於2019-11-12

0x00 | 前言

假設大家已對 Swift 語法有基本瞭解,並且已經上手體驗過。雖在工作中可能並不會立即介入 SwiftUI 和 Combine,但通過對這兩個框架的學習和使用可以從側面給我們提供一個優化的思路,從以往「流程化」和「命令式」的程式設計思維中轉變出來,提升開發效率。

此次分享在於快速對 SwiftUI 和 Combine 框架有一個基本認識,通過一個常規業務 demo 來驗證 SwiftUI 和 Combine 提升效率的可能性,分享我在學習 SwiftUI 和 Combine 遇到問題和值得開心的地方。

0x01 | SwiftUI

1. SwiftUI 是什麼?

  • 指令式程式設計 響應式程式設計。
  • 基於 UIKitCore GraphicsCore Text 等系統框架封裝了完整而優美的 DSL。
  • Combine 響應式程式設計框架和函數語言程式設計思想直接驅動了 SwiftUI 中的資料流向。

從實際問題看 SwiftUI 和 Combine 程式設計

  • 提供了一套通用的語法和基礎資料型別,抹平 Apple 自家平臺差異性,降低同生態跨端難度。
  • 拋棄 ViewController 概念。
  • 在 API 層面上,有 RAC 鏈式呼叫的影子和 Combine 的強依賴實現。

2. Combine 是什麼?

  • SwiftUI 中處理資料的本體,響應式框架。
  • 提供給 SwiftUI 中與資料來源雙向繫結的能力。
  • 資料流式處理「鏈式」呼叫。與 SwiftUI 的「鏈式」組織 UI 不同,SwiftUI 是通過鏈式呼叫構造出一個確定的單一物件(語法糖),但 Combine 的每一次鏈式呼叫都會生成一個新的源資料。

0x02 | 實現一個 Context Menu

Context Menu

容器

選單容器

「更多選單」是一個幾乎所有 App 裡都會去實現的一個元件,其承擔了非主業務,但又十分重要的二級工具類業務入口。如果通過常規的 UIKit 的思路去做,大致的實現思路是這樣的:

  1. 建立一個 UIWindowUIViewController,作為選單檢視的容器;
  2. 通過 UITableView 或迴圈元件的方式建立出具體的選單檢視;
  3. 檢視關係建立及選單點選事件跳轉邏輯回撥完善。

如果只想用 SwiftUI 去實現的化,在 SwiftUI 萬物皆 View,沒有 ViewController 的概念,所以這裡的容器就回落到了 View 身上。包裝一個檢視容器,可能會是這樣的:·

struct MASSquareMenuView: View {
    
    var body: some View {
        GeometryReader { _ in
            // ......
        }
            .frame(minWidth: UIScreen.main.bounds.width, 
                   minHeight: UIScreen.main.bounds.height)
    }
}
複製程式碼

MASSquareMenuView 充當了底層的 ViewController 角色。View 實際上是個結構體。如果 body 裡返回不確定的型別,DSL 解析會失敗,例如同時返回兩個 View,通過 if-else 判斷來返回不同的 View,這種情況會被拒絕執行。如果我們就是想通過一個標識位去判斷當前要返回的到底是什麼檢視,需要使用 @State 關鍵詞修飾的一個變數去操作。

選單 Cell 容器

struct MASSquareHostView: View {
    
    var body: some View {
        NavigationView {
            // ...
            
            ZStack {
                MASSquareMenuView {
                    // ......
                }
            }
            
            // ...
        }
    }
}
複製程式碼

鏈式呼叫

「鏈式呼叫的過程」被稱為是 SwiftUIViewmodifier,每個 modifier 的呼叫結束後,返回給下一個 modifier 有兩種情況:第一種情況只是對 View(如 Text)的 font 等與佈局無關的方法,返回給下一個 modifier 相同型別的 View;第二種情況對 View 的佈局產生了修改,如呼叫了 padding 等方法,返回給下一個鏈式呼叫的 modifier 是一個重新包裝過的全新 View

其實我覺得這跟之前用的鏈式呼叫庫從概念上是一樣的道理,有些鏈式方法的呼叫必須是依賴於某些方法的先執行,比如自定義 Image 這個標籤的大小,必須先設定 resizeable 才能設定 frame,否則失效。

資料來源

SwiftUI 的 API 設計哲學,強迫我去思考對外公開的元件所提供的定製化功能,之前跟 mentor 討論過,類似這種 ContextMenu 是封裝成一個 UI 元件還是一個業務元件,最後決定還是把這個選單元件做成一個 UI 元件。

「更多選單」的資料來源經過調整,最終寫出了一個基本符合 SwiftUI 風格的 API,基本符合是因為多了一個煩人的 Group,之前已經說過,SwiftUI 不接受多個檢視返回,如果確實要返回多個檢視的「組合檢視」,需要手動對這些檢視使用 Group 包裝成一個 View 進行返回。

引發一個新的問題,怎麼接收一組 View,通過對一個元件傳遞一串 View 來完全自定義選單元件裡的內容,使用 UIKit 的話我可能會這麼做:

PJPickerView.showPickerView(viewModel: {
    $0.titleString = "感情狀態"
    $0.pickerType = .custom
    $0.dataArray = [["單身", "約會中", "已婚"]]
}) { [weak self] finalString in
    if let `self` = self {
        self.loveTextField.text = finalString
    }
}
複製程式碼

從實際問題看 SwiftUI 和 Combine 程式設計

但在 SwiftUI 中,因目前版本(beta 7)受限於不支援返回不確定的內容,因此,我的設計為:

MASSquareMenuView(isShowMenu: self.$showingMenuView) {
    Group {
        MASSquareMenuCell(itemName: "筆記", 
                          itemImageName: "square.and.pencil") {
            FirstView()
        }
        MASSquareMenuCell(itemName: "廣場", 
                          itemImageName: "burst") {
            SecondView()
        }

        // ...
    }
}
複製程式碼

其中 itemNameitemImageName 均可通過 ForEach 來完成,目前還沒找到一個可以完成動態跳轉的比較好的方式。

拆解

如何把多個子 View 通過以上類似這種相對優雅的方式進行檢視組合?我的這種封裝方法思想來源於 List 系統元件的使用方式:

 List {
    // PJPostView(post: post)

    ForEach(posts) { post in
        PJPostView(post: post)
    }
}
複製程式碼

先來看 List 這個系統元件的定義:

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
public struct List<SelectionValue, Content> : View where SelectionValue : Hashable, Content : View {

    @available(watchOS, unavailable)
    public init(selection: Binding<Set<SelectionValue>>?, @ViewBuilder content: () -> Content)

    @available(watchOS, unavailable)
    public init(selection: Binding<SelectionValue?>?, @ViewBuilder content: () -> Content)

    public var body: some View { get }
    public typealias Body = some View
}
複製程式碼

發現有一個全新的關鍵詞 @ViewBuilder,要求被 @ViewBuilder 修飾的 content 閉包返回的是個 ContentContent 的定義如下:

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol ViewModifier {

    associatedtype Body : View

    func body(content: Self.Content) -> Self.Body

    typealias Content
}
複製程式碼

也就是說,content 裡的可以被「包含」的物件,只要是 View 型別即可,這一點很完美,但 @ViewBuilder 是什麼?文件中的定義為:

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@_functionBuilder public struct ViewBuilder {

    /// Builds an empty view from an block containing no statements, `{ }`.
    public static func buildBlock() -> EmptyView

    /// Passes a single view written as a child view (e..g, `{ Text("Hello") }`) through
    /// unmodified.
    public static func buildBlock<Content>(_ content: Content) -> Content where Content : View
}
複製程式碼

終於看出了點端倪,通過 @ViewBubilder 修飾的 View 可以接收多個組合檢視,從官方文件中,我們可以得知最多同時單個元件可承載的最大子元件數為 10 個。如果超過 10 個子元件,官方推薦的做法是再抽象進行封裝成一個新的元件。

從實際問題看 SwiftUI 和 Combine 程式設計

大致的選單 Cell 實現細節為:

struct MASSquareMenuView<Content: View>: View {
    
    @Binding var isShowMenu: Bool
    var content: () -> Content
    
    var body: some View {
        GeometryReader { _ in
            VStack(alignment: .leading) {
                self.content()
            }
            
            // ......
        }
    }
}
複製程式碼

對這個 MunuView 初始化的時候,不給 init 方法,補齊 content,並且因為在 Swift 5.x 中最後一個閉包可省略,這就出現了之前的 API 格式。

0x03 | Combine 與 CoreData

這裡引入 CoreData 的意義只是能夠給了一個相對穩定的資料來源,目前暫時還未結合網路請求進行驗證。

這個例子想要完成的事情有:

  • 在「彈出框」中輸入文字內容;
  • 在「首頁」展示輸入的所有內容;
  • 提供檢索;
  • CloudKit 備份。

首頁

輸入

實話實說,完成這整套無縫的邏輯下來,花了不少時間。主要的時間耗費在理解和適應 SwiftUI 與 Combine 之間的聯合關係,經常在思考如何合理有效的組織各個資料來源去控制元件的互動。其中一定要死死握住的就是「單一資料來源」,把能夠引發某個元件產生某種行為的源頭限制在同一個資料物件本身。

其中,最為常用的三個狀態修飾符為:

  • @State
  • @Binding
  • @ObservedObject

在這個例子中的使用方式為:

@State private var showingSheet = false

@Binding var text: String

@ObservedObject var aritcleManager = AritcleManager()
複製程式碼

使用 @State 來修飾 showingSheet 變數作為控制「輸入框」是否彈出的標識位,使用 @Binding 來修飾 text 從「彈出框」中引用出使用者輸入的內容,使用 @ObservedObject 修飾 aritcleManager 物件,其作為連線首頁資料互動的中樞。

AritcleManager 作為首頁資料處理的中樞,其承擔了「輸入」和「搜尋」兩個任務,而為了保證單一資料來源的理念,引入了 @Published 修飾其內部持有的真正資料來源 articles,每當 articles 發生改變時,都向外部訂閱者釋出通知。

class AritcleManager: NSObject, ObservableObject {
    // 寫法 1
    var objectWillChange: ObservableObjectPublisher = ObservableObjectPublisher()
    // 寫法 2
    @Published var articles: [Article] = []
}
複製程式碼

與 CoreData 的互動使用了 NSFetchedResultsController 來進行,這部分可以替換成網路互動部分的方法:

// MARK: NSFetchedResultsControllerDelegate
extension AritcleManager: NSFetchedResultsControllerDelegate {
    
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        articles = controller.fetchedObjects as! [Article]
        // 寫法 2 可省略,不需要主動觸發釋出
        objectWillChange.send()
    }
}
複製程式碼

在「首頁」中的初始化和互動操作為:

struct MASSquareHostView: View {
    
    @ObservedObject var aritcleManager = AritcleManager()
    
    var body: some View {
        NavigationView {
            MASSquareListView(articles: self.$aritcleManager.articles,
                              showingSheet: self.$showingSheet) {
                                self.aritcleManager.articles[$0].delete()
            }
        }
    }
}
複製程式碼

從寫法 1 發現了一個奇怪的地方(寫法 2 可暫時理解為是寫法 1 的語法糖), ObservableObjectPublisher 是怎麼做到「自動監聽」的呢?來看看其定義:

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
final public class ObservableObjectPublisher : Publisher {

    public typealias Output = Void

    public typealias Failure = Never

    public init()

    final public func receive<S>(subscriber: S) where S : Subscriber, S.Failure == ObservableObjectPublisher.Failure, S.Input == ObservableObjectPublisher.Output

    final public func send()
}
複製程式碼

其中 ObservableObjectPublisher 是繼承自 Publisher 類,而 Publisher 是 Combine 中三大支柱之一,具體定義為:

@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public protocol Publisher {

    associatedtype Output

    associatedtype Failure : Error

    func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}
複製程式碼

Combine 中的三大支柱

  • Publisher,負責釋出事件;
  • Operator,負責轉換事件和資料;
  • Subscribe,負責訂閱事件。

這三者都是協議,且都是 @propertyWrapper 的具體應用。

Publisher

Publisher 最主要的工作其實有兩個:釋出新的事件及其資料,以及準備好被 Subscriber 訂閱。OutputFailure 定義了某個 Publisher 所釋出的值的型別,以及可能產生的錯誤 的型別。

Publisher 可以釋出三種事件:

  1. 型別為 Output 的新值:這代表事件流中出現了新的值;
  2. 型別為 Failure 的錯誤:這代表事件流中發生了問題,事件流到此終止;
  3. 完成事件:表示事件流中所有的元素都已經發布結束,事件流到此終止。

Publisher 的這三種事件不是必須的,也就是說,Publisher 可能只發一個或者一個都不發,也有可能一直在發,永遠不會停止,這就是無限事件流,還有可能通過發出 failure 或者 finished 的事件表明不會再發出新的事件,這是有限事件流

Apple 提供了滿足幾乎所有場景的 Publiser

Operator

每個 Operator 的行為模式都一樣:它們使用上游 Publisher 所釋出的資料作為輸入,以此產生的新的資料,然後自身成為新的 Publisher,並將這些新的資料作為輸出,釋出給下游,這樣相當於得到了一個響應式的 Publisher 鏈條。

當鏈條最上端的 Publisher 釋出某個事件後,鏈條中的各個 Operator 對事件和資料進行處理。在鏈條的末端我們希望最終能得到可以直接驅動 UI 狀態的事件和資料。這樣,終端的消費者可以直接使用這些準備好的資料。

總結

問題一:其不適合直接使用在當前「樹形操作流」的工程裡,使用者對 App 的操作以目前的情況來看是一種「樹形結構」,但 SwiftUI 與 Combine 的強依賴,導致了必須寫大量的相容程式碼去相容 Combine 的開發哲學,但 Combine 自身的「線性開發模型」與現在的模型是衝突且難以相容的。所以,問題不僅僅只是在對系統版本的依賴上這麼簡單而已。

問題二:目前 SwiftUI 並不具備多行文字元件,只能通過 UITextView 包一層,包完了以後在模擬器上一跑就卡死,只能走真機。換句話說,如果是從零開始想要搞一個大事情,全部基於 SwiftUI 去 UI 表現層上的內容,幾乎不可能,非常非常痛苦。

這兩個問題在我看來都是可解的,尤其是問題二,正是因為其能夠完美的無縫相容 UIKit,在接入成本上可以忽略不計,反而是問題一帶來的影響會更大,雖然 Combine 與現在 Rx 等一套有異曲同工之處,但對已有業務的改造成本不小,比如埋點,可能會需要從以往的跟隨檢視的變化變為跟隨資料流。

SwiftUI 與 SB 和 xib 一樣,我認為其只是個 UI 表現層,且可以認為是用於佈局等最上層的操作,對待其應該使用 SB 和 xib 的思路去使用。

參考連結

demo

Masq

能否關個燈

相關內容

SwiftUI Tutorials

SwiftUI 的一些初步探索 (一)

SwiftUI 的一些初步探索 (二)

SwiftUI 與 Combine 程式設計

歷時五天用 SwiftUI 做了一款 APP,阿里工程師如何做的?

SwiftUI 怎麼實現一個「更多選單」?

SwiftUI 怎麼和 Core Data 結合?

開源庫

CombineX

MovieSwiftUI

相關文章