深入浅出 IGListKit

研发 @ 字节跳动

作者:大力智能技术团队-客户端 溥心

大多数 iOS 应用中列表都是通过 UICollectionView 实现。虽然它性能强大,API 丰富易用,但依旧存在一些不足。常见的如:

  1. 更新页面时如果调用 reloadData 会造成屏幕闪烁;
  2. 调用 performBatchUpdates(_:completion:) 时手写 updater 难度较大。

IGListKit 正是为了解决上述问题而诞生。

IMG_1.png

简介

整体看下 IGListKit 的架构图:

IMG_2.png

不同于直接输入 datasourceUICollectionViewIGListKit 选择在其上构建一个 adapter,针对不同类型的 object,初始化对应的 section controller,而后者负责构建和维护相应的 cell。同时每个 section controller 同时也是 section 视图,它上面也有 supplementary view 和 decoration view。

使用

  • 首先我们创建一个 adapter,通常是直接在相应的 View Controller 中创建。同时我们需要设置它的 data sourceupdater
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,如提前下载图片。

IMG_3.png

  • 接着我们需要构建自己的 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:):用来比较两个对象

  • 然后我们要将 datamodeladapter 连接起来。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

运行的结果如下图所示:

IMG_4.png

原理

在大致了解了 IGListKit 如何使用后,我们再来看下它的基本原理。用一句话来说明:通过对比前后两个 datasource,计算出 insertremoveupdatemove 四种差量更新,这样去对应地通知 UICollectionViewperformBatchUpdates(_:completion:),这样既不用手写 updater,也可以获得差量更新带来的性能收益。关于 Diff 算法的详细实现可以看这篇文章

核心的 Diff 代码和更新逻辑如下:

IMG_5.png

IMG_6.png

实践

IGListKit 的代码其实相当容易阅读和理解,但是在实践中我们遇到了两个问题。下面就来详细说说问题是什么以及如何解决。

差量更新

首先看一下现场的视频:

GIF_1.gif

我们发现,当回到应用首页后,整个列表因为更新闪烁了一下,似乎差量更新并没有如预期地工作。

分析

在分析这个问题之前,我们首先确定页面结构:

IMG_7.png

对应的 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 的地方:

IMG_8.png

这里的 fromObjectstoObjects 都是一维数组,因此 Diff 发生在 PersonalInfo 上,它是 Section 级别。也就是说,Diff 结果只能在 Section 维度上,因此更新也同样按照 Section 更新,也就是调用 reloadSections(_:)。而这个 Section 就是:

IMG_9.png

所以虽然我们只是更新推荐位的数据,却也会导致左侧蓝色卡片被更新,因为它们同属于一个 Section。

解决

大部分 IGListKit 的教程里,都没有提到如何实现 cell 级别的差量更新,而是推荐将 datasource 打平,也就是每个 section controller 的 numberOfItem() -> Int 保持默认值 1,以此实现 section 到 cell 的 1:1 关系,来模拟 cell 级别的更新。

这样的使用有一个问题,对于我们的应用场景:

IMG_10.png

使用两个 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 使用

首先看如下代码:

IMG_11.png

我们发现,我们没办法改变一个由 struct 组成的数组中的某个元素。这是因为 struct 默认是 immutable,我们没法 in-place 修改它的 元素。在这种情况下,一般想到的办法就是改用 class,通过引用来获得 in-place 修改属性的能力。

这样确实可以工作,但是它会引起新的问题:对于 IGListKit 而言,内部在调用 IGListDiffExperiment 时传入的需要是两个数组实例,而 in-place 地修改则会导致IGListKit 观察不到任何变化(因为前后修改的数组是同一个实例)。

分析

IGListKit 是一个数据驱动的列表,但是它不是双向绑定的响应式列表,这就要求我们任何变动都必须更新到 data source 上并让 adapter 可感知才可以。换句话说,整个数据的流动必须是自上而下的。

一种通用做法是:

  1. element 通知 view model 需要修改的字段
  2. view model 根据通知的 element 查找到对应的 index 并记录
  3. view model 重新构造 data source,并在上步记录的 index 对应的 element 上应用本次修改
  4. 新的 data source 构造完成,通知 IGListKit 进行更新

但是这样依旧很麻烦,我们希望通过一种更直观并高效的方式更新数据。

解决

首先我们需要回顾一下 IGListKit 的 Diff 算法。我们知道 diffIdentifier 用作数据的唯一标识,那么 isEqual(toDiffableObject) 的作用是什么?

IMG_12.png

通过阅读源码,可以看到,通过 diffIdentifier,可以计算得到 movedeleteinsert,而 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 可以强制我们更清晰地组织业务代码,优秀的默认动画,并获得优异的性能。推荐大家使用。

文章分类
iOS
文章标签