iOS 中的列表视图:UICollectionView 和 UITableView 可以在兼顾性能的同时让开发者比较容易地搭建复杂的 App UI 界面,并且用户的交互行为也变得顺畅。如果要百尺竿头更进一步的话,我们需要按需刷新列表而不是一股脑儿地调用 reloadData。在 iOS 13 之前,我们可以通过 IndexPath 或者 IndexSection 来进行局部刷新,但却几乎总是会遇到这个崩溃:

Terminating app due to uncaught exception’NSInternalInconsistencyException’,reason: ‘Invalid update: invalid number of sections. The number of sections contained in the tableView view after the update (1) must be equal to the number of sections contained in the tableView view before the update (1), plus or minus the number of sections inserted or deleted (0 inserted, 1 deleted).

而造成崩溃本质上的原因是模型层和渲染层的数据不一致。考虑到了开发者的“民间疾苦“,在 iOS 13 之后,Apple 提供了一套新的 API 用于 Diff 数据源,还附送优雅的刷新动画,You deserve it!

不能协议化的 Section 和 Item

现在,我们可以不用实现数据源协议,而是通过组合的理念使用 UICollectionViewDiffableDataSource 和 UITableViewDiffableDataSource 关联的 SectionIdentifierType 和 ItemIdentifierType 来进行管理。它的初始化过程神似 Rxcocoa,如下以 UICollectionViewDiffableDataSource(后续代码均以 UICollectionView 进行说明)举例:

1
2
3
4
5
UICollectionViewDiffableDataSource<Section, Item>(
collectionView: collectionView
) { collectionView, indexPath, item in
// return an instance of UICollectionViewCell.
}

因为需要做 Diff 运算,SectionIdentifierType 和 ItemIdentifierType 的范型均被要求遵循 Hashable 协议。这导致 Section 和 Item 必须是具体的类型,而不能使用协议进行声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protocol Section: Hashable {}
protocol Item: Hashable {}

let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())

/*
❌ build error:
Cannot infer type of closure parameter 'collectionView' without a type annotation
Cannot infer type of closure parameter 'indexPath' without a type annotation
Cannot infer type of closure parameter 'itemIdentifier' without a type annotation
Type 'any Section' cannot conform to 'Hashable'
Only concrete types such as structs, enums and classes can conform to protocols
*/
let source = UICollectionViewDiffableDataSource<PartlyRefreshSection, PartlyRefreshItem>(
collectionView: collectionView
) { collectionView, indexPath, itemIdentifier in UICollectionViewCell() }

使用基类封装局部刷新行为

首先,我们定义协议 CVPartlyRefreshable 来表示某个支持局部刷新的界面:

1
2
3
4
5
6
7
8
9
10
11
12
13
protocol CVPartlyRefreshable: AnyObject {
var collectionView: UICollectionView { get }
var dataSource: UICollectionViewDiffableDataSource<CVPartlyRefreshSection, CVPartlyRefreshItem> { get }
var operationQueue: OperationQueue { get }

var sections: [CVPartlyRefreshSection] { get set }
}

// ❗️刷新操作默认执行队列为主队列,实质上支持后台线程,但在实践过程中,发现某些动画效果与在主线程中进行表现不一致。
// ❗️且如果在后台线程中进行,需要时刻注意访问 UI 元素的问题。
extension CVPartlyRefreshable {
var operationQueue: OperationQueue { .main }
}

然后由于没有协议化 SectionIdentifierType 和 ItemIdentifierType 这条路,UICollectionView 和 UITableView 只能通过两种不同的基类来定义。它们主要用来表示模型层和公共方法的定义,对于具体的 Cell 实例由子类提供。Section 和 Item 的定义如下:

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
class CVPartlyRefreshItem {
weak var refresh: CVPartlyRefreshable?

var onRemove: ((CVPartlyRefreshItem) -> Void)?

func cell(in collectionView: UICollectionView, at indexPath: IndexPath) -> UICollectionViewCell {
fatalError()
}

func didSelect() {}
}

class CVPartlyRefreshSection: NSObject {
weak var refresh: CVPartlyRefreshable?

var onRemove: ((CVPartlyRefreshSection) -> Void)?

var items: [CVPartlyRefreshItem] = [] {
didSet {
items.forEach {
$0.refresh = refresh
$0.onRemove = { [weak self] itm in
self?.items.removeAll { $0 == itm }
}
}
}
}
}

Section 和 Item 中 onRemove 和 refresh 是为公共方法提供功能支持的前提条件:

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
extension CVPartlyRefreshable {
func append(section: CVPartlyRefreshSection) {
section.refresh = self
section.items.forEach { $0.refresh = self }
section.onRemove = { [weak self] stn in
self?.sections.removeAll { $0 == stn }
}
sections.append(section)
section.apply()
}
}

//❗️先操作模型层,然后更新渲染层
//❗️需要异步在主队列里面执行,如果上一个刷新操作未完成,新操作执行时也会导致此前的崩溃。
extension CVPartlyRefreshSection {
func apply() {
refresh?.operationQueue.addOperation { [weak self] in
guard let self, let dataSource = self.refresh?.dataSource else { return }
var snapshot = dataSource.snapshot()
snapshot.appendSections([self])
snapshot.appendItems(self.items)
dataSource.apply(snapshot, animatingDifferences: true)
}
}

func reload() {
refresh?.operationQueue.addOperation { [weak self] in
guard let self, let dataSource = self.refresh?.dataSource else { return }
var snapshot = dataSource.snapshot()
guard let sectionIdx = snapshot.indexOfSection(self) else { return }
let section = snapshot.sectionIdentifiers[sectionIdx]
snapshot.reloadItems(snapshot.itemIdentifiers(inSection: section))
dataSource.apply(snapshot)
}
}

func reload(items: [CVPartlyRefreshItem], completion: (() -> Void)? = nil) {
refresh?.operationQueue.addOperation { [weak self] in
guard let self, let dataSource = self.refresh?.dataSource else { return }
// 1. 更新模型层
self.items = items
var snapshot = dataSource.snapshot()
// 2. 找到当前 Section 的索引
// ⚠️ UITableViewDiffableDataSource 内部会对 Section 和 Item 进行拷贝
guard
let sectionIdx = snapshot.indexOfSection(self),
sectionIdx < snapshot.numberOfSections
else { return }
// 3. 获取当前的 Section
let section = snapshot.sectionIdentifiers[sectionIdx]
// 4. 删除当前 Section 的所有 Item
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: section))
// 5. 添加新的 Item 到 Section
snapshot.appendItems(items, toSection: section)
// 7. 应用快照
dataSource.apply(snapshot, completion: completion)
}
}

func append(
items: [CVPartlyRefreshItem],
reloadItem: CVPartlyRefreshItem? = nil,
removed: [CVPartlyRefreshItem] = [],
completion: (() -> Void)? = nil
) {
refresh?.operationQueue.addOperation { [weak self] in
guard let self, let dataSource = self.refresh?.dataSource else { return }
self.items.removeAll { removed.contains($0) }
self.items.append(contentsOf: items)
var snapshot = dataSource.snapshot()
guard
let sectionIdx = snapshot.indexOfSection(self),
sectionIdx < snapshot.numberOfSections
else { return }
let section = snapshot.sectionIdentifiers[sectionIdx]
if !removed.isEmpty {
let deletedItems = removed
.compactMap { snapshot.indexOfItem($0) }
.map { snapshot.itemIdentifiers[$0] }
snapshot.deleteItems(deletedItems)
}
snapshot.appendItems(items, toSection: section)
if let reloadItem = reloadItem {
snapshot.reloadItems([reloadItem])
}
dataSource.apply(snapshot, completion: completion)
}
}

func insert(
item: CVPartlyRefreshItem,
after: CVPartlyRefreshItem,
reload: CVPartlyRefreshItem? = nil,
removed: CVPartlyRefreshItem? = nil,
completion: (() -> Void)? = nil
) {
refresh?.operationQueue.addOperation { [weak self] in
guard let self, let dataSource = self.refresh?.dataSource else { return }
guard let afterIndexPath = dataSource.indexPath(for: after) else { return }
var snapshot = dataSource.snapshot()
guard
let sectionIdx = snapshot.indexOfSection(self),
sectionIdx < snapshot.numberOfSections
else { return }
self.items.insert(item, at: afterIndexPath.row + 1)
self.items.removeAll { $0 == removed }
if let removed = removed, let removedIdx = snapshot.indexOfItem(removed) {
snapshot.deleteItems([snapshot.itemIdentifiers[removedIdx]])
}
snapshot.insertItems([item], afterItem: after)
if let reload = reload, let idx = snapshot.indexOfItem(reload) {
snapshot.reloadItems([snapshot.itemIdentifiers[idx]])
}
dataSource.apply(snapshot, completion: completion)
}
}

func remove(items: [CVPartlyRefreshItem], completion: (() -> Void)? = nil) {
refresh?.operationQueue.addOperation { [weak self] in
guard let self, let dataSource = self.refresh?.dataSource else { return }
self.items.removeAll(where: { items.contains($0) })
var snapshot = dataSource.snapshot()
guard
let sectionIdx = snapshot.indexOfSection(self),
sectionIdx < snapshot.numberOfSections
else { return }
let allItems = snapshot.itemIdentifiers
let deletedItems = items.compactMap { snapshot.indexOfItem($0) }.map { allItems[$0] }
snapshot.deleteItems(deletedItems)
dataSource.apply(snapshot, completion: completion)
}
}

func remove() {
refresh?.operationQueue.addOperation { [weak self] in
guard let self, let dataSource = self.refresh?.dataSource else { return }
var snapshot = dataSource.snapshot()
guard
let sectionIdx = snapshot.indexOfSection(self),
sectionIdx < snapshot.numberOfSections
else { return }
onRemove?(self)
snapshot.deleteSections([snapshot.sectionIdentifiers[sectionIdx]])
dataSource.apply(snapshot)
}
}
}

extension CVPartlyRefreshItem {
func reload(animated: Bool = true, completion: (() -> Void)? = nil) {
refresh?.operationQueue.addOperation { [weak self] in
guard let self, let dataSource = self.refresh?.dataSource else { return }
var snapshot = dataSource.snapshot()
guard let itemIdx = snapshot.indexOfItem(self), itemIdx < snapshot.numberOfItems else { return }
snapshot.reloadItems([snapshot.itemIdentifiers[itemIdx]])
dataSource.apply(
snapshot,
animatingDifferences: animated,
completion: completion
)
}
}

func remove() {
refresh?.operationQueue.addOperation { [weak self] in
guard let self, let dataSource = self.refresh?.dataSource else { return }
var snapshot = dataSource.snapshot()
guard let itemIdx = snapshot.indexOfItem(self) else { return }
onRemove?(self)
snapshot.deleteItems([snapshot.itemIdentifiers[itemIdx]])
dataSource.apply(snapshot)
}
}
}

总结

回到使用侧,上文的 UICollectionViewCell() 就可以替换为 itemIdentifier.cell(in: collectionView, at: indexPath)。因为 Section 和 Item 与 UICollectionView 和 UITableView 的视图概念划分一一对应,所以如果有相关需求可在基类里面进行扩展,比如 Section 需要 header、footer 和装饰视图。理想情况下,在子线程中进行 dataSource.apply(snapshot) 是更好的选择,但随之带来的问题目前来说暂时没有想到很好的解决方式。👀 插个眼,后面脑筋通畅了再来弥补缺憾!