Combine与SwiftUI

文章链接

尽管今年的WWDC已经落幕,但在过去的一个多月时间,苹果给iOS开发者带来了许多惊喜,其中堪称最重量级的当属SwiftUICombine两大新框架

在更早之前,由于缺少系统层的声明式UI语言,在iOS系统上的UI开发对于开发者而言,并不友善,而从iOS13开始,开发者们终于可以摆脱落后的布局系统,拥抱更简洁高效的开发新时代。与SwiftUI配套发布的响应式编程框架Combine提供了更优美的开发方式,这也意味着Swift真正成为了iOS开发者们必须学习的语言。本文基于Swift5.1版本,介绍SwiftUI是如何通过结合Combine完成数据绑定

SwiftUI

首先来个例子,假如我们要实现上图的登陆界面,按照以往使用UIKit进行开发,那么我们需要:

  • 创建一个UITextField,用于输入账户
  • 创建一个UITextField,用于输入密码
  • 创建一个UIButton,设置点击事件将前两个UITextField的文本作为数据请求

而在使用SwiftUI进行开发的情况下,代码如下:

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对应表:

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实现封装成通用逻辑,用关键字@propertyDelegate(更新于beta4版本,使用@propertyWrapper替代)修饰读写逻辑,并以@wrapperName var variable的方式封装变量。以WWDC Session 415视频中的例子实现对变量copy-on-write的封装:

@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.

引用官方文档的描述,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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345