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 手动进行赋值。
####