作者:大力智能技术团队-客户端 溥心
大多数 iOS 应用中列表都是通过 UICollectionView 实现。虽然它性能强大,API 丰富易用,但依旧存在一些不足。常见的如:
- 更新页面时如果调用
reloadData会造成屏幕闪烁; - 调用
performBatchUpdates(_:completion:)时手写 updater 难度较大。IGListKit正是为了解决上述问题而诞生。
简介
整体看下 IGListKit 的架构图:
不同于直接输入 datasource 的 UICollectionView,IGListKit 选择在其上构建一个 adapter,针对不同类型的 object,初始化对应的 section controller,而后者负责构建和维护相应的 cell。同时每个 section controller 同时也是 section 视图,它上面也有 supplementary view 和 decoration view。
使用
- 首先我们创建一个 adapter,通常是直接在相应的 View Controller 中创建。同时我们需要设置它的 data source 和 updater。
class ViewController: UIViewController {
@IBOutlet weak var collectionView: UICollectionView!
lazy var adapter: ListAdapter = {
let updater = ListAdapterUpdater()
let adapter = ListAdapter(updater: updater,
viewController: self,
workingRangeSize: 1) adapter.collectionView = collectionView
adapter.dataSource = SuperHeroDatasource()
return adapter
}()
override func viewDidLoad() {
super.viewDidLoad()
_ = adapter
}
}
adapter 的创建需要三个属性:
- updater:这个对象用来负责 row 和 section 的更新。通常我们用默认实现就够了;
- view controller:通常就是持有该 adapter 的 view controller。可以设置为其他的对象,甚至保持为空;
- workingRangeSize:一个 working range 表示一个 range 的 section controller,它当前不可见,但是接近屏幕边缘。通常用来 prepare content,如提前下载图片。
- 接着我们需要构建自己的 data model,并且遵循
ListDiffable协议。
class SuperHero {
private var identifier: String = UUID().uuidString
private(set) var firstName: String
private(set) var lastName: String
private(set) var superHeroName: String
private(set) var icon: String
init(firstName: String,
lastName: String,
superHeroName: String,
icon: String) {
self.firstName = firstName
self.lastName = lastName
self.superHeroName = superHeroName
self.icon = icon
}
}
extension SuperHero: ListDiffable {
func diffIdentifier() -> NSObjectProtocol {
return identifier as NSString
}
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
guard let object = object as? SuperHero else {
return false
}
return self.identifier == object.identifier
}
}
一个 ListDiffable 对象需要实现两个函数:
-
diffIdentifier:一个可以用来标识和比较 model 的唯一对象
-
isEqual(toDiffableObject:):用来比较两个对象
-
然后我们要将 datamodel 和 adapter 连接起来。adapter 的职责是维护 datasource 并告诉列表如何显示它们,在
IGListKit中,datasource 是一个遵循了ListAdapterDataSource协议的对象。
class SuperHeroDataSource: NSObject, ListAdapterDataSource {
func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
return [SuperHero(firstName: "Peter",
lastName: "Parker",
superHeroName: "SpiderMan",
icon: "🕷"),
SuperHero(firstName: "Bruce",
lastName: "Wayne",
superHeroName: "Batman",
icon: "🦇"),
SuperHero(firstName: "Tony",
lastName: "Stark",
superHeroName: "Ironman",
icon: "🤖"),
SuperHero(firstName: "Bruce",
lastName: "Banner",
superHeroName: "Incredible Hulk",
icon: "🤢")]
}
func listAdapter(_ listAdapter: ListAdapter,
sectionControllerFor object: Any) -> ListSectionController {
return SuperHeroSectionController()
}
func emptyView(for listAdapter: ListAdapter) -> UIView? {
return nil
}
}
这里涉及到三个函数的实现:
-
objects(for:) -> [ListDiffable]:返回需要被 adapter 管理的 model 数组
-
listAdapter(_:sectionControllerFor:) -> SectionController:针对不同的 model 类型,返回相应的 section controller
-
emptyView(for:) -> UIView?:当 model 数组为空时应该显示的空态页面
-
最后通过 section controller 描述如何显示相应的 cell。通过 section controller 对代码进行拆分,不同的 datamodel 类型描述不同的 cell,这样的代码拆分方式相对于
UICollectionView而言是一个巨大的优化点。
class SuperHeroSectionController: ListSectionController {
var currentHero: SuperHero?
override func didUpdate(to object: Any) {
guard let superHero = object as? SuperHero else {
return
}
currentHero = superHero
}
override func numberOfItems() -> Int {
return 1 // One hero will be represented by one cell
}
override func cellForItem(at index: Int) -> UICollectionViewCell {
let nibName = String(describing: SuperHeroCell.self)
guard let ctx = collectionContext, let hero = currentHero else {
return UICollectionViewCell()
}
let cell = ctx.dequeueReusableCell(withNibName: nibName,
bundle: nil,
for: self,
at: index)
guard let superHeroCell = cell as? SuperHeroCell else {
return cell
}
superHeroCell.updateWith(superHero: hero)
return superHeroCell
}
override func sizeForItem(at index: Int) -> CGSize {
let width = collectionContext?.containerSize.width ?? 0
return CGSize(width: width, height: 50)
}
}
这里涉及到四个方法
- didUpdate(to:):当 section controller 获得 data 时调用该方法
- cellForItem(at:) -> UICollectionViewCell:根据 index,或者相应的 data 并设置对应的 cell 并返回
- sizeForItem(at:) -> CGSize:根据 index 返回相应 cell 的大小
- numberOfItem() -> Int:当前 section controller 会显示多少个 cell
运行的结果如下图所示:
原理
在大致了解了 IGListKit 如何使用后,我们再来看下它的基本原理。用一句话来说明:通过对比前后两个 datasource,计算出 insert、remove、update、move 四种差量更新,这样去对应地通知 UICollectionView 的 performBatchUpdates(_:completion:),这样既不用手写 updater,也可以获得差量更新带来的性能收益。关于 Diff 算法的详细实现可以看这篇文章。
核心的 Diff 代码和更新逻辑如下:
实践
IGListKit 的代码其实相当容易阅读和理解,但是在实践中我们遇到了两个问题。下面就来详细说说问题是什么以及如何解决。
差量更新
首先看一下现场的视频:
我们发现,当回到应用首页后,整个列表因为更新闪烁了一下,似乎差量更新并没有如预期地工作。
分析
在分析这个问题之前,我们首先确定页面结构:
对应的 dataSource 如下所示:
func buildItem(studyInfo: StudyInfo?, recommendRoom: StudyRoom?, studyRooms: [StudyRoom]?) -> [HomeListSection] {
return [
.title(Title()),
.personalInfo(PersonalInfo(
studyInfo: studyInfo,
recommendRoom: recommendRoom,
updateGradeHandler: { [weak self] in
if let self = self {
self.loadData()
}
})),
.studyRoomList(StudyRoomList(studyRooms: studyRooms))
]
}
final class PersonalInfo: NSObject {
var studyInfo: StudyInfo?
var recommendRoom: StudyRoom?
var updateGradeHandler: (() -> Void)?
init(studyInfo: StudyInfo?,
recommendRoom: StudyRoom?,
updateGradeHandler: (() -> Void)?) {
self.studyInfo = studyInfo
self.recommendRoom = recommendRoom
self.updateGradeHandler = updateGradeHandler
super.init()
}
}
extension PersonalInfo: ListDiffable {
func diffIdentifier() -> NSObjectProtocol {
"\(Self.self)" as NSObjectProtocol
}
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
guard let personalInfo = object as? PersonalInfo else {
return false
}
let newStudyInfo = personalInfo.studyInfo
let newRecommendRoom = personalInfo.recommendRoom
return ((newStudyInfo == studyInfo) && (newRecommendRoom == recommendRoom))
}
}
看上去没什么问题,ListDiffable 被正确实现了。再看下 IGListKit 中使用 Diff 的地方:
这里的 fromObjects 和 toObjects 都是一维数组,因此 Diff 发生在 PersonalInfo 上,它是 Section 级别。也就是说,Diff 结果只能在 Section 维度上,因此更新也同样按照 Section 更新,也就是调用 reloadSections(_:)。而这个 Section 就是:
所以虽然我们只是更新推荐位的数据,却也会导致左侧蓝色卡片被更新,因为它们同属于一个 Section。
解决
大部分 IGListKit 的教程里,都没有提到如何实现 cell 级别的差量更新,而是推荐将 datasource 打平,也就是每个 section controller 的 numberOfItem() -> Int 保持默认值 1,以此实现 section 到 cell 的 1:1 关系,来模拟 cell 级别的更新。
这样的使用有一个问题,对于我们的应用场景:
使用两个 cell 来实现是一个非常自然的事。但是如果选择使用两个 section 来实现的话,默认的 UICollectionViewFlowLayout 不支持该布局(section 的宽度默认是 crossAxis 的宽度)。我们需要选择使用 ListCollectionViewLayout,但是这样会限制未来我们对布局的自定义的能力。
因此,对于这种 case,我们可以选择使用 ListBindingSectionController 来获得 cell 级别的差量更新的能力。下面简介下它的使用方式:
其他的使用方式没有不同,只有两点要改变:
- data model 在实现
ListDiffable时有所不同:
func isEqual(toDiffableObject object: IGListDiffable?) -> Bool {
return true
}
- section controller 需要继承
ListBindingSectionController并实现ListBindingSectionControllerDataSource协议。示例代码如下所示:
func sectionController(_ sectionController: IGListBindingSectionController, viewModelsFor object: Any) -> [IGListDiffable] {
guard let month = object as? Month else { return [] }
let date = Date()
let today = Calendar.current.component(.day, from: date)
var viewModels = [IGListDiffable]()
viewModels.append(MonthTitleViewModel(name: month.name))
for day in 1..<(month.days + 1) {
let viewModel = DayViewModel(
day: day,
today: day == today,
selected: day == selectedDay,
appointments: month.appointments[day]?.count ?? 0
)
viewModels.append(viewModel)
}
for appointment in month.appointments[selectedDay] ?? [] {
viewModels.append(appointment)
}
return viewModels
}
func sectionController(_ sectionController: IGListBindingSectionController, cellForViewModel viewModel: Any, at index: Int) -> UICollectionViewCell {
let cellClass: AnyClass
if viewModel is DayViewModel {
cellClass = CalendarDayCell.self
} else if viewModel is MonthTitleViewModel {
cellClass = MonthTitleCell.self
} else {
cellClass = LabelCell.self
}
return collectionContext?.dequeueReusableCell(of: cellClass, for: self, at: index) ?? UICollectionViewCell()
}
func sectionController(_ sectionController: IGListBindingSectionController, sizeForViewModel viewModel: Any, at index: Int) -> CGSize {
guard let width = collectionContext?.containerSize.width else { return .zero }
if viewModel is DayViewModel {
let square = width / 7.0
return CGSize(width: square, height: square)
} else if viewModel is MonthTitleViewModel {
return CGSize(width: width, height: 30.0)
} else {
return CGSize(width: width, height: 55.0)
}
}
这里的 viewModels 本身也是 [ListDiffable],这样 IGListKit 就会在这一层再一次 Diff,以此实现 Cell 级别的更新。
配合 RxSwift 使用
首先看如下代码:
我们发现,我们没办法改变一个由 struct 组成的数组中的某个元素。这是因为 struct 默认是 immutable,我们没法 in-place 修改它的 元素。在这种情况下,一般想到的办法就是改用 class,通过引用来获得 in-place 修改属性的能力。
这样确实可以工作,但是它会引起新的问题:对于 IGListKit 而言,内部在调用 IGListDiffExperiment 时传入的需要是两个数组实例,而 in-place 地修改则会导致IGListKit 观察不到任何变化(因为前后修改的数组是同一个实例)。
分析
IGListKit 是一个数据驱动的列表,但是它不是双向绑定的响应式列表,这就要求我们任何变动都必须更新到 data source 上并让 adapter 可感知才可以。换句话说,整个数据的流动必须是自上而下的。
一种通用做法是:
- element 通知 view model 需要修改的字段
- view model 根据通知的 element 查找到对应的 index 并记录
- view model 重新构造 data source,并在上步记录的 index 对应的 element 上应用本次修改
- 新的 data source 构造完成,通知
IGListKit进行更新
但是这样依旧很麻烦,我们希望通过一种更直观并高效的方式更新数据。
解决
首先我们需要回顾一下 IGListKit 的 Diff 算法。我们知道 diffIdentifier 用作数据的唯一标识,那么 isEqual(toDiffableObject) 的作用是什么?
通过阅读源码,可以看到,通过 diffIdentifier,可以计算得到 move、delete 和 insert,而 update 则是通过 isEqual(toDiffableObject) 计算得到。
这样,在弄明白 IGListKit 的 Diff 更新原理后,我们就可以让 isEqual(toDiffableObject) 失效,也就是默认返回 true。这样,IGListKit 的 Diff 结果中将不会有 update,而 update 就可以交给 RxSwift 接管。唯一要注意的是,我们需要在 prepareForReuse 中取消 RxSwift 订阅。以下是示例代码:
class Model {
let name = "model1"
let field = BehaviorRelay(value: "data")
}
extension Model: ListDiffable {
func diffIdentifier() -> NSObjectProtocol {
name as NSObjectProtocol
}
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
return true
}
}
class ModelCell: UICollectionReusableView {
let label = UILabel()
var bag = DisposeBag()
func prepareForReuse() {
super.prepareForReuse()
bag = DisposeBag()
}
}
extension ModelCell {
func bindViewModel(model: Model) {
model
.field
.subscribe(onNext: { field in
self?.label.text = field
})
.dispose(by: bag)
}
}
需要注意的是,对于Section Controller,我们依旧需要使用 ListBindingSectionController 来开启 cell 级别的差量更新。
总结
通过阅读 IGListKit 的源码并深入了解它的工作原理,我们解决了实践中的两个典型问题。正确使用 IGListKit 可以强制我们更清晰地组织业务代码,优秀的默认动画,并获得优异的性能。推荐大家使用。