【ListDiff 专题】UICollectionViewDiffableDataSource 与 IGListKit——列表增量更新的两条主路径

12 阅读8分钟

【ListDiff 专题】UICollectionViewDiffableDataSource 与 IGListKit——列表增量更新的两条主路径

iOS 三方库精读 · 第 5 期
说明:本期不是某一个名为「ListDiff」的独立 Pod,而是围绕 列表差异计算(List Diff) 这一问题的 Apple 官方方案Instagram 开源方案 的对照精读。


一、一句话介绍

当列表数据源频繁增删改、又不想 reloadData() 整表刷新时,需要 Diff 算法 算出「最小变更集」,再配合 插入 / 删除 / 移动 动画平滑过渡。iOS 13+ 起,Apple 提供 UICollectionViewDiffableDataSource + NSDiffableDataSourceSnapshot(基于 Hashable 的声明式快照);更早、更复杂场景下,IGListKit(Instagram 出品)用 ListDiffable + ListAdapter + ListSectionController 完成同样目标。二者解决同一类问题,API 哲学与适用栈不同。

维度DiffableDataSource(系统)IGListKit(第三方)
出品AppleInstagram / Meta(社区维护)
最低系统iOS 13(DiffableDataSource)iOS 9+
依赖体积0(系统 API)约数百 KB 级
数据模型Hashable(Section / Item)ListDiffableNSObject
SwiftUI可与 UIViewRepresentable 桥接;列表本身多用 List/ForEach 另一条路需 UIKit 桥接
典型场景新工程、Swift、iOS 13+大型 OC 混编、超多 Section、历史版本

二、为什么选择「Diff」而不是全量刷新

原生痛点

  • reloadData()全量重绑,丢失滚动位置,动画粗,主线程压力大。
  • 手动 performBatchUpdates:要自己算 IndexPath 变更,极易与数据源不一致导致崩溃。
  • 列表越长、更新越频繁(Feed、点赞、评论数),越需要 增量更新 + 可动画的 apply

Diff 带来的价值

  1. 性能:只更新变化的 Cell,避免无谓 layout。
  2. 动画:插入/删除/移动由系统或 IGListKit 协调,过渡自然。
  3. 一致性:以「新快照」或「新对象列表」为单一数据源,减少 index 错乱。
  4. 可维护性:声明式 Snapshot(Apple)或 SectionController 模块化(IGListKit)。

三、方案一:UICollectionViewDiffableDataSource

设计哲学

  • NSDiffableDataSourceSnapshot<Section, Item> 描述当前整表状态(有哪些 Section、每个 Section 有哪些 Item)。
  • SectionItem 均需 Hashable(且用于 identity 的字段要稳定)。
  • apply(_:animatingDifferences:) 把新快照与旧状态比较,自动 diff 并驱动 UI。

环境要求

  • iOS 13+ / tvOS 13+;UITableView 也有对应的 Diffable API(同思路)。
  • 无需额外依赖。

最小示例

enum Section: Hashable { case main }

struct Item: Hashable {
    let id: UUID
    let title: String
}

let dataSource = UICollectionViewDiffableDataSource<Section, Item>(
    collectionView: collectionView
) { collectionView, indexPath, item in
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath)
    // configure cell with item
    return cell
}

var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: true)

常用 Snapshot 操作

操作含义
appendSections / appendItems追加
deleteItems / deleteSections删除
moveItem移动
reconfigureItems同 identity 下轻量刷新展示(iOS 15+,优先复用 cell,减少闪烁)
reloadItems按「重新加载」处理指定 item,路径更重,更容易闪烁;reconfigureItems 的老系统可退而求其次
reconfigureItemsreloadItems 怎么选
  • reconfigureItems:列表里仍是同一条业务数据(snapshot 中的 item 标识不变),只是文案、数字、图片等展示变了。系统会尽量复用现有 cell做更新,动画更轻,适合点赞数、标题副标题等高频刷新。
  • reloadItems:语义更接近传统 reload,整格重配概率更高,成本与闪烁风险通常更大;适合「必须走完整重载路径」的少数场景,或 iOS 14 及以下没有 reconfigureItems 时的替代。
  • 前提Hashableidentity 必须稳定(常见做法:hash/== 只基于稳定 id);若每次更新都换成「全新 struct 且 id 也变」,diff 可能变成删旧插新,而不是对同一条做 reconfigure。
为何常与 UICollectionViewCompositionalLayout 搭配

二者不是硬性绑定,但职责互补:

DiffableDataSourceCompositionalLayout
解决有什么数据、如何增量变更与动画长什么样:多段版式、不等高、横向嵌套滚动、头尾视图等

复杂 Feed(通栏 + 横滑商品条 + 多形态卡片)用 CompositionalLayout 描述版式更省事;用 Diffable 承接接口推送的增删改移,避免手写 performBatchUpdates 与 index 错乱。简单单列/等网格仍可用 UICollectionViewFlowLayout + Diffable,不必强行上 Compositional。

与 SwiftUI 的关系

  • SwiftUI 的 List/ForEach 使用 Identifiable状态驱动 的 diff,与 UIKit 的 Diffable 不是同一套 API,但思想一致。
  • SwiftUI 内嵌 UICollectionView 或需要 复杂瀑布流 / Compositional Layout 时,DiffableDataSource 仍是 UIKit 组合布局 的主流选择。

四、方案二:IGListKit

设计哲学

  • 每个数据对象实现 ListDiffablediffIdentifier() 唯一标识,isEqual(toDiffableObject:) 表示内容是否相等。
  • ListAdapter 连接 UICollectionView 与数据源;不同类型对象映射到不同 ListSectionController
  • 适合 多 Section、多 Cell 类型、强模块化 的 Feed(如电商首页、社交信息流)。

集成

Swift Package Manager

https://github.com/Instagram/IGListKit.git

CocoaPods

pod 'IGListKit'

数据模型(节选)

import IGListKit

final class UserItem: NSObject, ListDiffable {
    let id: String
    let name: String

    func diffIdentifier() -> NSObjectProtocol { id as NSString }

    func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
        guard let other = object as? UserItem else { return false }
        return name == other.name
    }
}

核心组件关系

  1. ListAdaptercollectionView + dataSource(实现 ListAdapterDataSource)。
  2. ListAdapterDataSourceobjects(for:) 返回 [ListDiffable]sectionController(for:) 返回对应 SectionController。
  3. ListSectionController:负责单个 section 的 item 数量、尺寸、cellForItem 等。

数据更新后调用 adapter.performUpdates(animated:) 触发 diff。


五、核心对比表

对比维度DiffableDataSourceIGListKit
出品方Apple(iOS 13+)Instagram / Meta
最低版本iOS 13.0iOS 9.0
语言风格Swift 原生、HashableOC/Swift 混编、ListDiffable
Diff 实现系统内置(黑盒),不可替换引擎IGListDiff 等开源实现,可阅读、封装或与架构深度对齐
学习曲线相对平缓较陡(SectionController 体系)
SwiftUI 原生需桥接 UIKit需桥接 UIKit
适用场景新项目、iOS 13+、声明式快照大型 OC 项目、复杂多 Section、老系统

六、选型建议

优先 DiffableDataSource,当:

  • 最低支持 iOS 13+,以 Swift 为主;
  • 使用 SwiftUI + 嵌套 UIKitCompositional Layout
  • 希望 少依赖、少样板代码,接受「快照即状态」的模型。

优先 IGListKit,当:

  • 需支持 iOS 12 及以下(新库已较少见,但存量项目存在);
  • OC 占比高、Feed 结构极复杂,需要 Section 级独立管理
  • 团队已有 IGListKit 经验,或需要与 Instagram 系架构对齐;
  • 需要在 算法/实现层 可控、可 fork 的 diff 管线(系统 Diffable 只能通过 Hashable 语义等间接影响结果,不能接入自定义 diff 实现)。

关于「精细控制 Diff」:DiffableDataSource 并非不能做 diff,而是 diff 引擎固定、不可换;多数产品只需把 identity / 相等语义 设计好即可。只有当你明确要动「算法层」或统一 legacy 的 IGList 架构时,才更值得为这一点选 IGListKit。


七、生产实践要点

架构

  • 模型分层FeedSectionHashable)+ FeedItemProtocol(稳定 itemId + cellType)。
  • 更新策略:全量重建、增量 reconfigureItems、分页 appendItems 分工明确。

性能

  • UICollectionViewDataSourcePrefetching 做图片预取;
  • 大列表可在后台线程构造 Snapshot,MainActorapply,避免阻塞主线程。

最佳实践摘要

  1. Hashable / diffIdentifier 稳定唯一,避免把可变展示字段误当 identity。
  2. 同 id、仅展示变化:优先 reconfigureItems(iOS 15+);慎用 reloadItems,除非需完整重载路径或兼容老系统。
  3. 动画异常时可尝试 animatingDifferences: false 排查是否与业务动画冲突。
  4. Cell 复用时必须重置状态,避免脏数据。
  5. 复杂版式优先考虑 CompositionalLayout + DiffableDataSource 组合;简单列表 FlowLayout + Diffable 即可。

常见问题

现象排查方向
Snapshot 后 Cell 不刷新Hashable 是否仅比较 id,未反映展示字段;必要时 reconfigure
动画闪烁、错乱批量更新合并;或关闭冲突动画
滚动卡顿主线程耗时、图片解码、过度 layout

八、参考资源


九、本期互动

小作业

DiffableDataSource 实现一个「点赞数变更」:仅 reconfigureItems 更新对应 item,其余行不刷新;观察与 reloadData 的差异。

可运行示例(小作业参考 · iOS 15+)

下面示例可在 新建 UIKit App 中:把根控制器换成 LikeReconfigureDemoViewController(),或从任意 UINavigationController push 进去即可运行。要点:列表里的 model 用 class 引用类型,snapshot 中始终是同一实例;改 likeCount 后对该实例调用 reconfigureItems,系统会尽量复用 cell 并再次走配置闭包,无需 reloadData

import UIKit

// MARK: - 可运行 Demo:Diffable + reconfigureItems 点赞

private enum DemoSection: Hashable {
    case main
}

/// 引用类型:identity 在 snapshot 中稳定,内容可变 → 适合演示 reconfigureItems
private final class LikeItem: Hashable {
    let id: UUID
    var title: String
    var likeCount: Int

    init(id: UUID = UUID(), title: String, likeCount: Int) {
        self.id = id
        self.title = title
        self.likeCount = likeCount
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (lhs: LikeItem, rhs: LikeItem) -> Bool {
        lhs.id == rhs.id
    }
}

private final class LikeCell: UICollectionViewCell {
    static let reuseId = "LikeCell"
    let label = UILabel()

    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.backgroundColor = .secondarySystemBackground
        contentView.layer.cornerRadius = 8
        label.numberOfLines = 0
        label.font = .preferredFont(forTextStyle: .body)
        label.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(label)
        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
            label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
            label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
        ])
    }

    required init?(coder: NSCoder) { fatalError("init(coder:)") }

    func configure(item: LikeItem) {
        label.text = "\(item.title)  ❤️ \(item.likeCount)"
    }
}

final class LikeReconfigureDemoViewController: UIViewController {
    private var collectionView: UICollectionView!
    private var dataSource: UICollectionViewDiffableDataSource<DemoSection, LikeItem>!

    /// 与 snapshot 共用同一批引用,改字段后 reconfigure 即可
    private var items: [LikeItem] = [
        LikeItem(title: "第一条", likeCount: 0),
        LikeItem(title: "第二条", likeCount: 3),
        LikeItem(title: "第三条", likeCount: 10)
    ]

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "reconfigureItems Demo"
        view.backgroundColor = .systemBackground

        let layout = UICollectionViewFlowLayout()
        layout.itemSize = CGSize(width: UIScreen.main.bounds.width - 32, height: 56)
        layout.sectionInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)

        collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.register(LikeCell.self, forCellWithReuseIdentifier: LikeCell.reuseId)
        view.addSubview(collectionView)
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
        ])

        dataSource = UICollectionViewDiffableDataSource<DemoSection, LikeItem>(
            collectionView: collectionView
        ) { collectionView, indexPath, item in
            let cell = collectionView.dequeueReusableCell(
                withReuseIdentifier: LikeCell.reuseId,
                for: indexPath
            ) as! LikeCell
            cell.configure(item: item)
            return cell
        }

        navigationItem.rightBarButtonItem = UIBarButtonItem(
            title: "给首条点赞",
            style: .plain,
            target: self,
            action: #selector(likeFirst)
        )

        applySnapshot(animated: false)
    }

    private func applySnapshot(animated: Bool) {
        var snapshot = NSDiffableDataSourceSnapshot<DemoSection, LikeItem>()
        snapshot.appendSections([.main])
        snapshot.appendItems(items, toSection: .main)
        dataSource.apply(snapshot, animatingDifferences: animated)
    }

    @objc private func likeFirst() {
        guard let first = items.first else { return }
        first.likeCount += 1

        var snapshot = dataSource.snapshot()
        snapshot.reconfigureItems([first])
        dataSource.apply(snapshot, animatingDifferences: true)
    }
}

说明:若用 structHashablelikeCount,改点赞后往往变成「另一条」item,diff 可能走删插而非 reconfigure;生产里常见做法是 稳定 id + class / 显式 reconfigure 路径,或按业务用 reloadItems 对比效果。

思考题

同一列表若 Section 数量随业务动态增减,你更倾向于 全量重建 Snapshot 还是 细粒度 delete/append sections?为什么?


📅 本系列每周五晚更新
✅ 第1期:Alamofire · ✅ 第2期:SDWebImage · ✅ 第3期:Kingfisher · ✅ 第4期:SnapKit · ✅ 第5期:ListDiff · ✅ 第6期:RxSwift · ✅ 第7期:Lottie · ✅ 第8期:MarkdownUI · ✅ 第9期:AFNetworking · ➡️ 第11期:DGCharts · ○ 第12期:Hero