IGListKit 学习笔记二-数据变化驱动视图更新

IGListKit 的数据变更后刷新是通过 diff 算法比较数据变更差异,然后在界面上应用这些差异。

数据刷新主要有两种方式 performUpdates 以及 reloadDatas。

# performUpdates 和 reloadData 的区别

performUpdates

  • 基于 UICollectionViewperformBatchUpdates:completion: API
  • 使用 diff 算法只会更新实际变动的数据和界面部分,非变动部分不会刷新。这可以大幅提高性能。
  • 有平滑的动画效果,用户体验更好。

reloadData

  • 基于 UICollectionViewreloadData API
  • 会完全刷新列表数据源和界面,并不会去进行 diff 性能较差。
  • 没有动画,界面会突然刷新,用户体验差。

总结下来就是

  • 如果数据源发生大规模变化,使用 reloadData 会简便一些,官方的 QA 有提到 (opens new window)
  • 如果数据源只有局部变动,强烈推荐使用 performUpdates,这可以达到最佳的性能和用户体验。

# performUpdates 闪退问题

但是在实际使用过程中 performUpdates 会闪退?官方的 issue 区也有类似的问题

https://github.com/Instagram/IGListKit/issues/799 (opens new window)

闪退内容大概意思就是更新前后的同一个 section 的 items 数量前后不一致导致更新无效。

IGListKitDemo[29744:591638] [UICollectionView] Performing reloadData as a fallback — Invalid update: invalid number of items in section 0. The number of items contained in an existing section after the update (2) must be equal to the number of items contained in that section before the update (1), plus or minus the number of items inserted or deleted from that section (0 inserted, 0 deleted) and plus or minus the number of items moved into or out of that section (0 moved in, 0 moved out). Collection view: <UICollectionView: 0x7fc50b816400; …. >

出现这个问题的主要原因是同一个数据变化导致它对应的 section items 数量发生变化,UICollectionView performUpdates 前后数量不一致导致 UICollectionView 刷新机制出现了问题。

什么场景下会出现这个问题?在 IGListKit 里面如果根据数据(model)的属性去判断 section items 的数量。如果对应属性修改引起了 section items 的数量变化,此时执行 performUpdates 就能触发上面的崩溃。

这里需要注意的点是,如何定义同一个数据?我们先来看看 IGListKit 底层使用的 diff 算法的变现。

# diff 算法的具体表现

这里不打算介绍 diff 算法的原理,如果感兴趣的话可以去网上搜 IGListKit 的 diff 算法,我们现在只是看下 diff 算法的具体表现。

结合学习笔记一的 Feed 的定义,我们修改了下 isEqual 的算法实现,增加了 feed.name 的判断

extension Feed: ListDiffable {
    func diffIdentifier() -> NSObjectProtocol {
        return "\(String(describing: id))" as NSObjectProtocol
    }
    func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
        guard let feed = object as? Feed else { return false }
        return feed.diffIdentifier().isEqual(self.diffIdentifier()) && feed.name == self.name
    }
}

具体的数据构建和 diff 结果如下

func buildOldFeeds() -> [Feed] {
    var oldFeeds = [Feed]()
    let feed1 = Feed.buildFeedWithId(1, name: "OldWang")
    let feed2 = Feed.buildFeedWithId(2, name: "OldFan")
    let feed3 = Feed.buildFeedWithId(3, name: "OldHuo")
    let feed4 = Feed.buildFeedWithId(4, name: "OldDao")
    oldFeeds.append(contentsOf: [feed1, feed2, feed3, feed4])
    return oldFeeds
}

func buildNewFeeds() -> [Feed] {
    var newFeeds = [Feed]()
    let feed5 = Feed.buildFeedWithId(2, name: "OldFan1")
    let feed6 = Feed.buildFeedWithId(5, name: "NewWang")
    let feed7 = Feed.buildFeedWithId(1, name: "OldWang")
    let feed8 = Feed.buildFeedWithId(4, name: "OldDao22")
    newFeeds.append(contentsOf: [feed5, feed6, feed7, feed8])
    return newFeeds
}

func compareDiff() {
    let oldFeeds = buildOldFeeds()
    let newFeeds = buildNewFeeds()
    let results = ListDiff(oldArray: oldFeeds, newArray: newFeeds, option: .equality)
    print("inserts \(results.inserts)\ndeletes \(results.deletes)\nupdates \(results.updates)\nmoves \(results.moves)")
    results.inserts.forEach { print("insert \(newFeeds[$0])") }
    results.deletes.forEach { print("delete \(oldFeeds[$0])") }
    results.updates.forEach { print("update index \($0)") }
    results.updates.forEach { print("update from \(oldFeeds[$0].name) to \(newFeeds[$0].name)") }
}
>>> 结果
inserts 1 indexes
deletes 1 indexes
updates 2 indexes
moves [<IGListMoveIndex 0x282c61be0; from: 1; to: 0;>, <IGListMoveIndex 0x282c61c20; from: 0; to: 2;>]
insert Optional("NewWang")
delete Optional("OldHuo")
update index 1
update index 3

里面基本上涵盖了数据变化的各种情况,可以看到

  • insert 和 delete 的判断依据是 id 的变化。即只要有 id 的变化就有对应的 insert 和 delete 数据增减变化。
  • updates 的判断依据是 isEqual 中的判断逻辑,比如上面数组变化前后 id 为 4 的 Feed,如果在 Feed 的 isEqual 方法中不引入 name 这个变量的话,就不会检查到数据的变化。

上面的的场景中,如果需要将 newFeeds 中的更新的数据赋给 oldFeeds,那需要先通过 results.updates 下标获取 oldFeeds 里的数据,然后通过 id 匹配找到 newFeeds 里对应的 Feed,通过 Feed 直接赋值的方式更新。

insert 和 delete 的概念比较好理解,这里重点关注下 update 的场景,下面这个场景在实际开发中经常会遇到,比如给帖子点赞,更新评论数等,其实就是改变了列表元素中某个实例的属性,如果这些属性不在 isEqual 的判断逻辑里,则直接进行 performUpdates 后是没有办法 diff 出任何东西的,因为比较前后的列表是一样的。

func buildOldFeeds() -> [Feed] {
    var oldFeeds = [Feed]()
    oldFeeds.append(Feed.buildFeedWithId(1, name: "OldWang"))
    return oldFeeds
}

func buildNewFeeds() -> [Feed] {
    var newFeeds = [Feed]()
    newFeeds.append(Feed.buildFeedWithId(1, name: "OldFan", content: "xxxx"))
    return newFeeds
}

func compareDiff() {
    let oldFeeds = buildOldFeeds()
    oldFeeds.first?.content = "xxx"
	let newFeeds = oldFeeds
    let results = ListDiff(oldArray: oldFeeds, newArray: newFeeds, option: .equality)
	print("inserts \(results.inserts)\ndeletes \(results.deletes)\nupdates \(results.updates)\nmoves \(results.moves)")
}
>>>
inserts 0 indexes
deletes 0 indexes
updates 0 indexes
moves []

# 回到 performUpdates 闪退的问题

假设我们有下面这段场景,一个 Feed 实例 contents 属性内容填充后会影响 Feed 对应的 section items 的变化。

extension Feed: ListDiffable {
    func diffIdentifier() -> NSObjectProtocol {
        return "\(String(describing: id))" as NSObjectProtocol
    }
    func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
        guard let feed = object as? Feed else { return false }
        return feed.diffIdentifier().isEqual(self.diffIdentifier()) && feed.name == self.name
    }
}
func buildOldFeeds() -> [Feed] {
	var oldFeeds = [Feed]()
	let feed1 = Feed.buildFeedWithId(1, name: "OldWang")
	oldFeeds.append(contentsOf: [feed1])
	return oldFeeds
}
func buildNewFeeds() -> [Feed] {
	var newFeeds = [Feed]()
	let feed5 = Feed.buildFeedWithId(1, name: "OldFan", content: "xxxx")
	newFeeds.append(feed5)
	return newFeeds
}
func mockChangeRefresh {
	feedList = buildOldFeeds()
	adapter.reloadData()
	DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1)) { [weak self] in
    guard let wself = self else { return }
		//场景一 
		wself.feedList.first?.content = "xxxx"
		//场景二
        wself.feedList = wself.buildNewFeeds()
        wself.adapter.performUpdates(animated: true)
	}
}

以上两种情况都会产生崩溃

  • 场景一的情况下 section items 在 peformUpdates: 前后的数量不同,而且 section 对应的 data 实例本身是没有发生任何改变,所以引起崩溃。

  • 场景二的情况下是新建了元素,所以会 diff 出来 updates,diff 后的结果也没有任何 updates,同样是会出现崩溃。

但是如果我们在 ListDiffable 协议的 isEqual 方法中添加 content 的判断如下

func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
    guard let feed = object as? Feed else { return false }
    return feed.diffIdentifier().isEqual(self.diffIdentifier()) && feed.name == self.name && feed.content == self.content
}

则在场景一下依然崩溃,因为 diff 前后本质上是同一个对象,所以没办法 diff 出差别;但是场景二下是能够 diff 出 updates 的,所以我们在使用 performUpdates 的时候前后要新建元素实例来触发 diff 算法生效。

IGListAdapter 维护的 sectionMapperformUpdates 前并不会干掉真实的列表真实数据,除非用户主动修改列表元素内容。像是上面场景二中那样直接赋新值。可以参考 IGListAdapterperformUpdatesAnimated:completion: 方法来查看 diff 算法中的数据来源(from, to)

# performUpdates 不生效

有的时候执行 performUpdates 不生效,本质上是没有 diff 算法计算出来的结果中没有任何改变(没有insertdeleteupdate)。这时候有两种原因

① 和上面小节中的场景一一样,直接在原实例属性上修改,diff 后没有产生任何修改。

② 新建了实例,但是修改的元素并没有出现在 isEqual 方法对比里,导致 diff 后没有任何修改。

基本上 performUpdates 不生效就是上面两种 CASE。其实本质上这种场景更应该使用 reloadSections API 。

# 总结

performUpdates API 的使用还是需要注意一些事项,最根本的问题还是要搞清楚 diff 算法,以及 IGListKit 是如何维护 diff 算法的双方(from,to)数据的。

我自己的解决方法就是,修改某个元素属性执行更新的时候,新建元素并执行对应修改,最好该元素实现 copy 协议,这样会比较方便或者考虑使用 IGListBindingSectionController

[2023-08-13 更新] 其实像是上面说的 performUpdates 不生效的这种情况,有几种解决方案

  1. 直接通过 reloadSecion: 这种 API 去解决
  2. 是通过使用 IGListBindingSectionController 这种方式实现cell级别的更新
  3. cell 层面的交互后去直接更新视图状态,同时修改对应model的属性。当 cell 滚出可视区域,重新滚回来执行 cellForItem: 方法的时候,会按照修改后的属性去更新视图状态。
  4. 最后一种做法才应该考虑通过新建元素然后执行修改属性的方法去实现。

参考地址:

  1. IGListKit - Modeling and Binding (opens new window)
  2. IGListKit 的基石 - IGListSectionController (opens new window)