從實際問題看 SwiftUI 和 Combine 編程

0x00 | 前言

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

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

0x01 | SwiftUI

1. SwiftUI 是什麼?

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

  • 提供了一套通用的語法和基礎數據類型,抹平 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 身上。包裝一個視圖容器,可能會是這樣的:·swift

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,不然失效。app

數據源

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 中,因目前版本(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 個子組件,官方推薦的作法是再抽象進行封裝成一個新的組件。

大體的菜單 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

相關文章
相關標籤/搜索