绪论
本文对于网上讲烂的东西,尽量不再赘述。遇到的时候尽量以一句话概括其原理。
我想以提出问题的方式作为文章暗线,探讨卡顿会出现的原因,以及我们的各种实际优化方案。并且探讨怎么样的优化比较适合应用到实际场景中的,避免过度优化。
比如YYKit在文章中写到因为CoreText 对象占用内存较少,所以尽量选择CoreText直接生产UI控件,我认为这就是过度优化了。原因在于写代码速度降低,并且更不容易维护。用越底层的东西写,性能当然更好,可是对于一些甚至觉得富文本都写起来都很麻烦的同学来说,所有控件用CoreText实现实在是不易接受,代码写起来会比较麻烦,下一个人维护也会很头疼。
这其中就涉及到了怎么样的优化才是平衡点。所以才有了这一篇自己和自己探讨的文章。
网络,缓存,渲染
从一个空白的tabview到最后呈现完毕的tabview的整一个过程,无非包含以上副标题的三个过程。那让我们来思考一下这三个过程中,我们能做的优化有什么。
网络网络
网络的请求的优化点有以下3点:如何优化网络,缓存DNS地址等等。
将获取的JSon数据如何才能更高效地解析成Model,包括一些尽量不要使用KVC,使用Setter/Getter方法,或直接调用实际变量等等。
实际上这一部分我们会做的比较少。大部分用的第三方的控件HappyDns,AFNetWoring,YYModel等。
主要处理的还是网络情况极差的情况下,数据Model迟迟未拿到,如何避免白屏时间过长:
一般就是做了Model的缓存,高度的缓存,图片的缓存,进入的时候优先展示缓存的内容,请求网络返回之后,通过对比数据再去更新最新的UI。但是刷新数据的时候一般会有闪一下的情况发生,网易新闻的处理方式应该是如果缓存没有过期,新数据到来的时候不主动刷新刷新列表,显示有信息,用户主动下拉刷新新数据,这也是实际的应用场景下的方案。
还有对于要下载的图片,如果由于运营的失误,上传了极大的高分辨率图,会出现不可预见的问题。原因如下:
SDWebImage 中 decodeImageWithImage这个方法用于对图片进行解压缩并且缓存起来,以保证tableviews/collectionviews 交互更加流畅,但是如果是加载高分辨率图片的话,会适得其反,有可能造成上G的内存消耗,对于高分辨率的图片,应该禁止解压缩操作,相关的代码处理为:
项目中,高清图涉及到的地方,禁用解压缩,调用完毕再恢复原设置即可。这样既能保证高分辨率图不crash,也能保证其他地方,普通图片依旧可以通过解压缩进行优化。
SDWebImage 为什么要对图片进行解压缩并且缓存起来。原因如下:
iOS 通常会在真正显示才会移交GPU解码图片,对于一个较大的图片,无论是直接或间接使用 UIImageView 或者绘制到 Core Graphics 中,都需要对图片进行解压(主线程),为了避免渲染的时候在主线程提交大量的解码操作,所以都做了提前在异步线程的解压和缓存。
常见的做法是在后台线程先把图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片。
所以无论是SDWebImage还是其他的图片库,大都都做了这个操作。
所以对于图片下载的处理,我们客户端需要自己做一层优化也可以说是防护。一般图片服务器保存在七牛,又拍,阿里云上的,都有提供下载缩略图的API,我也因此做了一个工具类。对不同服务器存储的图片,按照不同的规则做裁剪和缩略。
我在测试服务器上遇到过这样的问题,因为没有做缩略客户端会因为解码的过程,内存爆炸,Crash。
缓存高度或者布局
在网络中已经提到了缓存这一部分。在这里讨论设置Cell高度的几种方法优缺点。
具体问题具体分析。
如果Cell高度和布局是确定的,我们初始化tabview高度的时候就可以直接给rowheight。这可以避免不断去调用高度的代理方法。
甚至整个cell的渲染缓存也可以通过调用API来开启,这种方式可以降低离屏渲染的不断性能开销。
那如果高度是微信朋友圈甚至微博的那种不确定,甚至是不断改变的高度呢?
事实上 iOS7以后已经有估算高度的API,那问题是不是都解决了?
坑爹的estimatedRowHeight
If the table contains variable height rows, it might be expensive to calculate all their heights when the table loads. Using estimation allows you to defer some of the cost of geometry calculation from load time to scrolling time.
说的很美好,事实上:
"
1 设置估算高度后,contentSize.height 根据“cell估算值 x cell个数”计算,这就导致滚动条的大小处于不稳定的状态,contentSize 会随着滚动从估算高度慢慢替换成真实高度,肉眼可见滚动条突然变化甚至“跳跃”。
2 若是有设计不好的下拉刷新或上拉加载控件,或是 KVO 了 contentSize 或 contentOffset 属性,有可能使表格滑动时跳动。
3 估算高度设计初衷是好的,让加载速度更快,那凭啥要去侵害滑动的流畅性呢,用户可能对进入页面时多零点几秒加载时间感觉不大,但是滑动时实时计算高度带来的卡顿是明显能体验到的,个人觉得还不如一开始都算好了呢(iOS8更过分,即使都算好了也会边划边计算)
"
一句话:别用!
所以我们一般做的是使用一个NSDictionry缓存高度
或者对缓存高度的通常做法是使用一个NSDictionry缓存Cell的高度:
NSInteger layoutId = [[self.tvHomeDataSource[indexPath.row] layoutId] integerValue];
float height = 0.0;
switch (layoutId) {
case 1:
{
MThemeFirst *model = self.tvHomeDataSource[indexPath.row];
if (model.title.length > 0) {
height = [[self.dicRow objectForKey:@"1-1"] floatValue];
}else {
height = [[self.dicRow objectForKey:@"1-0"] floatValue];
}
}
break;
case 2:
height = [[self.dicRow objectForKey:@"2"] floatValue];
break;
case 3:
height = [[self.dicRow objectForKey:@"3"] floatValue];
break;
case 4:
height = [[self.dicRow objectForKey:@"4"] floatValue];
break;
case 5:
height = [[self.dicRow objectForKey:@"5"] floatValue];;
break;
}
return height;
在YYkit中,对性能敏感的APP中我觉得这是一个很值得做的操作。因为把复杂的布局和数据处理工作都交给WBStatusLayout了,不仅能很显著地提高流畅度,而且可读性高,耦合度低。
渲染渲染
接下来就到了重头戏--渲染。以上做的所有操作,都是为了能够保证60帧的渲染,没有出现问题。
屏幕显示图像的原理:无论是LCD还是老古董CRT,基本原理基本相同。
引用“iOS 保持界面流畅的技巧”:
CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。
为了解决一个帧缓冲区的刷新和读取效率,引入了双缓冲机制。
那么又如何保证两个缓冲区提交的内容完美衔接呢,因为如果显示器的刷新频率和GPU 的渲染速度不一致,就会覆盖已经渲染好的帧栈。
于是又引入了垂直同步(V-Sync)。
V-Sync 的主要作用就是保证只有在帧缓冲区中的图像被渲染之后,后备缓冲区中的内容才可以被拷贝到帧缓冲区中。
那么问题就来了如何保证在1/60s内我们需要的下一帧时机到来的时候。下一帧已经准备好了。答:无法保证。
因为某些原因下一帧没有准备好(UI 渲染需要时间较长,无法按时提交结果),或者一些需要密集计算的处理放在了主线程中执行,导致主线程被阻塞,无法渲染 UI 界面。
就会出现平时出现的卡顿的主要原因。
那么离屏渲染为什么会卡帧?
知道了性能瓶颈在哪里,再想如何去优化。
都说直接切圆角会发生离屏渲染,那么离屏渲染是什么?为什么切圆角会产生离屏渲染,为什么离屏渲染会产生卡顿。
离屏渲染,指的是GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。
离屏渲染的整个过程,需要多次切换上下文环境:先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上有需要将上下文环境从离屏切换到当前屏幕。而上下文环境的切换是要付出很大代价的。
为了避免这种情况,可以尝试开启 CALayer.shouldRasterize 属性,但这会把原本离屏渲染的操作转嫁到 CPU 上去。
但是如果这个纹理之后还能复用到的话,这样的操作是可取的,并不是所有的离屏渲染都是要干掉的。
所以更多时候我们应该通过异步线程,使用Core Graphic裁剪完毕,再回到主线程更新UI。如果是固定的样式,可以使用.9的方式拉伸
并且如果我们重 写了DrawRect方法,并且使用任何Core Graphics的技术进行了绘制操作,就涉及到了CPU渲染。整个渲染过程由CPU在App内同步地完成,渲染得到的Bitmap(位图)最后再交由GPU用于显示。
即使发生了离屏渲染,GPU的速度也未必会比CPU差,因为对于图像处理,通常用硬件会更快,因为GPU使用图像对高度并行浮点运算做了优化。这也是为什么现在大多数的直播都开始支持硬解码功能。
至于为什么圆角,阴影等会产生离屏,很多文章中都没有提到:
当使用圆角,阴影,遮罩的时候,图层属性的混合体被指定为在未预合成之前不能直接在屏幕中绘制,即当主屏的还没有绘制好的时候,所以就需要屏幕外渲染,最后当主屏已经绘制完成的时候,再将离屏的内容转移至主屏上。这里就发了两次切换上下文环境的过程,当前屏幕到屏幕外,屏幕外再回到当前屏幕。这个解释可以在iOS核心动画高级技巧(非常好的一本书)中看到。
渲染UI为什么要在主线程?
一般来说App 主线程开始在 CPU 中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。这些操作都发生在主线程。
那么我们就会问为什么iOS 为什么必须在主线程中操作UI?
因为UIKit不是线程安全的。
1.两个线程同时设置同一个背景图片,那么很有可能因为当前图片被释放了两次而导致应用崩溃。
2.两个线程同时设置同一个UIView的背景颜色,那么很有可能渲染显示的是颜色A,而此时在UIView逻辑树上的背景颜色属性为B。
3.两个线程同时操作view的树形结构:在线程A中for循环遍历并操作当前View的所有subView,然后此时线程B中将某个subView直接删除,这就导致了错乱还可能导致应用崩溃。
那为什么苹果设计的时候不去保证UIKit线程安全?
可能是像UIKit这样大的框架上确保线程安全是一个重大的任务,会带来巨大的成本。
那么我们是不是真的无法将以上的所有操作移到异步线程去了呢?答案当然是否定的。
首先图片解码这一步我们之前就提到,SDWebimage就做了提前的解码操作。
并且了解到 AsyncDisplayKit这个框架的同学可能知道,他做的就是将图片解码、布局以及其它 UI 操作全部移出主线程,只在最后一步提交到GPU的过程在提交给主线程进行。
但是无法简单使用AsyncDisplayKit的原因是因为他换了视图基类,没有采用UIView,创建了 ASDisplayNode 类,ASDisplayNode 是线程安全的。
但如果是一个存在已久的APP,如果要将所有的UIView替换成ASDisplayNode ,这个工作量可能就太大了。
他太重了,但是我们可以借鉴它的一些思路,将其做的一些优化简单地抽成我们能使用的功能。
所以我们能做的优化有什么?
我觉得能采用的大概有如下几点:
1 通过字典缓存高度,或者更好地缓存布局。存在一个布局类缓存布局和Model的一些复杂计算。
2 图片要做好缩略图。
3 一些复杂的创建和计算都不应该放在主线程操作,而像NSDateFormatter最好有一个工具类,千万不要频繁创建,可以发现肉眼可察觉的卡顿。
4 一些对圆角的处理,或者IM中切头像的操作,都应该通过异步线程,使用Core Graphic裁剪完毕,再回到主线程更新UI。如果是固定的样式(即不是从服务器下发的图片),直接找UI切图也未必是一个很Low的做法。如果要做长度拉伸,可以使用.9的方式拉伸。
5 尽量减少视图层级。甚至可以将所以的视图异步绘制成一张图片来减少层级。视图尽量不要设置透明。
6 图片提前解码,不要到提交GPU的时候再去解码,前者可以在后台线程操作,后者只能在主线程。
7 没有交互的控件尽量直接使用CALayer,不要使用UIView。
8 Autolayout的实现是通过对线性方程组或者不等式的求解,也会产生额外的性能消耗,但是似乎放弃Autolayout也不是明智之举。
9像墨客一样,在scrollview结束滑动的代理方法中判断,哪几个cell是要绘制的,判断不展示的图片可以用灰色的透明块展示。(实际应该被会产品经理砍死,中间快速滑动过程中有一大段的空白)
(void)drawCell:(VVeboTableViewCell *)cell withIndexPath:(NSIndexPath *)indexPath{
NSDictionary *data = [datas objectAtIndex:indexPath.row];
cell.selectionStyle = UITableViewCellSelectionStyleNone;
[cell clear];
cell.data = data;
if (needLoadArr.count>0&&[needLoadArr indexOfObject:indexPath]==NSNotFound) {
[cell clear];
return;
}
if (scrollToToping) {
return;
}
[cell draw];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
VVeboTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell"];
if (cell==nil) {
cell = [[VVeboTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault
reuseIdentifier:@"cell"];
}
[self drawCell:cell withIndexPath:indexPath];
return cell;
}
//按需加载 - 如果目标行与当前行相差超过指定行数,只在目标滚动范围的前后指定3行加载。
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset{
NSIndexPath *ip = [self indexPathForRowAtPoint:CGPointMake(0, targetContentOffset->y)];
NSIndexPath *cip = [[self indexPathsForVisibleRows] firstObject];
NSInteger skipCount = 8;
if (labs(cip.row-ip.row)>skipCount) {
NSArray *temp = [self indexPathsForRowsInRect:CGRectMake(0, targetContentOffset->y, self.width, self.height)];
NSMutableArray *arr = [NSMutableArray arrayWithArray:temp];
if (velocity.y<0) {
NSIndexPath *indexPath = [temp lastObject];
if (indexPath.row+3<datas.count) {
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row+1 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row+2 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row+3 inSection:0]];
}
} else {
NSIndexPath *indexPath = [temp firstObject];
if (indexPath.row>3) {
[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]];
}
}
[needLoadArr addObjectsFromArray:arr];
}
}
其他的点有些没想到,有些则需要在更高的流畅敏感度下再去优化,应由业务推动技术的优化。
过早的优化都是魔鬼。
欢迎diss
iOS 保持界面流畅的技巧
优化UITableViewCell高度计算的那些事
我正在看的一本书:《iOS核心动画高级技巧》对GPU和CPU在渲染中的介绍
CPU VS GPU
iOS离屏渲染优化
绘制像素到屏幕上
Getting Pixels onto the Screen