在 iOS 13 之后,Apple 推出了
UICollectionViewDiffableDataSource和UITableViewDiffableDataSource,作为传统UICollectionViewDataSource/UITableViewDataSource的现代化替代方案。这套方案彻底改变了数据与视图的绑定和刷新机制。本文档将结合实际工程改造(如追剧列表重构)的经验,对 DiffableDataSource 的原理、要点、注意点以及具体实现进行系统性总结。
一、 核心原理与传统方案的对比
1. 传统方案 (reloadData / performBatchUpdates)
传统方案中,Controller 充当了 UI 和数据的桥梁(Delegate/DataSource),遵循一种 "Pull-based" (拉取式) 模型:
- UI 向 Controller 询问:“你有几个 Section?”、“每个 Section 有多少个 Item?”、“这个 IndexPath 对应的 Cell 长什么样?”
- 当数据发生变化时,开发者需要手动调用
reloadData()(无动画、全量低效刷新)或者小心翼翼地计算增删改的索引,调用performBatchUpdates(极易因为计算错误导致 "Invalid update: invalid number of items" 崩溃)。
2. DiffableDataSource 方案
新方案采用了一种 "Push-based" (推送式) 且 "Declarative" (声明式) 的模型:
- 我们不再直接去修改底层数据源数组,也不再需要告诉 CollectionView 删除了哪一行、插入了哪一行。
- 取而代之的是,我们每次只需构建一个代表当前最新数据状态的“快照”
NSDiffableDataSourceSnapshot。 - 将新的快照
apply()给数据源,底层会自动运行高效的 Diff 算法,计算出旧快照和新快照之间的差异(新增、删除、移动、更新)。 - 然后自动以完美的动画执行这些增量更新,开发者完全不需要关心 IndexPath 的具体变化。
二、 核心要点 (Key Concepts)
1. 强类型的泛型声明
UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> 需要两个泛型参数,它们分别代表 Section 和 Item 的标识符类型。
这两个类型必须遵守 Hashable 协议。
enum Section: Hashable {
case main
}
enum Item: Hashable {
case video(model: MaiyaChaseListModel)
case add
}
因为 Item 内部关联了 MaiyaChaseListModel,所以要求模型本身也必须实现 Hashable:
final class MaiyaChaseListModel: SmartCodable, Hashable {
// 必须重写 hash 方法,只把能决定该模型“唯一身份”或“界面展示异同”的属性放进去
func hash(into hasher: inout Hasher) {
hasher.combine(shortPlayId)
}
// 必须重写 == 方法,Diff算法在发生哈希碰撞时,或者在比较两个快照差异时,会依赖它判断是否为同一对象
static func == (lhs: MaiyaChaseListModel, rhs: MaiyaChaseListModel) -> Bool {
return lhs.shortPlayId == rhs.shortPlayId
}
// ... 其他业务属性 ...
}
注意点:只有被 hasher.combine 加入的属性发生变化时,Diff 机制才会察觉并触发刷新。如果你更新了模型中一个没有参与 hash 计算的属性(比如只改了 totalEpisode),那么即便你 applySnapshot,系统也会认为该 Item 没变而忽略刷新。
2. 快照 (Snapshot) 驱动
数据不再是从数组中按索引(indexPath.row)读取,而是通过向 NSDiffableDataSourceSnapshot 中添加 Section 和 Item 来组装。每次刷新都是创建一个全新的快照,或者在现有快照副本上做修改。
3. Hashable 是唯一的真理
底层 Diff 算法识别一个 Item 是否“发生变化”或“被移动”,完全依赖于它的 Hash 值。
- 如果旧快照中有个 Hash 值为
A的元素,新快照中没了,它就会被视为 删除。 - 如果新快照中出现了 Hash 值为
B的元素,旧的没有,它就会被视为 插入。 - 如果 Hash 值
A的元素还在,但位置变了,它就会执行 移动 动画。 - 如果要强制触发某个已有 Item 的刷新(比如只更新了状态但不改变顺序),必须让这个 Item 的 Hash 值发生改变。
三、 具体实现范例
以下以改造一个视频列表为例,展示标准实现流程。
1. 声明数据源和标识符
首先在 Controller 内部或 Extension 中声明 Section 和 Item:
enum Section: Hashable {
case main
}
enum Item: Hashable {
case video(model: MaiyaChaseListModel)
case add
}
class MyListViewController: UIViewController {
// 声明数据源变量
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var collectionView: UICollectionView!
// ...
}
2. 配置数据源 (Configure DataSource)
在 viewDidLoad 或初始化 UI 时,创建并绑定 dataSource。这里取代了原本的 cellForItemAt 代理方法。
private func configureDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
(collectionView, indexPath, item) -> UICollectionViewCell? in
switch item {
case .video(let model, let isEdit, let isSelected):
let cell: MyListViewCell = collectionView.dequeueReusableCell(for: indexPath)
cell.itemModel = model
cell.editBtn.isSelected = isSelected
cell.isEditModal = isEdit
return cell
case .add:
let cell: MyListViewAddCell = collectionView.dequeueReusableCell(for: indexPath)
return cell
}
}
}
3. 构建并应用快照 (Apply Snapshot)
将网络请求回来的数据,或者状态变更后的数据,映射为 Item 数组,组装成快照并 apply。
private func applySnapshot(models: [MaiyaChaseListModel]) {
// 1. 创建全新的快照
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
// 2. 添加 Section
snapshot.appendSections([.main])
// 3. 将业务模型映射为 Item 标识符
var items = models.map { Item.video(model: $0) }
// 4. 将 Items 添加到指定的 Section
snapshot.appendItems(items, toSection: .main)
// 5. 应用快照 (必须在主线程执行,保证有动画效果)
DispatchQueue.main.async {
self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
}
4. 处理点击事件
在 didSelectItemAt 中,不再使用 indexPath.row 去原数组里取值,而是直接通过数据源获取当前点击的 Item 标识符。
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
// 安全地获取当前点击的 Item
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .add:
print("点击了添加按钮")
case .video(let model):
print("点击了视频: \(model.shortPlayId)")
}
}
四、 避坑指南与高危注意点 (Critical Warnings) ⚠️
1. Hash 冲突导致致命闪退
这是 DiffableDataSource 最容易引发 Crash 的地方。 规则:同一个快照中,绝对不允许出现两个 Hash 值完全相同的 Item。
-
场景:如果在下拉加载更多(Load More)时,后端接口返回了重复的数据(例如上一页的最后一条和下一页的第一条一样),此时直接
applySnapshot会立刻触发Fatal error: Invalid update: invalid number of items崩溃。 -
解法:在将网络数据赋值给数据源数组之前,必须进行绝对的安全去重。
// 严谨的做法:根据唯一业务主键去重,unique方法封装在ArrayUtils类中 let uniqueData = totalData.unique(by: \.shortPlayId) self.appChaseVideoList.accept(uniqueData)
2. 状态更新与动画闪烁(animatingDifferences的使用)
- 现象:当仅仅想要更新某个 Cell 的局部状态(比如点赞、选中、切换编辑模式),如果在
Item中将这个状态关联到了 Hash 中(如上面的isSelected),那么当isSelected改变时,旧的 Hash 和新的 Hash 会不一致。 - 底层机制:Diff 算法发现旧 Hash 消失,新 Hash 出现,它不会认为是“刷新了状态”,而是认为是**“删除了旧 Cell,插入了新 Cell”**。这会导致系统自动播放一段 Cell 渐隐渐现 (Fade) 的重排动画,造成严重的视觉闪烁。
- 解法(关闭动画):如果是批量状态切换(如点击“全选”、“进入编辑模式”),直接在
apply时传入animatingDifferences: false。
3. 多线程安全问题
虽然你可以(且官方推荐)在后台线程组装 Snapshot 进行复杂的 Diff 计算,但最终的 apply() 方法以及 configureDataSource 里的闭包回调,必须确保在主线程(Main Thread)中执行,否则极易引发不可预期的底层渲染异常。
4. 数据一致性
不要再维护两套状态。传统方案中,我们经常会在 ViewController 里维护一个 var dataArray = [Model](),然后让它和 CollectionView 同步。
在 DiffableDataSource 下,dataSource.snapshot() 本身就是“唯一真实的数据源 (Single Source of Truth)”。如果需要获取当前列表的所有数据,应该通过 dataSource.snapshot().itemIdentifiers 来获取,而不是去读自己维护的数组,以防止在动画执行过程中的短暂不一致。