文章

Combine 入门与实战

Combine 入门与实战

Combine 框架提供了一套强大且一致的 API,用于处理异步事件和数据流。

本文介绍 Combine 的基本概念、优势,并通过系统 Publisher 和自定义 Publisher 示例,展示如何在实际项目中应用 Combine。 同时还解释了一些关键函数的使用方法。

什么是 Combine?

Combine 是 Apple 在 WWDC 2019 上发布的一个响应式编程框架,旨在简化和统一异步编程。它使用声明式编程模型,通过 Publisher 和 Subscriber 来处理异步事件。

在了解 Combine 框架之前,需要了解一些基本概念:

  • Publisher(发布者): 发布者是数据流的源头,它会在未来某个时间点发布值或完成事件(包括失败事件)。常见的发布者有 JustFuturePassthroughSubjectCurrentValueSubject
  • Subscriber(订阅者): 订阅者是数据流的终点,它会订阅发布者并对发布者发布的值或事件作出响应。常见的订阅者有 SinkAssign
  • Operator(操作符): 操作符用于对发布者发布的数据进行变换或处理,比如 mapfilterflatMap 等。
  • Cancellable(取消订阅): 当订阅者不再需要数据流时,可以取消订阅,释放资源。

使用 Combine 的优势

  • 一致性和可组合性:Combine 提供了一套统一的 API,可以处理不同类型的异步事件和数据流,如网络请求、计时器、用户输入等。
  • 简化错误处理:Combine 内建了错误处理机制,可以轻松捕获和处理异步操作中的错误。
  • 简化内存管理:Combine 的 Cancellable 机制有助于更好地管理内存,避免内存泄漏。
  • 更好的测试性:Combine 的声明式编程模型使得代码更容易测试,可以方便地模拟各种异步事件和数据流。

系统 Publisher 示例

以下是一个使用系统提供的 Publisher 示例,展示如何使用 Combine 处理网络请求并更新 UI。

定义网络服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Combine
import Foundation

struct User: Codable, Identifiable {
    let id: Int
    let name: String
}

class UserService {
    func fetchUsers() -> AnyPublisher<[User], Error> {
        let url = URL(string: "https://jsonplaceholder.typicode.com/users")!
        return URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: [User].self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}

定义视图模型

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
26
import SwiftUI
import Combine

class UserViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var errorMessage: String?
    
    private var cancellables = Set<AnyCancellable>()
    private let userService = UserService()
    
    func startFetchingUsers() {
        userService.fetchUsers()
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    break
                case .failure(let error):
                    self.errorMessage = error.localizedDescription
                }
            }, receiveValue: { users in
                self.users = users
            })
            .store(in: &cancellables)
    }
}

SwiftUI 视图

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
26
27
28
29
30
31
struct ContentView: View {
    @StateObject private var viewModel = UserViewModel()
    
    var body: some View {
        VStack {
            List(viewModel.users) { user in
                Text(user.name)
            }
            
            if let errorMessage = viewModel.errorMessage {
                Text("Error: \(errorMessage)")
                    .foregroundColor(.red)
                    .padding()
            }
            
            Button("Start Fetching Users") {
                viewModel.startFetchingUsers()
            }
            .padding()
        }
    }
}

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

自定义 Publisher 示例

系统提供的 Publisher 足以处理许多常见的异步任务,但你可能需要创建自定义的 Publisher 来处理特定的需求。

为了在 Combine 中创建自定义的 Publisher,你需要遵循 Publisher 协议并实现其必需的方法:receive(subscriber:) 和 Output、Failure 类型别名。自定义的 Publisher 可以用于发布自定义事件或数据流,例如从自定义数据源获取数据或处理特定事件。

下面是一个示例,展示如何创建一个自定义的 Publisher,并在你的视图模型中使用它。

定义自定义 Publisher

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import Combine
import Foundation

struct TimerPublisher: Publisher {
    typealias Output = Int
    typealias Failure = Never
    
    private let interval: TimeInterval
    
    init(interval: TimeInterval) {
        self.interval = interval
    }
    
    func receive<S>(subscriber: S) where S : Subscriber, TimerPublisher.Failure == S.Failure, TimerPublisher.Output == S.Input {
        let subscription = TimerSubscription(subscriber: subscriber, interval: interval)
        subscriber.receive(subscription: subscription)
    }
}

final class TimerSubscription<S: Subscriber>: Subscription where S.Input == Int, S.Failure == Never {
    
    private var subscriber: S?
    private var timer: Timer?
    private var counter = 0
    
    init(subscriber: S, interval: TimeInterval) {
        self.subscriber = subscriber
        self.timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
            self?.tick()
        }
    }
    
    func request(_ demand: Subscribers.Demand) {
        // 处理背压(Backpressure)的请求
    }
    
    func cancel() {
        timer?.invalidate()
        timer = nil
        subscriber = nil
    }
    
    private func tick() {
        _ = subscriber?.receive(counter)
        counter += 1
    }
}

使用自定义 Publisher

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import SwiftUI
import Combine

class TimerViewModel: ObservableObject {
    @Published var counter: Int = 0
    
    private var cancellables = Set<AnyCancellable>()
    
    func startTimer() {
        TimerPublisher(interval: 1.0)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] value in
                self?.counter = value
            }
            .store(in: &cancellables)
    }
    
    func stopTimer() {
        cancellables.forEach { $0.cancel() }
        cancellables.removeAll()
    }
}

struct ContentView: View {
    @StateObject private var viewModel = TimerViewModel()
    
    var body: some View {
        VStack {
            Text("Counter: \(viewModel.counter)")
                .font(.largeTitle)
                .padding()
            
            Button("Start Timer") {
                viewModel.startTimer()
            }
            .padding()
            
            Button("Stop Timer") {
                viewModel.stopTimer()
            }
            .padding()
        }
    }
}

说明:

  • 定义自定义 Publisher:
    • TimerPublisher 遵循 Publisher 协议,并实现了 receive(subscriber:) 方法。
    • TimerSubscription 是一个 Subscription,它负责创建一个计时器并向订阅者发布值。
  • 使用自定义 Publisher:
    • 在视图模型 TimerViewModel 中,使用 TimerPublisher 创建一个每秒发布一次计时器事件的发布者。
    • 通过 sink(receiveValue:) 订阅这个发布者,并将接收到的值更新到 counter 状态。

在基于 UIKit App 中的使用示例

由于 SwiftUI 本身集成了 Combine,很多实现基于 @Stete @Binding @StateObject 这些就很容易实现。 下面是一个 UIKit 中应用, 比如:页面中监听 UITextField 文本变化

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
26
27
28
29
30
import UIKit
import Combine

class ViewController: UIViewController {
    @IBOutlet weak var label: UILabel!
    @IBOutlet weak var tf: UITextField!
    
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        NotificationCenter.default
            .publisher(for: UITextField.textDidChangeNotification, object:tf)
            .map( { ($0.object as! UITextField).text ?? ""} )
            // sink 创建订阅者,获取完成值和每次订阅值
            //.sink(receiveCompletion: { print($0) },
            //      receiveValue: {
            //    self.label.text = $0
            //})
            // assign 创建订阅者,用于订阅值直接赋值,需要用键值操作赋值
            //.assign(to: \.self.label!.text, on: self)
            .assign(to: \UILabel.text, on: self.label)
            .store(in: &cancellables)
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        view.endEditing(true)
    }
}

关键函数解释

1. sink

sink 是一个订阅操作符,会生成订阅者,用于接收发布者发布的数据和完成事件。它有两种常见的用法:

接收值:这个变体只处理发布者发布的值,不处理完成事件。

1
func sink(receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable

接收完成事件和值:这个变体可以同时处理发布者的完成事件和发布的值。

1
func sink(receiveCompletion: @escaping ((Subscribers.Completion<Self.Failure>) -> Void), receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable

2. assign

assign 是一个订阅操作符,会生成订阅者,它用于拿到目标结果直接赋值的情况。需要用 key path 指定赋值对象

1
2
3
4
NotificationCenter.default
    .publisher(for: UITextField.textDidChangeNotification, object:tf)
    .map( { ($0.object as! UITextField).text ?? ""} )
    .assign(to: \UILabel.text, on: self.label)

3. store(in:)

store(in:) 用于将 AnyCancellable 对象存储在集合中,以便统一管理和取消订阅。通常在视图模型中使用,以便在视图模型销毁时自动取消订阅,避免内存泄漏。

1
2
3
4
5
6
7
private var cancellables = Set<AnyCancellable>()

publisher
    .sink(receiveValue: { value in
        print(value)
    })
    .store(in: &cancellables)

4. eraseToAnyPublisher

eraseToAnyPublisher 用于将具体的发布者类型转换为 AnyPublisher,从而隐藏发布者的具体类型。这在需要返回通用发布者时非常有用。

1
2
3
4
let publisher = URLSession.shared.dataTaskPublisher(for: url)
    .map { $0.data }
    .decode(type: [User].self, decoder: JSONDecoder())
    .eraseToAnyPublisher()

5. flatMap

flatMap 用于将一个发布者的输出转换为另一个发布者,并将其扁平化,以便在链式操作中处理嵌套的发布者。

1
2
3
4
5
6
7
8
9
10
11
Timer.publish(every: 10.0, on: .main, in: .common)
    .autoconnect()
    .flatMap { _ in
        self.userService.fetchUsers()
            .catch { _ in Just([]) }
    }
    .receive(on: DispatchQueue.main)
    .sink(receiveValue: { users in
        self.users = users
    })
    .store(in: &cancellables)

6. catch

catch 用于捕获发布者发出的错误,并提供一个替代的发布者,以便在错误发生时继续发布数据流。

1
2
3
4
5
6
7
8
userService.fetchUsers()
    .catch { error in
        Just([]) // 返回一个空数组作为默认值
    }
    .sink(receiveValue: { users in
        self.users = users
    })
    .store(in: &cancellables)

总结

Combine 框架为 Swift 开发者提供了一套强大且统一的工具,用于处理异步事件和数据流。通过理解基本概念和关键函数,你可以更高效地编写响应式代码,并在项目中利用 Combine 的优势。无论是系统提供的 Publisher 还是自定义的 Publisher,Combine 都能帮助你简化异步编程,提升代码的可读性和可维护性。

希望这篇文章能帮助你更好地理解和应用 Combine 框架。

参考

https://developer.apple.com/documentation/combine/receiving-and-handling-events-with-combine

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