启动过程
iOS应用的启动可分为pre-main阶段和main()阶段,pre-main阶段为main函数执行之前所做的操作,main阶段为main函数到首页展示阶段。其中系统做的事情为:
- premain
- 加载所有依赖的Mach-O文件(递归调用Mach-O加载的方法)
- 加载动态链接库加载器dyld(dynamic loader)
- 定位内部、外部指针引用,例如字符串、函数等
- 加载类扩展(Category)中的方法
- C++静态对象加载、调用ObjC的 +load 函数
- 执行声明为attribute((constructor))的C函数
- main
- 调用main()
- 调用UIApplicationMain()
- 调用applicationWillFinishLaunching
优化思路
- 最多的用时还是在image加载和OC类的初始化,共占用总时长的79.3%,精简framework的引入和OC类有优化的空间。优化点:
- 减少不必要的framework,因为动态链接比较耗时 | 不要自己使用dlopen方法,让系统来处理模块加载
- 合并或者删减一些OC类,关于清理项目中没用到的类,使用工具AppCode代码检查功能,查到当前项目中没有用到的类如下:
- 删减一些无用的静态变量 | 删减没有被调用到或者已经废弃的方法
- 将不必须在+load方法中做的事情延迟
- 对于main()函数调用之前我们可以优化的点有:
- 不使用xib,直接视用代码加载首页视图 | 首页骨架屏 默认数据
- NSUserDefaults实际上是在Library文件夹下会生产一个plist文件,如果文件太大的话一次能读取到内存中可能很耗时,这个影响需要评估,如果耗时很大的话需要拆分(需考虑老版本覆盖安装兼容问题)
- 每次用NSLog方式打印会隐式的创建一个Calendar,因此需要删减启动时各业务方打的log,或者仅仅针对内测版输出log
- 对didFinishLaunching里的函数考虑能否挖掘可以延迟加载或者懒加载,需要与各个业务方pm和rd共同check 对于一些已经下线的业务,删减冗余代码。 对于一些与UI展示无关的业务,如微博认证过期检查、图片最大缓存空间设置等做延迟加载
统计启动时的耗时方法
这一阶段就是需要对启动过程的业务逻辑进行梳理,确认哪些是可以延迟加载的,哪些可以放在子线程加载,以及哪些是可以懒加载处理的。同时对耗时比较严重的方法进行review并提出优化策略进行优化。
DYLD_PRINT_STATISTICS_DETAILS
instrument TimeProfile
Instruments的TimeProfile来统计启动时的主要方法耗时,Call Tree->Hide System Libraries
打印时间的方式来统计各个函数的耗时
1 | double launchTime = CFAbsoluteTimeGetCurrent(); |
+load方法统计
同样的我们可以通过Instruments来统计启动时所有的+load方法,以及+load方法所用耗时
+load优化 与 attribute
使用__attribute优化+load方法 由于在我们的工程中存在很多的+load方法,而其中一大部分为cell模板注册的+load方法(我们的每一个cell对应一个模板,然后该模板对应一个字符串,在启动时所有的模板方法都在+load中注册对应的字符串即在字典中存储字符串和对应的cell模板,然后动态下发展示对应的cell)。
1 | 编译器提供了我们一种__attribute__((section("xxx段,xxx节")的方式让我们将一个指定的数据储存到我们需要的节当中。 |
通过使用attribute((section(“name”)))来指明哪个段。数据则用attribute((used))来标记,防止链接器会优化删除未被使用的段。
读取section中的值 attribute((constructor))
constructor 和 +load 都是在 main 函数执行前调用,但 +load 比 constructor 更加早一丢丢,因为 dyld(动态链接器,程序的最初起点)在加载 image(可以理解成 Mach-O 文件)时会先通知 objc runtime 去加载其中所有的类,每加载一个类时,它的 +load 随之调用,全部加载完成后,dyld 才会调用这个 image 中所有的 constructor 方法。所以 constructor 是一个干坏事的绝佳时机:
- 所有Class都已经加载完成
- main 函数还未执行
- 无需像 +load 还得挂载在一个Class中
在BeeHive源码中有下面一段代码:
_dyld_register_func_for_add_image
:这个函数是用来注册回调,当dyld链接符号时,调用此回调函数。在dyld加载镜像时,会执行注册过的回调函数
对于每一个已经存在的镜像,当它被动态链接时,都会执行回调void (func)(const struct mach_header mh, intptr_t vmaddr_slide),传入文件的mach_header以及一个虚拟内存地址 intptr_t。
通过调用BHReadConfiguration函数,我们就可以拿到之前注册到BeehiveMods特殊段里面的各个Module的类名,该函数返回类名字符串的数组。
1 | __attribute__((constructor)) |
二进制重排 + 静态插桩
静态插桩实际上是在编译期就在每一个函数内部二进制源数据添加 hook 代码 ( 我们添加的 __sanitizer_cov_trace_pc_guard 函数 ) 来实现全局的方法 hook 的效果 .
1 | void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { |
问题点
- 多线程问题
- 考虑到这个方法会来特别多次 , 使用锁会影响性能 , 这里使用苹果底层的原子队列 ( 底层实际上是个栈结构 , 利用队列结构 + 原子性来保证顺序 ) 来实现
- 循环引用
- Other C Flags 修改为如下 :-fsanitize-coverage=func,trace-pc-guard
- swift 工程 / 混编工程
* Other Swift Flags , 添加两条配置即可 : -sanitize-coverage=func -sanitize=undefined
压缩资源图片
压缩图片为什么能加快启动速度呢?因为启动的时候大大小小的图片加载个十来二十个是很正常的,图片小了,IO操作量就小了,启动当然就会快了。
事实上,Xcode在编译App的时候,已经自动把需要打包到App里的资源图片压缩过一遍了。然而Xcode的压缩会相对比较保守。
解决各种矛盾的方法就是要找出一种相当靠谱的压缩方法,而且最好是基本无损的,而且压缩率还要特别高,至少要比Xcode自动压缩的效果要更好才有意义。经过各种试验,最后发现唯一可靠的压缩算法是TinyPNG
图片预加载
在看 [UIImage imageNamed:] 文档时发现一句话
In iOS 9 and later, this method is thread safe.
看到它之后立刻想到,能否在进程启动早期通过子线程预先加载首页图片。为什么在早期呢?通过 Instruments 分析可看到在支付宝启动早期,CPU 占用是不那么满的,为了让启动过程中充分利用 CPU,就尽量在早期启动子线程。
问题与解决
在优化之后,也伴随而来一些不稳定的问题:
App 启动会有小概率的 Crash。
根据分析,我们决定把这段代码移到 AppDelegate 的 didFinishLaunching 中,并且增加开关。
iPhone7 不需要预加载
在 iPhone7 设备出来后,我们发现 iPhone7 的启动性能反而不如 iPhone6S。分析后发现,在性能更好的 iPhone7 上,由于启动很快,导致子线程的 imageNamed 与 主线程的 imageNamed 相互穿插调用,而 imageNamed 内部的线程安全锁的粒度很小,导致锁的消耗过大。
启动任务分类
App启动中的任务可以简单分为下面几类:
- 必须最早在主线程初始化的任务
- 可以子线程执行的任务
- 可以与2中的任务并行执行的主线程任务
- 可以在首页显示后子线程执行的任务
Background Fetch 简介
Background Fetch 类似一种智能的轮询机制,系统会根据用户的使用习惯进行适应,在用户真正启动应用之前,触发后台更新,来获取数据并且更新页面。
针对这样的策略,大家可能会有疑虑,这种频繁的后台启动会不会增加耗电量? 当然不会,系统会根据设备的电量和数据使用情况来调用频率控制,避免在非活跃时间频繁的获取数据。而且,进程启动后后存活的时间很短,多数情况下会立即 suspend
实践
- Info.plist 中 UIBackgroundModes 节点配置 fetch 数值
- didFinishLaunching 时配置[[UIApplication sharedApplication] setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum];
这一步配置的minimum interval,单位是秒,只是给系统的建议,系统并不会按照给定的时间间隔按规律的唤醒进程。 - 实现下面的回调,并调用 completionHandler- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
由于 Background Fetch 机制是为了让App在后台拉取准备数据,但支付宝只是为了实现”秒起“。调用 completionHandler 后系统将把 App 进程挂起。且系统必须在30秒内调用 completionHandler,否则进程将被杀死。此外根据文档,系统会根据后台调用 completionHandler 的时间来决定后台唤起App的频率。因此,认为可以“伪造“1秒的延迟时间,即1秒后调用 completionHandler。类似下面的代码:
1 | - (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler{ |
- 进程快速挂起导致 Sync 成功率下降
- 灰度期间,开发同学发现同步服务 Sync 成功率下降很多,找来找去发现原因:由于进程唤醒后,网络长连接线程被激活并马上建立长连接,而1秒后调用completionHandler,进程又被挂起。服务器端的sync消息则发送超时。
- 进程频繁挂起、唤醒导致网络建连次数增加
- 系统预测用户使用 App 的时间,并在用户实现 App 前唤醒 App,给予 App 后台准备数据的机会。再加上预测的准确性问题,这样进程被唤醒的次数远大于用户使用的次数。进程唤醒后,网络长连接会立即建立。因此导致网络建连次数大增,甚至翻倍。
- 由于进程挂起,导致定时器、延迟调用等时间“与预想的时间不同”
- 例如,一个间隔间隔时间为 60 秒的定时器,由于进程挂起时间超过 60 秒,则下次进程唤醒时会立刻触发到时。(延迟调用 dispatch_after 等类似)。对于进程自身来说,可能定时器有点不正常,需要排查所有的定时器逻辑,是否会因为挂起导致“业务层面的异常”。
- 获取时间戳
- 由于进程挂起,导致前后获取的时间戳间隔很大
为解决以上遇到的、以及预测到的问题,经过讨论,决定在 Background Fetch 后台唤醒的时候,不建立长连接。
解决
- 延后 10 秒调用 completionHandler。
- 后台唤醒存在两种情况:进程从无到有,进程从挂起到恢复。前者需要有充足的时间完成 App 的后台冷启动过程,因此定义了 10 秒的时间。
- 后台 Background Fetch 的时间内不建立长连接。