SwiftUI 配合 Combine:为什么我不再手动管理网络请求的取消

SwiftUI 配合 Combine:为什么我不再手动管理网络请求的取消

SwiftUI 的 .task 修饰符带自动取消,但我发现很多项目里 combine 的 publisher 用法还停留在"发起请求 → 存着 → 手动 cancel"这个模式。

其实 combine 的取消机制可以和 SwiftUI 生命周期无缝衔接,只要用对方式。

手动取消的经典反模式

先看一个常见的写法:

class SearchViewModel: ObservableObject {
    @Published var query = ""
    @Published var results: [SearchResult] = []
    @Published var isLoading = false

    private var searchCancellable: AnyCancellable?
    private var currentTask: Task<Void, Never>?

    func search() {
        // 取消旧请求
        searchCancellable?.cancel()
        currentTask?.cancel()

        isLoading = true

        searchCancellable = API.search(query: query)
            .receive(on: DispatchQueue.main)
            .sink { completion in
                self.isLoading = false
                if case .failure(let error) = completion {
                    print("Error: \(error)")
                }
            } receiveValue: { results in
                self.results = results
            }
    }
}

这段代码的问题:

  1. 要手动维护两个取消引用
  2. 状态初始化和取消逻辑分散
  3. viewModel 销毁时不一定触发取消

@Published 和 combine 的正确衔接方式

combine 和 SwiftUI 真正好的用法,是让 @Published 属性直接作为 publisher,而不是另外起一个 AnyCancellable

@MainActor
final class SearchViewModel: ObservableObject {
    @Published var query = ""
    @Published var results: [SearchResult] = []
    @Published var isLoading = false

    private var cancellables = Set<AnyCancellable>()

    init() {
        // 关键:把 query 的变化直接转成搜索请求
        $query
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .removeDuplicates()
            .sink { [weak self] newQuery in
                self?.performSearch(query: newQuery)
            }
            .store(in: &cancellables)
    }

    private func performSearch(query: String) {
        guard !query.isEmpty else {
            results = []
            return
        }

        isLoading = true

        Task {
            do {
                let searchResults = try await API.search(query: query)
                await MainActor.run {
                    self.results = searchResults
                    self.isLoading = false
                }
            } catch is CancellationError {
                // 预期内的取消,不做处理
            } catch {
                await MainActor.run {
                    self.isLoading = false
                }
            }
        }
    }
}

这里的改进:

  • 不需要手动取消 searchCancellable
  • query 变化自动触发搜索
  • 但还有一个问题:Task 的取消仍然需要处理

@Task 修饰符:Combine + SwiftUI 生命周期的完美结合

iOS 15+ 的 .task(id:) 修饰符提供了真正的生命周期绑定:

struct SearchView: View {
    @StateObject private var viewModel = SearchViewModel()

    var body: some View {
        VStack {
            TextField("搜索", text: $viewModel.query)

            if viewModel.isLoading {
                ProgressView()
            } else {
                List(viewModel.results) { result in
                    Text(result.title)
                }
            }
        }
        .task(id: viewModel.query) {
            // query 变化时自动取消旧任务
            await viewModel.search(query: viewModel.query)
        }
    }
}

.task(id:) 的语义是:当 id 变化时,取消旧任务,执行新任务。这比手动维护 currentTask?.cancel() 简洁得多。

一个完整的请求封装

Combine 在请求层的最佳实践是用 Future + Task 封装:

extension API {
    static func search(query: String) async throws -> [SearchResult] {
        try await withCheckedThrowingContinuation { continuation in
            URLSession.shared.dataTask(with: makeSearchRequest(query: query)) { data, response, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                guard let data else {
                    continuation.resume(throwing: APIError.noData)
                    return
                }
                do {
                    let results = try JSONDecoder().decode([SearchResult].self, from: data)
                    continuation.resume(returning: results)
                } catch {
                    continuation.resume(throwing: error)
                }
            }.resume()
        }
    }
}

然后在 ViewModel 里:

@MainActor
final class SearchViewModel: ObservableObject {
    @Published var query = ""
    @Published var results: [SearchResult] = []
    @Published var error: Error?

    private var currentTask: Task<Void, Never>?

    func search(query: String) async {
        guard !query.isEmpty else {
            results = []
            return
        }

        currentTask?.cancel()
        currentTask = Task {
            do {
                let searchResults = try await API.search(query: query)
                guard !Task.isCancelled else { return }

                self.results = searchResults
                self.error = nil
            } catch is CancellationError {
                // 预期取消,不做处理
            } catch {
                guard !Task.isCancelled else { return }
                self.error = error
            }
        }
    }
}

Combine 的 operators:链式请求处理

Combine 真正的威力在 operators。以下场景不需要手动写循环和判断:

多条件组合请求

// 当 filter 和 query 同时满足时才发请求
Publishers.CombineLatest($filter, $query)
    .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
    .removeDuplicates()
    .filter { filter, query in !query.isEmpty }
    .sink { filter, query in
        performSearch(filter: filter, query: query)
    }
    .store(in: &cancellables)

请求去重

$query
    .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
    .removeDuplicates()  // 连续相同 query 不触发
    .sink { _ in }
    .store(in: &cancellables)

竞态处理(后发先至取消):

$searchText
    .sink { [weak self] text in
        self?.currentSearchTask?.cancel()
        self?.currentSearchTask = Task {
            let results = try await API.search(text: text)
            guard !Task.isCancelled else { return }
            self?.results = results
        }
    }
    .store(in: &cancellables)

资源清理:cancellables 的内存管理

.store(in: &cancellables) 需要一个 Set<AnyCancellable>,这个 set 会在 ObservableObject 销毁时自动清空所有 subscription。

final class ViewModel: ObservableObject {
    private var cancellables = Set<AnyCancellable>()

    // subscriptions 会在 ViewModel 销毁时自动取消
}

但有一种情况容易漏:给 class 的某个方法传 self 的弱引用时:

init() {
    $query
        .sink { [weak self] value in
            self?.handleQuery(value)  // self 可能已经销毁
        }
        .store(in: &cancellables)
}

这时候 cancellables 里存的是 AnyCancellable,即使 self 销毁了,只要 cancellables 还被 ViewModel 持有,这个 subscription 就还在。

正确的做法:

class ViewModel: ObservableObject {
    private var cancellables = Set<AnyCancellable>()

    func bind() {
        $query
            .sink { [weak self] value in
                self?.handleQuery(value)
            }
            .store(in: &cancellables)
    }
}

init() {
    bind()  // cancellables 绑定到 self 上
}

总结

Combine 在 SwiftUI 项目里的最佳实践:

  1. @Published 驱动请求:而不是在 @Published 之外另起一套 publisher
  2. .task(id:) 替代手动 Task 管理:生命周期的取消交给 SwiftUI
  3. deDuplicate + debounce 是搜索框的标配:减少不必要的请求
  4. AnyCancellable 用 Set 存,不要用属性存:省去手动 cancel 的麻烦
  5. Combine 是值传递时代的产物:记住它是在 SwiftUI 的方向之前设计的,有些设计已经需要调整

Combine 和 SwiftUI 的结合点本质上是数据流驱动 UI:publisher 发出值,SwiftUI 响应值变化重新渲染。把这层关系用对,很多"取消"的逻辑就不需要手动写了。

最后更新 4/20/2026, 4:48:48 AM