Combine 三巨头之 Publisher

6 阅读5分钟

什么是 Publisher?

Publisher 是 Combine 框架的核心协议,它定义了一件事:这个对象会随时间推移,向外发出一系列事件

public protocol Publisher {
    associatedtype Output   // 发布的数据类型
    associatedtype Failure: Error  // 可能出现的错误类型

    func receive<S>(subscriber: S) where
        S: Subscriber,
        Self.Failure == S.Failure,
        Self.Output == S.Input
}

Publisher 能发出三种事件,后两种一旦发出,事件流永远终止:

事件含义能发出几次
output一个新的值零次到无数次
failure发生了错误最多一次,之后终止
finished所有值发送完毕最多一次,之后终止

实际场景一:UI — 搜索框输入

这是日常开发中最常遇到 Publisher 的场景之一。用户在搜索框里打字,每次击键都是一个新的事件。

不用 Combine 的写法(Delegate)

// ViewController 要实现 UISearchBarDelegate
extension SearchViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        // 用户每次输入都触发这里
        // 如果要防抖(debounce),还得自己手动管理 Timer
        fetchResults(query: searchText)
    }
}

问题:防抖、取消上一次请求、多个输入源合并……每一个都要自己手动写。

用 Combine 的写法

import Combine

class SearchViewController: UIViewController {

    @IBOutlet weak var searchBar: UISearchBar!
    private var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()

        // searchBar.textDidChangePublisher 就是一个 Publisher
        // 它的 Output 是 String,Failure 是 Never(不会出错)
        searchBar.textDidChangePublisher       // ← 这就是 Publisher
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)  // 防抖:停止输入 300ms 后才发出
            .removeDuplicates()                // 过滤掉和上次一样的内容
            .sink { [weak self] searchText in   // 接上订阅者,数据才开始流动
                self?.fetchResults(query: searchText)
            }
            .store(in: &cancellables)  // 保存订阅,防止立刻被销毁
    }
}

// 💡 .sink 和 .store 是 Combine 三巨头中 Subscriber 的核心概念,
//    这里先知道"接收数据用 sink,保活用 store"即可,
//    详细原理会在《三巨头之 Subscriber》中展开。

💡 searchBar.textDidChangePublisher 就是一个现成的 Publisher。每次用户输入,它就发出一个新的 String 值(output)。用户不输入了,它既不 failure 也不 finished,就静静等着。

事件流长这样:

用户输入:s → sw → swi → swift

时间轴:
  ── "s" ── "sw" ── "swi" ── "swift" ──────────▶
                                        (用户停了,等待下一次输入)

实际场景二:网络请求 — 调用 API

网络请求是 Publisher 的另一个典型场景。请求会发出一个值(返回的数据),然后结束;或者发出一个错误(网络超时、404 等),然后终止

不用 Combine 的写法(回调)

func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
    let url = URL(string: "https://api.example.com/users/(id)")!
    URLSession.shared.dataTask(with: url) { data, response, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        guard let data = data else { return }
        do {
            let user = try JSONDecoder().decode(User.self, from: data)
            completion(.success(user))
        } catch {
            completion(.failure(error))
        }
    }.resume()
}

嵌套回调、错误处理分散、取消请求需要额外管理……

用 Combine 的写法

import Combine

struct User: Decodable {
    let id: Int
    let name: String
}

class UserViewModel: ObservableObject {
    @Published var user: User? = nil
    @Published var errorMessage: String? = nil
    
    private var cancellables = Set<AnyCancellable>()

    func fetchUser(id: Int) {
        let url = URL(string: "https://api.example.com/users/(id)")!

        // URLSession.shared.dataTaskPublisher(for:) 返回的就是一个 Publisher
        // Output: (data: Data, response: URLResponse)
        // Failure: URLError
        URLSession.shared.dataTaskPublisher(for: url)        // ← 这就是 Publisher
            .map(.data)                                      // 只取 data 部分
            .decode(type: User.self, decoder: JSONDecoder())  // 解码成 User
            .receive(on: DispatchQueue.main)                  // 切回主线程
            .sink(
                receiveCompletion: { [weak self] completion in
                    switch completion {
                    case .failure(let error):
                        self?.errorMessage = error.localizedDescription  // failure 事件
                    case .finished:
                        break  // finished 事件,正常结束
                    }
                },
                receiveValue: { [weak self] user in
                    self?.user = user  // output 事件:拿到了 User
                }
            )
            .store(in: &cancellables)
    }
}

💡 dataTaskPublisher 是 URLSession 内置的 Publisher。它最多发出一个 output(请求成功的数据),然后发出 finished;如果网络出错,则发出 failure 然后终止。

事件流长这样:

正常情况(请求成功):
  ── (data, response) ── finished ✓

出错情况(网络超时):
  ── failure(URLError.timedOut) ✗

两个场景的对比

UI 搜索框网络请求
Publisher 来源searchBar.textDidChangePublisherURLSession.dataTaskPublisher
Output 类型String(Data, URLResponse)
Failure 类型Never(不会出错)URLError
会发出几次 output无数次(用户不断输入)最多一次(请求返回)
会 finished 吗通常不会成功后会

如何自己创建一个 Publisher:PassthroughSubject

上面两个例子都是用系统提供的现成 Publisher。如果你需要手动控制何时发出值,可以用 PassthroughSubject

// 创建一个 Publisher,Output 是 String,Failure 是 Never
let subject = PassthroughSubject<String, Never>()

// 手动发出一个新值(output 事件)
subject.send("Hello")
subject.send("World")

// 手动发出完成(finished 事件)
subject.send(completion: .finished)

// 手动发出错误(failure 事件)
// subject.send(completion: .failure(someError))

这在写单元测试、或者需要把旧代码的回调桥接成 Publisher 时非常有用。


@Published 和 Publisher 的关系

@Published 是一个属性包装器,它本质上是自动帮你创建了一个 Publisher 的语法糖。

class UserViewModel: ObservableObject {
    @Published var name: String = ""
}

当你声明 @Published var name 时,Swift 自动生成了一个 $name,它就是一个 Publisher<String, Never>。每当 name 的值发生变化,$name 就自动发出新值。

name = "Alice"$name 发出 "Alice"
name = "Bob"$name 发出 "Bob"
  • Publisher协议,定义了"能发布事件"这件事的规范
  • @Published快捷方式,帮你自动实现了一个符合 Publisher 协议的发布者
  • PassthroughSubject手动版,你自己决定什么时候 send

与传统回调的对比

两者各有适用场景,没有绝对的优劣:

传统回调 / DelegateCombine Publisher
学习曲线低,直观易懂高,概念较多
适合场景简单的一对一事件响应多个异步流需要组合、变换
苹果框架集成原生支持,UIKit/AppKit 无缝使用部分旧 API 需要桥接
多流组合手动处理,代码量大操作符链式处理,优势明显
可读性简单场景下更易读复杂场景下更易读
取消订阅手动管理,成熟稳定Cancellable 统一管理

💡 Delegate 是苹果生态中成熟且够用的模式。Combine 的真正优势在于多个异步操作需要组合的复杂场景,而不是全面替代 Delegate。