原创:有趣知识点摸索型文章
创作不易,请珍惜,之后会持续更新,不断完善
个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容
目录
- 一、IGListKit 是什么
- 二、怎样接入IGListKit
- 三、新增对于UITableView的支持
- 四、引发的思考
- 五、疑难解答
一、IGListKit 是什么
虽然在iOS开发中有很多很好用的列表控件,性能和API都很好用,对于简单无变化或者变化较为简单的列表cell
是可以满足开发需求的,但是对于复杂的列表,就会出现不足,常见的reloadData
时的闪烁和performBatchUpdates
时手动维护updater
的较大难度和易crash
,由此出现了针对复杂列表的三方库IglistKit
,它是 Instagram
的一个数据驱动的 UICollectionView
框架,为了构建快速和可扩展的列表。
iOS原生端开发过程中列表是最常见的需求之一。随着业务和UI交互设计的迭代,我们逐渐会接触到这样的需求:
- 列表中出现多种不同样式的
Cell
- 列表中出现复杂的
Cell
插入、更新、删除、移位动画
接着我们就遇到这样的问题:
- 同一列表中适配多种
Cell
, 导致dataSource
部分代码臃肿不好维护 - 同一列表中复杂的
Cell
带来同样多的回调适配, 进一步增加臃肿度和维护难度 - 复杂的列表更新策略配合多种不同的数据类型, 导致批量更新列表同样麻烦
针对某些Cell
组合的业务逻辑复用
Instagram
团队的开源框架IGListKit
是一个非常好用的解决方案。简单地说IGListKit
封装了很多友好的API去帮我们适配和更新UICollectionView/UITableView
(在4.0版中加入了对UITableView
的支持,但是主要API还是服务于UICollectionView
,它专注于处理列表的数据源和操作行为。
那么IGListKit
是如何做到的呢?如果我们最基本地使用IGListKit
,我们会接触到下面这几个类型:
- ListAdapter
- ListSectionController
- ListDiffable
ListAdapter
ListAdapter
是我们调用更新UI的API的入口,它帮我们桥接了UICollectionView
的一些API。在这个类型中有以下几个关键API:
@property (nonatomic, nullable, weak) UIViewController *viewController;
@property (nonatomic, nullable, weak) UICollectionView *collectionView;
@property (nonatomic, nullable, weak) id <IGListAdapterDataSource> dataSource;
@property (nonatomic, nullable, weak) id <IGListAdapterDelegate> delegate;
@property (nonatomic, nullable, weak) id <UICollectionViewDelegate> collectionViewDelegate;
- (void)performUpdatesAnimated:(BOOL)animated completion:(nullable IGListUpdaterCompletion)completion;
- (void)reloadDataWithCompletion:(nullable IGListUpdaterCompletion)completion;
- (void)reloadObjects:(NSArray *)objects;
从名字上我们就可以看出,ListAdapter
其实做了一些本来是UICollectionView
做的事情,比如更新行为,而IGListKit
的example
中也告诉了我们这句话:使用ListAdapter
去更新界面而不要再自己调用UICollectionView
的接口。
除此以外,我们还看到了dataSource
、delegate
、scrollDelegate
这类原来在UICollectionView
上的属性,实际上它就是桥接了对应的属性。我们还可以见到一个viewController
的属性,后面我们再讨论为什么会出现这个属性。
IGListAdapterDataSource
我们可以看到,这是一个协议。它非常简单只有几个的API:
- (NSArray<id <IGListDiffable>> *)objectsForListAdapter:(IGListAdapter *)listAdapter;
- (IGListSectionController *)listAdapter:(IGListAdapter *)listAdapter sectionControllerForObject:(id)object;
- (nullable UIView *)emptyViewForListAdapter:(IGListAdapter *)listAdapter;
在这里, 我们看到了另外两个关键类型ListSectionController
和IGListDiffable
。
从函数名字和注释我们可以看出,dataSource
是我们提供另外两个关键类型的数据的地方, 以及提供列表没有数据时候的提示UI组件的地方。
ListSectionController
ListAdapter
是我们发起更新的地方, 那么ListSectionController
就是我们做行为适配的地方了。
上面我们已经可以看到, 在IGListAdapterDataSource
协议中我们需要返回一个ListSectionController
的实例,而对这个函数里面提供了一个ListAdapter
的实例变量, 和一个id
类型的变量。
我们不难理解这个listAdapter
, 那么这个object
变量又是做什么的呢?它和ListSectionController
又有什么联系呢?先给出直接答案:这个object
就是我们另一个关键类型ListDiffable
。 而我们在这个函数中到底返回怎么样的ListSectionController
就取决于我们要对什么样的ListDiffable
数据进行适配。
接着看一下ListSectionController
的部分API:
- (NSInteger)numberOfItems;
- (CGSize)sizeForItemAtIndex:(NSInteger)index;
- (__kindof UICollectionViewCell *)cellForItemAtIndex:(NSInteger)index;
- (void)didUpdateToObject:(id)object;
- (void)didSelectItemAtIndex:(NSInteger)index;
- (void)didDeselectItemAtIndex:(NSInteger)index;
- (void)didHighlightItemAtIndex:(NSInteger)index;
@property (nonatomic, weak, nullable, readonly) UIViewController *viewController;
@property (nonatomic, weak, nullable, readonly) id <IGListCollectionContext> collectionContext;
@property (nonatomic, assign) UIEdgeInsets inset;
@property (nonatomic, assign) CGFloat minimumLineSpacing;
@property (nonatomic, assign) CGFloat minimumInteritemSpacing;
@property (nonatomic, weak, nullable) id <IGListSupplementaryViewSource> supplementaryViewSource;
@property (nonatomic, weak, nullable) id <IGListDisplayDelegate> displayDelegate;
在这里我们看到了一些很熟悉的函数名和属性,跳过一下像supplementaryViewSource
和displayDelegate
这样还不明确的属性。我们已经可以猜出ListSectionController
做的事情:
- 适配
UICollectionViewCell
的数量 - 适配对应的
UICollectionViewCell
实例 - 适配
Cell
的大小 - 适配
Cell
以及本Section
的间距 - 适配用户操作行为以及事件响应行为
- 可以获取当前所在的
UIViewController
ListDiffable
回顾ListAdapter
和ListSectionController
的API,我们已经明白, 我们每次更新列表, 就是我们更新ListDiffable
数组。到现在我们已经知道了,ListDiffable
是IGListKit
封装的API中列表的数据单位。
那么问题就是,我们要怎么去生成这个数据单位呢?查看代码,其实ListDiffable
是一个非常简单的协议:
NS_SWIFT_NAME(ListDiffable)
@protocol IGListDiffable
- (nonnull id<NSObject>)diffIdentifier;
- (BOOL)isEqualToDiffableObject:(nullable id<IGListDiffable>)object;
@end
-
diffIdentifier
明显是用于标识这条数据唯一性 - 函数
isEqualToDiffableObject(:)
则是具体实现如何判别这条数据和另一条数据不一样
二、怎样接入IGListKit
有了大致了解之后,我们看一下要怎样接入IGListKit
。这里先以UICollectionView
为例。参考IGListKit
的demo
,其中有一个比较简单的例子StoryboardViewController
。在这里我们看到了:
-
ListAdapter
的创建以及调用 - 在协议函数里返回了一个
ListSectionController
的子类StoryboardLabelSectionController
- 实现了
ListDiffable
协议的数据Person
ListAdapter的使用
创建的时候就需要传入viewController
, 以及一个updater
, 这个updater
暂时不讨论。
lazy var adapter: ListAdapter = {
return ListAdapter(updater: ListAdapterUpdater(), viewController: self)
}()
必要参数赋值:dataSource
、托管的collectionView
adapter.collectionView = collectionView
adapter.dataSource = self
在回调中更新UICollectionView
,可以通过adapter
找到对应的section
,修改数据后调用adapter
的performUpdates
函数。
func removeSectionControllerWantsRemoved(_ sectionController: StoryboardLabelSectionController) {
let section = adapter.section(for: sectionController)
people.remove(at: Int(section))
adapter.performUpdates(animated: true)
}
ListSectionController的使用
接着我们看一下这个StoryboardLabelSectionController
的代码:
final class StoryboardLabelSectionController: ListSectionController {
private var object: Person?
weak var delegate: StoryboardLabelSectionControllerDelegate?
override func sizeForItem(at index: Int) -> CGSize {
return CGSize(width: (self.object?.name.count)! * 7, height: (self.object?.name.count)! * 7)
}
override func cellForItem(at index: Int) -> UICollectionViewCell {
guard let cell = collectionContext?.dequeueReusableCellFromStoryboard(withIdentifier: "cell"
}
cell.text = object?.name
return cell
}
override func didUpdate(to object: Any) {
self.object = object as? Person
}
override func didSelectItem(at index: Int) {
delegate?.removeSectionControllerWantsRemoved(self)
}
}
-
StoryboardLabelSectionController
持有了Person
对象, 就是在didUpdate(to:)
函数中获得的,而在适配Cell
的时候用到了它。 - 在这个例子中, 每个
Section
中只有1条数据。 但是其实SectionController
控制的是UICollectionView
中的Section
, 所以也可以在这里适配多个数据或者多种Cell
。 -
Cell
的点击回调发生在didSelectItem(at:)
中, 此处用了delegate
作为回调方式。 而我们上面已经知道在ListSectionController中
有一个属性viewController
, 也可以通过这个属性实现回调。
Person
final class Person: ListDiffable {
let pk: Int
let name: String
init(pk: Int, name: String) {
self.pk = pk
self.name = name
}
func diffIdentifier() -> NSObjectProtocol {
return pk as NSNumber
}
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
guard let object = object as? Person else { return false }
return self.name == object.name
}
}
可以看到Person
类中除了ListDiffable
协议的2个必需的函数以外还有2个属性:
-
pk
属性被用作唯一标识 -
name
属性被用于在适配Cell
的时候加载显示 -
isEqual(toDiffableObject:)
中做了类型对比和name
属性的对比
小结
ListAdapter
的数据源就是实现了ListDiffable
协议的数据的数组,我们更新CollectionView
需要调用ListAdapter
的函数。ListDiffable
类型对应的是CollectionView
中的Section
单元的数据,它里面的数据也对应这个Section
里面的Cell
。ListSectionController
把相应ListDiffable
数据适配成对应的Section
,在它这里适配Cell
的样式和回调。
所以我们需要做的事情小结就是:
- 用
ListAdapter
桥接ViewController
和CollectionView
- 把原来
CollectionView
的dataSource
的协议函数改成ListAdapter
的dataSource
协议函数 - 给原来的数据源类型实现
ListDiffable
协议,记得ListDiffable
数据对应的是Section
- 把
Cell
的适配和回调代码迁移到ListSectionController
的子类中
三、新增对于UITableView的支持
上面我们讨论了CollectionView
场景接入IGListKit
,而在4.0更新之后,IGListKit
甚至可以支持TableView
的组件更新,而这是通过子模块IGListDiffKit
实现的。
我们会在ListDiffableKit
中接触以下类型:
- ListIndexPathResult
- ListIndexSetResult
这两个类型存储了列表组件变化的数据,而它们的关系就类似IndexPath
和IndexSet
的关系。我们先只看ListIndexPathResult
。
@property (nonatomic, copy, readonly) NSArray<NSIndexPath *> *inserts;
@property (nonatomic, copy, readonly) NSArray<NSIndexPath *> *deletes;
@property (nonatomic, copy, readonly) NSArray<NSIndexPath *> *updates;
@property (nonatomic, copy, readonly) NSArray<IGListMoveIndexPath *> *moves;
@property (nonatomic, assign, readonly) BOOL hasChanges;
- (nullable NSIndexPath *)oldIndexPathForIdentifier:(id<NSObject>)identifier;
- (nullable NSIndexPath *)newIndexPathForIdentifier:(id<NSObject>)identifier;
- (IGListIndexPathResult *)resultForBatchUpdates;
可以看到它这几个关键API:
- 属性
inserts
,deletes
,updates
,moves
分别对应插入, 删除, 更新, 移动的数据 - 属性
hasChanges
代表这条结果和列表上一次的结果是否出现不同 - 函数
oldIndexPathForIdentifier(:)
和newIndexPathForIdentifier(:)
可以根据唯一标识找到更新前/后其在列表中对应的IndexPath
- 函数
resultForBatchUpdates
返回可以用于安全更新TableView
或CollectionView
的ListIndexPathResult
实例
我们可以在demo
中找到一个对应的例子DiffTableViewController
,它就借助了ListIndexPathResult
去更新UITableView
。
@objc func onDiff() {
let from = people
let to = usingOldPeople ? newPeople : oldPeople
usingOldPeople = !usingOldPeople
people = to
// 调用全局函数,传入更新前后的数据源,获得ListIndexPathResult实例
let result = ListDiffPaths(fromSection: 0, toSection: 0, oldArray: from, newArray: to, option: .equality).forBatchUpdates()
// 调起tableView的批量更新
tableView.beginUpdates()
// 调起tableView的deleteRows,从result的deletes属性获得被删除的IndexPath数组
tableView.deleteRows(at: result.deletes, with: .fade)
// 调起tableView的insertRows,从result的inserts属性获得被添加的IndexPath数组
tableView.insertRows(at: result.inserts, with: .fade)
// 由于UITableView没有批量移动IndexPath的API, 所以要遍历result的moves属性, 逐个执行tableView的moveRow(at:, to:)函数
result.moves.forEach { tableView.moveRow(at: $0.from, to: $0.to) }
// 结束批量更新
tableView.endUpdates()
}
我们可以看到 仅仅使用ListIndexPathResult
, 我们不需要借助ListAdapter
也可以顺利更新列表。我们需要做的关键点是:
- 使用
ListDiffable
数据作为数据源 - 获得更新前和更新后的
dataSource
数组和对应的section
- 调用
ListDiffPaths()
函数得到ListIndexPathResult
- 调起
TableView/CollectionView
的批量更新函数, 取出变更的IndexPath
数据进行对应操作
注意在这个例子中ListDiffable
已经不是对应Section
的数据单位!因为UITableView
并没有对应的ListSectionController
去专门处理ListDiffable
数据。
四、引发的思考
接入IGListKit后代码结构发生了以下改善
- 通过
ListSectionController
对不同类型的Cell
进行单独适配,减轻了dataSource
和delegate
的负担 - 通过
ListAdapter
更新CollectionView
让我们不需要再自行维护具体的数据变化 - 通过
ListIndexPathResult
/ListIndexSetResult
也可以快速地让TableView
的更新变得简单化 - 如果遇到需要复用的
Cell
组合业务逻辑,可以直接复用ListSectionController
- 接入
IGListKit
无需改变Cell
的代码,也不影响CollectionView
和UITableView
本身在其superview
上的布局状态
那么,难道接入IGListKit
就只有好处吗?看看接入IGListKit
的副作用:
- 使用
ListSectionController
适配对应的ListDiffable
数据,项目整体代码量增加,会延长开发周期 -
CollectionView
界面迭代后需要进行大量代码迁移,如果界面中业务逻辑比较复杂容易引发错误,需要重新测试 - 如果原界面是通过
UITableView
实现的话,想要得到ListSectionController
带来的便利,需要把所有涉及的TableViewCell
改成CollectionViewCell
- 必须把数据源换成
ListDiffable
类型,因此要对原数据类型进行改造,如果不想/无法改造原类型代码,则需要另外定义新的类型 - 接入
IGListKit
也是有一定成本的
接入IGListKit的取舍是什么?
- 如果只是有复杂的列表更新需求,但是没有复杂的
Cell
适配,优先使用ListDiffableKit
- 遇上复杂
Cell
适配情况或者需要复用固定的Cell
组合业务,使用ListSectionController
。 如果是界面重构,预留时间做测试 - 如果使用Swift开发,优先使用
extension
给原来的Model
添加ListDiffable
协议,这样可以避免修改原Model
的代码 - 如果使用了OC开发,原来的
Model
不方便改造,考虑定义新的类型作为数据源,但是需要更新对应Cell
的代码
五、疑难解答
IGListKit
是instagram出的一款基于UICollectionView
的列表框架,采用数据驱动的方式来更新UI。并没有一个叫做IGList
的类,使用IGList
方式搭建的列表仍然只是普通的UICollectionView
。既然是数据驱动UI更新,那么修改了数据,UI就一定能更新吗? 答案是NO!
IGList框架是如何应用在UICollectionView的?
不使用IGList
UICollectionView
的dataSource
和delegate
由它本身(或其所属的ViewController
)担任,开发者直接实现原生的协议方法。如图所示:
使用了IGList
可以由UICollectionView
(或其所属的ViewController
)创建并持有IGListAdapter
(适配器),由UICollectionView
(或其所属的ViewController
)担任Adapter
的dataSource
和delegate
,UICollectionView
的dataSource
和delegate
则由Adapter
内部接管,开发者实现的是Adapter
自定义的协议方法。如图所示:
使用IGList之后需要作出的观念转变
UICollectionView仅面向section进行开发
cell
的配置不再由UICollectionView
管理,而是交由section
对应的控制器IGListSectionController
。存放列表数据的数组中的每个元素(可称之为sectionViewModel
)代表一个section
,不同的sectionViewModel
对应不同的sectionController
。数组内的元素顺序即为section
展示顺序。
列表数组的每个元素(sectionViewModel)被传递给对应的sectionController
如果某个section
内部的cell
类型和个数灵活多变,可以使用IGListBindingSectionController
(继承自IGListSectionController
),它会用sectionViewModel
生成成一组cellViewModel
,每个cellViewModel
对应一种cell
,cellViewModel
的数组顺序即为该section
的cell
展示顺序,因此可以看作是IGList
的“套娃”。
IGList的数据驱动UI更新机制
无论是sectionViewModel
还是cellViewModel
,都需要实现以下协议方法,这是IGList
内部的diff
算法的基础。IGList
的diff
算法在对比新旧两个model
(sectionViewModel
/cellViewModel
)时,会先调用diffIdentifier
方法判断是否为同一个section
/cell
。如果是同一个section
/cell
,再调用isEqualToDiffableObject:
方法判断该section
/cell
是否有更新,结果为false
则触发该section
/cell
的UI更新。
问题:旧model存在哪里?
IGListAdapter
内部存储了当前列表数据([SectionViewModel]
)
IGListBindingSectionController
内部存储了当前section
的数据([CellViewModel]
)
数据改了,为什么UI不更新?
等等!先分清是要整表更新还是某个section
更新?
整表更新
整表更新是指UICollectionView
本身调用adapter
的performUpdatesAnimated:completion:
方法进行更新。
需要整表更新的情况就三种:
- 删除
section
- 新增
section
- 某个
section
有新的数据对象
某个section更新
某个section
更新特指 IGListBindingSectionController
调用自己的updateAnimated:completion:
方法进行更新。
需要某个section
更新的情况:除整表更新的三种情况以外,其他情况都属于section
更新。
明确了整表更新和某个section
更新的场景之后,再来看UI不更新的原因,发现基本就三种原因:
1、修改的是同一个sectionViewModel
,但调用的是整表更新方法,isEqualToDiffableObject:
判断结果必然是true,不会触发UI更新;
2、不同的sectionViewModel
封装了同一个dataModel
(后端返回的数据结构),修改的是这个dataModel
的字段,但调用的是整表更新方法,isEqualToDiffableObject:
判断结果必然是true,不会触发UI更新;
3、isEqualToDiffableObject:
方法没有写触发更新的判断条件。
解决办法很简单:
1和2:使用IGListAdapter
的sectionControllerForObject:
方法,通过sectionViewModel
找到对应的IGListBindingSectionController
,然后调用section
更新方法;
3:补全触发更新的判断条件。
为什么列表显示不全?
原因:diffIdentifier
方法返回值粒度不够,通过diffIdentifier
判断是重复的数据不会被展示,导致section/cell
缺失。
解决办法:结合业务,具体情况具体分析
- 找到最细粒度(id+其他标识)
- 没有id可考虑使用时间戳
IGList的弱点和不足
1、如果列表太长,做整表更新时,虽然diff
算法的时间复杂度是O(N)
,但是N
太大也扛不住频繁更新,会阻塞主线程。(N
的极限没有测试过,但是一般长度的列表是完全没问题的,比如几百个section
)。
2、开启VoiceOver
时会crash
,目前无解,只能禁止app开启辅助功能。IGListAdapter
内部有一段注释说明了该问题:
IGList
能帮助我们更加灵活快速地构建复杂/频繁变化的列表。列表的UI变化都通过操作数据更新来完成,并且能实现更大粒度的复用(比如复用sectionController
)。使用积木的方式搭建列表,可以发挥无限可能。