赞
踩
最近基本将我的第二个项目完结,之后会记录一些源码的学习以及优化项目的一些方法,而在iOS中UITableView
是最为常用的一种控件,今天我们就从UITableView
性能优化开始讲起
UITableView 的优化本质在于提高滚动性能和减少内存使用
,以保证流畅的用户体验,从计算机层面来讲,其核心本质为降低 CPU和GPU 的工作来提升性能
CPU
:对象的创建和销毁、对象属性的调整、布局计算、文本的计算和排版、图片的格式转换和解码、图像的绘制
GPU
:接收提交的纹理和顶点描述、应用变换、混合并渲染、输出到屏幕
App主线程在CPU
中显示计算内容,比如视图的创建,布局的计算,图片解码,文本绘制,然后我们的CPU
会将计算好的内容提交到GPU
中进行变换,合成,渲染,这其中也包括我们常说的离屏渲染
在开发中,CPU
与GPU
任何一个压力过大都会导致掉帧
在一些用不到事件处理的地方我们可以使用CALayer
,而非UIView
,CALayer
相比于UIView
更加接近底层
同时实际上每一个UIView
都有一个CALayer
的属性,其实我们可以把UIView
理解为CALayer的高级
封装,他们的本质区别在于是否能响应事件,这也是CALayer性能优于UIView的主要原因
CALayer 更接近于底层的渲染引擎。UIView 的渲染最终也是由底层的 CALayer 来完成的,但直接使用 CALayer 可以减少一些由于 UIView 带来的额外计算和抽象层次。
_imageLayer = [CALayer layer];
_imageLayer.contentsGravity = kCAGravityResizeAspectFill;
_imageLayer.masksToBounds = YES;
// 假定图片大小为100x100,实际开发中应根据需要调整
_imageLayer.frame = CGRectMake((frame.size.width - 100) / 2, 20, 100, 100);
[self.layer addSublayer:_imageLayer];
我们这里的耗时操作包括:
1️⃣ 不读取文件 / 写入文件;
2️⃣ 尽量少用 addView 给 Cell 动态添加 View,可以初始化时就添加,然后通过 hide 来控制是否显示
因为我们在滑动我们的UITableView
中会不断调用cellForRowAtIndexPath
,这一点到后面会展开讲
这个部分属于是老生常谈了,简单来讲就是我们的UITableView
只会创建比一个屏幕所有显示的cell+1
个单元格,当当前的cell划出屏幕时,我们的cell
并不会销毁,而是会将其存入我们的复用池
当要显示某一个位置的cell时首先会去复用池中查找,如果找不到才会重新创建,而不是每一次都进行重新创建,这样就极大地减少了内存开销
这里之前也写过博客总结,不再赘述
【iOS】自定义cell及其复用机制
我们这里讲提前计算好布局的知识之前,我们需要先来了解一下tableView
代理方法执行顺序
这一篇博客中有比较详细的介绍tableView代理方法执行顺序
如果我们设定了预估行高estimatedRowHeight
,我们的Tableview会每一次先调用cellForRow
又调一次对应的heightForRow
方法。
如果我们并没有设定estimatedRowHeight
,则会先遍历一次每个cell的tableView:heightForRowAtIndexPath:
获取总的高度值 ,然后每调用一次cellForRow
的时候再调用一次对应的heightForRow
。
但因为iOS11中的tableView的estimatedRowHeight
默认值由原来的0变为UITableViewAutomaticDimension
(打印出来为-1),因此我们可以大致认为我们每一次先调用cellForRow
后会再调一次对应的heightForRow
方法。
也就是说我们的Tableview一定是先调用
cellForRow
方法,然后再调用heightForRow
方法
了解了这件事情与tableView
的复用机制之后,我们再回头看我们的cellForRow
与heightForRow
方法,我们知道当我们滑动tableview
时就不断地调用这两个方法,因此这两个方法是性能优化的关键
UITableViewCell高度计算主要分为两种,一种固定高度,另外一种动态高度。
self.tableView.rowHeight = 88;
这个属性是我们的固定高度,对于定高需求的表格,强烈建议使用这种方法来避免不必要的高度计算以及调用
另一种方式就是实现 UITableViewDelegate 中的:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
// return xxx
}
需要注意的是,实现了这个方法后,rowHeight
的设置将无效。所以,这个方法适用于具有多种 cell 高度的UITableView
。
这是一个估算行高的属性,对于动态计算行高,这里有多种方法,但核心还是通过设置预算高度和estimatedRowHeight = UITableViewAutomaticDimension
,然后用AutoLayout对控件进行约束达到撑开cell
的目的。
但是这也不可避免地加大了内存开销,因为AutoLayout
最终需要转成frame
这里撑开cell达到动态计算行高在之前的博客中也有讲过,这里直接给链接【iOS】实现评论区展开效果
我们通过estimatedRowHeight
与AutoLayout
大大简化了我们动态计算行高的过程,同时我们需要尽可能精确估计estimatedRowHeight
的范围,即使面对种类不同的 cell,我们依然可以使用简单的 estimatedRowHeight
属性赋值,只要整体估算值接近就可以,比如大概有一半 cell 高度是 44, 一半 cell 高度是 88, 那就可以估算一个 66,基本符合预期。尽可能精确的估算可以使初次加载和滚动表格时更加流畅
讲解完cell的两种高度计算方法,我们来分析一下这两种方法的计算时机
如果你为 UITableView
的 rowHeight
属性指定了一个具体的数值,那么所有的 cell
都将使用这个固定的高度,无需进行任何计算。
计算时机:在你设置 rowHeight
属性的时候或- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath;
确定,之后不再进行计算。
当你实现了 UITableViewDelegate
的 -tableView:heightForRowAtIndexPath:
方法并返回特定的高度值时,这个方法会在表格视图需要知道某个 cell 的高度时被调用
计算时机:有两个相关的阶段:
估算阶段:通过设置 estimatedRowHeight,UITableView 使用这个估算值来快速计算整个表格的滚动范围。这个估算值不需要精确匹配每个 cell 的实际高度,但应接近平均高度以优化性能。
self.pageTwoView.tableView.rowHeight = UITableViewAutomaticDimension;
self.pageTwoView.tableView.estimatedRowHeight = 200.0;
精确计算阶段:当 cell 即将显示在屏幕上时,UITableView 根据 Auto Layout 约束来计算 cell 的实际高度。这通常发生在滚动过程中,当新的 cell 即将进入可视区域时。
这个阶段通常发生在-tableView:heightForRowAtIndexPath:
方法之后,tableView:willDisplayCell:forRowAtIndexPath:
之前。
综上所述,cell的高度计算时机主要取决于如何设置UITableView
的行高策略
同时这里又引出了一个问题,如果我们在设置estimatedRowHeight
后频繁滑动我们的Tableview
,我们的heightForRowAtIndexPath
方法就会不断被调用来计算我们的动态高度,这里又引出我们优化UITableview
的一种新的思路,那就是缓存高度
我们在前面已经说到了我们cell高度的计算有两种方式:
rowHeight
设置行高,避免不必要的高度计算。Autolayout
+ estimatedRowHeight
动态计算行高,这样可以使开发者更好去调整动态的cell的高度,但是这样就避免不了在滑动时对已经加载过的cell进行重复计算我们是否可以通过一种方式可以让已经计算过的动态cell的高度不再重复计算,这就需要到我们的缓存高度
存储每个 cell 的计算过的高度值,并在表格请求这些高度时重用这些值,而不是重新计算。
首先我们要来回顾一下我们UITaleview
协议的执行顺序
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *) indexPath
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath;
这三个方法是按顺序执行的,我们的高度缓存以这三个方法展开
同时这里着重讲一下第三个方法,第三个方法调用的时候我们的cell已经配置完毕,因此我们可以直接在方法中得到cell高度进行缓存,缓存之后下一次再碰到相同的cell直接从我们的缓存中将高度取出来即可
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { if (indexPath.section == 0) { return 140; } else { NSString *cacheKey = [NSString stringWithFormat:@"%ld-%ld", (long)indexPath.section, (long)indexPath.row]; // 检查缓存 NSNumber *cachedHeight = [self.heightCache objectForKey:cacheKey]; if (cachedHeight) { NSLog(@"存在缓存%@", cacheKey); return cachedHeight.doubleValue; } else { // 缓存行高 return UITableViewAutomaticDimension; } } }
首先我们在heightForRowAtIndexPath
代码中检查是否有缓存,如果没有我们则返回UITableViewAutomaticDimension
来自动计算行高,有的话则返回cachedHeight.doubleValue
来避免重新计算
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
CGFloat cellHeight = cell.frame.size.height;
//添加缓存
NSString *cacheKey = [NSString stringWithFormat:@"%ld-%ld", (long)indexPath.section, (long)indexPath.row];
[self.heightCache setObject:@(cellHeight) forKey:cacheKey];
}
然后我们在willDisplayCell
中获取行高并将其加入缓存,当我们滑动到已经加载过的cell时我们的heightForRowAtIndexPath
方法就会先检查缓存中是否有已经存入的高度,从而避免了重新计算导致内存开销
当我们对我们的TableView添加数据时我们的缓存也会随之添加数据,这个方法十分容易实现
但是对于删除数据源时,这个就比较复杂了
当删除一个 indexPath 为 [0:5] 的 cell 时,要使[0:0] ~ [0:4] 的高度缓存不受影响,而 [0:5] 后面所有的缓存值都向前移动一个位置。
这个方法是如何实现的在下方的库中有具体实现,后面看源码的时候会加以补充
也许到了上面这一步有些人已经觉得优化地比较OK了,但是这里笔者再提出一个问题:
我们使用缓存高度这个方法时是否必须要将所有的cell都先遍历一遍,然后将其存入我们的缓存模型
如果仅仅按照上面的方法,这个问题是肯定的,因为没有呈现在屏幕上的cell并没有存入我们的缓存模型,当我们一个个滑动到对应cell时,对应的cell才逐步加入到缓存模型中
我们是否可以实现将没有呈现在屏幕上的cell也悄无声息地加入我们的缓存模型中呢
这里笔者在查阅资料时发现了一个第三方库很好地解决了这个问题,尽管这个库已经很久没有维护了,但是他其中涉及到RunLoop
的思想十分适合学习
FDTemplateLayoutCell
在大佬的博客中,他们RunLoop空闲时间执行预缓存任务
其中说到:
FDTemplateLayoutCell
的高度预缓存是一个优化功能,它要求页面处于空闲状态时才执行计算,当用户正在滑动列表时显然不应该执行计算任务影响滑动体验。
也就是说当我们的页面静止时,我们可以对我们的高度进行预缓存,这样既不会影响用户的使用体验,也优化了UITableView的性能
因为笔者还未系统学习RunLoop,这里简单讲一些自己对这个库的实现原理的理解
这里具体的实现在系统学习RunLoop
之后会回头学习补充
对于比较简单的cell,不涉及到动态高度变化,我们可以直接通过Frame设置尺寸,因为使用AutoLayout
或是Masonry
进行布局时会消耗更多CPU资源去计算尺寸
就是异步在画布上绘制内容,将复杂的绘制过程放到后台线程执行,然后在主线程显示
这里的原理还没有了解,后面系统学习GCD等知识后回来补充
iOS-UIView异步绘制
(1)滑动操作时,只显示目标范围内的cell内容,显示过的超出目标范围内之后则进行清除;
核心思想:当用户滑动表格或集合视图时,只为屏幕范围内可见的单元格(cell)加载内容。一旦这些单元格滚动出屏幕变得不可见,与它们关联的资源(如图像数据)应该被清除或释放,以避免不必要的内存占用。
实现方法:利用 UITableView
或 UICollectionView
的代理方法来跟踪哪些单元格是可见的。当单元格即将出现在屏幕上时(例如在 tableView:willDisplayCell:forRowAtIndexPath:
方法中),加载并显示其内容。
当单元格滑出屏幕变得不可见时(例如在tableView:didEndDisplayingCell:forRowAtIndexPath:
方法中),可以释放或清除这些单元格的额外资源。
(2)滑动过程中,不加载显示图片,停止时才加载显示图片;
核心思想:当快速滚动时用户无法详细查看这些图片,这样就避免大量的资源加载与绘制操作,当停止滚动时才会加载显示在当前屏幕的图片
实现方法:通过监听 UIScrollViewDelegate
的 scrollViewDidScroll
: 和 scrollViewDidEndDecelerating
: 方法来判断滚动状态。在 scrollViewDidScroll
: 中暂停加载图片
在 scrollViewDidEndDecelerating
:(滚动停止)和 scrollViewDidEndDragging:willDecelerate:
(拖拽停止)方法中恢复加载。对于图片加载,可以使用异步加载和缓存机制,如使用第三方库 SDWebImage 或 Kingfisher 时,它们提供的 API 支持这种按需加载策略。
(1)使用异步子线程处理,然后再返回主线程操作;
(2)图片缓存处理,避免多次处理操作;
(3)图片圆角处理时,设置 layer 的 shouldRasterize 属性为 YES,可以将负载转移给 CPU;
第七部分与这一部分内容在学习玩SDWebImage的源码后会加以补充
GPU主要负责渲染与绘制图形,因此在这个层面我们着重讲一下iOS中的离屏渲染对UITableView
性能产生的影响
在OpenGL中,GPU对屏幕的渲染有以下两种方式:
On-Screen Rendering
:意思是当前屏幕渲染,指的是GPU的渲染操作是在当前用于显示的屏幕缓冲区进行
Off-Screen Rendering
:意思就是我们说的离屏渲染了,指的是GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。
相比于当前屏幕渲染,离屏渲染的代价是很高的,主要体现在两个方面:
光栅化:layer.shouldRasterize
= YES
遮罩:layer.mask
圆角:同时设置 layer.masksToBounds = YES 和 layer.cornerRadius > 0
阴影:layer.shadowlayer.allowsGroupOpacity = YES 和 layer.opacity != 1
重写drawRect
方法
使用贝塞尔曲线UIBezierPath和Core Graphics框架画出一个圆角
UIImageView *imageView = [[UIImageView alloc]initWithFrame:CGRectMake(100, 100, 100, 100)];
imageView.image = [UIImage imageNamed:@"myImg"];
//开始对imageView进行画图
UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 1.0);
//使用贝塞尔曲线画出一个圆形图
[[UIBezierPath bezierPathWithRoundedRect:imageView.bounds cornerRadius:imageView.frame.size.width] addClip];
[imageView drawRect:imageView.bounds];
imageView.image = UIGraphicsGetImageFromCurrentImageContext();
//结束画图
UIGraphicsEndImageContext();
[self.view addSubview:imageView];
对于shadow,如果图层是个简单的几何图形或者圆角图形,我们可以通过设置shadowPath来优化性能,能大幅提高性能
imageView.layer.shadowColor = [UIColor grayColor].CGColor;
imageView.layer.shadowOpacity = 1.0;
imageView.layer.shadowRadius = 2.0;
UIBezierPath *path = [UIBezierPath bezierPathWithRect:imageView.frame];
imageView.layer.shadowPath = path.CGPath;
离屏渲染这部分后面系统学习后会单开博客讲解,这里只讲个大概
UITableView的性能优化涉及到了许多层面,下到底层的Layer属性,上到第三方库SDWebImage与RunLoop,这些东西的实现都十分巧妙,还有很长一段路需要学习
这里笔者写一下Tableview 性能优化方法总览
heightForRowAtIndexPath:
是调用最频繁的方法,我们围绕这个方法展开,通过避免重复计算来减少我们的内存开销。当行高固定时使用固定行高,不固定时缓存一次后返回固定行高)借鉴博客:
iOS tableView 优化
优化UITableViewCell高度计算的那些事
UITableView 优化
swift5.x tableViewCell自适应高度及高度缓存(systemLayoutSizeFitting)
iOS UITableView性能优化
iOS 保持界面流畅的技巧(转载)
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。