ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

UITableViewDiffableDataSource 实战:告别 IndexPath,用数据驱动 iOS 列表

UITableViewDiffableDataSource 实战:告别 IndexPath,用数据驱动 iOS 列表 做 iOS 列表开发的人多少都经历过这种噩梦明明数组里删掉了一条数据界面上却因为indexPath算错直接崩溃明明只是刷新一行的状态却调了reloadData把整个 table 闪了个遍项目里每次涉及列表增删都要提心吊胆地处理insertRows和deleteRows的配对。我自己在重构老项目时甚至见过一个performBatchUpdates里嵌套了十几种分支判断的祖宗代码只为了维护数据和 UI 的一致性。直到UITableViewDiffableDataSource出现苹果才真正把列表开发从手动维护数据源和 IndexPath的泥潭里拉了出来。它引入了一套完全不同的思维模式你不再告诉 UITableView 哪一行要插入、哪一行要删除而是直接把现在应该长什么样的数据快照丢给它剩下的插入、删除、移动动画由系统内部通过 diff 算法自动算好。这篇文章我会从它的核心机制讲起到完整跑通一个项目级的 Diffable 列表再到把老代码一步步重构过来的实操路线最后把我这两年踩过的坑和排查经验一并整理出来。不管你是第一次接触 Diffable 的新手还是已经被 delegate dataSource 折磨到想掀桌的老手这篇应该都能给你一个清晰的落地路径。需要说明的是UITableViewDiffableDataSource从 iOS 13 开始就完全可用所以如果你的最低版本在 iOS 13 以上完全可以直接上手不用考虑任何兼容方案。1. 列表开发的老问题为什么我们需要 Diffable Data Source1.1 传统数据源方案的三大痛点在 Diffable 出现之前UIKit 给开发者提供的列表更新方式主要有两种reloadData和performBatchUpdates配合insertRows/deleteRows。reloadData的问题最直观——不管数据有没有变它都会把整个列表重刷一遍。数据量小的时候无所谓但当一个页面有几百上千条记录时每次 reload 必然带来可见的闪烁和滚动位置跳动。更关键的是reloadData会无差别地重新调用所有可见 cell 的cellForRowAt那些本来没变化的 cell 也被迫重建这在真实体验上就是卡和闪。performBatchUpdates看上去是解决方案但它引来了更严重的问题手动管理 IndexPath。比如你要同时删除第 2 行并插入一个新行到第 5 行你必须先理清楚操作顺序确保这一步用的indexPath是上一步删改之后的新位置。稍有大意就会抛出经典的Invalid update: invalid number of rows in section崩溃。我在老项目里见过太多这种崩溃日志几乎每一个都是因为后台返回的数据和本地缓存数组不同步而 UI 又强行按照本地数组去执行动画导致的。第三个痛点藏在更深的地方——数据和 UI 的关系从来不是数组 回调这么简单。真实的业务列表往往要同时维护多个数据源、筛选状态、编辑状态、加载状态这些状态一旦交织就是灾难。比如一个已选好友 全量好友的列表你需要同时维护已选集合、全量数组、以及 cell 里勾选状态的数组三个数据源只要有一个和 UI 对不上立刻出现错选、错位、点击无响应等诡异问题。1.2 Diffable 带来的根本性思维转变UITableViewDiffableDataSource的核心理念是单一数据源 数据驱动 UI。开发者手里只维护一个NSDiffableDataSourceSnapshot它描述的是当前 UI 应该呈现的完整状态——有哪些 section、每个 section 里有哪些 item。当你调用apply(_:animatingDifferences:)时系统内部对新旧 snapshot 做 diff 计算进而自动推断出需要执行的增量更新操作。这种模式跟传统方式最大的区别是你不再描述怎么变只描述**变成什么样**。底层的 diff 算法会负责找出插入、删除、移动的最小集合。对于开发者来说代码逻辑从过程式命令变成了状态声明这其实和现在流行的声明式 UI 思想是一脉相承的。另外Diffable 还在协议层面帮我们挡掉了一类经典 bug。传统的UITableViewDataSource通过numberOfRowsInSection和cellForRowAt两个方法描述数据任何一处和模型不一致都会崩。而 Diffable 的 item 都要求Hashable同一时刻一个列表里不能出现两个相同的 identifier否则会直接断言失败。你看编译器级别和运行时级别双重把关从机制上保证了数据的唯一性和完整性。2. 核心机制拆解Diffable 的三张牌2.1 Snapshot唯一事实来源理解 Diffable 最好的方式是把它想象成一份拍照底稿。NSDiffableDataSourceSnapshot本身是一个值类型它内部保存了两样东西当前完整的 section 列表和 item 列表。当你对一份 snapshot 做操作时操作的是它的副本直到你把它apply到 dataSource 上它才正式生效。我习惯把 snapshot 理解成 git 的 commit——你在本地随便改文件操作 snapshot提交之后apply系统帮你算出和上一个 commit 的完整差异然后做最小化的文件变更。最常见的 snapshot 操作var snapshot dataSource.snapshot() // 获取当前快照 snapshot.deleteItems([model]) // 删除某个 item snapshot.appendItems([newModel]) // 追加新的 item dataSource.apply(snapshot) // 提交系统自动做 diff这里要提醒一点snapshot 是值类型不是引用类型。所以var snapshot dataSource.snapshot()之后改动这份 snapshot并不会立刻修改 UI也不会影响 dataSource 内部的状态。所有修改必须通过apply提交UI 才会跟着变化。2.2 泛型约束与 Hashable 的深层逻辑UITableViewDiffableDataSourceSectionIdentifier, ItemIdentifier的泛型类型约束都要求遵守Hashable这不是苹果随意加的限制。diff 算法要在旧快照和新快照之间找到相同项和不同项通常的做法是把元素放进哈希表做匹配——只有确定了唯一标识系统才能判断某一条数据是新增、删除还是被移动了。实际项目中Section 通常用一个枚举来表达例如enum HomeSection: Hashable { case banner case product case recommend }Item 最好用一个包含稳定唯一标识的模型来充当比如从服务端拿到的id而不是直接使用模型类的整块数据。我见过有人直接把整个 model struct 当作 identifier如果 model 里有一个updatedAt字段每次刷新都会变那么即使业务上它还是同一条数据diff 计算也会把它当成一个全新 item导致 UI 上出现莫名其妙地重排。所以最稳妥的写法是给 model 加一个唯一的id字段让Hashable只基于这个 id 判断相等其他展示字段的变化通过 cell 的configure(with:)来刷新struct ProductItem: Hashable { let id: String var name: String var price: Double static func (lhs: ProductItem, rhs: ProductItem) - Bool { lhs.id rhs.id } func hash(into hasher: inout Hasher) { hasher.combine(id) } }这样写的好处是diff 只看身份不关心内容。当价格或名称变化时Diffable 不会识别为新增/删除你可以另外通过reloadItems或配置模型的方式去刷新 cell 内容而不是把整行抹掉重来。2.3 内部 diff 算法与更新流程apply(snapshot)被调用后dataSource 会对比当前 snapshot 和内部维护的旧 snapshot计算出三个维度的差异被删除的 item、被插入的 item、发生移动的 item。系统把差异转换成一系列UICollectionView批量更新操作交由 UIKit 执行对应的插入、删除、移动动画。这个过程的重点是diff 计算后的操作集一定是最小且自洽的。无论你的数据从 100 条变成 50 条再穿插新增 30 条你都不需要关心哪个 indexPath 先插、哪个先删——系统生成的动画序列保证不会产生 index 错乱。从 iOS 15 开始apply方法增加了completion回调可以方便地拿到动画执行完成的时机dataSource.apply(snapshot, animatingDifferences: true) { // 动画完成可以做一些后续操作 }如果你需要跳过 diff 动画、直接全量重载比如切入后台再回来时的懒刷新可以使用 iOS 15 提供的applySnapshotUsingReloadData(snapshot)。它本质上就是结合了reloadData的高性能版本但注意这个方法在 iOS 17 开始被标为废弃系统会自动根据 context 选择是否用 diff 方式刷新所以新项目里可以直接忽略它继续使用apply。3. 从零到一完整实现一个 Diffable 列表3.1 数据模型与界面准备先搭一个最小的可运行案例一个城市列表点击某个 cell 可以把它从列表中删除模拟数据的动态变化。模型定义如下struct City: Hashable { let id UUID() let name: String let population: Int }这里我们用UUID()作为唯一 id保证同一份数据即使名字相同也不会被当成同一个 item。如果你实际项目中 model 本来就带了数据库或服务端 id直接用更好。界面部分就是标准的UIViewControllerUITableView注册 cellfinal class CityListViewController: UIViewController { private let tableView: UITableView { let tableView UITableView(frame: .zero, style: .insetGrouped) tableView.register(UITableViewCell.self, forCellReuseIdentifier: CityCell) return tableView }() private var cities [City]() override func viewDidLoad() { super.viewDidLoad() setupTableView() setupDataSource() applyInitialSnapshot() } }3.2 DataSource 的声明与配置声明一个UITableViewDiffableDataSourceSection 类型用一个简单的枚举private enum Section: Hashable { case main } private var dataSource: UITableViewDiffableDataSourceSection, City!初始化时传给它 tableView 和一个 cell provider 闭包。这个闭包的职责是给一个 indexPath 和 model返回一个配置好的 cellprivate func setupDataSource() { dataSource UITableViewDiffableDataSourceSection, City(tableView: tableView) { [weak self] tableView, indexPath, city in let cell tableView.dequeueReusableCell(withIdentifier: CityCell, for: indexPath) var content cell.defaultContentConfiguration() content.text city.name content.secondaryText 人口\(city.population) cell.contentConfiguration content return cell } tableView.dataSource dataSource }这一步要注意设置了tableView.dataSource dataSource之后旧的UITableViewDataSource方法就不要再写了。Diffable 已经替你把numberOfRowsInSection、cellForRowAt等内部实现好了你如果再去实现同名方法会是两份逻辑造成混乱。3.3 Snapshot 的构建、应用与刷新策略初始数据通过生成 snapshot 并 apply 完成private func applyInitialSnapshot() { var snapshot NSDiffableDataSourceSnapshotSection, City() snapshot.appendSections([.main]) snapshot.appendItems(cities, toSection: .main) dataSource.apply(snapshot, animatingDifferences: false) }首屏加载我习惯用animatingDifferences: false避免进入页面时出现数据从无到有的动画。后续每次替换整个列表、做搜索过滤时再给 true让 diff 动画自然呈现。删除一个城市的操作是这样private func deleteCity(_ city: City) { cities.removeAll { $0.id city.id } var snapshot dataSource.snapshot() snapshot.deleteItems([city]) dataSource.apply(snapshot, animatingDifferences: true) }核心思路就是改动模型数组的同时必须同步修改 snapshot。因为模型数组是业务层的事实来源snapshot 是 UI 层的事实来源两者要始终一致。如果更新某个城市的人口数private func updatePopulation(for city: City, newPopulation: Int) { guard let index cities.firstIndex(where: { $0.id city.id }) else { return } var updatedCity city updatedCity.population newPopulation cities[index] updatedCity var snapshot dataSource.snapshot() snapshot.reloadItems([updatedCity]) dataSource.apply(snapshot) }这里reloadItems会让对应行重新走一遍 cell provider从而用新的 model 内容刷新 cell并且在 iOS 15 之后默认还带一个淡入淡出的动画。如果你希望更新内容时完全无动画可以在生成的 snapshot 对象上设置 reload 动画。4. 真实项目中的高级玩法与重构思路4.1 多 Section 管理与动画控制真实的列表通常不只一个 section。比如一个内容 App 的首页可能是顶部轮播 为你推荐 热门话题 广告的组合。Diffable 在这个场景比传统方案有天然优势因为你可以直接用单个 snapshot 表达一个三维结构enum HomeSection: Hashable { case carousel case recommend(String) // 带参数的 section 也支持 case hotTopic case ad }构建 snapshot 时按顺序 appendSection再往对应 section 里 appendItemsvar snapshot NSDiffableDataSourceSnapshotHomeSection, HomeItem() snapshot.appendSections([.carousel, .recommend(为你推荐), .hotTopic]) snapshot.appendItems(bannerItems, toSection: .carousel) snapshot.appendItems(recommendItems, toSection: .recommend(为你推荐)) snapshot.appendItems(hotItems, toSection: .hotTopic)这里的HomeSection枚举带着关联值依然可以正常做 Hashable。一个非常实用的点在于你可以通过直接操作 snapshot 来高效地更新某个特定 section 的内容而不影响其他 section。比如下拉刷新只刷新为你推荐只需要var snapshot dataSource.snapshot() if let recommendSection snapshot.indexOfSection(.recommend(为你推荐)), let currentItems snapshot.itemIdentifiers(inSection: .recommend(为你推荐)) { // 删除旧的一组插入新的一组 snapshot.deleteItems(currentItems) snapshot.appendItems(newRecommendItems, toSection: .recommend(为你推荐)) } dataSource.apply(snapshot)注意这里indexOfSection的用法已经在 iOS 15 被废弃更推荐直接使用sectionIdentifiers判断是否存在、或者用snapshot.indexOfSection(_:)的替代写法新项目直接用indexOfSection如果最低版本不需要兼容老系统就没有问题否则需要做判断。我个人实践中多用snapshot.sectionIdentifiers.contains来检查避免兼容性问题。动画控制的细节snapshot本身提供了reloadItems(_:with:)或reloadSectionsiOS 15来指定 reload 的动画效果。实际项目中要根据业务场景选型例如数据频繁变化的正在直播列表如果用默认的 fade 动画频繁刷新会显得很跳可以考虑reloadSections无动画或自定义动画。4.2 可拖动排序与移动操作Diffable 不仅支持增删也支持列表项的移动操作。开启方式有两个条件首先在 dataSource 上设置reordering闭包返回 true 表示这个 item 允许移动dataSource.reorderingHandlers.canReorderItem { item in return true }然后在 VC 中实现UITableViewDataSource的moveRowAt方法虽然 dataSource 已经是 Diffable但移动回调要额外实现func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { var snapshot dataSource.snapshot() guard let fromItem snapshot.itemIdentifiers(inSection: .main)[safe: sourceIndexPath.row], let toItem snapshot.itemIdentifiers(inSection: .main)[safe: destinationIndexPath.row] else { return } snapshot.moveItem(fromItem, beforeItem: toItem) dataSource.apply(snapshot, animatingDifferences: false) }这里有一个极容易踩的坑Diffable 默认支持 cell 移动但不会自动同步你的业务数组。如果你用一个普通的[City]数组作为业务数据源拖动之后数组顺序还是旧的下一次 apply snapshot 时 UI 会被纠正回去。解决方法是每次移动回调完成后以 snapshot 的 item 顺序为准重建一下业务数组func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { let movedItems dataSource.snapshot().itemIdentifiers cities movedItems // 再把这个顺序同步到服务端或本地存储 }优先顺序上可以这么做业务数组始终与 UI 当前展示的顺序 保持一致而 Diffable 里apply之后的 snapshot 就是 UI 顺序。4.3 从旧代码重构到 Diffable 的落地套路从老项目迁移到 Diffable不建议一次性全量重构。我的经验是分三步走。第一步把UI 数据源和业务数据分开。在旧代码里很可能直接拿dataArray给 tableView 用同时又在别处修改dataArray。重构前先理清楚哪个数组是 UI 的唯一映射哪个数组是业务状态优先把业务状态和 UI 状态解耦。第二步对新页面直接用 Diffable 实现。新页面的数据源先统一改成 snapshot 模型用apply(snapshot)刷新不再写reloadData。第三步老页面替换时把原来的numberOfRowsInSection/cellForRowAt方法体迁移到 cell provider 闭包中把原来所有insertRows/deleteRows/performBatchUpdates的逻辑全部改成对 snapshot 的增删操作。举个例子原来这样的代码// 旧代码手动管理 indexPath let indexPath IndexPath(row: dataArray.count, section: 0) dataArray.append(item) tableView.insertRows(at: [indexPath], with: .automatic)重构后变成// 新代码直接操作 snapshot var snapshot dataSource.snapshot() snapshot.appendItems([item], toSection: .main) dataSource.apply(snapshot, animatingDifferences: true)代码量几乎减少一半最重要的是不再有数组更新了但没同步 UI这类 bug 的生存空间了。如果项目里引用了 Combine可以让状态管理更干净。比如用Published var models: [Model]sink中去 apply snapshotprivate var cancellables SetAnyCancellable() func bind() { viewModel.$cities .receive(on: DispatchQueue.main) .sink { [weak self] cities in guard let self else { return } var snapshot NSDiffableDataSourceSnapshotSection, City() snapshot.appendSections([.main]) snapshot.appendItems(cities, toSection: .main) self.dataSource.apply(snapshot, animatingDifferences: true) } .store(in: cancellables) }注意 Combine 的 sink 默认在哪个线程取决于上游 publisher。网络请求多半返回在后台线程一定要receive(on: RunLoop.main)或者DispatchQueue.main否则 Diffable 在主线程操作 UI 时会直接崩溃或出现不可预期的状态。5. 常见问题与踩坑速查5.1 崩溃类问题最经典的崩溃就是identifier 冲突。但 Diffable 本身不像传统 dataSource 一样提供 number of rows 的判断所以它的崩溃方式比较诡异——通常是断言失败或apply后 UI 表现异常。我遇到的第一类坑是同一个 item 被同时加到了两个 section 里。Diffable 要求 item identifier 在同一个 snapshot 里全局唯一如果你把一个 model 同时 append 到 sectionA 和 sectionB系统会直接抛异常。解决方法是给 item 的外层包一层包装让每个 section 里持有不同的 wrapper用不同的组合标识来区分。第二个常见崩溃场景是在数据源还没初始化时就调用了 apply。也就是说在viewDidLoad里如果先调用了applyInitialSnapshot但setupDataSource还没完成就会遇到 nil。我的一般做法是dataSource 的声明和 tableView 建立绑定放在viewDidLoad最早处保证第一个 snapshot apply 时 dataSource 一定非 nil。第三个场景是 cell provider 闭包里的隐式解包崩溃。dequeueReusableCell(withIdentifier:)如果用了不存在的 identifier或者注册时机不对return 出来的是 nil再强制解包就会 crash。我在重构时看到过把tableView.register写在viewDidLoad末尾、而 dataSource 注册在前面的顺序错误导致首次刷新必崩。务必先注册 cell再绑定 dataSource。第四个是后台线程改 snapshot。diff 计算和 UI 更新必须都在主线程执行否则轻则数据错乱、重则 crash。新手用网络回调直接 apply 是最容易忽略这个的所以我在项目里统一封装了一个applySnapshot方法内部强制DispatchQueue.main.async。5.2 刷新、动画与体验类问题Diffable 偶尔会出现明明数据只有局部变化但整行整行地闪烁的情况。通常的原因有两个一是 item 的Hashable实现包含了所有展示字段导致看似同一行实际被识别成了删除旧 插入新二是在 cell provider 中直接依赖 model 的引用去配置内容但 model 是可变对象刷新时比较逻辑失效。解决办法是把和hash(into:)收敛到稳定 id 上让 diff 只关心身份展示内容的更新用reloadItems或者直接把 model 赋给 cell 再让 cell 内部根据字段变化做处理。另一个困扰很多人的问题数据频繁刷新时动画顺序乱跳。比如一个实时行情列表后台每秒推一次数据你在回调里直接apply会发现 cell 的移动、插入动画变得非常鬼畜。这时可以考虑为刷新加节流或者在高频场景使用animatingDifferences: false只保证数据结果正确、不做动画表演。我实测在股票类列表里用无动画刷新体验反而更好——用户更关注数字变化而不是动画过程。还有一个 iOS 15 的已知小问题在多个 section 的列表中同时修改多个 section 的数据偶尔会出现某一行的内容瞬间空白然后才恢复。这个偶尔触发原因和 UIKit 内部对 cell 复用的处理时序有关。遇到这种情况可以先回退到applySnapshotUsingReloadData或把一次大 diff 拆成两次 apply问题通常会消失。Header/Footer 的配置也需要特别留意。UITableViewDiffableDataSource本身没有专门处理 header/footer 的闭包所以如果你需要自定义 headerView仍然要通过tableView(_:viewForHeaderInSection:)的 delegate 方法实现。在 delegate 里要拿到当前 section 对应的模型最方便的方式是func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) - UIView? { let sectionIdentifier dataSource.snapshot().sectionIdentifiers[section] // 根据 sectionIdentifier 构建 header }不要存你自己的section 数组直接用dataSource.snapshot()去查这样 header 和列表内容永远基于同一份事实。5.3 一些更深入的实践经验坚持把业务数据和UI snapshot之间的同步放到专门的方法里不要散落在各处。我在项目里习惯让 ViewModel 暴露一个纯业务快照的信号VC 里只做一件事把业务快照转换成NSDiffableDataSourceSnapshot然后 apply。这样即便业务逻辑复杂UI 层的代码也会非常稳定。如果你在做一个搜索页面Diffable 几乎是刚需。搜索框每次输入都触发分词和结果更新手动 reload 会有明显闪烁而 diffable 做增量插入动画非常顺滑。我通常这样写private func performSearch(with keyword: String) { let filtered allItems.filter { $0.name.contains(keyword) } var snapshot NSDiffableDataSourceSnapshotSection, City() snapshot.appendSections([.main]) snapshot.appendItems(filtered, toSection: .main) dataSource.apply(snapshot, animatingDifferences: true) }只要把apply(snapshot)看成提交一份 UI 状态每次搜索时计算新的 content 状态剩下的交给系统代码可以非常简单。最后一个经验是当你熟悉 Diffable 之后再回看performBatchUpdates那一套你会明白苹果为什么坚持推进这种模式。它不是一次 API 的小修小补而是把数据一致性从开发者的肩膀上移走。用 Diffable 之后我项目里真正的列表崩溃率几乎降到了零。如果你还在纠结要不要花时间迁移我个人体会是新项目直接上 Diffable 没有任何犹豫空间老项目可以从最频繁改动的页面开始尝试。你只需要花上一个下午把三五个页面的逻辑改过来就会明显感受到数据不动、UI 不动数据一变、UI 自动跟着变的那种踏实感。后面的改动就顺着这条路一路推下去就好。
返回列表