平台变了,原来的选择也该重新审视
IGListKit 曾经解决了正确的问题。复杂文档列表包含最近文档、文件夹、置顶内容、权限、缓存、分页、加载与重试状态,多种异步输入可能在同一轮 run loop 改变列表。
在需要手动同步数组与 index path 更新的年代,IGListKit 提供稳定身份、内容比较、自动差异更新、独立 section controller 和 working range,显著降低了并发更新的维护难度。
iOS 13 之后,UIKit 原生支持了基于标识符的安全更新,以及可组合的 section 布局。当最低系统版本能够提升时,我们开始问:适配层是否还在减少复杂性?
迁移前,状态已经只有一份
文档、分页、权限和缓存并不属于 collection view。它们由应用状态管理,Selector 生成不可变展示投影:稳定的 section 与 item ID、渲染字段、加载与错误状态、内容修订号。
同一份不可变投影生成 Diffable 快照与布局;用户操作沿状态管线返回。
视图只渲染投影,用户操作转换为应用事件。这让我们可以替换渲染层,而不用重写数据所有权。
IGListKit 真正带来的价值
ListDiffable 将“是不是同一个对象”与“内容是否变化”分开:
final class DocumentListItem: ListDiffable {
let id: String
let title: String
let subtitle: String?
let contentRevision: Int
func diffIdentifier() -> NSObjectProtocol {
id as NSString
}
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
guard let other = object as? DocumentListItem else {
return false
}
return title == other.title
&& subtitle == other.subtitle
&& contentRevision == other.contentRevision
}
}
index path 不再被误当作身份。文档移动后还是同一份文档,元数据变化则更新已有 item。Section controller 也让文档行、文件夹、横向置顶区域、加载与重试界面各自管理尺寸、配置和交互。
但它仍然是围绕 UICollectionView 的另一套对象、生命周期和更新协调机制。平台补齐能力之后,这份成本需要重新评估。
iOS 13 补齐了哪两块能力
身份与快照成为原生概念
UICollectionViewDiffableDataSource 接收 section 和 item 标识符的快照,由 UIKit 比较前后结构:
enum FeedSectionID: Hashable {
case pinned
case folders
case documents
case status
}
enum FeedItemID: Hashable {
case document(String)
case folder(String)
case loading
case retry(String)
}
func apply(_ projection: FeedProjection, animated: Bool) {
var snapshot = NSDiffableDataSourceSnapshot<FeedSectionID, FeedItemID>()
for section in projection.sections {
snapshot.appendSections([section.id])
snapshot.appendItems(section.itemIDs, toSection: section.id)
}
// The projection only includes reload IDs that still exist in the snapshot.
snapshot.reloadItems(projection.reloadedItemIDs)
dataSource.apply(snapshot, animatingDifferences: animated)
}
它不完全等于 IGListKit:标识符遵循 Hashable,iOS 13 上内容变化仍需要明确的 reload 策略。但核心能力已经一致——声明下一状态,而不是手动协调数组修改与批量更新。
不同 section 可以组合布局
UICollectionViewCompositionalLayout 让竖直列表、横向置顶条、文件夹网格和全宽状态区域共享一个 collection view:
func makeLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { [weak self] index, environment in
guard let section = self?.projection.sections[safe: index] else {
return nil
}
switch section.id {
case .pinned:
return self?.makePinnedSection(environment: environment)
case .folders:
return self?.makeFolderSection(environment: environment)
case .documents:
return self?.makeDocumentSection(environment: environment)
case .status:
return self?.makeStatusSection(environment: environment)
}
}
}
原生预取接口也已经覆盖我们主要的 working-range 用途。身份、更新、布局与邻近数据准备不再必须依赖适配器。
额外的一层为什么开始变贵
首先,ListDiffable 渗入展示模型,让状态投影依赖渲染框架。原生方案只需稳定 ID 与不可变 Swift 值。
其次,应用状态已经决定列表内容,ListAdapter 还要维护另一份对象图并解释内容相等性。排查旧数据需要经过状态、Selector、相等判断、adapter 更新和 section 生命周期;原生路径缩短为状态、投影、快照与渲染。
Section controller 的模块化仍有价值,但简单的状态项未必需要独立生命周期对象。选中、导航、无障碍焦点和布局失效等跨组件行为,又常常需要穿过 adapter 回到 UIKit。
Working range 也可能成为另一个发起请求的入口。更清晰的规则是:预取只表达意图,状态层统一去重并接受结果。
最后还有升级、桥接约定、adapter 断言与自定义行为适配的日常成本。单项都不严重,但当平台已经提供相同核心能力时,它们不再自动值得保留。
性能取决于更新路径
这次迁移不支持“原生一定更快”的结论。两条路径最终都渲染到同一个 UICollectionView。IGListKit 本身支持增量更新,也有性能代理;原生代码若在配置 cell 时解码大图、重复计算布局或过度 reload,一样会掉帧。
| 阶段 | IGListKit 路径 | 原生路径 |
|---|---|---|
| 展示投影 | 生成展示值 | 生成展示值 |
| 身份 | 包装成 ListDiffable 对象 | Hashable section 与 item ID |
| 差异计算 | Adapter 比较对象与内容 | UIKit 比较快照 |
| 组件分派 | Section controller | Cell provider 与功能渲染器 |
| 布局和回调 | 经过 controller 与 adapter | 布局与 UIKit delegate 直接协作 |
原生方案没有让 diff 免费,只是移除一条转换边界:不再创建框架专用包装对象,不再保留 section controller 图,也减少了回调转发。
评估覆盖缓存首屏恢复、密集元数据更新、滚动中分页、加载与权限状态插入删除,以及文档跨 section 移动。关注投影耗时、更新时长、主线程占用、重新配置的 cell 数、保留对象和滚动卡顿,尤其是慢尾部,而不只是最快一次 diff。
本文没有保留可复现的原始 Instruments 数据,不能据此宣称通用速度提升。可以明确的是:性能不退步是发布门槛,减少不再必要的协调对象则是结构上的收益。公开基准还需要数据规模、设备、系统、构建配置与完整分布。
保持性能的规则包括:合并密集状态变化,只应用最新投影;只 reload 内容变化的 ID;把格式化与图片准备移出显示回调;让状态层去重预取;缓存稳定布局测量;分别测量恢复、分页和元数据突发更新。
保留模块化,改变组织方式
移除 IGListKit 不意味着把所有代码塞进 view controller。每个功能仍提供 cell 渲染器与 section 布局构建器:
protocol FeedItemRendering {
func makeCell(
in collectionView: UICollectionView,
at indexPath: IndexPath,
item: FeedItem
) -> UICollectionViewCell
}
final class FeedRendererRegistry {
private let renderers: [FeedItem.Kind: FeedItemRendering]
func makeCell(
in collectionView: UICollectionView,
at indexPath: IndexPath,
item: FeedItem
) -> UICollectionViewCell {
guard let renderer = renderers[item.kind] else {
preconditionFailure("Missing renderer for \(item.kind)")
}
return renderer.makeCell(
in: collectionView,
at: indexPath,
item: item
)
}
}
Cell provider 根据 ID 找到不可变展示数据,再交给 registry 中的渲染器。View controller 负责协调,不实现所有 cell。我们保留了功能内聚,移除了模型协议和适配器生命周期。
原生方案仍需遵守的纪律
- ID 必须稳定、唯一,不用数组索引、标题或对象地址代替。
- 身份与内容分开;iOS 13 上元数据变化要明确
reloadItems(_:)。 - 快照有序应用,密集投影合并,新状态取代旧的待处理状态。
- 布局与数据源必须读取同一份投影,不能各自解释 section 顺序。
- 预取只派发意图,不直接追加数据;过期结果由状态层处理。
分步骤迁移,而不是重写产品
先冻结身份协议,覆盖重复 ID、移动和纯内容更新;再把 ListDiffable 从投影类型移除。同一投影同时生成旧对象列表与原生快照,用测试比较顺序和身份。
随后抽出 cell 配置与布局,将 working range 迁到预取。在打开动画之前,先验证缓存恢复、分页、权限变化、空状态和重试。导航、无障碍与更新结果一致后,才移除旧适配器。
迁移可行的前提,是业务状态、请求顺序与缓存策略从未属于 IGListKit。
放弃了什么
IGListKit 在较低系统版本上仍提供成熟更新能力,working range 很方便,section controller 也有更强的逐对象封装。最低版本较低,或已经积累大量 section controller 的团队,完全可能作出不同选择。
我们也接手了 renderer registry、reload 策略和快照序列化。原生不等于零架构,而是只维护与自身状态模型直接对应的架构。
回头看,最重要的结果是一条更短的路径:规范状态 → Selector → 展示投影 → Diffable 快照 → Collection View。平台边界变化后,重新判断抽象是否仍有价值,比坚持某一种框架立场更重要。