UITableView学习笔记一-差量数据源

这篇笔记是介绍基于差量数据源(diffable data source)去更新列表展示的使用说明。对 UITableViewUICollectionView 都生效,这边笔记是用 UITableView 为例子的。

# 普通数据源方式

通常来说我们都是让包含 TableView 实例的视图控制器实现 UITableViewDataSource 协议,实现对应的方法,类似下面这样:

func numberOfSections(in tableView: UITableView) -> Int {
    return data.sections.count
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return data.sections[section].count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Identifier", for: indexPath)
    //config cell
    return cell
}

这种直接通过代理提供数据源的方式使用起来非常简单也比较直观,但是当涉及到一些比较复杂的处理的时候我们经常会遇到错误,比如下面这种报错:

Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (1) must be equal to the number of rows contained in that section before the update (1), plus or minus the number of rows inserted or deleted from that section (0 inserted, 1 deleted) and plus or minus the number of rows moved into or out of that section (0 moved in, 0 moved out).

这种报错的本质其实就是真实的数据源和 UI 视图层视图操作不匹配导致的,比如上面这个报错就是 调用 tableView.insertRows 的时候并没有在数据源上做对应的修改。

很多时候为了避免上面这种视图和数据不匹配的情况,我们在插入数据后直接调用 tableView.reloadData 刷新,但是感觉这样很不优雅,对应的插入删除的动画效果没有了。

总结一下传统的基于代理提供的数据源驱动列表更新的机制有如下两个问题:

  1. 容易出错。
  2. 动画效果不理想。

# 差量数据源方式

为了改进传统的代理数据源驱动方式,苹果提供了一套新的列表数据源方式,即差量数据源方式。基于差量数据源更新的总体思想是:视图展示快照数据,当快照发生变化的时候,调用更新方法,框架帮你做快照之间的diff,最终视图根据diff结果渲染后展示新快照数据。 具体到实际开发中对应到的两个类是:

  1. NSDiffableDataSourceSnapshot (opens new window) :官方解释是「A representation of the state of the data in a view at a specific point in time」。我理解就类似是快照的概念,就某个时间点 UI 表示的真实数据的状态。

    差量数据源(diffable data source)就是使用快照(snapshot)来为 TableView 和 CollectionView 提供数据的。我们使用快照来设置初始化视图展示状态,同样地也用快照来反映视图数据的变化。快照中的数据是由我们要展示的 section 和 item 组成的,我们通过对 section 和 items 的增删改去操作快照。这里的 section 和 items 的概念其实是沿用下来了,还是和之前代理数据源的数据提供方式一致。

  2. UITableViewDiffableDataSource (opens new window) 的官方 API 说明是「The object you use to manage data and provide cells for a table view」,即用来管理数据和提供 cell 的对象。

# Demo

通过一个简单的例子来看一下这两个类是如何协作完成数据展示的:

enum Section {
    case main
} 
struct Note:Hashable {
    var id:Int
    var content:String
}
class ViewController: UIViewController {
    var tableView:UITableView!
    private var dataSource:UITableViewDiffableDataSource<Section, Note>!

    override func viewDidLoad() {
        super.viewDidLoad()
				//create tableview..
        let noteList = [
          Note(id:0, content:"0"),
          Note(id:1, content:"1"),
        ]
        self.dataSource = getDataSource()
        updateData(noteList)
    }
	//更新数据的步骤
	//1. 创建空的 snapshot
	//2. 填充 snapshort 数据
	//3. apply() snapshot 
    func updateData(_ noteList: [Note]) {
      var snapshot = NSDiffableDataSourceSnapshot<Section, Note>()
      snapshot.appendSections([.today])
      snapshot.appendItems(noteList)
      self.dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
    }
	//获取 UITableViewDiffableDataSource 
    func getDataSource() -> UITableViewDiffableDataSource<Section,Note> {
        return UITableViewDiffableDataSource(tableView: self.tableView) { tableView, index, note in
            let cell = UITableViewCell.init(style: .default, reuseIdentifier: "cell")
            cell.textLabel?.text = note.content
            return cell
        }
    }
}

代码说明

  1. UITableViewDiffableDataSource 的创建过程是绑定了 tableView 和一个提供 cell 的回调(cell provider),回调中创建 UITableViewCell 部分和原来 UITableViewDataSource 数据代理方法里的 cellForRow 方法里的逻辑是一样的,而回调的参数提供了必要的数据部分,不用我们再去通过 indexPath 去找数据了。

  2. UITableViewDiffableDataSource 的另外一个最重要的作用是 apply snapshot,这是例子中 UITableViewDiffableDataSourceNSDiffableDataSourceSnapshot 唯一交互的地方。这也就是我们说的 UITableViewDiffableDataSource 用来管理数据。如果我们有多份 snapshot,我们可以通过 UITableViewDiffableDataSource 来管理到底哪个 snapshot 展示到视图。

  3. 不同的视图使用的数据源类型并不一样, 比如对于 UICollectionView 来说,它的数据源提供类是 UICollectionViewDiffableDataSource。但是所有列表视图用到的快照(snapshot)类型是一样的,都是 NSDiffableDataSourceSnapshot

  4. 列表视图更新的基本套路就是三步走:

    1. 创建(create)空快照
    2. 填充(populate)空快照
    3. 应用(apply)空快照
  5. NSDiffableDataSourceSnapshot 对于数据源模型的要求,我们之前说 NSDiffableDataSourceSnapshot 是由 sections 和 items 组成的,所以就是明确 sections 和 items 的类型。NSDiffableDataSourceSnapshot 官方声明如下:

    class UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> : NSObject where SectionIdentifierType : Hashable, SectionIdentifierType : Sendable, ItemIdentifierType : Hashable, ItemIdentifierType : Sendable
    

    SectionIdentifierType, ItemIdentifierType 都必须遵守 Hashable 协议,上面例子中我们 SectionIdentifierType 使用的枚举类型(没有关联值),自动支持枚举,ItemIdentifierType 使用的结构体,结构体的属性都默认支持 Hashable,所以结构体也默认支持。

    Hashable 说明: (opens new window) When you define an enumeration without associated values, it gains Hashable  conformance automatically。For structs whose stored properties are all Hashable the compiler is able to provide an implementation of hash(into:)  automatically.

    同时也要求 SectionIdentifierType, ItemIdentifierType 两个类都必须有唯一 id,这是diff算法的基本要求。

UITableView 变化数据源提供方式不影响 UITableViewDelegate 的实现,比如要实现点击cell打印文字的方法我们可以使用如下的方式,框架提供了 indexPath 转 Item 的方法,所以还是挺方便的。

private var dataSource:UITableViewDiffableDataSource<Section, Note>!
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let item = dataSource.itemIdentifier(for: indexPath)
    print("content \(item?.content)")
}

差量数据源的方式让开发者摆脱了自己去做 diff 的枯燥的工作,更加专心到上层业务逻辑,再也不必担心数据源和视图操作不一致的问题以及动画效果的问题。比较适用列表数据经常变化的场景,如果列表纯作为一个展示的功能的话,两种数据源提供方式都可以的。

参考地址:

  1. WWDC2019-Advances in UI Data Sources (opens new window)
  2. WWDC2020-Advances in diffable data sources (opens new window)
  3. 掘金-iOS DiffableDataSource的使用 (opens new window)
  4. Diffable Data Sources Adoption with Ease (opens new window)