原创:有趣知识点摸索型文章
创作不易,请珍惜,之后会持续更新,不断完善
个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容
虽然在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
是我们调用更新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
的属性,后面我们再讨论为什么会出现这个属性。
我们可以看到,这是一个协议。它非常简单只有几个的API:
- (NSArray<id <IGListDiffable>> *)objectsForListAdapter:(IGListAdapter *)listAdapter;
- (IGListSectionController *)listAdapter:(IGListAdapter *)listAdapter sectionControllerForObject:(id)object;
- (nullable UIView *)emptyViewForListAdapter:(IGListAdapter *)listAdapter;
在这里, 我们看到了另外两个关键类型ListSectionController
和IGListDiffable
。
从函数名字和注释我们可以看出,dataSource
是我们提供另外两个关键类型的数据的地方, 以及提供列表没有数据时候的提示UI组件的地方。
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
回顾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
。这里先以UICollectionView
为例。参考IGListKit
的demo
,其中有一个比较简单的例子StoryboardViewController
。在这里我们看到了:
ListAdapter
的创建以及调用ListSectionController
的子类StoryboardLabelSectionController
ListDiffable
协议的数据Person
创建的时候就需要传入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)
}
接着我们看一下这个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
, 也可以通过这个属性实现回调。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
的子类中上面我们讨论了CollectionView
场景接入IGListKit
,而在4.0更新之后,IGListKit
甚至可以支持TableView
的组件更新,而这是通过子模块IGListDiffKit
实现的。
我们会在ListDiffableKit
中接触以下类型:
这两个类型存储了列表组件变化的数据,而它们的关系就类似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
数据。
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
也是有一定成本的Cell
适配,优先使用ListDiffableKit
Cell
适配情况或者需要复用固定的Cell
组合业务,使用ListSectionController
。 如果是界面重构,预留时间做测试extension
给原来的Model
添加ListDiffable
协议,这样可以避免修改原Model
的代码Model
不方便改造,考虑定义新的类型作为数据源,但是需要更新对应Cell
的代码IGListKit
是instagram出的一款基于UICollectionView
的列表框架,采用数据驱动的方式来更新UI。并没有一个叫做IGList
的类,使用IGList
方式搭建的列表仍然只是普通的UICollectionView
。既然是数据驱动UI更新,那么修改了数据,UI就一定能更新吗? 答案是NO!
UICollectionView
的dataSource
和delegate
由它本身(或其所属的ViewController
)担任,开发者直接实现原生的协议方法。如图所示:
可以由UICollectionView
(或其所属的ViewController
)创建并持有IGListAdapter
(适配器),由UICollectionView
(或其所属的ViewController
)担任Adapter
的dataSource
和delegate
,UICollectionView
的dataSource
和delegate
则由Adapter
内部接管,开发者实现的是Adapter
自定义的协议方法。如图所示:
cell
的配置不再由UICollectionView
管理,而是交由section
对应的控制器IGListSectionController
。存放列表数据的数组中的每个元素(可称之为sectionViewModel
)代表一个section
,不同的sectionViewModel
对应不同的sectionController
。数组内的元素顺序即为section
展示顺序。
如果某个section
内部的cell
类型和个数灵活多变,可以使用IGListBindingSectionController
(继承自IGListSectionController
),它会用sectionViewModel
生成成一组cellViewModel
,每个cellViewModel
对应一种cell
,cellViewModel
的数组顺序即为该section
的cell
展示顺序,因此可以看作是IGList
的“套娃”。
无论是sectionViewModel
还是cellViewModel
,都需要实现以下协议方法,这是IGList
内部的diff
算法的基础。IGList
的diff
算法在对比新旧两个model
(sectionViewModel
/cellViewModel
)时,会先调用diffIdentifier
方法判断是否为同一个section
/cell
。如果是同一个section
/cell
,再调用isEqualToDiffableObject:
方法判断该section
/cell
是否有更新,结果为false
则触发该section
/cell
的UI更新。
IGListAdapter
内部存储了当前列表数据([SectionViewModel]
)
IGListBindingSectionController
内部存储了当前section
的数据([CellViewModel]
)
等等!先分清是要整表更新还是某个section
更新?
整表更新是指UICollectionView
本身调用adapter
的performUpdatesAnimated:completion:
方法进行更新。
需要整表更新的情况就三种:
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
缺失。
解决办法:结合业务,具体情况具体分析
1、如果列表太长,做整表更新时,虽然diff
算法的时间复杂度是O(N)
,但是N
太大也扛不住频繁更新,会阻塞主线程。(N
的极限没有测试过,但是一般长度的列表是完全没问题的,比如几百个section
)。
2、开启VoiceOver
时会crash
,目前无解,只能禁止app开启辅助功能。IGListAdapter
内部有一段注释说明了该问题:
IGList
能帮助我们更加灵活快速地构建复杂/频繁变化的列表。列表的UI变化都通过操作数据更新来完成,并且能实现更大粒度的复用(比如复用sectionController
)。使用积木的方式搭建列表,可以发挥无限可能。