IGListKit框架学习:Diff算法

7 阅读4分钟

在iOS开发中,UITableView或UICollectionView的数据源更新常面临效率与稳定性问题,直接刷新整个列表不仅耗时,还可能引发界面卡顿或崩溃。IGListKit框架中的Diff算法,通过精准对比新旧数据源的差异,实现列表的高效增量更新,是解决这一问题的核心方案。

本文将结合完整的Swift示例代码,从数据模型协议实现、Diff核心函数解析、批量更新安全用法三个维度,拆解IGListKit Diff算法的工作原理,帮助开发者快速掌握其核心逻辑与实际应用技巧,理解如何通过Diff算法规避更新过程中的常见坑点,提升列表交互体验。

一、ListDiffable协议实现(数据模型)

ListDiffPaths 函数做了什么?

功能:定义数据模型需遵循的协议,提供数据唯一标识(diffIdentifier)和内容对比(isEqual)能力,为Diff算法对比新旧数据提供依据。

  • ListDiffPaths 函数返回的是 ListIndexPathResult。该函数会对传入的两组数据进行比较,比较方法是通过数据模型的diffIdentifierisEqual函数进行。最终将各数据放入对应的数组中,里面有 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:用 ListDiffableisEqual(toDiffableObject:) 比较。同一 diffIdentifier 下,若 isEqualfalse,会记为 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 / UICollectionViewperformBatchUpdates / beginUpdates 使用的结果,文档说明是:Creates a new result object with operations safe for use in UITableView and UICollectionView batch updates.

  • 实现上(见 IGListIndexPathResult.m)大致会:

    1. 把 「移动 + 同位置还要 update」 这类容易和系统 batch 规则冲突的情况,改成 先删后插。
    2. 对 仅内容变化(updates) 的项,改成在旧 indexPathdelete、在新 indexPathinsert(这样就不用在 batch 里再调 reloadRows,避免和 move 混用时的坑)。
    3. 返回的新结果里 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

  • 若同时用 moveRowreloadRows 描述「同一条」的变化,容易发生崩溃 Exception / crash:例如 NSInternalInconsistencyException,提示 batch 后行数、index pathdata source 对不上。
  • 所以为了处理这种情况,将update数据转化为deleteinsert更安全