IOS框架使用:IGListKit

原创:有趣知识点摸索型文章
创作不易,请珍惜,之后会持续更新,不断完善
个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的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做的事情,比如更新行为,而IGListKitexample中也告诉了我们这句话:使用ListAdapter去更新界面而不要再自己调用UICollectionView的接口。

除此以外,我们还看到了dataSourcedelegatescrollDelegate这类原来在UICollectionView上的属性,实际上它就是桥接了对应的属性。我们还可以见到一个viewController的属性,后面我们再讨论为什么会出现这个属性。


IGListAdapterDataSource

我们可以看到,这是一个协议。它非常简单只有几个的API:

- (NSArray<id <IGListDiffable>> *)objectsForListAdapter:(IGListAdapter *)listAdapter;
- (IGListSectionController *)listAdapter:(IGListAdapter *)listAdapter sectionControllerForObject:(id)object;
- (nullable UIView *)emptyViewForListAdapter:(IGListAdapter *)listAdapter;

在这里, 我们看到了另外两个关键类型ListSectionControllerIGListDiffable
从函数名字和注释我们可以看出,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;

在这里我们看到了一些很熟悉的函数名和属性,跳过一下像supplementaryViewSourcedisplayDelegate这样还不明确的属性。我们已经可以猜出ListSectionController做的事情:

  • 适配UICollectionViewCell的数量
  • 适配对应的UICollectionViewCell实例
  • 适配Cell的大小
  • 适配Cell以及本Section的间距
  • 适配用户操作行为以及事件响应行为
  • 可以获取当前所在的UIViewController

ListDiffable

回顾ListAdapterListSectionController的API,我们已经明白, 我们每次更新列表, 就是我们更新ListDiffable数组。到现在我们已经知道了,ListDiffableIGListKit封装的API中列表的数据单位。

那么问题就是,我们要怎么去生成这个数据单位呢?查看代码,其实ListDiffable是一个非常简单的协议:

NS_SWIFT_NAME(ListDiffable)
@protocol IGListDiffable
- (nonnull id<NSObject>)diffIdentifier;
- (BOOL)isEqualToDiffableObject:(nullable id<IGListDiffable>)object;
@end
  • diffIdentifier明显是用于标识这条数据唯一性
  • 函数isEqualToDiffableObject(:)则是具体实现如何判别这条数据和另一条数据不一样

二、怎样接入IGListKit

有了大致了解之后,我们看一下要怎样接入IGListKit。这里先以UICollectionView为例。参考IGListKitdemo,其中有一个比较简单的例子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,修改数据后调用adapterperformUpdates函数。

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里面的CellListSectionController把相应ListDiffable数据适配成对应的Section,在它这里适配Cell的样式和回调。

所以我们需要做的事情小结就是:

  • ListAdapter桥接ViewControllerCollectionView
  • 把原来CollectionViewdataSource的协议函数改成ListAdapterdataSource协议函数
  • 给原来的数据源类型实现ListDiffable协议,记得ListDiffable数据对应的是Section
  • Cell的适配和回调代码迁移到ListSectionController的子类中

三、新增对于UITableView的支持

上面我们讨论了CollectionView场景接入IGListKit,而在4.0更新之后,IGListKit甚至可以支持TableView的组件更新,而这是通过子模块IGListDiffKit实现的。

我们会在ListDiffableKit中接触以下类型:

  • ListIndexPathResult
  • ListIndexSetResult

这两个类型存储了列表组件变化的数据,而它们的关系就类似IndexPathIndexSet的关系。我们先只看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 返回可以用于安全更新TableViewCollectionViewListIndexPathResult实例

我们可以在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进行单独适配,减轻了dataSourcedelegate的负担
  • 通过ListAdapter更新CollectionView让我们不需要再自行维护具体的数据变化
  • 通过ListIndexPathResult/ListIndexSetResult也可以快速地让TableView的更新变得简单化
  • 如果遇到需要复用的Cell组合业务逻辑,可以直接复用ListSectionController
  • 接入IGListKit无需改变Cell的代码,也不影响CollectionViewUITableView本身在其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

UICollectionViewdataSourcedelegate由它本身(或其所属的ViewController)担任,开发者直接实现原生的协议方法。如图所示:

使用了IGList

可以由UICollectionView(或其所属的ViewController)创建并持有IGListAdapter(适配器),由UICollectionView(或其所属的ViewController)担任AdapterdataSourcedelegateUICollectionViewdataSourcedelegate则由Adapter内部接管,开发者实现的是Adapter自定义的协议方法。如图所示:

使用IGList之后需要作出的观念转变

UICollectionView仅面向section进行开发

cell的配置不再由UICollectionView管理,而是交由section对应的控制器IGListSectionController。存放列表数据的数组中的每个元素(可称之为sectionViewModel)代表一个section,不同的sectionViewModel对应不同的sectionController。数组内的元素顺序即为section展示顺序。

列表数组的每个元素(sectionViewModel)被传递给对应的sectionController

如果某个section内部的cell类型和个数灵活多变,可以使用IGListBindingSectionController(继承自IGListSectionController),它会用sectionViewModel生成成一组cellViewModel,每个cellViewModel对应一种cellcellViewModel的数组顺序即为该sectioncell展示顺序,因此可以看作是IGList的“套娃”。

IGList的数据驱动UI更新机制

无论是sectionViewModel还是cellViewModel,都需要实现以下协议方法,这是IGList内部的diff算法的基础。IGListdiff算法在对比新旧两个modelsectionViewModel/cellViewModel)时,会先调用diffIdentifier方法判断是否为同一个section/cell。如果是同一个section/cell,再调用isEqualToDiffableObject:方法判断该section/cell是否有更新,结果为false则触发该section/cell的UI更新。

问题:旧model存在哪里?

IGListAdapter内部存储了当前列表数据([SectionViewModel]

IGListBindingSectionController内部存储了当前section的数据([CellViewModel]

数据改了,为什么UI不更新?

等等!先分清是要整表更新还是某个section更新?

整表更新

整表更新是指UICollectionView本身调用adapterperformUpdatesAnimated: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:使用IGListAdaptersectionControllerForObject:方法,通过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)。使用积木的方式搭建列表,可以发挥无限可能。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,393评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,790评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,391评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,703评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,613评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,003评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,507评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,158评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,300评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,256评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,274评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,984评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,569评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,662评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,899评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,268评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,840评论 2 339

推荐阅读更多精彩内容