iOS App启动优化

启动过程

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
2
3
double launchTime = CFAbsoluteTimeGetCurrent();
[SDWebImageManager sharedManager];
NSLog(@"launchTime = %f秒", CFAbsoluteTimeGetCurrent() - launchTime);

+load方法统计

同样的我们可以通过Instruments来统计启动时所有的+load方法,以及+load方法所用耗时

+load优化 与 attribute

使用__attribute优化+load方法 由于在我们的工程中存在很多的+load方法,而其中一大部分为cell模板注册的+load方法(我们的每一个cell对应一个模板,然后该模板对应一个字符串,在启动时所有的模板方法都在+load中注册对应的字符串即在字典中存储字符串和对应的cell模板,然后动态下发展示对应的cell)。

1
2
3
4
5
6
编译器提供了我们一种__attribute__((section("xxx段,xxx节")的方式让我们将一个指定的数据储存到我们需要的节当中。

在BeeHive框架中:

```objc
@class BeeHive; char * kShopModule_mod __attribute((used, section("__DATA,""BeehiveMods"""))) = """ShopModule""";

通过使用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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
__attribute__((constructor))
void initProphet() {
_dyld_register_func_for_add_image(dyld_callback);
}

static void dyld_callback(const struct mach_header *mhp, intptr_t vmaddr_slide)
{
NSArray *mods = BHReadConfiguration(BeehiveModSectName, mhp);
for (NSString *modName in mods) {
Class cls;
if (modName) {
cls = NSClassFromString(modName);

if (cls) {
[[BHModuleManager sharedManager] registerDynamicModule:cls];
}
}
}
}


NSArray<NSString *>* BHReadConfiguration(char *sectionName,const struct mach_header *mhp)
{
NSMutableArray *configs = [NSMutableArray array];
unsigned long size = 0;
#ifndef __LP64__
uintptr_t *memory = (uintptr_t*)getsectiondata(mhp, SEG_DATA, sectionName, &size);
#else
const struct mach_header_64 *mhp64 = (const struct mach_header_64 *)mhp;
uintptr_t *memory = (uintptr_t*)getsectiondata(mhp64, SEG_DATA, sectionName, &size);
#endif

unsigned long counter = size/sizeof(void*);
for(int idx = 0; idx < counter; ++idx){
char *string = (char*)memory[idx];
NSString *str = [NSString stringWithUTF8String:string];
if(!str)continue;

BHLog(@"config = %@", str);
if(str) [configs addObject:str];
}

return configs;
}

二进制重排 + 静态插桩

静态插桩实际上是在编译期就在每一个函数内部二进制源数据添加 hook 代码 ( 我们添加的 __sanitizer_cov_trace_pc_guard 函数 ) 来实现全局的方法 hook 的效果 .

1
2
3
4
5
6
7
8
9
10
11
12
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
//它的作用其实就是去读取 x30 中所存储的要返回时下一条指令的地址 . 所以他名称叫做 __builtin_return_address . 换句话说 , 这个地址就是我当前这个函数执行完毕后 , 要返回到哪里去 .
void *PC = __builtin_return_address(0);
Dl_info info;
dladdr(PC, &info);

printf("fname=%s \nfbase=%p \nsname=%s\nsaddr=%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);

char PcDescr[1024];
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

问题点

  • 多线程问题
    • 考虑到这个方法会来特别多次 , 使用锁会影响性能 , 这里使用苹果底层的原子队列 ( 底层实际上是个栈结构 , 利用队列结构 + 原子性来保证顺序 ) 来实现
  • 循环引用
    • 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
2
3
4
5
- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler{
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
completionHandler(UIBackgroundFetchResultNewData);
});
}
  • 进程快速挂起导致 Sync 成功率下降
    • 灰度期间,开发同学发现同步服务 Sync 成功率下降很多,找来找去发现原因:由于进程唤醒后,网络长连接线程被激活并马上建立长连接,而1秒后调用completionHandler,进程又被挂起。服务器端的sync消息则发送超时。
  • 进程频繁挂起、唤醒导致网络建连次数增加
    • 系统预测用户使用 App 的时间,并在用户实现 App 前唤醒 App,给予 App 后台准备数据的机会。再加上预测的准确性问题,这样进程被唤醒的次数远大于用户使用的次数。进程唤醒后,网络长连接会立即建立。因此导致网络建连次数大增,甚至翻倍。
  • 由于进程挂起,导致定时器、延迟调用等时间“与预想的时间不同”
    • 例如,一个间隔间隔时间为 60 秒的定时器,由于进程挂起时间超过 60 秒,则下次进程唤醒时会立刻触发到时。(延迟调用 dispatch_after 等类似)。对于进程自身来说,可能定时器有点不正常,需要排查所有的定时器逻辑,是否会因为挂起导致“业务层面的异常”。
  • 获取时间戳
    • 由于进程挂起,导致前后获取的时间戳间隔很大

为解决以上遇到的、以及预测到的问题,经过讨论,决定在 Background Fetch 后台唤醒的时候,不建立长连接。

解决

  • 延后 10 秒调用 completionHandler。
    • 后台唤醒存在两种情况:进程从无到有,进程从挂起到恢复。前者需要有充足的时间完成 App 的后台冷启动过程,因此定义了 10 秒的时间。
  • 后台 Background Fetch 的时间内不建立长连接。