如何正确的刷新 Epoxy 列表可以说是熟练使用这个框架的关键,初上手时总会有 “为什么列表刷新后数据没变” 和 “为什么它刷新后数据就变了” 的疑惑。

本文从源码分析的角度,带大家来梳理 Epoxy 列表刷新的原理,并学会如何正确刷新 Epoxy 列表。

Epoxy 列表本质上还是 UICollectionView,只不过实现了 diff 算法,封装了 reuse id 的创建。

对于一个常见的 UICollectionViewCellUITableViewCell,我的习惯是:

  • Cell 初始化时创建 subviews,添加到 contentView 上,并设置布局。
  • 对外暴露 config() 方法,或直接暴露 subviews
    • collectionView(_: cellForItemAt:) 方法中将数据设置到 subviews 上。

如果想要列表上 Cell 中的数据发生变化,无外乎以下几种方法:

  • 创建不同的 Cell。不同的数据对应不同的 Cell,抛弃或部分抛弃重用机制。
  • 在 Cell 创建或复用后,重新设置 subviews 上的数据。这包括两种方法:
    • collectionView(_: cellForItemAt:) 方法中调用 config(),或直接使用 subviews
    • 使用响应式框架,比如 Combine,将数据绑定到 Cell 的 subviews 上。

那么接下来,我们就从这几种方法上入手,看看如何正确刷新 Epoxy 列表。

Cell 创建流程

首先我们需要知道 Epoxy 是如何创建一个 Cell。在使用框架时,我们没有显式创建过 reuse id,Epoxy 框架是如何创建它的?

源码分析

CollectionView/Internal/CollectionViewDataSource.swift 中,关于 Cell 的创建如下所示:

  func collectionView(
    _ collectionView: UICollectionView,
    cellForItemAt indexPath: IndexPath)
    -> UICollectionViewCell
  {
    guard
      let item = data?.item(at: indexPath),
      let section = data?.section(at: indexPath.section),
      let reuseID = reuseIDStore.registeredReuseID(for: item.viewDifferentiator)
    else {
      // The `item(…)` or `registeredReuseID(…)` methods will assert in this scenario.
      return UICollectionViewCell()
    }

    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseID, for: indexPath)

    if let cell = cell as? CollectionViewCell {
      self.collectionView?.configure(
        cell: cell,
        with: item,
        at: .init(itemDataID: item.dataID, section: .dataID(section.dataID)),
        animated: false)
    } else {
      EpoxyLogger.shared.assertionFailure(
        "Only CollectionViewCell and subclasses are allowed in a CollectionView.")
    }
    return cell
  }

首先关注创建 Cell 时使用的标识符,即 reuseID 这个变量是从何而来的。

let reuseID = reuseIDStore.registeredReuseID(for: item.viewDifferentiator)

这里包含两个需要明白的内容,分别是 reuseIDStoreviewDifferentiator

从后往前看,viewDifferentiator 定义在 ViewDifferentiatorProviding 协议里,它的类型 ViewDifferentiator 是一个遵循 Hashable 的结构体:

// MARK: - ViewDifferentiatorProviding

public protocol ViewDifferentiatorProviding {
  var viewDifferentiator: ViewDifferentiator { get }
}

// MARK: - ViewDifferentiator

public struct ViewDifferentiator: Hashable {
  public init(viewType: AnyClass, styleID: AnyHashable?) {
    viewTypeDescription = "\(type(of: viewType.self))"
    self.styleID = styleID
  }

  public var viewTypeDescription: String
  public var styleID: AnyHashable?
}

从源码注释上可以看出来这两个类型就是服务于 reuseID 创建的。ItemModel 是这么实现 ViewDifferentiatorProviding 协议的:

extension ItemModel {
  public var viewDifferentiator: ViewDifferentiator {
    .init(viewType: View.self, styleID: styleID)
  }
  ...
}

这里的 View 是一个范型,可以理解为一个实现了 EpoxyableView 的 UIView 对象。styleID 定义在 StyleIDProviding 协议中,是一个扩展出来的存储属性,其类型是 AnyHashable,这里就省略展示它的定义了。

那么使用时它的值是哪儿来的呢?

public struct ItemModel<View: UIView>: ViewEpoxyModeled {
  ...
  
  public init<Params: Hashable, Content: Equatable>(
    dataID: AnyHashable,
    params: Params,
    content: Content,
    makeView: @escaping (Params) -> View,
    setContent: @escaping (CallbackContext, Content) -> Void)
  {
    self.dataID = dataID
    styleID = params // ⬅️ 在这里进行赋值
    erasedContent = content
    self.makeView = { makeView(params) }
    self.setContent = { setContent($0, content) }
    isErasedContentEqual = { otherModel in
      guard let otherContent = otherModel.erasedContent as? Content else { return false }
      return otherContent == content
    }
  }
  
  ...
}

上面这个是 ItemModel 的初始、基本初始化方法,可以看到在初始化时,是使用 params 这个参数初始化 styleID 属性的。但是基本上我们都很少使用这个初始化方法,而是使用下面这个:

extension StyledView where Self: BehaviorsConfigurableView & ContentConfigurableView {
  public static func itemModel(
    dataID: AnyHashable,
    content: Content,
    behaviors: Behaviors? = nil,
    style: Style)
    -> ItemModel<Self>
  {
    ItemModel<Self>(
      dataID: dataID,
      params: style, // ⬅️ 发现 params 的来源
      content: content,
      makeView: Self.init(style:),
      setContent: { context, content in
        context.view.setContent(content, animated: context.animated)
      })
      .setBehaviors { context in
        context.view.setBehaviors(behaviors)
      }
  }
}

看到这里就明白了:这个 styleID 就是创建 EpoxyableView 时定义的 Style。所以 viewDifferentiator 代表的就是遵循 EpoxyableView 协议的这个类型本身以及它所包含的 Style 对象。当同一个 View 但是 Style 不同时,viewDifferentiator 也就不同。

看完了 viewDifferentiator,让我们再回过头来看 reuseIDStore

它的类型是一个类 ReuseIDStore,从命名上可以看出这个类是专门用来存储 reuse id 的。通过源码可以看到,这个类内部使用两个 Dictionary 来封装 reuse id 的创建、存储以及读取逻辑。

对外主要提供了两个方法:reuseID(byRegistering:)registeredReuseID(for:),参数都是 ViewDifferentiator。前者用于存储 reuse id,后者负责查询读取。

内部包含以下两个 Dictionary:

private var uniqueViewDifferentiatorCountsForViewTypes = [String: Int]()
private var reuseIDsForViewDifferentiators = [ViewDifferentiator: String]()

uniqueViewDifferentiatorCountsForViewTypes 负责计数,即一个 CollectionView 注册了几次这个 View(Cell)。

通过追踪调用栈,可以发现每次调用 setSections(_: strategy:) 方法时,如果存在新的 ViewDifferentiator,就会调用 reuseID(byRegistering:) 进行注册。

这里我们结合注册方法 reuseID(byRegistering:) 的实现来一起看:

public func reuseID(byRegistering viewDifferentiator: ViewDifferentiator) -> String {
  if let existingReuseID = reuseIDsForViewDifferentiators[viewDifferentiator] {
    return existingReuseID
  }

  let viewType = viewDifferentiator.viewTypeDescription
  let uniqueViewDifferentiatorCount = uniqueViewDifferentiatorCountsForViewTypes[viewType] ?? 0
  uniqueViewDifferentiatorCountsForViewTypes[viewType] = uniqueViewDifferentiatorCount + 1

  let reuseID = "\(viewType)_\(uniqueViewDifferentiatorCount)" // ⬅️ 类型加出现次数
  reuseIDsForViewDifferentiators[viewDifferentiator] = reuseID
  return reuseID
}

ViewDifferentiator 是遵循 Hashable 的,对于同一个 View 类型而言,不同的 Style 实例意味着不同的 ViewDifferentiator,每一种都会调用 reuseID(byRegistering:) 进行注册。通过累加 count,可以保证不同的 ViewDifferentiator 对应不同的 reuse id。

小结

到这里我们可以用一个例子来总结一下 reuse id 的创建过程和创建结果。

假设定义如下一个 View:

final class TextRow: UIView, EpoxyableView {
  enum Style {
    case small, large
  }
}

使用时如下:

// ... other row

TextRow.itemModel(dataID: "small", style: .small)

// ... other row

TextRow.itemModel(dataID: "large", style: .large)

// ... other row

那么当我们调用 collectionView.setSections(_: strategy:) 时,因为两个 ItemModel 实例对应的 Style 不同,CollectionView 中就会注册两个 Cell,reuse id 分别为 TextRow_0TextRow_1

所以如果你希望抛弃或部分抛弃 Cell 的重用机制,那么你就需要保证 reuse id 各不相同,即 使用不同的 View,或者不同的 View.Style

在实际使用时,我们常通过字面含义,将比如 TextColor 等 UI 样式封装在 Style 中。在遇到 “可用时显示黑色,不可用时展示灰色” 这种需求时,动态修改 TextColor,随后调用 setSections(_: strategy:) 刷新列表。这时列表数据发生变动,其实是 reuse id 不同,系统创建了新的 Cell 来展示不同的文字颜色。很多时候这种现象其实违背了开发者的初衷。

更新 Cell 数据

另外一种方法就是更新 Cell 上子视图的数据,比如 Label 的 text 等。在 Epoxy 中,我们常将数据定义在 Content 里,并在 setContent(_: animated:) 方法内将数据渲染到 UI 上,比如这样:

extension HeaderRow: ContentConfigurableView {
    struct Content: Equatable {
        let title: String
        let tips: String?
    }
    
    func setContent(_ content: Content, animated: Bool) {
        titleLabel.text = content.title
        tipsLabel.text = content.tips
    }
}

那么是不是修改了 Content 之后,再调用 setSections(_: strategy:) 刷新列表即可刷新 UI?让我们来看一下源码。

Content 设置流程


源码分析

首先看一下 itemModel(dataID: content: behaviors: style:) 方法的定义,它是 EpoxyableView 创建 ItemModel 的方法。

extension StyledView where Self: BehaviorsConfigurableView & ContentConfigurableView {
  public static func itemModel(
    dataID: AnyHashable,
    content: Content,
    behaviors: Behaviors? = nil,
    style: Style)
    -> ItemModel<Self>
  {
    ItemModel<Self>(
      dataID: dataID,
      params: style,
      content: content, // ⬅️ 传入
      makeView: Self.init(style:),
      setContent: { context, content in // ⬅️ 在闭包内又吐了出来,相当于闭包持有了 Content 实例
        context.view.setContent(content, animated: context.animated)
      })
      .setBehaviors { context in
        context.view.setBehaviors(behaviors)
      }
  }
}

从实现上看,content 实例被包裹进了 setContent 闭包,延迟到合适的时机再进行调用:

extension ItemModel: InternalItemModeling {
  ...

  public func configure(cell: ItemWrapperView, with metadata: ItemCellMetadata) {
    // Even if there's no `setContent` closure, we need to make sure to call `viewForCell` to ensure that the cell is set up.
    let view = viewForCell(cell)
    setContent?(.init(view: view, metadata: metadata))
  }

  ...
}

这里插一句,在寻找 setContent 闭包的 Caller 时,你有可能会注意到下面这个方法:

extension AnyItemModel: InternalItemModeling {
  ...
  public func configure(cell: ItemWrapperView, with metadata: ItemCellMetadata) {
    model.configure(cell: cell, with: metadata)
    if let view = cell.view {
      setContent?(.init(view: view, metadata: metadata))
    }
  }
  ...
}

这个方法属于 AnyItemModel,这个类型擦除负责包裹不同的 ItemModel 实例,方法第一行的 model 即是它所包装的 ItemModel 实例。

为什么提到这个方法呢?因为这个方法看上去调用了两次 setContent 闭包。然而实际上,不止 setContentAnyItemModel 中的其他类似的属性(比如 setBehaviors)都是 nil,所以 setContent 闭包只调用了一次,重点还是关注 ItemModelconfigure(cell: with:) 方法。

AnyItemModelsetContentnil 是设计上决定的,项目中搜索不到对其进行赋值的代码。至于为什么明明没有赋值,却还有调用,猜测是为了架构上的统一?

继续向上寻找调用方,可以发现是在上文中提到的 collectionView(_: cellForItemAt:) 方法内调用的。方法实现内有一行 self.collectionView?.configure( ... ),该方法内无判断地调用了 model 的 configure(cell: with:) 方法。

小结

总结一下逻辑和调用链:

刷新 CollectionView 时,执行 collectionView(_: cellForItemAt:),通过层层调用,最终 model.setContent 闭包被调用,View 的 setContent(_: animated) 被调用,新的 Content 被设置到 UI 上。

到这里结束了吗?还不要高兴的太早,不要忘记了 CollectionView 还存在 Diff 的逻辑:更新 Content 实例后调用 setSections(_: strategy:)CollectionView 一定会被刷新吗?未必。

CollectionView 刷新时机


源码分析

让我们自顶向下看,先看 setSections(_: strategy:) 的实现:

open class CollectionView: UICollectionView {
  ...

  public func setSections(_ sections: [SectionModel], animated: Bool) {
    let strategy: UpdateStrategy
    if configuration.usesBatchUpdatesForAllReloads {
      strategy = animated ? .animatedBatchUpdates : .nonanimatedBatchUpdates
    } else {
      strategy = animated ? .animatedBatchUpdates : .reloadData
    }
    setSections(sections, strategy: strategy)
  }

  public func setSections(_ sections: [SectionModel], strategy: UpdateStrategy) {
    EpoxyLogger.shared.assert(Thread.isMainThread, "This method must be called on the main thread.")
    epoxyDataSource.registerSections(sections)
    apply(.make(sections: sections), strategy: strategy)
  }

  ...
}

registerSections() 上文有提到,负责注册 Section 以及 Cell。重点关注下面的 apply(_: strategy:) 方法。查看该的实现,可以看到其内部调用了下面这个方法:

open class CollectionView: UICollectionView {
  ...

  private func updateView(with data: CollectionViewData, strategy: UpdateStrategy) {
    updateState = .preparingUpdate

    let performUpdates = {
      self.performBatchUpdates({
        self.performUpdates(data: data, animated: strategy.animated)
      }, completion: { _ in
        if let nextUpdate = self.queuedUpdate, self.window != nil {
          self.queuedUpdate = nil
          self.updateView(with: nextUpdate.newData, strategy: nextUpdate.strategy)
        } else {
          self.completeUpdates()
        }
      })
    }

    // There's two cases in which we should always have a strategy of `.reloadData`:
    // - The first update, since we're going from empty content to non-empty content, and we don't want to animate that update.
    // - Before the first layout of this collection view when `bounds.size` is still zero, since there's no benefit to doing batch updates in that scenario.
    let override = (epoxyDataSource.data == nil || bounds.size == .zero) ? .reloadData : strategy

    switch override {
    case .animatedBatchUpdates:
      performUpdates()
    case .nonanimatedBatchUpdates:
      UIView.performWithoutAnimation {
        performUpdates()
      }
    case .reloadData:
      let result = epoxyDataSource.applyData(data)
      updateState = .updating(from: result.oldData)
      reloadData()
      completeUpdates()
    }
  }

  ...
}

通过上面的实现我们可以得到第一个刷新方案:如果 strategy.reloadData,那么会直接调用 UICollectionViewreloadData 方法(即不使用 Epoxy 内置的 Diff 算法),此时 collectionView(_: cellForItemAt:) 方法必执行,新的 Content 可以被正确地更新。

之所以保留注释,是因为这里的注释比较关键。通过注释我们可以得知:初次加载时,或者对一个尚未具有尺寸的 CollectionView 调用刷新时,将忽略外部参数,永远使用 .reloadData 方式进行刷新。

那如果使用其他的 strategy 呢?就需要关注 performUpdates 闭包里调用的 performUpdates(data: animated:) 方法了。

这里稍微提一下 UICollectionViewperformBatchUpdates(_: completion:) 方法,它是用来将多个操作(update、insert、delete 等),合并至一个动画内进行展示。
因为 performUpdates(data: animated:) 里使用了 diff 算法,该算法会同时计算出上述多种操作,所以为了更好的 UI 交互展示,需要将这些操作合并至一个动画内展示。

performUpdates(data: animated:) 方法实现比较多,这里挑一些我们关注的贴一下:

open class CollectionView: UICollectionView {
  ...

  private func performUpdates(data: CollectionViewData, animated: Bool) {
    let result = epoxyDataSource.applyData(data)
    updateState = .updating(from: result.oldData)

    for (fromIndexPath, toIndexPath) in result.changeset.itemChangeset.updates {
      if
        let cell = cellForItem(at: fromIndexPath) as? CollectionViewCell,
        let item = epoxyDataSource.data?.item(at: toIndexPath)
      {
        let metadata = ItemCellMetadata(
          traitCollection: traitCollection,
          state: cell.state,
          animated: animated)
        item.configure(cell: cell, with: metadata) // ⬅️ 熟悉的老朋友
        item.configureStateChange(in: cell, with: metadata)
      }
    }

    ...

    deleteSections(result.changeset.sectionChangeset.deletes)
    deleteItems(at: result.changeset.itemChangeset.deletes)

    for (fromIndex, toIndex) in result.changeset.sectionChangeset.moves {
      moveSection(fromIndex, toSection: toIndex)
    }

    for (fromIndexPath, toIndexPath) in result.changeset.itemChangeset.moves {
      moveItem(at: fromIndexPath, to: toIndexPath) // ⬅️ moveItem 未必会触发 cell 加载
    }

    insertSections(result.changeset.sectionChangeset.inserts)
    insertItems(at: result.changeset.itemChangeset.inserts) // ⬅️ insertItems 也会触发 cell 加载
  }

  ...
}

方法实现的第一行,epoxyDataSource.applyData(data) 就是应用 diff 算法。之前写的 Epoxy 源码分析系列文章中,第一篇就是有关 Epoxy 的 Diff 算法的,这里就不再赘述算法原理。

在这个方法实现中可以说有两个刷新逻辑:

  • 熟悉的朋友:item.configure(cell: cell, with: metadata)。也就是说如果上面的条件都满足,那么 configure(cell: with:) 方法就会被调用,Content 就会被更新。
  • 又或者归到 inserts 里,作为一个新的 Cell 插入到列表中。

IndexPathChangeset 包含以下几种情况:

public struct IndexPathChangeset {
  ...

  /// The inserted `IndexPath`s needed to get from the old collection to the new collection.
  public var inserts: [IndexPath]

  /// The deleted `IndexPath`s needed to get from the old collection to the new collection.
  public var deletes: [IndexPath]

  /// The updated `IndexPath`s needed to get from the old collection to the new collection.
  public var updates: [(old: IndexPath, new: IndexPath)]

  /// The moved `IndexPath`s needed to get from the old collection to the new collection.
  public var moves: [(old: IndexPath, new: IndexPath)]

  ...
}

这两个方案中,inserts 的逻辑很简单。让我们忽略 diff 算法的逻辑,直接说结论:只要 dataID 发生变化,它就是一个新的 ItemModel,就会执行 delete & insert。所以第二个刷新方案就是:修改前后,ItemModeldataID 不同。

configure(cell: with:) 的前置条件都有哪些呢?首先是 for 循环的数组不为空,其次是两个 if。聪明的你应该可以发现,这两个 if 不是逻辑重点,第一个 if 只是为了满足 Swift 语法的类型转换,而第二个 if 是为了判断 Cell 是否展示出来,避免 configure(cell: with:) 多次调用。所以重点就在于 result.changeset.itemChangeset.updates 是否为空。

所以我们关注的重点就是:如何修改我们的数据源,可以让其归到 updates 集合里。条件有以下几点:

  • 这个 ItemModel 在刷新前已经存在于列表中,在列表中具有 Index。
  • ItemModelisDiffableItemEqual(to:) 方法返回 false

相关方法的实现如下:

extension ItemModel: Diffable {
  ...

  public func isDiffableItemEqual(to otherDiffableItem: Diffable) -> Bool {
    guard let other = otherDiffableItem as? Self else {
      return false
    }
    return isErasedContentEqual?(other) ?? true
  }
}

isErasedContentEqualItemModel 初始化时进行设置:

public struct ItemModel<View: UIView>: ViewEpoxyModeled {
  ...

  public init<Content: Equatable>(
    dataID: AnyHashable,
    content: Content,
    setContent: @escaping (CallbackContext, Content) -> Void)
  {
    self.dataID = dataID
    erasedContent = content // ⬅️ erasedContent 就是 content
    self.setContent = { setContent($0, content) }
    isErasedContentEqual = { otherModel in
      guard let otherContent = otherModel.erasedContent as? Content else { return false }
      return otherContent == content // ⬅️ Content 遵循 Equatable 的目的就在此
    }
  }

  ...
}

从上面两个方法实现中我们可以得到结论:

  • 如果 EpoxyableView 没有定义 Content,那么也就不存在 updates 的逻辑。
  • 更新前后,两个 ItemModelContent 不相等,EpoxyableViewsetContent(_: animated:) 方法才会被调用。

那么第三种刷新方案就是:确保修改前后的 Content 不相等。

小结

现在我们知道了,在调用 setSections(_: strategy:) 方法之后,Style 不变的前提下,以下几种情况 EpoxyableViewsetContent(_: animated) 方法将被调用:

  • strategy.reloadData 时。
    • 包括空 CollectionView 初次加载数据,或者 CollectionView 尚没有 Size 时。
  • 修改数据前后,ItemModeldataID 发生了改变。
  • 修改数据前后,Content== 返回 false,即两个 Content 不相等。

比如下面的例子就是有效的:

  private var count = 0 {
    didSet { setItems(items, animated: true) }
  }

  @ItemModelBuilder 
  private var items: [ItemModeling] {
    TextRow.itemModel(
      dataID: DataID.row,
      content: .init(
        title: "Count \(count)",
        body: "Tap to increment"),
      style: .large)
      .didSelect { [weak self] _ in
        self?.count += 1
      }
  }

总结

到这里让我们总结一下,借助 Epoxy 的机制我们应该如何刷新列表。

方法 描述 推荐指数
使用不同的 dataID 比如声明一个 enum DataID { case some(value: String) },每次刷新时修改 value 的值。
或者将 dataID 和数组的下标做对应,这样在移动 Cell 时,相当于同一个 ItemModel 的 Content 发生了变化。
🌟
使用不同的 Style 将需要变化的值定义在 Style 里,刷新 Style 后刷新列表 🌟🌟
利用 Equatable,修改 Content 确保 Content 的判等逻辑符合您的要求,修改 Content 后刷新列表 🌟🌟🌟🌟

有一个刷新之外的引申话题,上文也提到过,那就是什么样的数据该放到 Style 中。希望看完这篇文章后,对于这个问题你也能有新的思考和理解。

想必对于聪明的你来说,上述分析过程有点太简单了,只要稍微深入追一下代码,就能梳理清楚这些情况。甚至 Epoxy 的 demo 和文档中已经介绍的很清楚了。

但是有的时候,生活就是这么诡异。不把这些过程写下来的话,对于使用还不熟练的我而说,经常会陷入文章开头的疑问:“为什么列表刷新后数据没变” 和 “为什么它刷新后数据就变了”。也就是说即使我们知道了 “当 Content 不一致时刷新列表,列表数据会发生变化” 并按之付出实践时,依然会有不生效的情况发生。

这篇文章还不是完全体,日后当我再次发现刷新失效的情况时,我将完善这篇文章,补充对应的解决方案。