在iOS开发中,UITableView或UICollectionView的数据源更新常面临效率与稳定性问题,直接刷新整个列表不仅耗时,还可能引发界面卡顿或崩溃。IGListKit框架中的Diff算法,通过精准对比新旧数据源的差异,实现列表的高效增量更新,是解决这一问题的核心方案。
本文将结合完整的Swift示例代码,从数据模型协议实现、Diff核心函数解析、批量更新安全用法三个维度,拆解IGListKit Diff算法的工作原理,帮助开发者快速掌握其核心逻辑与实际应用技巧,理解如何通过Diff算法规避更新过程中的常见坑点,提升列表交互体验。
一、ListDiffable协议实现(数据模型)
ListDiffPaths 函数做了什么?
功能:定义数据模型需遵循的协议,提供数据唯一标识(diffIdentifier)和内容对比(isEqual)能力,为Diff算法对比新旧数据提供依据。
ListDiffPaths函数返回的是ListIndexPathResult。该函数会对传入的两组数据进行比较,比较方法是通过数据模型的diffIdentifier和isEqual函数进行。最终将各数据放入对应的数组中,里面有inserts / deletes / updates / moves
import IGListKit
import UIKit
// ListDiffable协议提供diffIdentifier和isEqual能力,用于Diff算法对比数据
final class Person: ListDiffable {
let pk: Int
let name: String
init(pk: Int, name: String) {
self.pk = pk
self.name = name
}
// 数据的唯一标识符(用于区分不同数据,此处用pk作为唯一标识)
func diffIdentifier() -> NSObjectProtocol {
return pk as NSNumber
}
// 用来判断同一唯一标识下,数据内容是否发生改动(此处对比name字段)
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
guard let object = object as? Person else { return false }
return self.name == object.name
}
}
二、视图控制器定义(基础配置)
option: .equality 决定怎么比
.equality:用ListDiffable的isEqual(toDiffableObject:)比较。同一diffIdentifier下,若isEqual为false,会记为update(之后由forBatchUpdates转成删+插,见下)。.pointerPersonality:用指针/对象身份比较,更像「是不是同一个对象实例」,一般不用在「换了一批新model实例但 id 相同」这种场景。
final class DiffTableViewController: UITableViewController {
// 两组数据包含:移动、删除、新增、更新 全部场景(用于Diff算法对比)
// 刷新前数据源
let oldPeople = [
Person(pk: 1, name: "Kevin"),
Person(pk: 2, name: "Mike"),
Person(pk: 3, name: "Ann"),
Person(pk: 4, name: "Jane"),
Person(pk: 5, name: "Philip"),
Person(pk: 6, name: "Mona"),
Person(pk: 7, name: "Tami"),
Person(pk: 8, name: "Jesse"),
Person(pk: 9, name: "Jaed")
]
// 刷新后数据源
let newPeople = [
Person(pk: 2, name: "Mike"),
Person(pk: 10, name: "Marne"),
Person(pk: 5, name: "Philip"),
Person(pk: 1, name: "Kevin"),
Person(pk: 3, name: "Ryan"),
Person(pk: 8, name: "Jesse"),
Person(pk: 7, name: "Tami"),
Person(pk: 4, name: "Jane"),
Person(pk: 9, name: "Chen")
]
// 当前显示的数据源(默认显示旧数据源)
lazy var people: [Person] = {
return self.oldPeople
}()
// 标记当前是否使用旧数据源(用于切换新旧数据源)
var usingOldPeople = true
override func viewDidLoad() {
super.viewDidLoad()
// 配置导航栏按钮,点击触发Diff更新
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .play,
target: self,
action: #selector(DiffTableViewController.onDiff))
// 注册表格单元格(用于表格复用)
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
}
- 这里的
Person用 pk 作diffIdentifier,用 name 做isEqual,所以 同 id 改名 会走update分支;新 id 会insert,消失的 id 会delete,同 id 改位置 会move。
三、核心方法(Diff更新+表格数据源)
// 点击导航栏按钮触发的Diff更新方法
@objc func onDiff() {
let from = people // 当前数据源(旧数据)
let to = usingOldPeople ? newPeople : oldPeople // 要切换到的数据源(新数据/旧数据)
usingOldPeople = !usingOldPeople // 切换数据源标记
people = to // 更新当前显示的数据源
// 1. 对比新旧数据源,生成Diff结果(使用.equality对比规则)
// 2. 转换为表格批量更新安全的操作结果
let result = ListDiffPaths(fromSection: 0, toSection: 0, oldArray: from, newArray: to, option: .equality).forBatchUpdates()
// 执行表格批量更新(删除、插入、移动操作)
tableView.beginUpdates()
tableView.deleteRows(at: result.deletes, with: .fade)
tableView.insertRows(at: result.inserts, with: .fade)
result.moves.forEach { tableView.moveRow(at: $0.from, to: $0.to) }
tableView.endUpdates()
}
// MARK: UITableViewDataSource(表格数据源代理方法)
// 表格行数(等于当前数据源的数量)
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return people.count
}
// 配置表格单元格显示内容(显示当前数据源对应索引的name)
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = people[indexPath.row].name
return cell
}
forBatchUpdates 做了什么?
-
forBatchUpdates()会将ListIndexPathResult再生成一个适合UITableView / UICollectionView的performBatchUpdates / beginUpdates使用的结果,文档说明是:Creates a new result object with operations safe for use in UITableView and UICollectionView batch updates. -
实现上(见 IGListIndexPathResult.m)大致会:
- 把 「移动 + 同位置还要 update」 这类容易和系统 batch 规则冲突的情况,改成 先删后插。
- 对 仅内容变化(updates) 的项,改成在旧
indexPath上delete、在新indexPath上insert(这样就不用在batch里再调reloadRows,避免和move混用时的坑)。 - 返回的新结果里
updates往往为空,操作主要落在deletes / inserts / moves上。
-
所以示例里下面这样写是符合「batch 安全」的用法:
tableView.deleteRows(at: result.deletes, with: .fade) tableView.insertRows(at: result.inserts, with: .fade) result.moves.forEach { tableView.moveRow(at: $0.from, to: $0.to) }
为什么 forBatchUpdates() 要「去掉」updates / 不直接用 reloadRows
- 若同时用
moveRow和reloadRows描述「同一条」的变化,容易发生崩溃 Exception / crash:例如NSInternalInconsistencyException,提示 batch 后行数、index path与data source对不上。 - 所以为了处理这种情况,将update数据转化为
delete和insert更安全