文章

Swift响应式编程应用实践

响应式编程

“A unified, declarative API for processing values over time”

统一、声明式、为处理变化的值而生的 API

Apple在iOS13中,引入了Combine。在之前业界比较有名的是ReactiveX系列的Swift版本RxSwift。响应式编程也叫申明式编程,和我们常用的指令式变成有很大区别。

响应式编程能做到

  • Target/Action
  • 通知中心
  • URLSession
  • KVC
  • 定制的回调

Combine

Combine 框架有三个核心概念

  • 发布者(Publisher)
  • 订阅者(Subscriber)
  • 操作符(Operator)

Publisher

Apple 为官方基础库中的很多常用类提供了 Combine 拓展 Publisher,如 Timer, NotificationCenter, Array, URLSession, KVO 等。

看个URLSession的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// `cancellable` 是用于取消订阅的 token,下文会详细介绍
cancellable = URLSession.shared
    // 生成一个 https://example.com 请求的 Publisher
    .dataTaskPublisher(for: URL(string: "https://example.com")!)
    // 将请求结果中的 Data 转换为字符串,并忽略掉空结果,下面会详细介绍 compactMap - Operator
    .compactMap {
        String(data: $0.data, encoding: .utf8)
    }
    // 在主线程接受后续的事件 (上面的 compactMap 发生在 URLSession 的线程中)-- Operator
    .receive(on: RunLoop.main)
    // 对最终的结果(请求结果对应的字符串)进行消费 - Subscriber
    .sink { _ in
        //
    } receiveValue: { resultString in
        self.textView.text = resultString
    }

看个NotificationCenter例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let trickNamePublisher = NotificationCenter.default.publisher(for: .newTrickDownloaded) // Publisher
.map { noti in  // Operator
     return noti.userInfo?["data"] as! Data
}
.tryMap { data in 
     let decoder = JSONDecoder()
     try decoder.decode(MagicTrick.self, from: data)
}

let cancellable = NotificationCenter.default
    .publisher(for: UserCenter.userStateChanged)
    .flatMap { value in
        return requestingAPI().materialize()
    }
    .sink { text in
        titleLabel.text = text
    }

还有一些特殊的 Publisher 也十分有用:

  • Future:只会产生一个事件,要么成功要么失败,适用于大部分简单回调场景
  • Just:对值的简单封装,如Just(1)
  • @Published:下文会详细介绍 在大部分情况下,使用这些特殊的Publisher 以及下文介绍的Subject 可以灵活组合出满足需要的事件源。极少的情况下,需要实现自定义的 Publisher ,可以看这篇文章。

看一个KVO的实现例子,活用@Published修饰属性,有点和useState类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 声明变量
class Alarm {
    @Published
    public var countDown = 0
}

let alarm = Alarm()

// 订阅变化
let cancellable = alarm.$countDown // Published<Int>.Publisher
    .sink { print($0) }

// 修改 countDown,上面 sink 的闭包会触发,有点像useState,对属性进行订阅
alarm.countDown += 1

结合UI的一个@Published例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Published var password: String = ""
@Published var passwordAgain: String = ""

let printerSusdription = $password.sink {
	print("The published value is '\($0)'")
}

@IBAction func passwordChanged(_ sender: UITextFiled) {
  // 用户输入明码,就会触发上面的sink封装
	password = sender.text ?? ""
}

@IBAction func passwordAgainChanged(_ sender: UITextFiled) {
  // 用户输入明码,就会触发下面的sink封装
	password = sender.text ?? ""
}

var validatePassword: AnyPublisher<String?, Never> {
  return CobineLatest($password, $passwordAgain) { password, passwordAgain in
    guard password == passwordAgain, password.count > 8 else { retur nil }
    return password                                              
  }
  .map { $0 == "12345678" ? nil : $0 } // 这里可以换成正则表达式,代表太简单的密码不生效
  .eraseToAnyPublisher() // 类型擦除,见下文
}

Cancellable & AnyCancellable

一个订阅都会生成一个 AnyCancellable 对象,用于控制订阅的生命周期。通过这个对象,我们可以取消订阅。当这个对象被释放时,订阅也会被取消。

1
2
// 取消订阅
cancellable.cancel()

需要注意的是,每一个订阅我们都需要持有这个 cancellable,否则整个订阅会立即被取消并结束掉

Operator

对于数组和字典我们常用的高阶函数,也都有一样的的响应式Operator方法,常见的有

1
2
3
4
5
6
7
8
9
10
11
map // Publishers.Map
flatMap // Publishers.FlatMap
filter // Publishers.Filter
reduce // Publishers.Reduce
combineLatest // Publishers.CombineLatest
tryMap // Publishers.TryMap
tryCatch // Publishers.TryCatch
decode // Publishers.Decode
replaceError // Publishers.ReplaceError
zip //将多个输入的单次值组合后转换为单个元组,所有输入都有输入值才能产生输出
combineLatest // 将多个输入的单次值组合后转换为单个值,其中一个输入有输入值就能产生输出

使用起来基本都同高阶函数的用法

1
2
3
4
5
6
7
8
9
10
11
12
13
[1, 2, 3].publisher
    .map { $0 * 10 }
    .sink { value in
        // 将会答应出 10, 20, 30
        print(value)
    }

// Publiser<Int?, Never> -> Publisher<Int, Never>
cancellable = [1, nil, 2, 3].publisher
        .compactMap { $0 }
        .map { $0 * 10 }
        .sink { print($0) }
        // 将会答应出 10, 20, 30

类型擦除操作

Combine 中的 Publisher 在经过各种 Operator 变换之后会得到一个多层泛型嵌套类型:

1
2
3
4
URLSession.shared.dataTaskPublisher(for: URL(string: "https://resso.com")!)
    .map { $0.data }
    .decode(type: String.self, decoder: JSONDecoder())
// 这个 publisher 的类型是 Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher, JSONDecoder. Input>, String, JSONDecoder>

如果在 Publisher 创建变形完成后立即订阅消费,这并不会带来任何问题。但一旦我们需要把这个 Publisher 提供给外部使用时,复杂的类型会暴露过多内部实现细节,同时也会让函数/变量的定义非常臃肿。Combine 提供了一个特殊的操作符 erasedToAnyPublisher,让我们可以擦除掉具体类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 生成一个类型擦除后的请求。函数的返回值更简洁
func requestRessoAPI() -> AnyPublisher<String, Error> {
    let request = URLSession.shared.dataTaskPublisher(for: URL(string: "https://resso.com")!)
        .map { $0.data }
        .decode(type: String.self, decoder: JSONDecoder())
    // Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher, JSONDecoder. Input>, String, JSONDecoder>
    // to
    // AnyPublisher<String, Error>
    return request.eraseToAnyPublisher()
}

// 在模块外,不用关心 `requestRessoAPI()` 返回的具体类型,直接进行消费
cancellable = requestRessoAPI().sink { _ in

} receiveValue: {
    print($0)
}

通过类型擦除,最终暴露给外部的是一个简单的 AnyPublisher<String, Error>。

Debugging调试操作

print 和 handleEvents

print 可以打印出整个订阅过程从开始到结束的 Subscription 变化与所有值,例如:

1
2
3
4
5
cancellable = [1, 2, 3].publisher
  .receive(on: DispatchQueue.global())
  // 使用 `Array Publisher` 作为所有打印内容的前缀
  .print ( "Array Publisher")
  .sink { _ in }

可以得到:

1
2
3
4
5
6
7
Array Publisher: receive subscription: (ReceiveOn)
Array Publisher: request unlimited
Array Publisher: receive cancel
Array Publisher: receive value: (1)
Array Publisher: receive value: (2)
Array Publisher: receive value: (3)
Array Publisher: receive finished

在一些情况下,我们只对所有变化中的部分事件感兴趣,这时候可以用 handleEvents 对部分事件进行打印。类似的还有 breakpoint,可以在事件发生时触发断点。

Subscriber

Subsriber 作为事件的订阅端,它的定义与 Publisher 对应,Publisher 中的 Output对应Subscriber 的 Input。常用的 Subscriber 有 Sink 和 Assign。

  • Key Path 赋值
  • Sink
  • Assign
  • Subjects
  • SwiftUI

Sink 直接对事件流进行订阅使用,可以对 Value 和 completion 分别进行处理。

1
2
3
4
5
6
7
8
// 从数组生成一个 Publisher
cancellable = [1, 2, 3, 4, 5].publisher
    .sink { completion in
        // 处理事件流结束
    } receiveValue: { value in
        // 打印会每个值,会依次打印出 1, 2, 3, 4, 5
        print(value)
    }1.2.3.4.5.6.7.8.

Assign 是一个特化版的 Sink ,支持通过 KeyPath 直接进行赋值。

1
2
3
4
5
let textLabel = UILabel()
cancellable = [1, 2, 3].publisher
    // 将 数字 转换为 字符串,并忽略掉 nil ,下面会详细介绍这个 Operator
    .compactMap { String($0) }
    .assign(to: \.text, on: textLabel)

需要留意的是,如果用 assign 对 self 进行赋值,可能会形成隐式的循环引用,这种情况需要改用 sink 与 weak self 手动进行赋值。

####

参考资料

从响应式编程到 Combine 实践

初探WWDC的Combine

再探WWDC的Combine

携程的深入浅出 Apple 响应式框架 Combine

RxSwift中文文档

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

本文由作者按照 CC BY 4.0 进行授权