iOS-APP启动优化(一)

前言

APP的启动优化,对开发者来说是一个永无止境的过程。开发者们在追求更快的路上,实现了一次又一次的突破(这里也包括Apple团队对操作系统的优化);而且启动优化也是面试经常会问到的问题。本篇文章我们就一起探索下在iOS APP中启动优化的那些事儿。
通常我们在做优化的时候会将优化以main()函数为界分为2个部分,即main之前的pre-main阶段main()之后。这里我们就具体的看下:

启动性能检测main()之前

我们在前面的文章介绍过,在APP启动到main函数执行的这个阶段,是由dyld来操作的,即_dyld_start->dyldbootstrap::start()->dyld::_main()
->dyld::initializeMainExecutable()->ImageLoader->ImageLoaderMachO->
libSystem_initializer()->libdispatch_init()->_objc_init()->
_dyld_objc_notify_register()->map_images->load_images->main() 的大致流程。而且这个阶段我们也无法介入,所在这里只能借助dyld的一些配置参数来获取一些信息。这里我们通过一个DYLD_PRINT_STATISTICS配置来获取pre-main阶段的耗时统计。
Edit Schemes -> Run -> Arguments -> Environment Variables中添加配置:

Edit Schemes

配置完成之后,运行我们的程序(冷启动),然后看控制台输出:

Total pre-main time: 5.8 seconds (100.0%)
         dylib loading time: 169.23 milliseconds (2.9%)
        rebase/binding time: 312.46 milliseconds (5.3%)
            ObjC setup time: 229.00 milliseconds (3.9%)
           initializer time: 5.1 seconds (87.7%)
           slowest intializers :
             libSystem.B.dylib :   6.01 milliseconds (0.1%)
                       TestApp : 5.0 seconds (85.9%) 主程序耗时

这里可以看到,输出了在pre-main阶段的总的耗时数,同时列举出了每个环节的具体耗时情况,以及最耗时操作项。接下来,对每一项做一个简单的介绍和一些优化建议:

dylib loading time 动态库加载耗时(169.23ms)。关于动态库的加载,这个是不可避免的,我们能做的就是减少动态库的引用,官方的建议的是动态库的使用应该在6个以内,所以这里就引入了一个动态库合并的概念(后面的文章会详细介绍),通过合并动态库,从而减少在pre-main时的加载时间。

rebase/binding 偏移修正/符号绑定。这个过程由操作系统完成。(ASLR安全机制,在二进制文件头部添加随机值)/

ObjC setup OC类注册。这也就意味着项目中OC类越多,这里消耗的时间也就会增加。

initializer 这个阶段指的是+ (void)load,C++构造函数等初始化操作。 这里可以看到用时5.1 seconds,是所有项做高的。这里是因为我在项目里面随便的一个类里实现了+(void)load函数,并模拟了一个耗时操作。所以这里的优化比较明确:1. 能不使用+load就尽量不要使用,可以将load内部逻辑推迟到initialize时;2. 使用到了load,就尽量不要在内部执行耗时操作;3. 如果混编了C++代码,要尽量减少构造函数中的耗时操作

slowest intializers 启动时用时最慢的文件,这个可以看到耗时最多的是TestApp项目本身,这里主要是由于那个模拟的耗时操作导致。

启动性能检测main()之后

main()函数之后的优化就因项目不同而异了,大致有这么几个核心:

  1. 业务逻辑:这里主要指APP从启动到首页呈现的阶段。尽量减少与该阶段无关且没有必要的初始化代码操作,把这部分代码以懒加载的方式处理。
  2. 删除无用代码:这里是随着业务的发展,APP不断的迭代更新,会产生很多的的下架业务,从而堆积了很多的无用代码,这些代码会增加ObjC setup的耗时,所以要清理掉。
  3. 多线程操作:在启动时,将一些必要的非UI业务且需要初始化操作的任务放在子线程中,这样可以在APP启动的时候,发挥CPU的最大性能。
  4. 启动页面:首要呈现的画面,尽量减少使用 .xibstoryBoard来实现,因为它们需要解析成代码,会造成耗时。
  5. 业务具体优化:提升代码质量,采用最合理的实现方式等。

如果说上面的优化,都是具体项目内部代码的优化,而且都是些常规的操作,那接下来就开始装逼了,近年来比较有名的项目层面的优化——二进制重排。在第一次听到这个概念的时候,觉的非常的牛逼(无知嘛),但实际了解后发现,这本不是一个新的概念,而是苹果(Xcode)本身就提供给我们的能力,操作也非常的简单,由于不了解,所以被忽视。在实际操作前,我们需要弄清楚的是,

  1. 什么是二进制排列?
  2. 二进制重排是如何做到优化的,或者说它优化的是什么?

为了弄清楚这两个问题,我们就需要先了解一下计算机内存的发展演变过程,带着问题,继续阅读下文:

虚拟内存与物理内存

物理内存指通过物理内存条而获得的内存空间。
在早期的操作系统没有虚拟内存的概念,而是直接操作物理内存,当应用程序启动后,会全部载入物理内存中。

早起操作系统的内存应用方式

这样就导致了2个非常严重的问题:
1.内存不够用:由于应用程序会直接全部载入到物理内存,当多个应该启动时,出现了内存使用紧张问题;同时随着软件行业的快速发展,软件越来越大,当前的逻辑无法满足需求。
2.安全问题:由于是直接载入到物理内存,所以黑客可以通过内存地址直接访问到应用程序,并做任意更改;而且可以通过地址偏移访问到其他应用程序;造成了严重的安全问题。

为了解决上面的问题,就出面了虚拟内存虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。在当下的操作系统中,被普遍应用。

App->虚拟内存->物理内存

操作系统会给每一个进程分配独立的虚拟内存空间(大小为4G,并不会满载),将二进制文件进行碎片化切割,而切割的每一个碎片称为一个页表页表大小在MacOS的大小为4kb,在iOS下是16kb。页表实际上是虚拟地址->物理地址的映射表,通过MMU技术实现查询,最终找到对应的物理地址。
此时,由于物理内存只存在多个进程的非连续碎片,使得物理内存空间得到了释放;每个APP进程都有了独立的虚拟内存,进程与进程之间隔离且独立,互不干扰,安全方面提升;同时开发者只能访问虚拟内存地址,而无法操作物理地址,黑客攻击难度加大。
还要注意一个点是:为了防止攻击者能可靠地跳转到内存的特定位置来利用函数,在二进制文件加载到虚拟内存时,采用ASLR技术,在二进制的头部添加了一个随机地址偏移。这也就对应上了在pre-main阶段的rebase(偏移修正)操作。

这跟二进制重排有什么关系呐?
当CPU访问页表数据时,首先访问虚拟内存页表地址,通过MMU 读取到物理地址,如果存在页表,读取物理内存上的数据;物理内存页表不存在时,会将虚拟内存对应的页表载入到物理内存,然后读取物理内存上的数据;当物理内存满载时,此时如果需要载入新的页表,操作系统会通过一些算法来找到物理内存中不活跃的页表数据,让新的页表直接覆盖掉(比如我们打开多个APP,并将APP退入到后台,长时间不打开后,再次从后台唤起时,需要重新加载)。

这里重点说一下物理内存页表不存在时的情况,此时会触发一个缺页异常/缺页中断(PageFault),然后会将对应的虚拟内存中的页表载入到物理内存。一个PageFault是会耗时的,只不过这个耗时非常短,用户感知不到。
在我们的应用中,什么时候会大量的出现PageFault异常呐?
毫无疑问是APP刚启动的时候,初始化时,物理内存上还没有相关的页表,需要大量的载入,同时抛出大量的PageFault,大量的PageFault堆积导致了用户可感知的耗时。
而这里的页表载入到物理内存是存在资源浪费的。比如APP启动到首页呈现的过程中调用了func0,test1,load2等方法,但这3个方法分别存在Page0,Page3,Page5,而为了达到目的,需要将这3个页表都载入到内存中,也就是3x16kb 的数据量,而我访问这3个方法的数据量一共还没有一个页表的大小,如果能将这3个方法放在同一个页表内,不就不需要其他页表的载入了吗?这里就引出了我们二进制重排,将一些相关性的数据放在同一个页表中(默认放在最前面的页表中),这样就能减少多个页表的载入,从而减少PageFault的出现次数,启动耗时自然也就减少了。

接下来,我们通过一个简单的Demo 来感受一下:

@implementation ViewController
- (void)loadOtherObject
{
    TestObject *objc = [TestObject alloc];
    [objc toDoSomething];
}
- (void)func1
{
    NSLog(@"func1");
}
- (void)viewDidLoad
{
    [super viewDidLoad];
    NSLog(@"viewDidLoad");
    [self func1];
    [self loadOtherObject];
}
void func0(){
    NSLog(@"func0");
}
+ (void)load
{
    NSLog(@"我是load");
    func0();
}
@end

上面代码的执行顺序:
load->func0->viewDidLoad->func1->loadOtherObject->toDoSomething
但是代码的排列顺序是什么样的呐?查看代码排列顺序:
Target->Build Settings->Link Map->Write Link Map File = YES

Link Map

command+build后,会在指定的Path to Link Map File的位置生成一个.txt文件,打开文件:
LinkMap.txt片段

从这里可以看出代码的排列顺序是跟代码书写顺序是一致的,页表的拆分也会按照这个顺序来分割,这也就导致了在ViewController中的用到的-[TestObject toDoSomething] 跟其它函数的调用会分布在不同的页表中,而二进制重排的目的就上将有关联性的零散分布在多个页表中的数据整合到一个页表内,来减少多个页表的载入耗时,提升APP启动效率。

这里的代码排列顺序为什么是先LoginView,TestObject,LoginViewModel,SecneDelegate.....的顺序呐?这是由Build Phases下的Compile Source决定的:

Compile Source

这里要注意的是二进制重排并不受这里顺序的影响。

PageFault调试

通过XcodeInstruments工具来直观的看下当APP启动时会抛出多少PageFault异常数量。
通过command+control+I唤起Instruments,然后选择System Trace

Instruments

点击运行,当程序起来后,结束运行,等待生成报告,
System Trace

这里需要切换到APP的MainThread,然后选择Summary:Virtual Memory,之后可以看到列表第一行File Backed Page InCount值为836,这里表示APP启动时发生了836缺页异常/缺页中断(PageFault)。我们的优化目标就是降低这个Count的值。

二进制重排

上面大篇幅的文字,都是为二进制重排做铺垫,同时也回答了上面提到的2个问题。接下来,我们就实际的操作一下二进制重排,上文中我也提到过,Xcode本身就给我们提供了这个能力,我们只需要按照步骤操作就行了。

二进制文件 本质上是由 链接器(LLVM-ld) 生成的,而链接器本身自带一个参数Order file,这个参数就是为了满足开发者自定义符号排列顺序而存在的。
在apple的库源码项目中也会看到这么一个.order文件的存在,它也是通过二进制重排的方式做了一些优化:

libobjc.order

libobjc.order内的符号排列

到此,我们首先要在我们项目的根目录创建一个.order文件,然后在Xcode中配置Order File,最后编辑.order文件:

配置Order File

接下来编辑TestApp.order文件,在上的Link-Map中已经看到过代码排列顺序,
而且可以看到启动时使用到的代码都在后面:

# Address   Size        File  Name
0x100004ED4 0x00000028  [  1] +[LoginView load]
0x100004EFC 0x00000014  [  1] +[LoginView initialize]
0x100004F10 0x00000114  [  1] -[LoginView initWithFrame:]
0x100005024 0x00000014  [  1] -[LoginView loadUI]
0x100005038 0x00000074  [  1] -[LoginView loginClick]
0x1000050AC 0x0000002C  [  1] -[LoginView viewModel]
0x1000050D8 0x00000048  [  1] -[LoginView setViewModel:]
0x100005120 0x00000044  [  1] -[LoginView .cxx_destruct]
0x100005164 0x0000002C  [  2] -[TestObject toDoSomething]
0x100005190 0x00000080  [  3] -[LoginViewModel toLoginWithPhone:pass:]
0x100005210 0x000000B4  [  4] -[SceneDelegate scene:willConnectToSession:options:]
0x1000052C4 0x0000004C  [  4] -[SceneDelegate sceneDidDisconnect:]
0x100005310 0x0000004C  [  4] -[SceneDelegate sceneDidBecomeActive:]
0x10000535C 0x0000004C  [  4] -[SceneDelegate sceneWillResignActive:]
0x1000053A8 0x0000004C  [  4] -[SceneDelegate sceneWillEnterForeground:]
0x1000053F4 0x0000004C  [  4] -[SceneDelegate sceneDidEnterBackground:]
0x100005440 0x0000002C  [  4] -[SceneDelegate window]
0x10000546C 0x00000048  [  4] -[SceneDelegate setWindow:]
0x1000054B4 0x00000044  [  4] -[SceneDelegate .cxx_destruct]
0x1000054F8 0x000000AC  [  5] _main
0x1000055A4 0x00000088  [  6] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x10000562C 0x00000108  [  6] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x100005734 0x00000080  [  6] -[AppDelegate application:didDiscardSceneSessions:]
0x1000057B4 0x00000064  [  7] -[ViewController loadOtherObject]
0x100005818 0x0000002C  [  7] -[ViewController func1]
0x100005844 0x00000088  [  7] -[ViewController viewDidLoad]
0x1000058CC 0x0000001C  [  7] _func0
0x1000058E8 0x00000030  [  7] +[ViewController load]

接下来我想要修改这个排列顺序为:

# Address   Size        File  Name
0x1000058E8 0x00000030  [  7] +[ViewController load]
0x1000058CC 0x0000001C  [  7] _func0
0x1000054F8 0x000000AC  [  5] _main
0x100005844 0x00000088  [  7] -[ViewController viewDidLoad]
0x100005818 0x0000002C  [  7] -[ViewController func1]
0x1000057B4 0x00000064  [  7] -[ViewController loadOtherObject]
0x100005164 0x0000002C  [  2] -[TestObject toDoSomething]
·····其他符号````````

编辑TestApp.order,在文件中添加我们想要的符号顺序:

TestApp.order

然后command + B编译一下,然后查看LinkMap.txt的变化情况:
LinkMap.txt

可以看到的是,代码的排列顺序按照了order文件的顺序排列,并且将他们都放在了开头。
到此,我们就完成了一次牛逼的二进制重排(是不是非常的简单)。

  • 细节
    a. 在order 文件中指定的符号,Link Map 会按照order配置的顺序放在最前面;没有指定的符号,会按照原来的方式排列;
    b. 在order指定一个不存在的方法符号,编译时不会发生错误的,会自动忽略掉;

  • 问题
    该如何写出一个完整的Order File文件,或者说如何获取启动时加载的所有方法符号,启动结束的界限在哪里?

iOS-APP启动优化(二)

总结

专业名词解析:页表MMUASLR物理内存虚拟内存

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

推荐阅读更多精彩内容