app启动优化

测试启动时间

下面我们通过重签名微信的IPA包来测试一下微信的启动耗时。创建一个项目,然后将微信的IPA包以及重签名的ssh脚本放到项目中。


项目目录结构
  • 其中WeChatDemo是我们创建的一个空工程,随便命令一个bundleId,然后使用真机调试,将空工程WeChatDemo安装到手机上。
  • 然后我们在工程的Build Phases中的Run Script配置自己的ssh脚本


    配置脚本
  • 然后再次真机调试,就可以将我们的微信ipa包冲签名后安装到手机上了。
  • 接下来我们在Edit Scheme中的Argements添加环境变量DYLD_PRINT_STATISTICS。


    环境变量

    然后再次执行,就可以打印出项目的启动时间了。

Total pre-main time: 1.8 seconds (100.0%)
         dylib loading time: 311.70 milliseconds (16.8%)
        rebase/binding time: 817.29 milliseconds (44.1%)
            ObjC setup time: 217.60 milliseconds (11.7%)
           initializer time: 504.95 milliseconds (27.2%)
           slowest intializers :
             libSystem.B.dylib :   6.52 milliseconds (0.3%)
    libMainThreadChecker.dylib :  44.93 milliseconds (2.4%)
          libglInterpose.dylib : 189.89 milliseconds (10.2%)
                        WeChat : 312.20 milliseconds (16.8%)

总共的启动时间为1.8秒。
dylib loading time: 加载动态库的时间,想要节约这部分的时间,就是减少动态库的数量,将一个动态库进行合并。苹果建议不要超过6个自定义的动态库;
rebase/binding time: binding是指的符号绑定时间;rebase是指的修正偏移指针,通过ASLR(一个随机值)加上虚拟地址的偏移值才能访问到程序内部的函数。通过这样的一个随机值进行访问,更加安全。
Objc setup time:OC类的注册时间。优化这个时间以及上面的rebase/binding的时间,只能减少OC的类。如果项目中存在没有使用到的类,应该删除。
initializer time: +(load)方法的耗时。最耗时的intializers中Wechat主程序占用了16.8%,其次是libgInterpose.dylib,这是一个系统的调试动态库,我们一般不用管。优化这部分的耗时,将一些可以懒加载的代码移到initialize方法中去,但是initialize方法可能会调用多次,这个一定要注意。

main函数启动后的优化

1、使用懒加载;
2、启动阶段充分发挥CPU的性能,使用多线程初始化;
3、启动阶段的相关界面不要使用Xib、Storyboard,因为xib还需要解析成代码,不如直接使用纯代码。

二进制重排

虚拟地址和物理地址

物理地址就是内存的实际物理地址;早期的app是直接访问的物理地址,应用启动后就会全部加载进内存中。这样整个app所占的内存也都确定了。既然可以访问到物理内存,而且整个app在内存中占用一整段的空间,那么我们就可以去访问其他的app的内存地址。这样就造成了两个后果:
1、不安全了;
2、整个app不管有没有使用到的部分直接加载到内存,造成了内存的浪费,加上早期的手机内存比较小,所以开启一个app的时候常常报错”内存已满,请先关闭其他的进程“。

安全问题解决:
现在我们的app访问的地址都是虚拟地址,然后iOS系统维护这一张进程的映射表,通过映射表就可以将虚拟地址转化为物理地址。但是这个转化是由操作系统来操作的,这样就保证了安全。

虚拟地址映射物理地址

内存浪费问题解决

内存分页

内存分页,iOS上内存16k为一页,对应着进程映射表(也叫页表)的一个单元。然后一页一页的加载到内存中。使用到哪一页的内存,先去页表中查找,如果有存在的话,就可以直接访问对应的物理内存;如果不存在,就会缺页中断,堵塞该进程,然后会从磁盘去加载该页。在将一页数据加载到内存的时候,如果此时内存不够用,系统会通过判断覆盖掉不活跃的部分。

ASLR

虽然虚拟地址可以保证app内无法访问其他app的内存地址,但是虚拟地址是连续的,编译完成后,虚拟地址就排列好了(从0x0开始一直往后排)。这样别人可以通过访问虚拟地址进行代码注入。ASLR就是解决这个问题的。在每次启动的时候ASLR会生成一个随机数,然后访问虚拟地址的时候,需要加上这个随机数才能正确访问。

使用Instrument得到应用启动时的page Fault(缺页中断)

首先使用Xcode将应用安装到手机上,然后我们启动Instrument,选中Instrument中的System Trace,在System Trace中选择当前调试应用的MainThread,然后下面选择Main Thread -----> Summary:Virtual Memory,然后呈现出来的File Backed Page In就是启动时候加载的页数。


Instruments中的System Trace

注意:如果我们的应用启动一次后,杀死应用,然后接着重新启动,再次测试,发现这次的File Backed Page In的数量会减少。这是因为手机内存中存在上次启动的缓存。想要再次进行冷启动的话,我们可以先多启动几个其他的应用,然后再启动我们的应用,这样先前启动缓存的内容会被覆盖。

那么我们怎么知道不同方法的编译顺序呢?我们可以通过link map文件来查看。在xcode的Build Setting设置中搜索Link Map,然后将Write Link Map FIle设置为YES,上面的PAth to Link Map File就是link Map文件的路径。

开启Link map文件写入

设置为YES后,重新build一下,然后我们就可以到对象的目录下去查找link map文件了。文件目录位于Products同级目录Intermediates.noindex/项目名.build/Debug-iphonesimulator(我使用的模拟器)/项目名.build/LinkMap.text文件。
link map文件目录

LinkMap文件内容主要是链接的文件、函数等相关内容。
主要包含三部分:
1、# Object files:生成的.o文件,按照顺序排列;
2、# Sections: 代码块、数据块的地址以及大小;
3、# Symbols:函数的地址、占用大小、所属的文件以及函数名,按照编译的顺序排列。

# Symbols:
# Address   Size        File  Name
0x100000EB0 0x00000030  [  2] +[AppDelegate load]
0x100000EE0 0x00000080  [  2] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x100000F60 0x00000120  [  2] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x100001080 0x0000006C  [  2] -[AppDelegate application:didDiscardSceneSessions:]
0x1000010F0 0x00000030  [  3] +[ViewController load]
0x100001120 0x00000039  [  3] -[ViewController viewDidLoad]
0x100001160 0x00000099  [  4] _main
0x100001200 0x000000A0  [  5] -[SceneDelegate scene:willConnectToSession:options:]
0x1000012A0 0x00000040  [  5] -[SceneDelegate sceneDidDisconnect:]
0x1000012E0 0x00000040  [  5] -[SceneDelegate sceneDidBecomeActive:]
0x100001320 0x00000040  [  5] -[SceneDelegate sceneWillResignActive:]
0x100001360 0x00000040  [  5] -[SceneDelegate sceneWillEnterForeground:]
0x1000013A0 0x00000040  [  5] -[SceneDelegate sceneDidEnterBackground:]
0x1000013E0 0x00000020  [  5] -[SceneDelegate window]
0x100001400 0x00000040  [  5] -[SceneDelegate setWindow:]
0x100001440 0x00000033  [  5] -[SceneDelegate .cxx_destruct]
......

从上面的 Symbols可以看出,函数加载的顺序是按照文件顺序来排列的。下面我们来重新排列一下函数的顺序。
我们的连接器是ld,可以配置order file来实现排雷函数的顺序。我们在项目的根目录下创建一个test.order文件,在xcode的工程配置中配置order file。


配置order file

下面我们来编译一下test.order文件

_main
-[ViewController viewDidLoad]
+[ViewController load]
+[AppDelegate load]

先排列一下这几个函数,然后再次执行程序,查看LinkMap文件,就会发现加载函数的顺序已经按照我们上面指定的顺序加载了。

获取app启动的期间调用的所有函数和方法

方式一、通过fishhook拦截objc_msgSend()函数。
fishhook可以hook系统函数,而所有的OC方法调用都会执行objc_msgSend()这个方法,所有通过拦截objc_msgSend()就可以hook所有的OC方法。但是因为objc_msgSend()的参数是可变的,所以无法直接过去到第二个参数(也就是OC方法的SEL),只能通过寄存器获取,那么就需要使用汇编代码了。可以参考抖音团队的这边文章:
https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q

通过上面的动态hook的方式,我们只能拦截OC的方法。而通过clang插桩的方式不仅可以Hook OC方法,可以hook 函数、block等。实现全部的hook。

方式二、clang插桩
http://clang.llvm.org/docs/SanitizerCoverage.html
LLVM有个代码覆盖的工具在SanitizerCoverage中,它可以覆盖我们自定义的所有的方法,block,函数,也就是在我们在我们所有的自定义方法获取函数中插入一个回调方法,这样任何自定义方法的调用都会对回调函数进行调用。
使用方式:

  • 首先在编译器的设置Other C Flag中插入-fsanitize-coverage=trace-pc-guard;
  • 在任意的类中实现下面两个方法:
    void __sanitizer_cov_trace_pc_guard(uint32_t *guard)
    void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop)

然后任何自定义的方法执行前都会首先调用__sanitizer_cov_trace_pc_guard函数。我们通过汇编代码可以看到,编译器会在方法中插入了__sanitizer_cov_trace_pc_guard函数的执行。

覆盖方法的插入

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self test];
}

- (void)test {
    
}

获取自定义方法的地址
下图中的bl是执行test方法。bl的下一条指令的地址为ox1024f1a3c

image.png

下面的test方法执行的汇编,最下面的有个return,是函数的返回,此时会将返回值存储在x30中,然后我们读取x30的值。发现x30的值和上面的bl执行test方法的下一条指令的地址相同。也就是说test方法返回了test方法执行完成后的下一条指令的地址。


image.png

我们使用静态插桩的方式拦截自定义方法,就是在自定义方法中插入了__sanitizer_cov_trace_pc_guard方法,所以__sanitizer_cov_trace_pc_guard返回的地址就是我们自定义方法中执行完成__sanitizer_cov_trace_pc_guard方法后的下一条指令地址。通过该地址,我们就可以知道自定义方法。

void *PC = __builtin_return_address(0);

根据方法地址获取到方法名
通过上面的__builtin_return_address方法我们就可以获取到该方法返回得地址。
拿到地址后,我们可以通过dladdr函数,获取到地址对应方法的信息,包含方法名、方法的起始地址等。

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;  // Duplicate the guard check.
    
  void *PC = __builtin_return_address(0);
    
    Dl_info info;
    dladdr(PC, &info);

    printf("---------------------------");
    printf("sname:%s \n", info.dli_sname);
}
---------------------------
 sname:-[ViewController test] 

这样我们就获取到自定义的方法名信息。

将获取到的方法存储起来

我们这里将上面获取到的方法的地址存储起来,因为可能会有多线程的操作,所以我们必须保证线程安全。选择使用原子队列来进行存储。

  • 首先导入头文件:
#import <libkern/OSAtomic.h>
  • 然后创建原子队列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;

OS_ATOMIC_QUEUE_INIT只能存储结构体,并且是以链表的形式进行存储。

  • 创建链表的节点
typedef struct {
    void *pc;
    void *next;
}SYNode;

其中pc存储的是方法的指针,next是下一个节点地址。

  • 往队列中存储
// 在堆区申请内存
SYNode *node = malloc(sizeof(SYNode));
// 给节点赋值
*node = (SYNode){PC, NULL};
// 压栈存储
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));

OSAtomicEnqueue函数有三个参数。第一个参数是队列的地址,第二个参数是节点,第三个参数是节点结构体中next的偏移。offsetof函数就是用来获取结构体中某成员的偏移的。

将上一步存储起来的方法取出来
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    while (YES) {
        SYNode *node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
        if (node == NULL) {
            break;
        }
        //根据地址获取到方法
        Dl_info info;
        dladdr(node->pc, &info);
        printf("%s \n", info.dli_sname);
    }
}

我们在touchesBegan方法中通过doWhile循环获取到所有的存储的方法。点击屏幕触发touchesBegan方法后发现一直在循环打印方法-[ViewController touchesBegan:withEvent:]。因为doWhile循环同样会触发__sanitizer_cov_trace_pc_guard方法,所以就是每次doWhile循环一次,就会触发一次__sanitizer_cov_trace_pc_guard方法,然后存储一次touchesBegan方法。
解决方法:上面我们在Other C Flags中添加的参数由-fsanitize-coverage=trace-pc-guard改为-fsanitize-coverage=func,trace-pc-guard,增加了一个func,表示只有方法函数类才会触发。
最后我们打印的结果如下:

-[ViewController touchesBegan:withEvent:] 
-[SceneDelegate window] 
-[SceneDelegate window] 
-[SceneDelegate sceneDidBecomeActive:] 
-[SceneDelegate sceneWillEnterForeground:] 
block1_block_invoke 
test 
-[ViewController viewDidLoad] 
-[SceneDelegate window] 
-[SceneDelegate window] 
-[SceneDelegate window] 
-[SceneDelegate scene:willConnectToSession:options:] 
-[SceneDelegate window] 
-[SceneDelegate window] 
-[SceneDelegate setWindow:] 
-[SceneDelegate window] 
-[AppDelegate application:didFinishLaunchingWithOptions:] 
main 
+[AppDelegate load] 
+[ViewController load] 

可见,不管是方法,还是函数、block、load方法全部都打印出来了。但是有点小问题:
1、按照order file的规则,函数和block(除去方法外)前面应该加上"_";
2、因为栈的特点,打印的顺序反了;
3、存在重复的方法,需要去重处理;

下面针对上面的问题进行修复:

NSMutableArray <NSString *> * symbolNames = [NSMutableArray array];

while (YES) {
    SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
    if (node == NULL) {
        break;
    }
    Dl_info info;
    dladdr(node->pc, &info);
    NSString * name = @(info.dli_sname);
    BOOL  isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
    // 如果不是OC方法,在前面加上下划线
    NSString * symbolName = isObjc ? name: [@"_" stringByAppendingString:name];
    [symbolNames addObject:symbolName];
}
//取反
NSEnumerator * emt = [symbolNames reverseObjectEnumerator];
//去重
NSMutableArray<NSString *> *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
NSString * name;
while (name = [emt nextObject]) {
    if (![funcs containsObject:name]) {
        [funcs addObject:name];
    }
}

然后转换成字符串,存储到本地文件:

//将数组变成字符串
NSString * funcStr = [funcs  componentsJoinedByString:@"\n"];

NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"hank.order"];
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];

然后我们就可以拿到这个文件。设置Write Link Map File为Yes,配置好order file的路径。就可以按照我们order文件中顺序加载了,可以通过linkMap文件进行验证。

swift方法的覆盖

因为swift的编译器前端为swift,所以我们需在Building Setting 中的Other Swift Flags中添加-sanitize-coverage=func-sanitize=undefined

swift设置

因为swift的命名空间的原因,swift函数的名字显示不太友好,例如:

_$s9TraceDemo9SwiftTestC05swiftD4LoadyyFZTo
_$s9TraceDemo9SwiftTestC05swiftD4LoadyyFZ
_$ss5print_9separator10terminatoryypd_S2StFfA0_
_$ss5print_9separator10terminatoryypd_S2StFfA1_

注意:获取到方法调用顺序文件后,一定要将Other C Flags和Other Swift Flags中的配置去掉,因为只要存在这个配置,编译器每次编译的时候都会进行插桩。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容