1. 从旧数据源到 diffable:我为什么彻底放弃了 cellForRowAt
做 iOS 列表开发的人,对 UITableViewDataSource 这一套应该再熟悉不过了:先实现 numberOfSections、numberOfRows(in:),再在 cellForRowAt 里通过 indexPath 从数据数组里取数据、配置 cell,数据变化时手动调 reloadData() 或者费劲地计算 insertRows、deleteRows。这套玩法用了快十年,看起来也没啥大问题,直到你的列表开始有动态增删、有 section 折叠、有频繁的数据刷新。
我真正被旧方案“逼疯”是在一次消息列表重构里。需求是从服务器拉新消息时要逐条插到顶部,断网重连后要整批合并,用户已读后要移除未读小红点。每次数据一变化,我得手工计算“哪一行插入、哪一行删除、哪一行移动”,稍不留神 beginUpdates / endUpdates 里的 insert 和 delete 不匹配,系统直接抛 NSInternalInconsistencyException,整个 App 崩掉。后来换了一个思路,不再手工维护“前后两个界面的差异”,而是直接描述“当前这份完整数据应该长什么样”,剩下的交给系统算——这就是 UITableViewDiffableDataSource 的核心思路。
这一篇文章围绕这个技术点,从理念、入门、重构到踩坑一层层拆开讲,适合两类人看:
- 还在用老数据源、被
reloadData闪烁和崩溃搞到头疼的 iOS 开发者; - 已经在用 diffable,但对 snapshot 的语义、重构边界、复杂列表场景还有疑问的人。
1.1 传统数据源方法的三座大山
旧数据源带来的问题,本质上可以归成三类。
第一类是状态不同步。numberOfRows 返回的是一个旧数组的 count,而 cellForRowAt 从另一个数组里取值,只要数据源里的数组和 UI 状态被改了,却没有同步给 tableView,页面就会出现 dataSource 异常崩溃。尤其是多人协作的项目里,A 同学在 viewModel 里 filter 了一个数组,B 同学另外写了个排序逻辑,最后 indexPath 完全对不上。
第二类是手动 diff 成本极高。一旦列表需要做局部刷新,你就要自己实现“对比新旧数组、算出增删改移动”的算法,或者用第三方库。数据量大时,reloadData 会打断用户操作,键盘收起、列表滚动位置跳动,体验很差。
第三类是动画控制几乎靠猜。beginUpdates 和 endUpdates 看起来能批量做动画,但你要是 insert 和 delete 同一个 indexPath,或者在同一个动画 block 里对某个 section 又删除又插入,系统会直接崩给你看。解决这种崩溃,一般只能到处加 if 判断,代码越写越丑。
而 diffable 像一个“状态快照同步器”。你不需要告诉 tableview 哪一行变了,只要把当前完整的 section、item 结构丢给它,它会自动对比前后快照,生成最小更新集合并执行动画。数据数组、UI 状态永远是一致的,因为 tableview 所有的呈现都来自这一份快照。
1.2 diffable 解决的其实是“状态同步”问题
理解 diffable 的关键,不是背 API,而是理解它的运行机制。你有没有觉得它很像前端的“虚拟 DOM diff”?React 的做法是:你描述完整 UI 状态,框架负责对比差异,只更新变更的部分。diffable 也差不多,只不过它对比的是数据源快照。
你手上的数据模型发生变化后,把新模型封装成 NSDiffableDataSourceSnapshot,交给 dataSource 应用。dataSource 内部把这份 snapshot 和上一次的 snapshot 对比,计算出哪些 item 是新增的、删除的、移动的,然后自动执行对应的 collection view 更新方法。你对 tableview 不需要再手动调 reloadData,也不需要自己算 indexPath 了。
“状态同步”意识的转变,是把整个列表开发的思路从“命令式”变成“声明式”。像我这种写惯了旧数据源的人,刚接触 diffable 时最不适应的就是“想改某个 cell 的标题就改数组,然后整体 apply”。总觉着“我明明只想改一个 cell,为什么要把整个数组传一遍?不会很浪费吗?”实际上系统会对比,只有改动的 cell 会被更新,性能不一定比你想的差。
1.3 核心概念先搞清楚:DataSource、Snapshot、Identifier
diffable 体系里就三个概念:
UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType>:数据源对象,负责管理 cell 的创建和配置,持有当前 snapshot;NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>:某一时刻列表完整状态的快照,包含所有 section 和 item,以及它们的顺序;ItemIdentifierType:item 的唯一标识,必须实现Hashable。
放在一起用,就是你创建 dataSource 时定义每个 cell 怎么创建;每次要刷新列表时,创建一个新的 snapshot 或者从 dataSource 拿一份 snapshot() 拷贝,往里 append / delete / move,然后 apply(snapshot)。
Sections 和 Items 都需要唯一标识,而且这里的“唯一”是必须保证的。如果同一时刻一个 section 标识出现在两个 section 上,diffable 会认为你传入了非法的 snapshot,直接抛异常。这个标识一般用模型的 id 就行,不必用整个模型对象;如果模型没有稳定 id,就得让 Hashable 相等性只基于 id 计算,否则每次都会被视为一个新对象。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 入门动手:把一个普通列表改成 diffable 驱动
你先不用急着重构大型项目,先找一个最简单的列表页练手,比如一个设置页或者一个静态列表页面。改起来非常快,基本就三步:注册 cell、创建 dataSource、apply snapshot。
2.1 最基本的三步配置
假设我们有一个 SettingItem 模型,表示一行设置项,就一个 title 和一个 icon 名:
swift复制struct SettingItem: Hashable {
let id: UUID
let title: String
let iconName: String
}
注意它实现了 Hashable,这一步是 diffable 的硬性要求,后面我会专门讲为什么。
第一步,配置 tableView 并注册 cell:
swift复制tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
第二步,创建 dataSource。这里有几个闭包,但本质上只有 cellProvider 是你必须实现的,它对应旧的 cellForRowAt:
swift复制dataSource = UITableViewDiffableDataSource<Int, SettingItem>(tableView: tableView) { tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.textLabel?.text = item.title
return cell
}
第三步,构建 snapshot 并 apply:
swift复制var snapshot = NSDiffableDataSourceSnapshot<Int, SettingItem>()
snapshot.appendSections([0])
snapshot.appendItems(items, toSection: 0)
dataSource.apply(snapshot, animatingDifferences: true)
就这三步,列表已经能跑了。后续哪怕数据全变了,也只需要重新生成一份 snapshot 再 apply 一次。有人会问:那 cell 点击、cell 高度这些呢?dataSource 只负责提供 cell 和 section 数量,tableView 的 delegate 仍然是原来的 delegate。didSelectRowAt、heightForRowAt 这些方法完全不用变。
2.2 snapshot 的语义比你想的要深
apply(snapshot) 并不是简单地把新数据指给 tableView,它内部会拿新 snapshot 和当前最新的 snapshot 做 diff。所以这里有个反直觉的点:你每次都需要传入“完整的新状态”,不是增量。
举个常见错误。很多初学者会这样写:
swift复制var snapshot = NSDiffableDataSourceSnapshot<Int, SettingItem>()
snapshot.appendItems([newItem])
dataSource.apply(snapshot)
他们以为这样是在“往现有列表里追加一项”。但 snapshot 是全新创建的,里面只有一个 item,apply 之后列表就只剩这一个 item 了。正确做法是先取当前 snapshot:
swift复制var snapshot = dataSource.snapshot()
snapshot.appendItems([newItem])
dataSource.apply(snapshot)
或者干脆从新数据源重建一份完整快照:
swift复制var snapshot = NSDiffableDataSourceSnapshot<Int, SettingItem>()
snapshot.appendSections([0])
snapshot.appendItems(currentItems, toSection: 0)
dataSource.apply(snapshot, animatingDifferences: false)
我用这个 API 踩过好几次“列表突然清空”的坑,全是 snapshot 没取全导致的。你要记住一句话:snapshot 是当前列表的全量表示,你往里面 append 之前,必须想清楚里面现在到底有什么。
2.3 注册 Cell 和补全回调的绑定技巧
cellProvider 是 diffable 里最核心的回调,它相当于老方法里 cellForRowAt 的一部分,但有一些细节上的差别。
首先,你不需要在这里判断 indexPath.row < items.count 这种边界条件,因为 dataSource 保证这个 indexPath 对应的 item 一定存在。底层已经帮你处理好了越界问题。所以 cellProvider 的核心逻辑就剩“拿到 item,配置 cell”。
其次,如果你有多种 cell,需要根据 item 类型返回不同的 cell 时,这里同样能处理,只是建议用不同的 reuseIdentifier 区分。比如一个动态列表里混着文本行、图片行、按钮行,每种 cell 一个类、一个复用标识,在 cellProvider 里根据 item 的类型去 dequeue 对应的 cell。这么做之后,cellForRowAt 里的 switch 并不会消失,只是从“判断 indexPath 位置”变成“判断 item 类型”,可读性明显更好。
还有一个小技巧:如果你用的是 xib 加载的 cell,记得用 register(_:forCellReuseIdentifier:) 注册;纯代码布局的 cell 也要先 register,否则 cellProvider 里 dequeue 时拿不到可复用的 cell,会返回 nil 再导致崩溃。这类问题排查起来很恶心,因为它不崩在注册那一行,而是崩在运行时 dequeue。
3. 重构是重头戏:从老代码迁移到 diffable 的完整链路
如果只是新建一个项目用 diffable,其实难度不大。真正麻烦的是把一个运行了很久、代码里到处是 reloadData 和手动 cell 配置的老列表改过来。这节我讲讲我重构一个订单列表页时完整走过的过程,以及中途遇到的各种“旧代码惯性”问题。
那个订单列表页不算特别复杂,但也不简单:有 section header、有状态筛选、有左滑操作、有下拉刷新。当时数据部分用了 RxSwift,数据数组存在 BehaviorRelay<[OrderEntity]> 里,controller 每次收到数组变化就 tableView.reloadData()。我这次的重构目标不是改网络层,而是只把“数据到 UI 的中间层”换成 diffable。
3.1 先盘点重构范围,别一上来就全改
重构前我最重要的一条建议是:先压缩这次改动的边界。很多时候我们看到 diffable 很香,就想着把整个页面重写一遍,结果一改就是两周,中途还引入一堆新问题。
我当时给自己定的边界是:
- 网络请求、数据解析、状态筛选逻辑完全不动;
UITableViewDataSource相关方法全部删掉,换成 diffable;- delegate 里的点击、高度、左滑操作先保留;
- 某个临时逻辑里通过
indexPath定位数据的代码,全部改为通过itemIdentifier定位。
这个边界很重要,因为它把“重构数据源”和“重构业务逻辑”解耦了。在动手之前,先花一小时找一下原来的 cellForRowAt 里有没有依赖 indexPath 从别的数组里取数据的操作。这种代码在迁移后最容易被漏掉。
3.2 cellForRow 删了,那高度、点击、滑动怎么办
旧数据源删掉之后,最直观的变化是 numberOfRows 和 cellForRowAt 都不见了。但很多人可能会慌了:tableView 的 heightForRowAt 也是问“这个 indexPath 的高度”,里面可能藏着一堆 dataArray[indexPath.row],那这些方法怎么办?
答案很简单:delegate 方法仍然能拿到 indexPath,你仍然可以像以前一样通过 indexPath 去取数据。diffable 只接管了 dataSource 部分,没有接管 delegate。所以:
tableView(_:heightForRowAt:)直接保留,里面通过dataSource.itemIdentifier(for: indexPath)取数据即可;tableView(_:didSelectRowAt:)同样保留,你也可以通过dataSource.itemIdentifier(for: indexPath)替代旧的数组下标访问。
这里有一个很隐蔽的好处:旧代码里如果数据数组已经变了,但 tableView 界面还没更新(比如你在一个异步回调里改了数组但忘了 reload),此时用 dataArray[indexPath.row] 可能直接数组越界。而用 itemIdentifier(for:) 是安全地根据当前 UI 快照反查 item,永远不会越界。这也修复了一类“indexPath 越界”的崩溃隐患。
3.3 多类型 Cell 和 Section 的处理
订单列表不止一种 cell。顶部有订单状态 banner,中间是商品信息 cell,底部是价格和操作按钮 cell。旧代码里靠一个 switch indexPath.section 加一个 switch indexPath.row 的组合去区分,比较乱。
我用 diffable 之后,把 section 定义成枚举:
swift复制enum OrderSection: Hashable {
case banner
case products
case summary
}
enum OrderItem: Hashable {
case banner(OrderBanner)
case product(ProductEntity)
case summary(OrderSummary)
}
注意 OrderItem 使用关联值。这里有一个很容易犯的错:ProductEntity 如果本身没实现 Hashable,OrderItem 的 Hashable 就会编译失败。所以要么给 ProductEntity 加 Hashable,要么在枚举里只放 ProductEntity.ID,然后在 cellProvider 里通过 id 去查找数据。推荐第二种方式,因为模型本身涉及的内容多,强行加 Hashable 可能牵扯到不需要的字段,而且 Diffable 对比两个 item 是否相等时,底层用的是 hashValue,如果你把整个实体都 hash 进去,等于每次任何字段变化都会发生 diff。
3.4 验证重构结果的方法
代码写完不代表重构完成,最关键的是验证 UI 行为和原来一致。我当时做了一个简单但有效的事:在重构前后各给页面录屏一遍,然后对比各种筛选状态下的列表表现。重点看三块:
- 切换筛选条件时,列表动画是否流畅、有没有明显的 reload 闪烁;
- 下拉刷新数据后,滚动位置是否保持不变;
- 左滑操作完成删除后,行是否正常消失,有没有错位。
其实还应该加一步:用 Thread Sanitizer 跑一遍,确保你 apply snapshot 的操作都在主线程。diffable 本身没有强制要求你在主线程操作,但 tableView 的 UI 更新必须回主线程,否则 UI 刷新和 system diff 之间会存在数据竞争,这种崩溃极其难查。
4. 动画细节与新指标:apply 后到底发生了什么
diffable 被很多人喜欢的核心原因是动画。但动画不是“系统自动帮我搞定一切”,而是它有自己的一套 diff 规则。如果你对这些规则不熟,可能会出现“明明数据没变,却执行了删除再插入动画”“列表整体闪烁”这些奇怪现象。
4.1 动画是由 diff 算出来的,不是由你写出来的
在旧数据源里,动画是显式声明的:insertRows(at:)、deleteRows(at:) 是你要自己调用的方法。diffable 里,动画完全依赖 item 的 Hashable 相等性。
这就解释了一个常见怪现象:如果你某个 item 的 id 没变,但内容变了,比如商品价格从 10 变成 12,你会观察到一个“刷新”动画,但它不会重新创建 cell。系统把它识别为同一个 item,只是内容变化,最终是复用旧 cell 重新执行配置,并不会产生 cell 的生命周期事件。如果你希望有“重新加载”的视觉反馈,比如闪一下,那得自己在 model 里加一个不稳定的字段去触发 diff,比如 reloadID。
反过来,如果你模型里某个字段被错误地包含在 Hashable 实现里,比如对象内部有一个 lastUpdateTime,每次服务端返回都会变,那每次 apply 时系统都会认为这是不同 item,于是执行的是“删旧增新”而不是“原地更新”。表现为列表轻微闪烁、滚动位置跳变、甚至连续刷新时 cell 闪烁。
所以我的建议是:Hashable 实现里尽量只包含稳定唯一标识,不要包含展示字段。查询和展示字段放在普通属性里,cellProvider 每次都会用最新的值配置 cell,即时有变化也会被反映在 UI 上,只是没有“动画过程”而已。
4.2 performBatchUpdates 时代的老问题
老开发应该都写过类似代码:
swift复制tableView.beginUpdates()
tableView.insertRows(at: indexPathArray, with: .automatic)
tableView.deleteRows(at: indexPathArray, with: .automatic)
tableView.endUpdates()
这套代码要求你传入的 indexPath 必须精确对应当前 UI 的状态,否则系统会直接抛 “Invalid update: invalid number of rows”。很多人为了避免崩溃,一遇到复杂变动就直接调 reloadData(),结果又丢失了动画。
diffable 把它彻底解决了,因为它内部的 diff 是在系统控制的“安全上下文”里执行的。你只要保证 snapshot 本身合法(同一时刻没有重复的 identifier),系统会自己编排 insert / delete / move 的执行顺序。你不再需要关心先插入还是先删除。这一点在“列表筛选”场景里体验最明显:原来切筛选条件可能各种崩溃、闪烁,现在就是一个 apply(snapshot) 的事。
4.3 大列表性能:什么时候 diffable 会变慢
diffable 也不是万能药。因为它在 apply 时需要计算新旧快照的 diff,如果 item 数量特别大,比如上万条,每次刷新都要比较每一个 item 的 hash,计算开销不可忽略。
我实际测试过一个 5000 条数据的列表,每次下拉刷新 apply 一次,主线程大概要花 60~100ms 去做 diff。这个量级在普通列表上感知不到,但在需要频繁刷新的大列表上会有明显卡顿。
应对方案有三个方向:
- 精简模型 hash 计算:让
hash(into:)只包含 Int 类型的 id,而不是把字符串、枚举都算进去; - 减少 apply 频率:比如合并短时间内多次变化,只做最后一次 apply;
- 无动画 apply:大刷新场景用
animatingDifferences: false,能省不少时间。
另外,diffable 分析 diff 是在主线程的。如果你担心大数据量的内容生成导致主线程耗时,可以把网络数据的处理放到后台线程,只在 apply(snapshot) 这一步回主线程。数据模型本身在后台解析、构建数组都没问题。
5. 与老代码撕裂时的几个典型坑
这一节专门写我在项目里实际遇到过、且用旧数据源思维去想很容易栽进去的几个坑。说白了大半都是“惯性思维”导致的,不是 diffable 本身难用。
5.1 同一 identifier 出现在同一层级的崩溃
diffable 强制要求:同一个层级中,每个 item identifier 必须唯一。这个“同一个层级”指的是同一个 section 内的 items,或者说所有 section 层级不能重复。
一旦传入的 snapshot 里出现了重复的 identifier,系统会直接抛异常:
code复制Fatal error: Duplicate identifier in snapshot
这个崩溃的排查方式和你想象中不太一样。明明数组里每个元素 id 都不同,但你还是崩了?那多半是模型类重写了 == 而没有正确重写 hashValue,或者两个不同对象 hash 碰巧一致。另一个常见场景是刷新时没有清空往期 snapshot 的闭包,导致你引用了一个已经失效的快照,然后在上面 appendItems 把旧 item 加回来了。
我习惯在模型里统一用 UUID().uuidString 当 id,然后 Hashable 只 implement id,不从其他字段生成 hash。这样一来这个崩溃基本绝迹。
5.2 reload 顺序和 apply 顺序的冲突
有时你的页面同时使用 beginUpdates / endUpdates 做批量更新,又调用了 apply(snapshot),两者交错执行,系统状态会混乱。diffable 没有做透明化处理,apply 内部会调用 reload / insert 等,但不会和你手动调用同步。
我建议的规矩是:一旦一个 tableView 使用了 diffable dataSource,所有 dataSource 层面的增删改一律通过 apply 完成;delegate 层面的操作(比如取消选中)不受影响。绝对不要在一个页面上既用 diffable 又手动拿着 tableView 调 insertRows。两者混用的后果是没有规律地崩溃,而且只在某些操作路径触发,难以复现。
5.3 底部刷新、占位视图、空数据状态
很多列表页有“下拉加载更多”的逻辑。老代码里,加载更多往往是在 willDisplay cell 里判断 indexPath.row == dataArray.count - 1 来触发,或者直接在 numberOfRows 里额外加一个加载 footer cell。
用 diffable 之后,这个判断逻辑不需要变多少,因为 willDisplay 里你同样能通过 itemIdentifier(for:) 判断当前 item 是不是最后一个。只是你需要想清楚:加载更多的“加载中”状态,是作为一个 cell 写进 snapshot,还是用 footer view 展示。
我自己的习惯是:加载中状态作为 footer view 展示,不进 snapshot。因为 footer 是独立的视图,不参与 diff,可以在 apply 之外自由控制显示隐藏。如果在 snapshot 里加一个 loading cell,每次加载更多都要在 snapshot 的末尾追加、删除它,逻辑虽然能做,但容易污染数据源,尤其是一边下拉刷新一边加载更多时,状态很难管理。
5.4 配合下拉刷新时的 tableView 高度跳动
以前用 reloadData() 时,下拉刷新会强制重绘所有 cell,已经展开的 cell 高度可能突然回到默认值。diffable 对这类问题改善明显,因为如果你 item 的 Hashable 稳定且没有变化,apply 时系统不会刷新那些没有变动的 cell,也就不会重置高度。
但这也有个代价:如果你在 cell 内部动态更改了约束并希望 tableView 高度跟着变化(比如展开/收起评论),diffable 默认不感知 cell 内容的高度变化。你需要额外手动处理:
- 要么在 cell 上定时更新约束后,手动触发
tableView.beginUpdates(); tableView.endUpdates()(注意,这只是刷新高度,不影响 dataSource diff,不冲突); - 要么把展开状态放到 item 的
Hashable值里,让 diffable 感知到变化并 reload 这个 cell。
推荐第一种。因为展开/收起是一种 UI 状态,不应该污染列表的 diff 计算;第二种做法会导致所有展开状态变化都走一遍 diff,如果你在快速连续点击展开/收起,可能出现动画追不上的问题。
6. 多 Section、多层级与复杂业务:diffable 的进阶用法
列表开发一旦进入中大型项目,单 section 场景只是热身。筛选、分组、搜索、层级展开,每一类都需要你重新思考数据结构。
6.1 Section 是稳定分组,不是临时筛选结果
一开始用 diffable,很容易把 section 和数据模型的分类属性直接绑定。比如订单分“全部、待付款、待发货、已完成”四个 tab,每个 tab 是一个独立页面,每个页面内部可能就一个 section。
但如果你在一个页面里同时显示“今天”和“更早”两个分组,section 就应该按“今天”“更早”来切,而不是按订单状态切。这里的判断标准很简单:section 要描述列表的视觉分组,而不是业务筛选条件。
如果你把业务状态塞进 section,切 tab 或筛选时就得频繁重构 section 列表,diffable 反而更绕。而视觉分组是相对稳定的,比如“推荐”“热门”“最新”,这种 section 结构不会频繁变化,用 diffable 管理起来非常顺手。
6.2 树形结构怎么展开和收起
常见需求:一级列表点开一个 section,可以展开看到子 item。用 diffable 实现时有两种思路。
思路一,把所有展开的子 item 都放进同一个 section,用不同 item 类型区分父子。这种实现简单,但你需要维护一个“展开状态”的 Set,在点击时手动修改 snapshot,把子 item 插入或删除。代码量中等,适合层级只有一层的场景。
思路二,父 section 和子 section 用不同层级表示。不过 UITableViewDiffableDataSource 没有原生支持嵌套 section 层级(它是扁平化的 section 与 item 结构),所以只能靠变通。我一般推荐思路一,把“父子关系”用 item 枚举表示,展开态用 Hashable 里的 expanded 字段携带,每次点击触发一次 apply。
对比来看,思路一更符合 diffable 的设计哲学:你要维护的不是“节点树”,而是一份扁平可展示的列表。当你发现自己的数据模型是个树时,建议先拍扁,再喂给 diffable,而不是硬塞一个树形结构进去。
6.3 搜索列表怎么用 diffable
搜索场景下,输入关键词会实时过滤列表。老代码里通常是每次 textDidChange 时重新计算 filter 后的数组并 reloadData(),结果键盘一直弹,列表一直闪。
用 diffable 后,可以把过滤逻辑后的结果放到一个 currentItems 变量里,每次变化就重建 snapshot apply。注意这里不需要 debounce 太长时间,因为 diffable 的 diff 开销很小,几百条数据的过滤几乎没有延时。不过如果你每次输入一个字符都要重新跑网络请求,那还是应该 debounce,这只是逻辑层面的优化,跟 diffable 无关。
我还有一个习惯:搜索时把 animatingDifferences 设为 false,因为高频输入场景下动画反而显得拖沓,容易和键盘动画抢时间。等停止输入 0.5 秒后再 apply 一次带动画的,体验会更好。
6.4 与 Combine / RxSwift 配合时的数据流设计
不少项目里数据流已经用了 Combine 或 RxSwift。diffable 与它们配合时,最关键的是“谁触发 apply”。
推荐的做法是:ViewModel 对外暴露一个 CurrentValueSubject<[SectionModel], Never> 或 BehaviorRelay<[SectionModel]>。ViewController 订阅它,收到新数据后直接构建 snapshot 并 apply。ViewModel 不持有任何 UITableView 相关对象,也不关心 UI 层如何渲染。这样 diffable 就只是 View 层的一个渲染工具,数据层保持干净。
我在实际项目中踩过的坑是:BehaviorRelay 每次发送数组时,如果数组是同一个可变对象,内部元素变了但它没有 emit,导致 UI 没刷新。除非你每次发送的是一个新数组实例,否则订阅者收不到信号。同理,snapshot 也是值类型,每次都要创建新的再 apply,不能修改原来的 snapshot 直接传过去。
7. 一个完整的最小 Demo:从零搭一个可运行列表
只讲道理不讲代码的教程等于没写。这一节给一个完整可运行的最小 demo,稍微改改就能搬到项目里用。我假设你已经创建好了一个 iOS 项目,并且部署版本是 iOS 13.0+。
7.1 定义模型和 section
swift复制struct User: Hashable {
let id: Int
let name: String
}
enum Section: Hashable {
case main
}
这里 User 只把 id 参与 hash。你可以测试一下:如果让 name 参与 hash,修改名字时 diffable 会认为 item 变了,继而重载 cell;这未必是坏事,但你要心里有数。
7.2 创建 dataSource 和 apply
swift复制final class ViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
var dataSource: UITableViewDiffableDataSource<Section, User>!
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
dataSource = UITableViewDiffableDataSource<Section, User>(tableView: tableView) { tableView, indexPath, user in
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = user.name
return cell
}
update(with: [
User(id: 1, name: "张三"),
User(id: 2, name: "李四")
])
}
func update(with users: [User]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, User>()
snapshot.appendSections([.main])
snapshot.appendItems(users, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: true)
}
}
这就跑起来了。如果你当前的数据源是从某个数组触发的,只需要在数组变化后调用 update(with:),那个列表闪动的问题基本就消失了。
7.3 与现有 MVVM 结构整合的例子
假设现有页面有一个 ViewModel:
swift复制final class OrderListViewModel {
struct Output {
let users: CurrentValueSubject<[User], Never>
}
}
ViewController 订阅输出:
swift复制viewModel.output.users
.receive(on: DispatchQueue.main)
.sink { [weak self] users in
var snapshot = NSDiffableDataSourceSnapshot<Section, User>()
snapshot.appendSections([.main])
snapshot.appendItems(users, toSection: .main)
self?.dataSource.apply(snapshot, animatingDifferences: true)
}
.store(in: &cancellables)
这样一个干净的响应式列表就成立了。后续新增删除、排序、分组都只在 ViewModel 里维护数组,UI 层只负责 apply,几乎不去碰 indexPath。
8. 关于 iOS 版本兼容与长期维护的一些个人经验
最后这部分更像是我做完两个项目的 diffable 迁移后,沉淀下来的一些维护级建议,不一定会在官方文档里看到,但长期维护时非常管用。
8.1 最低版本要求与兼容方案
UITableViewDiffableDataSource 是 iOS 13 推出的。如果你的 App 还要支持 iOS 12 以下,就只能继续用老数据源,或者自己包一层“diff 算法 + apply 接口”的假 diffable。我的建议是:如果项目最低版本已经到 iOS 13 或 iOS 14,别犹豫,直接上;如果还在 iOS 12,那我为这个“历史包袱”默哀,继续使用老方案不要强行移植,否则你在兼容层里写的东西可能比老数据源还多。
8.2 让 model 保持轻量 Hashable 的强烈建议
diffable 的 hash 计算是每次 apply 都会发生的,所以 model 的 hash 效率直接决定性能。我不建议把整个 model 通过 Hashable 自动合成,因为自动合成会把所有属性都参与 hash。
更好的做法是:
swift复制struct User: Hashable {
let id: Int
let name: String
static func == (lhs: User, rhs: User) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
这样即使 name 变了,diffable 也认为它是同一个 item。cellProvider 会在复用 cell 的时候拿到最新的 name 并刷新 UI。要注意,这种“通过刷新 cellProvider 来更新内容”的方式不会自动执行 cell 配置,因为 cellProvider 只有在新 cell 被配置时调用。等你用到这个场景时,如果不生效,问题的根因就在这里。
实际操作中的补充方案:如果你希望内容变化时强制 reload 一次,可以把做法改成一个方法,在 apply 前先构建一个带 reloadItems 的 snapshot。snapshot 支持 reloadItems(_:),你可以把内容有变化的 item 手动加进 reload 集合。这是官方 API,比“更新 hash 字段”要直观得多。
8.3 Debug 时怎么确认 diff 是否符合预期
排查 diffable 问题最麻烦的一点是:你不知道系统到底 diff 出了什么。我建议在 Debug 模式下打印一下 apply 前后的快照内容:
swift复制var snapshot = NSDiffableDataSourceSnapshot<Section, User>()
snapshot.appendSections([.main])
snapshot.appendItems(users, toSection: .main)
if hasChanged(dataSource.snapshot(), to: snapshot) {
dataSource.apply(snapshot, animatingDifferences: true)
}
hasChanged 你可以自己实现一个简易比较,或者直接无条件 apply。Debug 时也可以给 dataSource 加一个 wrapper,打印 appendItems 的 item IDs。这样一旦出现“列表清空、闪烁、莫名重排”,你至少能快速定位是 snapshot 内容不对,还是 diff 计算的问题。
8.4 别把 diffable 神话,它只是工具
这可能是最重要的一条经验。diffable 确实能大幅度减少手动 diff 的崩溃,但它没有解决所有列表问题:
- 它不解决 cell 内部复杂的自适应高度问题;
- 它不解决列表大量滚动时的 cell 复用性能瓶颈;
- 它不感知键盘、不感知 scrollView 的 contentInset 调整;
- 它也不负责网络请求的合并与竞态。
我见过一些团队把 diffable 当成银弹,把以前所有手写的 beginUpdates/endUpdates 都改成 apply,结果发现原先某些底层的复杂逻辑反而没法表达了。真实项目里,列表页的复杂度往往不在数据源,而在业务状态机的流转。diffable 只是帮你减少了一个维度的复杂度,其他维度的复杂度该处理还是得处理。
我对最近一两年 iOS 列表开发趋势的切身感受是:diffable 把“数据怎么显示”这件事从 controller 里剥掉了一层,但不代表 controller 就能彻底瘦身。它真正解放的是你反复对比数组、计算 indexPath 的脑力,让你有余力去关注更核心的业务逻辑。对新手来说,建议直接以 diffable 为起点学列表开发,旧数据源只需要知道它能做什么就行,不必深陷于手动 diff 的泥潭。对老项目,则建议像我一样,一次只重构一个页面,先跑通再铺开,稳扎稳打不会翻车。
