iOS学习笔记 App启动速度的优化与监控
前言:
好久没更新Blog了,因为写过的Demo实在太多了,所以后期自己又封装的相册选择、抽屉、轮播、引导页、二维码、走马灯等等Demo就实在懒得上传了。而针对性的加深学习,又属于深一脚浅一脚,整理出来之后觉得网上也都能查到相关性的资料,就没有更新了。再加上之前因为某个事件而很少来简书,就没想起来要更新点什么了。
不过最近开始跟一位大牛做针对性的iOS框架体系的深度学习,就打算更新一些笔记个人与心得。
前排强推极客时间-戴铭聚聚的iOS开发高手。
App启动时都干了些什么?
一般情况下,App启动分为冷启动和热启动
· 冷启动,App在点击启动前,进程不在系统里,此时需要系统新创建一个进程分配给它的情况,是一次完整的启动过程。
·热启动,App在冷启动后用户将App退到后台,在App的进程还在系统里的情况下,用户重新启动进入App的过程。
关于热启动的一些监控,Appdelegate里面就有很详细的方法提供,包括App退到后台、回到前台、即将挂起等。
为什么用户会感觉启动慢?
用户能感知到的启动慢,其实都发生在主线程上而主线程慢的原因有很多:在主线程执行大文件的读写操作、渲染周期中执行了大量计算等。
所以,想要App启动速度优化,需要把启动时的所有耗时都找出来,也就需要先弄清楚App在启动时都干了些什么。
App的启动时间,指的是从用户点击App开始,到用户看到第一个界面之间的时间。也就是,主要包括三个阶段:
1 - main()函数执行前。
2 - main()函数执行后。
3 - 首屏渲染完成后。
main()函数执行前
在main()函数执行前,系统主要会做下面几件事:
· 加载可执行文件(App的.o文件)
· 加载动态链接库,进行rebase指针调整和bind符号绑定
· Objc运行时的初始处理,包括Objc相关类的注册、category注册、selector唯一性检查等
· 初始化,包括了执行+load()方法,attribute((construction))修饰的函数的调用、创建C++静态全局变量。
(attribute 强制内联,所有加了attribute((always_inline))的函数在被调用时不会被编译成函数调用而是直接扩展到调用函数体内)
注意点:经常见到有人问 +load 和 +initalize 的区别。
· +load 是在App冷启动,Main函数执行前就会执行的。
· +initalize 则是类或子类第一次接收到消息时执行的。如果子类收到initalize消息执行请求时, Super class 没有收到过,则会先调用 super class 的 initalize 方法。
建议:+load 方法里的内容可以放到首屏渲染完成后再执行,或使用 +initalize方法替换掉。在一个+load 方法里,方法执行操作会带来4毫秒的消耗,积少成多的时候,影响会越来越大。
这个阶段的优化,可以做到的事情包括:
· 减少动态库加载。每个库本身都有依赖关系,当使用数量过多时,尽量将多个动态库进行合并。最多可以将6个动态库合并为一个。
· 减少加载启动后不回去使用的类或方法。
· +load 方法里的内容可以放到首屏渲染完成后再执行,或使用 +initalize方法替换掉。在一个+load 方法里,方法执行操作会带来4毫秒的消耗,积少成多的时候,影响会越来越大。
· 控制C++全局变量的数量。
main()函数执行后
main()函数执行后的阶段,指的是从main()函数执行开始,到Appdelegate的didFinishLaunchingWithOption 方法里首屏渲染相关方法执行完成。
(Appdelegate的didFinishLaunchingWithOption就是首屏渲染相关方法的执行,结束后代表main函数执行完。)
首页的业务代码都是要在这个夹断,也就是首屏渲染前执行的。主要有:
· 首屏初始化所需配置文件的读写操作
· 首屏列表大数据的读取
· 首屏渲染的大量计算等
优化方式,应该是从功能上梳理出哪些是首屏渲染必要的初始化功能,哪些是App启动必要的初始化功能,而哪些是只需要在对应功能开始使用时才需要初始化的。
首屏渲染完成后
这个阶段主要完成的是,非首屏其他业务服务模块的初始化、监听的注册、配置文件的读取等。也就是didFinishLaunchingWithOptions方法作用域内,执行首屏渲染之后的所有方法执行完成,从首屏渲染完成时开始到didFinishLaunchingWithOptions方法作用域结束时结束。
这个阶段时,用户已经看到了App的首页信息了,所以优先级可以往后放放。但是,那些会卡住主线程的方法还是需要最优先处理,不然还是会影响后续的用户交互。
优化方案,提供了两个角度:功能级别的启动优化、方法级别的启动优化。
功能级别的启动优化
功能级别的优化,是要从main()函数执行后这个阶段下手。
优化的思路是:main()函数开始执行后到首屏渲染完成前只处理首屏相关的业务,其他非首屏业务的初始化、监听注册、配置文件读取等都放到首屏渲染完成后去做。
方法级别的启动优化
经过功能级别的启动优化后,用户看到App首屏的时间将会大幅缩短,也就达到了优化App启动速度的目的。
之后需要做的,就是检查首屏渲染完成前主线程上有哪些耗时方法,将没必要的耗时方法滞后或者异步执行。
通常情况下,这些耗时操作表现在加载、编辑、存储图片和文件等资源。或像+load()方法,一个耗时4毫秒,100个就会是400毫秒,这种积少成多的耗时操作。
所以,需要一个能够对启动方法耗时进行全面、精确检查的手段。
目前来看,对App启动速度的监控,主要有两种:
1. 定时抓取主线程上的方法调用堆栈,计算一段时间里各个方法的耗时。如:XCode工具套间里自带的TimeProfiler。
优点是开发类似工具成本不高,能够快速开发后集成到App中,以便在真实环境中进行检查。
而说到定时抓取,就会涉及到定时间隔的长短问题。
· 定时间隔设置得长了,会漏掉一些方法,从而导致检查出来的耗时不精确。
· 定时间隔设置得短了,抓取堆栈这个方法本事调用过多也会影响整体耗时,导致结果不准确。
定时间隔如果小于所有方法执行的时间(比如0.002秒),那么基本就能监控到所有方法。但是,整体的耗时时间就不够准确。
一般会将这个时间设置为0.01秒,对整体耗时的影响小,不过很多方法耗时就不精确了。但因为整体耗时的数据更加重要些,单个方法耗时精度不高也是可以接受的。
总结来说,定时抓取主线程调用栈的方式虽然精准度不够高,但是够用了。
2.对objc_msgSend方法进行Hook来掌握所有方法的执行耗时。
hook:在原方法开始执行时换成执行其他自己所指定的方法,或者在原有方法执行前后执行我指定的方法,来达到掌握和改变指定方法的目的。
hook Objc_msgSend这种方式的优点是非常准确,缺点是只能针对Objective-C的方法。当然,对于C方法和Block可以使用libffi的ffi_call来达成hook,但缺点就是编写维护相关工具门槛高。
如何做一个方法级别启动耗时检查工具来辅助分析和监控?
首先,要知道为什么hook了Objc_msgSend方法,就可以hook全部Objective-C的方法?
Objective-C里每个对象都会指向一个类,每个类都会有一个方法列表,方法列表里的每个方法都是由selector、函数指针和metadata组成。
Objc_msgSend方法干的活儿,就是在运行时根据对象和方法的selector去找到对应的函数指针,然后执行。也就是说,Objc_msgSend是Objective-C里方法执行的必经之路,能够控制所有的Objective-C的方法。
(用我的理解描述就是:OC对象的所有方法执行,都需要通过SEL去找到对应的IMP(函数指针),然后执行。
每个方法都有选择子SEL和实现子IMP。SEL保存函数声明的地址,IMP保存函数实现的地址。每个SEL会对应一个IMP,对应方式存储在一张选择子表里。通过SEL可以获取到函数执行的地址,也就是实现子IMP的地址,进而执行函数。
而Objc_msgSend干的活,在运行时,根据对象和方法的SEL找到对应的IMP,然后执行IMP。)
Objc_msgSend本身使用汇编语言编写的,原因有两个:
- Objc_msgSend的调用频次最高,在它上面进行的性能优化能够提升整个App生命周期的性能。而汇编语言能把这个优化做到极致。
2.可以做到其他语言难以实现的功能,如通过未知参数跳转到任意函数指针的功能。
Objc_msgSend方法执行的逻辑:先获取对象对应类的信息,再获取方法的缓存,根据方法的SEL查找函数指针,经过异常错误处理后,最后跳到对应函数的实现。(异常错误:方法发送的三次回调,都没人处理就会报错,程序崩溃。)
怎么hook Objc_msgSend方法?
大致思路:实现两个方法pushCallRecord 和 popCallRecord,分别记录Objc_msgSend方法调用前后的时间,然后相减得到方法的执行耗时。
具体实现牵扯到了汇编代码。
如针对arm64架构,arm64有31个64bit的整数型寄存器,分别用x0 到 x30表示。主要思路是:
1.入栈参数,参数寄存器是x0~x7。对于objc_msgSend方法来说,x0第一个参数是传入对象,x1第二个参数是选择器_cmd。syscal的number放到x8里。
2.交换寄存器中保存的参数,将用于返回的寄存器lr中的数据移到x1里。
3.使用bl label语法调用pushCallRecord函数。
4.执行原始的objc_msgSend,保存返回值。
5.使用bl label语法调用popCallRecord函数。
而想要详细观看,具体还需要实现三步:
1.设计两个结构体:CallRecord,记录调用方法详细信息,包括obj和SEL等;ThreadCallStack,用index记录当前调用方法树的深度。
有了SEL再通过NSStringFromSelector就能够取得方法名,有了Obj通过Object_getClass就能够得到Class,再用NSStringFromClass能够获得类名。
2.pthread_setspecific()可以将私有数据设置在指定线程上,pthread_getSpecific()用来读取这个私有数据。用此特性,将ThreadCallStack的数据和该线程绑定在一起,随时进行数据存取。
3.因为要记录深度,而一个方法的调用里会有更多的方法调用,所以可以在方法的调用里增加两个方法pushCallRecord和popCallRecord,分别记录方法调用的开始和结束时间,这样才能够在开始时深度加一,结束时减一。