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
}
}
}
这段代码的问题:
- 要手动维护两个取消引用
- 状态初始化和取消逻辑分散
- 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 项目里的最佳实践:
- 让
@Published驱动请求:而不是在@Published之外另起一套 publisher - 用
.task(id:)替代手动 Task 管理:生命周期的取消交给 SwiftUI - deDuplicate + debounce 是搜索框的标配:减少不必要的请求
- AnyCancellable 用 Set 存,不要用属性存:省去手动 cancel 的麻烦
- Combine 是值传递时代的产物:记住它是在 SwiftUI 的方向之前设计的,有些设计已经需要调整
Combine 和 SwiftUI 的结合点本质上是数据流驱动 UI:publisher 发出值,SwiftUI 响应值变化重新渲染。把这层关系用对,很多"取消"的逻辑就不需要手动写了。
