iOS 性能优化整理
背景
之前对iOS性能优化总是碎片化了解,而且看了之后很快就忘记了。最近正好需要做一下技术调研,想要对于这个课题进行深入整理,并且通过实际的应用、结合项目,进行理论与实践相结合。
iOS性能优化相对复杂、内容相关性广、涉及面也很广,接下来通过对其的了解,逐步进行分析和总结。
性能优化导图这是我总结的性能优化内容,我会从以上几个方面进行整理。
- 首先我会对卡顿优化进行整理,卡顿的现象是什么?为什么会发生卡顿?我们要怎么解决卡顿?有哪些解决思路?然后通过实际应用,看看如果在项目开发的时候避免卡顿。
- 接下来也是比较重要的:内存优化。我们为何要进行内存优化?内存是怎么被消耗的?然后说一下内存的管理模型,通过内存管理模型我们进行内存的分析,最后结合实际,说说我们具体在项目中如何避免内存泄漏。
- 然后说一下启动优化。什么是冷启动?什么是热启动?他们的区别是什么?APP启动的具体流程是什么?最后通过实际整理,说一下启动优化的具体思路。
- 接着说一下电量优化。
- 最后说一下安装包瘦身。
CPU 占用率、 内存使用情况、启动时间、卡顿、FPS、使用时崩溃率、耗电量监控、流量监控、网络状况监控、等等。
一、卡顿优化
APP卡顿指的是在APP运行过程中出现的掉帧现象。
什么是掉帧现象?为什么会出现掉帧,那我们就要先从UI图像的显示原理说起。
1、UI图像显示原理
我在之前讲解视图相关的知识的时候已经有过讲解。(PS:具体查阅
https://www.jianshu.com/p/08a19fc1068f)
大致总结就是:CPU和GPU通过渲染总线,把需要显示的图像放在帧缓冲区,然后视频控制器在V-Sycn信号到来之后把图像放在显示器上进行显示,这样就形成了一帧图像。如果在V-Sycn信号到来的时候,CPU和GPU没有把需要显示的图像放在帧缓冲区,那这帧就无法在显示器上显示,从而发生丢帧现象。
我们应该知道保证操作流畅不卡顿,应该保证屏幕图像的刷新率在60HZ/s,也就是说一秒要生成对应60帧对应的图像(目前iPadpro已经实装了120HZ/S的高刷,体验相当丝滑),如果发生丢帧现象,那么刷新率就会降到60HZ/s以下,于是就出现了卡顿,APP的体验也就瞬间下滑。
2、如何解决卡顿问题
一个优秀的APP使用起来肯定是丝般顺滑,如果想让你的APP体验上一个台阶,那么卡顿的问题是肯定要避免的,接下来我们要说一说如何解决卡顿的问题。
解决思路
通过上面对于UI图像显示原理的说明,我们可以看出图像的生成是主要依赖CPU和GPU共同协作完成。是否在V-Sycn信号来之前成功把生成的图像放在帧缓冲区是非常重要的,所以我们要合理安排CPU和GPU的工作,让帧图像的生成有条不紊的进行。通过对CPU和GPU的工作优化,从而优化卡顿问题就是我们解决卡顿的思路。
首先我们分别看一下CPU和GPU在 “图像” 方面的工作内容。
CPU
CPU的工作内容从上图可以了解到CPU的工作内容大致分为:对象创建、对象调整、对象销毁、布局计算。那么我们就从以上四个方面逐步分析,看看如何在这四方面进行工作的优化。
(1)对象创建
对象的创建会分配内存、调整属性、甚至还有读取文件等操作,比较消耗 CPU 资源。尽量用轻量的对象代替重量的对象,可以对性能有所优化。比如 CALayer 比 UIView 要轻量许多,那么不需要响应触摸事件的控件,用 CALayer 显示会更加合适。如果对象不涉及 UI 操作,则尽量放到后台线程去创建,但可惜的是包含有 CALayer 的控件,都只能在主线程创建和操作。通过 Storyboard 创建视图对象时,其资源消耗会比直接通过代码创建对象要大非常多,在性能敏感的界面里,Storyboard 并不是一个好的技术选择。
尽量推迟对象创建的时间,并把对象的创建分散到多个任务中去。尽管这实现起来比较麻烦,并且带来的优势并不多,但如果有能力做,还是要尽量尝试一下。如果对象可以复用,并且复用的代价比释放、创建新对象要小,那么这类对象应当尽量放到一个缓存池里复用。
(2)对象调整
对象的调整也经常是消耗 CPU 资源的地方。这里特别说一下 CALayer:CALayer 内部并没有属性,当调用属性方法时,它内部是通过运行时 resolveInstanceMethod 为对象临时添加一个方法,并把对应属性值保存到内部的一个 Dictionary 里,同时还会通知 delegate、创建动画等等,非常消耗资源。UIView 的关于显示相关的属性(比如 frame/bounds/transform)等实际上都是 CALayer 属性映射来的,所以对 UIView 的这些属性进行调整时,消耗的资源要远大于一般的属性。对此你在应用中,应该尽量减少不必要的属性修改。
当视图层次调整时,UIView、CALayer 之间会出现很多方法调用与通知,所以在优化性能时,应该尽量避免调整视图层次、添加和移除视图。
(3)对象销毁
对象的销毁虽然消耗资源不多,但累积起来也是不容忽视的。通常当容器类持有大量对象时,其销毁时的资源消耗就非常明显。同样的,如果对象可以放到后台线程去释放,那就挪到后台线程去。
(4)布局计算与绘制
视图布局的计算是 App 中最为常见的消耗 CPU 资源的地方。如果能在后台线程提前计算好视图布局、并且对视图布局进行缓存,那么这个地方基本就不会产生性能问题了。
不论通过何种技术对视图进行布局,其最终都会落到对 UIView.frame/bounds/center 等属性的调整上。上面也说过,对这些属性的调整非常消耗资源,所以尽量提前计算好布局,在需要时一次性调整好对应属性,而不要多次、频繁的计算和调整这些属性.
Autolayout 是苹果本身提倡的技术,在大部分情况下也能很好的提升开发效率,但是 Autolayout 对于复杂视图来说常常会产生严重的性能问题。随着视图数量的增长,Autolayout 带来的 CPU 消耗会呈指数级上升。 如果你不想手动调整 frame 等属性,你可以用一些工具方法替代(比如常见的 left/right/top/bottom/width/height 快捷属性),或者使用 ComponentKit、AsyncDisplayKit 等框架。
除了文本计算,CPU还负责了绘制(Display)的工作(具体我其他文章也介绍UIView的绘制原理,可以参考一下),之后做一些准备工作(PrePare),然后把对应的位图提交到GPU上面(Commit)
- layout: UI布局和文本计算包括控件Frame的设置,对于控件的文字或者size的计算。
- Display: 就是绘制过程,drawRect方法发生在这一步骤
- PrePare: 准备阶段,假如有UIImageView,那么设置它的image的时候,图片是不能直接显示到屏幕上去的,需要对图片进行解码,解码的动作就发生在这一过程当中
- Commit: 对CPU最终的输出结果位图进行提交。
GPU
GPU工作内容从上图可以了解到CPU的工作内容大致分为:顶点着色器、形状装配
、几何着色器、光栅化、片段着色器、测试与混合。
(1)顶点着色器(Vertex Shader)
该阶段输入的是顶点数据(Vertex Data),顶点数据是一系列顶点的集合。顶点着色器主要的目的是把 3D 坐标转为 “2D” 坐标,同时顶点着色器可以对顶点属性进行一些基本处理。
( 一句话简单说,确定形状的点。)
(2)形状(图元)装配(Shape Assembly)
该阶段将顶点着色器输出的所有顶点作为输入,并将所有的点装配成指定图元的形状。
图元(Primitive) 用于表示如何渲染顶点数据,如:点、线、三角形。
这个阶段也叫图元装配。
( 一句话简单说,确定形状的线。)
(3)几何着色器(Geometry Shader)
该阶段把图元形式的一系列定点的集合作为输入,通过生产新的顶点,构造出全新的(或者其他的)图元,来生成几何形状。
( 一句话简单说,确定三角形的个数,使之变成几何图形。)
(4)光栅化(Rasterization)
该阶段会把图元映射为最终屏幕上相应的像素,生成片段。片段(Fragment
) 是渲染一个像素所需要的所有数据。
( 一句话简单说,将图转化为一个个实际屏幕像素。)
(5)片段着色器(Fragment Shader)
该阶段首先会对输入的片段进行裁切(Clipping)。裁切会丢弃超出视图以外的所有像素,用来提升执行效率。并对片段(Fragment)进行着色。
( 一句话简单说,对屏幕像素点着色。)
(6)测试与混合(Tests and Blending)
该阶段会检测片段的对应的深度值(z 坐标),来判断这个像素位于其它图层像素的前面还是后面,决定是否应该丢弃。此外,该阶段还会检查 alpha 值( alpha 值定义了一个像素的透明度),从而对图层进行混合。
( 一句话简单说,检查图层深度和透明度,并进行图层混合。)
通过这六个步骤逐步生成一个对应的图像,最终把图像提交到帧缓冲区当中去。
其中前五步都是固定的流水线作业,我们无法干预,但是通过第六点我们可以对GPU的工作进行优化。
在测试与混合中由于有不同的图层混合,而且会有不同的alpha指定,所以最终生成的像素颜色不一样,从而会出现不同的图像内容。
关于混合,GPU采用如下公式进行计算,并得出最后的实际像素颜色。
R = S + D * (1 - Sa)
含义:
R:Result,最终像素颜色。
S:Source,来源像素(上面的图层像素)。
D:Destination,目标像素(下面的图层像素)。
a:alpha,透明度。
结果 = S(上)的颜色 + D(下)的颜色 * (1 - S(上)的透明度)
可以看出几个比较关键的点S、D、A. 我们可以围绕这几个点进行进行优化。
1、尽量减少透视图的数量和层次;
2、减少透明的视图(alpha < 1),不透明的就设置 opaque 为 YES;
3、尽量避免离屏渲染(离屏渲染在之前的文章讲解过)
离屏渲染的整个过程,需要多次切换上下文环境,先是从当前屏幕(On-Screen)切换到离屏(Off-Screen),渲染结束后,将离屏缓冲区的渲染结果显示到屏幕上,上下文环境从离屏切换到当前屏幕,这个过程会造成性能的消耗。
最终也会增加图像的层级,最终混合的时候会产生性能消耗。
3、解决办法总结
通过上面的讲解,对于卡顿优化的方法大概总结如下:
CPU层面
1、尽量用轻量的对象代替重量的对象。
CALayer * topLayer = [CALayer layer];
topLayer.frame = CGRectMake(100, 100, 100, 100);
topLayer.cornerRadius = 50;
topLayer.masksToBounds = NO;
topLayer.backgroundColor = [UIColor greenColor].CGColor;
[self.view.layer addSublayer:topLayer];
2、对象不涉及 UI 操作,则尽量放到后台线程去创建。
dispatch_async(dispatch_get_global_queue(0, 0), ^{
// 处理耗时操作的代码块...
//通知主线程刷新
dispatch_async(dispatch_get_main_queue(), ^{
//回调或者说是通知主线程刷新UI,
});
});
3、通过 Storyboard 创建视图对象时,其资源消耗会比直接通过代码创建对象要大非常多,在性能敏感的界面里,尽量使用纯代码进行开发。
4、如果对象可以复用,并且复用的代价比释放、创建新对象要小,那么这类对象应当尽量放到一个缓存池里复用(比如TableViewCell的复用规则)。
5、应该尽量减少不必要的属性修改。
6、应该尽量避免调整视图层次、添加和移除视图。
7、如果对象可以放到子线程去释放,那就挪到子线程去。
8、在子线程中预排版(布局计算,文本计算),让主线程有更多的时间去响应用户的交互。
9、预渲染(文本等异步绘制,图片编解码等)。
GPU层面
1、UIView尽量设置为不透明。
2、尽量设置UIView的背景色。
3、经我测试,设置shadowOpacity的透明度会发生离屏渲染,cornerRadius+clipsToBounds未发生离屏渲染,但是如果加了子视图再设置clipsToBounds的时候就会发生离屏渲染。
4、UIImageView中只设置图片和maskToBounds / clipsToBounds不会触发离屏渲染,除非再设置背景色就会离屏渲染
UIImageView * aview = [[UIImageView alloc] init];
aview.frame = CGRectMake(100,100, 100, 100);
aview.layer.cornerRadius = 50;
aview.backgroundColor = [UIColor redColor];//如果设置背景色就会产生离屏渲染
aview.image = [UIImage imageNamed:@"ailitype_icon"];
aview.layer.masksToBounds = YES;
aview.clipsToBounds = YES;
5、善用离屏渲染(合理利用shouldRasterize属性)。
如果一定会发生丽萍渲染就一定要设置shouldRasterize的属性,shouldRasterize的主旨在于降低性能损失,但总是至少会触发一次离屏渲染。如果你的layer本来并不复杂,打开这个开关反而会增加一次不必要的离屏渲染。
PS:
①如果layer 不能被复用,没有必要开启光栅化。
②如果layer不是静态,频繁被修改,比如在动画中,开始反而影响效率
③缓存内容时间限制,100ms内没被使用,自动丢弃
④缓存空间有限,最大不超过屏幕的2.5倍
//以下代码大家可以自行测试
UIView * aview = [[UIView alloc] init];
aview.frame = CGRectMake(100,100, 100, 100);
aview.layer.cornerRadius = 50;
aview.layer.masksToBounds = NO;
aview.layer.shadowColor = [UIColor greenColor].CGColor;
aview.layer.shadowRadius = 4.0;
//aview.layer.shadowOpacity = 1; //设置阴影透明度会发生离屏渲染
aview.layer.shadowOffset = CGSizeMake(5, 5);
aview.backgroundColor = [UIColor redColor];
//aview.layer.shouldRasterize = YES;//使用了光栅化就一定会发生离屏渲染
[self.view addSubview:aview];
5、设置layer的mask的时候会发生离屏渲染
UIImageView * aview = [[UIImageView alloc] init];
aview.frame = CGRectMake(100,100, 100, 100);
aview.backgroundColor = [UIColor clearColor];
[self.view addSubview:aview];
CALayer * topLayer = [CALayer layer];
topLayer.frame = CGRectMake(10, 10, 80, 80);
topLayer.cornerRadius = 50;
topLayer.masksToBounds = NO;
topLayer.contents = (id)[UIImage imageNamed:@"ailitype_icon"].CGImage;
topLayer.contentsGravity = kCAGravityResizeAspect;
topLayer.backgroundColor = [UIColor greenColor].CGColor;
aview.layer.mask = topLayer; //设置成mask的时候会发生离屏渲染
PS:如何监测离屏渲染
1、模拟器打开
模拟器下打开离屏渲染
2、真机Instrument-选中Core Animation-勾选Color Offscreen-Rendered Yellow
总结下来我们在开发的过程中,要注意对象的管理、耗时操作的处理、和UI相关的问题。
为了保持UI的流畅度我们也可以借助于三方库。
AsyncDisplayKit
AsyncDisplayKit 是 Facebook 开源的一个用于保持 iOS 界面流畅的库
这个三方库我在之前的文章中提到过。https://www.jianshu.com/p/08a19fc1068f
4、卡顿检测
在实际的项目尽量要做一下卡顿检测,为了卡顿能直接可视化检测,我整理了一份代码,可以直接在项目中使用,其原理就是利用RunLoop的机制,通过监听RunLoop状态切换时间来检测是否卡顿,如果没有达到就是出现了卡顿。
具体地址如下:
一、内存优化
在开发过程中对于内存的管理和优化是非常重要的,合理的内存资源分配和使用是一门高深的技术。其最主要的目的是如何高效,快速的分配,并且在适当的时候释放和回收内存资源。
内存是什么?它是什么样的?接下来我们就看看内存的布局。
1、内存布局
关于内存的布局我整理了一份图片
内存布局
从图中我们可以看到,在iOS中内存是一块相对复杂的区域,这块区域从低地址到高地址,不同的地址段的职责也是不一样的。
首先程序进行加载,然后进行内存的分配:
从低到高我们说一下内存分配了哪些内容:
1、保留段:系统保留的一块内存区域。
2、代码区(.text):存储编译后的代码区域,而且还包括了操作码和要操作的对象的地址引用。
3、常量区:存储已使用的字符串常量(比如const、extern修饰的字符串),程序结束后由系统释放,相同字符串的地址是一致的。
4、全局(静态)区(.bbs/data):存储全局变量或者静态变量(static),程序结束后由系统进行释放。static int a:未初始化的全局静态变量,存放在全局(.bbs)段。static int a = 10:已初始化的全局静态变量,存放在全局data段当中。
PS:可以看到当程序加载到内存中的时候,全局区、常量区、代码区、是已经分配好的。
5、堆区:iOS存放的对象都是存在堆区的,由开发者进行管理(ARC下是开发者不回收,由系统自行回收)。速度相对较慢,但是操作灵活(是由链表结构进行管理的),所以会造成内存碎片话,分配的地址是由低到高的。
6、栈区:用来存放参数值,局部变量值,对象的指针值,由系统自行进行分配和释放,不需要开发者进行管理。快速高效,但是操作不是很灵活(在内存中是连续的),分配的地址是从高到低的。
PS:堆区是从低到高进行分配,栈区是从高到低分配,一旦堆区和栈区相遇,就会发生堆栈溢出。堆区和栈区是在程序运行的过程中所产生的。
7、内核区:内核所占用的内存空间。
通过上面的图片我们还可以了解到:获取对象值的查找过程是通过栈中对应的对象在堆中的地址进行具体查找的,具体表现形式如下:
int a = 1; //局部变量 存储在栈中
int b = 2; //局部变量 存储在栈中
Palindrome * c = [[Palindrome alloc] init]; //创建对象 地址存储在栈中,实际值是存储在堆中的
变量和对象在内存中的分配