如果不做复用,会发生什么
很多人接触列表时,先记住的是规则:“一定要 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 点行高为例,小屏手机大概只需要十几个视图。
视图可以反复扮演不同的数据行,不必与数据一一终身绑定。这个观察自然导出复用池:
- 维护当前可见的 cell 集合。
- 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 和异步身份校验就不再是零散规则。