转载:TableView性能优化
原文链接:https://juejin.cn/post/6955731915672387592
tableView性能优化
Cell重用、标识重用
使用 static
修饰重用标识名称能够保证这个标识只会创建一次,提高性能。接着调用dequeueReusableCellWithIdentifier:方法
获取缓存池中的Cell
。如果没有就调用 initWithStyle:ReusIdentifier:方法
创建一个新的Cell
。注意事先需要调用registerNib/registerClass方法
为 TableView
注册一下重用标识。
动态高度
我们需要实现它的代理,来给出高度:
objectivec复制代码- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {// return xxx
}
这个代理方法实现后,上面的 rowHeight
的设置将会变成无效。在这个方法中,我们需要提高cell高度
的计算效率,来节省时间。
自从iOS8
之后有了 self-sizing cell
的概念,cell
可以自己算出高度,使用self-sizing cell
需要满足以下三个条件:
(1)使用 Autolayout
进行 UI
布局约束(要求cell.contentView
的四条边都与内部元素有约束关系)。
(2)指定 TableView
的 estimatedRowHeight属性
的默认值。
(3)指定 TableView的rowHeight
属性为 UITableViewAutomaticDimension
。
ini复制代码- (void)viewDidload {self.myTableView.estimatedRowHeight = 44.0;self.myTableView.rowHeight = UITableViewAutomaticDimension;
}
除了提高cell高度
的计算效率之外,对于已经计算出的高度,我们需要进行缓存,对于已经计算过的高度,没有必要进行计算第二次。
减少视图的数目
我们在 cell
上添加系统控件的时候,实际上系统都会调用底层的接口进行绘制,大量添加控件时,会消耗很大的资源并且也会影响渲染的性能。当使用默认的 UITableViewCell
并且在它的 ContentView
上面添加控件时会相当消耗性能。所以目前最佳的方法还是继承 UITableViewCell
,并重写drawRect方法
。
重绘操作仍然在 drawRect方法
中完成,但是苹果不建议直接调用 drawRect方法
,当然如果你强直直接调用此方法,当然是没有效果的。苹果要求我们调用UIView类中的 setNeedsDisplay方法
,则程序会自动调用 drawRect方法
进行重绘。(调用 setNeedsDisplay
会自动调用 drawRect
)。
使用hidden隐藏图层
避免动态添加图层。在初始化cell
的时候一并将所有图层预先创建好,通过hidden属性
控制子图层的显示或隐藏,因为单纯的显示操作要比创建快的多。
在快速滚动时考虑使用界面外壳
当用户快速滚动列表视图时,虽然使用了所有的优化,但视图的重用和渲染仍然需要超过 16 毫秒,还有可能出现偶发的丢帧现象,从而导致不流畅的体验。
在这些情况下,使用一个界面外壳是一个较好的选择,外壳可以被预先定义,它的唯一目的就是告诉终端用户这些部分即将展示一些数据。当滚动速度降低,并低于阈值时,刷新最终的视图并填充数据。
你可以使用与列表视图相关联的 panGestureRecognizer 属性获取速度值。
// 列表视图的速度
-(void)scrollViewDidScroll:(UIScrollView *)scrollView { CGPoint velocity = [tableView.panGestureRecognizer velocityInView:self.view]; self.velocity = velocity;
}
-(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
if(fabs(self.velocity.y) > 2000) { //返回界面外壳} else {//返回真正的单元格 }
}
避免离屛渲染。
开启离屛渲染的代价就是需要新开辟一块新的缓冲区,在渲染的过程中还会多次的切换上下文,这些都是很消耗性能的。以下情况均会造成离屛渲染:
1. shadows(阴影)
其原因在于需要显示在所有layer
内容的下方,因此必须被渲染在先。但此时阴影的本体(layer
和其子layer
)都还没有被组合到一起,只能另外申请一块内存,把本体内容都先画好,再根据渲染结果的形状,添加阴影到帧缓冲区frame buffer
,最后把内容画上去。不过如果我们能够预先告诉CoreAnmation
(通过 shadowPath属性
)阴影的几何形状,那么阴影当然可以先被独立渲染出来,不需要依赖layer本体
,也就不再需要离屏渲染了。
2. 设置了组透明度为YES,并且透明度不为1的layer(不透明)
产生离屏渲染的条件是layer.opacity != 1.0
并且有 子layer
或者背景图。alpha
并不是分别应用在每一层之上,而是只有到 整个layer树
画完之后,再统一加上alpha,最后和底下其他layer
的像素进行组合。显然也无法通过一次遍历就得到最终结果。
3. masks(遮罩)
我们知道mask是应用在layer和其所有子layer的组合之上的,而且可能带有透明度,那么其实和group opacity的原理类似,不得不在离屏渲染中完成。
4. cornerRadius+clipsToBounds
容器的子layer
因为父容器有圆角,那么也会需要被裁剪,而这时它们还在渲染队列中排队,尚未被组合到一块画布上,自然也无法统一裁剪,不得已只能另开一块内存来操作。而如果只是设置cornerRadius
,并不会触发离屏渲染。
5. shouldRasterize(光栅化)
如果layer
不是静态的,我们更新已光栅化的layer
,会造成大量的离屏渲染。 例如UITableViewCell
因为复用的原因,重绘是很频繁的。如果此时设置了光栅化,会造成大量离屏渲染,降低性能。 不要过度使用,系统限制了缓存的大小为 2.5 * Screen Size
。超出缓存之后,同样会造成大量的离屏渲染。 离屏渲染缓存内容有时间限制,被光栅化的图片(即缓存内容)如果超过100ms没有被使用,那么它就会丢弃,无法进行复用。(所以光栅化只能用在图像内容不变的前提下,且只对连续不断使用的图片进行缓存:用于避免静态内容的复杂特效的重绘,例如UIBlurEffect
用于避免多个View
嵌套的复杂View
的重绘。)
6. edge antialiasing(抗锯齿)
分页加载数据,预先异步请求数据 - Prefetching API
在 viewDidLoad
中先请求网络数据来获取一些初始化数据,然后再利用 UITableView
的 Prefetching API
来对数据进行预加载,从而来实现数据的无缝加载。
UITableViewDataSourcePrefetching 协议
// this protocol can provide information about cells before they are displayed on screen.@protocol UITableViewDataSourcePrefetching <NSObject>@required// indexPaths are ordered ascending by geometric distance from the table view
- (void)tableView:(UITableView *)tableView prefetchRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;@optional// indexPaths that previously were considered as candidates for pre-fetching, but were not actually used; may be a subset of the previous call to -tableView:prefetchRowsAtIndexPaths:
- (void)tableView:(UITableView *)tableView cancelPrefetchingForRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;@end
第一个函数会基于当前滚动的方向和速度对接下来的 IndexPaths
进行 Prefetch
,通常我们会在这里实现预加载数据的逻辑。
第二个函数是一个可选的方法,当用户快速滚动导致一些 Cell
不可见的时候,你可以通过这个方法来取消任何挂起的数据加载操作,有利于提高滚动性能, 在下面我会讲到。
实现这俩个函数的逻辑代码为:
swift复制代码extension ViewController: UITableViewDataSourcePrefetching {// 翻页请求func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {let needFetch = indexPaths.contains { $0.row >= viewModel.currentCount}if needFetch {// 1.满足条件进行翻页请求indicatorView.startAnimating()viewModel.fetchImages()}for indexPath in indexPaths {if let _ = viewModel.loadingOperations[indexPath] {return}if let dataloader = viewModel.loadImage(at: indexPath.row) {print("在 \(indexPath.row) 行 对图片进行 prefetch ")// 2 对需要下载的图片进行预热viewModel.loadingQueue.addOperation(dataloader)// 3 将该下载线程加入到记录数组中以便根据索引查找viewModel.loadingOperations[indexPath] = dataloader}}}func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]){// 该行在不需要显示的时候,取消 prefetch ,避免造成资源浪费indexPaths.forEach {if let dataLoader = viewModel.loadingOperations[$0] {print("在 \($0.row) 行 cancelPrefetchingForRowsAt ")dataLoader.cancel()viewModel.loadingOperations.removeValue(forKey: $0)}}}
}
最后,再加上俩个有用的方法该功能就大功告成了:
// 用于计算 tableview 加载新数据时需要 reload 的 cellfunc visibleIndexPathsToReload(intersecting indexPaths: [IndexPath]) -> [IndexPath] {let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows ?? []let indexPathsIntersection = Set(indexPathsForVisibleRows).intersection(indexPaths)return Array(indexPathsIntersection)}// 用于确定该索引的行是否超出了目前收到数据的最大数量func isLoadingCell(for indexPath: IndexPath) -> Bool {return indexPath.row >= (viewModel.currentCount)}
滑动TableView时,按需加载内容
有些情况下我们可能会去快速的滑动列表,这时候其实会有大量的cell对象被创建、被重用,但其实我们可能只是去浏览列表停止的那一页的上下一定范围内的信息,前面快速划过的那些信息对我们来说都是无用的。此时我们可以通过ScrollView的代理方法
scrollViewWillEndDragging: withVelocity: targetContentoffset:
来按需加载内容。
#pragma mark - UIScrollViewDelegate
//按需加载 - 如果目标行与当前行相差超过指定行数,只在目标滚动范围的前后指定3行加载。
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset { NSIndexPath *targetPath = [_myTableView indexPathForRowAtPoint:CGPointMake(0, targetContentOffset->y)]; NSIndexPath *firstVisiblePath = [[_myTableView indexPathsForVisibleRows] firstObject]; NSInteger skipCount = 8; if (labs(firstVisiblePath.row - targetPath.row)> skipCount) { NSArray *temp = [_myTableView indexPathsForRowsInRect:CGRectMake(0, targetContentOffset->y, _myTableView.frame.size.width, _myTableView.frame.size.height)]; NSMutableArray *arr = [NSMutableArray arrayWithArray:temp]; if (velocity.y<0) { NSIndexPath *indexPath = [temp lastObject]; if (indexPath.row+33) { [arr addObject:[NSIndexPath indexPathForRow:indexPath.row-3 inSection:0]]; [arr addObject:[NSIndexPath indexPathForRow:indexPath.row-2 inSection:0]]; [arr addObject:[NSIndexPath indexPathForRow:indexPath.row-1 inSection:0]]; } } [_dataList addObjectsFromArray:arr]; }
}
targetContentOffset
是 TableView
减速到停止的地方, velocity
表示速度向量。
如何避免滚动时的卡顿: 异步化UI,不要阻塞主线程
当你遇到滚动卡顿的应用程序时,通常是由于任务长时间运行阻碍了 UI
在主线程上的更新,想让主线程有空来响应这类更新事件,第一步就是要将消耗时间的任务交给子线程去执行,避免在获取数据时阻塞主线程。
苹果提供了很多为应用程序实现并发的方式,例如 GCD
,我在这里对 Cell
上的图片进行异步加载使用的就是它。 代码如下:
swift复制代码class DataLoadOperation: Operation {var image: UIImage?var loadingCompleteHandle: ((UIImage?) -> ())?private var _image: ImageModelprivate let cachedImages = NSCache<NSURL, UIImage>()init(_ image: ImageModel) {_image = image}public final func image(url: NSURL) -> UIImage? {return cachedImages.object(forKey: url)}override func main() {if isCancelled {return}guard let url = _image.url else {return}downloadImageFrom(url) { (image) inDispatchQueue.main.async { [weak self] inguard let ss = self else { return }if ss.isCancelled { return }ss.image = imagess.loadingCompleteHandle?(ss.image)}}}// Returns the cached image if available, otherwise asynchronously loads and caches it.func downloadImageFrom(_ url: NSURL, completeHandler: @escaping (UIImage?) -> ()) {// Check for a cached image.if let cachedImage = image(url: url) {DispatchQueue.main.async {print("命中缓存")completeHandler(cachedImage)}return}URLSession.shared.dataTask(with: url as URL) { data, response, error inguardlet httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200,let mimeType = response?.mimeType, mimeType.hasPrefix("image"),let data = data, error == nil,let _image = UIImage(data: data)else { return }// Cache the image.self.cachedImages.setObject(_image, forKey: url, cost: data.count)completeHandler(_image)}.resume()}
}
在willDisplayCell:forRowAtIndexPath:代理方法中绑定数据
那具体如何使用呢!别急,听我娓娓道来,这里我再给大家一个小建议,大家都知道 UITableView
实例化 Cell
的方法是:tableView:cellForRowAtIndexPath:
,相信很多人都会在这个方法里面去进行数据绑定然后更新 UI
,其实这样做是一种比较低效的行为,因为这个方法需要为每个 Cell
调用一次,它应该快速的执行并返回重用 Cell
的实例,不要在这里去执行数据绑定,因为目前在屏幕上还没有 Cell
。我们可以在 tableView:willDisplayCell:forRowAtIndexPath:
这个方法中进行数据绑定,这个方法在显示cell
之前会被调用。
为每个 Cell
执行下载任务的实现代码如下:
swift复制代码 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {guard let cell = tableView.dequeueReusableCell(withIdentifier: "PreloadCellID") as? ProloadTableViewCell else {fatalError("Sorry, could not load cell")}if isLoadingCell(for: indexPath) {cell.updateUI(.none, orderNo: "\(indexPath.row)")}return cell}func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {// preheat image ,处理将要显示的图像guard let cell = cell as? ProloadTableViewCell else {return}// 图片下载完毕后更新 celllet updateCellClosure: (UIImage?) -> () = { [unowned self] (image) incell.updateUI(image, orderNo: "\(indexPath.row)")viewModel.loadingOperations.removeValue(forKey: indexPath)}// 1. 首先判断是否已经存在创建好的下载线程if let dataLoader = viewModel.loadingOperations[indexPath] {if let image = dataLoader.image {// 1.1 若图片已经下载好,直接更新cell.updateUI(image, orderNo: "\(indexPath.row)")} else {// 1.2 若图片还未下载好,则等待图片下载完后更新 celldataLoader.loadingCompleteHandle = updateCellClosure}} else {// 2. 没找到,则为指定的 url 创建一个新的下载线程print("在 \(indexPath.row) 行创建一个新的图片下载线程")if let dataloader = viewModel.loadImage(at: indexPath.row) {// 2.1 添加图片下载完毕后的回调dataloader.loadingCompleteHandle = updateCellClosure// 2.2 启动下载viewModel.loadingQueue.addOperation(dataloader)// 2.3 将该下载线程加入到记录数组中以便根据索引查找viewModel.loadingOperations[indexPath] = dataloader}}}
对预加载的图片进行异步下载(预热):
swift复制代码func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {let needFetch = indexPaths.contains { $0.row >= viewModel.currentCount}if needFetch {// 1.满足条件进行翻页请求indicatorView.startAnimating()viewModel.fetchImages()}for indexPath in indexPaths {if let _ = viewModel.loadingOperations[indexPath] {return}if let dataloader = viewModel.loadImage(at: indexPath.row) {print("在 \(indexPath.row) 行 对图片进行 prefetch ")// 2 对需要下载的图片进行预热viewModel.loadingQueue.addOperation(dataloader)// 3 将该下载线程加入到记录数组中以便根据索引查找viewModel.loadingOperations[indexPath] = dataloader}}}
取消 Prefetch
时,cancel
任务,避免造成资源浪费
swift复制代码func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]){// 该行在不需要显示的时候,取消 prefetch ,避免造成资源浪费indexPaths.forEach {if let dataLoader = viewModel.loadingOperations[$0] {print("在 \($0.row) 行 cancelPrefetchingForRowsAt ")dataLoader.cancel()viewModel.loadingOperations.removeValue(forKey: $0)}}}