从源码看如何刷新 Epoxy 列表
如何正确的刷新 Epoxy 列表可以说是熟练使用这个框架的关键,初上手时总会有 “为什么列表刷新后数据没变” 和 “为什么它刷新后数据就变了” 的疑惑。
本文从源码分析的角度,带大家来梳理 Epoxy 列表刷新的原理,并学会如何正确刷新 Epoxy 列表。
Epoxy 列表本质上还是 UICollectionView
,只不过实现了 diff 算法,封装了 reuse id 的创建。
对于一个常见的 UICollectionViewCell
或 UITableViewCell
,我的习惯是:
- 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)
这里包含两个需要明白的内容,分别是 reuseIDStore
和 viewDifferentiator
。
从后往前看,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_0
和 TextRow_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
闭包。然而实际上,不止setContent
,AnyItemModel
中的其他类似的属性(比如setBehaviors
)都是nil
,所以setContent
闭包只调用了一次,重点还是关注ItemModel
的configure(cell: with:)
方法。
AnyItemModel
的setContent
是nil
是设计上决定的,项目中搜索不到对其进行赋值的代码。至于为什么明明没有赋值,却还有调用,猜测是为了架构上的统一?
继续向上寻找调用方,可以发现是在上文中提到的 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
,那么会直接调用 UICollectionView
的 reloadData
方法(即不使用 Epoxy 内置的 Diff 算法),此时 collectionView(_: cellForItemAt:)
方法必执行,新的 Content
可以被正确地更新。
之所以保留注释,是因为这里的注释比较关键。通过注释我们可以得知:初次加载时,或者对一个尚未具有尺寸的 CollectionView
调用刷新时,将忽略外部参数,永远使用 .reloadData
方式进行刷新。
那如果使用其他的 strategy
呢?就需要关注 performUpdates
闭包里调用的 performUpdates(data: animated:)
方法了。
这里稍微提一下
UICollectionView
的performBatchUpdates(_: 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。所以第二个刷新方案就是:修改前后,ItemModel
的 dataID
不同。
那 configure(cell: with:)
的前置条件都有哪些呢?首先是 for 循环的数组不为空,其次是两个 if。聪明的你应该可以发现,这两个 if 不是逻辑重点,第一个 if 只是为了满足 Swift 语法的类型转换,而第二个 if 是为了判断 Cell 是否展示出来,避免 configure(cell: with:)
多次调用。所以重点就在于 result.changeset.itemChangeset.updates
是否为空。
所以我们关注的重点就是:如何修改我们的数据源,可以让其归到 updates
集合里。条件有以下几点:
- 这个
ItemModel
在刷新前已经存在于列表中,在列表中具有 Index。 ItemModel
的isDiffableItemEqual(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
}
}
isErasedContentEqual
在 ItemModel
初始化时进行设置:
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
的逻辑。 - 更新前后,两个
ItemModel
的Content
不相等,EpoxyableView
的setContent(_: animated:)
方法才会被调用。
那么第三种刷新方案就是:确保修改前后的 Content
不相等。
小结
现在我们知道了,在调用 setSections(_: strategy:)
方法之后,Style
不变的前提下,以下几种情况 EpoxyableView
的 setContent(_: animated)
方法将被调用:
strategy
为.reloadData
时。- 包括空
CollectionView
初次加载数据,或者CollectionView
尚没有 Size 时。
- 包括空
- 修改数据前后,
ItemModel
的dataID
发生了改变。 - 修改数据前后,
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
不一致时刷新列表,列表数据会发生变化” 并按之付出实践时,依然会有不生效的情况发生。
这篇文章还不是完全体,日后当我再次发现刷新失效的情况时,我将完善这篇文章,补充对应的解决方案。