iOS 底层 - 性能优化之启动和电池能耗

本文源自本人的学习记录整理与理解,其中参考阅读了部分优秀的博客和书籍,尽量以通俗简单的语句转述。引用到的地方如有遗漏或未能一一列举原文出处还望见谅与指出,另文章内容如有不妥之处还望指教,万分感谢 !

写在前面:

Mach-O文件简介

  • Mach object的缩写,是MaciOS上用于存储程序、库的标准格式 ; Mach-O文件是一种叫法,就像以 .text 结尾的文件,被叫做为text文件

常见的Mach-O文件有:

  • MH_OBJECT:目标文件(.o)、静态库文件(.a) 静态库其实就是N个.o合并在一起

  • MH_EXECUTE:可执行文件 .app/xx

  • MH_DYLIB:动态库文件 .dylib.framework/xx

  • MH_DYLINKER:动态链接编辑器 /usr/lib/dyld

  • MH_DSYM:存储着二进制文件符号信息的文件
    .dSYM/Contents/Resources/DWARF/xx(常用于分析APP的崩溃信息)

dyld简介

在iOS系统中,几乎所有的程序都会用到动态库,而动态库在加载的时候都需要用dyld(位于/usr/lib/dyld)程序进行链接。很多系统库几乎都是每个程序都要用到的,与其在每个程序运行的时候一个一个将这些动态库都加载进来,还不如先把它们打包好,一次加载进来来的快。

  • dynamic link editor的缩写, Apple的 动态链接器,macOS和iOS通用; 动态库不能直接运行 ,需要通过系统的动态链接加载器进行加载到内存后执行;主要用来装载Mach-O文件; 比如:动态库 和 可执行文件。

  • 动态链接器在系统中以一个用户态的可执行文件形式存在, 一般应用程序会在Mach-O文件部分指定一个LC_LOAD_DYLINKER的加载命令,此加载命令指定了dyld的路径,通常它的默认值是“/usr/lib/dyld”。

  • dyld加载时,为了优化程序启动,启用了共享缓存(shared cache)技术。共享缓存会在进程启动时被dyld映射到内存中

  • 每当任何Mach-O镜像加载时,dyld首先会检查该Mach-O镜像与所需的动态库是否在共享缓存中,如果存在,则直接将它在共享内存中的内存地址映射到进程的内存地址空间。在程序依赖的系统动态库很多的情况下,这种做法对程序启动性能是有明显提升的。

可执行文件:

  • 平时编写的代码最终会被编译成为一个Mach-O格式的文件
  • 开发过程中所用到的动态库(比如:UIKit、Foundation) 依赖信息也会存储在可执行文件中

动态库:

  • 程序运行时由系统动态加载到内存,而不是复制,供程序调用。
  • 系统只加载一次,多个程序共用,节省内存。因此,编译内容更小,而且因为动态库是需要时才被引用,所以更快。
    简单认识:系统的UIKit框架最终被dyld以动态库的形式加载到内存 !

系统使用动态链接有几点好处:

代码共用:很多程序都动态链接了这些 lib,但它们在内存和磁盘中中只有一份。 易于维护:由于被依赖的 lib 是程序执行时才链接的,所以这些 lib 很容易做更新,比如libSystem.dyliblibSystem.B.dylib 的替身,哪天想升级直接换成libSystem.C.dylib 然后再替换替身就行了。 减少可执行文件体积:相比静态链接,动态链接在编译时不需要打进去,所以可执行文件的体积要小很多。

iOS 底层 - 性能优化之启动和电池能耗_第1张图片
动态链接优点.jpg

如上图所示,不同进程之间共用系统dylib的_TEXT区,但是各自维护对应的_DATA区。

所有动态链接库和我们App中的静态库.a和所有类文件编译后的.o文件最终都是由dyld,Apple的动态链接器来加载到内存中。每个image都是由一个叫做ImageLoader的类来负责加载(一一对应)

1. 应用启动

APP启动时间长短,直接会影响用户对APP的第一体验;如果启动时间过长,不但会影响体验导致用户直接奥利给,同时可能会触发苹果的 watch dog机制 kill 掉APP;这就很尴尬了,掉粉啊这 ! APP启动卡死接着直接崩溃了。。。。。。

  • Xcode在debug模式下默认不开启 watch dog,所以有条件还是走一走真机测试

APP的启动可以分为2种

  • 冷启动:Cold Launch , 从零开始启动APP
  • 热启动:Warm Launch , APP已经在内存中,在后台存活着,再次点击图标启动APP

在衡量APP的启动时间之前先了解下,APP的启动流程:

iOS 底层 - 性能优化之启动和电池能耗_第2张图片
APP的启动流程.png

Mach-O文件加载

iOS 底层 - 性能优化之启动和电池能耗_第3张图片
Mach-O文件结构.png

mach-o文件有如下几个部分组成:

  • Header:保存了一些基本信息,包括了该文件运行的平台、文件类型、LoadCommands的个数等等。

  • LoadCommands:可以理解为加载命令,在加载Mach-O文件时会使用这里的数据来确定内存的分布以及相关的加载命令。比如我们的main函数的加载地址,程序所需的dyld的文件路径,以及相关依赖库的文件路径。

  • Data: 这里包含了具体的代码、数据等等。

APP的启动从用户态来说可以分为三个阶段,即 dyld加载依赖库runtime初始化main函数 总结如下

Launch time = dyld + runtime + main()

当然也有部分同学将其分为两个阶段:main()之前main()之后;这都没有问题,只是概括粒度的差别而已;也可以直接将其分为:内核态用户态

APP加载过程:

  1. 系统会开启一个进程,然后读取可执行文件(Mach-O文件),从里面获得dyld的路径
  2. 加载dyld,dyld先初始化运行环境,开启缓存策略,加载(递归)程序相关依赖库(其中也包含我们的可执行文件)到内存中,并生成相应的镜像

C++静态对象初始化构造器initializer

  1. 对依赖库进行链接 -->link(),调用每个依赖库的初始化方法Initalizer,在这一步,runtime被初始化---->
    (libSystem.dylib库libdispatch_init里调用了runtime的初始化方法_objc_init);
    runtime初始化后不会闲着,在_objc_init注册了几个通知,从dyld这里接手了几个活,其中包括:
    初始化相应依赖库里的类结构
    调用依赖库里所有的load方法

  2. 当所有依赖库的初始化后,轮到最后一位(程序可执行文件)进行初始化,在这时runtime会对项目中 所有类进行类结构初始化然后调用所有的load方法

  • 调用map_images(镜像)进行可执行文件内容的解析和处理
  • 在load_images中调用call_load_methods,调用所有Class(包括分类)的+load方法
  • 进行各种objc结构的初始化(注册Objc类、初始化类对象)
  1. dyld返回main函数地址,这时进程进入就绪状态;main函数被调用后进程进入执行状态,至此便来到了熟悉的程序入口。
    这些事情大多数在 dyld:_main 方法中被发生;

main():调用UIKit库中的UIApplicationMain()找到应用的委托方法执行开发者自定义的任务,比如:获取主控制器显示到UIWindow

  1. Xcode提供的主函数调用UIKit的UIApplicationMain函数

  2. UIApplicationMain函数创建UIApplication对象和你的 AppDelegate

  3. UIKit从主故事板nib文件 加载应用程序的默认入口。

  4. UIKit调用AppDelegate的: willFinishLaunchingWithOptions:方法。

  5. UIKit执行状态恢复,它调用你的AppDelegateUIWindow的附加方法。

  6. UIKit调用AppDelegate的: didFinishLaunchingWithOptions:方法。

  7. 初始化完成后,系统使用场景委托或应用程序委托来显示UI并管理应用程序的生命周期。

总结:

APP的启动由dyld主导,将可执行文件加载到内存,顺便加载所有依赖的动态库
并由runtime负责加载成objc定义的结构
所有初始化工作结束后,dyld就会调用main函数
接下来就是UIApplicationMain函数AppDelegateapplication:didFinishLaunchingWithOptions:方法

补充:

dyld是苹果操作系统一个重要组成部分,而且令人兴奋的是,它是开源的,任何人可以通过苹果官网下载它的源码来阅读理解它的运作方式(下载地址:Source Browser),了解系统加载动态库的细节。

dyld详细流程:

XNU加载程序可执行文件后,通过分析文件来获得dyld所在路径来加载dyld,同时也把当前主程序的Mach-O头部信息给了dyld;有了头部信息,加载器就可以从头开始,遍历整个Mach-O文件的信息,获取LoadCommands(加载命令)、Data(数据、代码);有了这些就正式开始搭建初始化程序环境 ! ! ! !

  • 加载dyld-->__dyld_start()-->dyldbootstrap::start()-->_main()函数; dyld的加载动态库的代码就是从_main()开始

共九个步骤如下:

第一步: 设置运行环境,处理环境变量

第二步:初始化主程序

  • 核心API -- instantiateFromLoadedImage()
  • 将实例化好的主程序添加到全局主列表sAllImages中,最后调用addMappedRange()申请内存,更新主程序映像映射的内存区。做完这些工作,第二步初始化主程序就算完成了。

第三步:加载共享缓存

  • 主要执行mapSharedCache()来完成映射共享缓存
  • 进行动态库的版本化重载,这主要通过函数checkVersionedPaths()完成

第四步:加载插入的动态库

  • 循环遍历DYLD_INSERT_LIBRARIES环境变量中指定的动态库列表,并调用loadInsertedDylib()将其加载

第五步:链接主程序

  • 执行link()完成主程序的链接操作,该函数调用了ImageLoader自身的link()函数;主要目的是将实例化的主程序的动态数据进行修正,达到让进程可用的目的,其中典型的就是主程序中的符号表修正操作

rebase和bind

rebase修复的是指向当前镜像内部的资源指针。
bind修复的是指向镜像外部的资源指针。

由于ASLR(address space layout randomization)的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要这两步来修复镜像中的资源指针,来指向正确的地址。

rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。bind在其后进行,由于要查询符号表,来指向跨镜像的资源,加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算

备注:recursiveBind()完成递归绑定符号表的操作。此处的符号表针对的是非延迟加载的符号表,它的核心是调用了doBind(),在ImageLoaderMachOCompressed中,该函数读取映像动态链接信息的bind_offbind_size来确定需要绑定的数据偏移与大小,然后挨个对它们进行绑定,绑定操作具体使用bindAt()函数,它主要通过调用resolve()解析完符号表后,调用bindLocation()完成最终的绑定操作,需要绑定的符号信息有三种:

BIND_TYPE_POINTER:需要绑定的是一个指针。直接将计算好的新值屿值即可。
BIND_TYPE_TEXT_ABSOLUTE32:一个32位的值。取计算的值的低32位赋值过去。
BIND_TYPE_TEXT_PCREL32:重定位符号。需要使用新值减掉需要修正的地址值来计算出重定位值。

第六步:链接插入的动态库

  • 链接插入的动态库与链接主程序一样,都是使用的link(),插入的动态库列表是前面调用addImage()保存到sAllImages中的,之后,循环获取每一个动态库的ImageLoader,调用link()对其进行链接,注意:sAllImages中保存的第一项是主程序的映像。

第七步:执行弱符号绑定

  • 调用weakBind()函数执行弱符号绑定。
    首先通过调用contextgetCoalescedImages()sAllImages中所有含有弱符号的映像合并成一个列表,合并完后调用initializeCoalIterator()对映像进行排序,排序完成后调用incrementCoalIterator()收集需要进行绑定的弱符号
    incrementCoalIterator ()是一个虚函数,在ImageLoaderMachOCompressed中,该函数读取映像动态链接信息的weak_bind_offweak_bind_size来确定弱符号的数据偏移与大小,然后挨个计算它们的地址信息。之后调用getAddressCoalIterator(),按照映像的加载顺序在导出表中查找符号的地址,找到后调用updateUsesCoalIterator()执行最终的绑定操作,执行绑定的是bindLocation()

第八步:执行初始化方法

  • 执行初始化的方法入口是initializeMainExecutable(),主要实现了doImageInit()doModInitFunctions()执行映像模块中设置为init的函数静态初始化方法

第九步:查找程序入口函数并返回

  • 这一步调用主程序映像的getThreadPC()函数来查找主程序的LC_MAIN加载命令获取程序的入口点,没找到就调用getMain()LC_UNIXTHREAD加载命令中去找,找到后就跳转到入口点指定的地址返回 !
到这里,dyld整个加载动态库的过程就算完成了。

2. 启动时间优化主要针对冷启动

查看main()函数执行之前的耗时:

Xcode提供通过添加环境变量来打印APP启动时间分析

  • Edit scheme --> Run -->Arguments-->Environment Variables 添加 DYLD_PRINT_STATISTICS 设置为 1
  • 如果需要更加详细的时间信息,添加 DYLD_PRINT_STATISTICS_DETAILS 设置为 1
  • 以上环境变量本质上插入到 dyld全局的环境变量sEnv中,
iOS 底层 - 性能优化之启动和电池能耗_第4张图片
添加环境变量.png

输出结果示例

Total pre-main time:  36.22 milliseconds (100.0%)
         dylib loading time:  14.43 milliseconds (42.1%)
        rebase/binding time:   1.82 milliseconds (5.3%)
            ObjC setup time:   3.89 milliseconds (11.3%)
           initializer time:  13.99 milliseconds (40.9%)
           slowest intializers :
             libSystem.B.dylib :   3.20 milliseconds (6.4%)
   libBacktraceRecording.dylib :   3.90 milliseconds (8.4%)
    libMainThreadChecker.dylib :   6.55 milliseconds (19.1%)
-------------------------   分析    ------------------------
pre: previous  在…以前
在执行main函数之前所用的时间:36.22毫秒
                                    动态库加载:14.43 毫秒
                                    rebase绑定:1.82毫秒
                                ObjC结构准备:3.89毫秒
                                             初始化:13.99毫秒
比较慢的加载:
 libSystem.B.dylib :  3.20 毫秒
 libBacktraceRecording.dylib :   3.90 毫秒
 libMainThreadChecker.dylib :  6.55 毫秒

优化方案:

dyld优化:

  • 减少动态库、合并一些动态库(定期清理不必要的动态库)
  • 减少Objc类分类的数量、减少Selector数量(定期清理不必要的类、分类)
  • 减少C++虚函数数量
    -Swift尽量使用struct
  • 使用AppCode工具检查未被使用的文件

虚函数

C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法。比如:模板技术,RTTI技术,虚函数技术,要么是试图做到在编译时决议,要么试图做到运行时决议。

  • 和oc的多态不同,虽然也可以认为父类拥有多种形态,但OC只支持单继承;父类指针可以指向子类示例,但是不能调用没有在父类中声明的方法;只能调用子类重写的方法

runtime

  • +initialize方法和dispatch_once取代所有的__attribute__((constructor))C++静态构造器、Objc的load

main

  • 在不影响用户体验的前提下,尽可能将一些操作延迟,不要全部都放在finishLaunching方法中
  • 按需加载,用到的时候再加载

3. 电池能耗

手机电池电量是极其有限的,没有电的手机就像一块没有实际作用的模型;一个APP如果对电池消耗影响很大就可能会被用户奥利给 !

一般开发中对电池消耗比较大的几个方面:

  1. CPU处理,Processing; 高频的处理会加快电量的消耗

  2. 网络,Networking, 长连接发送接收数据,手机需要持续的保持信号接收和发送对手机电量的消耗会比较大;比如:微信、QQ等APP

  3. 定位,Location; 持续定位不断的获取GPS信息,和刷新对电量消耗也会很快 比如:高德导航、百度地图等软件使用起来电量就消耗很快

  4. 图像,Graphics ; GPU的图像渲染是会占用大量的资源,同时也很耗电

可以通过对一下方面做出相应的优化措施:

  • 尽可能降低CPU、GPU的功耗,即CPU、GPU的优化
  • 少用定时器, 定时器会持续循环的做事情,这样的操作会对电量消耗较快
  • 优化 I/O (文件读写)操作
  1. 尽量不要频繁写入小数据,可以把小数据整理在空闲时一次性写入;在不影响结果的情况下

  2. 读写大量重要数据时,考虑用dispatch_io,其提供了基于GCD的异步操作文件I/O的API, 用dispatch_io系统会优化对磁盘访问;

  3. 数据量比较的情况,不建议直接存储在文件里;可以考虑是用数据库,比如SQLiteCoreData; 因为数据库对数据读写都是有优化过的,有响应的算法,会比直接读写更有优势的多

  4. 网络优化

    • 减少、压缩网络数据,比如网络传输早期用XML:体积比较大 ,后来使用JSON: 体积就比较小;现在也有人在用protocl buffer这种格式传输,但前提是服务器也使用相同的格式接收
    • 上传图片,文件等数据先进行压缩,或者分片上传
    • 如果多次请求的结果相同,尽量使用缓存
    • 断点续传,100MB的文件,下载了一半,突然关机了;下次下载的时候可以从50MB开始继续下载,不需要从头开始
    • 如果网络状态变为不可用或者是未知网络时,不要尝试频繁执行网络请求;
    • 让用户可以取消长时间运行或者网速很慢的网络操作;并设置合适的超时时间
    • 批量传输,比如 下载视频流时,不要传输很小的数据包,直接下载整个文件或者一大块一大块地下载。 如果下载广告,一次性多下载一些,然后再慢慢展示。如果下载电子邮件,一次下载多封,不要一封一封下载
  5. 定位优化

    • 如果只需要快速确定用户位置,最好用CoreLocation框架中CLLocationManagerrequestLocation方法获取位置信息;
      此方法优势:定位完成后,会自动让定位硬件断电
    • 如果不是导航应用,尽量不要实时获取定位;定位完毕就关掉定位服务
    • 尽量降低定位精度,比如尽量不要使用精度较高的kCLLocationAccuracyBest
    • 需要用到后台定位时,尽量设置pausesLocationUpdatesAutomaticallyYES :用来停止当用户静止时位置的自动更新;
  6. 硬件检测优化

    • 用户移动、摇晃、倾斜设备是,会产生动作(motion)事件; 这些事件由加速度计、陀螺仪、磁力计等硬件检测;在不需要检测的场景,应该及时关闭这些的使用

今日头条iOS客户端启动速度优化
iOS 底层 - 性能优化之CPU、GPU
iOS 底层 - 性能优化之安装包瘦身(App Thinning)

你可能感兴趣的:(iOS 底层 - 性能优化之启动和电池能耗)