iOS Swift 并发中的任务组织与取消
iOS Swift 并发中的任务组织与取消
Swift 并发把异步流程写成了接近同步代码的形式,但任务仍然具有生命周期、优先级和取消状态。如果只关注 async 与 await 的语法,而没有设计任务归属,就容易出现页面退出后继续更新界面、搜索结果乱序、并发子任务失败关系不明确等问题。
一、让界面状态在主执行域更新
页面模型通常需要在主执行域修改可观察状态。可以直接为整个模型声明 MainActor,减少每个方法重复切换线程的代码。
@MainActor
final class ArticleViewModel: ObservableObject {
enum State {
case idle
case loading
case content([Article])
case empty
case failed(String)
}
@Published private(set) var state: State = .idle
private let repository: ArticleRepository
init(repository: ArticleRepository) {
self.repository = repository
}
func load() async {
state = .loading
do {
let articles = try await repository.fetchArticles()
state = articles.isEmpty ? .empty : .content(articles)
} catch is CancellationError {
return
} catch {
state = .failed("数据加载失败")
}
}
}
取消并不是业务失败,因此通常不应该展示错误提示。真正的错误也应先转换成稳定的界面文案,避免直接暴露底层错误信息。
二、把任务绑定到页面生命周期
SwiftUI 的 task 修饰器会在视图出现时启动任务,并在视图离开时发出取消信号。
struct ArticleListView: View {
@StateObject var viewModel: ArticleViewModel
var body: some View {
content
.task {
await viewModel.load()
}
}
}
UIKit 中可以由控制器持有任务,并在合适的生命周期中取消:
final class ArticleViewController: UIViewController {
private var loadTask: Task<Void, Never>?
private let viewModel: ArticleViewModel
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
loadTask = Task {
await viewModel.load()
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
loadTask?.cancel()
loadTask = nil
}
}
是否在 viewDidDisappear 取消,要根据页面业务决定。如果任务需要在短暂遮挡期间继续,就应选择更准确的停止时机。
三、理解取消是协作式的
调用 cancel 只会设置取消状态,不会强行终止所有代码。挂起操作可能检查取消,长时间同步计算则需要主动检查。
func transform(_ records: [RawRecord]) async throws -> [Record] {
var output: [Record] = []
output.reserveCapacity(records.count)
for (index, record) in records.enumerated() {
if index.isMultiple(of: 100) {
try Task.checkCancellation()
}
output.append(convert(record))
}
return output
}
如果底层封装忽略取消,上层即使不再接收结果,网络或计算资源仍可能继续消耗。因此,数据层也需要把取消传递到底层能力。
四、用 async let 表达共同成败
当多个结果缺一不可时,async let 可以并发执行,并保持结构化的父子关系。
func loadHome() async throws -> HomeData {
async let profile = repository.fetchProfile()
async let orders = repository.fetchOrders()
async let notices = repository.fetchNotices()
return try await HomeData(
profile: profile,
orders: orders,
notices: notices
)
}
父任务离开作用域时,未完成的子任务会被取消。如果其中一个结果失败,整体加载失败。这段结构直接体现了“共同成败”的业务含义。
五、使用任务组处理动态数量任务
任务数量在运行时才能确定时,可以使用任务组:
func loadDetails(ids: [String]) async throws -> [Detail] {
try await withThrowingTaskGroup(of: Detail.self) { group in
for id in ids {
group.addTask {
try await repository.fetchDetail(id: id)
}
}
var details: [Detail] = []
for try await detail in group {
details.append(detail)
}
return details.sorted { $0.id < $1.id }
}
}
任务完成顺序不保证与添加顺序一致。如果界面或业务依赖原始顺序,需要携带索引并在收集后恢复,而不能假设返回顺序稳定。
六、解决搜索结果乱序
用户连续输入时,旧搜索可能比新搜索更晚返回。保存并取消上一任务,可以确保只接受最新输入。
@MainActor
final class SearchViewModel: ObservableObject {
@Published private(set) var items: [SearchItem] = []
private var searchTask: Task<Void, Never>?
private let repository: SearchRepository
func search(keyword: String) {
searchTask?.cancel()
searchTask = Task {
do {
try await Task.sleep(nanoseconds: 300_000_000)
let result = try await repository.search(keyword: keyword)
try Task.checkCancellation()
items = result
} catch is CancellationError {
return
} catch {
items = []
}
}
}
}
示例中的空数组仅代表产品明确约定的失败展示。如果业务要求保留旧结果,则不应在失败时清空。
七、谨慎使用非结构化任务
普通 Task 会继承当前执行上下文中的部分信息,但它仍需要有明确持有者。分离任务更适合真正独立、无需继承当前任务关系的工作,不适合为了绕过生命周期而随意使用。
创建任务前可以先回答三个问题:谁持有它,谁负责取消它,失败后由谁处理。任何一个问题没有答案,都意味着任务边界还不清晰。
八、测试并发时序
并发测试应覆盖成功之外的路径:
- 页面消失后是否停止更新界面。
- 连续搜索时是否只展示最后一次输入的结果。
- 某个并发子任务失败时,其他任务是否按业务规则取消。
- 取消是否被误显示为错误。
- 动态任务返回顺序变化时,最终排序是否正确。
使用可控制的仓库替身和延迟,可以稳定制造先后顺序,不必让测试依赖真实网络环境。
总结
Swift 并发的重点是任务结构,而不仅是语法。界面状态放在主执行域,任务与页面生命周期绑定,耗时工作主动响应取消,再根据共同成败或独立失败的业务关系选择并发方式,才能避免异步结果错乱并保持代码可推理。
- 点赞
- 收藏
- 关注作者
评论(0)