Combine與SwiftUI

文章連接html

儘管今年的WWDC已經落幕,但在過去的一個多月時間,蘋果給iOS開發者帶來了許多驚喜,其中堪稱最重量級的當屬SwiftUICombine兩大新框架前端

在更早以前,因爲缺乏系統層的聲明式UI語言,在iOS系統上的UI開發對於開發者而言,並不友善,而從iOS13開始,開發者們終於能夠擺脫落後的佈局系統,擁抱更簡潔高效的開發新時代。與SwiftUI配套發佈的響應式編程框架Combine提供了更優美的開發方式,這也意味着Swift真正成爲了iOS開發者們必須學習的語言。本文基於Swift5.1版本,介紹SwiftUI是如何經過結合Combine完成數據綁定編程

SwiftUI

首先來個例子,假如咱們要實現上圖的登錄界面,按照以往使用UIKit進行開發,那麼咱們須要:swift

  • 建立一個UITextField,用於輸入帳戶
  • 建立一個UITextField,用於輸入密碼
  • 建立一個UIButton,設置點擊事件將前兩個UITextField的文本做爲數據請求

而在使用SwiftUI進行開發的狀況下,代碼以下:api

public struct LoginView : View {
    @State var username: String = ""
    @State var password: String = ""
    
    public var body: some View {
        VStack {
            TextField($username, placeholder:  Text("Enter username"))
                .textFieldStyle(.roundedBorder)
                .padding([.leading, .trailing], 25)
                .padding([.bottom], 15)
            SecureField($password, placeholder: Text("Enter password"))
                .textFieldStyle(.roundedBorder)
                .padding([.leading, .trailing], 25)
                .padding([.bottom], 30)
            Button(action: {}) {
                Text("Sign In")
                    .foregroundColor(.white)
                }.frame(width: 120, height: 40)
                .background(Color.blue)
        }
    }
}
複製代碼

SwiftUI中,使用@State修飾的屬性會在發生改變的時候通知綁定的UI控件強制刷新渲染,這種新命名歸功於新的PropertyWrapper機制。能夠看到SwiftUI的控件命名上和UIKit幾乎保持一致的,下表是兩個標準庫上的UI對應表:app

SwiftUI UIKit
Text UILabel / NSAttributedString
TextField UITextField
SecureField UITextField with isSecureTextEntry
Button UIButton
Image UIImageView
List UITableView
Alert UIAlertView / UIAlertController
ActionSheet UIActionSheet / UIAlertController
NavigationView UINavigationController
HStack UIStackView with horizatonal
VStack UIStackView with vertical
Toggle UISwitch
Slider UISlider
SegmentedControl UISegmentedControl
Stepper UIStepper
DatePicker UIDatePicker

View

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

    /// The type of view representing the body of this view.
    ///
    /// When you create a custom view, Swift infers this type from your
    /// implementation of the required `body` property.
    associatedtype Body : View

    /// Declares the content and behavior of this view.
    var body: Self.Body { get }
}
複製代碼

雖然在SwiftUI中使用View來表示可視控件,但實際上截然不同,View是一套容器協議,不展現任何內容,只定義了一套視圖的交互、佈局等接口。UI控件須要實現協議中的body返回須要展現的內容。另外View還擴展了Combine響應式編程的訂閱接口:框架

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {

    /// Adds an action to perform when the given publisher emits an event.
    ///
    /// - Parameters:
    ///   - publisher: The publisher to subscribe to.
    ///   - action: The action to perform when an event is emitted by
    ///     `publisher`. The event emitted by publisher is passed as a
    ///     parameter to `action`.
    /// - Returns: A view that triggers `action` when `publisher` emits an
    ///   event.
    public func onReceive<P>(_ publisher: P, perform action: @escaping (P.Output) -> Void) -> SubscriptionView<P, Self> where P : Publisher, P.Failure == Never

    /// Adds an action to perform when the given publisher emits an event.
    ///
    /// - Parameters:
    ///   - publisher: The publisher to subscribe to.
    ///   - action: The action to perform when an event is emitted by
    ///     `publisher`.
    /// - Returns: A view that triggers `action` when `publisher` emits an
    ///   event.
    public func onReceive<P>(_ publisher: P, perform action: @escaping () -> Void) -> SubscriptionView<P, Self> where P : Publisher, P.Failure == Never
}
複製代碼

@State

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@propertyDelegate 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 }

    /// Produces the binding referencing this state value
    /// TODO: old name for storageValue, to be removed
    public var storageValue: Binding<Value> { get }
}
複製代碼

Swift5.1的新特性之一,開發者能夠將變量的IO實現封裝成通用邏輯,用關鍵字@propertyWrapper(更新於beta4版本)修飾讀寫邏輯,並以@wrapperName var variable的方式封裝變量。以WWDC Session 415視頻中的例子實現對變量copy-on-write的封裝:dom

@propertyWrapper
public struct DefensiveCopying<Value: NSCopying> {
    private var storage: Value
    
    public init(initialValue value: Value) {
        storage = value.copy() as! Value
    }
    
    public var wrappedValue: Value {
        get { storage }
        set {
            storage = newValue.copy() as! Value
        }
    }
    
    ///  beta4版本更新,必須聲明projectedValue後才能使用$variable的方式訪問Wrapper<Value>
    ///  beta3版本使用wrapperValue命名
    public var projectedValue: DefensiveCopying<Value> {
        get { self }
    }
}

public struct Person {
    @DefensiveCopying(initialValue: "")
    public var name: NSString
}
複製代碼

另外以PropertyWrapper封裝的變量,會默認生成一個命名爲$nameDefensiveCopying<String>類型的變量,更新後會默認生成_name命名的Wrapper<Value>類型參數,或聲明關鍵變量wrapperValue/projectedValue後生成可訪問的$name變量,下面兩種值訪問操做是相同的:異步

extension Person {
    func visitName() {
        printf("name: \(name)")
        printf("name: \($name.value)")
    }
}
複製代碼

Combine

Customize handling of asynchronous events by combining event-processing operators.async

引用官方文檔的描述,Combine是一套經過組合變換事件操做來處理異步事件的標準庫。事件執行過程的關係包括:被觀察者Observable和觀察者Observer,在Combine中對應着PublisherSubscriber

異步編程

不少開發者認爲異步編程會開闢線程執行任務,多數時候程序在異步執行時確實也會建立線程,可是這種理解是不正確的,同步編程和異步編程的區別只在於程序是否會堵塞等待任務執行完畢,下面是一段無需額外線程的異步編程實現代碼:

class TaskExecutor {
    static let instance = TaskExecutor()
    private var executing: Bool = false
    private var tasks: [() -> ()] = Array()
    private var queue: DispatchQueue = DispatchQueue.init(label: "SerialQueue")
    
    func pushTask(task: @escaping () -> ()) {
        tasks.append(task)
        if !executing {
            execute()
        }
    }
    
    func execute() {
        executing = true
        let executedTasks = tasks
        tasks.removeAll()
        executedTasks.forEach {
            $0()
        }
        if tasks.count > 0 {
            execute()
        } else {
            executing = false
        }
    }
}

TaskExecutor.instance.execute()
TaskExecutor.instance.pushTask {
    print("abc")
    TaskExecutor.instance.pushTask {
        print("def")
    }
    print("ghi")
}
複製代碼

單向流動

若是A事件會觸發B事件,反之不成立,能夠認爲兩個事件是單向的,比如說我餓了,因此我去吃東西,但不會是我去吃東西,因此我餓了。在編程中,若是數據流動可以保證單向,會讓程序變得更加簡單。舉個例子,下面是一段非單向流動的常見UI代碼:

func tapped(signIn: UIButton) {
    LoginManager.manager.signIn(username, password: password) { (err) in
        guard err == nil else {
            ERR_LOG("sign in failed: \(err)")
            return
        }
        UserManager.manager.switch(to: username)
        MainPageViewController.enter()
    }
}
複製代碼

在這段代碼中,Action實際上會等待State/Data完成後,去更新ViewView會再去訪問數據更新狀態,這種邏輯會讓數據在不一樣事件模塊中隨意流動,易讀性和可維護性都會變得更差:

而一旦事件之間的流動採用了異步編程的方式來處理,發出事件的人不關心等待事件的處理,無疑能讓數據的流動變得更加單一,Combine的意義就在於此。SwiftUI與其結合來控制業務數據的單向流動,讓開發複雜度大大下降:

來自淘寶技術

Publisher

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

    /// The kind of values published by this publisher.
    associatedtype Output

    /// The kind of errors this publisher might publish.
    ///
    /// Use `Never` if this `Publisher` does not publish errors.
    associatedtype Failure : Error

    /// This function is called to attach the specified `Subscriber` to this `Publisher` by `subscribe(_:)`
    ///
    /// - SeeAlso: `subscribe(_:)`
    /// - Parameters:
    ///     - subscriber: The subscriber to attach to this `Publisher`.
    ///                   once attached it can begin to receive values.
    func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}
複製代碼

Publisher定義了發佈相關的兩個信息:OutputFailure,對應事件輸出值和失敗處理兩種狀況,以及提供了receive(subscriber:)接口註冊事件訂閱者。在iOS13以後,蘋果基於Foundation標準庫實現了不少Combine的響應式接口,包括:

  • URLSessionTask能夠在請求完成或者請求出錯時發出消息

  • NotificationCenter新增響應式編程接口

以官方的NotificationCenter擴展爲例建立一個登陸操做的Publisher

extension NotificationCenter {
    struct Publisher: Combine.Publisher {
        typealias Output = Notification
        typealias Failure = Never
        init(center: NotificationCenter, name: Notification.Name, Object: Any? = nil)
    }
}

let signInNotification = Notification.Name.init("user_sign_in")

struct SignInInfo {
    let username: String
    let password: String
}

let signInPublisher = NotificationCenter.Publisher(center: .default, name: signInNotification, object: nil)
複製代碼

另外還須要注意的是:Self.Output == S.Input限制了PublisherSubscriber之間的數據流動必須保持類型一致,大多數時候老是很難維持一致性的,因此Publisher一樣提供了map/compactMap的高階函數對輸出值進行轉換:

/// Subscriber只接收用戶名信息
let signInPublisher = NotificationCenter.Publisher(center: .default, name: signInNotification, object: nil)
    .map {
        return ($0 as? SignInfo)?.username ?? "unknown"
    }
複製代碼

Subscriber

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

    /// The kind of values this subscriber receives.
    associatedtype Input

    /// The kind of errors this subscriber might receive.
    ///
    /// Use `Never` if this `Subscriber` cannot receive errors.
    associatedtype Failure : Error

    /// Tells the subscriber that it has successfully subscribed to the publisher and may request items.
    ///
    /// Use the received `Subscription` to request items from the publisher.
    /// - Parameter subscription: A subscription that represents the connection between publisher and subscriber.
    func receive(subscription: Subscription)

    /// Tells the subscriber that the publisher has produced an element.
    ///
    /// - Parameter input: The published element.
    /// - Returns: A `Demand` instance indicating how many more elements the subcriber expects to receive.
    func receive(_ input: Self.Input) -> Subscribers.Demand

    /// Tells the subscriber that the publisher has completed publishing, either normally or with an error.
    ///
    /// - Parameter completion: A `Completion` case indicating whether publishing completed normally or with an error.
    func receive(completion: Subscribers.Completion<Self.Failure>)
}
複製代碼

Subscriber定義了一套receive接口用來接收Publisher發送的消息,一個完整的訂閱流程以下圖:

在訂閱成功以後,receive(subscription:)會被調用一次,其類型以下:

@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public protocol Subscription : Cancellable, CustomCombineIdentifierConvertible {

    /// Tells a publisher that it may send more values to the subscriber.
    func request(_ demand: Subscribers.Demand)
}
複製代碼

Subscription能夠認爲是單次訂閱的會話,其實現了Cancellable接口容許Subscriber中途取消訂閱,釋放資源。基於上方的NotificationCenter代碼,完成Subscriber的接收部分:

func registerSignInHandle() {
    let signInSubscriber = Subscribers.Assign.init(object: self.userNameLabel, keyPath: \.text)
    signInPublisher.subscribe(signInSubscriber)
}

func tapped(signIn: UIButton) {
    LoginManager.manager.signIn(username, password: password) { (err) in
        guard err == nil else {
            ERR_LOG("sign in failed: \(err)")
            return
        }
        let info = SignInfo(username: username, password: password)
        NotificationCenter.default.post(name: signInNotification, object: info)
    }
}
複製代碼

Combine與UIKit

得力於Swift5.1的新特性,基於PropertyWrapperCombine標準庫,可讓UIKit一樣具有綁定數據流動的能力,預設代碼以下:

class ViewController: UIViewController {

    @Publishable(initialValue: "")
    var text: String
    
    let textLabel = UILabel.init(frame: CGRect.init(x: 100, y: 120, width: 120, height: 40))

    override func viewDidLoad() {
        super.viewDidLoad()
        textLabel.bind(text: $text)
        
        let button = UIButton.init(frame: CGRect.init(x: 100, y: 180, width: 120, height: 40))
        button.addTarget(self, action: #selector(tapped(button:)), for: .touchUpInside)
        button.setTitle("random text", for: .normal)
        button.backgroundColor = .blue
        
        view.addSubview(textLabel)
        view.addSubview(button)
    }
    
    @objc func tapped(button: UIButton) {
        text = String(arc4random() % 101)
    }
}
複製代碼

每次點擊按鈕的時候生成隨機數字符串,而後textLabel自動更新文本

Publishable

字符串在發生改變的時候須要更新綁定的label,在這裏使用PassthroughSubject類對輸出值類型作強限制,其結構以下:

@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
final public class PassthroughSubject<Output, Failure> : Subject where Failure : Error {

    public init()

    /// This function is called to attach the specified `Subscriber` to this `Publisher` by `subscribe(_:)`
    ///
    /// - SeeAlso: `subscribe(_:)`
    /// - Parameters:
    ///     - subscriber: The subscriber to attach to this `Publisher`.
    ///                   once attached it can begin to receive values.
    final public func receive<S>(subscriber: S) where Output == S.Input, Failure == S.Failure, S : Subscriber

    /// Sends a value to the subscriber.
    ///
    /// - Parameter value: The value to send.
    final public func send(_ input: Output)

    /// Sends a completion signal to the subscriber.
    ///
    /// - Parameter completion: A `Completion` instance which indicates whether publishing has finished normally or failed with an error.
    final public func send(completion: Subscribers.Completion<Failure>)
}
複製代碼

Publishable的實現代碼以下(7.25更新):

@propertyWrapper
public struct Publishable<Value: Equatable> {
    private var storage: Value
    var publisher: PassthroughSubject<Value?, Never>
    
    public init(initialValue value: Value) {
        storage = value
        publisher = PassthroughSubject<Value?, Never>()
        Publishers.AllSatisfy
    }
    
    public var wrappedValue: Value {
        get { storage }
        set {
            if storage != newValue {
                storage = newValue
                publisher.send(storage)
            }
        }
    }

    public var projectedValue: Publishable<Value> {
        get { self }
    }
}
複製代碼

UI extensions

經過extension對控件進行擴展支持屬性綁定:

extension UILabel {

    func bind(text: Publishable<String>) {
        let subscriber = Subscribers.Assign.init(object: self, keyPath: \.text)
        text.publisher.subscribe(subscriber)
        self.text = text.value
    }
}
複製代碼

這裏須要注意的是,建立的Subscriber會被系統的libswiftCore持有,在控制器生命週期結束時,若是不能及時的cancel掉全部的subscriber,會致使內存泄漏:

func freeBinding() {
    subscribers?.forEach {
        $0.cancel()
    }
    subscribers?.removeAll()
}
複製代碼

最後放上運行效果:

其餘

從今年wwdc發佈的新內容,不難看出蘋果的野心,因爲Swift自己就是一門特別適合編寫DSL的語言,而在iOS13上新增的兩個標準庫讓項目的開發成本和維護成本變得更低的特色。因爲其極高的可讀性,開發者很容易就習慣新的標準庫。目前SwiftUI實時用到了UIKitCoreGraphics等庫,能夠看作是基於這些庫的抽象封裝層,隨着後續Swift的普及度,蘋果底層能夠換掉UIKit獨立存在,甚至實現跨平臺的大前端一統。固然目前蘋果上的大前端尚早,不過將來可期

參考閱讀

SwiftUI Session

Property Wrappers

Combine入門導讀

新晉網紅SwiftUI

相關文章
相關標籤/搜索