iOS程序中,视图可以用UIView和CALayer来创建,下面就以它们为导火索来进一步学习总结iOS程序显示性能优化。
一、UIView和CALayer的区别
-
UIView
是UIKit
框架中的, 继承于UIRespond
,可以响应触摸事件;CALayer
是QuartzCore
框架里面CoreAnimation
中的,继承自NSObject
, 不响应事件。 -
UIView
有个只读的layer
属性,称为根Layer,UIView的图层是由layer
来生成的,所以对UIView的frame等属性的修改本质上是对layer的属性的修改。 - 我们可以给
UIView
添加子layerself.layer addSublayer:
,效果像addSubview
一样显示,但sublayer
在修改position
、size
、opacity
等属性时会产生隐式动画(默认有个动画效果), 这就没有操作subview
方便。 - 使用系统提供的layer和自定义layer都可添加为subLayer,但需要调用
[layer setNeedsDisplay]
。 对于自定义layer方式有三个:设置layer代理、自定义CALayer子类。
/// 方式一:添加layer,设置代理,调用 [layer setNeedsDisplay];
CALayer *layer = [CALayer layer];// 测试设置代理方式用这个
ZLLayer *layer = [ZLLayer layer]; // 测试自定义layer用这个
layer.delegate = self;
layer.bounds = CGRectMake(0, 0, 200, 200);
layer.backgroundColor = UIColor.blueColor.CGColor;
layer.position = CGPointMake(100, 100);
layer.anchorPoint = CGPointZero;
[self.view.layer addSublayer:layer];
[layer setNeedsDisplay];
// 代理方法的实现
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx{
NSLog(@"%s", __func__);
CGContextSetRGBFillColor(ctx, 1, 0, 0, 1);
CGContextAddEllipseInRect(ctx, CGRectMake(10, 10, 80, 80));
CGContextFillPath(ctx);
}
/// 方式二:自定义子类(自定义的ZLLayer可以直接添加到layer上,也可以创建自定义view类,在+ (Class)layerClass中返回)
@implementation ZLLayer
- (void)display{
NSLog(@"%s", __func__);
[super display];
NSLog(@"%@", self.contents);
}
- (void)drawInContext:(CGContextRef)ctx{
NSLog(@"%s", __func__);
CGContextAddEllipseInRect(ctx, CGRectMake(10, 10, 80, 80));
CGContextSetRGBFillColor(ctx, 1, 0, 0, 1);
CGContextFillPath(ctx);
}
@end
-
UIView
是根layer
的代理。
二、UIView从创建到显示主要经过了哪些步骤?
我们通过上述自定义的ZLLayer
类, 在各个方法中打印调用方法顺序及上下文,可以验证下面的过程。
当视图(UIView或CALayer)第一次展示到窗口或者手动调用
-setNeedsDisplay
时,当前图层Layer会被标记为dirty
(多次调用-setNeedsDisplay
效果是一样的), 接着当渲染系统准备好时会调用图层layer的-display
方法。在
-display
方法中,[super dispalay]
中的实现是:
- 首先会创建上下文(如果已有上下文则使用之前的).
- 接下来会判断layer的delegate有没有实现
- (void)displayLayer:(CALayer *)layer
, 如果实现了则系统会认为layer.content
由这个方法里面生成,系统会调用这个方法并且不会去绘制内容(即
不会调用layer内部- (void)drawInContext:(CGContextRef)ctx
, 也不会调用代理的- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx
)。- 如果
layer的delegate
没有实现- (void)displayLayer:(CALayer *)layer
, 则系统会认为需要手动绘制内容,首先判断Layer
中有没有实现- (void)drawInContext:(CGContextRef)ctx
方法,实现了则调用; 否则调用代理的- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx
去完成内容绘制。
- UIView是Layer的代理,UIView中实现了代理方法
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx
,内部会调用UIView的- (void)drawRect:(CGRect)rect
方法。 - 上面方法调用完后,GPU根据上下文环境进行纹理的渲染,渲染完成后放入帧缓存中。
- 当显示器发出VSync(垂直同步信号)时,视频控制器从帧缓存中读取一帧,按照水平同步信号显示到屏幕。
APP卡顿优化学习总结
综上,我们创建视图应该是这样的:
- 首先对于不复杂的视图内容,则正常使用UIView、layer。
- 如果像八角形、股票曲线等复杂内容,需要手动使用
UIKit
中的UIBezierPath
等API或者Core Graphics
中的API的,可以根据需要选择- 第一种方法: 创建CALayer,设置delegate,然后在代理类实现方法
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx
,将内容绘制到这个ctx
中。 - 第二种方法:创建自定义CALayer子类,重写方法
- (void)drawInContext:(CGContextRef)ctx
,将内容绘制到这个ctx
中。 - 第三种方法,将自定义view作为展示视图,重写view的
- (void)drawRect:(CGRect)rect
方法,将内容绘制到这个ctx
中。 - 第四种方法:使用UIKit中的API,里面会默认获取在栈顶的上下文,将内容绘制到上下文中。
- 第一种方法: 创建CALayer,设置delegate,然后在代理类实现方法
比如绘制一个八角形的代码:
- 通过 UIKit 能做到这一点
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(16.72, 7.22)];
[path addLineToPoint:CGPointMake(3.29, 20.83)];
[path addLineToPoint:CGPointMake(0.4, 18.05)];
[path addLineToPoint:CGPointMake(18.8, -0.47)];
[path addLineToPoint:CGPointMake(37.21, 18.05)];
[path addLineToPoint:CGPointMake(34.31, 20.83)];
[path addLineToPoint:CGPointMake(20.88, 7.22)];
[path addLineToPoint:CGPointMake(20.88, 42.18)];
[path addLineToPoint:CGPointMake(16.72, 42.18)];
[path addLineToPoint:CGPointMake(16.72, 7.22)];
[path closePath];
path.lineWidth = 1;
[[UIColor redColor] setStroke];
[path stroke];
- 通过 Core Graphics, 需要手动获取上下文
CGContextBeginPath(ctx);
CGContextMoveToPoint(ctx, 16.72, 7.22);
CGContextAddLineToPoint(ctx, 3.29, 20.83);
CGContextAddLineToPoint(ctx, 0.4, 18.05);
CGContextAddLineToPoint(ctx, 18.8, -0.47);
CGContextAddLineToPoint(ctx, 37.21, 18.05);
CGContextAddLineToPoint(ctx, 34.31, 20.83);
CGContextAddLineToPoint(ctx, 20.88, 7.22);
CGContextAddLineToPoint(ctx, 20.88, 42.18);
CGContextAddLineToPoint(ctx, 16.72, 42.18);
CGContextAddLineToPoint(ctx, 16.72, 7.22);
CGContextClosePath(ctx);
CGContextSetLineWidth(ctx, 1);
CGContextSetStrokeColorWithColor(ctx, [UIColor redColor].CGColor);
CGContextStrokePath(ctx);
三、隐式动画
我们对UIView中非根layer修改属性如:posion、bounds、opacity时会自动有一个动画效果,如果不想要这个效果可以这么做:
// 使用UIView的api去除隐式动画,如果不是手动添加的layer可起作用
[UIView performWithoutAnimation:^{
[collectionView reloadItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:index inSection:0]]];
}];
// 假如self.frameLayer是手动添加到view中的,使用下面的方式去除隐式动画
- (void)layoutSubviews{
[super layoutSubviews];
[CATransaction begin];
[CATransaction setDisableActions:YES];
self.frameLayer.frame = self.frameView.bounds;
[CATransaction commit];
}
隐式动画产生的原因:
可阅读iOS动画-CALayer隐式动画原理与特性
Core Animation
在每个RunLoop周期
中会自动开始一次新的事务,即使你不显式的使用[CATranscation begin]开始一次事务。- 在一次RunLoop运行循环中改变的属性会被集中执行默认0.25秒的动画。
- 事务通过CATransaction类来做管理的:
//1.动画属性的入栈 + (void)begin; //2.动画属性出栈 + (void)commit; //3.设置当前事务的动画时间 + (void)setAnimationDuration:(CFTimeInterval)dur; //4.在动画结束时提供一个完成的动作 + (void)setCompletionBlock:(nullable void (^)(void))block;
四、提高显示性能
- GPU 是一个专门为图形高并发计算而量身定做的处理单元, 能高效的将不同纹理合成起来;
- GPU Driver 使不同的GPU在下一个层级(如 OpenGL/OpenGL ES)更为统一;
- OpenGL 和 GPU 密切的工作以提高GPU的能力,并实现硬件加速渲染
- iOS程序中,UIKit框架中控件的图层部分是由CALayer来完成,而CALayer是Core Animation中的。
- 一个纹理就是一个包含 RGBA 值的长方形,在 Core Animation 世界中这就相当于一个 CALayer。
从GPU工作,可以做的优化:
每一个 layer 是一个纹理,所有的纹理都以某种方式堆叠在彼此的顶部,GPU 需要合成堆叠的纹理来得到屏幕上像素对应具体的 RGB 值。GPU计算时分像素和纹理对齐、不对齐两种情况:
- 在像素对齐的情况下,如果纹理不透明,那么GPU会取最上层的那个纹理的色值;如果是有透明度,则需要计算多个纹理共同作用下的色值。
- 在像素不对齐的情况下,主要有两个原因可能会造成不对齐。第一个便是缩放;当一个纹理放大缩小的时候,纹理的像素便不会和屏幕的像素排列对齐。另一个原因便是当纹理的起点不在一个像素的边界上。在这两种情况下,GPU 需要再做额外的计算。它需要将源纹理上多个像素混合起来,生成一个用来合成的值。当所有的像素都是对齐的时候,GPU 只剩下很少的工作要做。Core Animation 工具和模拟器有一个叫做 color misaligned images 的选项,当这些在你的 CALayer 实例中发生的时候,这个功能便可向你展示。
因此我们需要尽量减少视图层次、减少透明视图、layer的放大缩小.
从CPU工作来看,可以做的优化:
- 可一次计算好子控件的frame后保存,减少计算量。
- 对于加载图片等耗时操作,开启子线程去执行,比如SDWebView用的自己创建的最大并发数为6的并发队列来安排下载任务。
- 图片下载完后,设置给UIImageView时,如果我们不在子线程完成图片的解码,那么解码操作会在主线程完成,容易会造成卡顿。解决办法是在子线程将PNG\JPG\WebP等格式图片画到图形上下文当中后生成CGImage,再回调到主线程将这个CGImage设置给UIImageView;另外, 在处理高分辨率大图时直接解码操作会让内存暴增,这时则需要将图片分段绘制到上下文。
具体可参考 SDWebImage的图片解码源码阅读 - 尽量避免离屏渲染。
- 你可以使用可变尺寸的图像来降低绘图系统的压力。让我们假设你需要一个 300×50 点的按钮插图,这将是 600×100=60k 像素或者 60kx4=240kB 内存大小需要上传到 GPU,并且占用 VRAM。如果我们使用所谓的可变尺寸的图像,我们只需要一个 54×12 点的图像,这将占用低于 2.6k 的像素或者 10kB 的内存,这样就变得更快了。
Core Animation 可以通过 CALayer 的contentsCenter
属性来改变图像,大多数情况下,你可能更倾向于使用,-[UIImage resizableImageWithCapInsets:resizingMode:]
。
同时注意,在第一次渲染这个按钮之前,我们并不需要从文件系统读取一个 60k 像素的 PNG 并解码,解码一个小的 PNG 将会更快。通过这种方式,你的程序在每一步的调用中都将做更少的工作,并且你的视图将会加载的更快。
五、离屏渲染
屏幕外的渲染会渲染图层树中的一部分到一个新的缓冲区中,这需要消耗更多的CPU和GPU时间,从而导致卡顿。我们常见的操作代码会导致的有:
阴影:设置阴影会导致离屏渲染。
self.bgView.layer.shadowColor = [UIColor blackColor].CGColor;//shadowColor阴影颜色
self.bgView.layer.shadowOffset = CGSizeMake(0,0);//shadowOffset阴影偏移,x向右偏移2,y向下偏移6,默认(0, -3),这个跟shadowRadius配合使用
self.bgView.layer.shadowOpacity = 0.3;//阴影透明度,默认0
self.bgView.layer.shadowRadius = 4;//阴影半径,默认3
避免的方式:设置shadowPath
//参数依次为大小,设置四个角圆角状态,圆角曲度 设置阴影路径可避免离屏渲染
self.bgView.layer.shadowPath =
[UIBezierPath bezierPathWithRoundedRect:self.bgView.bounds
byRoundingCorners:UIRectCornerAllCorners
cornerRadii:CGSizeMake(self.bgView.layer.cornerRadius, self.bgView.layer.cornerRadius)].CGPath;
圆角:设置cornerRadius
同时设置masksToBounds=YES
会触发离屏渲染,解决办法是设置将图片画到有圆角的rect生成新图片后再进行设置。解决常见的masksToBounds
self.imageView.layer.cornerRadius = 5;
self.imageView.layer.masksToBounds = YES;
遮罩:如果你将mask应用到一个layer上,Core Animation 为了应用这个 mask,会强制进行屏幕外渲染。避免方式:如果不是很必要则不使用。
光栅化: 如果你的程序混合了很多图层,并且想要他们一起做动画,GPU 通常会为每一帧(1/60s)重复合成所有的图层。当使用离屏渲染时,GPU 第一次会混合所有图层到一个基于新的纹理的位图缓存上,然后使用这个纹理来绘制到屏幕上。现在,当这些图层一起移动的时候,GPU 便可以复用这个位图缓存,并且只需要做很少的工作。需要注意的是,只有当那些图层不改变时,这才可以用。如果那些图层改变了,GPU 需要重新创建位图缓存。你可以通过设置 shouldRasterize 为 YES 来触发这个行为。
self.xxview.layer.shouldRasterize = YES;
self.xxview.layer.contentsScale = [UIScreen mainScreen].scale;
测试离屏渲染的发生
如下图所示,如果是触发了离屏渲染的layer会有黄颜色。
绘制像素到屏幕的过程