全部文章

从零实现 UITableView 的 Cell 复用

从一千行数据和一个小小的可视窗口出发,用 Swift 重建复用池,理解异步图片错位与状态残留的根源。

如果不做复用,会发生什么

很多人接触列表时,先记住的是规则:“一定要 dequeue,不要每次创建 cell。”但只记规则,很容易在后面遇到复用问题时失去方向。这里先不使用 UITableView,从最直接的方案推导设计。

假设要显示 1,000 个联系人,最容易想到的是在 UIScrollView 中为每条数据创建一个行视图:

let rowHeight: CGFloat = 56

for (index, item) in items.enumerated() {
    let row = RowView(item: item)
    row.frame = CGRect(x: 0,
                       y: CGFloat(index) * rowHeight,
                       width: scrollView.bounds.width,
                       height: rowHeight)
    scrollView.addSubview(row)
}

scrollView.contentSize = CGSize(width: scrollView.bounds.width,
                                height: CGFloat(items.count) * rowHeight)

它确实能滚动,但会付出三笔与数据量一起增长的成本:

  • 内存:1,000 个视图及其标签、图片、图层同时存活,屏幕却只能显示十几行。
  • 首屏时间:出现第一帧前,要分配、配置并布局所有行。数据越多,打开越慢。
  • 无效占用:绝大部分视图在屏幕外,仍然占用内存、留在视图层级中。

通讯录能流畅浏览大量条目,显然没有为每条数据永久保留一个视图。关键在于重新理解屏幕与数据的关系。

屏幕只是数据上的一个窗口

数据可以有一千行,屏幕通常只能容纳 bounds.height / rowHeight 行。以 56 点行高为例,小屏手机大概只需要十几个视图。

视图可以反复扮演不同的数据行,不必与数据一一终身绑定。这个观察自然导出复用池:

  1. 维护当前可见的 cell 集合。
  2. cell 完全离开可视区域后,放回复用池。
  3. 新行进入屏幕时,先从池中取;没有可用 cell 才创建。

可视窗口、离屏 cell 与复用池离开窗口的 cell 进入复用池;新出现的行优先取出这些对象。

窗口填满,再有少量备用对象后,创建数量就会趋于稳定。内存主要取决于屏幕大小,而不是数据总数。

滚动一帧时的五个步骤

用户拖动,contentOffset.y 改变。列表根据偏移和行高求出可见索引范围,把离开范围的 cell 回收,再为新进入范围的行取出 cell、设置数据和 frame,加入可见集合。

实际的池会按 reuseIdentifier 分桶。文本、照片、广告使用不同 cell 类型,不能混用。标识符表达的是一种兼容性约定:“这个桶里的 cell,都能展示这一类行。”

这也带来两个直接结果:首屏之后,分配数量通常会很快稳定;而一个曾经显示第 3 行的 cell,随后可能显示第 40 行。复用对象并不会自动清空它的历史状态。

两代 dequeue API,变化的是谁处理池未命中

iOS 6 之前,dequeue 可能返回 nil,创建逻辑由调用方承担:

var cell = tableView.dequeueReusableCell(withIdentifier: "Row")
if cell == nil {
    cell = UITableViewCell(style: .subtitle, reuseIdentifier: "Row")
}

现代 API 先注册类型,再保证 dequeue 能返回对象:

tableView.register(RowCell.self, forCellReuseIdentifier: "Row")

let cell = tableView.dequeueReusableCell(withIdentifier: "Row",
                                         for: indexPath)

底层思路并没有变化。注册告诉列表如何构造对应标识符的 cell,因此框架可以接管池未命中时的创建工作,不必让每个数据源重复处理。

实现一个 MiniTableView

下面是一个 Swift 4 的最小版本:固定行高、单一 cell 类型、没有 section。它就是 UIScrollView 加上可见集合和复用池。

protocol MiniTableViewDataSource: AnyObject {
    func numberOfRows(in tableView: MiniTableView) -> Int
    func tableView(_ tableView: MiniTableView,
                   configure cell: MiniCell,
                   forRow row: Int)
}

final class MiniTableView: UIScrollView {
    weak var dataSource: MiniTableViewDataSource?
    var rowHeight: CGFloat = 56

    private var visibleCells: [Int: MiniCell] = [:]
    private var reusePool: [MiniCell] = []
    private(set) var createdCellCount = 0

    func reloadData() {
        visibleCells.values.forEach { $0.removeFromSuperview() }
        visibleCells.removeAll()
        reusePool.removeAll()

        let rows = dataSource?.numberOfRows(in: self) ?? 0
        contentSize = CGSize(width: bounds.width,
                             height: CGFloat(rows) * rowHeight)
        setNeedsLayout()
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        guard let dataSource = dataSource else { return }

        let rowCount = dataSource.numberOfRows(in: self)
        guard rowCount > 0 else { return }

        let contentBounds = CGRect(origin: .zero, size: contentSize)
        let visibleRect = bounds.intersection(contentBounds)
        guard !visibleRect.isNull, visibleRect.height > 0 else { return }

        let firstVisible = min(
            rowCount - 1,
            max(0, Int(floor(visibleRect.minY / rowHeight)))
        )
        let lastVisible = min(
            rowCount - 1,
            max(firstVisible,
                Int(floor((visibleRect.maxY - .ulpOfOne) / rowHeight)))
        )

        // 1. Cells whose rows left the window go back to the pool.
        let rowsLeavingWindow = visibleCells.keys.filter {
            $0 < firstVisible || $0 > lastVisible
        }
        for row in rowsLeavingWindow {
            guard let cell = visibleCells.removeValue(forKey: row) else {
                continue
            }
            cell.removeFromSuperview()
            reusePool.append(cell)
        }

        // 2. Rows that entered the window ask the pool first.
        for row in firstVisible...lastVisible where visibleCells[row] == nil {
            let cell = dequeue()
            cell.frame = CGRect(x: 0,
                                y: CGFloat(row) * rowHeight,
                                width: bounds.width,
                                height: rowHeight)
            dataSource.tableView(self, configure: cell, forRow: row)
            addSubview(cell)
            visibleCells[row] = cell
        }
    }

    private func dequeue() -> MiniCell {
        if let cell = reusePool.popLast() {
            cell.prepareForReuse()
            return cell
        }
        createdCellCount += 1
        return MiniCell()
    }
}

一个字典记录可见 cell,一个数组存放备用 cell。dequeue() 优先从数组取对象,createdCellCount 则用于验证创建数量是否真的停止增长。

下面是 1,000 行数据在 iPhone 8 上的一组示例日志。具体数字会随窗口大小和滚动方式变化;需要关注的是分配数量趋于稳定,而非与行数同步增长。

row 0    created: 13
row 87   created: 14
row 412  created: 14
row 999  created: 14

首屏创建 13 个 cell,快速滚动期间又创建一个,后续大部分数据复用这些对象即可。完整的 UITableView 还要处理按类型分桶、section、页眉、动态行高与缓存、选中和编辑状态、动画等问题。

UIKit 的源码不公开,但 Chameleon 这个面向 Mac 的 UIKit 开源重实现采用了相似结构:离开可视区域的 cell 成为复用候选,而不是立刻销毁。

复用的代价:状态会留下来

第 3 行的勾选跑到第 40 行、别处的行意外展开、图片短暂闪出上一条数据的内容,都来自同一个事实:dequeue 得到的对象不是空白对象。

prepareForReuse 是第一道防线:

override func prepareForReuse() {
    super.prepareForReuse()
    avatarView.image = nil
    accessoryType = .none
}

这里重置的是视觉状态,建立中性的起点;真正的数据配置属于数据源。复用时 cell 还不知道自己将展示哪一行,因此不应在这里决定业务内容。

异步图片尤其容易暴露这个问题:第 3 行开始下载头像,滚动后 cell 已用于第 40 行,旧请求才返回。回调不能仅仅因为还持有 cell,就直接更新图片;必须再次确认当前身份:

func tableView(_ tableView: UITableView,
               cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Row",
                                             for: indexPath) as! RowCell
    let item = items[indexPath.row]

    cell.representedURL = item.avatarURL
    imageLoader.load(item.avatarURL) { [weak cell] url, image in
        guard let cell = cell, cell.representedURL == url else {
            return   // the cell has moved on to another row
        }
        cell.avatarView.image = image
    }
    return cell
}

取消旧请求可以减少无用工作,身份检查则保证晚到结果不会写错位置。重建过复用池后,这类 bug 不再神秘:同一个对象确实已经被分配给了另一条数据。

复用能解决什么,不能解决什么

复用降低了逐行创建成本,也避免内存随总行数增长。它不会自动消除复杂行高计算、昂贵的数据配置、阴影与圆角引发的额外渲染,或主线程上的磁盘、网络和大图解码。

estimatedRowHeight 延迟不必要的精确计算,iOS 10 的预取接口则在行接近窗口时提前准备数据。它们与复用池遵循同一种思路:窗口很小,把资源花在窗口附近。

复用不是后来附加的技巧,而是由“可视范围有限、数据可能很大”自然推导出的设计。理解这个模型后,reuseIdentifier、dequeue、prepareForReuse 和异步身份校验就不再是零散规则。