Combine

作者: 奚山遇白 | 来源:发表于2020-03-26 13:44 被阅读0次

    本文内容基于 WWDC19 - 722 Introducing CombineWWDC19 - 721 Combine in Practice 整理,旨在帮助大家了解Combine响应式编程的基础知识。

    1. 简介

    在开发时我们可能经常会遇到很多异步处理行为,例如但不限于以下类型:

    Target/Action
    Notification center
    URLSession
    Key-value observing
    Ad-hoc callbacks

    因为这些异步行为会对 UI 造成影响,使得代码复杂化。例如一个普通注册页面的场景:


    注册页面示例

    你可能需要完成下面几个联动步骤:

    1. 有效的用户名(Key-value observing)
    2. 有效地密码和重复密码(Key-value observing)
    3. 检查密码与重复密码是否相同(Key-value observing)
    4. 创建按钮有效性的联动(Key-value observing/Notification center)
    5. 创建按钮点击(Target/Action)完成的网络请求(URLSession)结果处理(Notification center/Ad-hoc callbacks)

    可以预想到代码的复杂判断程度了,还需实时获取各个步骤的状态以实现联动,此时你可能会使用第三方诸如 RxSwift 等框架来帮助简化异步编程代码。而此次 WWDC 2019 苹果为了帮助开发者简化异步编程,官方发布了 Swift 的异步编程框架 - Combine,并承诺会对 Cocoa 框架提供紧密的结合支持,给异步编程带来了更优选。

    Combine :A unified, declarative API for processing values over time,一个统一的、为异步处理值的声明式 API。Combine 作用是将异步事件通过组合事件处理操作符进行自定义处理,是一个由请求驱动的,允许用户有机会更仔细地管理应用程序的内存使用和性能的响应式编程框架。该框架的主要思想以及常用功能API 与 RxSwift 都相同之处,感兴趣的可以点击:RxSwift to Combine Cheatsheet了解两者的联系与差异,快速从 RxSwift 切换至官方出品~

    2. Combine 特性

    Combine 是使用 swift 编写并用于 swift 的框架,这也意味着 Combine 可以受益于 Swift 的一些语言特性。

    2.1 Generic 泛型支持

    Combine 受益于 Swift 泛型带来的便利性。泛型能够让开发者编写自定义需求以及任意类型的灵活可用的的函数和类型,提取更多模板代码避免重复编码,用一种清晰和抽象的方式来表达代码的意图,而这也意味着我们使用 Combine 可以让异步操作的代码支持泛型,然后适配到各个种类的异步操作中。

    2.2 Type safe 类型安全

    同样受益于 Swift,类型安全的特性可以在编译时而非延迟到运行时去检查类型安全问题。

    2.3 Composition first 组合优先

    Combine 的主要设计理念是组合优先。这意味着核心设计可以简单且便于理解,但当组合在一起使用时,又能产生更优的效果。

    Combine 提供了ZipCombineLastest两个操作符用来对单个操作进行组合,详见章节3.3。

    2.4 Request driven 请求驱动

    Combine 是由请求驱动的,不同于平常开发中的事件驱动,并不由于事件特征发生而分发事件处理,而是基于请求和响应的设计思想,消费者向生产者请求某个事务的变化,当变化时生产者给消费者对应的响应。

    3. Combine 核心

    Combine 有三个核心概念:

    Publishers:发布者
    Subscribers:订阅者
    Operators:操作符

    示例如下图所示,发布者发出消息,利用操作符对该消息进行处理,向下传递给订阅者。


    flow

    3.1 Publishers 发布者

    发布者描述了 如何产生值和错误,所以它们并不一定是产生它们的东西,而是意味着作为一种描述,它们是值类型当然在swift 中我们将其作为结构体来使用。

    发布者在 Combine 框架中是以协议的形式进行具体实现的:

    public protocol Publisher {
    
        // 产生的值的类型
        associatedtype Output
    
        // 失败的错误类型,当发布者不产生错误时,可以使用 Never
        associatedtype Failure : Error
    
        // 发布者允许订阅者注册,实现这个方法,将调用 `subscribe(_:)` 订阅的订阅者附加到发布者上
        func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
    }
    
    extension Publisher {
        // 将订阅者附加到发布者上,供外部调用,不直接使用 `receive(_:)` 方法
        public func subscribe<S>(_ subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
    }
    

    结合 NotificationCenter 使用示例如下:

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

    如上代码所示,它是一个结构体,其输出类型为notifications,而失败类型是never。它使用三个变量进行初始化:中心,名称和对象。与现有的 NotificationCenter API相比比较类似,苹果官方声称并不会替换 NotificationCenter,而是会逐步适应。

    3.2 Subscribers 订阅者

    和 Publishers 发布者相对的,就是订阅者。订阅者定义了如何描述接受的值和错误,类似的,定义了关联类型 InputFailure。因为订阅服务器通常在收到值时行为并改变状态,所以我们使用 swift 中的引用类型,这意味着它们是类。在 Combine 框架中订阅者同样是一个协议:

    public protocol Subscriber : CustomCombineIdentifierConvertible {
    
        // 接受到的值的类型
        associatedtype Input
    
        // 可能接受到的错误的类型,如果订阅服务器无法接收失败,则可以使用类型never
        associatedtype Failure : Error
        
        // 以下三个为订阅者的关键功能
        // 接收到订阅的消息,其中订阅消息(Subsciption)描述如何控制发布者到订阅者的数据流动,用于表达发布者和订阅者之间的连接。
        func receive(subscription: Subscription)
    
        // 接收到产生的值的消息
        func receive(_ input: Self.Input) -> Subscribers.Demand
    
        // 如果订阅者所连接的发布者是有限的,那么它可能接收到产生已经终止的消息,不管是正常完成情况还是失败情况
        func receive(completion: Subscribers.Completion<Self.Failure>)
    }
    

    结合 assign 使用示例如下:

    extension Subscribers {
        class Assign<Root, Input>: Subscriber, Cancellable {
            typealias Failure = Never
        }
        init(object: Root, keyPath: ReferenceWritableKeyPath<Root, Input>)
    }
    

    assign 是一个类,它使用类的实例、对象的实例以及该对象的类型安全密钥路径初始化。其意义在于,当它接收到输入时,会将其写入该对象的属性。因为在swift中,当您只是编写属性值时,无法处理错误,所以assign的失败类型对应被设置为 never。

    发布者与订阅者两者之间的消息处理流程如下图所示:


    发布者与订阅者

    3.3 Operators 操作符

    前两小节讲解了发布者与订阅者的基本概念,那么我们尝试一下将这两个概念用起来。假如现在有一个巫师学校,巫师达到一定等级之后会毕业,此时需要更新对象模型的值,利用发布者与订阅者的概念,可能会产生如下代码:

    // Using Publisher and Subscriber
    class Wizard {
        var grade: Int
    }
    
    // 创建一个名为merlin的巫师,他的等级为5
    let merlin = Wizard(grade: 5)
    // 创建一个发布者以发布等级信息
    let graduationPublisher =
    NotificationCenter.Publisher(center: .default, name: .graduated, object: merlin)
    // 创建一个订阅者以接受等级信息
    let gradeSubscriber = Subscribers.Assign(object: merlin, keyPath: \.grade)
    
    // 将订阅者附加到发布者上
    // 此时编译器会告知我们一个类型不匹配的错误:Instance method 'subscribe' requires the types 'NotificationCenter.Publisher.Output' (aka 'Notification') and 'Int' be equivalent,因为
    graduationPublisher.subscribe(gradeSubscriber) 
    

    那么此时我们怎样将 Notification 转换为 int呢?


    transform

    这就引出了操作符的概念,操作符所做的是描述一种行为,用于更改值、添加值、删除值或任意数量的不同类型的行为,订阅另一个我们称之为上游的发布服务器,并将结果发送给我们称之为下游的订阅服务器。

    Combine 框架中,有以下几类声明式操作符 API:

    【1】函数式转换

    比如 mapfilterreduce 等函数式思想里的常见的高阶函数的操作符。

    【2】列表操作

    比如 firstdropappend 等在产生值序列的中使用便捷方法的操作符。

    【3】错误处理

    比如 catchretry 等进行错处理的操作符。

    【4】 线程/队列行为

    比如 subscribeOnreceiveOn 等对订阅和接受时线程进行指定的操作符。

    【5】 调度和时间处理

    比如 delaydebounce(去抖动),throttle(节流) 等操作符。

    那么等级判断的这个例子就可以选用合适的操作符 map 进行处理:

    // Using Operators
    let graduationPublisher =
    NotificationCenter.Publisher(center: .default, name: .graduated, object: merlin)
    let gradeSubscriber = Subscribers.Assign(object: merlin, keyPath: \.grade)
    
    // 使用操作符 map 对信息进行处理
    let converter = Publishers.Map(upstream: graduationPublisher) { note in
        return note.userInfo?["NewGrade"] as? Int ?? 0
    }
      
    converter.subscribe(gradeSubscriber)
    

    另外由于 Combine 组合优先的特性,苹果官方更是提供了诸如 zipCombineLatest操作符,帮我们进行多个发布者的组合。

    Zip 操作符可以通过传入多个发布者进行初始化,要求多个组合的发布者的的错误类型一致,而输出是多个组合的发布者合并成一个的元组。并且只有当组合的每一个发布者都产生值的时候,才会将值合并成元组发送给订阅者。

    extension Publishers {
        public struct Zip<A, B> : Publisher where A : Publisher, B : Publisher, A.Failure == B.Failure {
            public typealias Output = (A.Output, B.Output)
            public typealias Failure = A.Failure
            public let a: A
            public let b: B
            public init(_ a: A, _ b: B)
            public func receive<S>(subscriber: S) where S : Subscriber, B.Failure == S.Failure, S.Input == (A.Output, B.Output)
        }
    }
    
    zip

    与 zip 相似的 CombineLatest操作符使用多个发布者加上一个 transform 的转换闭包(在闭包中将两个产生的值处理并返回)进行初始化,同样也要求多个组合发布者的错误类型一致,输出是 transform 闭包里的 Output 类型。与 zip 所需组合的多个发布者均产生值时才合并为元组发布不同, CombineLatest当多个发布者中任意一个发布者产生值时,都会执行 transform 闭包的操作并将结果发送给订阅者。

    extension Publishers {
        public struct CombineLatest<A, B, Output> : Publisher where A : Publisher, B : Publisher, A.Failure == B.Failure {
            public typealias Failure = A.Failure
            public let a: A
            public let b: B
            public let transform: (A.Output, B.Output) -> Output
            public init(_ a: A, _ b: B, transform: @escaping (A.Output, B.Output) -> Output)
            public func receive<S>(subscriber: S) where Output == S.Input, S : Subscriber, B.Failure == S.Failure
        }
    }
    
    combineLatest

    4. 实际使用

    了解了以上内容,让我们回过头来看下本文最开始的注册页面的例子,里面需要输入用户名和密码,其中用户名需要经过服务器的检验是否有效,密码需要超过 8 个字符且需要和重复密码匹配。用户名和密码都符合要求时,下面的按钮状态将变成可点击状态,让我们使用 Combine 来完成这个🌰

    // 用注解给属性添加发布者
    // 注解即 PropertyWrapper,详细了解可参考 https://xiaozhuanlan.com/topic/5203689741#section-3
    @Published var password: String = ""
    @Published var passwordAgain: String = ""
    
    var valiatedPassword: AnyPublisher<String?, Never> {
          // 合并密码和重复密码发布者,当其中一个产生值时检查密码是否符合要求
        return Publishers.CombineLatest($password, $passwordAgain) { password, passwordAgain in
            guard password == passwordAgain, password.count > 8 else {
                return nil
            }
            return password
        }
          // 可以判断密码是不是太简单,比如 12345678
        .map { $0 == "password1" ? nil : $0}
          // 转换为 AnyPublisher
        .eraseToAnyPublisher()
    }
    
    @Published var username: String = ""
    
    // 提交给服务器判断用户名是否合法,网络请求等异步行为
    func usernameAvailable(_ username:String, completion:((Bool) -> ())) {
       // ...
    }
    
    var validatedUsername: AnyPublisher<String?, Never> {
          // 限制产生值的频率,去除抖动
        return $username.debounce(for: 0.5, scheduler: RunLoop.main)
                  // 去重,重复的不需要再次检验
            .removeDuplicates()
                  // 转换成新的发布者
            .flatMap { username in
                   // 使用 Future 适配已有的异步操作
                return Publishers.Future { promise in
                    usernameAvailable(username) { available in
                        promise(.success(available ? username : nil))
                    }
                }
            }
                  // 转换为 AnyPublisher
            .eraseToAnyPublisher()
    }
    
    var validatedCredentials: AnyPublisher<(String,String)?,Never> {
          // 合并检验密码和检验用户名发布者,均有合理值时发送
        return Publishers.Zip(validatedUsername, valiatedPassword) { username, password -> (String, String)? in
            guard let a = username, let b = password else {
                return nil
            }
            return (a, b)
        }
        .eraseToAnyPublisher()
    }
    
    var signupButton:UIButton!
    
    // 检查是否有合理的值
    var signupButtonStream = validatedCredentials.map{ $0 != nil }
                                                                                            // 指定接收的调度者
                                                .receive(on: RunLoop.main)
                                                                                            // 使用 KVO Assign 订阅者改变 UI 状态
                                                .assign(to: \.isEnabled, on: signupButton)
    

    参考:

    Apple 官方异步编程框架:Swift Combine 简介

    Apple 官方异步编程框架:Swift Combine 应用

    相关文章

      网友评论

        本文标题:Combine

        本文链接:https://www.haomeiwen.com/subject/wfqcuhtx.html