聊一聊启动优化吧

启动优化,在不影响业务的前提条件下,怎么提高启动的速度,这是我们要考虑的事情。

在这,根据系统打印提示信息这条主线,看下启动过程中,每个阶段都做的什么,在这些阶段我们能做哪些优化的事情。

添加Xcode的打印环境DYLD_PRINT_STATISTICS

Total pre-main time: 390.79 milliseconds (100.0%)
         dylib loading time: 228.22 milliseconds (58.4%)
        rebase/binding time:   5.04 milliseconds (1.2%)
            ObjC setup time:  21.47 milliseconds (5.4%)
           initializer time: 135.91 milliseconds (34.7%)
           slowest intializers :
             libSystem.B.dylib :   3.75 milliseconds (0.9%)
    libMainThreadChecker.dylib :  41.48 milliseconds (10.6%)
                  xxxx : 155.96 milliseconds (39.9%)

这是main函数之前系统启动过程中,也就是pre-main,经过的一些阶段,咱们针对每个阶段来详细说。

dylib loading

这是库加载的时间,包括共享缓存库、我们自己的动态库,如果进行了越狱开发,还包含了我们插入的动态库。
系统的共享缓存库(UIKit、Foundation)这些,系统是做了优化的,加载时间一般都是固定的,浪费时间的点一般在我们自己的动态库,我们自己的动态库怎么优化呢,超过6个最好合并一下。使用静态库等。

rebase/binding

rebase :这个是因为我们可执行文件加载到虚拟内存的时候,都会使用ASLR的技术,也就是在内存的最前边加一个随机的偏移值,来保证我们应用的安全。所以,我们使用的系统的函数、我们自定义的类、变量这些指针,都需要加上这个偏移值,才是真正在内存中的位置。
换句话说,类越少、方法越少,需要修正的就越少。
那么就可以使用一些工具检测你APP中的无用类,无用的方法等。

binding:这个是指针对外绑定,上边提到了共享缓存,也就是系统的库,是不加载到我们虚拟内存中的,他是在公共内存中,供大家都使用的,那么我们写了系统的函数,怎么最终找到这个真是的函数地址,就需要binding这个操作,当然,这个分为普通绑定和lazy绑定,这一部分其实没什么需要优化的空间。

ObjC setup

这一部分是加载类信心,初始化类,加载类信息,是从Mach-O的_DATA段中加载一系列类相关的信息,然后初始化,这部分先加载所有的类,插入到一个大表中,加载方法、protocol、property等,然后就是初始化类,设置类的superClass指针(这一步是递归创建的),设置类的ro/ rw相关的字段,然后合并Category相关的信息,将Category的方法、协议、property按照规则,插入到生成的类中,方法协议这些都是二维数组的形式存放,后编译的Category,插入的位置越靠前。这个想了解细节的可以看下源码。
那这部分的优化,还是在类的数量,方法的数量,协议的数量这些。

initializer time

这部分主要做的事情,加载load方法,C++静态初始化函数(attribute((constructor))),attribute((constructor)) 的调用是在load的后边,这个可以了解一下。
这部分的优化:减少load方法,尽量将一些操作进行懒加载,也就是放到+(void)initialize 函数中,这个函数是在类在第一次被调用的时候进行调用的,注意的是,这个方法可能会被多次调用,最好加dispatch_once做一下防护。
attribute((constructor))这种方法最好就是不用。

这是系统打印的阶段流程,pre-main我们能做的事情和系统能做的事情就是这些。
在这里拓展一下系统详细的启动流程,可以比对上述阶段做下了解。

应用的启动流程

  • 内核加载我们的Mach-O的可执行文件
  • 从Mach-O文件中,找到dyld的路径并加载
    从最开始的dyld start开始uintptr_t start()
  • slide 首先生成一个ASLR的随机值 (这就是那个偏移值)
  • rebaseDyld 开始符号的绑定过程 (将我们的指针开始rebase操作)
  • // allow dyld to use mach messaging
    mach_init();
  • dyld main (这是dyld的main函数)
    • 配置环境变量 (Xcode相关的一些环境变量的读取)
    • load shared cache(加载共享缓存,系统动态库UIKit等)
      • 这里边有几个判断
        • 缓存库是不是只用在这个程序
        • 是否存在reuseExistingCache,加载过就什么都不做
        • 不存在缓存中,加载mapCacheSystemWide
    • 加载主程序Mach-O(实例化主程序)
      • 这是第一个被加载的image
    • load inserted libraries 插入动态库(越狱开发修改这个字段,插入自己的动态库),等于问你插入哪些动态库,并且加载
    • link 链接程序,这是一个递归的过程,链接我们依赖系统的动态库,这里边也有rebase操作(指针修复,前边加一个偏移)
      • binding 符号的绑定
    • link 我们inset的dylib
    • binding 过程,weak binding等
  • initializeMainExecutable()初始化主程序开始了
    • 最终dyld调用到了notifySingle()//后续就到了runtime去初始化了
  • 初始化以后,会将一个函数指针load_images,传给dyld,让dyld调用,load_images这个函数里边调用了load方法。do while循环调用所有load
  • attribute((constructor)) void func(){
    }
    也就是执行C++的构造函数
  • 调用真正的main入口

runtime中objc所作的预处理,因为在runtime中放了两个回调函数供dyld去调用

  • objc - setup
    • 从Mach-O中读取类信息,注册到一个全局的表中
    • 读取Mach-O方法信息(sel),注册到全局的一个表中。
    • 创建类,信息的组装realizeClassWithoutSwift
      • ISA指针
      • superclass指针
      • cache
      • bits相关
        • 生成rw
          • 添加方法、协议、property等
          • 处理category,方法、协议拷贝进bits中。(注意这个二维数组的形式)

以上过程包含两部分,dyld的程序初始化过程,有一部分需要objc runtime的支持。

整个pre-main就这些,也可以自己进行了解后,看些是否还有更多需要注意的地方。

有了pre-mian,就有 main之后相关。

我这里认定,从main到第一个页面的viewDidAppear是main之后。
这里边都是具体业务相关,真正可以节省时间的点,都在这里边;那么启动前的代码应该怎么写呢?
个人理解,原则上到viewDidAppear所有的函数,都应该只是为了服务第一个页面的创建及渲染,等第一个页面出来后,在初始化其他相关的内容;当然这也只是美好的愿望,有些库的初始化和一些其他操作,可能不仅是为了第一个页面服务,并且还必须要放到这里去做。
这其实可以叫做分阶段启动。
必须要做的:

  • 埋点功能、
  • Crash 采集
  • 网络配置

可以延后的:各种SDK的初始化、配置文件的请求等。其实地理位置的请求也可以延后,之前看过美团还是哪个公司,他们会先用上次缓存的地理位置请求,等到首页加载完以后,如果地理位置不一样,再重新刷新。

第二种思路
可以考虑多线程启动,目前手机都是多核,可以开2-3个线程,是可以加快初始化速度的。
使用这个方案有几个注意点:

  1. 如果主线程忙完,子线程还没忙完,这时候会黑屏,所以启动的时候,就要加一个假的图片,等每个线程结束,都去问一下其他线程状态怎么样了,都结束了,假的图片消失,显示首页。
  2. 不是所有的SDK初始化都适合放到子线程,需要测试,有些依赖主线程的执行,有些内部使用了UIKit相关的。

其实目前大部分APP都有广告图,我们可以在显示广告的时候,去初始化其他的东西。

main之后也就这些。

去年的时候,抖音发布了一个二进制重排的技术进行优化,这里也研究了一下。
我们APP加载进内存,其实是一个虚拟内存,不是真正的物理内存,代码的执行是要在真正的物理内存进行的,这时候,又有一个中间的映射表出现,我们虚拟内存的数据,通过映射表,映射到真正的物理内存,然后进行代码的执行,为什么要有中间的映射表呢?因为他可以使用页的形式分块加载进物理内存,不必将整个APP全部加载进内存,每一页的大小是16KB,也就是代码执行到哪,就通过映射表将这16KB的数据映射到物理内存去执行,这样可以充分利用物理内存,保证整个物理内存基本都是高效率运转,并且安全性也有保证。
原理就是这样,如果每次启动时候的代码,分散在虚拟内存中,可能会引发多次的映射过程(缺页异常),如果能把启动是的代码,放到一个16KB页中,是不是映射一次就可以了,当然这是理想的情况,整个启动的过程大约4000次左右的映射过程大约0.2ms左右,如果进行优化,能减少百分之10左右。
我们可以使用Instruments 中的system trace 进行查看page fault的次数。

具体怎么将启动的函数聚合到一起,两种方案:

  • 抖音使用的是HOOK objc_msgSend,找到调用的所有函数,有几个缺点,静态方法hook不到,swift的结构体和枚举都是值类型、我们自己写的C函数、block这些都hook不到。(具体hook方法可以使用fishkook,一个fb开源的工具,但是还要用到汇编相关,不过肯定很多开源的)
  • 可以考虑使用Clang编译器带的函数,一般叫做Clang插桩,Clang编译器会在每个函数执行过程中插入代码,打印当前函数,这样找到的方法比较全面。
    __sanitizer_cov_trace_pc_guard
    __sanitizer_cov_trace_pc_guard_init配置这两个函数,然后将启动方法输出到一个文件中,配置到在Xcode中order-file。这样,系统在编译的时候,会按照我们制定的顺序加载。默认的编译顺序是按照Build Phases中的Compile source里边加载的。

搞完,我们可以通过输出link map查看symbols的编译顺序。

这里涉及到一点,冷启动和热启动;我们通过page fault的次数可以看出,如果启动过的APP,被杀掉以后,下一次启动page fault的次数减少了很多,说明启动过,内存中这些数据就已经存在了(内存中的数据销毁,其实就是被覆盖,如果这个时候,我们启动很多其他的应用,再次启动这个应用,还是会减缓启动时间)。
冷启动就是内存中,完全不存在这个APP,热启动就是被杀掉,然后接着启动,这时候,内存中很多数据已经有了,不需要操作了。

工具拓展:
怎么监控启动时间呢?
前滴滴出行技术专家戴铭,他实现了hook objc_msgSend,然后在这个里边添加开始调用函数和函数调用结束的标识,从而了解每一个函数的执行时间。
粗颗粒,可以使用一些定时工具,计算从启动,到启动完,或者某个阶段的时间(BLStopwatch可以看下)
Instruments 也是很好的工具 Timer 、system trace都可以显示每个函数的执行时间,他的实现原理好像是固定时间抓取函数的调用栈,粗略的算下执行时间,就和微信开源的性能检测工具Matrix,定时抓取调用栈信息,比对栈的头部,看这个调用栈出现了几次,次数多了,就认为是卡顿,优点类似。

当然,整个流程还有一些细节点,如果要考虑特别完善,要考虑网络、首页渲染、数据读取等一些细节,看有没有优化的空间。

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