1. 先理解痛点:传统 UITableViewDataSource 到底哪里别扭
1.1 reloadData 的粗暴哲学
在 UITableViewDiffableDataSource 出现之前,iOS 开发者跟列表打交道的方式几乎是一个模子刻出来的:实现 UITableViewDataSource 协议,返回 numberOfSections、numberOfRowsInSection、cellForRowAt,然后在数据发生变化时调用 reloadData() 或 reloadRows(at:with:)。这种方式在数据量小、页面逻辑简单的项目里跑得很顺,但一旦列表开始复杂,问题就扑面而来。
我印象最深的是那个经典场景:后台推送了一条新消息,你需要把新数据插到列表顶部。传统写法是先把数据源数组 update 一下,然后 reloadData()。表面上没问题,但用户端看到的效果是——整个列表闪一下、滚动位置被重置、正在播放的视频突然断掉、图片因为 cell 被复用重新加载了一遍。这是因为 reloadData() 压根不关心你改了什么,它把“整张表重画一次”当成唯一策略。数据量小时还能忍,几百行以上、带图片、带视频、带轮播的页面,这种全量刷新会直接把流畅度打没。
更隐蔽的是数据源不同步导致的崩溃。传统写法里,你告诉 UITableView 某个 section 有 5 行,但实际返回的 cellForRowAt 却因为数组越界等其他原因拿不到数据,应用直接闪退。这种崩溃在团队协作项目里非常常见,尤其是多人维护同一个列表页、各自改动数据逻辑时,数据源数组和 UI 的表现经常不在一个节奏上。
1.2 手动 diff 的人为失误
有人会说,那不用 reloadData,用 reloadRows 不就行了?问题是 reloadRows 要求你自己计算出“哪些行需要刷新、哪些行需要删除、哪些行需要插入”,这个计算逻辑叫 diff。自己写 diff 算法,小列表还能应付,一旦涉及多 Section、动态增删、拖拽排序、搜索过滤,几乎每个版本迭代都会冒出新 bug。要么漏掉某个 case 导致越界崩溃,要么动画错乱,要么在某些边界情况下数据源不同步,然后用户一脸茫然地看着列表里多了一行幽灵数据。
就算你用了第三方 diff 库,比如 IGListKit 那套方案,也得先调整数据模型、学习一套新的列表架构,迁移成本不低。而且这些库的定位更偏向 Feed 流这种超复杂列表,对大多数业务页面来说是杀鸡用了牛刀。
1.3 DiffableDataSource 的设计思路
2019 年,苹果在 iOS 13 里推出了 UITableViewDiffableDataSource 和 UICollectionViewDiffableDataSource。它的核心变化是:开发者不再手动管理数据源数组和表格的对应关系,而是把数据装进一个叫 Snapshot 的模型里,告诉 DiffableDataSource“这是当前完整的数据状态”,剩下的比较、动画、局部刷新全部由系统自动完成。
这个设计其实很像 Git 的工作方式。你不需要告诉 Git 具体删了哪一行、改了哪一行,只需要提交一个完整的新版本,Git 自己 diff 出变化。DiffableDataSource 也是一样,每次把新的 Snapshot apply 上去,系统会对比新旧快照的差异,自动决定插入、删除、移动哪些 cell,并且自带优雅的动画效果。
这里有个关键前提:列表里的每一条数据模型必须遵守 Hashable 协议。因为系统要靠 Hashable 的哈希值来识别“这一行还是原来的数据还是新来的数据”。理解了这一点,后续很多坑你就能提前避开,比如同一个模型里某个字段一变,整个 cell 就被判定为新行、导致动画异常,其实就是 Hashable 的实现不严谨。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 快速上手:第一次写出 DiffableDataSource 列表
2.1 搭建最小可用列表
先看一个最简单的例子。假设我们要做一个联系人列表,每个 section 代表姓氏首字母,cell 显示联系人姓名。
依赖注入的阶段,我们需要定义两种类型:
Section:枚举,遵守 Hashable,可以用首字母作为原始值。Contact:结构体,遵守 Hashable,包含姓名、头像 URL 等字段。
数据源属性可以这么声明:
swift复制private var dataSource: UITableViewDiffableDataSource<Section, Contact>!
接下来在 viewDidLoad 里创建数据源,并实现 cellProvider 闭包:
swift复制dataSource = UITableViewDiffableDataSource<Section, Contact>(tableView: tableView) { tableView, indexPath, contact in
let cell = tableView.dequeueReusableCell(withIdentifier: "ContactCell", for: indexPath)
cell.textLabel?.text = contact.name
return cell
}
第一次上手时,最容易困惑的点是:这里没有返回行数的方法了,那 TableView 怎么知道有多少行?答案是通过 Snapshot。构建快照并 apply:
swift复制var snapshot = NSDiffableDataSourceSnapshot<Section, Contact>()
snapshot.appendSections([.A, .B])
snapshot.appendItems([Contact(name: "Alice")], toSection: .A)
snapshot.appendItems([Contact(name: "Bob")], toSection: .B)
dataSource.apply(snapshot, animatingDifferences: true)
每次数据变化,就重新构造一个完整 Snapshot 再 apply 上去,系统自动 diff。这个流程非常重要,后面几乎所有的刷新场景都在重复“构建 Snapshot -> apply”这两个动作。
2.2 理解三个核心对象:DataSource、Snapshot、Cell
这三个对象的关系,我平时给团队新人讲的时候喜欢打个比方:DataSource 是表格的“显示器”,Snapshot 是数据的“照片”,Cell 是“像素点”。
显示器本身不存数据,它只负责接收照片,然后把照片里的内容渲染到屏幕上。Cell 就是像素,每种 Cell 类型对应一种像素规格。你每次更新界面,不是告诉显示器“把第 3 个像素改成红色”,而是重新拍一张完整的照片递过去,显示器自己对比新旧照片,找出哪些像素变了,然后只重新绘制那些变化的区域。
这样说可能有点抽象,但实际使用中确实就是这样思考的。DataSource 不需要持有可变数组,你手里唯一要维护的“真数据”其实是业务层的数据源数组——比如从网络请求拿到的联系人列表。每次需要刷新 UI 时,把这份业务数据做成一个新的 Snapshot 交给 DataSource 即可。
这也意味着一个问题:如果业务数据数组和 Snapshot 不同步,会不会有问题?会。不过好消息是,DiffableDataSource 的运行时一致性由系统保证——它内部维护了当前展示在屏幕上的数据集合,你在 apply 一个格式不对的 Snapshot 时(比如某个 item 被重复添加、section 不存在等),系统会直接抛异常而不是默默容忍。这点比传统 DataSource 的“延迟崩溃”强太多,问题出现时你立刻就能发现。
2.3 选择 RowIdentifier 的两种方式
Hashable 是 DiffableDataSource 识别 item 的唯一依据。在你自己的模型里实现 Hashable 时,有两种常见做法。
第一种是直接用结构体自带的全字段 hash。比如 Contact 包含 name、phone、avatarURL,系统会把这三个字段一起用来计算哈希值。这种做法的好处是写起来省事,坏处是只要头像 URL 一变,系统就认为这是新联系人,会走插入动画而不是刷新动画。某些场景下这个行为反而合理,比如头像变了就应该重头渲染。但在一些需要保持 cell 稳定状态的页面(比如正在播放的视频、滚动位置、展开状态),全字段 hash 会让你明显感觉到动画变得“太敏感”。
第二种是只拿稳定的唯一标识来 hash。比如给每条数据加一个 id 字段,只按 id 实现 Hashable:
swift复制struct Contact: Hashable {
let id: UUID
var name: String
var phone: String
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
这么写的好处是,只要 id 不变,系统就认定这是同一行数据,后续刷新只更新 cell 内容,不会触发奇怪的插入删除动画。但注意,Equal 判断也需要同步只比较 id,不然会出现“hash 相同但 equal 不相等”的问题,结果可能导致 NSDiffableDataSourceSnapshot 在查找变更时出现意外。最简单省心的方案是让 == 和 hash(into:) 都只基于 id 实现。
实际项目里,我通常还会在 ViewModel 层封装一个 Item 类型,包含 view state 的全部字段,同时只拿底层数据的稳定 id 作为 hash 依据,这样 UI 层的状态变化不会引起整行动画。
3. 从“能跑”到“好用”:进阶功能怎么实现
3.1 多 Section 的建模与排序
真实业务里,列表很少是单 Section 的。比如一个电商首页,顶部是 banner 轮播,中间是分类入口,下面是推荐商品的瀑布流。对应到 DiffableDataSource,Section 要注意的是它的类型。
Section 本身也必须遵守 Hashable。我见过不少人直接用枚举做 Section,这是最佳实践,因为枚举天然是 Hashable 的,而且语义清晰。用枚举时,RawValue 可以用 Int、String 或任意 Hashable 类型。
Section 的排序规则是 append 顺序,也就是说你在 Snapshot 里 append 顺序决定了它在表格里的上下顺序。这个设计很直观,但也容易踩坑——如果你在 network 回调里直接构造快照,却没有保证 append 顺序稳定,用户刷新一次列表,section 顺序可能就变一次。所以建议在 Model 层就把 Section 排序做好,而不是在 UI 层临时拼。
一个常见需求是“动态 section”。例如根据权限开关显示不同的区块。DiffableDataSource 处理这个很容易,你只要在构建 Snapshot 时有条件地 append 对应 section 即可。同样,如果某个 section 下面没有 item,你想隐藏它,只需要不 append 对应的 section,或者 append 了但不放任何 item——但要注意,空 section 仍然会展示 Header 或者高度为 0 的分组,这要看你的 TableView 风格。我通常会在构建 Snapshot 前过滤掉空 section,避免出现奇怪的空行。
3.2 搜索过滤与动态数据更新
搜索过滤是最能体现 DiffableDataSource 价值的功能之一。传统写法里,每次输入关键字都要手动计算过滤后数组和原数组的差距,再决定 reloadData 还是局部刷新,非常容易出 bug。DiffableDataSource 的做法就简单得多:不管 keyword 怎么变,你只需要重新建立一个 Snapshot。
伪代码思路:
swift复制func performSearch(keyword: String) {
let filtered = allContacts.filter { $0.name.contains(keyword) }
var snapshot = NSDiffableDataSourceSnapshot<Section, Contact>()
let sections = ContactSection.allCases // 这里可以按需生成
snapshot.appendSections(sections)
for section in sections {
let items = filtered.filter { $0.section == section }
snapshot.appendItems(items, toSection: section)
}
dataSource.apply(snapshot, animatingDifferences: true)
}
每次键盘输入一个字母就 apply 一次,动画自然流畅。有些细微体验问题需要注意:如果 apply 频率太高,动画会显得很急躁,用户打字过程中列表一直在跳,反而不舒服。我常用做法是配合定时器做个 300ms 的防抖,或者直接把 animatingDifferences 在连续输入时设成 false,等最终确定关键字后再开动画。
实现时还有个小细节:当过滤结果为空时,空快照会导致整个列表空白,此时配合 Empty 状态的展示逻辑。如果你只有一个表示空态的 Section,那没问题;但如果空态是一个普通 cell,你要确保它在过滤前后都是同一个 Item,否则系统会认为它是新增行,产生一次插入动画。
3.3 动画控制与差异计算细节
apply 的 animatingDifferences 参数控制是否播放 diff 动画。它默认是 true,但有一些场景必须手动设成 false。比如页面首屏加载第一次展示数据、从后台回前台做静默刷新、批量更新数据时。这些场景如果用动画,用户体验会变成“列表一直跳来跳去”。我个人的习惯是首屏直接 false,后续增量更新开 true。
DiffableDataSource 的 diff 计算是同步的,如果数据量大,动辄几千行,apply 时可能会有可感知的卡顿。但实测下来,iOS 系统内部的 diff 算法已经很强了,几千行的 diff 基本在几毫秒内完成。真正要小心的反而是 cell 内部自身的渲染逻辑——如果 cellForRowAt 里加载了高分辨率图片、做了复杂布局,diff 再快也会被 cell 渲染拖垮。
另外值得注意的一点:apply(_:animatingDifferences:completion:) 的 completion 闭包在动画结束后调用。如果你需要在刷新后做滚动、更新某些 UI 状态,可以放在 completion 里执行,避免跟动画冲突。在我自己的项目中,搜索结束后的落位、点击跳转后的数据同步,都用这个机制处理过。要注意 completion 不一定总在主线程被调用,所以如果里面要更新 UI,记得回到主队列。
3.4 空态与加载态
DiffableDataSource 没有内置空态视图,所以需要自己处理。常见做法是额外构造一个 Item 类型,加入 loadingItem 和 emptyItem 这两个枚举 case,在数据为空时往 Snapshot 里塞一个代表“空态提示”的 item,然后 cellProvider 里判断这个 item 类型,返回一个居中的提示 cell。这样做的好处是空态也走 diff 动画,从加载态切换到空态时过渡很自然。
之后如果还要加下拉刷新、上拉加载,都可以沿用这个模式。我在项目里一般是定义统一的状态枚举:
swift复制enum ListState {
case loading
case loaded([Item])
case empty(String)
case error(String)
}
然后每次 listState 变化,都构建一份对应的 Snapshot。这样列表页的数据状态完全是单向的,可预测性强,问题排查也方便。
4. 实战重构:手把手把旧列表页迁移过来
4.1 原项目的问题清单
我之前接手过一个IM消息列表页,原代码用 UITableViewDataSource 实现,作者维护了两套数组:一个是服务器下发的会话数组,一个是 UI 层根据业务规则过滤后的展示数组。刷新逻辑是这样的:网络回包后,先更新源数组,再手动计算过滤条件,然后 reloadData。看起来简单,但实际迭代半年后,问题严重到我们不得不对它进行重构。
具体表现有:下拉刷新后整个列表闪白;异步更新会造成数据源和 UI 错位,在快速下拉刷新时频繁崩溃;会话置顶、免打扰、草稿箱、未读数变化四五个开关组合起来,手动 diff 的逻辑已经没人能完全说清。最痛苦的是,每次新增一个业务状态,这个页面的 diff 逻辑就要重新推演一遍,测试成本特别高。
4.2 重构步骤拆解
我重构的核心理念是:UI 层只负责根据完整状态构建 Snapshot,业务层负责提供完整状态,两者之间不再有复杂的“增量更新”逻辑。
第一步,明确 RowIdentifier。IM 会话唯一标识是 conversationId,于是把 model 的 Hashable 只基于 conversationId 实现。这样未读数变化、免打扰开关切换这些业务状态变化,都不会被系统误判为新行。
第二步,梳理 Section。会话列表里有置顶区、普通区、草稿区,虽然它们在视觉上可能连续,但逻辑上应该拆成三个 section。这样置顶/取消置顶时只需要移动 section 里的 item,diff 动画会自动处理。
第三步,改造数据源。把 UITableViewDataSource 直接换掉,在 cellProvider 里面根据 conversation 的当前状态渲染 cell。由于 cell 的展示状态全部从当前 item 读取,系统 diff 时自然会把所有变化过的行重新刷新。
第四步,处理刷新时机。网络层回包后直接在一个方法里重组 Snapshot 并 apply。不管这次改动是一个会话的未读数变了,还是整个会话列表重新排序了,逻辑都是一样的。
4.3 重构前后的对比收益
重构完的显著变化是崩溃率下降。原列表页历史崩溃里,跟数据源不一致相关的占了大头,重构后这部分直接归零。其次是开发效率提高——后续新加了个“会话折叠”功能,原来的代码估计要调半天 diff,现在只需要在构建 Snapshot 时根据折叠状态过滤一下 item,大概十几行代码就搞定了。
还有一个意料之外的收益是 code review 变轻松了。以前 review 这个列表页,要脑补数据从网络层到 UI 层的流转,还要分析各种并发时序。现在逻辑被拆成三层:业务层维护状态、映射层把状态转成 Snapshot、展示层只负责渲染,每一层的职责都清晰很多,reviewer 只需要关注自己关心的那部分。
重构当然不是没有代价。改动面大,必须回归所有列表交互场景。我个人建议是先用 feature flag 把新旧两个页面保留,内部测试对比一段时间后再下线旧代码。不要急着一次性替换,给团队留出足够的观察时间。
5. 坑位清单:我在生产环境踩过的雷
5.1 apply 动画偶发崩溃
有一个坑是在快速连续刷新时偶发崩溃,报错信息类似“Invalid parameter not satisfying: self.test()”。原因是旧数据源在动画没有结束的时候,又 apply 了新 Snapshot,系统内部的快照状态已经变化,但你传给 UI 的回调里拿到的 IndexPath 对应的数据已经不再是期望的数据。
解决办法有几种,最常用的有两种:
- 在数据源内部用
snapshot()方法获取当前快照,然后基于当前快照做增量修改,而不是每次都从零构造。 - 对高频刷新做节流,保证同一时间只有一个 apply 在播放。
我在项目里最后用了第二种,因为基于当前快照做增量修改,在多线程并发更新的场景下还是会有竞态问题,不如从业务层就保证同一时刻只有一个数据源变更请求。
5.2 cell 复用导致的 UI 错乱
这是老生常谈,但换了 DiffableDataSource 后仍然有。原因是 diff 系统只负责 cell 的增删移动,不负责 cell 内部的 UI 状态清空。如果你在 cellForRowAt 里做了异步图片加载,或者设置了一些一次性状态(比如长长的横线、某个临时高亮色),复用后可能会出现上一个 cell 的状态残留。
解决办法是老规矩:在 cell 的 prepareForReuse 里把所有状态清干净,或者在 cellProvider 里对所有 UI 状态做“无条件设置”。建议使用后者,因为 prepareForReuse 只做“清空”,很容易漏掉某个状态;无条件设置则是每次渲染前都强制设一遍,不容易漏。
5.3 自定义 Hashable 的陷阱
自定义 Hashable 时,最大的陷阱是 hash 和 equal 不一致。比如你让 hash 只基于 id,但 equal 判断仍然比较了所有字段。当两个不同的业务对象(不同 id)在某些字段上相等时,系统会认为它们是同一个 item,导致差异计算错误。
解决办法是保证 hash 和 equal 使用同一套逻辑。最稳妥的写法:
swift复制func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: Contact, rhs: Contact) -> Bool {
lhs.id == rhs.id
}
两个方法都只依赖 id,这样就不会有歧义。我见过不少人只实现了 hash,不实现 equal,这时默认 equal 会比较所有存储属性,也会出问题。
5.4 兼容性与最低系统版本
UITableViewDiffableDataSource 最低支持 iOS 13,如果你的 App 还要兼容 iOS 12 及以下,就需要做兼容方案。常见做法是封装一层 ListDataSource 协议,内部用系统版本判断:
- iOS 13 以上用 DiffableDataSource
- iOS 12 及以下回落到传统 UITableViewDataSource
封装的好处是业务代码不用感知系统差异,统一调用同一个刷新接口。但这个兼容层写起来要花一点心思,特别是 diff 和动画部分,低版本只能退化为 reloadData,所以刷新效果会有差异。我们当时的处理是,低版本用户较少,且列表数据量不大,reloadData 的体验尚可接受,就没有额外引入第三方库来模拟 diff 动画。
6. 最后分享两个小技巧
如果你决定在团队里推广 DiffableDataSource,我建议从新页面开始试点,不要一上来就重构老页。选一个数据状态复杂、逻辑清晰、增量刷新要求高的页面,先做一版示范,把踩坑经验沉淀成团队文档,再逐步扩展。这样团队接受度和成功率都会高很多。
第二个技巧是关于调试的:在开发阶段,可以给 dataSource 的 defaultRowAnimation 设一个显眼的值,比如 .left,这样每次 apply 时你都能明显看到系统在做 diff。如果出现不该有的动画,说明你的 hash 设计有问题,可以趁早修掉。上线前再改回 .fade 或 .automatic。
UITableViewDiffableDataSource 不是一个需要“精通”的复杂 API,它的使用方式就那么几个。真正需要花心思的是数据建模和业务状态的设计,一旦你把这些理清楚了,列表开发会从此告别手动 diff 的苦日子。
