一. 性能指标
APP 的性能指标主要是包括 CPU、GPU、内存、电池耗电、网络加载几个大的方面,网络加载在下文会提及,电池耗电主要是由于 CPU、GPU、网络等因素决定,所以不作为基础的指标。
1. CPU占有率
iOS APP 为单进程的应用,不涉及到跨进程通讯(不包括 Extention)。
1.1 线程使用
线程的使用及通讯会带来 CPU 的开销,大量的线程启用自然时候使得 CPU 使用率上升,不同线程之间的通讯需要添加锁来确保线程安全,又加大了线程的使用周期。
使用线程时需要注意:
- 不要在并发队列中使用过多的线程锁操作,如果必要则需要降低加锁代码的执行时耗,尽量精简化,也可以直接采用串行队列来实现同步。
- 不同场景使用不同的 GCD 队列,例如 执行多个独立的 I/O 操作 | 数据编解码(全局并发队列:任务执行不分先后)、单线程数据库操作(自定义串行队列:同步执行任务)、实现文件的多读单写 | 多个并行任务的组合操作(自定义并发队列:执行的任务存在依赖关系)。
1.2 执行方法耗时
常见较为耗时的场景如下。
- 对象创建:对象的创建会分配内存、调整属性,个别类的对象创建则更为耗时,如NSDateFormatter、NSCalendar,频繁生产临时变量可以改成单例调用。
- 布局计算:视图布局的计算会由于不同逻辑的运行时耗而带来不同程度的 CPU 开销。
- 图像绘制:开启 ImageContext,把图像绘制到画布中,从当前的 ImageContext 获取 image,并赋值给 layer.contents 显示。
- 图片解码:图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码。
- 图片过大:超过 GPU 的最大纹理尺寸时,图片需要先由 CPU 进行预处理,这对 CPU 和 GPU 都会带来额外的资源消耗(iOS图片裁剪/缩略性能探究)。
/* 避免频繁创建NSDateFormatter实例 */
+ (NSDateFormatter *)dateFormatter {
static NSDateFormatter *formatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [[NSDateFormatter alloc] init];
formatter.dateFormat = @"yyyy-MM-dd HH:mm:ss";
});
return formatter;
}
1.3 I/O操作
I/O 操作是指文件的读取、写入、更新。磁盘 I/O 的执行速度要远低于 CPU 和内存的速度。文件的读写主要性能开销是 I/O,同时也会有小占比的 CPU 与内存的消耗。
在 APP 运行过程中,由于 I/O 操作速度较慢,方法的调用时耗自然也就更大,通常会使用多线程来进行文件的读写操作,防止主线程的堵塞。文件大小与文件数量关系着线程资源的开销,最终决定 CPU 的性能开销。
1.4 CPU使用分析
Xcode自带的 CPU 检测工具:
- instruments-Activity Monitor,分析 CPU 的使用情况,Instruments之TimeProfiler。
第三方开源的 CPU 检测组件:
- 滴滴的 DoraemonKit,一款面向泛前端产品研发全生命周期的效率平台。
2. GPU渲染-FPS
FPS :Frames Per Second 的简称缩写,意思是每秒传输帧数,可以理解为我们常说的“刷新率”(单位为Hz)。FPS 是测量用于保存、显示动态视频的信息数量。每秒钟帧数愈多,所显示的画面就会愈流畅,FPS 值越低就越卡顿,所以这个值在一定程度上可以衡量应用在图像绘制渲染处理时的性能。iOS 系统中正常的屏幕刷新率为 60Hz(60次每秒)。页面渲染优化相关内容会在下文根据具体场景列举说明。
Xcode 自带的 FPS 检测工具:
- instruments-CoreAnimation,查内存分配情况的,Instruments之CoreAnimation
第三方开源的FPS检测组件:
- 滴滴的 DoraemonKit,一款面向泛前端产品研发全生命周期的效率平台。
3. 内存
这里讲的内存主要是内存缓存,不对内存管理做过多的叙述,有兴趣可以看一下我之前写的文章-iOS内存管理。
每一台 iPhone 机子都拥有固定的物理内存空间,也就是我们常说的运行内存 2个G、4个G 这种硬件配置。系统的运行会有一部分的内存开销,其他的则由运行的 APP 共同分配。
和安卓不同的是,IOS 系统并没有限制固定的内存分配规则,所以运行一个 APP,有时候可以达到几百甚至超过 1GB 的内存使用,不过这样无限制的消耗内存会导致内存警告,最终导致进程被杀掉。
内存的使用场景:
- 临时/局部,临时申请的内存空间,使用完即释放,如二级页面的数据源缓存。
- 静态/全局,静态内存,static、const、extern 声明的常量与对象(单例对象、全局数组)。
内存的缓存策略:MemoryCache
- 常规缓存,NSDictionary、NSArray、NSSet、NSPointerArray / NSMapTable / NSHashTable(支持弱引用)。
- 缓存+淘汰策略,LRU、LFU、NSCache(LFU 优先于 LRU)。
Xcode自带的内存检测工具:
- instruments-Allocations,查内存分配情况的,Instruments之Allocations
- instruments-Leaks,动态内存分析,内存检测Instruments之Leaks
- Xcode-Product-Analyze,静态内存分析,静态内存分析-Analyze的使用
第三方开源的内存监控组件:
- Faceboo k的 FBMemoryProfiler,分析 iOS 内存使用和检测循环引用,仅检测 OC。
- 腾讯的 OOMDetector,OOM 监控、大内存分配监控、内存泄漏检测,支持监控 C++ 对象和 malloc 内存块以及 VM 内存。
二. 场景应用
1. 启动
iOS 冷启动流程分为 Pre-main 与 main,也就是 main 函数入口的之前与之后的两部分。网上这方面的资料也很多,这里就大概过一下,相关的博文推荐:抖音-iOS启动优化之原理篇、抖音-iOS启动优化之实战篇、抖音-基于二进制文件重排的解决方案
1.1 Pre-main
1)具体流程
- Dyld:动态链接器,在系统内核做好程序准备工作之后,交由 dyld 负责余下的工作。
- Load Dylibs:加载动态库,iOS 的动态库包含 dylib 与动态 framework。
- Rebase:将镜像加载到内存,修正镜像内部的指针偏移(ASLR),并以 Page 为单位进行签名验证,保证不会被篡改,性能消耗主要在 I/O。
- Bind:查询符号表,将指针指向镜像外部的内容(符号绑定),性能消耗主要在 CPU 计算。
- Objc:注册 Objc 类,将类别插入类的⽅法列表⾥,检查 selector 的唯一性。
- initalizers:调用 Objc 类与类别的 +load 方法,调用 C/C++ 中的构造器函数,创建非基本类型的C++静态全局变量。
2)优化策略
- 合并自定义的动态库,删除冗余的 dylib 与动态 framework。
- 将动态库转为静态库(Mach-O Type:Static Library)。
- 使用二进制重排,减少 Page 载入的缺页中断问题。
- 减少 ObjC 类、方法(selector)、类别(category)的数量。
- 减少 ObjC 的 +load 方法,类/协议的绑定通过启动项自注册的方式实现。
- 减少 C/C++ 的 constructor 函数、C++ 静态全局变量。
1.2 Main
1)具体流程
2)优化策略
- SDK 注册较为耗时的可以使用异步并发加载,部分二级页才用到的 SDK 可以采用懒加载。
- 避免启动后出现过多的耗性能操作,例如频繁读写 I/O,数据解码等耗时方法的调用。
- 避免启动时多个接口请求的串行等待,采用任务队列的方式替代。
2. 页面
2.1 原生页面-渲染原理
1)View的渲染
View 的展示是由 Layer 实现,View 主要处理 Touch 响应链相关的事件。当 View / Layer 的 frame 与图层结构发生改变时,View / Layer 被标记为待处理状态,系统会监听 mainRunLoop 的 BeforeWaiting / Exit 状态,在监听回调中遍历所有待处理 View / Layer,刷新 UI 布局。
上面提到 View 的本质是 Layer,Layer 则包含 contents,这个 contents 指向的是一块缓存又名Baking Store。Objective-c 提供了 Core Animation 的渲染内核,底层是由 OpenGL 实现 GPU 渲染,流程大致如下:
- 初始化用于绘制的上下文 EAGLContext;
- 创建帧缓冲区和渲染缓冲区,设置画布的宽高;
- 添加附件,比如颜色附件或者深度附件;
- 切换到帧缓冲区,在帧缓冲中进行绘制;
- 切换到屏幕缓冲区,读取帧缓冲中的信息;
- 绘制到屏幕上,在容器 dealloc 时删除缓冲区。
所有的 Bitmap,包括图片、文本、栅格化的内容,最终都要由内存提交到显存,绑定为 GPU Texture。
2)GPU的离屏渲染
- 当前屏幕渲染,指的是 GPU 的渲染操作在当前用于显示的屏幕缓冲区进行。
- 离屏渲染,指的是 GPU 在当前屏幕缓冲区外新开辟一个缓冲区进行渲染操作。
离屏渲染主要开销包括创建新的缓冲区、屏幕缓冲区到离屏缓冲区的来回切换。
iOS 中主要是由于 Layer 的某些属性设置导致的离屏渲染,常见的有遮罩(mask)、透明(opaque)、阴影(shadow)、光栅化(rasterize)、圆角(cornerRadius),离屏渲染会让 APP 的交互变得不流畅(如:比较复杂的图文混排 List),需要避免频繁触发离屏渲染,相关博文推荐:iOS离屏渲染场景及优化方案
2.2 原生页面-渲染优化
- 离屏渲染:mask / opaque / shadow / rasterize / cornerRadius,这些属性都会引起离屏渲染,低刷新频率并不会出现卡帧的现象,主要出现在列表页快速滑动的时候。
- 视图结构:CALayer 替换非交互的 UIView,减少图层嵌套,精简图层数量。
- 数据加工:预排版 - 提前将数据模型转换成布局模型,使用异步线程实现数据加工(I/O操作、数据换算)。
- 异步渲染: 把复杂的图像绘制逻辑放在子线程去执行,具体实现可以参考开源库 Graver、AsyncDisplayKit。
- 图片预解码:使用异步线程先把图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片。
/* 图片预解码:通过 CGBitmapContext 创建图片 */
- (void)drawImage:(UIImage *)image {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
CGImageRef imageRef = image.CGImage;
size_t width = image.size.width;
size_t height = image.size.height;
size_t bitsPerComponent = CGImageGetBitsPerComponent(imageRef);
size_t bytesPerRow = CGImageGetBytesPerRow(imageRef);
CGColorSpaceRef space = CGImageGetColorSpace(imageRef);
uint32_t bitmapInfo = CGImageGetBitmapInfo(imageRef);
CGContextRef contextRef = CGBitmapContextCreate(NULL, width, height, bitsPerComponent, bytesPerRow, space, bitmapInfo);
CGContextDrawImage(contextRef, CGRectMake(0, 0, width, height), imageRef);
CGImageRef tImageRef = CGBitmapContextCreateImage(contextRef);
CGContextRelease(contextRef);
dispatch_async(dispatch_get_main_queue(), ^{
self.layer.contents = (__bridge id)tImageRef;
CGImageRelease(tImageRef);
});
});
}
2.2 原生页面-动画效果
iOS 项目中最常见的动画包括帧动画、显式动画和隐式动画。
通过 UIImageView 配置帧图片的方式,或者 Gif 组件实现帧动画效果,UIImageView.animations 适用于帧数较少的场景,省去了 Gif 解析的环节,直接配置帧图片。
Gif 的播放对 CPU 与内存的开销较大(文件解析->缓存->定时器->解码显示),可以使用FLAnimatedImage / YYImage(本地)、SDWebImage(网络),都对 Gif 渲染做了优化。就 FLAnimatedImage 的实现而言,从三个方面优化了 Gif 的渲染,分别是异步解析 gifData、CADisplayLink 的使用、gifData 大小制定缓存策略(见下方图片)。
尽管对 Gif 渲染做了一定的优化,但在 Gif 帧数及帧图片较大的时候,Gif 仍是会带来不少的开销,特别是多个 Gif 同时渲染的页面。Lottie 的出现很好地解决了这个问题,一个基于移动端和 web 端的跨平台动画框架。
动画的冲突也会出现明显的卡顿现象,如在 Push 一个 VC 时,该 VC 页面即刻唤起键盘,就会出现卡顿或者是没有弹起动效的情况,可以通过异步调用的方式来规避。
CoreAnimation 提供 CABasicAnimation、CAKeyframeAnimation、CAAnimationGroup、CATransition 的相关能力,用 CoreAnimation 的 API 替换 UIView.animate 可以减少额外的开销。
2.3 web页面
1)白屏时间长
- 资源本地化:web 页面常见的问题就是白屏时间长,需要依次加载 html,cdn 资源文件,以及页面的网络请求。可以通过加载 H5 本地资源包的方式,或者 cdn 资源拦截+本地映射的方式来减少白屏时长,具体实现可以参考H5资源本地化策略-iOS。
- 骨架屏:尽管页面加载网络数据时会有加载圈提示,但接口响应较慢会导致页面一直在转圈,这时就需要引入骨架屏,页面在加载完 web 资源后,通过 webpage 打包生成的骨架屏预先展示出页面的大致结构 (Vue页面骨架屏注入实践),或者是通过设置各个 UI 组件的占位来预先展示出页面的大致结构。
2)图片展示
- 上传压缩:减少网路加载时耗,以及大图片的渲染开销。
- 图片占位:防止图片加载时,页面出现跳动的现象。
2.4 网络加速
1)图片加载支持webp
WebP 是一种同时提供了 有损压缩 与 无损压缩(可逆压缩)的图片文件格式,派生自影像编码格式 VP8,是由 Google 在购买 On2 Technologies 后发展出来,以 BSD 授权条款发布。
具体实现流程:
- 服务端支持图片的 webp 加载;
- 通过 Hook 文件下载 API,给图片 url 添加后缀 ‘.webp’;
- 加载 webp 资源文件;
- SDWebImage 自带 webp 解码器,APP 启动时注册一下即可;
- webp 解码成 jpg/png,图片展示。
2)HttpDNS解析
HttpDNS 解析是使用 HTTP 协议进行域名解析,代替现有基于 UDP 的 DNS 协议,域名解析请求直接发送到阿里云的 HTTPDNS 服务器,从而绕过运营商的 Local DNS,能够避免 Local DNS 造成的域名劫持问题和调度不精准问题。
httpDns 解析将现有域名解析成 IP 地址,通过 IP 直连的方式进行网络访问。市面上的 APP 大部分是通过的阿里云与腾讯云提供的 SDK 来实现。
移动解析HttpDNS_移动互联网域名解析_域名防劫持 - 腾讯云
具体实现流程:
- 通过 NSURLProtocol 对请求进行重定向;
- 获取域名解析后的IP信息;
- 将原有请求 URL 的域名替换成 IP;
- 重新发送请求实现 IP 直连。
3)使用网络缓存 + 请求数据压缩 + 接口分屏加载
- 使用网络缓存:系统提供了网路请求缓存 NSURLCache,默认 diskCapacity 为10M(NSURLCache详解)。
- 请求数据压缩:目前大部分公司都是使用 JSON 数据格式进行网络数据传输,比起 XML 更为精简,减少了数据大小与数据解析的时耗(JSON模型转换库评测)。
- 接口分屏加载:分屏加载主要看页面接口的时耗,如果页加载时耗较长,可以拆分为两个接口,优先获取首屏展示的数据。
三. 编译打包
1. 编译打包优化
在项目经过长周期的迭代后,Run / Archive 的时长从一开始的几分钟到十几二十几分钟,一方面由于 Mac 设备更新换代,另一方面则是工程架构的复杂化,或者是项目设计不合理导致的臃肿。
- 工程配置-Build Settings,设置 Optimization Level、Debug Information Format、Build Active Architecture Only(提高Xcode的编译速度)。
- 将不经常改动的源码打包成静态库(.a / .framework),省去重新编译的时间。
- 减少 Storyboard 和 Xib 的使用,xml 解析与节点处理会带来一定的性能开销。
- 排查项目中无用的资源文件、类和第三方库,去除冗余资源。
- 资源压缩/合并,使用 TinyPNG 进行图片压缩,合并 Asset,合并 ObjC 类(一大堆工具类、API 拆分过度、功能重复的类库等),避免过度封装。
- PCH 文件只放置相对静态且较为通用的类声明,减少不必要的重新编译。
2. 包大小优化
原生业务比较多的 APP,在经过一定迭代后,ipa 包都会比较大,上百兆、甚至达到了一两百兆。这时候就需要优化包大小,相关的博文推荐:今日头条 iOS 安装包大小优化
- 工程配置-Build Settings,设置 Asset Optimization 为 space,Link-Time Optimization 为 Incremental。
- 排查项目中无用的资源文件、类和第三方库,去除冗余资源。
- 较大的内置资源文件存放在云端,等需要用到的时候才去下载,包括图片 / 音视频文件等。
- 资源压缩/合并,使用 TinyPNG 进行图片压缩,合并 Asset,合并 ObjC 类(一大堆工具类、API 拆分过度、功能重复的类库等),避免过度封装。
- 属性动态化,用 @dynamic 修饰一个属性,不生成成员变量、get/set 方法。
四. APP稳定性
1. 闪退问题
1.1 采集/度量
- 集成 Bugly 或者 Fabric(及时性与精确度较高),实现 Crash 的采集与分析。
- 使用 Xcode 自带的 Instruments-Zombies 检测僵尸对象,主要是在应用上线前的度量。
- 通过 Xcode 的 Organizer-Crashes,查看用户上报的 Crash 日志,应用发布上线之后的分析。
1.2 常见闪退优化
- 数据容错:像数组越界、字典获取对象类型异常,常见的做法是新增 Array、Dictionary 的类别方法来容错,通过切面编程在原有IMP调用之前实现逻辑容错。
- 系统API异常:每次 iOS 更新大版本都需要对 APP 做一次系统兼容性的全面测试,修复新系统带来的兼容性问题。
- 页面堆栈异常:页面 Push / Pop 切换太过频繁导致堆栈异常,只需要在 BaseNavigationVC 中对页面的频次做限制即可,如果没有集成 Base 类可以通过 Hook 的方式来实现。
- 方法属性缺失:改写系统UI组件结构导致其调用属性/方法异常导致的 Crash,只需要在相应层级的类添加属性或者方法即可(例如:替换UITabBar的内部元素 UITabBarItem,自定义 TabBarItem 类需要添加 image 与 title 属性)。
2. 卡顿问题
1.1 采集/度量
- 集成 Bugly 或者 FireBase Performance Monitor,实现卡顿的采集与分析。
- 使用 Xcode 自带的I nstruments-Core Animation / Time Profiler 检测 FPS 与耗时 API。
- 通过 DoraemonKit-debug 工具,在 debug 模式采集应用的卡顿信息。
1.2 常见卡顿优化
- 耗时方法优化:包含数据编解码、系统耗时 API、I/O 操作、处理大量遍历逻辑等阻塞 UI 线程的操作,上文已经做了较详细的叙述,这里不在具体展开。
- 页面渲染优化:具体细节可以查阅上文的 ‘页面’ 章节。