@[TOC]linux
APP的启动时间,直接影响用户对你的APP的第一体验和判断。若是启动时间过长,不仅仅体验直线降低,并且可能会激发苹果的watch dog机制kill掉你的APP,那就悲剧了,用户会以为APP怎么一启动就卡死而后崩溃了,不能用,而后长按APP点击删除键。(Xcode在debug模式下是没有开启watch dog的,因此咱们必定要链接真机测试咱们的APP)ios
在衡量APP的启动时间以前咱们先了解下,APP的启动流程:git
咱们将 App 启动方式分为:程序员
APP的启动能够分为两个阶段,即main()
执行以前和main()
执行以后。总结以下:github
- t(App 总启动时间) = t1( main()以前的加载时间 ) + t2( main()以后的加载时间 )。
- t1 = 系统的
dylib
(动态连接库)和 App 可执行文件的加载时间;- t2 =
main()
函数执行以后到AppDelegate
类中的applicationDidFinishLaunching:withOptions:
方法执行结束前这段时间。
因此咱们对APP启动时间的获取和优化都是从这两个阶段着手,下面先看看main()
函数执行以前如何获取启动时间。面试
main()
函数执行以前衡量main()
函数执行以前的耗时 对于衡量main()
以前也就是time1
的耗时,苹果官方提供了一种方法,即在真机调试的时候,勾选DYLD_PRINT_STATISTICS
选项(若是想获取更详细的信息能够使用DYLD_PRINT_STATISTICS_DETAILS
),以下图:算法
Total pre-main time: 34.22 milliseconds (100.0%)
dylib loading time: 14.43 milliseconds (42.1%)
rebase/binding time: 1.82 milliseconds (5.3%)
ObjC setup time: 3.89 milliseconds (11.3%)
initializer time: 13.99 milliseconds (40.9%)
slowest intializers :
libSystem.B.dylib : 2.20 milliseconds (6.4%)
libBacktraceRecording.dylib : 2.90 milliseconds (8.4%)
libMainThreadChecker.dylib : 6.55 milliseconds (19.1%)
libswiftCoreImage.dylib : 0.71 milliseconds (2.0%)
复制代码
系统级别的动态连接库,由于苹果作了优化,因此耗时并很少,而大多数时候,t1的时间大部分会消耗在咱们自身App中的代码上和连接第三方库上。 因此咱们应如何减小main()
调用以前的耗时呢,咱们能够优化的点有:shell
- 合并动态库,减小没必要要的
framework
,特别是第三方的,由于动态连接比较耗时;- check
framework
应设为optional
和required
,若是该framework
在当前App支持的全部iOS系统版本都存在,那么就设为required
,不然就设为optional
,由于optional
会有些额外的检查;- 合并或者删减一些
OC
类,关于清理项目中没用到的类,能够借助AppCode
代码检查工具:- 删减一些无用的静态变量
- 删减没有被调用到或者已经废弃的方法
- 将没必要须在+load方法中作的事情延迟到+initialize中
- 尽可能不要用C++虚函数(建立虚函数表有开销)
- 避免使用
attribute((constructor))
,可将要实现的内容放在初始化方法中配合dispatch_once
使用。- 减小非基本类型的 C++ 静态全局变量的个数。(由于这类全局变量一般是类或者结构体,若是在构造函数中有繁重的工做,就会拖慢启动速度)
咱们能够从原理上分析main
函数执行以前作了一些什么事情:
Mach-O
格式文件,既 App 中全部类编译后生成的格式为 .o
的目标文件集合。
- 分析 App 依赖的全部
dylib
。- 找到
dylib
对应的Mach-O
文件。- 打开、读取这些
Mach-O
文件,并验证其有效性。- 在系统内核中注册代码签名
- 对
dylib
的每个segment
调用mmap()
。
系统依赖的动态库因为被优化过,能够较快的加载完成,而开发者引入的动态库须要耗时较久。
Rebase和Bind操做: 因为使用了ASLR
技术,在 dylib
加载过程当中,须要计算指针偏移获得正确的资源地址。 Rebase
将镜像读入内存,修正镜像内部的指针,消耗 IO
性能;Bind
查询符号表,进行外部镜像的绑定,须要大量 CPU
计算。
Objc setup : 进行 Objc 的初始化,包括注册 Objc 类、检测 selector 惟一性、插入分类方法等。
Initializers : 往应用的堆栈中写入内容,包括执行 +load
方法、调用 C/C++ 中的构造器函数(用 attribute((constructor))
修饰的函数)、建立非基本类型的 C++ 静态全局变量等。
main()
函数执行以后衡量main()函数执行以后的耗时 第二阶段的耗时统计,咱们认为是从main ()
执行以后到applicationDidFinishLaunching:withOptions:
方法最后,那么咱们能够经过打点的方式进行统计。 Objective-C项目由于有main
文件,因此我么直接能够经过添加代码获取:
// 1. 在 main.m 添加以下代码:
CFAbsoluteTime AppStartLaunchTime;
int main(int argc, char * argv[]) {
AppStartLaunchTime = CFAbsoluteTimeGetCurrent();
.....
}
// 2. 在 AppDelegate.m 的开头声明
extern CFAbsoluteTime AppStartLaunchTime;
// 3. 最后在AppDelegate.m 的 didFinishLaunchingWithOptions 中添加
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"App启动时间--%f",(CFAbsoluteTimeGetCurrent()-AppStartLaunchTime));
});
复制代码
Swift项目是没有main文件,但咱们能够经过添加@UIApplicationMain
标志的方式,帮咱们添加了main
函数了。因此若是是咱们须要在main
函数中作一些其它操做的话,须要咱们本身来建立main.swift
文件,这个也是苹果容许的。 咱们能够删除AppDelegate
类中的 @UIApplicationMain
标志;
而后自行建立main.swift
文件,并添加程序入口:
import UIKit
var appStartLaunchTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()
UIApplicationMain(
CommandLine.argc,
UnsafeMutableRawPointer(CommandLine.unsafeArgv)
.bindMemory(
to: UnsafeMutablePointer<Int8>.self,
capacity: Int(CommandLine.argc)),
nil,
NSStringFromClass(AppDelegate.self)
)
复制代码
而后在AppDelegate
的didFinishLaunchingWithOptions :
方法最后添加:
// APP启动时间耗时,从mian函数开始到didFinishLaunchingWithOptions方法结束
DispatchQueue.main.async {
print("APP启动时间耗时,从mian函数开始到didFinishLaunchingWithOptions方法:(CFAbsoluteTimeGetCurrent() - appStartLaunchTime)。")
}
复制代码
总的说来,main
函数以后的优化有如下方式:
- 尽可能使用纯代码编写,减小xib的使用;
- 启动阶段的网络请求,是否都放到异步请求;
- 一些耗时的操做是否能够放到后面去执行,或异步执行等。
- 使用简单的广告页做为过渡,将首页的计算操做及网络请求放在广告页展现时异步进行。
- 涉及活动需变动页面展现时(例如双十一),提早下发数据缓存
- 首页控制器用纯代码方式来构建,而不是 xib/Storyboard,避免布局转换耗时。
- 避免在主线程进行大量的计算,将与首屏无关的计算内容放在页面展现后进行,缩短 CPU 计算时间。
- 避免使用大图片,减小视图数量及层级,减轻 GPU 的负担。
- 作好网络请求接口优化(DNS 策略等),只请求与首屏相关数据。
- 本地缓存首屏数据,待渲染完成后再去请求新数据。
上面1.1.1和1.1.2将的App启动相关的优化,都是基于一些代码层面,设计方面尽可能作到好的优化减小启动时间。咱们还有一种重操做系统底层原理方面的优化,也是属于main
函数执行以前阶段的优化。
学过操做系统原理,咱们就会知道咱们操做系统加载内存的时候有分页和分段两种方式,因为手机的实际内存的限制,通常操做系统给咱们的内存都是虚拟内存,也就是说内存须要作映射。如分页存储的方式,若是咱们App须要的内存很大,App一次只能加载有限的内存页数,不能一次性将App全部的内存所有加载到内存中。 若是在APP启动过程当中发现开始加载的页面没有在内存中,会发生缺页中断,去从磁盘找到缺乏的页,重新加入内存。而缺页中断是很耗时的,虽然是毫秒级别的,可是,若是连续发生了屡次这样的中断,则用户会明显感受到启动延迟的问题。
知道了这个原理,咱们就须要从分页这个方面来解决。咱们用二进制重排的思想就是要将咱们APP启动所须要的相关类,在编译阶段都从新排列,排到最前面,尽可能避免,减小缺页中断发生的次数,从而达到启动优化的目的。
下面咱们来详细分析一下内存加载的原理
在早期的计算机中 , 并无虚拟内存的概念 , 任何应用被从磁盘中加载到运行内存中时 , 都是完整加载和按序排列的 . 可是这样直接使用物理内存会存在一些问题:
- 安全问题 : 因为在内存条中使用的都是真实物理地址 , 并且内存条中各个应用进程都是按顺序依次排列的 . 那么在 进程1 中经过地址偏移就能够访问到 其余进程 的内存 .
- 效率问题 : 随着软件的发展 , 一个软件运行时须要占用的内存愈来愈多 , 但每每用户并不会用到这个应用的全部功能 , 形成很大的内存浪费 , 然后面打开的进程每每须要排队等待 .
为了解决上面物理内存存在的问题,引入了虚拟内存的概念。引用了虚拟内存后 , 在咱们进程中认为本身有一大片连续的内存空间其实是虚拟的 , 也就是说从 0x000000 ~ 0xffffff 咱们是均可以访问的 . 可是实际上这个内存地址只是一个虚拟地址 , 而这个虚拟地址经过一张映射表映射后才能够获取到真实的物理地址 .
整个虚拟内存的工做原理这里用一张图来展现 :
引用虚拟内存后就不存在经过偏移能够访问到其余进程的地址空间的问题了 。由于每一个进程的映射表是单独的 , 在你的进程中随便你怎么访问 , 这些地址都是受映射表限制的 , 其真实物理地址永远在规定范围内 , 也就不存在经过偏移获取到其余进程的内存空间的问题了 .
引入虚拟内存后 , cpu 在经过虚拟内存地址访问数据须要经过映射来找到真实的物理地址。过程以下:
- 经过虚拟内存地址 , 找到对应进程的映射表 .
- 经过映射表找到其对应的真实物理地址 , 进而找到数据 .
学过操做系统,咱们知道cpu内存寻址有两种方式:分页和分段两种方式。
虚拟内存和物理内存经过映射表进行映射 , 可是这个映射并不多是一一对应的 , 那样就太过浪费内存了 ,咱们知道物理内存实际就是一段连续的空间,若是所有分配给一个应用程序使用,这样会致使其余应用得不到响应. 为了解决效率问题 , 操做系统为了高效使用内存采用了分页和分段两种方式来管理内存。
对于咱们这种多用户多进程的大部分都是采用分页的方式,操做系统将内存一段连续的内存分红不少页,每一页的大小都相同,如在 linux 系统中 , 一页内存大小为 4KB
, 在不一样平台可能各有不一样 . Mac OS 系统内核也是基于linux的, 所以也是一页为 4KB
。可是在iOS 系统中 , 一页为 16KB
。
内存被分红不少页后,就像咱们的一本很厚的书本,有不少页,可是这么多页,若是没有目录,咱们很难找到咱们真正须要的那一页。而操做系统采用一个高速缓存来存放须要提早加载的页数。因为CPU的时间片很宝贵,CPU要负责作不少重要的事情,而直接从磁盘读取数据到内存的IO操做很是耗时,为了提升效率,采用了高速缓存模式,就是先将一部分须要的分页加载到高速缓存中,CPU须要读取的时候直接从高速缓存读取,而不去直接方法磁盘,这样就大大提升了CPU的使用效率,可是咱们高速缓存大小也是颇有限的,加载的页数是有限的,若是CPU须要读取的分页不在高速缓存中,则会发生缺页中断,从磁盘将须要的页加载到高速缓存。
以下图,是两个进程的虚拟页表映射关系:
当应用被加载到内存中时 , 并不会将整个应用加载到内存中 . 只会放用到的那一部分 . 也就是懒加载的概念 , 换句话说就是应用使用多少 , 实际物理内存就实际存储多少 .
当应用访问到某个地址 , 映射表中为 0 , 也就是说并无被加载到物理内存中时 , 系统就会马上阻塞整个进程 , 触发一个咱们所熟知的 缺页中断 - Page Fault
.
当一个缺页中断被触发 , 操做系统会从磁盘中从新读取这页数据到物理内存上 , 而后将映射表中虚拟内存指向对应 ( 若是当前内存已满 , 操做系统会经过置换页算法 找一页数据进行覆盖 , 这也是为何开再多的应用也不会崩掉 , 可是以前开的应用再打开时 , 就从新启动了的根本缘由 ).
操做系统经过这种分页和覆盖机制 , 就完美的解决了内存浪费和效率问题,可是因为采用了虚拟内存 , 那么其中一个函数不管如何运行 , 运行多少次 , 都会是虚拟内存中的固定地址 . 这样就会有漏洞,黑客能够很轻易的提早写好程序获取固定函数的实现进行修改 hook
操做 . 因此产生这个很是严重的安全性问题。
例如:假设应用有一个函数 , 基于首地址偏移量为
0x00a000
, 那么虚拟地址从0x000000 ~ 0xffffff
, 基于这个 , 那么这个函数我不管如何只须要经过0x00a000
这个虚拟地址就能够拿到其真实实现地址 .
为了解决上面安全问题,引入了ASLR
技术 . 其原理就是 每次 虚拟地址在映射真实地址以前 , 增长一个随机偏移值。
Android 4.0 , Apple iOS4.3 , OS X Mountain Lion10.8
开始全民引入 ASLR 技术 , 而实际上自从引入ASLR
后 , 黑客的门槛也自此被拉高 . 再也不是人人均可作黑客的年代
经过上面对内存加载原理的讲解,咱们了解了分页和缺页中断。而咱们接下来要讲解的启动优化--二进制重排技术
就是基于上面的原理,尽可能减小缺页中断发生的次数,从而达到减小启动时间的损耗,最终达到启动时间优化的目的。
在了解了内存分页会触发中断异常 Page Fault
会阻塞进程后 , 咱们就知道了这个问题是会对性能产生影响的 . 实际上在 iOS 系统中 , 对于生产环境的应用 , 当产生缺页中断进行从新加载时 , iOS 系统还会对其作一次签名验证 . 所以 iOS 生产环境的应用 page fault
所产生的耗时要更多 .
抖音团队分享的一个
Page Fault
,开销在0.6 ~ 0.8ms
, 实际测试发现不一样页会有所不一样 , 也跟 cpu 负荷状态有关 , 在0.1 ~ 1.0 ms
之间 。
当用户使用应用时 , 第一个直接印象就是启动 app
耗时 , 而恰巧因为启动时期有大量的类 , 分类 , 三方 等等须要加载和执行 , 多个 page fault
所产生的的耗时每每是不能小觑的 . 这也是二进制重排进行启动优化的必要性 .
假设在启动时期咱们须要调用两个函数 method1
与 method4
. 函数编译在 mach-o
中的位置是根据 ld
( Xcode
的连接器) 的编译顺序并不是调用顺序来的 . 所以极可能这两个函数分布在不一样的内存页上 .
那么启动时 , page1
与 page2
则都须要从无到有加载到物理内存中 , 从而触发两次 page fault
.
而二进制重排的作法就是将 method1
与 method4
放到一个内存页中 , 那么启动时则只须要加载 page1
便可 , 也就是只触发一次 page fault
, 达到优化目的 .
实际项目中的作法是将启动时须要调用的函数放到一块儿 ( 好比 前10页中 ) 以尽量减小 page fault
, 达到优化目的 . 而这个作法就叫作 : 二进制重排 .
若是想查看真实 page fault
次数 , 应该将应用卸载 , 查看第一次应用安装后的效果 , 或者先打开不少个其余应用 .
由于以前运行过 app
, 应用其中一部分已经被加载到物理内存并作好映射表映射 , 这时再启动就会少触发一部分缺页中断 , 而且杀掉应用再打开也是如此 .
其实就是但愿将物理内存中以前加载的覆盖/清理掉 , 减小偏差 .
查看步骤以下:
打开 Instruments
, 选择 System Trace
.
选择真机 , 选择工程 , 点击启动 , 当首个页面加载出来点击中止 . 这里注意 , 最好是将应用杀掉从新安装 , 由于冷热启动的界定其实因为进程的缘由并不必定后台杀掉应用从新打开就是冷启动 .
以下图是后台杀掉重启应用的状况:
以下图是第一次安装启动应用的状况:
此外,你还能够经过添加 DYLD_PRINT_STATISTICS
来查看 pre-main
阶段总耗时来作一个侧面辅证 .
二进制重排具体操做,其实很简单 , Xcode 已经提供好这个机制 , 而且 libobjc
实际上也是用了二进制重排进行优化 .
在objc4-750源码中提供了libobjc.order
以下图:
咱们在Xcode中经过以下步骤来进行二进制重排:
首先 , Xcode 是用的连接器叫作 ld
, ld
有一个参数叫 Order File
, 咱们能够经过这个参数配置一个 order
文件的路径 .
在这个 order
文件中 , 将你须要的符号按顺序写在里面
当工程 build
的时候 , Xcode 会读取这个文件 , 打的二进制包就会按照这个文件中的符号顺序进行生成对应的 mach-O
.
如何查看本身工程的符号顺序
重排先后咱们须要查看本身的符号顺序有没有修改为功 , 这时候就用到了 Link Map
.
Link Map
是编译期间产生的产物 , ( ld
的读取二进制文件顺序默认是按照 Compile Sources - GUI
里的顺序 ) , 它记录了二进制文件的布局 . 经过设置 Write Link Map File
来设置输出与否 , 默认是 no
.
clean
一下 , 运行工程 ,
Products - show in finder
, 找到
macho
的上上层目录.
按下图依次找到最新的一个 .txt 文件并打开.
这个文件中就存储了全部符号的顺序 , 在 # Symbols: 部分:
page fault
的次数从而实现时间上的优化.
能够看到 , 这个符号顺序明显是按照 Compile Sources
的文件顺序来排列的 .
在了解卡顿产生的缘由以前,先看下屏幕显示图像的原理。
咱们先要链接一些关于CPU,GPU的相关概念:
- GPU是一个专门为图形高并发计算而量身定作的处理单元,比CPU使用更少的电来完成工做而且GPU的浮点计算能力要超出CPU不少。
- GPU的渲染性能要比CPU高效不少,同时对系统的负载和消耗也更低一些,因此在开发中,咱们应该尽可能让CPU负责主线程的UI调动,把图形显示相关的工做交给GPU来处理,当涉及到光栅化等一些工做时,CPU也会参与进来,这点在后面再详细描述。
- 相对于CPU来讲,GPU能干的事情比较单一:接收提交的纹理(Texture)和顶点描述(三角形),应用变换(transform)、混合(合成)并渲染,而后输出到屏幕上。一般你所能看到的内容,主要也就是纹理(图片)和形状(三角模拟的矢量图形)两类。
CPU 和 GPU 的协做:
垂直同步技术:让CPU和GPU在收到vSync信号后再开始准备数据,防止撕裂感和跳帧,通俗来说就是保证每秒输出的帧数不高于屏幕显示的帧数。
双缓冲技术:iOS是双缓冲机制,前帧缓存和后帧缓存,cpu计算完GPU渲染后放入缓冲区中,当gpu下一帧已经渲染完放入缓冲区,且视频控制器已经读完前帧,GPU会等待vSync(垂直同步信号)信号发出后,瞬间切换先后帧缓存,并让cpu开始准备下一帧数据 安卓4.0后采用三重缓冲,多了一个后帧缓冲,可下降连续丢帧的可能性,但会占用更多的CPU和GPU
如今的手机设备基本都是采用双缓存+垂直同步(即V-Sync)屏幕显示技术。 如上图所示,系统内CPU、GPU和显示器是协同完成显示工做的。其中CPU负责计算显示的内容,例如视图建立、布局计算、图片解码、文本绘制等等。随后CPU将计算好的内容提交给GPU,由GPU进行变换、合成、渲染。GPU会预先渲染好一帧放入一个缓冲区内,让视频控制器读取,当下一帧渲染好后,GPU会直接将视频控制器的指针指向第二个容器(双缓存原理)。这里,GPU会等待显示器的VSync(即垂直同步)信号发出后,才进行新的一帧渲染和缓冲区更新(这样能解决画面撕裂现象,也增长了画面流畅度,但须要消费更多的计算资源,也会带来部分延迟)。
- CPU: 计算视图frame,图片解码,须要绘制纹理图片经过数据总线交给GPU
- GPU: 纹理混合,顶点变换与计算,像素点的填充计算,渲染到帧缓冲区。
- 时钟信号:垂直同步信号V-Sync / 水平同步信号H-Sync。
- iOS设备双缓冲机制:显示系统一般会引入两个帧缓冲区,双缓冲机制
- 图片显示到屏幕上是CPU与GPU的协做完成
总的说来,图片渲染到屏幕的过程:
读取文件->计算Frame->图片解码->解码后纹理图片位图数据经过数据总线交给GPU->GPU获取图片Frame->顶点变换计算->光栅化->根据纹理坐标获取每一个像素点的颜色值(若是出现透明值须要将每一个像素点的颜色*透明度值)->渲染到帧缓存区->渲染到屏幕
- 假设咱们使用 +imageWithContentsOfFile: 方法从磁盘中加载一张图片,这个时候的图片并无解压缩;
- 而后将生成的 UIImage 赋值给 UIImageView
- 接着一个隐式的 CATransaction 捕获到了 UIImageView 图层树的变化
- 在主线程的下一个 runloop 到来时,Core Animation 提交了这个隐式的 transaction ,这个过程可能会对图片进行 copy 操做,而受图片是否字节对齐等因素的影响,这个 copy 操做可能会涉及如下部分或所有步骤: (1).分配内存缓冲区用于管理文件 IO 和解压缩操做 (2). 将文件数据从磁盘读到内存中; (3).将压缩的图片数据解码成未压缩的位图形式,这是一个很是耗时的 CPU 操做; (4). 最后 Core Animation 中CALayer使用未压缩的位图数据渲染 UIImageView 的图层。 (5). CPU计算好图片的Frame,对图片解压以后.就会交给GPU来作图片渲染
- 渲染流程: (1).GPU获取获取图片的坐标 (2).将坐标交给顶点着色器(顶点计算) (3).将图片光栅化(获取图片对应屏幕上的像素点) (4). 片元着色器计算(计算每一个像素点的最终显示的颜色值) (5).从帧缓存区中渲染到屏幕上
既然图片的解压缩须要消耗大量的 CPU 时间,那么咱们为何还要对图片进行解压缩呢?是否能够不通过解压缩,而直接将图片显示到屏幕上呢?答案是否认的。要想弄明白这个问题,咱们首先须要知道什么是位图
其实,位图就是一个像素数组,数组中的每一个像素就表明着图片中的一个点。咱们在应用中常常用到的 JPEG 和 PNG 图片就是位图
你们能够尝试
UIImage *image = [UIImage imageNamed:@"text.png"];
CFDataRef rawData = CGDataProviderCopyData(CGImageGetDataProvider(image.CGImage));
复制代码
打印rawData,这里就是图片的原始数据.
事实上,无论是 JPEG 仍是 PNG 图片,都是一种压缩的位图图形格式。只不过 PNG 图片是无损压缩,而且支持 alpha 通道,而 JPEG 图片则是有损压缩,能够指定 0-100% 的压缩比。值得一提的是,在苹果的 SDK 中专门提供了两个函数用来生成 PNG 和 JPEG 图片:
// return image as PNG. May return nil if image has no CGImageRef or invalid bitmap format
UIKIT_EXTERN NSData * __nullable UIImagePNGRepresentation(UIImage * __nonnull image);
// return image as JPEG. May return nil if image has no CGImageRef or invalid bitmap format. compression is 0(most)..1(least)
UIKIT_EXTERN NSData * __nullable UIImageJPEGRepresentation(UIImage * __nonnull image, CGFloat compressionQuality);
复制代码
所以,在将磁盘中的图片渲染到屏幕以前,必须先要获得图片的原始像素数据,才能执行后续的绘制操做,这就是为何须要对图片解压缩的缘由。
既然图片的解压缩不可避免,而咱们也不想让它在主线程执行,影响咱们应用的响应性,那么是否有比较好的解决方案呢?
咱们前面已经提到了,当未解压缩的图片将要渲染到屏幕时,系统会在主线程对图片进行解压缩,而若是图片已经解压缩了,系统就不会再对图片进行解压缩。所以,也就有了业内的解决方案,在子线程提早对图片进行强制解压缩。
而强制解压缩的原理就是对图片进行从新绘制,获得一张新的解压缩后的位图。其中,用到的最核心的函数是 CGBitmapContextCreate
:
CG_EXTERN CGContextRef __nullable CGBitmapContextCreate(void * __nullable data,
size_t width, size_t height, size_t bitsPerComponent, size_t bytesPerRow,
CGColorSpaceRef cg_nullable space, uint32_t bitmapInfo)
CG_AVAILABLE_STARTING(__MAC_10_0, __IPHONE_2_0);
复制代码
函数参数解释:
data
:若是不为 NULL ,那么它应该指向一块大小至少为 bytesPerRow * height 字节的内存;若是 为 NULL ,那么系统就会为咱们自动分配和释放所需的内存,因此通常指定 NULL 便可;
width 和height
:位图的宽度和高度,分别赋值为图片的像素宽度和像素高度便可;
bitsPerComponent
:像素的每一个颜色份量使用的 bit 数,在 RGB 颜色空间下指定 8 便可;
bytesPerRow
:位图的每一行使用的字节数,大小至少为 width * bytes per pixel 字节。当咱们指定 0/NULL 时,系统不只会为咱们自动计算,并且还会进行 cache line alignment 的优化
space
:就是咱们前面提到的颜色空间,通常使用 RGB 便可;
bitmapInfo
:位图的布局信息.kCGImageAlphaPremultipliedFirst
YYImage中解压图片的代码: YYImage
用于解压缩图片的函数 YYCGImageCreateDecodedCopy
存在于 YYImageCoder
类中,核心代码以下:
CGImageRef YYCGImageCreateDecodedCopy(CGImageRef imageRef, BOOL decodeForDisplay) {
...
if (decodeForDisplay) { // decode with redraw (may lose some precision)
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef) & kCGBitmapAlphaInfoMask;
BOOL hasAlpha = NO;
if (alphaInfo == kCGImageAlphaPremultipliedLast ||
alphaInfo == kCGImageAlphaPremultipliedFirst ||
alphaInfo == kCGImageAlphaLast ||
alphaInfo == kCGImageAlphaFirst) {
hasAlpha = YES;
}
// BGRA8888 (premultiplied) or BGRX8888
// same as UIGraphicsBeginImageContext() and -[UIView drawRect:]
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, YYCGColorSpaceGetDeviceRGB(), bitmapInfo);
if (!context) return NULL;
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef); // decode
CGImageRef newImage = CGBitmapContextCreateImage(context);
CFRelease(context);
return newImage;
} else {
...
}
}
复制代码
它接受一个原始的位图参数 imageRef ,最终返回一个新的解压缩后的位图 newImage ,中间主要通过了如下三个步骤:
- 使用 CGBitmapContextCreate 函数建立一个位图上下文;
- 使用 CGContextDrawImage 函数将原始位图绘制到上下文中;
- 使用 CGBitmapContextCreateImage 函数建立一张新的解压缩后的位图。
事实上,SDWebImage 中对图片的解压缩过程与上述彻底一致,只是传递给 CGBitmapContextCreate 函数的部分参数存在细微的差异. SDWebImage和YYImage解压图片性能对比:
- 在解压PNG图片,SDWebImage>YYImage
- 在解压JPEG图片,SDWebImage<YYImage
SDWebImage
解压图片的核心代码以下:
SDWebImage的使用:
CGImageRef imageRef = image.CGImage;
// device color space
CGColorSpaceRef colorspaceRef = SDCGColorSpaceGetDeviceRGB();
BOOL hasAlpha = SDCGImageRefContainsAlpha(imageRef);
// iOS display alpha info (BRGA8888/BGRX8888)
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);
// kCGImageAlphaNone is not supported in CGBitmapContextCreate.
// Since the original image here has no alpha info, use kCGImageAlphaNoneSkipLast
// to create bitmap graphics contexts without alpha info.
CGContextRef context = CGBitmapContextCreate(NULL,
width,
height,
kBitsPerComponent,
0,
colorspaceRef,
bitmapInfo);
if (context == NULL) {
return image;
}
// Draw the image into the context and retrieve the new bitmap image without alpha
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
CGImageRef imageRefWithoutAlpha = CGBitmapContextCreateImage(context);
UIImage *imageWithoutAlpha = [[UIImage alloc] initWithCGImage:imageRefWithoutAlpha scale:image.scale orientation:image.imageOrientation];
CGContextRelease(context);
CGImageRelease(imageRefWithoutAlpha);
return imageWithoutAlpha;
复制代码
上面讲解了图片显示的原理和屏幕渲染的原理,形成卡顿的缘由有不少,最主要的缘由是由于发生了掉帧,以下图:
由上面屏幕显示的原理,采用了垂直同步机制的手机设备。在 VSync 信号到来后,系统图形服务会经过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容,好比视图的建立、布局计算、图片解码、文本绘制等。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。因为垂直同步的机制,若是在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留以前的内容不变。这就是界面卡顿的缘由。
在开发中,CPU和GPU中任何一个压力过大,都会致使掉帧现象,因此在开发时,也须要分别对CPU和GPU压力进行评估和优化。
卡顿监控通常有两种实现方案:
- 主线程卡顿监控。经过子线程监测主线程的runLoop,判断两个状态区域之间的耗时是否达到必定阈值。
- FPS监控。要保持流畅的UI交互,App 刷新率应该当努力保持在 60fps。FPS的监控实现原理,上面已经探讨过这里略过。
在使用FPS监控性能的实践过程当中,发现 FPS 值抖动较大,形成侦测卡顿比较困难。为了解决这个问题,经过采用检测主线程每次执行消息循环的时间,当这一时间大于规定的阈值时,就记为发生了一次卡顿的方式来监控。 这也是美团的移动端采用的性能监控Hertz 方案,微信团队也在实践过程当中提出来相似的方案--微信读书 iOS 性能优化总结。
以下图是美团Hertz方案流程图:
方案的提出,是根据滚动引起的Sources事件或其它交互事件老是被快速的执行完成,而后进入到kCFRunLoopBeforeWaiting状态下;假如在滚动过程当中发生了卡顿现象,那么RunLoop必然会保持kCFRunLoopAfterWaiting或者kCFRunLoopBeforeSources这两个状态之一。 因此监控主线程卡顿的方案一:
开辟一个子线程,而后实时计算 kCFRunLoopBeforeSources
和 kCFRunLoopAfterWaiting
两个状态区域之间的耗时是否超过某个阀值,来判定主线程的卡顿状况。 可是因为主线程的RunLoop
在闲置时基本处于Before Waiting
状态,这就致使了即使没有发生任何卡顿,这种检测方式也总能认定主线程处在卡顿状态。 为了解决这个问题寒神(南栀倾寒)给出了本身的解决方案,Swift
的卡顿检测第三方ANREye
。这套卡顿监控方案大体思路为:建立一个子线程进行循环检测,每次检测时设置标记位为YES,而后派发任务到主线程中将标记位设置为NO。接着子线程沉睡超时阙值时长,判断标志位是否成功设置成NO,若是没有说明主线程发生了卡顿。 结合这套方案,当主线程处在Before Waiting
状态的时候,经过派发任务到主线程来设置标记位的方式处理常态下的卡顿检测:
#define lsl_SEMAPHORE_SUCCESS 0
static BOOL lsl_is_monitoring = NO;
static dispatch_semaphore_t lsl_semaphore;
static NSTimeInterval lsl_time_out_interval = 0.05;
@implementation LSLAppFluencyMonitor
static inline dispatch_queue_t __lsl_fluecy_monitor_queue() {
static dispatch_queue_t lsl_fluecy_monitor_queue;
static dispatch_once_t once;
dispatch_once(&once, ^{
lsl_fluecy_monitor_queue = dispatch_queue_create("com.dream.lsl_monitor_queue", NULL);
});
return lsl_fluecy_monitor_queue;
}
static inline void __lsl_monitor_init() {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
lsl_semaphore = dispatch_semaphore_create(0);
});
}
#pragma mark - Public
+ (instancetype)monitor {
return [LSLAppFluencyMonitor new];
}
- (void)startMonitoring {
if (lsl_is_monitoring) { return; }
lsl_is_monitoring = YES;
__lsl_monitor_init();
dispatch_async(__lsl_fluecy_monitor_queue(), ^{
while (lsl_is_monitoring) {
__block BOOL timeOut = YES;
dispatch_async(dispatch_get_main_queue(), ^{
timeOut = NO;
dispatch_semaphore_signal(lsl_semaphore);
});
[NSThread sleepForTimeInterval: lsl_time_out_interval];
if (timeOut) {
[LSLBacktraceLogger lsl_logMain]; // 打印主线程调用栈
// [LSLBacktraceLogger lsl_logCurrent]; // 打印当前线程的调用栈
// [LSLBacktraceLogger lsl_logAllThread]; // 打印全部线程的调用栈
}
dispatch_wait(lsl_semaphore, DISPATCH_TIME_FOREVER);
}
});
}
- (void)stopMonitoring {
if (!lsl_is_monitoring) { return; }
lsl_is_monitoring = NO;
}
@end
其中LSLBacktraceLogger是获取堆栈信息的类,详情见代码Github。
打印日志以下:
2018-08-16 12:36:33.910491+0800 AppPerformance[4802:171145] Backtrace of Thread 771:
======================================================================================
libsystem_kernel.dylib 0x10d089bce __semwait_signal + 10
libsystem_c.dylib 0x10ce55d10 usleep + 53
AppPerformance 0x108b8b478 $S14AppPerformance25LSLFPSTableViewControllerC05tableD0_12cellForRowAtSo07UITableD4CellCSo0kD0C_10Foundation9IndexPathVtF + 1144
AppPerformance 0x108b8b60b $S14AppPerformance25LSLFPSTableViewControllerC05tableD0_12cellForRowAtSo07UITableD4CellCSo0kD0C_10Foundation9IndexPathVtFTo + 155
UIKitCore 0x1135b104f -[_UIFilteredDataSource tableView:cellForRowAtIndexPath:] + 95
UIKitCore 0x1131ed34d -[UITableView _createPreparedCellForGlobalRow:withIndexPath:willDisplay:] + 765
UIKitCore 0x1131ed8da -[UITableView _createPreparedCellForGlobalRow:willDisplay:] + 73
UIKitCore 0x1131b4b1e -[UITableView _updateVisibleCellsNow:isRecursive:] + 2863
UIKitCore 0x1131d57eb -[UITableView layoutSubviews] + 165
UIKitCore 0x1133921ee -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 1501
QuartzCore 0x10ab72eb1 -[CALayer layoutSublayers] + 175
QuartzCore 0x10ab77d8b _ZN2CA5Layer16layout_if_neededEPNS_11TransactionE + 395
QuartzCore 0x10aaf3b45 _ZN2CA7Context18commit_transactionEPNS_11TransactionE + 349
QuartzCore 0x10ab285b0 _ZN2CA11Transaction6commitEv + 576
QuartzCore 0x10ab29374 _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv + 76
CoreFoundation 0x109dc3757 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 23
CoreFoundation 0x109dbdbde __CFRunLoopDoObservers + 430
CoreFoundation 0x109dbe271 __CFRunLoopRun + 1537
CoreFoundation 0x109dbd931 CFRunLoopRunSpecific + 625
GraphicsServices 0x10f5981b5 GSEventRunModal + 62
UIKitCore 0x112c812ce UIApplicationMain + 140
AppPerformance 0x108b8c1f0 main + 224
libdyld.dylib 0x10cd4dc9d start + 1
复制代码
方案二: 是结合CADisplayLink
的方式实现
经过维基百科咱们知道,FPS是Frames Per Second 的简称缩写,意思是每秒传输帧数,也就是咱们常说的“刷新率(单位为Hz)。 FPS是测量用于保存、显示动态视频的信息数量。每秒钟帧数愈多,所显示的画面就会愈流畅,FPS值越低就越卡顿,因此这个值在必定程度上能够衡量应用在图像绘制渲染处理时的性能。通常咱们的APP的FPS只要保持在 50-60之间,用户体验都是比较流畅的。 苹果手机屏幕的正常刷新频率是每秒60次,便可以理解为FPS值为60。咱们都知道
CADisplayLink
是和屏幕刷新频率保存一致,因此咱们是否能够经过它来监控咱们的FPS呢?
CADisplayLink
是什么?
CADisplayLink
是CoreAnimation
提供的另外一个相似于NSTimer
的类,它老是在屏幕完成一次更新以前启动,它的接口设计的和NSTimer
很相似,因此它实际上就是一个内置实现的替代,可是和timeInterval
以秒为单位不一样,CADisplayLink
有一个整型的frameInterval
属性,指定了间隔多少帧以后才执行。默认值是1,意味着每次屏幕更新以前都会执行一次。可是若是动画的代码执行起来超过了六十分之一秒,你能够指定frameInterval
为2,就是说动画每隔一帧执行一次(一秒钟30帧)。
使用CADisplayLink
监控界面的FPS
值,参考自YYFPSLabel
:
import UIKit
class LSLFPSMonitor: UILabel {
private var link: CADisplayLink = CADisplayLink.init()
private var count: NSInteger = 0
private var lastTime: TimeInterval = 0.0
private var fpsColor: UIColor = UIColor.green
public var fps: Double = 0.0
// MARK: - init
override init(frame: CGRect) {
var f = frame
if f.size == CGSize.zero {
f.size = CGSize(width: 55.0, height: 22.0)
}
super.init(frame: f)
self.textColor = UIColor.white
self.textAlignment = .center
self.font = UIFont.init(name: "Menlo", size: 12.0)
self.backgroundColor = UIColor.black
link = CADisplayLink.init(target: LSLWeakProxy(target: self), selector: #selector(tick))
link.add(to: RunLoop.current, forMode: RunLoopMode.commonModes)
}
deinit {
link.invalidate()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - actions
@objc func tick(link: CADisplayLink) {
guard lastTime != 0 else {
lastTime = link.timestamp
return
}
count += 1
let delta = link.timestamp - lastTime
guard delta >= 1.0 else {
return
}
lastTime = link.timestamp
fps = Double(count) / delta
let fpsText = "(String.init(format: "%.3f", fps)) FPS"
count = 0
let attrMStr = NSMutableAttributedString(attributedString: NSAttributedString(string: fpsText))
if fps > 55.0{
fpsColor = UIColor.green
} else if(fps >= 50.0 && fps <= 55.0) {
fpsColor = UIColor.yellow
} else {
fpsColor = UIColor.red
}
attrMStr.setAttributes([NSAttributedStringKey.foregroundColor:fpsColor], range: NSMakeRange(0, attrMStr.length - 3))
attrMStr.setAttributes([NSAttributedStringKey.foregroundColor:UIColor.white], range: NSMakeRange(attrMStr.length - 3, 3))
DispatchQueue.main.async {
self.attributedText = attrMStr
}
}
}
复制代码
经过CADisplayLink
的实现方式,并真机测试以后,确实是能够在很大程度上知足了监控FPS
的业务需求和为提升用户体验提供参考,可是和Instruments
的值可能会有些出入。下面咱们来讨论下使用CADisplayLink
的方式,可能存在的问题。 (1). 和Instruments
值对比有出入,缘由以下: CADisplayLink
运行在被添加的那个RunLoop
之中(通常是在主线程中),所以它只能检测出当前RunLoop
下的帧率。RunLoop中所管理的任务的调度时机,受任务所处的RunLoopMode
和CPU的繁忙程度所影响。因此想要真正定位到准确的性能问题所在,最好仍是经过Instrument
来确认。 (2). 使用CADisplayLink
可能存在的循环引用问题。
例如如下写法:
let link = CADisplayLink.init(target: self, selector: #selector(tick))
let timer = Timer.init(timeInterval: 1.0, target: self, selector: #selector(tick), userInfo: nil, repeats: true)
复制代码
缘由:以上两种用法,都会对 self
强引用,此时 timer
持有 self
,self
也持有 timer
,循环引用致使页面 dismiss
时,双方都没法释放,形成循环引用。此时使用 weak
也不能有效解决:
weak var weakSelf = self
let link = CADisplayLink.init(target: weakSelf, selector: #selector(tick))
复制代码
那么咱们应该怎样解决这个问题,有人会说在deinit
(或dealloc
)中调用定时器的invalidate
方法,可是这是无效的,由于已经形成循环引用了,不会走到这个方法的。
YYKit
做者提供的解决方案是使用 YYWeakProxy
,这个YYWeakProxy
不是继承自NSObject
而是继承NSProxy
。
NSProxy
是一个为对象定义接口的抽象父类,而且为其它对象或者一些不存在的对象扮演了替身角色。
修改后代码以下:
let link = CADisplayLink.init(target: LSLWeakProxy(target: self), selector: #selector(tick))
复制代码
卡顿优化从 CPU层面的 相关优化,有下面这些方式:
- 尽可能用轻量级的对象,好比用不到事件处理的地方使用
CALayer
取代UIView
- 尽可能提早计算好布局(例如
cell
行高)- 不要频繁地调用和调整
UIView
的相关属性,好比frame
、bounds
、transform
等属性,尽可能减小没必要要的调用和修改(UIView
的显示属性实际都是CALayer
的映射,而CALayer
自己是没有这些属性的,都是初次调用属性时经过resolveInstanceMethod
添加并建立Dictionary
保存的,耗费资源)Autolayout
会比直接设置frame
消耗更多的CPU
资源,当视图数量增加时会呈指数级增加.- 图片的
size
最好恰好跟UIImageView
的size
保持一致,减小图片显示时的处理计算- 控制一下线程的最大并发数量
- 尽可能把耗时的操做放到子线程
- 文本处理(尺寸计算、绘制、
CoreText
和YYText
): (1). 计算文本宽高boundingRectWithSize:options:context:
和文本绘制drawWithRect:options:context:
放在子线程操做 (2). 使用CoreText
自定义文本空间,在对象建立过程当中能够缓存宽高等信息,避免像UILabel/UITextView
须要屡次计算(调整和绘制都要计算一次),且CoreText
直接使用了CoreGraphics
占用内存小,效率高。(YYText
)- 图片处理(解码、绘制) 图片都须要先解码成
bitmap
才能渲染到UI上,iOS建立UIImage
,不会马上进行解码,只有等到显示前才会在主线程进行解码,固能够使用CoreGraphics
中的CGBitmapContextCreate
相关操做提早在子线程中进行强制解压缩得到位图.- TableViewCell 复用: 在
cellForRowAtIndexPath:
回调的时候只建立实例,快速返回cell
,不绑定数据。在willDisplayCell: forRowAtIndexPath:
的时候绑定数据(赋值)- 高度缓存: 在
tableView
滑动时,会不断调用heightForRowAtIndexPath:
,当cell
高度须要自适应时,每次回调都要计算高度,会致使 UI 卡顿。为了不重复无心义的计算,须要缓存高度。- 视图层级优化: 不要动态建立视图,在内存可控的前提下,缓存
subview
。善用hidden
。- 减小视图层级: 减小
subviews
个数,用layer
绘制元素. 少用clearColor
,maskToBounds
,阴影效果等。- 减小多余的绘制操做.
- 图片优化: (1)不要用
JPEG
的图片,应当使用PNG
图片。 (2)子线程预解码(Decode
),主线程直接渲染。由于当image
没有Decode
,直接赋值给imageView
会进行一个Decode操做。 (3)优化图片大小,尽可能不要动态缩放(contentMode
)。 (4)尽量将多张图片合成为一张进行显示。- 减小透明
view
: 使用透明view会引发blending
,在iOS的图形处理中,blending
主要指的是混合像素颜色的计算。最直观的例子就是,咱们把两个图层叠加在一块儿,若是第一个图层的透明的,则最终像素的颜色计算须要将第二个图层也考虑进来。这一过程即为Blending
。- 理性使用
-drawRect
: 当你使用UIImageView
在加载一个视图的时候,这个视图虽然依然有CALayer
,可是却没有申请到一个后备的存储,取而代之的是使用一个使用屏幕外渲染,将CGImageRef
做为内容,并用渲染服务将图片数据绘制到帧的缓冲区,就是显示到屏幕上,当咱们滚动视图的时候,这个视图将会从新加载,浪费性能。因此对于使用-drawRect:
方法,更倾向于使用CALayer
来绘制图层。由于使用CALayer
的-drawInContext:,Core Animation
将会为这个图层申请一个后备存储,用来保存那些方法绘制进来的位图。那些方法内的代码将会运行在 CPU上,结果将会被上传到GPU。这样作的性能更为好些。 静态界面建议使用-drawRect:
的方式,动态页面不建议。- 按需加载: 局部刷新,刷新一个cell就能解决的,坚定不刷新整个
section
或者整个tableView
,刷新最小单元元素。 利用runloop
提升滑动流畅性,在滑动中止的时候再加载内容,像那种一闪而过的(快速滑动),就没有必要加载,能够使用默认的占位符填充内容。
Blending
补充:会致使
blending
的缘由:
UIView
的alpha
< 1。UIImageView
的image含有alpha channel
(即便UIImageView
的alpha
是1,但只要image
含有透明通道,则仍会致使blending
)。
为啥
blending
会致使性能的损失?
- 缘由是很直观的,若是一个图层是不透明的,则系统直接显示该图层的颜色便可。而若是图层是透明的,则会引发更多的计算,由于须要把另外一个的图层也包括进来,进行混合后的颜色计算。
opaque
设置为YES,减小性能消耗,由于GPU将不会作任何合成,而是简单从这个层拷贝。
GPU
层面的卡顿相关优化有下面这些方式:
- 尽可能避免短期内大量图片的显示,尽量将多张图片合成一张进行显示
GPU
能处理的最大纹理尺寸是4096x4096,一旦超过这个尺寸,就会占用CPU
资源进行处理,因此纹理尽可能不要超过这个尺寸GPU
会将多个视图混合在一块儿再去显示,混合的过程会消耗CPU资源,尽可能减小视图数量和层次- 减小透明的视图(
alpha
<1),不透明的就设置opaque
为YES
,GPU
就不会去进行alpha
的通道合成- 尽可能避免出现离屏渲染.
- 合理使用光栅化
shouldRasterize
: 光栅化是把GPU的操做转到CPU上,生成位图缓存,直接读取复用。CALayer
会被光栅化为bitmap
,shadows
、cornerRadius
等效果会被缓存。 更新已经光栅化的layer
,会形成离屏渲染。bitmap
超过100ms没有使用就会移除。 受系统限制,缓存的大小为 2.5X Screen Size。shouldRasterize
适合静态页面显示,动态页面会增长开销。若是设置了shouldRasterize
为 YES,那也要记住设置rasterizationScale
为contentsScale
。- 异步渲染.在子线程绘制,主线程渲染。例如 VVeboTableViewDemo
- 在OpenGL中,GPU有2种渲染方式 On-Screen Rendering:当前屏幕渲染,在当前用于显示的屏幕缓冲区进行渲染操做 Off-Screen Rendering:离屏渲染,在当前屏幕缓冲区之外新开辟一个缓冲区进行渲染操做
- 离屏渲染消耗性能的缘由 须要建立新的缓冲区 离屏渲染的整个过程,须要屡次切换上下文环境,先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束之后,将离屏缓冲区的渲染结果显示到屏幕上,又须要将上下文环境从离屏切换到当前屏幕
- 光栅化,
layer.shouldRasterize = YES
- 遮罩,
layer.mask
- 圆角,同时设置
layer.masksToBounds = YES
、layer.cornerRadius
大于0. 考虑经过CoreGraphics
绘制裁剪圆角,或者叫美工提供圆角图片- 阴影,
layer.shadowXXX
若是设置了layer.shadowPath
就不会产生离屏渲染.layer.allowsGroupOpacity
为YES,layer.opacity
的值小于1.0
- 使用
ShadowPath
指定layer
阴影效果路径。- 使用异步进行
layer
渲染(Facebook开源的异步绘制框架AsyncDisplayKit
)。- 设置
layer
的opaque
值为YES,减小复杂图层合成。- 尽可能使用不包含透明(
alpha
)通道的图片资源。- 尽可能设置
layer
的大小值为整形值。- 直接让美工把图片切成圆角进行显示,这是效率最高的一种方案。
- 不少状况下用户上传图片进行显示,能够在客户端处理圆角。
- 使用代码手动生成圆角
image
设置到要显示的View
上,利用UIBezierPath
(Core Graphics
框架)画出来圆角图片。
Instuments
是Xcode
套件中没有被充分利用的工具,不少iOS开发者历来没用过Instrument
,特别是经过短暂培训出来的同窗们,因此,不少面试官也会问性能条调优方面的知识,来判断面试的同窗是否真正应用对年开发经验。
- 第一种:为对象A申请了内存空间,以后再也没用过对象A,也没释放过A致使内存泄漏,这种是
Leaked Memory
内存泄漏- 第二种:相似于递归,不断地申请内存空间致使的内存泄漏,这种状况是
Abandoned Momory
Allocations
工具可让开发者很好的了解每一个方法占用内存的状况,并定位相关的代码,以下图:
右键就能够打开Xcode自动定位到相关占用内存方法的代码上
圈着数字红色方框中的数字,表明着FPS值,理论上60最佳,实际过程当中59就能够了,说明就是很流畅的,说明一下操做方式:在手指不离开屏幕的状况下,上下滑动屏幕列表 介绍一下Deug Display中选项的做用
这个选项基于渲染程度对屏幕中的混合区域进行绿到红的高亮(也就是多个半透明图层的叠加),因为重绘的缘由,混合对GPU性能会有影响,同时也是滑动或者动画掉帧的罪魁祸首之一 GPU每一帧的绘制的像素有最大限制,这个状况下能够轻易绘制整个屏幕的像素,但若是发生重叠像素的关系须要不停的重绘同一区域的,掉帧和卡顿就有可能发生。 GPU会放弃绘制那些彻底被其余图层遮挡的像素,可是要计算出一个图层是否被遮挡也是至关复杂而且会消耗CPU的资源,一样,合并不一样图层的透明重叠元素消耗的资源也很大,因此,为了快速处理,通常不要使用透明图层, 1). 给View添加一个固定、不透明的颜色 2). 设置opaque 属性为true 可是这对性能调优的帮助并不大,由于UIView的opaque 属性默认为true,也就是说,只要不是认为设置成透明,都不会出现图层混合 而对于UIIimageView来讲,不只须要自身须要不是透明的,它的图片也不能含有alpha通道,这也上图9张图片是绿色的缘由,所以图像自身的性质也可能会对结果有影响,因此你肯定本身的代码没问题,还出现了混合图层可能就是图片的问题了 而针对于屏幕中的文字高亮成红色,是由于一没有给文字的label增长不透明的背景颜色,而是当UILabel内容为中文时,label的实际渲染区域要大于label的size,由于外围有了一圈的阴影,才会出现图层混合咱们须要给中文的label加上以下代码:
retweededTextLab?.layer.masksToBounds = true
retweededTextLab?.backgroundColor = UIColor.groupTableViewBackground
statusLab.layer.masksToBounds = true
statusLab.backgroundColor = UIColor.white
复制代码
看下效果图:
statusLab.layer.masksToBounds = true
单独使用不会出现离屏渲染 2). 若是对
label
设置了圆角的话,圆角部分会离屏渲染,离屏渲染的前提是位图发生了形变
这个选项主要是检测咱们有无滥用或正确使用layer的shouldRasterize属性.成功被缓存的layer会标注为绿色,没有成功缓存的会标注为红色。 不少视图Layer因为Shadow、Mask和Gradient等缘由渲染很高,所以UIKit提供了API用于缓存这些Layer,self.layer.shouldRasterize = true系统会将这些Layer缓存成Bitmap位图供渲染使用,若是失效时便丢弃这些Bitmap从新生成。图层Rasterization栅格化好处是对刷新率影响较小,坏处是删格化处理后的Bitmap缓存须要占用内存,并且当图层须要缩放时,要对删格化后的Bitmap作额外计算。 使用这个选项后时,若是Rasterized的Layer失效,便会标注为红色,若是有效标注为绿色。当测试的应用频繁闪现出红色标注图层时,代表对图层作的Rasterization做用不大。 在测试的过程当中,第一次加载时,开启光栅化的layer会显示为红色,这是很正常的,由于尚未缓存成功。可是若是在接下来的测试,。例如咱们来回滚动TableView时,咱们仍然发现有许多红色区域,那就须要谨慎对待了
这个选项主要检查咱们有无使用不正确图片格式,因为手机显示都是基于像素的,因此当手机要显示一张图片的时候,系统会帮咱们对图片进行转化。好比一个像素占用一个字节,故而RGBA则占用了4个字节,则1920 x 1080的图片占用了7.9M左右,可是平时jpg或者png的图片并无那么大,由于它们对图片作了压缩,可是是可逆的。因此此时,若是图片的格式不正确,则系统将图片转化为像素的时间就有可能变长。而该选项就是检测图片的格式是不是系统所支持的,如果GPU不支持的色彩格式的图片则会标记为青色,则只能由CPU来进行处理。CPU被强制生成了一些图片,而后发送到渲染服务器,而不是简单的指向原始图片的的指针。咱们不但愿在滚动视图的时候,CPU实时来进行处理,由于有可能会阻塞主线程。
一般 Core Animation 以每秒10此的频率更新图层的调试颜色,对于某些效果来讲,这可能太慢了,这个选项能够用来设置每一帧都更新(可能会影响到渲染性能,因此不要一直都设置它)
这里会高亮那些被缩放或者拉伸以及没有正确对齐到像素边界的图片,即图片Size和imageView中的Size不匹配,会使图过程片缩放,而缩放会占用CPU,因此在写代码的时候保证图片的大小匹配好imageView,以下图所示: 图片尺寸 170 * 220px
能够看到图片高亮成黄色显示,更改下imageView的大小: ![]()
let imageView = UIImageView(frame: CGRect(x: 50, y: 100, width: 85, height: 110))
imageView.image = UIImage(named: "cat")
view.addSubview(imageView)
复制代码
看下效果图:
/* 圆角处理 */
view.layer.maskToBounds = truesomeView.clipsToBounds = true
/* 设置阴影 */
view.shadow..
/* 栅格化 */
view.layer.shouldRastarize = true
复制代码
针对栅格化处理,咱们须要指定屏幕的分辨率
//离屏渲染 - 异步绘制 耗电
self.layer.drawsAsynchronously = true
//栅格化 - 异步绘制以后 ,会生成一张独立的图片 cell 在屏幕上滚动的时候,本质上滚动的是这张图片
//cell 优化,要尽可能减小图层的数量,想当于只有一层
//中止滚动以后,能够接受监听
self.layer.shouldRasterize = true
//使用 “栅格化” 必须指定分辨率
self.layer.rasterizationScale = UIScreen.main.scale
复制代码
指定阴影的路径,能够防止离屏渲染
// 指定阴影曲线,防止阴影效果带来的离屏渲染
imageView.layer.shadowPath = UIBezierPath(rect: imageView.bounds).cgPath
复制代码
这行代码制定了阴影路径,若是没有手动指定,Core Animation会去自动计算,这就会触发离屏渲染。若是人为指定了阴影路径,就能够免去计算,从而避免产生离屏渲染。 设置cornerRadius自己并不会致使离屏渲染,但不少时候它还须要配合layer.masksToBounds = true使用。根据以前的总结,设置masksToBounds会致使离屏渲染。解决方案是尽量在滑动时避免设置圆角,若是必须设置圆角,能够使用光栅化技术将圆角缓存起来:
// 设置圆角
label.layer.masksToBounds = true
label.layer.cornerRadius = 8
label.layer.shouldRasterize = true
label.layer.rasterizationScale = layer.contentsScale
复制代码
若是界面中有不少控件须要设置圆角,好比tableView中,当tableView有超过25个圆角,使用以下方法
view.layer.cornerRadius = 10
view.maskToBounds = Yes
复制代码
那么fps将会降低不少,特别是对某些控件还设置了阴影效果,更会加重界面的卡顿、掉帧现象,对于不一样的控件将采用不一样的方法进行处理: 1). 对于label类,能够经过CoreGraphics来画出一个圆角的label 2). 对于imageView,经过CoreGraphics对绘画出来的image进行裁边处理,造成一个圆角的imageView,代码以下:
/// 建立圆角图片
///
/// - parameter radius: 圆角的半径
/// - parameter size: 图片的尺寸
/// - parameter backColor: 背景颜色 默认 white
/// - parameter lineWith: 圆角线宽 默认 1
/// - parameter lineColor: 线颜色 默认 darkGray
///
/// - returns: image
func yw_drawRectWithRoundCornor(radius: CGFloat, size: CGSize, backColor: UIColor = UIColor.white, lineWith: CGFloat = 1, lineColor: UIColor = UIColor.darkGray) -> UIImage? {
let rect = CGRect(origin: CGPoint(x: 0, y: 0), size: size)
UIGraphicsBeginImageContextWithOptions(rect.size, true, 0)
let bezier = UIBezierPath(roundedRect: rect, byRoundingCorners: UIRectCorner.allCorners, cornerRadii: CGSize(width: radius, height: radius))
backColor.setFill()
UIRectFill(rect)
bezier.addClip()
draw(in: rect)
bezier.lineWidth = 1
lineColor.setStroke()
bezier.stroke()
let result = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return result
}
复制代码
这个选项会对任何直接使用OpenGL绘制的图层进行高亮,若是仅仅使用UIKit或者Core Animation的API,不会有任何效果
Leaks
主要用来检查内存泄漏,在前面Allcations
里面咱们提到内存泄漏分两种,如今咱们研究Leaked Memory
, 从用户使用角度来看,内存泄漏自己不会产生什么危害,做为用户,根本感受不到内存泄漏的存在,真正的危害在于内存泄漏的堆积,最终会耗尽系统全部的内存。咱们直接看图:
instruments
中,虽然选择了
Leaks
模板,但默认状况下也会添加
Allocations
模板.基本上凡是内存分析都会使用
Allocations
模板, 它能够监控内存分布状况。 选中
Allocations
模板3区域会显示随着时间的变化内存使用的折线图,同时在4区域会显示内存使用的详细信息,以及对象分配状况. 点击
Leaks
模板, 能够查看内存泄露状况。若是在3区域有 红X 出现, 则有内存泄露, 4区域则会显示泄露的对象. 打用
leaks
进行监测:点击泄露对象能够在(下图)看到它们的内存地址, 占用字节, 所属框架和响应方法等信息.打开扩展视图, 能够看到右边的跟踪堆栈信息,4 黑色代码最有可能出现内存泄漏的方法
监测结果的分析:
Time Profiler
是Xcode自带的工具,原理是定时抓取线程的堆栈信息,经过统计比较时间间隔之间的堆栈状态,计算一段时间内各个方法的近似耗时。精确度取决于设置的定时间隔。
经过 Xcode → Open Developer Tool → Instruments → Time Profiler 打开工具,注意,需将工程中 Debug Information Format 的 Debug 值改成 DWARF with dSYM File,不然只能看到一堆线程没法定位到函数。
正常Time Profiler是每1ms采样一次, 默认只采集全部在运行线程的调用栈,最后以统计学的方式汇总。因此会没法统计到耗时太短的函数和休眠的线程,好比下图中的5次采样中,method3都没有采样到,因此最后聚合到的栈里就看不到method3。
咱们能够将 File -> Recording Options 中的配置调高,便可获取更精确的调用栈。
static void add(const struct mach_header* header, intptr_t imp) {
usleep(10000);
}
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
_dyld_register_func_for_add_image(add);
});
....
}
复制代码
能够看到整个记录过程耗时7s,但 Time Profiler 上只显示了1.17s,且看到启动后有一段时间是空白的。这时经过 System Trace 查看各个线程的具体状态。
接着咱们观察 0x5d39c 线程,发如今主线程阻塞的这段时间,该线程执行了屡次10ms的 sleep 操做,到此就找到了主线程被子线程阻塞致使启动缓慢的缘由。
从此,当咱们想更清楚的看到各个线程之间的调度就能够使用 System Trace,但仍是建议优先使用 Time Profiler,使用简单易懂,排查问题效率更高。
App Launch是Xcode11 以后新出的工具,功能至关于 Time Profiler 和 System Trace 的整合。
能够对 objc_msgSend
进行 Hook
获取每一个函数的具体耗时,优化在启动阶段耗时多的函数或将其置后调用。实现方法可查看笔者以前的文章 经过objc_msgSend
实现iOS方法耗时监控。
可能形成
tableView
卡顿的缘由有:
- 最经常使用的就是
cell
的重用, 注册重用标识符 若是不重用cell
时,每当一个cell
显示到屏幕上时,就会从新建立一个新的cell; 若是有不少数据的时候,就会堆积不少cell
。 若是重用cell
,为cell建立一个ID,每当须要显示cell 的时候,都会先去缓冲池中寻找可循环利用的cell
,若是没有再从新建立cell
- 避免
cell
的从新布局cell
的布局填充等操做 比较耗时,通常建立时就布局好 如能够将cell
单独放到一个自定义类,初始化时就布局好- 提早计算并缓存
cell
的属性及内容 当咱们建立cell
的数据源方法时,编译器并非先建立cell 再定cell的高度 而是先根据内容一次肯定每个cell的高度,高度肯定后,再建立要显示的cell,滚动时,每当cell进入凭虚都会计算高度,提早估算高度告诉编译器,编译器知道高度后,紧接着就会建立cell,这时再调用高度的具体计算方法,这样能够方式浪费时间去计算显示之外的cell- 减小
cell
中控件的数量 尽可能使cell得布局大体相同,不一样风格的cell能够使用不用的重用标识符,初始化时添加控件, 不适用的能够先隐藏- 不要使用
ClearColor
,无背景色,透明度也不要设置为0 渲染耗时比较长- 使用局部更新 若是只是更新某组的话,使用
reloadSection
进行局部更新- 加载网络数据,下载图片,使用异步加载,并缓存
- 少使用
addView
给cell
动态添加view
- 按需加载cell,cell滚动很快时,只加载范围内的cell
- 不要实现无用的代理方法,
tableView
只遵照两个协议- 缓存行高:
estimatedHeightForRow
不能和HeightForRow
里面的layoutIfNeed
同时存在,这二者同时存在才会出现“窜动”的bug。因此个人建议是:只要是固定行高就写预估行高来减小行高调用次数提高性能。若是是动态行高就不要写预估方法了,用一个行高的缓存字典来减小代码的调用次数便可- 不要作多余的绘制工做。 在实现
drawRect
:的时候,它的rect参数就是须要绘制的区域,这个区域以外的不须要进行绘制。例如上例中,就能够用CGRectIntersectsRect
、CGRectIntersection
或CGRectContainsRect
判断是否须要绘制image
和text
,而后再调用绘制方法。- 预渲染图像。 当新的图像出现时,仍然会有短暂的停顿现象。解决的办法就是在
bitmap context
里先将其画一遍,导出成UIImage
对象,而后再绘制到屏幕;- 使用正确的数据结构来存储数据。
绘制像素到屏幕上 iOS图形原理与离屏渲染 iOS 保持界面流畅的技巧 Advanced Graphics and Animations for iOS Apps(session 419) 使用 ASDK 性能调优 - 提高 iOS 界面的渲染性能 Designing for iOS: Graphics & Performance iOS离屏渲染之优化分析 iOS视图渲染以及性能优化总结 iOS 离屏渲染 深入理解移动端优化之离屏渲染 iOS 流畅度性能优化、CPU、GPU、离屏渲染 离屏渲染优化详解:实例示范+性能测试
专题内容比较多,后面细份内容会有部分重复。