iOS性能优化的一些套路

1
2
3
4
5
程序优化第一法则:不要优化
————编程珠玑(续)
过早的优化是万恶之源
————Donald Knuth

不要过早优化,也不要为了优化而优化,只有在项目真正需要优化的时候再优化。我们实际工作中,在开始一个项目时,应该也不会太多去考虑性能优化的问题。不过随着项目版本的不断迭代,模块和代码量不断增加,研发团队人员变动,以及其他各种原因,难免会出现一些实现不太友好的代码和方案,影响到app的用户体验,这个时候就需要相应的优化来提升app的性能。

iOS的性能优化是一个比较有挑战的工作,有一定的难度,需要着手优化的人有一定的姿势积累,能够分析出各方面可能出现的性能问题。所以一般也是进阶必须掌握的技能。当然,优化的过程和可优化的一些点也是有套路的,综合网上的方案和自身的一些经验,这里做一下简单的总结:

启动优化

这里说的启动优化指的是冷启动,根据启动的流程,可以分为2部分来分别优化,具体如下:

main函数之前

ipa实际上就是一堆二进制文件(.o文件)的集合,而这堆二进制文件就是xcode编译我们的代码后生产的最终产物,文件头部会给操作系统留下入口(这个入口不是main,而是@start标识)。当应用被启动,系统首先找到程序入口,将二进制文件分页映射到虚拟内存(mmap),然后加载动态库(load image),然后对需要rel的符号的地址进行rebase和bind,之后就是对使用的oc类和分类的注册和初始化。至此,mian函数前的工作做完了。

优化点

知道了具体流程,我们就可以对流程中各个环节进行有针对性的优化了:

  • 利用Xcode的DYLD_PRINT_STATISTICS环境变量监测mian函数前的耗时.
  • 尽可能精简代码量,减少mmap次数。
  • 动态库的使用,移除不必要的动态库依赖。
  • 合并或者删除无用的oc类和分类。
  • 删除没有被调用的方法,减少非唯一selector的个数。
  • 将不必须的初始化方法从+load移到+initialize。

main函数之后

这部分内容就是业务相关的东西了,根据各自app的需求会有不同的优化方案,不像上面的部分具有通用性。启动优化的最终目的肯定都是最快的展示首页内容,这里列举一些常用的优化套路。

  • 能延迟执行的就延迟执行。比如SDK的注册,非首页UI的创建等。
  • 不能延迟执行的,尽量放到后台执行。比如网络请求,数据读取,原始JSON数据转对象,日志发送等。
  • 既不能延时又不能后台执行的尽量已懒加载的方式创建实例。
  • 利用检测工具监测启动阶段函数调用的耗时情况,进行针对性优化。

具体细节可参考今日头条这篇优化文章

内存优化

蒸米大大的这一篇文章比较详细的描述了iOS中的虚拟内存和物理内存相关的内容。我们一般说的在程序中分配和回收内存指的都是虚拟内存,程序员一般不直接和物理内存(cpu内存,不包括磁盘)打交道。更多虚拟内存和物理内存的内容可阅读操作系统相关的文章和书籍去学习。

在计算机发展的初期,程序员都是直接访问物理内存,随着计算机技术的飞速发展,这种内存访问方式不再满足程序员的需求,新的内存访问方式出现,这就是被沿用至今的虚拟内存。虚拟内存在程序员和物理内存之间提供了一个良好的中间环境,它隔离了程序员直接使用物理内存带来的各种危险操作,提高了物理内存的利用率,简化了内存管理方式,保证了进程的安全。所以有这样一句古话:计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决,看来不是没有道理的。

言归正传,下面我们从创建、复用、回收三个方面总结一下iOS在内存使用过程中的一些需要注意的点:

创建

  • 减少大图的使用。
  • weak strong dance保证self的生命周期。
  • weak替换unsafe_unretain,保证指针安全。
  • 合理利用自动释放池,避免内存暴涨导致OOM。
  • 尽量使用懒加载的方式创建对象,避免无用和重复创建,减少不必要的内存开销。
  • 在子线程手动申请(malloc)大内存的的时候 ping 一下主线程,因为子线程无法收到内存警告的传递。

缓存和复用

  • 合理使用缓存减轻CPU的计算压力。
  • 复用UI对象内存,通过绑定model更新UI。
  • 合理使用缓存数据结构(NSCache VS NSMapTable VS NSHashTable VS NSDictionary)。

回收

  • 保证使用的内存能够被正确的回收掉,避免内存泄露(timer,delegate,block)。
  • 使用适当的工具对内存的回收进行检测,发生内存泄露及时抛出提示。
  • 处理系统的内存警告。

UI优化

先说一下iOS中UI展示的过程。iOS中的视图容器是CALayer,所有的显示在屏幕上的内容最终都是被渲染到CALayer上。CPU计算好需要渲染的内容放入渲染缓冲区,GPU从渲染缓冲区拿到数据,OpenGLES经过顶点着色,光栅化,片元着色,逐片段操作等一系列流程,将处理好的数据(bitmap)丢到后帧缓冲区,前帧缓冲区再从后帧缓冲区更新数据,最终渲染到CALayer。

从上面展示的流程可以看到,UI的展示分为两个部分,CPU处理部分和GPU处理部分。如果某一部分的处理被阻塞的话,UI显示就会出现卡顿。所以,可以从这两部分着手优化。

CPU优化

  • 利用UI卡顿检测工具检测屏幕的fps。
  • 懒加载方式创建UI对象,使用CALayer替换UIView。
  • 减少视图相关属性的修改,避免视图层级的调整,尽量不要动态添加和移除视图。
  • 文本的异步计算和渲染。
  • 尽量提前计算好视图布局并缓存,避免动态计算和更新布局。
  • 异步解码图片和绘制视图。

GPU优化

  • 将多个小图合并为一张大图显示,控制图片尺寸不要大于GPU处理上限。
  • 避免带透明度的视图重叠显示,会产生像素的混合。
  • 避免出现离屏渲染。

具体细节参考YY的这篇文章

ipa包大小优化

上面提到过,iOS的ipa包实际上就是一堆二进制文件的集合。而这堆二进制文件,就是我们的代码经过Xcode编译之后的产物。这里简单介绍一下Xcode点击run后的整个流程:

预编译

  • 将所有的 #deifne 删除 并展开所有的宏定义。
  • 处理所有的条件编译指令 比如#if #else #ifdef #endif 等等。
  • 处理#include预编译指令 将所包含的文件插入到预编译指令的位置。
  • 删除所有的注释 // / / 等等。
  • 添加行号和文件名标识。
  • 保留所有#pragma编译器指令。

编译

编译过程就是把预处理完成的文件进行一系列词法分析,语法分析,语义分析及优化后生成相应的汇编代码文件(词法分析->语法分析->语义分析->源代码优化->代码生成>目标代码优化)。

词法分析
  • 利用有限状态机扫描源代码 生成一系列记号(Token)。
  • 记号分类:关键字、标识符、字面量(包括数字,字符串)、特殊符号(+、=)。
  • 每种记号存入对应的表内。
  • 词法规则可以自定义。
语法分析
  • 对上面的词法表进行语法分析、生成语法树。
  • 整个分析过程采用上下文无关语法。
  • 语法树以表达式为节点。
语义分析
  • 编译器所能分析的语义是静态语义。
  • 静态语义通常包括声明和类型的匹配,类型的转换。
  • 经过语义分析,语法树被标识了类型。
  • 语义分析判断该语法是否合法。
中间语言生成
  • 直接在语法树上面优化比较困难,所以源代码优化器会先将语法树转换为中间代码。
  • 中间代码是设备无关的。
  • 编译器前端负责产生机器无关的中间代码,后端负责将中间代码转换成目标机器代码。
  • 跨平台编译器就是有一个前端和多个后端的组合。
目标代码生成与优化
  • 编译器后端主要包括代码生成器和目标代码优化器。

链接

  • 链接的过程主要包括:地址和空间分配、符号决议、重定向。
  • 目标文件和库一起链接生成可执行文件。
  • 库其实是一种编译后的目标文件。
  • 目标文件之间的函数和变量的访问在链接过程中被重定向。

可执行文件的装载(即上面提到的main函数前的部分)

了解了二进制文件的产生过程后,我们可以利用一些工具来对它们做一些针对性的优化工作:

二进制文件的优化

查看mach.o文件的大小

首先,在Xcode(Debug下)中开启编译选项Write Link Map File,然后在这个目录下面(这里以OpenCourse为例)找到下面这个txt文件。

1
~/Library/Developer/Xcode/DerivedData/OpenCourse-gyfdubhkhvixzvetgqbmqrzplarc/Build/Intermediates.noindex/OpenCourse.build/Debug2-iphoneos/OpenCourse.build/OpenCourse-LinkMap-normal-arm64.txt

这个LinkMap里展示了整个可执行文件的全貌,列出了编译后的每一个.o目标文件的信息(包括静态链接库.a里的),以及每一个目标文件的代码段,数据段存储详情。对于比较大的文件,进行对应的优化即可。

查看mach.o文件大小的工具

编译优化
  • Optimization Level release使用Fastest。
  • Strip Linked Product release设置为YES。
  • Symbols Hidden by Default release设置为YES。

资源文件的优化

  • 图片资源在打包过程中压缩比很低,基本是原图copy。减少图片数量和大小可以直接降低ipa的大小。
  • 及时删除无用代码(包括无用的类,依赖,方法,测试用例等)。

压缩图片大小的工具

检测无用的图片的工具

检测无用类和依赖的工具

编码和架构优化

好的代码质量和架构设计是项目稳定运行和迭代的基石。而编码高手和低手之间很大一部分区别在于对细节的处理上,要养成良好的编码习惯非一朝一夕,需要时间的积累和大量的练习,同时,也需要一些理论上的支持。下面推荐几本经典书籍,平时编码过程中,需要经常提醒自己甚至强迫自己按照书中这些tips去做,养成良好的编码习惯,提升技术上的综合素质。

代码的设计原则和架构方案《架构整理之道》

总结一下本书中比较重要的几个章节:

SRP单一职责原则
  • 函数设计也需要遵循SRP原则
  • 任何一个软件模块都应该有且仅有一个被修改的原因
  • 任何一个软件模块都应该只对某一类行为者负责
  • Employee类中各行为应该被分解
OCP开闭原则
  • 良好的设计软件应该易于扩展,同时抗拒修改
  • 如果A组件不想被B组件上发生的修改所影响,那么就应该让B组件依赖于A组件
  • 利用SRP分解功能,利用DIP反转依赖
  • 软件系统不应该依赖不直接使用的组件
LSP里氏替换原则
  • 以接口或者多态的形式实现函数调用的可替换性
ISP接口隔离原则
  • 利用接口隔离原则隔离掉不需要的依赖关系,因为不需要的依赖会导致不必要的重新编译和重新部署
DIP依赖反转原则
  • 如果想要设计一个灵活的系统,在源代码层面的依赖关系中就应该多引用抽象类型而非具体实现
  • 接口比具体实现更稳定,因为如果修改接口必须修改实现,但是反之则不一定.
  • 不要在具体实现类上创建子类,具体实现应该放到子类去做,除非是比较稳定且通用的逻辑.
  • 不要覆盖包含具体实现的函数,如果必须这样,请在覆盖方法中先调用父类方法.
  • 源代码依赖方向永远是控制流方向的反转
组件聚合
复用/发布等同原则(REP)
  • 软件复用的最小粒度应等同于其发布的最小粒度
  • ERP原则就是指组件中的类与模块必须是彼此紧密相关的,一个组件不能由一组毫无关联的类和模块组成
  • 一个组件中包含的类与模块还应该是可以同时发布的
共同闭包原则(CCP)
  • 我们应该将那些会同时修改,并且为相同目的而修改的类放到同一个组件中,而将不会同时修改,并且不会为了相同目的而修改的那些类放到不同的组件中
  • CCP原则是SRP原则和OCP原则的组件版
共同复用原则(CRP)
  • 不要强迫一个组件的用户依赖他们不需要的东西
  • 我们希望组件中的所有类是不可拆分的,不应该出现别人只需要依赖它的某几个类而不需要其他类的情况
  • CRP的作用不仅是告诉我们应该将哪些类放在一起,更重要的是要告诉我们应该将哪些类分开
  • CRP原则实际上是ISP原则的一个普适版,ISP建议我们不要依赖不需要的类,CRP建议我们不要依赖不需要的组件
  • 优秀的架构师应该在REP,CCP和CRP原则间找到一个好的平衡点,一个项目的组件结构设计的重心是根据该项目的开发时间和成熟度不断变化的
组件耦合
无依赖环原则
  • 组件依赖关系图中不应该出现环
  • 依赖环会导致组件之间的发布和运行很难稳定下来
  • 每周构建是指team每一周专门抽出一天来进行组件的构建调试工作,而其他时间则忽略组件的构建问题
  • 消除循环依赖的一个解决办法是将研发项目划分成一些可单独发布的组件,这些组件独立完成构建和发布
  • DIP原则打破循环依赖好的选择
自上而下的设计
  • 组件结构图是不可能自上而下被设计出来的,它必须随着软件系统的变化而变化和扩张.
  • 组件结构图并不是用来描述应用程序功能的,它更像是应用程序在构建性与维护性方面的一张地图
  • 组件结构图的一个重要目的是如何隔离频繁的变更
稳定依赖原则
  • 依赖关系必须要指向更稳定的方向
  • 任何一个我们预期会经常变更的组件都不应该被一个难于修改的组件所依赖
  • 稳定性指标的计算方法:出口依赖/(出口依赖+入口依赖). 0最稳定,1最不稳定.
  • 组件并不是都需要稳定
稳定抽象原则
  • 一个组件的抽象化程度应该与其稳定性保持一致
  • 组件抽象化的计算方法:组件中的抽象类和接口/组件中类的个数 0表现没有抽象类 1表示全是抽象类
  • 好的组件结构应该在抽象和稳定之间找到一个好的平衡点

编码规范和原则《程序员修炼之道》

从小工到专家,你需要做到下面这些:

DRY

DRY:系统中的每一项知识都必须具有单一,无歧义,权威的表示.

重复是怎样产生的:

  • 强加的重复,开发着觉得无可选择,环境要求.
  • 无意的重复,开发者没有意识到他们在重复.
  • 无耐性的重复,开发者偷懒,copy比较容易.
  • 开发者之间的重复,几个人重复了同样的信息.
正交性

正交性是从几何学中借来的术语,如果两条直线相交成直角,它们就是正交的.沿着某一条直线移动,你投影到另一条直线上的位置不变.

正交性的好处:消除无关事物之间的影响(解耦不必要的依赖)

分层设计是正交系统的强大方式..因为每层都只适用在其下面的层次提供的抽象,在改动底层实现,而又不影响其他代码方面,你拥有极大的灵活性.分层也降低了模块间依赖关系失控的风险.

正交性的简单测试方法:如果我显著的改变某个特定功能背后的需求,有多少模块会受影响?在正交系统中,答案是一个

正交性在编码中的应用:

  • 让你的代码保持解耦
  • 避免使用全局数据
  • 避免编写相似的函数
  • 养成不断批判对待自己代码的习惯.寻找任何重新进行组织,以改善其结构和正交性的机会,这个过程叫做重构,它非常重要.
调试

调试的心理学:

  • 调试是解决问题,要据此发起进攻
  • 要修正问题,而不是发出指责(bug是你的还是别人的,这不是真的很有关系.)
  • 不要恐慌,如果你看到bug报告的第一反应是那不可能,你就完全错了.一个脑细胞都不要浪费在以“但那时不可能”起头的思路上,因为很明显,那不仅可能,而且已经发生.
  • 小心近视,要抵制只修正你看到的症状的急迫愿望,更有可能的情况是,实际的故障离你正在观察的地方可能还有几步远,并且可能涉及许多其他的相关事物.

调试策略:

  • 你需要与qa面谈,搜集比最初给你的数据更多的数据.
  • qa不可能全覆盖所有的case,你需要系统的进行这样的测试,比如分支语句,你可能需要自己造数据去覆盖所有的分支.
  • 复现bug
  • 数据可视化(log)
  • 堆栈和断点
  • 橡皮鸭,把你的逻辑对着桌上的橡皮鸭描述一遍,看看有没有漏洞.
  • 消除过程,二分调试
  • 遇到自认为不可能的bug时,不要假定,要证明.
解耦与得墨忒耳法则

多模块耦合的危害

  • 调用链冗长
  • 对一个模块的简单改动会传遍系统中的一些无关模块
  • 开发者害怕改动代码,因为他们不清楚哪些代码可能受影响

函数的得墨忒耳法则规定,某个对象的任何方法都应该只调用以下情形的方法:

  • 它自身的方法
  • 传入该方法的任何对象的方法
  • 它创建的任何对象的方法
  • 任何直接持有的组件的对象的方法(比如全局对象)
不要靠巧合编程

深思熟虑的编程,而不是靠运气和偶然的成功.

怎样深思熟虑的编程:

  • 总是意识到你在做什么
  • 不要盲目的编程
  • 按照计划行事
  • 依靠可靠的事物,不要依靠巧合或假定
  • 为你的假定建立文档
  • 不要只是测试你的代码,还要测试你的假定
  • 为你的工作划分优先级
  • 不要做历史的奴隶,不要让已有的代码支配将来的代码,如果不再适用,所有的代码都可以被替换.
重构

何时重构:

  • 重复,你发现了对DRY原则的违反
  • 非正交的设计
  • 过时的知识
  • 性能问题

早重构,常重构

如何重构:

  • 不要试图在重构的同时增加功能
  • 在开始重构之前,确保你拥有良好的测试.
  • 采取短小,深思熟虑的步骤.
注重实效的团队
  • 不要留破窗户
  • 不要做温水里的青蛙
  • 交流的重要性
  • DRY
  • 正交性

无情的测试

早测试,常测试,自动测试

要到通过全部测试,编码才算完成

测试什么:

  • 单元测试

    模块测试

  • 集成测试

    子系统能很好的系统工作

  • 验证和校验

    用户回归

  • 资源耗尽,错误及恢复

    1.内存空间
    2.磁盘空间
    3.cpu带宽
    4.挂钟时间
    5.磁盘带宽
    6.网络带宽
    7.调色板
    8.视频分辨率
    9.fps

  • 性能测试

    预期的用户数,连接数或每秒事务数

  • 可用性测试

    qa,灰度…

傲慢与偏见

在你的作品上签名,你的签名应该被视为质量的保证,如果有一天你离开了网易,是否会因为代码质量问题而把自己的签名换成netease呢?(手动@学姐😁)。

小结

以上是一个iOSer项目优化的简单总结,一部分可能是iOS特有的优化项目,还有一部分则具有普适性,因为不管什么技术方向,最终它们的产物都会是二进制。技术的深度和广度这两个方向的发展问题也一直都是程序员们讨论的话题,其实不管是深度和广度,只要找准一个方向一直走下去,就可以了。