刚切换 Diffable Data Source 那天,我把手头一个老项目的列表页从头到尾翻了一遍,第一反应是:之前写的那堆 reloadData、indexPath.row、还有藏在 numberOfRows 里的各种 if 分判,到底是怎么扛过这么多版本的。并不是传统写法完全不能跑,而是每次来个新功能、调个删改排序,我就得像排雷一样检查 cell 复用、动画状态和数组索引,动不动就白屏、闪屏、崩溃三连。后来团队里有人提议试试 UITableViewDiffableDataSource,一开始我挺抗拒,觉得不就是多了个快照嘛,能有多大差别。真正改完一个复杂列表之后,我记得很清楚,那天下午我把原来的 dataSource 实现整个删掉了,心里只有一个词:清爽。
如果你现在还在用传统的 UITableViewDataSource 写列表,尤其是那种数据模型复杂、增删排序频繁的页面,我强烈建议你花点时间把这套东西学明白。这篇文章我打算从“为什么必须换”讲起,然后深入 diffable 的快照机制,最后用真实的重构过程带你走一遍从传统写法到现代写法的完整迁移,顺便把那些文档里不写的坑都给你踩一遍。
1. 为什么要换掉传统的 UITableViewDataSource
1.1 传统列表写法里那些每天都在踩的坑
先别急着上代码,我们回想一下用传统 UITableViewDataSource 维护列表时最让人头疼的几个场景。
第一,dataSource 回调强制要 indexPath。你的数据是一维数组还好,一旦列表变成分区列表、多 section 混合内容,numberOfSections、numberOfRowsInSection、cellForRowAt、didSelectRowAt 四个回调里同时维护一个二维数据结构,每次增删都要手动计算正确的 section、row,写错一个就等着“Index out of range”吧。我见过一个老项目,Add 一条记录要在 array.insert 后手动 tableView.insertRows(at:),结果因为数组和 UI 不是同步更新,偶发崩溃,线上反馈了好几次才定位到漏了处理空 section 的场景。
第二,reloadData 是最省事但也是最粗暴的方案。很多时候我们根本不想处理精确的增删改,于是直接 self.tableView.reloadData()。问题是 reloadData 会重建所有可见 cell,不仅打断用户当前滚动,还会导致图片闪烁、输入框失焦、展开状态丢失。如果页面里还有轮播图、视频播放器这类重量级组件,每次刷新卡顿感非常明显。而且 reloadData 完全没有动画,用户看到列表“跳”一下,体验很廉价。
第三,手动 diff 非常难写且容易出 bug。为了优化交互体验,一些同学会尝试自己写 diff 逻辑,也就是比对新旧两组数据,找出新增、删除、更新、移动的项,再去调 performBatchUpdates / beginUpdates。理论上可行,但现实是做出来一个勉强能用的 diff 工具就要花不少精力,还要处理“同一个 item 同时被删除又插入”“两个相同 item 移动到不同位置”这种边界情况。稍有不慎 batch update 就会抛异常:“Invalid update: invalid number of rows in section”。说实话,这不是算法能力的问题,而是这个方案本身的容错率太低了。
1.2 Diffable 的诞生背景与定位
UITableViewDiffableDataSource 是 iOS 13 推出的,同期还有 UICollectionViewDiffableDataSource。它的定位不是单纯帮你少写几行代码,而是从架构层面改变了列表数据的刷新方式。
传统模式是“你告诉我 UI 该怎么变”,你得手动计算哪个 cell 该插入、该删除、该更新,UI 是数据模型的下游,而且中间隔着大量样板代码。Diffable 模式是“你告诉我数据现在长什么样”,框架自己比较新旧数据的差异,然后自动处理 UI 更新。
你没看错,苹果把 diff 的能力直接做进了系统框架。这意味着你不需要再维护老一套的批量刷新逻辑,也不需要祈祷每个 indexPath 都对得上,框架在底层会安全地帮你完成精确的动画更新。这个转变对列表开发的工程意义,不亚于 Auto Layout 之于手写 frame。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Diffable Data Source 的核心原理与设计逻辑
2.1 快照(Snapshot):唯一数据源
要理解 UITableViewDiffableDataSource,你只需要抓住一个核心概念:Snapshot。
你可以把它理解为当前列表状态的“一张完整照片”,也就是某一个时刻,所有 section 和 item 的数据结构到底是什么样。你每次想更新列表,不需要去改动 tableView,而是先构建一个新的 snapshot,然后把新 snapshot apply 给 dataSource,剩下的差异计算、动画更新统统交给框架。
swift复制var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: true)
这段代码几乎是 diffable 使用的“最小骨架”。但推敲一下,它背后的设计思路很值得琢磨:
- Section 和 Item 都必须实现
Hashable,原因在于 diff 的过程依赖哈希值来判断“两个 item 是否相等”。 - Snapshot 是值类型,对它的任何修改都是独立的,不用担心外部数组被隐式改动。
- Apply 之后,框架拿旧 snapshot 和新 snapshot 做对比,再生成一批精确的 UI 操作。
2.2 Hashable 驱动的差异计算
Diffable 之所以能实现精细动画,核心依赖 Hashable。你声明的 Item 类型,必须能稳定地哈希自身,框架才能判断“这个 item 是新加的”“那个 item 被删了”“这两个 item 只是内容变了位置没变”。
这里我踩过一个非常典型的坑:一开始图省事,直接拿类对象当 Item 用。类对象的哈希值默认跟内存地址相关,同一份数据如果重新从服务器 fetch了一遍,即便内容一模一样,在框架眼里也是两个不同的 item。结果每次下拉刷新,列表都认为旧 item 全被删了、新 item 全插进来了,动画就是从“空列表”过渡到“全新列表”,完全没有 diff 的效果,还会出现 cell 闪烁。
正确的做法是让 Item 使用结构体,并尽量基于一个稳定不变的标识符(比如 ID)来参与哈希运算,而不是把整个对象的所有字段都拿来做哈希。
swift复制struct ProductItem: Hashable {
let id: Int
let name: String
let price: Double
let imageURL: URL?
// 只以 id 判断“是不是同一个产品”
// name/price/imageURL 变化时,framework 会认为是同一个 item 的数据发生了更新
}
注意这里的微妙之处:Hashable 的 hash 结果决定“是不是同一个 item”,而 framework 一旦确认是同一个 item 之后,只要其他属性(比如 name 变了),它就会更新 cell。所以你要想清楚:哪些字段是稳定标识,哪些字段是可变数据。这跟数据库主键的设计思路完全一致。
2.3 为什么这个设计比手动 reloadData 更正确
我觉得 diffable 最厉害的一点,是把“UI 更新策略”从“数据变更之后手动同步”提升到了“每次提供完整数据快照,系统自动 diff”的维度。这背后的心智转变其实是很深的:
- 你再也不用关心 indexPath 计算,因为数据源只认标识符,不认位置。
- 你再也不用关心批量更新的顺序问题,系统会保证 apply 完整性。
- 你再也不用考虑“数据模型和 UI 不同步”导致的越界崩溃,因为它每次拿到的都是一份完整一致的快照。
举个类比,传统方式就像一个厨师在厨房里盯着每道菜,哪个顾客加了菜,他就自己去灶台上“增删菜”,一旦忘记哪个桌号对应哪个菜就乱套了。Diffable 的方式则是每个饭点,后厨直接把所有桌号、菜名、份量整理成一张“总单”交给系统,系统自己算清楚哪个桌子需要补菜、哪个桌子要撤菜,全程自动且不易出错。
3. 从传统代码迁移:最小改动方案
3.1 版本要求与前置检查
先确认环境:UITableViewDiffableDataSource 最低支持 iOS 13,如果你的 App 还要支持 iOS 12 及以下,就得另做兼容。我做过一次统计,多数工具类 App 在 iOS 13 覆盖率已经接近九成,所以这两年大多数团队都已经放开了最低版本限制。如果你还在维护老项目,建议在业务需求排期里把最低版本提到 iOS 13 作为一个明确的技术债清理项。
项目里如果用了 Swift,需要确保 Xcode 版本能正常编译 iOS 13 SDK;如果还在比较老的 Xcode 上写 Swift 4,可能需要额外考虑 API 可用性标注。
3.2 一个最少改动的重构示例
我们拿最经典的联系人列表页面举例。传统写法大概是这样的:
swift复制// 传统
final class ContactListViewController: UIViewController, UITableViewDataSource {
private var contacts: [Contact] = []
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return contacts.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let contact = contacts[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! ContactCell
cell.configure(with: contact)
return cell
}
}
改成 diffable 之后的版本:
swift复制// Diffable
final class ContactListViewController: UIViewController {
enum Section {
case main
}
struct ContactItem: Hashable {
let id: Int
let name: String
let phone: String
}
private var dataSource: UITableViewDiffableDataSource<Section, ContactItem>!
@IBOutlet weak var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(ContactCell.self, forCellReuseIdentifier: "cell")
dataSource = UITableViewDiffableDataSource<Section, ContactItem>(tableView: tableView) { tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! ContactCell
cell.configure(with: item)
return cell
}
}
func updateContacts(_ contacts: [Contact]) {
let items = contacts.map { ContactItem(id: $0.id, name: $0.name, phone: $0.phone) }
var snapshot = NSDiffableDataSourceSnapshot<Section, ContactItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: true)
}
}
仔细对比可以发现几个明显变化:
- 不再需要控制器实现
UITableViewDataSource,cellForRowAt直接合并在 dataSource 的初始化闭包里。 - 所有 UI 更新统一收敛到一个
updateContacts方法里,外部只负责给数据。 - 模型层用一个
ContactItem结构体代替原来裸的Contact,但实际业务模型不一定完全等同 UI 模型,这一步我们后面细说。
很多人初次上手,对“不再需要设置 tableView.dataSource = self”这个变化不太适应。但这也正是优势,因为 tableView 的 dataSource 职责从“整个控制器自实现”变成了“一个专门管理 snapshot 的独立对象”,后续遇到问题只需要关注 dataSource 和 snapshot 的关系就够了。
3.3 过渡技巧:不是所有页面都非改不可
我见过一些团队因为重构意愿太强,一次性把十几个列表页全部改掉,结果改动面过大,review 困难,期间还出现了几个页面因为模型 Hashable 设计不合理导致 diff 动画奇怪的 bug。我不建议这么做。
我的建议是分三步走:
- 先选一个列表最复杂、增删改最频繁、用户对动画感知最强的页面,比如购物车、订单状态列表、聊天会话列表,把 diffable 跑通。
- 跑通之后观察一段时间,确认没有莫名其妙的白屏和卡顿,再把通用的 dataSource 封装成可复用的组件,比如
AutoUpdatingTableDataSource<Section, Item>。 - 最后才逐步替换那些静态展示型列表页,这种页面可能永远不需要 update,用了 diffable 也不会有什么坏处,但没必要放在重构最紧急的前置阶段。
这个顺序的核心逻辑是:让新方案先在最能体现优势的场景里证明自己,再用来解决普通场景,减少团队回归时的不确定因素。
4. 多分区与复杂数据模型的进阶操作
4.1 Section 与 Item 的模型设计
实际业务里的列表基本不会只有一个分区。比如一个设置页面可能有“账号”“通知”“关于”三个分区,而首页列表可能是“Banner、推荐商品、历史记录”多种结构的瀑布流。
定义 section 枚举时,我建议直接用一种内部枚举类型来表达分区含义,而不是滥用字符串:
swift复制enum HomeSection: Hashable {
case banner
case recommended
case history
}
如果章节之间本身还有层级关系,或者需要按业务组合多个维度,也可以让 section 枚举带关联值,比如:
swift复制enum ProductListSection: Hashable {
case header(String)
case products(category: Category)
}
这里要特别注意:section 本身也必须可哈希,且它的哈希值会影响 diff 结果。如果两个 section 使用了不同的关联值,framework 会当成两个不同 section。这个特性有时很好用,比如你要根据分类动态生成多个 partition 的时候。
4.2 处理 section header/footer 以及 footer 数据
UITableViewDiffableDataSource 默认是不管 section header/footer 的,它只管行数据。header/footer 的数据来源还是老老实实走 UITableViewDelegate 的 viewForHeaderInSection、titleForHeaderInSection 这些方法,或者在 viewDidLoad 里通过 UITableViewHeaderFooterView 配置。
有时候你希望 header 的内容能跟着数据一起变化,比如分组标题来自服务端字段。我常用的方案是让 header 对应的 section 在枚举里带上相关数据,然后在 titleForHeaderInSection 中拿到 section 类型并提取展示数据:
swift复制func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
guard let sectionIdentifier = dataSource.snapshot().sectionIdentifiers[safe: section] else {
return nil
}
switch sectionIdentifier {
case .header(let title):
return title
default:
return nil
}
}
注意,这里不能再像传统写法那样通过 indexPath 去索引模型数组,因为 diffable 不保证数组索引与 UI 索引严格一一对应的计算方式(虽然绝大多数时候是一致的)。更稳妥的做法是 dataSource.snapshot().sectionIdentifiers[section] 这种明确通过快照查询 section 的方式。
4.3 当数据模型发生变化时,如何精准更新某一行
这是 diffable 日常使用频率最高的一个场景。假设你的购物车页面,用户点击“加号”按钮,某个商品数量从 1 变成 2。如果你直接传递全量数据构建一个 snapshot,framework 能自动识别“同一个 item 内容变化了”,并触发 cell 刷新。
问题在于,如果 cell 的复用配置逻辑很重,比如图片下载、富文本拼接、布局计算,即使数据只变了一个字段,它也会重新渲染整个 cell。这时候,做分字段更新反而更合适。不过 diffable 并不直接支持“我只更新这个 item 的某个字段”这种 API,你只能更新整个 item。
我的经验是:UI 模型越细粒度,diff 的精确度就越高。比如一个“商品数量 +1”的操作,如果 Item 结构体里包含数量字段,framework 会刷新该行;但如果你只希望数量文本变化而不想触发图片重新下载,那就要把图片 URL 是否变化和数量变化拆开考虑。通常我会把 Item 设计成多个子结构,比如 ProductInfo、QuantityControl,然后计算哈希时只包含图片 URL 而非全部数据,这样能实现“图片不变不重下,数量变了只改数字”的精细化刷新效果。
swift复制struct CartItem: Hashable {
let productID: Int
let productName: String
let price: Double
var quantity: Int
func hash(into hasher: inout Hasher) {
hasher.combine(productID)
}
static func == (lhs: CartItem, rhs: CartItem) -> Bool {
return lhs.productID == rhs.productID
&& lhs.quantity == rhs.quantity
}
}
看上面这个例子:hash 只 combine productID,所以框架不会因为 quantity 变化就认为这是新 item;而 == 比较两个字段,所以 quantity 变化时,framework 才会“更新 cell 内容”。这种设计既满足了 diff 的定位逻辑,又能控制刷新范围。
4.4 多分区同时更新的实战案例
再举一个真实的复杂案例:一个订单列表页,按订单状态分为“待付款”“待发货”“已发货”三个分区,每个分区里是一个订单卡片。用户完成付款后,这个订单应该从“待付款”移动到“待发货”。
你只需要构建一个全新的 snapshot,把三个分区的数据全部放进去:
swift复制var snapshot = NSDiffableDataSourceSnapshot<OrderSection, OrderItem>()
let pending = orders.filter { $0.status == .pending }
let shipping = orders.filter { $0.status == .shipping }
let delivered = orders.filter { $0.status == .delivered }
snapshot.appendSections([.pending, .shipping, .delivered])
snapshot.appendItems(pending, toSection: .pending)
snapshot.appendItems(shipping, toSection: .shipping)
snapshot.appendItems(delivered, toSection: .delivered)
dataSource.apply(snapshot, animatingDifferences: true)
framework 会自动计算出:一个 item 在“待付款”中被删除,在“待发货”中被插入,同时如果 item 的哈希相等,还会智能地把它视为“移动”而非“删除+插入”,从而呈现动画效果最平滑的移动过程。如果用手写 deleteRows + insertRows,这个迁移逻辑够你调试半小时。
5. 手势、重排与动画控制的细节
5.1 左滑删除与批量操作
UITableViewDiffableDataSource 支持系统级的左滑删除手势,只要实现 UITableViewDelegate 的 trailingSwipeActionsConfigurationForRowAt 即可。
不过很多人忽略一个关键点: 左滑删除的闭包里,必须通过 dataSource 拿到当前 item,而不是从数组里拿。因为 diffable 的数据模型已经全部被迁移到 snapshot 中,外部数组不应该再承担“当前 UI 状态”的职责。我见过同事在删除闭包里直接用 dataSource.snapshot().itemIdentifiers[indexPath.row],其实这是对的,但要注意 indexPath 在闭包里虽然可用,但 apply 后可能变化,所以更稳妥的写法是直接用 itemIdentifier(for: indexPath)。
swift复制func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let deleteAction = UIContextualAction(style: .destructive, title: "删除") { [weak self] _, _, completion in
guard let self = self else { return }
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
completion(false)
return
}
var snapshot = self.dataSource.snapshot()
snapshot.deleteItems([item])
self.dataSource.apply(snapshot, animatingDifferences: true)
completion(true)
}
return UISwipeActionsConfiguration(actions: [deleteAction])
}
核心思路是:从 snapshot 删除 item,然后 apply,不要自己维护数组去删。这样 UI 和模型永远处于同一个快照体系,不会再出现数组索引不一致的崩溃。
批量操作也是一样的套路,比如“清空已读通知”按钮,你可以直接构建一个新 snapshot,把需要删除的 items 一次性 delete 掉,系统会自动完成所有行的移除动画。
5.2 拖拽重排:和想象中不一样的坑
自 iOS 11 起,UITableView 原生支持 drag/drop reorder,但注意,Diffable Data Source 并不原生支持带 snapshot 的拖拽重排,这是很多人踩坑最深的点。
正确做法是:在 canMoveRowAt 或 tableView(_:moveRowAt:to:) 里更新数据源时,不能只改数组,还要手动更新 snapshot。
swift复制func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
guard let fromItem = dataSource.itemIdentifier(for: sourceIndexPath),
let toItem = dataSource.itemIdentifier(for: destinationIndexPath) else {
return
}
var snapshot = dataSource.snapshot()
snapshot.moveItem(fromItem, beforeItem: toItem) // 或 afterItem
dataSource.apply(snapshot, animatingDifferences: false)
}
这里有个非常微妙的点:如果只是简单调用 moveItem,在 section 之间移动时可能会出现逻辑不符合预期的情况,因为 moveItem(_:beforeItem:) / moveItem(_:afterItem:) 的目标位置是相对于另一个 item 的,如果目标 item 和源 item 在同一个 snapshot 中的位置关系比较复杂,就会产生奇怪的排序结果。我的建议是,跨 section 移动时,直接重新构建一个完整 snapshot 并按新顺序 appendItems,这样最稳妥。
5.3 动画控制与 animatingDifferences 场景
apply(snapshot, animatingDifferences:) 这个参数很关键。业务上,大部分场景我们希望有动画,比如插入、删除、移动,动画会带来更连贯的视觉体验。但在某些场景,比如首次加载、页面从后台回前台、服务器推送的全量更新,动画反而显得拖沓,甚至会造成视觉闪烁,这时候建议设为 false。
swift复制// 首次加载,不需要动画
dataSource.apply(snapshot, animatingDifferences: false)
// 用户点击“刷新”,希望看到动画效果
dataSource.apply(snapshot, animatingDifferences: true)
另外一个细节:iOS 15 之后,apply 的默认参数是不带动画,如果你不显式传 true,在 iOS 15+ 上可能不会出现你预期中的系统动画。这就导致很多人在新系统上发现“哎怎么没有动画了”,在旧系统上又是正常的。我建议所有调用点都显式传 animatingDifferences,不要依赖默认值。
6. 重构经验:什么时候改、怎么改、改了之后
6.1 判断一个页面是否需要重构的信号
并不是所有列表页都必须立即改成 diffable。我知道有些老项目跑得好好的,没必要为了炫技而动。但下面三个信号一旦出现,就应该认真考虑重构了:
- 频繁出现 reloadData 的代码。如果你在一个页面里看到“避免闪烁所以延迟 reload”这种 hack,说明传统写法已经影响体验了。
- 批量更新时自己写 diff 逻辑。与其维护一套容易出错的自研 diff,不如让系统框架来做。
- 数组与 UI 状态越界崩溃。常见于多线程更新数据源、网络回调回来后立即刷新,而表格还在滚动。
我遇到过最典型的一个页面:IM 会话列表。新消息进来要插入、会话置顶要移动、未读数变化要更新 cell,传统写法在 handle 不同业务消息时调用了十几次 reloadData,而且因为消息可能是后台线程回调,还得小心翼翼包一层 DispatchQueue.main.async。改成 diffable 之后,所有的 UI 更新都收敛到“构建 snapshot 并 apply”这一条路径,线程问题、 index 问题、漏刷问题基本全部消失。
6.2 搭配 MVVM 的推演与配套改造
如果你目前项目是 MVC 结构,Pure Diffable 也可以直接用,无非是把 updateContacts 改成 viewModel 暴露出的数据回调即可。如果已经在用 MVVM,搭配 way 更顺畅:
- ViewModel 负责把业务模型转换为 UI 模型(即符合
Hashable的 Item 结构体)。 - ViewController 只监听 ViewModel 的“数据快照”输出,比如
snapshotPublisher或 delegate 方法。 - 每当 ViewModel 发出新 snapshot,ViewController 直接
dataSource.apply(snapshot)。
举个例子,我们项目里有一个 OrderListViewModel,它内部接收“订单列表 API 返回”“轮询状态变化”“用户操作反馈”等事件,每次事件处理完成后,最终都会组装一个完整的 NSDiffableDataSourceSnapshot,然后回调给 viewController。viewController 这边的代码就像一个薄薄的 adapter:
swift复制final class OrderListViewController: UIViewController {
private var dataSource: UITableViewDiffableDataSource<OrderSection, OrderItem>!
override func viewDidLoad() {
super.viewDidLoad()
bindViewModel()
}
private func bindViewModel() {
viewModel.onOrdersUpdated = { [weak self] snapshot in
self?.dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
这个模式的好处是:数据的状态机集中在 ViewModel,UI 更新完全由快照驱动,界面末日稳定。这种架构对大型工程特别有价值,因为多人协作时,任何人都不用去猜“这个页面列表的数据源在哪个数组里”,而是直接看 ViewModel 的 buildSnapshot 方法即可。
6.3 性能如何量化
有人担心 diffable 的底层 diff 算法会不会造成性能损失。实测下来,几百个 item 的列表,diff 计算时间几乎可以忽略不计,主要的开销还是在 cell 渲染上。apply 内部会调度到下一次 runloop,避免同一 runloop 内多次 apply 导致无谓计算。因此在频繁数据更新的场景,diffable 相比 reloadData 反而有性能优势,因为系统只刷新变化的部分。
我用 Instruments 对同一份 500 条数据的列表做过对比:
| 操作 | reloadData | Diffable apply |
|---|---|---|
| 全部刷新耗时 | ~85ms | ~18ms |
| 单行更新耗时 | ~60ms | ~3ms |
| 插入一行 | 需手动计算 | 自动处理 |
| 闪烁 | 明显 | 无 |
当然,这个数据在真机上因设备而异,但趋势非常明显:精确刷新永远比整表刷新便宜。
6.4 与 UICollectionViewDiffableDataSource 的统一
如果你在项目里同时使用 UICollectionView 和 UITableView,会发现两者的 diffable 用法几乎一样。尤其是 iOS 14 引入的 UICollectionView.CellRegistration 之后,collection view 的 cell 配置变得更简洁。这算是一个额外红利:你把 diffable 这套思维迁移到 collection view 上,基本零成本。不少项目借着重构列表的机会,顺手把 collection view 也升级了 diffable,因为背后的 snapshot 模型完全一致。
关于 UICollectionView 的 NSDiffableDataSourceSectionSnapshot 还可以更细粒度地做树形结构(展开收起),这又是另一个话题,但保持核心思路不变:所有 UI 状态更新都以 snapshot 为准。
7. 常见问题与排查技巧实录
7.1 崩溃:Item 的 Hashable 实现不稳定
最常见的问题就是把 Hashable 的 hash 和 == 混合使用,比如 hash 只 combine id,但 == 比较了所有字段。这样会导致一种极端情况:两个 item 的 id 相同但其他字段不同,hash 相等,但 == 不相等,framework 在内部 diff 时会认为它们既是同一个 item 又不是同一个 item,偶尔触发奇怪的不一致性。
解决方式很简单:hash 的内容必须与 == 的判定逻辑保持一致。要么只比较 id,要么比较参与哈希的所有字段。我的建议是:
swift复制struct OrderItem: Hashable {
let orderID: String
let status: String
let amount: Double
func hash(into hasher: inout Hasher) {
hasher.combine(orderID)
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.orderID == rhs.orderID && lhs.status == rhs.status && lhs.amount == rhs.amount
}
}
注意这个例子中,status 变化会让 == 返回 false,但 hash 值不变,系统会认为“同一行内容更新了”,从而刷新 cell。如果你希望 status 变化不被刷新生效,那就应该让 == 只比较 orderID。
7.2 动画异常:apply 后 cell 闪烁或跳动
常见原因有两个:
- 每次构建 snapshot 时,Item 的
Hashable实现不稳定,导致同一个业务对象在不同时刻被识别为不同 item。 - 每次构建 snapshot 时,Item 里的某个字段携带随机值,比如 UUID、时间戳,导致“同一个 item 每次哈希都不同”。
排查思路:在 apply 调用前打印 snapshot 的 itemIdentifiers,对比前后两次的标识符列表,看是否存在不应该出现的“新增”或“删除”。
还有一个情况是行高变化导致的视觉跳动。比如 cell 内有图片加载之后变高,diffable 认为“行内容变了”并刷新该行,但行高变化如果不配合 estimatedRowHeight 调整,tableView 会出现跳动。解决办法是确保 cell 的自适应高度配置正确,或者固定行高。
7.3 多线程更新数据源导致崩溃
Diffable 并不改变 UI 只能在主线程更新的规则。所有 apply 调用都必须在主线程执行。如果你的 ViewModel 在后台线程计算了 snapshot,回到主线程再 apply 即可。
swift复制DispatchQueue.main.async { [weak self] in
self?.dataSource.apply(snapshot, animatingDifferences: true)
}
AVOID:直接在网络回调里 apply,虽然有时候碰巧能运行,但在数据量大、界面切换复杂的页面容易取到中间态,造成崩溃。
7.4 与旧代码共存时的数据源切换
重构过程中,如果你的页面同时存在旧版数据源逻辑和新版 diffable data source,可以给 table view 显式设置新 dataSource,并把它持有在控制器属性中。尽量不要在页面生命周期内频繁切换 dataSource,那样会导致 tableView 回调混乱。
如果旧代码还有其他地方通过 tableView.dataSource 获取数据,比如测试工具、埋点分析,记得同步适配。重构这类基础设施时,往往不是代码本身难,而是隐式依赖太多。
7.5 关于 section header 高度、刷新时机
UITableViewDiffableDataSource 默认会在 apply 时重新加载可见的 section header/footer 数据。这意味着如果你的 header 是通过 delegate 方法返回 title,apply 之后 titleForHeaderInSection 会重新被调用。但如果你用 UITableViewHeaderFooterView 自定义视图,需要保证它的配置方法在刷新时能拿到正确的 section 模型。
我踩过一个坑:第一次 apply 后,header 的 title 正确,但第二次 apply 时,delegate 方法获取 sectionIdentifiers[section] 时因为 section 数量变化导致越界。后来我在所有 delegate 方法里先做 safe 检查,再取值,才稳定下来。safe 取 index 可以自己写一个小 extension,或者在 sectionIdentifiers 为空时直接返回 nil。
7.6 如何用 debug 工具验证 snapshot
Xcode 的视图调试器里,选中 UITableView,在右侧面板可以看到 dataSource 的 snapshot 信息,包括当前 section 和 item 数量。平时排查“是不是没有 apply”“是不是 snapshot 是空的”,可以直接在这个面板里观察,非常方便。
我习惯在自定义 dataSource 的子类里重写 apply 方法,打一条日志,记录当前 snapshot 的 item 数量,这样线上问题时可以通过日志迅速判断是数据问题还是 UI 问题。
8. 个人重构心得与额外建议
最后分享一点我自己在重构多个项目后沉淀下来的经验。
不要试图在一次提交里把所有列表全量替换掉,也别因为某页面简单就跳过迁移。最好是从核心业务页开始,在改的过程中深挖 item 和 section 的哈希策略。一旦确定了一套稳定的模型设计,后续其他页面都可以复用同一种模式。比如我们团队后来定了一个规范:所有 UI 列表模型必须以 struct + 稳定 id 为基础,禁止直接拿 Realm 对象或 Core Data 的 NSManagedObject 当 Item。这条规范在 diffable 时代极大减少了崩溃和动画异常。
还有一点,diffable 对数据变化频繁、更新方式多样的列表是质变。但如果你只是一个静态介绍页,列表内容写死、从不变化,那用传统写法也完全够用。工具永远是拿来解决问题的,不是拿来炫技的。不过现代 iOS 开发中,diffable 已经逐渐成为列表开发的主流写法,苹果也持续在往这个方向加功能,比如 iOS 15 的 applySnapshotUsingReloadData、iOS 16 对 section snapshot 的扩展。早点掌握,后期无论是接手新项目还是重构旧项目,都会比别人多很多底气。
如果在实际迁移中遇到什么奇怪的动画或者莫名的崩溃,欢迎在评论区把你的 snapshot 模型定义和 apply 场景贴出来,我们可以一起看看哈希和 diff 过程中到底哪里出了问题。这类问题看着诡异,但绝大多数都能从 Hashable 的实现里找到根因。
