UIKit 项目异步改造:Combine、async/await、@Published

1. 为什么 UIKit 项目也需要现代异步能力?

现在的应用,无论是前端、Flutter 还是移动端原生,都有一个共同趋势:异步操作越来越多、界面状态变化也越来越频繁。Web 有 async/await + Promise 统一异步流程,用事件流和状态管理驱动 UI;Flutter 用 sync/await + Future/Stream 处理异步,用 BLoC、Provider、Riverpod 聚合状态并更新界面。这些体系之所以能稳定,是因为都遵循了同一种现代异步模型:异步逻辑线性化、事件流可组合、UI 完全由状态驱动。

UIKit 项目同样也面临这些问题,随着 Swift Concurrency、Combine 和 @Published 逐渐成熟,UIKit 也具备了和 Web、Flutter 类似的现代异步能力:async/await 负责逻辑,Combine 管事件流,状态集中在 ViewModel,由 UI 自动响应变化。

在典型 UIKit 项目中,常见痛点包括:

  • 回调链复杂
    网络成功回调触发下一步逻辑,错误还需要额外处理,代码层层嵌套,维护成本高。
  • 状态散落在不同对象中
    加载状态、结果状态、错误状态分布在多个回调和对象中,难以追踪来源。
  • 任务缺乏统一管理
    重复请求、快速输入导致多个并发任务互相覆盖,旧任务结果可能反向污染最新 UI 状态。
  • UI 控件事件难以组合
    多个输入条件并存时,缺少简单方式将输入、节流、防抖、条件组合再串联到网络请求。

这些问题随着业务迭代,可能还会进一步放大,甚至影响体验与稳定性。而利用 Combine、async/await 与 @Published,可以让 UIKit 项目获得一套更加清晰的异步处理方式。它们覆盖了不同的异步需求:

  • Combine 更适合事件流,例如文本输入、按钮点击、通知变化。
  • async/await 更适合业务逻辑,例如网络请求、数据处理、串行任务。
  • @Published 更适合状态广播,例如 loading 状态、结果状态或错误信息。

在一起使用时,可以将输入、逻辑、状态三个部分划分得更清晰,让 ViewController 只负责 UI 展示,让 ViewModel 管理状态,让数据层只处理纯粹的异步 IO。UIKit 项目并不需要大规模重构,也无需迁移到 SwiftUI 就能有并发带来的提升。这种改造可以以一种渐进方式进行,比如从网络请求开始,再到事件输入管理,最后到状态统一管理,使旧项目保持稳定同时获得更高的可维护性与扩展能力。

接下来说说如何将这三种技术自然结合到 UIKit 的 MVVM 架构中,最终构建一个结构清晰且可扩展的异步系统。

2.核心概念:从 UIKit 的角度理解 Combine、async/await 与 @Published

在 UIKit 工程中引入现代异步工具时,最重要的不是掌握语法细节,而是弄清这些工具在工程架构中的角色定位。三种技术覆盖的关注点并不相同,它们之间并不存在替代关系,而是各自解决一类异步问题,因此能够自然地协同工作。以下从 UIKit 的实际需求切入,快速理清这三者的关键作用。

2.1 Combine:管理事件流的利器

UIKit 的大量行为本质是事件流:文本输入、按钮点击、滑动、应用通知、KVO 变化、定时器触发等都属于这类。一旦事件交织、组合或频繁触发,传统方式就会变得很乱。Combine 在这种场景中具备明显优势,提供了一种可组合、可预测的方式处理连续事件。在 UIKit 里,Combine 有几个非常实用的特性:

  • 文本输入防抖
    搜索框连续输入可能触发大量请求,可以用 Combine 的 debounce 来防抖。
  • 事件组合
    例如滚动事件与网络状态结合,决定是否加载下一页内容。
  • 对系统通知的优雅封装
    NotificationCenter.publisher 提供了一种比 selector 更简洁的处理方式。
  • KVO 的替代方案
    减少手动观察与移除观察者的操作。

Combine 让输入控制更加结构化,而不是散落在各处,尤其适合高频 UI 行为。

不过需要注意一点:UIKit 并没有为所有控件提供完整的 Combine 支持。 系统原生只提供:

  • UIControl 的事件(如 .touchUpInside / .valueChanged)
  • KVO 属性
  • NotificationCenter 发布端

对于 UITextView、UIScrollView、UIGestureRecognizer、UIBarButtonItem 等控件,没有现成 Publisher,需要依赖扩展。在实际工程中,更常采用以下三方库扩展 Combine 对 UIKit 的支持:

  • CombineCocoa(推荐)
    最成熟、最完整的 UIKit Combine 扩展库。
    支持:按钮点击、文本输入、文本视图、手势、滚动事件、导航条按钮等。
  • CombineExt
    不是专门的 UIKit 扩展库,但提供大量常用 Operator,能与 CombineCocoa 搭配使用。
  • CombineFeedback / ReactiveUIKit(可选)
    适用于更复杂的架构需求,偏工程化。

其中,CombineCocoa 几乎是 UIKit + Combine 项目的默认选择:

1
2
3
4
5
6
7
8
button.tapPublisher
.sink { ... }

textField.textPublisher
.sink { ... }

scrollView.didScrollPublisher
.sink { ... }

简洁、直观,非常容易融入现有 UIKit 架构。

总体而言,Combine 的原生能力足以覆盖系统事件,但在实际工程中往往需要使用扩展库才能做到“覆盖所有 UIKit 控件”。CombineCocoa 目前是最成熟的选择。

2.2 async/await:更易管理的异步业务逻辑

Combine 适合事件,而 async/await 更适合业务流程。在网络请求、数据处理、磁盘读取、权限获取、设备查询等操作中,通过 async/await 能够以接近同步的方式书写逻辑,避免传统回调方式中常见的深层嵌套或复杂状态机。在 UIKit 业务中,async/await 带来的直接收益包括:

  • 可读性更高
    一系列依赖顺序的异步逻辑看上去就像普通流程,维护起来更轻松。
  • 错误处理更集中
    从层层回调变成自然的 throw 异常传播,逻辑更清晰。
  • 任务生命周期可控
    Task 与 TaskGroup 可以与视图模型绑定,解决并发过量和内存泄漏问题。
  • 更自然的取消逻辑
    在快速输入或快速页面切换场景中,取消旧任务变得简单直接。

在 MVC 或 MVVM 中,async/await 理想使用场景是在网络层或业务逻辑层,可作为核心异步执行方案。

2.3 @Published:ViewModel 的现代状态广播方式

@Published 经常与 SwiftUI 一起出现,但它的本质来自 Combine,并不依赖 SwiftUI。在 UIKit 的 MVVM 架构中,@Published 的主要作用是用来广播状态的变化,让外部对状态变更保持透明和自动响应。相比 Notification 或 KVO,@Published 更适合用于构建结构化的状态系统:

  • 状态集中
    ViewModel 负责输出数据、管理错误、加载状态等。
  • 订阅简单
    ViewController 通过 Combine 订阅任意状态,变化时自动刷新 UI。
  • 意图明确
    看到 @Published 就知道这是会对外广播的状态,直观。
  • 更易测试
    状态流可以被订阅与验证,相比回调更易编写单元测试。

在 UIKit 环境下,@Published 主要用于表示「界面需要响应的状态」,例如搜索结果、状态更新、错误信息等。

2.4 三者之间的分工关系

  • Combine 管理连续事件,包括输入行为和系统事件。
  • async/await 管理核心异步任务,如网络请求、业务操作。
  • @Published 管理状态广播,将逻辑结果传回界面。

这种分工方式不仅减少耦合,还能自然地形成一种井然有序的异步架构:输入在ViewController,逻辑在ViewModel,结果由状态统一输出,不同模块之间的职责边界更加清晰。

下一章将从一个完整的示例场景开始,展示三者在 UIKit 项目的实际协同方式。

3. 示例:在 UIKit 中构建一个异步搜索界面

为了展示 Combine、async/await 与 @Published 在 UIKit 中的自然协同方式,下面章节将以一个简洁但具代表性的示例作为主线:搜索界面。

这个场景包含以下几个特征,非常适合作为现代异步改造的范例:

  • 输入具有实时性,搜索框输入通常是连续的,需要处理快速输入带来的事件风暴
  • 网络请求需要节流和取消,每次输入都可能触发新的请求,旧任务必须及时取消,防止结果乱序
  • 状态需要统一管理,包括加载状态、结果数据、错误提示,统一由 ViewModel 输出
  • UI 更新保持简单,控制器只做展示层,不承担业务逻辑

通过这个示例,可以看到现代异步逻辑在 UIKit 中如何通过自然组合形成清晰的结构的。

3.1 界面结构

页面由一个搜索输入框和一个列表组成,用于展示搜索结果。布局方式不作赘述,可以使用 Auto Layout、SnapKit 或直接 frame 布局,示例重点在异步行为本身。基本结构如下:

  • 顶部为文本输入框,输入内容随时变更,触发 Combine 事件流
  • 列表用于展示搜索结果,由 ViewModel 的 @Published 状态驱动更新
  • 后台通过 async/await 发起搜索请求,并自动取消之前旧任务
  • ViewController 对状态进行订阅,实现响应式的 UI 刷新

简洁的界面背后有多个典型异步任务。

图3-1 搜索界面UI
### 3.2 流程总览 整个搜索逻辑可以概括为以下几步,结构清晰且易维护: - 文本框内容变化形成事件流(Combine) - 防抖处理,避免请求过多 - 事件流进入 ViewModel - 每次输入触发新的搜索任务 - 网络请求通过 async/await 执行 - 旧任务自动取消 - 请求完成后更新 @Published 状态 - 控制器订阅状态并刷新表格 - 错误与加载状态同样通过状态流传递

输入、逻辑、状态分别由不同工具负责,各司其职,没有互相干扰,也没有分散在不同对象的回调。

3.3 示例模块划分

示例由三个主要部分组成:

  • Repository,提供 async 的搜索 API,可以是 URLSession,也可以是本地数据模拟,作用是为 ViewModel 提供异步接口
  • ViewModel,负责处理逻辑,接收输入事件,以 async/await 执行搜索,通过 @Published 输出结果与状态
  • ViewController,负责 UI 展示,从 Combine 输入事件触发搜索,订阅 ViewModel 状态更新 UI

这样的分工使页面行为和业务逻辑之间结构清晰,扩展或修改时影响范围更小。

3.4 完整代码

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
import UIKit
import Foundation
import Combine

struct SearchRepository {
enum SearchError: Error {
case emptyKeyword
case serverError
}
func search(keyword: String) async throws -> [String] {
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
throw SearchError.emptyKeyword
}
// 模拟网络延迟
try await Task.sleep(nanoseconds: 500_000_000) // 0.5 秒
// 随机模拟一次错误,演示错误处理
if Bool.random() {
throw SearchError.serverError
}
// 模拟返回结果
return ["「\(trimmed)」相关结果一", "「\(trimmed)」相关结果二", "「\(trimmed)」相关结果三", "「\(trimmed)」更多内容……"]
}
}

@MainActor
final class SearchViewModel: ObservableObject {
private let repository: SearchRepository
// Task<Success, Failure>
private var currentTask: Task<Void, Never>? // 成功返回空,不会返回失败
@Published var results: [String] = []
@Published var isLoading: Bool = false
@Published var errorMessage: String?

init(repository: SearchRepository) {
self.repository = repository
}
func performSearch(keyword: String) {
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)

if trimmed.isEmpty {
currentTask?.cancel()
currentTask = nil
results = []
isLoading = false
errorMessage = nil
return
}

currentTask?.cancel()

isLoading = true
errorMessage = nil

currentTask = Task { [weak self] in
guard let self else { return }

do {
let data = try await repository.search(keyword: trimmed)
try Task.checkCancellation()

results = data
isLoading = false
errorMessage = nil

} catch is CancellationError {
// 不更新 UI
} catch {
results = []
isLoading = false
errorMessage = "搜索失败,请稍后再试"
}
}
}
}
final class SearchViewController: UIViewController {

private let searchTextField = UITextField()
private let tableView = UITableView()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let errorLabel = UILabel()

private let viewModel = SearchViewModel(repository: SearchRepository())
private var cancellables = Set<AnyCancellable>()

override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupLayout()
bindViewModel()
bindSearchTextField()
}

private func setupUI() {
title = "搜索示例"
view.backgroundColor = .systemBackground

searchTextField.borderStyle = .roundedRect
searchTextField.placeholder = "输入关键字开始搜索"

tableView.dataSource = self
tableView.delegate = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")

loadingIndicator.hidesWhenStopped = true

errorLabel.textColor = .systemRed
errorLabel.font = UIFont.systemFont(ofSize: 14)
errorLabel.numberOfLines = 0
errorLabel.textAlignment = .center

view.addSubview(searchTextField)
view.addSubview(tableView)
view.addSubview(loadingIndicator)
view.addSubview(errorLabel)
}

private func setupLayout() {
searchTextField.translatesAutoresizingMaskIntoConstraints = false
tableView.translatesAutoresizingMaskIntoConstraints = false
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
errorLabel.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
searchTextField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
searchTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
searchTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
searchTextField.heightAnchor.constraint(equalToConstant: 40),

errorLabel.topAnchor.constraint(equalTo: searchTextField.bottomAnchor, constant: 8),
errorLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
errorLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),

tableView.topAnchor.constraint(equalTo: errorLabel.bottomAnchor, constant: 8),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),

loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}

private func bindViewModel() {
// 订阅搜索结果
viewModel.$results
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.tableView.reloadData()
}
.store(in: &cancellables)

// 订阅加载状态
viewModel.$isLoading
.receive(on: RunLoop.main)
.sink { [weak self] isLoading in
if isLoading {
self?.loadingIndicator.startAnimating()
} else {
self?.loadingIndicator.stopAnimating()
}
}
.store(in: &cancellables)

// 订阅错误信息
viewModel.$errorMessage
.receive(on: RunLoop.main)
.sink { [weak self] message in
self?.errorLabel.text = message
self?.errorLabel.isHidden = (message == nil)
}
.store(in: &cancellables)

// 如果一个UI状态需要订阅多个属性,有两种方案:
// 1. 在viewModel中组合一个UI属性供订阅
// 2. 用CombineLatest 组合多个属性
}

private func bindSearchTextField() {
// 使用 NotificationCenter + Combine 监听文本变化,并做防抖
NotificationCenter.default.publisher(
for: UITextField.textDidChangeNotification,
object: searchTextField
)
.compactMap { notification in
(notification.object as? UITextField)?.text
}
.debounce(for: .milliseconds(400), scheduler: RunLoop.main)
.removeDuplicates()
.sink { [weak self] text in
self?.viewModel.performSearch(keyword: text)
}
.store(in: &cancellables)
}
}

extension SearchViewController: UITableViewDataSource, UITableViewDelegate {

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
viewModel.results.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let item = viewModel.results[indexPath.row]
var config = cell.defaultContentConfiguration()
config.text = item
cell.contentConfiguration = config
return cell
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
}
}

3.5 示例的目的

通过这个示例能够看到以下关键点:

  • 输入节流如何用 Combine 实现
  • 快速输入时,如何让旧任务自动取消
  • 请求结果如何用 @Published 统一广播
  • 控制器如何以最小代码量完成 UI 刷新
  • 异步逻辑如何从控制器中完全抽离
  • 状态如何集中管理并自然驱动界面

这类模式在开发中普遍适用。无论是搜索页面、筛选界面、分页浏览、地图输入提示、动态表格加载,甚至复杂的业务面板,都可以用类似方式处理异步行为。下一章将进入 ViewModel 的实现部分,展示具体代码与架构如何结合,从输入到请求再到状态输出的完整链路。

4. 架构主体:UIKit + MVVM 中三者的分工与协作

为了让这套模式在更大的工程里也保持清晰,需要把 ViewController、ViewModel、Repository 三个角色的责任说清楚。这一章围绕三个问题展开:

  1. ViewController 到底该做什么
  2. ViewModel 应该承担哪些逻辑
  3. Repository 负责的边界在哪里

4.1 ViewController:只负责输入和展示

在传统 UIKit 写法中,ViewController 很容易变成“上帝类”:既监听输入,又调网络,还管状态和 UI 更新,代码膨胀得很快。搜索示例的目标是把逻辑尽量移出控制器,让控制器更干净,只保留下面几件事:

  1. 负责搭建和布局界面
  2. 把输入事件转交给 ViewModel
  3. 订阅 ViewModel 的状态并更新 UI

可以直接对照示例里的控制器结构来理解这种分工。控制器并不直接发起网络请求,而是通过 Combine 把文本变化变成一个输入流:

1
2
3
4
5
6
7
8
9
10
11
12
13
NotificationCenter.default.publisher(
for: UITextField.textDidChangeNotification,
object: searchTextField
)
.compactMap { notification in
(notification.object as? UITextField)?.text
}
.debounce(for: .milliseconds(400), scheduler: RunLoop.main)
.removeDuplicates()
.sink { [weak self] text in
self?.viewModel.performSearch(keyword: text)
}
.store(in: &cancellables)

这里,控制器关心的只是“文本变了”这件事,以及合适的节奏。至于搜索如何执行、任务如何取消、错误如何处理,都由 ViewModel 负责。

在输出侧,控制器订阅的是 ViewModel 持有的 @Published 状态:

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
viewModel.$results
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.tableView.reloadData()
}
.store(in: &cancellables)

viewModel.$isLoading
.receive(on: RunLoop.main)
.sink { [weak self] isLoading in
if isLoading {
self?.loadingIndicator.startAnimating()
} else {
self?.loadingIndicator.stopAnimating()
}
}
.store(in: &cancellables)

viewModel.$errorMessage
.receive(on: RunLoop.main)
.sink { [weak self] message in
self?.errorLabel.text = message
self?.errorLabel.isHidden = (message == nil)
}
.store(in: &cancellables)

控制器不做额外判断,只根据状态更新 UI。逻辑集中在 ViewModel,界面只负责响应状态,非常直接。这种分工有个明显好处:页面需要改版时,只改 UI 代码;业务逻辑变更时,优先动 ViewModel;对接新接口时,优先动 Repository。层次清晰,心里有底。

如果某个 ViewModel 属性需要被多个Controller订阅,其实@Published 和 Combine publisher 天然支持多订阅。需要注意的是:多个页面必须持有同一个 ViewModel 或同一个状态源(比如 AppStore),而不是各自 new 一份。可以通过在上层创建 ViewModel 并传入子页面,或者使用共享状态容器的方式来保证状态统一;如果上游逻辑较重,还可以用 share() 让多个订阅共享同一条数据流。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 共享状态容器(AppState / Store)
@MainActor
final class AppStore {
static let shared = AppStore()

@Published private(set) var user: User?
}
// 每个页面分别订阅
AppStore.shared.$user
.sink { [weak self] user in
// 根据 user 更新当前页面
}
.store(in: &cancellables)

这相当于一个轻量级“全局状态中心”,适合确实是全局状态(登录用户、主题、配置等)。

4.2 ViewModel:串联输入、异步逻辑和状态的核心

ViewModel 是这套架构的核心,负责把输入流转换为异步任务,再把结果映射为状态输出。在搜索示例里,ViewModel 的职责可以概括为三件事:

  1. 接收关键字,决定是否要发起搜索
  2. 通过 async/await 调用 Repository,负责任务取消和错误处理
  3. 更新 @Published 状态,让界面自动感知变化

示例中的 ViewModel 采用了 @MainActor 注解,保证所有状态更新都在主线程完成:

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
48
49
50
51
52
53
54
let defaultSearchRepository = SearchRepository()

@MainActor
final class SearchViewModel: ObservableObject {

private let repository: SearchRepository
private var currentTask: Task<Void, Never>?

@Published var results: [String] = []
@Published var isLoading: Bool = false
@Published var errorMessage: String?

init(repository: SearchRepository = defaultSearchRepository) {
self.repository = repository
}

func performSearch(keyword: String) {
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)

if trimmed.isEmpty {
currentTask?.cancel()
currentTask = nil
results = []
isLoading = false
errorMessage = nil
return
}

currentTask?.cancel()

isLoading = true
errorMessage = nil

currentTask = Task { [weak self] in
guard let self else { return }

do {
let data = try await repository.search(keyword: trimmed)
try Task.checkCancellation()

results = data
isLoading = false
errorMessage = nil

} catch is CancellationError {
// 任务被取消,不更新状态
} catch {
results = []
isLoading = false
errorMessage = "搜索失败,请稍后再试"
}
}
}
}

有几个点值得注意:

  • 输入检查在 ViewModel 内完成。空关键字时不发起请求,而是直接清空结果和状态,这种逻辑属于业务层,不应该留在控制器。
  • 任务取消逻辑集中在 ViewModel。每次搜索都会取消上一条 Task,避免快速输入导致的任务堆积和乱序问题。也就是说,“最新输入拥有最高优先级”。
  • 所有状态变化都通过 @Published 暴露出去。控制器只订阅 results、isLoading 和 errorMessage 三个属性,不关心内部逻辑细节。这种状态集中管理方式在复杂页面里会显得格外重要。

从框架角度看,ViewModel 正好是 Combine、async/await 和 @Published 的交汇点:

  • 文本输入通过 Combine 传入
  • 业务处理通过 async/await 完成
  • 结果与状态通过 @Published 传播出去

三者在这里实现了自然的组合。

4.3 Repository:只负责异步 IO

Repository 层的目标是提供清晰、可测试的异步接口,不参与 UI 与状态管理。示例中给出了最简化版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct SearchRepository {
enum SearchError: Error {
case emptyKeyword
case serverError
}

func search(keyword: String) async throws -> [String] {
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
throw SearchError.emptyKeyword
}
try await Task.sleep(nanoseconds: 500_000_000) // 模拟网络延迟
if Bool.random() {
throw SearchError.serverError
}
return ["「\(trimmed)」相关结果一", "「\(trimmed)」相关结果二", "「\(trimmed)」相关结果三", "「\(trimmed)」更多内容……"]
}
}

在真实项目中,这一层可以进一步扩展:

  1. 把 URLSession 请求封装为 async 函数
  2. 集中处理 HTTP 状态码、解析错误等通用逻辑
  3. 支持缓存逻辑,例如内存缓存或磁盘缓存
  4. 在单元测试中被轻松替换为 mock 实现

Repository 不需要关心 ViewModel 使用 @Published 还是别的方式,只暴露一个简单的 async 接口。这样做的好处是,未来如果要迁移到 SwiftUI,数据层都不需要大改。

4.4 三层协作关系的整体视角

把三部分合起来看,可以得到一条相当清晰的流程线:

  1. 控制器把 UITextField 的变化转成 Combine 事件流
  2. 事件流经 debounce 处理后交给 ViewModel
  3. ViewModel 调用 async/await 的 Repository 方法发起搜索
  4. 任务完成或取消后,ViewModel 更新 @Published 状态
  5. 控制器订阅这些状态并刷新表格、指示器和错误文案

输入、逻辑、状态各自的归属非常明确:

  • 输入属于控制器
  • 逻辑属于 ViewModel
  • 数据访问属于 Repository
  • 界面展示则由控制器在订阅状态后完成渲染更新

这种分工模式不是只服务于一个搜索界面,而是可以在整个 UIKit 项目中复用。无论是登录表单、筛选条件、复杂数据面板、列表分页,甚至多条件联动的业务流程,只要遵循这套分工,异步行为的复杂度都会显著下降。下一章会在这套架构的基础上,进一步深入任务管理与状态一致性的问题,围绕快速输入、取消、乱序结果等常见场景展开,让这套模式在更高强度的异步环境中依旧保持稳定。

5. 异步任务管理:快速输入、取消逻辑与状态一致性

搜索界面本身不算复杂,但在真实使用时,异步行为会快速暴露出不少工程问题。例如输入频繁变化、网络响应顺序不稳定、任务堆积、旧结果覆盖新结果等。这些问题在 UIKit 项目中非常常见,处理得不好,就会造成 UI 闪烁、状态错乱、延迟甚至崩溃。这一章聚焦三个关键主题,也是现代异步架构的核心能力:

  1. 快速输入下的任务取消
  2. 异步结果乱序(Out-of-order)
  3. 任务生命周期管理

这些内容是构建可预测异步系统最关键的基础。

5.1 快速输入导致的连续请求风暴

在搜索界面中,文本框输入通常是连续的。在短时间内触发十几次甚至几十次输入改动是很常见的。如果每一次输入都直接触发网络请求,会导致一系列问题:

  • 网络被频繁请求
  • 用户体验糟糕
  • 旧请求还没结束,新的又被发送
  • 请求堆积最终影响性能和电量

使用 Combine 的 debounce 方法,可以让文本输入在短时间稳定下来后再触发搜索。例如示例使用了 400ms 的防抖时间。

1
.debounce(for: .milliseconds(400), scheduler: RunLoop.main)

这使得输入行为变得可控,不会在用户快速打字时触发一连串请求。debounce 对 UI 输入来说基本是必需的,但还不够,因为就算触发次数减少,仍然可能遇到一个更麻烦的问题:乱序。

5.2 异步结果乱序:典型的 Out-of-Order 问题

即便输入节流处理得再好,最终还是不可避免会存在以下情况:

  1. 用户输入 A,发起请求 A
  2. 用户马上输入 B,发起请求 B
  3. 请求 B 更快返回
  4. 请求 A 稍慢返回,但覆盖了结果

结果界面显示的却是 A 的结果,而用户明明输入的是 B。这个问题不是 Combine 的锅,也不是 async/await 的锅,而是网络和线程调度本身的正常行为。唯一能解决的方法,是让逻辑保证:只有最新的请求可以更新界面,旧任务的输出应被忽略,示例中的 ViewModel 已经展示了这种做法,通过取消方式实现:

1
2
currentTask?.cancel()
currentTask = Task { ... }

每次开始新的搜索任务,都将旧任务取消。除此之外,还应该在 Task 内检查是否已经被取消:

1
try Task.checkCancellation()

这行代码确保在 await 恢复后再继续逻辑之前,确认任务仍然有效。要点是:

  • 取消并不等于马上终止,需要主动检查
  • 检查点越靠近结果更新越有效
  • 必须在状态更新前进行校验

正确处理 out-of-order 问题后,搜索结果始终与最新输入保持一致。

5.3 任务生命周期管理

常见 UIKit 项目的另一个坑是任务生命周期失控。包括:

  • 任务没取消,用户切换页面还在执行
  • 快速输入导致数十个任务堆积
  • ViewModel 销毁后任务仍在后台运行
  • Cell 重用中任务无法映射到正确的 indexPath

这些问题看起来分散,根源却一致:任务的生命周期应该绑定到具体的业务实体,而不是任由其在全局运行。
在示例中,任务被明确地绑定到 ViewModel:

1
private var currentTask: Task<Void, Never>?

ViewModel 存在时任务存在,ViewModel 被释放时任务一并取消,每次启动新任务会取消旧任务。UIKit 第一视图层级较为复杂,控制器生命周期不稳定,尤其在 push、pop、modal 切换过程中,ViewController 可能被多次加载、销毁、重新构建。因此把任务放在控制器里并不理想。

放在 ViewModel 内部会更加稳定,因为:ViewModel 通常与页面绑定。它的生命周期更接近业务状态,不容易被 UI 事件意外触发或移除。

5.4 approach 对比:为什么不用 OperationQueue 或 GCD?

UIKit 时代常见的任务机制包括:

  • DispatchWorkItem
  • GCD 异步队列
  • OperationQueue / Operation

它们仍然可用,但 async/await 和 Task 足以替代这些较老机制:

  • 更自然的控制流(await)
  • 更清晰的取消语义
  • 任务结构化,不需要手动管理依赖
  • 更贴近 Swift 并发模型

举个简单例子,GCD 取消任务实际上并不是真正的取消;OperationQueue 的取消需要手动检查 cancel 状态;而 Swift 的 Task 拥有真正的取消传播机制,简化了大量控制代码。对于搜索示例这种 UI 驱动的异步行为而言,Task 更适合承担核心角色。

小结:任务管理的三个关键点
整个系统要稳定运行,需要关注三个点:

  • 输入节流,用 debounce 控制触发频率,让事件进入有序节奏。
  • 最新任务优先,每次请求前取消旧任务,确保结果不会乱序污染 UI。
  • 生命周期绑定,让任务绑定到 ViewModel,让逻辑跟随业务状态走,而不是跟随 UI 生命周期走。

具备这些基础后,就能安全、清晰地处理大部分异步场景。下一章将在此基础上进一步展开,讨论如何让 UI 更新更高效,包括列表加载、滚动过程中的取消、异步图片加载,以及更复杂界面中如何使用相同模式提升体验。

6. UI 异步优化:列表加载、滚动过程与异步图片的处理方式

异步任务在真实界面中的表现,往往不是简单的“发起请求并显示结果”。UIKit 的 UI 交互更多来自滚动、快速变化的可视区域、列表重用、多层内容组合。只要涉及列表或异步加载,就会带来任务取消、绑定关系不清晰、数据错位、闪烁等问题。这一章围绕三个常见的 UIKit 场景展开:

  1. 列表内容异步加载
  2. 滚动动作加速导致的任务取消
  3. Cell 重用与异步任务错位

这些问题的解决思路,与搜索示例使用的结构化异步模型高度一致,只是延伸到了更复杂的 UI 交互中。

6.1 UITableView 与异步加载的典型问题

列表是 UIKit 最常见的独立视图,而列表滚动又是异步逻辑出错的地方。常见典型问题包括:

  • 任务完成顺序与 Cell 显示顺序不一致
  • Cell 重用导致旧任务结果出现在新位置
  • 滚动太快触发大量网络请求
  • 加载过程中 UI 闪烁或出现错误图片

这些问题本质上与搜索示例中遇到的 Out-of-order 类似,但影响范围更大,因为一个列表可能同时存在数十到数百个 Cell。在这种情况下,异步任务必须和 Cell、indexPath 或数据模型绑定得更加准确,否则很容易出现错位。

6.2 使用 indexPath 绑定任务的最小可靠策略

一种简单且有效的处理方式,是在发起异步任务前记录当前 indexPath,任务完成后再检查是否仍然匹配。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
func configure(with item: Item, at indexPath: IndexPath) {
self.indexPath = indexPath
currentTask?.cancel()
currentTask = Task {
let image = try await imageLoader.load(item.imageURL)
try Task.checkCancellation()
if self.indexPath == indexPath {
self.imageView.image = image
}
}
}

这段模式具备几个特点:

  • 每个 Cell 持有自己的 Task
  • Cell 重用时会取消旧任务
  • 完成回调会检查 indexPath 是否一致
  • 只有匹配的结果才会被应用到 UI

这比传统的 tag 对比方式更安全,也比通知或回调方式更集中。

6.3 滚动过程中的节流

快速滚动时触发网络或解码任务,会导致卡顿甚至掉帧。这类问题可以用 Combine 的 throttle 或 debounce 控制触发频率。例如实现“下拉加载更多”或“滚动到底部加载下一页”,通常会监听 scrollView 的偏移量:

1
2
3
4
5
6
7
8
scrollPublisher
.throttle(for: .milliseconds(200), scheduler: RunLoop.main, latest: true)
.sink { offset in
if offset.isNearBottom {
viewModel.loadNextPage()
}
}
.store(in: &cancellables)

节流处理能显著减少不必要的计算和网络请求,使滚动保持流畅。

6.4 异步图片加载:更通用的结构

UIKit 场景中图片加载是极常见的工作。一个健壮的加载器通常具备以下特性:

  • 缓存支持(内存缓存、磁盘缓存)
  • 请求合并(同一 URL 不要重复请求)
  • 任务取消(Cell 滚动时不浪费资源)
  • 失效检查(确保图片与当前 Cell 对应)

一个简单的加载器可以使用 actor 管理缓存:

1
2
3
4
5
6
7
8
9
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
cache[url]
}
func set(_ image: UIImage, for url: URL) {
cache[url] = image
}
}

actor 是 Swift 里用于构建线程安全引用类型的关键字,自动防止并发访问冲突,不需要自己加锁,比 class 更适合管理共享可变状态。加载逻辑:

1
2
3
4
5
6
7
8
9
10
11
func load(_ url: URL) async throws -> UIImage {
if let cached = await cache.image(for: url) {
return cached
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw ImageError.invalidData
}
await cache.set(image, for: url)
return image
}

这种结构可以不依赖第三方库,且可以自然结合 Task 和任务取消机制。

6.5 列表异步加载的最佳工程实践

将前面所有内容整合,可以总结出一套可广泛适用的最佳实践模式:

  • 任务绑定到 Cell 或 ViewModel
  • 所有异步加载必须具备任务取消能力
  • Cell 重用时立即取消旧任务
  • 结果更新之前必须检查有效性
  • 滚动事件需要节流或合并
  • 缓存逻辑放在独立模块,避免 ViewModel 重复计算
  • 任何 UI 状态更新都应回到主线程(@MainActor 或 MainActor.run)

这些原则能让列表滚动、异步加载和 UI 刷新保持稳定,也能避免难以定位的错位 bug。

6.6 UI 与异步结合的核心思想

无论是图片加载、搜索提示、分页还是后台数据刷新,最终都可以概括成一个核心思想:异步行为必须与界面状态一致,不一致的原因要么是任务没取消,要么是状态没有集中管理,要么是事件过度频繁,要么是完成回调时上下文已经变化。

结构化并发、Combine 和 @Published 的组合,其实就是把这些问题交回到了一个更清晰的架构体系里:

  • Combine 负责输入节奏和事件流
  • async/await 提供可靠的执行模型
  • @Published 提供一致状态输出
  • UI 则根据状态做展示,不关心异步细节。

下一章将进一步讨论错误处理与状态管理,解释如何让状态体系更稳定、更容易扩展,以及如何在复杂业务中构建可控制的状态机。

7. 错误处理与状态管理:构建清晰、可维护的状态系统

异步行为不仅带来任务和时序上的复杂度,还会引发更容易被忽略的问题:错误如何统一处理、状态如何保持清晰、UI 如何在状态变化中保持一致。随着界面逻辑越来越复杂,处理不好这一层,很容易出现 UI 混乱、加载指示器闪烁、错误提示覆盖不对时机的问题。这一章聚焦于三件核心事情:

  1. 如何让错误处理更统一
  2. 如何构建稳定的状态系统
  3. 如何让 UI 始终跟随状态,而不是跟随逻辑分支

这部分内容看似偏理论,但在实际开发中很实用,可以显著降低后续改动的风险。

7.1 错误来源的多样性与统一入口

在一个真实的异步系统中,错误可能来自多种层级:

  • 网络连接错误
  • 服务器返回错误
  • 超时
  • 解析失败
  • 非预期数据格式
  • 任务取消
  • ViewModel 的业务校验错误
  • 自定义逻辑抛出的异常

如果每个错误都用不同的方式在不同地方处理,分散在各处、风格不一致、不利于维护。搜索示例中使用了统一的错误入口:

1
2
3
4
5
} catch {
results = []
isLoading = false
errorMessage = "搜索失败,请稍后再试"
}

所有错误都被统一归集到一个状态更新点,UI 只依赖 errorMessage,不关心错误类型。这种方式有几个特点:

  • ViewModel 是错误的屏障,控制器不处理错误
  • 所有错误都转成统一的 UI 状态
  • UI 层不需要写大量 if/else 判断

这让界面逻辑变得轻量、稳定。

7.2 使用 @Published 构建清晰状态流

在 MVC 模式中,状态往往散布在多个地方,也可能因为回调顺序不一致导致 UI 的状态与业务状态不同步。引入 @Published 后,状态变得集中且可观察。以搜索示例为例:

1
2
3
@Published var results: [String] = []
@Published var isLoading: Bool = false
@Published var errorMessage: String?

任何层修改了这三个状态之一,控制器就能立即更新界面,不需要自己写判断逻辑。后续如果界面增加更多元素,例如:

  • 空状态文案
  • 重试按钮
  • 下拉刷新
  • 分页状态指示器

依然只需要补充新的 @Published 属性,而不需要到处改判断条件。ViewModel 变成了整个页面的状态源,而不是把状态藏在控制器内部,各个部分独立更新。

7.3 状态更新顺序的稳定性

一个常见问题是:加载状态、结果状态、错误状态之间的更新顺序可能混乱,会导致 UI 闪动或状态不一致。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 开始加载
isLoading = true
results = []
errorMessage = nil

// 异步结束
isLoading = false
results =
errorMessage = nil

// 异步抛出了错误,可能变成
isLoading = false
results = []
errorMessage = “失败”

为了避免状态不一致,有两个基本原则:

原则一:同一事件产生的状态更新放在一起

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
func performSearch(keyword: String) {
// ...
// 加载中
isLoading = true
errorMessage = nil

currentTask = Task { [weak self] in
guard let self else { return }

do {
let data = try await repository.search(keyword: trimmed)
try Task.checkCancellation()

// 在成功分支中处理
results = data
isLoading = false
errorMessage = nil
} catch is CancellationError {
// 不更新 UI
} catch {
// 在错误分支中处理
results = []
isLoading = false
errorMessage = "搜索失败,请稍后再试"
}
}
}

这种写法的优势是:

  • 逻辑清晰
  • 不会出现遗漏
  • 控制器自动感知状态变化

原则二:状态之间要有明确职责区分

例如避免让 isLoading 决定 error 是否展示,而是让 errorMessage 自己控制。这样每个状态只负责一个 UI 维度:

  • results 管列表
  • isLoading 管指示器
  • errorMessage 管错误提示

视图不需要再额外判断三个状态之间的关系。

7.4 错误状态的可扩展性

后续如果需要给错误增加更多信息:

  • 错误的类型
  • 错误的来源
  • 是否可重试
  • 提示的文案
  • 应该展示哪种 UI

可以从 errorMessage: String? 演进为枚举:

1
2
3
4
5
enum SearchErrorState {
case none
case message(String)
case retryable(String)
}

然后:

1
@Published var errorState: SearchErrorState = .none

这种状态机式写法非常适合复杂界面,尤其是多个状态互斥的页面:空白页、加载中、正常结果、错误展示。通过 enum 可以自然表达彼此之间的关系,比使用多个 Bool 值会好很多。

7.5 处理取消带来的状态问题

取消是一种特殊场景,因为取消意味着任务“本来是在执行,但中途不再需要结果”。如果不加处理,可能出现:

  • 取消后突然覆盖结果
  • 取消后错误提示出现
  • 取消后 UI 回到错误状态
  • 取消后加载状态保持为 true

示例中的做法是:

1
2
3
catch is CancellationError {
// 不更新 UI
}

意味着取消并不属于成功,不属于错误,也不属于空态,只是一种“保持当前状态就好”的情况。这是处理取消最稳定且最自然的方式。

状态管理的小结
整个状态系统遵循以下三个核心:

  • 所有状态都由 ViewModel 统一持有
    控制器永远不拥有业务状态,只做 UI 展示。
  • 状态之间职责分明,不相互影响
    loading、error、results 是三条平行的轴。
  • 错误和取消必须有明确、可预测的行为
    取消不会影响 UI,错误统一集中处理

只要坚持这三点,后续即便界面变得更复杂,异步逻辑更密集,整个页面依旧能够保持稳定,不容易陷入“加了一个判断,UI 又乱了”的局面。

8. 测试策略:在异步与状态驱动架构中保持可验证性

具备良好结构的异步架构,必须能够被测试。UIKit 项目如果没有测试支撑,一旦异步逻辑变复杂,就会面临大量回归问题:状态更新顺序不对、取消行为出错、错误展示位置不正确、无限 loading、重复请求等。

本章重点讨论构建在 Combine + async/await + @Published 基础上的搜索模块,应该如何编写稳定、可重复且真正有价值的测试。核心分三块:

  1. 测试 ViewModel 的状态流
  2. 测试 async/await 的异步逻辑
  3. 测试 Combine 输入事件

8.1 测试 ViewModel:验证状态更新是否正确

ViewModel 是搜索模块的核心逻辑,测试重点是确保它在不同输入和不同异步结果下能给出正确的状态变化。由于使用了 @Published,可以通过 Combine 来观察它的状态。
一个简单的测试示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func test_searchSuccess() async {
let mockRepo = MockSearchRepository(result: ["A", "B"])
let vm = await SearchViewModel(repository: mockRepo)

var states = [[String]]()

let cancellable = await vm.$results
.sink { value in
states.append(value)
}

await vm.performSearch(keyword: "Hi")

// 允许任务执行一段时间
try? await Task.sleep(nanoseconds: 100_000_000)

XCTAssertEqual(states.last, ["A", "B"])

cancellable.cancel()
}

要点:

  • 使用 Mock repository 来控制异步返回
  • 使用 await 捕获状态变化
  • 测试的是结果状态,而非 UI
  • 检查最后的状态是否符合预期

这种方式对于复杂状态流(包含 loading、error、data)同样有效。

8.2 测试 loading 与 error 状态

例如测试错误的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func test_searchError() async {
let mockRepo = MockSearchRepository(error: .serverError)
let vm = await SearchViewModel(repository: mockRepo)

await vm.performSearch(keyword: "Hello")

try? await Task.sleep(nanoseconds: 100_000_000)

let errorMessage = await vm.errorMessage
let results = await vm.results

XCTAssertNil(results)
XCTAssertEqual(errorMessage, "搜索失败,请稍后再试")
}

通过这种方式可以单独验证每一种错误分支。

8.3 测试取消行为

取消行为是异步逻辑中最容易出错的部分,尤其在连续输入时。测试取消的关键是确保旧任务不会覆盖新任务结果:

1
2
3
4
5
6
7
8
9
10
11
12
func test_searchCancellation() async {
let mockRepo = SlowMockRepo()
let vm = await SearchViewModel(repository: mockRepo)

await vm.performSearch(keyword: "A")
await vm.performSearch(keyword: "B") // 取消 A

try? await Task.sleep(nanoseconds: 200_000_000)

let results = await vm.results
XCTAssertEqual(results, ["B result"])
}

这里关键点在 mock repo 加入延迟,用来模拟 network 乱序,核心验证任务取消是否生效。

8.4 测试 async 函数:模拟各种异步行为

Mock repository 可以分为三类:

  • 成功快速返回
  • 成功但延迟
  • 失败返回
1
2
3
4
5
6
7
8
9
10
11
struct MockRepository: SearchRepositoryProtocol {
let delay: UInt64
let result: [String]?
let error: Error?

func search(keyword: String) async throws -> [String] {
try await Task.sleep(nanoseconds: delay)
if let error = error { throw error }
return result ?? []
}
}

使用不同组合就能模拟各种网络场景。

8.5 测试 Combine 输入:模拟用户输入

在单元测试环境中,UIKit 的控件事件无法直接触发,例如 UITextField 的编辑事件。因此在测试输入逻辑时,重点不是去“模拟 UITextField”,而是直接测试 Combine。

模拟方式是手动创建事件源,例如 PassthroughSubject,用它来构造与真实输入一致的事件流,然后观察经过 debounce、过滤、转换之后的输出是否正确。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func test_textInputDebounce() {
let subject = PassthroughSubject<String, Never>()
var values = [String]()

let cancellable = subject
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { values.append($0) }

subject.send("H")
subject.send("He")
subject.send("Hel")

// 等待 500ms 保证 debounce 触发
let expectation = XCTestExpectation(description: "Wait")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)

XCTAssertEqual(values.last, "Hel")
}

能验证 UI 输入事件是否被防抖正确处理。

8.6 最佳实践:测试中要避免的误区

  • 避免直接等固定时间(sleep),应使用小的延迟或 Task.yield,让测试稳定且快速
  • 避免测试 UI 细节,应测试状态,而非 UI 地方的 label 文案
  • 避免在同一测试中验证太多状态变化,测试的粒度越清晰,可维护性越高
  • 避免使用真实网络,测试必须完全可控,不能受外部环境影响
  • 当 ViewModel 使用 @MainActor 时,确保测试也在主线程执行,这是 Swift Concurrency 下常见的问题

测试总结
通过 async/await 与 Combine 的合作,测试反而变得比旧式 callback 更清晰:

  • ViewModel 逻辑可测试
  • 状态可捕获
  • 任务取消可模拟
  • 错误分支可控制
  • Combine 输入可模拟
  • 整个链路可重复验证

有了测试,异步架构的维护成本会大幅降低,重构时也不会担心隐藏状态变化导致 UI 出问题。下一章将重点讨论如何让 UIKit 项目逐步迁移到这种现代异步架构,无需重写,也无需一次性切入,适合在已有项目中渐进式采用。

9. 渐进式迁移:让现有 UIKit 项目平滑过渡到现代异步架构

UIKit 项目大多数都处于“维护 + 增量开发”的状态,重写成本高、风险大,开发节奏也不允许一次性切换架构。因此,引入 Combine、async/await 和 @Published 必须是渐进式的,既保持原有代码可用,又让新的逻辑逐步采用更清晰的异步结构。

这一章重点讨论三个方面:

  1. 迁移顺序:哪些部分可以最先升级
  2. 如何与旧代码共存
  3. 团队层面的可执行规范

目标不是“推翻重写”,而是在现有结构上自然升级。

9.1 第一阶段:从网络层开始迁移到 async/await

网络层是最适合开始迁移的地方,原因很简单:

  • 网络请求本来就是异步操作
  • 逻辑往往集中、封装良好
  • 几乎不依赖 UI
  • 回调方式写起来最乱,引入 async 后立刻见效

例如把旧的回调式 API:

1
func search(keyword: String, completion: @escaping ([String]?, Error?) -> Void)

替换成 async 版本:

1
func search(keyword: String) async throws -> [String]

然后把旧调用方式替换为:

1
let data = try await repository.search(keyword: text)

这个变化非常局部,不会影响 UI,也不会改变调用方的结构。并且,网络层一旦 async 化,后续 ViewModel 的简化几乎是自然发生的。

9.2 第二阶段:用 Combine 管控输入事件

当网络逻辑稳定后,就可以把 UI 输入行为使用 Combine 管理起来。例如:

  • 文本输入的防抖
  • 按钮点击的节流
  • 滚动触发下一页
  • KVO 的替换
  • 通知中心事件的统一处理

具体写法类似搜索示例里的:

1
2
3
4
5
6
7
8
9
NotificationCenter.default.publisher(
for: UITextField.textDidChangeNotification,
object: searchTextField
)
.debounce(for: .milliseconds(400), scheduler: RunLoop.main)
.sink { [weak self] text in
self?.viewModel.performSearch(keyword: text)
}
.store(in: &cancellables)

这一阶段的升级不会改变原来的 MVC 或 MVVM 结构,只是将“输入事件控制”从零散的回调移到一个更可控的事件管道里。

9.3 第三阶段:状态统一管理 —— 引入 @Published 的最关键改造

MVC 的核心痛点是“状态散落在控制器内部”。引入 @Published 后,状态集中到 ViewModel,由界面自动响应。
升级路径如下:

9.3.1 第一层:将结果数据移动到 @Published

例如:

1
@Published var results: [String] = []

原来控制器维护的数据移到 ViewModel,这一步立刻降低控制器复杂度。

9.3.2 第二层:把 loading 状态搬过去

1
@Published var isLoading: Bool = false

loading 的显示逻辑从 UI 判断变成状态输出。

9.3.3 第三层:把 error 逻辑搬过去

1
@Published var errorMessage: String?

错误集中处理,控制器只负责展示。做到这里,ViewModel 基本成为完整的状态中心。

9.4 与旧代码共存:不需要一次性全面切换

采用渐进式迁移时,有几个常见情况需要处理:

9.4.1 场景一:旧回调 API 不动,但新逻辑想用 async

可以用 continuation 封装:

1
2
3
4
5
6
7
8
9
10
11
func search(keyword: String) async throws -> [String] {
try await withCheckedThrowingContinuation { continuation in
legacyAPI.search(keyword: keyword) { result, error in
if let result = result {
continuation.resume(returning: result)
} else {
continuation.resume(throwing: error ?? SomeError.unknown)
}
}
}
}

这样不会动旧 API,却能让新代码用 async 统一处理。而要把 async 函数转换成旧式回调形式(completion handler),做法和上面 “回调 → async” 的 continuation 方向刚好相反:

1
2
3
4
5
6
7
8
9
10
func search(keyword: String, completion: @escaping ([String]?, Error?) -> Void) {
    Task {
        do {
            let result = try await search(keyword: keyword)
            completion(result, nil)
        } catch {
            completion(nil, error)
        }
    }
}

9.4.2 场景二:部分界面用 Combine,部分不用

完全没问题。Combine 可以独立存在,不需要全局使用才能发挥价值。

9.4.3 场景三:部分页面有 ViewModel,部分仍在控制器写逻辑

也是常见状态,不必强推统一架构。随着业务变动,自然会逐渐偏向 ViewModel 模式。

9.5 存在风险的迁移方式(应避免)

  • 一次性全局替换网络层
    很难测试,不安全。
  • 强制所有输入都改成 Combine
    每个页面情况不同,不必教条。
  • 所有状态都迁移到 @Published
    只迁移对 UI 有意义的状态,不要滥用。
  • 让控制器和 ViewModel 同时持有状态
    容易导致状态不同步,是常见陷阱。

9.6 团队层面的可执行规范

以下规则是迁移过程中非常实用的,在多数工程里都适用:

  • 网络层必须提供 async API
    即便底层仍然是回调,也必须对上层只暴露 async 版本。
  • 输入事件应该通过 Combine 管控
    特别是文本输入、滚动、通知等高频事件。
  • ViewModel 必须管理状态
    控制器不保存任何业务状态,只负责展示。
  • 取消逻辑要集中管理
    不允许散落在控制器或多个函数中。
  • 不把 UI 依赖塞进 Repository 层
    Repository 保持纯粹。

这些规则可以让团队的“写法”更统一,也能让后续协作更顺畅。

渐进式迁移的总结
整个迁移过程可以总结为三步:

  1. 先把网络逻辑 async 化(最少风险)
  2. 再用 Combine 管控输入(结构更清晰)
  3. 最后集中状态到 ViewModel(核心改造)

升级后的代码具备以下优点:

  • 逻辑线性
  • 状态集中
  • 输入稳定
  • 取消及时
  • UI 更轻量

这让旧的 UIKit 项目拥有了现代异步架构的可读性、可维护性和可预测性。下一章将着眼于架构整体的未来方向,包括更复杂场景中的并发处理、Swift 未来的趋势,以及 UIKit 项目长期可持续的现代化策略。

10. UIKit 未来趋势

UIKit 依旧是大量成熟应用的主力,也会继续存在很长时间。它不会因为 SwiftUI 的推进而被替代,尤其是在复杂业务、历史包袱较深或团队人数有限的项目中。

但这并不意味着 UIKit 项目无法向现代并发架构迈进。随着 Swift Concurrency、actor、AsyncSequence 等能力不断完善,UIKit 完全可以逐步升级,获得稳定、安全且更具扩展性的异步体系。

这一章从更宏观的角度讨论未来方向,重点关注三个层面:

  1. Swift 并发体系的发展趋势
  2. UIKit 项目中并发相关的演进方向
  3. 更长期的组织与工程策略

它们都围绕一个核心理念:UIKit 不需要转向 SwiftUI 才能拥抱现代异步能力,现代异步能力反而更能延长 UIKit 的寿命。

10.1 Swift 并发体系将在未来持续强化

Swift 的并发模型刚经历了几年的快速发展,现在逐渐走向稳定和体系化。从 Swift 5.5 到 Swift 6,可以观察到明显趋势:

  • async/await 成为主要异步接口方式
  • actor 成为默认的线程安全机制
  • 发送与共享状态的隔离更加严格
  • AsyncSequence 成为事件流的统一抽象
  • Task 取消、优先级、隔离系统更严格

未来的系统 API 会越来越多采用 async 版本:网络、文件、数据库访问、蓝牙、推送、传感器等,都可能提供 async 接口来替换旧回调模式。UIKit 即便保持现状,也会直接受益。

10.2 Combine 的定位逐渐转向“事件流工具”

Combine 早期被当作“官方版 RxSwift”,承担过异步链路、状态管理、响应式架构等多种职责。但随着 Swift Concurrency 成熟,async/await 逐渐接管业务逻辑与任务链路,@Observable(iOS17引入) 负责状态驱动,Combine 也自然回到了最适合自己的位置:处理持续产生的事件。

它最擅长的是输入与事件节奏控制,包括文本输入、滚动行为、系统通知、KVO、节流防抖以及多事件组合等,这些都不是 async/await 能替代的能力。未来更合理的分工会是:

  • 事件 → Combine
  • 异步逻辑 → async/await
  • 状态 → @Published 或 AsyncSequence

Combine 不会退出舞台,而是专注于事件流的处理,在 UIKit 中依旧不可或缺。

10.3 @Published 会进一步弱化 MVC 的状态散落问题

UIKit 的 MVC 一直被诟病,是因为状态散落在控制器、视图、回调中。引入 @Published 后,状态得以统一管理,避免 MVC 中常见的:重复刷新、多点更新、状态不一致、回调交织导致的混乱。

未来 @Published 或其衍生工具(如 Observable macro)会成为 UIKit 开发中非常自然的选择。尤其在:

  • 网络列表
  • 表单页
  • 筛选逻辑
  • 动态内容展示
  • 错误提示与 loading 管理

这些场景下都可以直接从状态驱动 UI。

10.4 UIKit 长期并发架构的核心会向“状态机 + actor”演进

随着业务复杂度增加,状态机结构自然成为更高级的抽象。例如:

1
2
3
4
5
6
enum SearchState {
case idle
case loading
case success([Item])
case failure(String)
}

未来的 ViewModel 很可能会是这种形式:

  • 输入事件通过 Combine 进入
  • 状态机运转逻辑通过 async 串行执行
  • 状态变化通过 @Published 或 AsyncSequence 输出
  • 并发资源通过 actor 管理

这种结构天然符合 Swift 并发设计,既清晰又安全。

10.5 actor 将成为管理共享资源的主力

UIKit 项目的复杂场景通常会涉及共享资源:图片缓存、数据缓存、网络会话、后台同步任务、数据库写入。这些是 class + GCD 难以完全安全管理的,而 actor 的设计正好适用于这些情况:线程安全、访问有序、不会死锁、易于维护

最终效果是:整个应用的共享资源层会逐步被 actor 接管。

10.6 UIKit + Swift Concurrency 会长期共存

未来趋势非常明确:

  • UIKit 依然成熟、稳定、适合复杂业务
  • SwiftUI 更易声明式,但生态与成熟度还在发展
  • Swift Concurrency 更像一层“基础设施”,对所有 UI 技术都适用

因此,不需要将 UIKit 项目强制迁移到 SwiftUI,也不必陷入“UIKit 会消失”的误解。更现实的趋势是:

  • UIKit 继续作为 UI 层
  • Swift Concurrency 管理逻辑层
  • Combine 管理事件流与用户输入
  • @Published 成为状态驱动 UI 的统一抽象

三者堆叠在一起,形成了“现代 UIKit 架构”真正的雏形。

10.7 从工程视角看,渐进式才是真正可行的路线

长期来看,有三条实际可落地的方向:

  1. 网络层与异步资源全面 async 化
  2. 事件流逐步替换为 Combine
  3. 状态集中到 ViewModel

当这三步做到位,即便 UI 仍是 UIKit,整体架构已经拥有现代 Swift 的大部分能力。团队协作方面,也能得到明显收益:

  • 状态变更点减少
  • 逻辑从控制器迁移到 ViewModel
  • 异步代码更可测
  • 架构更统一
  • 回归 bug 减少

这些优势往往比“换 UI 框架”带来的价值更直接、更现实。

10.8 向更远未来的可能性

未来可能会出现一些更先进的工具或模式:

  • SwiftData + async persistent store( Swift 并发生态下的全新数据库系统)
  • 更多系统 API 变成 async
  • Observable macro(@Observable宏) 统一取代 @Published
  • Task 组管理器、Task 取消链工具抽象
  • 更多标准化 AsyncSequence 事件流

它们不会改变架构方向,而是进一步强化 Swift 并发生态。UIKit 项目完全能够享受这些演进成果。

总结
这一系列章节展示了一个事实:UIKit 项目并不会因为年代长就失去现代异步能力,它完全可以与 Swift 最新的并发技术深度融合,只要从网络层、输入事件、状态管理三个角度逐步推进,最终能得到一个结构清晰、可维护、安全并发的系统。

  • Combine 管输入
  • async/await 管逻辑
  • @Published 管状态
  • actor 管共享资源

这是 UIKit 项目的“现代化四件套”。

11. 总结:UIKit 的现代异步架构

经过前面的所有章节,可以看到 UIKit 项目与 Combine、async/await、@Published 完全能够自然结合,不仅不会显得突兀,反而能在复杂业务中带来更清晰的层次、更稳定的异步行为和更可控的状态系统。

这一章将整个体系收束成一个完整的体系,从整体视角把关键点再次串起来,形成一套可以在实际工程中可借鉴的架构思路。

11.1 整体结构:输入、逻辑、状态三层分工

现代 UIKit 异步架构最核心的思想,是将异步行为拆成三个清晰的层次:

  • 输入(Combine)
    负责管理用户行为、滚动、文本输入、通知等源源不断的事件流。通过防抖、节流、合并等方式让事件进入“可管理”的节奏
  • 逻辑(async/await)
    负责真正的业务处理,例如网络请求、数据转换、分页逻辑。结构化并发让逻辑变直观、可预测,Task 的取消机制让旧任务不会污染最新结果
  • 状态(@Published)
    负责将可视状态集中管理,例如 loading、结果、错误信息,状态是界面的唯一驱动力,控制器不参与业务判断

将输入、逻辑、状态拆开之后,每一层都变得简单、可理解、可替换,也方便测试。

11.2 Combine 的定位:事件流与节奏器

Combine 在 UIKit 项目中的作用主要集中在:

  • 文本输入防抖(搜索、筛选)
  • 滚动事件节流(分页加载)
  • 监听系统通知(键盘、 app 生命周期)
  • 处理 KVO(只要需要观察某个属性变化)
  • 将多个输入组合成一个统一的触发点

它不是业务逻辑的承载者,也不应该承担状态管理。它的最佳位置是“输入层”。

11.3 async/await:主力异步执行引擎

异步逻辑通过 async/await 能得到最明显的结构改善。从搜索示例中可以看出:

  • 避免回调层层嵌套
  • 任务取消更精确
  • 错误传播自然、集中
  • 代码顺序可读性更高

未来系统 API 将更多提供 async 版本,这意味着 async/await 会成为主流异步方式,不再只是可选项。

11.4 @Published:把状态从控制器中解放出来

UIKit 的经典问题之一就是状态混乱:

  • tableView 什么时候刷新
  • loading 什么时候展示
  • 错误提示什么时候出现
  • 不同回调导致状态不一致

@Published 将状态集中到 ViewModel,使得控制器只需要订阅变化:

1
2
3
viewModel.$results  刷新表格  
viewModel.$isLoading 控制菊花
viewModel.$errorMessage 更新错误提示

控制器不再负责判断,只做展示,整个页面关系清晰许多。

11.5 Task 取消与 Out-of-Order 的解决方案

现代异步架构真正的难点在于:多个异步任务并行执行时,哪个结果应该被 UI 接受? UIKit 的搜索、图片加载、分页都容易发生旧任务覆盖新任务的问题。通过 Task 的取消机制和有效性检查:

1
2
currentTask?.cancel()
try Task.checkCancellation()

结合 indexPath 匹配、状态判断等策略,可以消除乱序带来的 UI 错位。这是 Swift 并发模型相较 GCD 最大的优势之一。

11.6 actor:为共享资源提供更安全的并发模型

共享资源一直是 UIKit 工程中最容易出错的地方,例如:

  • 图片缓存
  • 全局的 session
  • 计数器
  • 后台任务队列

过去依赖 DispatchQueue 或锁机制,容易出现死锁或竞争。actor 的出现让共享状态变得自然安全,可以放心把缓存、会话、长任务管理器都放在里面统一处理。

11.7 测试能力更强、更清晰

使用现代并发模型,测试反而变得容易:

  • ViewModel 逻辑可通过 async 单测
  • 状态可通过 Combine 订阅验证
  • 取消和延迟可通过 mock 精确控制
  • 输入事件可通过 Subject 模拟

相比旧式回调,这种结构更可控、边界更明确。

11.8 渐进式迁移路径:适合所有 UIKit 项目

全文提出的迁移路径已经在多个实际工程中验证过:

  1. 第一步:网络层 async 化
  2. 第二步:输入事件使用 Combine
  3. 第三步:状态集中到 ViewModel

这种路径风险最低、收益最明显、可局部替换,不影响原业务架构。对于老项目也适用,对于新项目更是直接可用。

11.9 UIKit 的未来:不是被替代,而是与现代并发共存

SwiftUI 的推进并不会立即淘汰 UIKit,它和 UIKit 的关系更像是互补:

  • SwiftUI 擅长声明式 UI
  • UIKit 擅长成熟业务和复杂界面

UI 实现方式可以不同,但底层并发架构应该统一。UIKit 项目只要采用 Combine + async/await + @Published,就能彻底摆脱传统异步困境,形成一个稳定、现代的异步模型。

附录1:Rx 与 Combine 的体系对比

UIKit 项目在处理事件流、异步逻辑时,常见的方案除了 Combine 之外,还有 RxSwift / RxCocoa。这两套体系在使用方式上很相似,但底层理念有很大不同,适用场景和未来方向也有明显差异。本章从架构、生态、异步模型、整合能力等几个角度,对两者做一个系统性的对比,方便在项目中做技术选型。

1. 整体定位上的根本区别

RxSwift 来自 ReactiveX 体系,是一套跨语言的一致化响应式编程模型,与 Swift 无强绑定;Combine 则是苹果官方实现的响应式框架,与 Swift 语言自身紧密结合。可以用一句话概括:Rx 构建的是“响应式的体系”,Combine 则是 Swift 并发生态的一部分。 这种定位产生了后续几乎所有差异。

2. 基础模型:Observable vs Publisher

Rx 使用 Observable / Observer 模型,类型丰富、灵活性高;Combine 使用 Publisher / Subscriber 模型,错误类型是泛型参数,类型系统更严谨。Rx 更自由、概念更多;Combine 更严格,语言层面融入度更高。

3. 操作符的数量与表达力

RxSwift 的操作符极其丰富,几乎覆盖各种事件流需求;Combine 的原生操作符较少,复杂组合需要 CombineExt 辅助。
如果事件转换非常复杂,Rx 的上限更高;如果主要是 UI 输入、过滤、防抖这类常见场景,Combine 完全够用。

4. 异步系统的整合能力

这是两者最大的分水岭。
Rx 并不与 Swift Concurrency 有天然关系,需要手动桥接;
Combine 能与 async/await 和 AsyncSequence 深度整合,这是官方面向未来的方向。

Publisher 可以直接转为 AsyncSequence:

1
for await value in publisher.values { ... }

未来系统 API 会更多采用 async/await 与 AsyncSequence,这让 Combine 更具长期优势。

5. 调度模型与线程管理

Rx 有完整的 Scheduler 体系,可对线程切换做非常精细的控制;Combine 的调度方式更简单,依赖 RunLoop / DispatchQueue 这类系统组件。

灵活度:Rx 更强,简单性:Combine 更好。取舍取决于项目复杂度。

6. 生态库与成熟度

RxSwift 家族的生态非常完善,包括 RxCocoa、RxRelay、RxTest 等工具库,资料多、示例多、社区成熟,是老项目和大型项目的常见选择。
Combine 的生态较小,但 CombineCocoa、CombineExt 已够覆盖大多数常见需求。随着 async/await 推进,Combine 的定位逐渐变成 Swift 生态的自然一部分。

7. UIKit 控件支持能力

RxCocoa 对 UIKit 的控件包装非常完整:按钮、输入框、文本视图、滚动事件、手势、导航条按钮等都开箱即用。

Combine 原生支持有限,需要自己扩展Publisher或使用 CombineCocoa。UIKit 支持的成熟度:RxCocoa > CombineCocoa > Combine 原生

8. 学习曲线与团队成本

RxSwift 的学习曲线更高,概念更多,操作符数量庞大;Combine 体系较轻量,与 Swift 原生语法更统一,对团队更友好。

对于需要快速让多人掌握一致风格的团队而言,Combine 的成本更低。

9. 性能与系统级优化

Combine 是系统级框架,与 Swift/SwiftUI/Swift Concurrency 深度配合,在执行效率、优化空间、未来 API 一致性方面更有优势。RxSwift 属于第三方库,性能足够好,但无法做到系统级优化。

10. 综合结论

RxSwift 更强大、更灵活、更成熟,适合事件组合非常复杂或已有大量 Rx 代码的项目。
Combine 更轻量、语言层级更统一、与 Swift 并发整合更深,是新 UIKit 项目的更佳选择。

一句更直观的总结:

Rx 是“泛用型响应式框架”,Combine 是“苹果生态官方响应式方案”。在 Swift 并发时代,Combine 的长期价值更高;在复杂事件流场景下,Rx 的表达力仍然更强。