系统作的内存方面优化
NSString
NSString的类型分为三种:
-
__NSCFConstantString
:字符串常量,存储在字符串常量区。引用计数很大。 -
__NSCFString
:运行时类型,存储在堆上。引用计数+1. -
NSTaggedPointerString
:小对象,存储在常量区,既包含指针,也包含值。小对象指针不再是简单的地址,而是地址 + 值.优点是占用空间小 节省内存.
taggedPoint小地址是值和指针地址放在了一起,存储在常量区,不进行retain,release管理,能够直接释放回收,效率更高,创建更快,将近100倍。iOS14之后还对taggedPointer进行了混淆。最高位是taggedPointer的类型。
NSString * te = @“12345678912111”;这种方式不论多长,都是__NSCFConstantString
类型,存储在常量区。
通过stringWithFormat
或alloc
并且长度小于一定值时才为taggedPoint。
Nonpointer_isa
SideTables
即散列表,散列表中主要有引用计数表
和弱引用表
。
Nonpointer_isa为非指针类型的isa,可以存储更多类的信息,包括引用计数。当引用计数存储到一定值时,并不会再存储到Nonpointer_isa
的位域的extra_rc
中,而是会存储到引用计数表
中。
id objc_retain(id obj)
{
if (obj->isTaggedPointerOrNil()) return obj;
return obj->retain();
}
ALWAYS_INLINE id
objc_object::rootRetain(bool tryRetain, objc_object::RRVariant variant)
通过对retain源码的分析可以得知:
当引用计数存储到一定值时,并不会再存储到Nonpointer_isa
的位域的extra_rc
中,而是除以二(RC_HALF
)的部分存储到SideTables
散列表中。
SideTables是一个真机长度8个元素,其他64个长度的hash数组,本质是一个哈希表
,集合了数组和链表的长处,增删改查都比较方便,里面存储了SideTable。SideTables的hash键值就是一个对象obj的address。为什么sidetables最多有8张呢,而不是一张呢
? 因为所有对象的引用技术全放在一张散列表中不安全,假如只访问一个而可以拿到全部的,并且每次访问都要加锁解锁,对于性能和安全性都不高,所以分了8张表。
retain做了什么:简单回答是计数值加1,更加底层应该这么回答
:
- 首先判断
是不是
taggedpointer,如果是,就立马返回。因为taggedpointer
不需要内存管理,taggedPointer存储在常量区。 - 判断如果
不是nonpointerisa
,直接操作
散列表sidetable的引用计数,每次操作散列表会开锁等耗时耗性能,所以表会有多张,为的是更安全。 - 如果
是nonpointerisa
,表示isa联合体开启了指针优化,isa可以存储更多信息,那么就操作isa.bits中的extra_rc的引用计数+1。 - 如果extra_rc
满了
,就和散列表对半开,各存一半,以提高性能。//这么操作的目的在于提高性能,因为如果都存在散列表中,当需要release-1时,需要去访问散列表,每次都需要开解锁,比较消耗性能。extra_rc存储一半的话,可以直接操作extra_rc即可,不需要操作散列表,性能会提高很多。 - alloc创建的nopointerisa对象引用计数
为0
,包括sideTable,uintptr_t rc = 1 + bits.extra_rc
;所以对于alloc来说,是 0+1=1,这也是为什么通过retaincount获取的引用计数为1的原因
。alloc创建的对象实际的引用计数为0,其引用计数打印结果为1,是因为在底层rootRetainCount方法中,引用计数默认+1了,但是这里只有对引用计数的读取操作,是没有写入操作的,简单来说就是:为了防止alloc创建的对象被释放(引用计数为0会被释放),所以在编译阶段,程序底层默认进行了+1操作
。实际上在extra_rc中的引用计数仍然为0。 - 在dealloc流程汇总会判断是否有isa、cxx、关联对象、弱引用表、引用计数表。
引用计数分别保存在
isa.extra_rc
和sidetable
中,当isa.extra_rc溢出
时,将一半
计数转移至sidetable
中,而当其下溢
时,又会将计数转回
。当二者都为空时,会执行释放
流程 。
项目可以进行的优化
界面优化:
- cell数据预加载,数据请求下来后提前计算高度存储在model中,tableview代理中直接赋值高度。如果cell布局用的是autolayout,可以开启tableView的预估高度机制Self-Sizing 。
- 按需加载:scrollviewwillEndDragging里判断加载的是否是前后三行。
- 图片渲染:图片最终能展示的都是
bitmap位图
。不管是 JPEG 还是 PNG 图片,都是一种压缩
的位图图形格式。只不过 PNG 图片是无损压缩
,并且支持 alpha 通道,而 JPEG 图片则是有损压缩
,可以指定 0-100% 的压缩比。解压缩后的图片大小 = 图片的像素宽 * 图片的像素高 * 每个像素所占的字节数 4
。将压缩的图片数据解码成未压缩的位图形式,这是一个非常耗时的 CPU 操作,并且它默认是在主线程中
执行的。imageView.image = image这一步会做解码,并且是主线程
上。在将磁盘中的图片渲染到屏幕之前,必须先要得到图片的原始像素数据,才能执行后续的绘制操作
,这就是为什么需要对图片解压缩的原因。所以可以用SDWebImage预解码
,异步在子线程强制解压缩
。强制解压缩的原理就是对图片进行重新绘制
,得到一张新的解压缩后的位图。其中,用到的最核心的函数是CGBitmapContextCreate
。当头像要切圆角时,下载下来后,会在后台线程将头像预先渲染为圆形并单独保存到一个 ImageCache 中去。 - 复杂界面的渲染可以合成一张图后然后再进行渲染,具体可以参考美团的
Graver
框架。 - main函数前后阶段优化
冷启动:系统里没有任何进程的缓存信息,典型的是重启手机后直接启动 App
热启动:如果把 App 进程杀了,然后立刻重新启动,这次启动就是热启动,因为进程缓存还在。
main函数之前的阶段pre-main阶段的启动时间
其实就是dyld加载过程
的时间。针对main函数之前的启动时间,苹果提供了内建的测量方法,在Edit Scheme -> Run -> Arguments ->Environment Variables
添加环境变量 DYLD_PRINT_STATISTICS
设为 1),然后运行就能看到打印耗时的日志了。
Total pre-main time: 2.7 seconds (100.0%)
dylib loading time: 450.07 milliseconds (15.6%) 主要是`加载`动态库
rebase/binding time: 210.8 milliseconds (8.5%) (偏移修正/符号绑定耗时) ALSR rebase /binding符号 (偏移地址+ALSR = 运行时执行地址) 外部`绑定`的动态库越多越耗时
ObjC setup time: 921.22 milliseconds (33.5%) (OC类注册的耗时):OC类越多,越耗时。Swift耗时就会少很多。
initializer time: 1113.94 milliseconds (45.3%) 执行load和构造函数的耗时
pre-main阶段优化建议
:
苹果官方建议自定义的动态库
最好不要超过6个
。
二进制重排
:主要是大项目 二进制重排优化都适用,主要目的是将启动时刻需要调用的方法排列在一起。启动时刻会出现大量的缺页异常PageFault
,当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),分配物理内存,有需要的话会从磁盘mmap读人数据。发生Page Fault的时候线程是被blocked
。一般项目会有0.5-1s的page fault。导致Page Fault次数过多的根本原因
是启动时刻需要调用的方法,处于不同的Page导致的。因此,我们的优化思路就是:将所有启动时刻需要调用的方法,排列在一起,即放在一个页中,这样就从多个
Page Fault变成了一个
Page Fault。这就是二进制重排的核心原理。
利用xcode自带工具Instrument中的
SystemTrace
就能看到项目的PageFault
次数,即图中的File Backed Page In
。
-
工程Build Setting打开link map,记录了二进制文件的布局,可以看到方法的执行顺序,路径在.app上一级同级的文件再依次往下找。
-
打开xcode的Instrument中的system trace,用来展示启动pageFault次数。
-
工程路径中创建一个.order文件,在这个order文件里排列你想加载的顺序,然后在build setting里搜order file,把.order路径加进去就可以了。
那么那么多方法,哪个方法才算是启动期间调用的呢。在方法的启动耗时中,需要去 Hook objc_msgSend 来达到监控所有 ObjC 方法的目的。
hook objc_msgsend
:
- 绝大部分Objective C的方法在编译后会走
objc_msgSend
,所以通过[fishhook](https://github.com/facebook/fishhook)
hook这一个C函数即可获得Objective C符号。由于objc_msgSend是变长参数
,所以hook代码需要用汇编
来实现,对开发人员要求较高。而且也只能拿到OC 和 swift中@objc 后的方法. - 所以用
clang插桩
来拿到所有方法,做到100%覆盖符号。进入clang官网,会有示例代码.主要是trace_pc_guard追踪各种方法、函数、block等的调用。需要在Build Settings中的Other C Flags,输入-fsanitize-coverage=trace-pc-guard,则Clang
就在读代码时候生成中间代码IR时插入一行调用自己函数方法的代码,在xcode中实现对应的函数方法就可以拿到了。这属于汇编插桩
。 - dlfcn.h里的方法dladdr可以根据上面clang的函数方法返回的函数地址来获取到对应的方法名sname
main阶段之后的优化建议
:
- 多线程加载业务逻辑,充分利用多线程。能放后台初始化的放后台,尽量不要占用主线程的启动时间。减少启动初始化的流程,能懒加载的懒加载,能延迟的延迟。
- 不用的代码和类去除(2w个类大约800毫秒)
- 尽量主页面不要用xib等。
- swift尽量使用struct结构体,因为结构体是值类型,保存在栈中,效率比堆上更高。栈空间地址分配的过程中是
从高到低
的(先分配0x0010再分配0x0001)
瘦身
官方 App Thinning
- App Thinning 会专门针对不同的设备来选择只适用于当前设备的内容以供下载。大部分工作都是由 Xcode 和 App Store 来帮你完成的,你只需要通过 Xcode 添加 xcassets 目录,然后将图片添加进来即可。
- Xcode 默认会开启
DEAD_CODE_STRIP
选项,C/C++/Swift 等静态语言
编译器会在 link 的时候移除未使用的代码,但是对于 Objective-C 等动态语言
是无效的,因为动态语言是建立在运行时
上面的。 - 编译期优化参数:
GCC_OPTIMIZATION_LEVEL
定义了 clang 用什么优化等级
进行编译优化。 Xcode 默认的 Debug 使用-O0
, Release 使用-Os
。更为激进的-Oz
会减小相同代码存在多份
问题,但是也会使得的函数调用存在更深的调用栈
,会影响性能。Oz 的核心原理是对重复的连续机器指令外联成函数进行复用,和“内联函数”的原理正好相反。因此,开启 Oz,能减小二进制的大小,但同时理论上会带来执行效率的额外消耗。对性能(CPU)敏感的代码使用需要评估。 - 链接期优化参数: LLVM 提供链接期编译优化,通过设置工程中的
Link-Time Optimization
进行控制,其本质是开启生成LTO
等优化格式。调试期不建议开启
,会增加编译时间。开启 LTO 之后对于 Objc Runtime 需要的一些结构 比如方法签名的literal string, protocol
的结构等有比较大的优化。
己方
- 使用频率高且小的图片放到 Asset.car 中,Asset.car 能保证其加载和渲染的速度最优。而大的图片比如背景图之类的,长宽尺寸就有上千个像素,而这种放到 Asset.car 中会大大的增加安装包的大小。
- 无用图片资源删除,推荐库LSUnusedResources
- 有用图片瘦身:图片大小超过了 100KB,你可以考虑使用 WebP;将图片转成 WebP格式,推荐iSparta.在显示图片时使用 libwebp 进行解析。WebP 在 CPU 消耗和解码时间上会比 PNG 高两倍。所以需要在性能和体积上做取舍。
- 对PNG图片无损压缩来优化包大小没有效果的,因为Xcode 会通过自己的压缩算法重新对图片进行处理,只能压缩其尺寸大小。Xcode 中,构建 Asset Catalog 的工具 actool 会首先对 Asset Catalog 中的 png 图片进行解码,得到 Bitmap 数据,然后再运用 actool 的编码压缩算法进行编码压缩处理。无损压缩通过变换图片的编码压缩算法减少大小,但是不会改变 Bitmap 数据。对于 actool 来说,它接收的输入没有改变,所以无损压缩无法优化 Assets.car 的大小。对于放入 Asset.car 中的图片如果图片没有半透明效果,使用 70% 的有损压缩
JPEG
是一个不错的方式,既能保证图片清晰度的同时获得更小的大小。 - 代码瘦身:LinkMap 来获得所有的代码类和方法的信息。Mach-O 文件的 __objc_selrefs、__objc_classrefs 和 __objc_superrefs可以获取用过的方法,类,父类。但是Objective-C 是门动态语言,方法调用可以写成在运行时动态调用,这样就无法收集全所有调用的方法和类,还要
二次确认
。例如+load
方法会被系统调用,但也能检查为未使用类。推荐Appcode
工具。最简单的静态分析:基于 otool dump 最终产物中的 __objc_class_list & __objc_class_refs 做差集
找到未使用的 Objc
类。 -
Assets.car
和Mach-O
是占用空间最大的两个文件。目前市场上最低支持的 iOS 系统版本一般为iOS 9
。然而,大部分 Pod 库的 Podspec 文件中指定的deployment_target
(最低支持版本)由于未及时修改,依然还是 iOS 8,这就导致了这些 Pod 库中指定的resource_bundles
在构建出 Assets.car 时,是以iOS 8
为最低支持版本的。统一改成iOS 9这样会多出一些优化空间。 -
符号裁剪
:符号解释, - 减少 Block 的使用
我们知道 Block 是一个特殊的 OC 对象。需要占用部分二进制空间来表征一个 Block 对象。所以在非必要使用 Block 的场景。去掉 Block 实现可以优化不少包大小,常见的比如 Masonry 通过 Block 实现的链式调用。由此可见越是方便开发工作量,对性能就越是一个考验。大部分问题都能转化为空间和时间的取舍问题。
实际用到了但被扫描成无用类:
* 一个类确实没有被其他地方使用, 但是本身逻辑依赖 +load 、+initialize、__attribute__((constructor)) 在启动时调用
* 通过 string 动态调用
* 抽象基类、基类等会被认为是无用类
* 通过运行时动态生成的代码引用了某个类
* 一个类专门作为通知处理类
* MTLModel 等,通过运行时消息机制 assign value 的无法通过 classref 统计
* 典型的 DI 场景。如果一个类声明遵循了某个 Protocol,外部使用的时候使用了这个 Protocol 进行方法调用
实际没用到但被认为有用到:
* 某个对象被另外一个对象引用,但是另外一个对象本身未被使用到。这时候会遗漏掉这个对象所属 Class 的检查
电量
-
CPU
:要避免让 CPU 做多余的事情。对于大量数据的复杂计算,应该把数据传到服务器去处理,如果必须要在 App 内处理复杂数据计算,可以通过 GCD 的dispatch_block_create_with_qos_class
方法指定队列的 Qos 为QOS_CLASS_UTILITY
,将计算工作放到这个队列的 block 里。在QOS_CLASS_UTILITY
这种 Qos 模式下,系统针对大量数据的计算,以及复杂数据处理专门做了电量优化。 -
I/O
:将碎片化的数据磁盘存储操作延后,先在内存中聚合,然后再进行磁盘存储。碎片化的数据进行聚合,在内存中进行存储的机制,可以使用系统自带的NSCache
来完成。NSCache
是线程安全的,NSCache 会在到达预设缓存空间值时清理缓存,这时会触发cache:willEvictObject:
方法的回调,在这个回调里就可以对数据进行 I/O 操作,达到将聚合的数据 I/O 延后的目的。I/O 操作的次数减少了,对电量的消耗也就减少了。SDWebImage
图片加载框架,在图片的读取缓存处理时没有直接使用 I/O,而是使用了 NSCache
。SDWebImage 将获取的图片数据都放到了 NSCache 里,利用 NSCache 缓存策略进行图片缓存内存的管理。每次读取图片时,会检查 NSCache 是否已经存在图片数据:如果有,就直接从 NSCache 里读取;如果没有,才会通过 I/O 读取磁盘缓存图片。 - 苹果专门维护了一个电量优化指南,分别从
CPU、设备唤醒、网络、图形、动画、视频、定位、加速度计、陀螺仪、磁力计、蓝牙
等多方面因素提出了电量优化方面的建议。大家可以瞅瞅。
卡顿检测
卡顿检测原理是通过子线程
对主runloop添加runloopObserver
监控即将休眠
和唤醒
两个状态间的时间间隔大于2s
左右则认为卡顿,这里确切的说是执行souce
和进入休眠
两个状态更为精确。这里通过开启一个子线程,用while代码循环持续loop,用定义一个超时2s左右的信号量,超过这个时间往下执行的时候判断信号量若不等于0则认为卡顿,然后将 BeforeSources
和AfterWaiting
这两个状态区间上传调用栈,并像微信卡顿监听方案matrix
那样利用退火算法
,保证重复的卡顿调用栈信息不会被上传。线程数超出64
个时会导致主线程卡顿,如果卡顿是由于线程多造成的,那么就没必要通过获取主线程堆栈去找卡顿原因了,根据 matrix-iOS
的实测,每隔 50 毫秒获取主线程堆栈会增加 3% 的 CPU 占用,可以忽略不计。 卡顿的类型有线程过多、CPU满负荷、绘制过度、IO操作、抢锁
文件 dump:如果内存 dump 的堆栈跟上次捕捉到的不一样,则 dump 到文件中;否则按照斐波那契数列
将检查时间递增(1,1,2,3,5,8…)直到没有遇到卡顿或卡顿堆栈不一样。这样能够避免同一个卡顿写入多个文件的情况,也能避免检测线程围着同一个卡顿空转的情况。
离屏渲染
只要裁剪(透明度/阴影)的内容需要画家算法未完成之前的内容参与就会触发offscreenrendering
。
正常的显示是从帧缓存区FrameBuffer
去取,而当我们设置了 cornerRadius 以及 masksToBounds 进行圆角 + 裁剪+阴影+高斯模糊
时,如前文所述,masksToBounds 裁剪属性会应用到所有的 sublayer 上。这也就意味着所有的 sublayer 必须要重新被应用一次圆角+裁剪,这也就意味着所有的 sublayer 在第一次被绘制完之后,并不能立刻被丢弃
,而必须要被保存在 Offscreen buffer
中等待下一轮圆角+裁剪,注意这时候并没有显示到屏幕上,多个图层都在离屏缓存区等待被一一裁剪圆角(这里是每个图层都要被裁剪,并不只是某个),这也就诱发了离屏渲染。帧缓存区是展示完了就丢弃的
。离屏渲染并不都是坏的,因为对于频繁显示的复杂的,离屏会提高性能效率。
网友评论