全部文章

为什么我们从 IGListKit 回到 UICollectionView

iOS 13 的 Diffable Data Source 与 Compositional Layout 改变了抽象边界:如何保留列表模块化,同时减少不再需要的适配层。

平台变了,原来的选择也该重新审视

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 controllerCell 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。平台边界变化后,重新判断抽象是否仍有价值,比坚持某一种框架立场更重要。

延伸阅读