【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(第三方) |
|---|---|---|
| 出品 | Apple | Instagram / Meta(社区维护) |
| 最低系统 | iOS 13(DiffableDataSource) | iOS 9+ |
| 依赖体积 | 0(系统 API) | 约数百 KB 级 |
| 数据模型 | Hashable(Section / Item) | ListDiffable(NSObject) |
| SwiftUI | 可与 UIViewRepresentable 桥接;列表本身多用 List/ForEach 另一条路 | 需 UIKit 桥接 |
| 典型场景 | 新工程、Swift、iOS 13+ | 大型 OC 混编、超多 Section、历史版本 |
二、为什么选择「Diff」而不是全量刷新
原生痛点
reloadData():全量重绑,丢失滚动位置,动画粗,主线程压力大。- 手动
performBatchUpdates:要自己算IndexPath变更,极易与数据源不一致导致崩溃。 - 列表越长、更新越频繁(Feed、点赞、评论数),越需要 增量更新 + 可动画的 apply。
Diff 带来的价值
- 性能:只更新变化的 Cell,避免无谓 layout。
- 动画:插入/删除/移动由系统或 IGListKit 协调,过渡自然。
- 一致性:以「新快照」或「新对象列表」为单一数据源,减少 index 错乱。
- 可维护性:声明式 Snapshot(Apple)或 SectionController 模块化(IGListKit)。
三、方案一:UICollectionViewDiffableDataSource
设计哲学
- 用
NSDiffableDataSourceSnapshot<Section, Item>描述当前整表状态(有哪些 Section、每个 Section 有哪些 Item)。 Section与Item均需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 的老系统可退而求其次 |
reconfigureItems 与 reloadItems 怎么选
reconfigureItems:列表里仍是同一条业务数据(snapshot 中的 item 标识不变),只是文案、数字、图片等展示变了。系统会尽量复用现有 cell做更新,动画更轻,适合点赞数、标题副标题等高频刷新。reloadItems:语义更接近传统 reload,整格重配概率更高,成本与闪烁风险通常更大;适合「必须走完整重载路径」的少数场景,或 iOS 14 及以下没有reconfigureItems时的替代。- 前提:
Hashable的 identity 必须稳定(常见做法:hash/==只基于稳定 id);若每次更新都换成「全新 struct 且 id 也变」,diff 可能变成删旧插新,而不是对同一条做 reconfigure。
为何常与 UICollectionViewCompositionalLayout 搭配
二者不是硬性绑定,但职责互补:
| DiffableDataSource | CompositionalLayout | |
|---|---|---|
| 解决 | 有什么数据、如何增量变更与动画 | 长什么样:多段版式、不等高、横向嵌套滚动、头尾视图等 |
复杂 Feed(通栏 + 横滑商品条 + 多形态卡片)用 CompositionalLayout 描述版式更省事;用 Diffable 承接接口推送的增删改移,避免手写 performBatchUpdates 与 index 错乱。简单单列/等网格仍可用 UICollectionViewFlowLayout + Diffable,不必强行上 Compositional。
与 SwiftUI 的关系
- SwiftUI 的
List/ForEach使用 Identifiable 与 状态驱动 的 diff,与 UIKit 的 Diffable 不是同一套 API,但思想一致。 - 在 SwiftUI 内嵌 UICollectionView 或需要 复杂瀑布流 / Compositional Layout 时,DiffableDataSource 仍是 UIKit 组合布局 的主流选择。
四、方案二:IGListKit
设计哲学
- 每个数据对象实现
ListDiffable:diffIdentifier()唯一标识,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
}
}
核心组件关系
ListAdapter:collectionView+dataSource(实现ListAdapterDataSource)。ListAdapterDataSource:objects(for:)返回[ListDiffable];sectionController(for:)返回对应 SectionController。ListSectionController:负责单个 section 的 item 数量、尺寸、cellForItem等。
数据更新后调用 adapter.performUpdates(animated:) 触发 diff。
五、核心对比表
| 对比维度 | DiffableDataSource | IGListKit |
|---|---|---|
| 出品方 | Apple(iOS 13+) | Instagram / Meta |
| 最低版本 | iOS 13.0 | iOS 9.0 |
| 语言风格 | Swift 原生、Hashable | OC/Swift 混编、ListDiffable |
| Diff 实现 | 系统内置(黑盒),不可替换引擎 | IGListDiff 等开源实现,可阅读、封装或与架构深度对齐 |
| 学习曲线 | 相对平缓 | 较陡(SectionController 体系) |
| SwiftUI 原生 | 需桥接 UIKit | 需桥接 UIKit |
| 适用场景 | 新项目、iOS 13+、声明式快照 | 大型 OC 项目、复杂多 Section、老系统 |
六、选型建议
优先 DiffableDataSource,当:
- 最低支持 iOS 13+,以 Swift 为主;
- 使用 SwiftUI + 嵌套 UIKit 或 Compositional Layout;
- 希望 少依赖、少样板代码,接受「快照即状态」的模型。
优先 IGListKit,当:
- 需支持 iOS 12 及以下(新库已较少见,但存量项目存在);
- OC 占比高、Feed 结构极复杂,需要 Section 级独立管理;
- 团队已有 IGListKit 经验,或需要与 Instagram 系架构对齐;
- 需要在 算法/实现层 可控、可 fork 的 diff 管线(系统 Diffable 只能通过
Hashable语义等间接影响结果,不能接入自定义 diff 实现)。
关于「精细控制 Diff」:DiffableDataSource 并非不能做 diff,而是 diff 引擎固定、不可换;多数产品只需把 identity / 相等语义 设计好即可。只有当你明确要动「算法层」或统一 legacy 的 IGList 架构时,才更值得为这一点选 IGListKit。
七、生产实践要点
架构
- 模型分层:
FeedSection(Hashable)+FeedItemProtocol(稳定itemId+cellType)。 - 更新策略:全量重建、增量
reconfigureItems、分页appendItems分工明确。
性能
UICollectionViewDataSourcePrefetching做图片预取;- 大列表可在后台线程构造 Snapshot,
MainActor上apply,避免阻塞主线程。
最佳实践摘要
- Hashable / diffIdentifier 稳定唯一,避免把可变展示字段误当 identity。
- 同 id、仅展示变化:优先
reconfigureItems(iOS 15+);慎用reloadItems,除非需完整重载路径或兼容老系统。 - 动画异常时可尝试
animatingDifferences: false排查是否与业务动画冲突。 - Cell 复用时必须重置状态,避免脏数据。
- 复杂版式优先考虑 CompositionalLayout + DiffableDataSource 组合;简单列表 FlowLayout + Diffable 即可。
常见问题
| 现象 | 排查方向 |
|---|---|
| Snapshot 后 Cell 不刷新 | Hashable 是否仅比较 id,未反映展示字段;必要时 reconfigure |
| 动画闪烁、错乱 | 批量更新合并;或关闭冲突动画 |
| 滚动卡顿 | 主线程耗时、图片解码、过度 layout |
八、参考资源
- Apple: UICollectionViewDiffableDataSource
- Apple: NSDiffableDataSourceSnapshot
- GitHub: Instagram/IGListKit
- IGListKit 文档与示例
九、本期互动
小作业
用 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)
}
}
说明:若用 struct 且 Hashable 含 likeCount,改点赞后往往变成「另一条」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