复用机制
在我们使用 TableView 以及 CollectionView 的时候,在tableView(_ tableView:, cellForRowAt:)方法中,我们会见到 dequeueReusableCell
let cell = tableView.dequeueReusableCell(withIdentifier: TableViewCell.reuseIdentifier) as? TableViewCell
这是 TableView 的复用机制,是 iOS 开发中非常经典且核心的性能优化设计
- TableView 只会创建屏幕中可显示的 cell 。
- 当我们往下滑时,需要一个新的 cell 放置在列表的下方。此时,我们不去生成新的 cell 而是先从 UITableView 的复用池 里去获取,该池存放了已经生成的 、能够复用的 cell ;如果该池为空,才会主动创建一个新的 cell 。
- 当顶部的 cell 只要完全滑出屏幕的范围,它不会被销毁,相反,它会被放入复用池中。
复用池存储在 UITableView 的内部,每种 reuseIdentifier 对应一个队列,用来存放可复用的 cells。
必须注意的问题
1. UI 状态残留
现象:第一行的 cell 有一个红色的勾选标记。当你滑到第 20 行时,发现第 20 行也有一个红色的勾选,但实际上第 20 行的数据并没有被勾选。
原因:从打印的 cell 地址来看,可以看出第 20 行的 cell,复用了第 1 行的 cell。第 1 行把勾选框设为了 isHidden = false,当配置第 20 行时,没有显示地把勾选框设为 isHidden = true,它就会保留之前的状态。
解决:在 cellForRowAt 中,对 cell 的所有状态进行"全覆盖"重置。
swift
// 错误写法 ❌
if model.isChecked {
cell.checkMark.isHidden = false
}
// 如果 else 没写,复用过来的 cell 如果原本是显示的,这里就会错误显示
// 正确写法 ✅
if model.isChecked {
cell.checkMark.isHidden = false
} else {
cell.checkMark.isHidden = true // 必须重置回默认状态
}

2. prepareForReuse 的正确使用
UITableViewCell 有一个生命周期方法 prepareForReuse() 。当 cell 从屏幕移除准备进入复用池,或者从复用池取出准备再次被使用时,系统会调用这个方法。
- 推荐:在这个方法里重置与数据无关的视图状态,(如:停止动画、取消下载任务、重置 alpha 值、重置背景色)
- 不推荐:在这里配置数据源,因为 cellForRowAt 马上就会覆盖它,这是多余的操作
性能优化
1. 动态高度计算优化
当我们需要让 cell 高度根据内容动态变化时,有两种方式:约束引擎计算或者手动计算。
约束引擎计算 self-sizing cell
- 使用 Autolayout 进行 UI布局约束(要求cell.contentView的四条边都与内部元素有约束关系,保证"内容撑满")。
- 指定 TableView 的 estimatedRowHeight 预估值属性的默认值。
- 指定 TableView的 rowHeight 属性为 UITableViewAutomaticDimension。
UITableView 底层实际是一个 ScrollView,一开始系统需要知道整个列表的 contentSize 总高度。
- 未开启 estimatedRowHeight:系统会调用所有的行 heightForRowAt → 计算成本巨大
- 开启 estimatedRowHeight:系统不再计算所有行的真实高度,而是采用 "估计高度 * 总行数" 来计算 contentSize → 启动时非常快,当 cell 需要显示在屏幕上,再去计算 cell 的实际高度。
手动计算
- 依旧开启 estimatedRowHeight ,减少启动时的计算操作
- 在代理方法
tableView(_:heightForRowAt:)中手动实现根据内容计算高度的方法
问题:每滑动一次,就要重新计算一次,heightForRowAt 频繁被调用,cell 内容复杂时 CPU 占用非常高。
因此我们可以使用缓存,用字典数组来保存第一次计算的结果,对于已经计算过的 cell 就不用重复计算了。
对于 cell 有点击展开 / 收起等多个状态时:我们可以设置多个缓存数组,分别缓存不同的状态下的高度。
| 方法 | 优点 | 缺点 | 性能 |
|---|---|---|---|
| Self-sizing | 不用手写代码计算,适配性强,简单 | AutoLayout 计算比手算慢 | 中等 |
| 手动计算 + 无缓存 | 实现简单 | 滑动卡顿,CPU 计算量大 | 最差 |
| 手动计算 + 缓存 | 计算快,不依赖 AutoLayout 引擎计算 | 代码复杂,需要自己维护缓存 | 最高 |
2. 显示网络数据
场景引入:当 tableView 显示的图片是从网络下载时,如何实现无缝滑动呢?
最容易想到的就是,当用户停下时,向服务器请求数据,提供一个 Loading 控件显示动画,待请求数据返回后,Loading 动画消失,由 UITableView 或者 UICollectionView 控件继续加载这些数据并显示给用户。
但是 App 向服务器请求数据到数据返回这段时间留下了一个空白,如果在网络差的情况下,这段空白的时间将会持续,这给人的体验会很不好。那该如何去避免这种现象呢!或者说我们能否去提前获取到其余的数据,在用户毫无感知的情况下把数据请求过来,看上去就像无缝加载一样呢!
为了改善应用程序体验,在 iOS 10 上,Apple 对 UICollectionView 和 UITableView 引入了 Prefetching API,它提供了一种在需要显示数据之前预先准备数据的机制,旨在提高数据的滚动性能。
其实这个无限滚动并不是真正意义上的永无止尽,严格意义上来讲它是有尽头的,只不过这个功能背后的数据是不可估量的,只有大量的数据做支持才能让应用一直不断的从服务端获取数据。
使用 Prefetching API
-
设置delegate
tableView.prefetchDataSource = self
-
实现 UITableViewDataSourcePrefetching 的协议方法
@protocol UITableViewDataSourcePrefetching <NSObject>
@required
- (void)tableView:(UITableView *)tableView prefetchRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;
@optional
- (void)tableView:(UITableView *)tableView cancelPrefetchingForRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;
@end
第一个函数会基于当前滚动的方向和速度对接下来的 IndexPaths 进行 Prefetch,通常我们会在这里实现预加载数据的逻辑。
第二个函数是一个可选的方法,当用户快速滚动导致一些 Cell 不可见的时候,你可以通过这个方法来取消任何挂起的数据加载操作。
异步下载
当遇到滑动卡顿时,通常是由于任务长时间运行阻碍了 UI 在主线程上的更新,想让主线程有空来响应这类更新事件,第一步就是要将消耗时间的任务交给子线程去执行,避免在获取数据时阻塞主线程。因此,我们可以将图片的下载异步去进行。
此外,还有一个小技巧。
我们都知道 UITableView 实例化 Cell 的方法是:tableView:cellForRowAtIndexPath: ,会在这个方法里面去进行数据绑定然后更新 UI,其实这样做是一种比较低效的行为,因为这个方法需要为每个 Cell 调用一次,它应该快速的执行并返回重用 Cell 的实例,不要在这里去执行数据绑定,因为目前在屏幕上还没有 Cell。我们可以在 tableView:willDisplayCell:forRowAtIndexPath: 这个方法中进行数据绑定,这个方法在显示cell之前会被调用。
设置缓存
我们可以加入缓存 NSCache 对象,来对图片做一个缓存,在下载开始的时候,检查有没有命中缓存,如果命中则直接返回图片,否则重新下载图片,并添加到缓存中。这样能很好的降低内存和磁盘的占用。
NSCache 是 iOS 提供的自动管理内存的缓存类。当内存吃紧时,系统会自动清理。比 Dictionary 更安全,也不会爆内存。
3. 按需加载
虽然第二个 Perfetching 能够提前加载网络数据快速显示出来,但是如果当我们非常非常快地滑动时,又立刻停止,如何以最快的速度显示当前停留的 cell 的图片?
这时候其实会有大量的 cell 对象被创建、被重用,但其实我们可能只是去浏览列表停止的那一页的上下一定范围内的信息,前面快速划过的那些信息对我们来说都是无用的。
此时我们可以通过ScrollView的代理方法来按需加载内容。
代码链接:https://github.com/0Kathleen/iOS-Smooth-ScrollingTable-Demo.git
两级缓存
ImageCache 类:内存缓存。
- 是线程安全的
- 当系统内存紧张时,NSCache 会自动丢弃部分缓存对象,防止 App 因内存过高被系统杀掉。如果是普通的 Dictionary,则需要手动处理内存警告
在 init 中还配置了 URLSession 的 urlCache磁盘缓存
- 如果有磁盘缓存,就直接用磁盘的,不发网络请求;否则加载。
- 即便 App 重启,只要磁盘缓存还在且没过期,图片就不用重新下载。
滑动流程
整体流程
- 刚进入页面 (静止)
- TableView 加载,cellForRow 和 willDisplay 被调用
- isScrollingFast 为 false
- 屏幕上的 5-6 张图片开始以高优先级 1.0 下载并显示
- 慢速滑动时
- 手指慢慢推,速度 < 2000
- 预加载:prefetchRowsAt 被触发,提前下载后面第 10-15 行的图(存入缓存)
- 显示:当第 10 行滑入屏幕,willDisplay 触发。loadImageIfNeeded 命中缓存,直接显示
- 移出:第 1 行滑出屏幕,didEndDisplaying 触发,如果它还没下完,取消下载
- 快速滑动
- 手指用力一划,速度 > 2000,isScrollingFast 设为 true
- 大量 Cell 快速经过屏幕,willDisplay 被疯狂调用。进入 loadImageIfNeeded -> 发现没缓存 -> 发现 isScrollingFast == true -> 直接 Return,不发请求。
- 滑动结束
- 滑动慢慢减速,最后停在第 500 行
- scrollViewDidEndDecelerating 触发,重置 isScrollingFast = false
- 调用 loadVisbleCellsImmediately(),遍历当前屏幕上的第 500-506 行,以最高优先级 (1.0) 强制发起请求
- 图片瞬间加载出来
参考:
https://blog.csdn.net/ScheenDuan/article/details/143114983?spm=1001.2014.3001.5502
https://blog.csdn.net/yueliangmua/article/details/135025450?spm=1001.2014.3001.5502