iOS 启动速度优化和安装包优化简单总结

712 阅读10分钟

启动速度优化

APP启动过程

pre-main 阶段

  1. dyld
    (1). 加载可执行文件(自身App的所有.o文件的集合)。
    (2). 加载动态链接库。
    加载动态链接器dyld(dynamic loader,是一个专门用来加载动态链接库的库)。
    dyld递归加载应用所有依赖的动态链接库dylib。

    动态链接器(the dynamic link editor),是一个专门用来加载动态链接库的库。在 xnu 内核为程序启动做好准备后,执行由内核态切换到用户态,由dyld完成后面的加载工作。
    系统先读取App的可执行文件(Mach-O文件),从里面获得dyld的路径,然后加载dyld,dyld去初始化运行环境,开启缓存策略,加载程序依赖的动态库(其中也包含我们的可执行文件),并对这些库进行链接(主要是rebaseing和binding),最后调用每个依赖库的初始化方法,在这一步,runtime被初始化。

    Mach-O: Mach-O文件格式是 OS X 与 iOS 系统上的可执行文件格式,像我们编译过程产生的.O文件,以及程序的可执行文件,动态库等都是Mach-O文件。

    ImageLoader: 用于辅助加载特定可执行文件格式的类,程序中对应实例可简称为image(如程序可执行文件,Framework库,bundle文件)。

  2. Rebasing 和 Binding
    ASLR(Address Space Layout Randomization),地址空间布局随机化。在ASLR技术出现之前,程序都是在固定的地址加载的,这样hacker可以知道程序里面某个函数的具体地址,植入某些恶意代码,修改函数的地址等,带来了很多的危险性。ASLR就是为了解决这个的,程序每次启动后地址都会随机变化,这样程序里所有的代码地址都需要需要重新对进行计算修复才能正常访问。
    rebasing这一步主要就是调整镜像内部指针的指向。
    Binding:将指针指向镜像外部的内容。

  3. ObjC setup
    Objc 运行时的初始处理,包括 Objc 相关类的注册、category 注册、selector 唯一性检查等。+load() 方法调用也是在这一步。
    上面最后一步调用的objc_init方法,这个是runtime的初始化方法,在这个方法里面主要的操作就是加载类:

    /***********************************************************************
    * _objc_init
    * Bootstrap initialization. Registers our image notifier with dyld.
    * Called by libSystem BEFORE library initialization time
    **********************************************************************/
    
    void _objc_init(void)
    {
        static bool initialized = false;
        if (initialized) return;
        initialized = true;
    
        // fixme defer initialization until an objc-using image is found?
        environ_init();
        tls_init();
        static_init();
        lock_init();
        exception_init();
    
        _dyld_objc_notify_register(&map_images, load_images, unmap_image);
    }
    

    _dyld_objc_notify_register(&map_images, load_images, unmap_image);向dyld注册了一个通知事件,当有新的image加载到内存的时候,就会触发load_images方法,这个方法里面就是加载对应image里面的类,并调用load方法。

    load_images(const char *path __unused, const struct mach_header *mh)
    {
        // Return without taking locks if there are no +load methods here.
        if (!hasLoadMethods((const headerType *)mh)) return;
    
        recursive_mutex_locker_t lock(loadMethodLock);
    
        // Discover load methods
        {
            rwlock_writer_t lock2(runtimeLock);
            prepare_load_methods((const headerType *)mh);
        }
    
        // Call +load methods (without runtimeLock - re-entrant)
        call_load_methods();
    }
    
    /***********************************************************************
    * call_load_methods
    * Call all pending class and category +load methods.
    * Class +load methods are called superclass-first. 
    * Category +load methods are not called until after the parent class's +load.
    * 
    * This method must be RE-ENTRANT, because a +load could trigger 
    * more image mapping. In addition, the superclass-first ordering 
    * must be preserved in the face of re-entrant calls. Therefore, 
    * only the OUTERMOST call of this function will do anything, and 
    * that call will handle all loadable classes, even those generated 
    * while it was running.
    *
    * The sequence below preserves +load ordering in the face of 
    * image loading during a +load, and make sure that no 
    * +load method is forgotten because it was added during 
    * a +load call.
    * Sequence:
    * 1. Repeatedly call class +loads until there aren't any more
    * 2. Call category +loads ONCE.
    * 3. Run more +loads if:
    *    (a) there are more classes to load, OR
    *    (b) there are some potential category +loads that have 
    *        still never been attempted.
    * Category +loads are only run once to ensure "parent class first" 
    * ordering, even if a category +load triggers a new loadable class 
    * and a new loadable category attached to that class. 
    *
    * Locking: loadMethodLock must be held by the caller 
    *   All other locks must not be held.
    **********************************************************************/
    void call_load_methods(void)
    {
        static bool loading = NO;
        bool more_categories;
    
        loadMethodLock.assertLocked();
    
        // Re-entrant calls do nothing; the outermost call will finish the job.
        if (loading) return;
        loading = YES;
    
        void *pool = objc_autoreleasePoolPush();
    
        do {
            // 1. Repeatedly call class +loads until there aren't any more
            while (loadable_classes_used > 0) {
                call_class_loads();
            }
    
            // 2. Call category +loads ONCE
            more_categories = call_category_loads();
    
            // 3. Run more +loads if there are classes OR more untried categories
        } while (loadable_classes_used > 0  ||  more_categories);
    
        objc_autoreleasePoolPop(pool);
    
        loading = NO;
    }
    

    如果有继承的类,那么会先调用父类的load方法,然后调用子类的,但是在load里面不能调用[super load]。最后才是调用category的load方法。所以在这一步,所有的load都会被调用到。

  4. C++ initializer
    创建 C++ 静态全局变量。在这一步,如果我们代码里面使用了clang的__attribute__((constructor))构造方法,都会调用到。

main 阶段

两种方式,个人觉得第二种更能反应问题。
第一种,main 到 didFinishLaunching 结束;
第二种,main 到第一个 ViewController 的 viewDidAppear。

  1. 调用main()
  2. 调用UIApplicationMain()
  3. 调用applicationWillFinishLaunching
  4. 首页初始化UI渲染、数据的读取和计算、数据库读取和存储等
文章推荐

iOS程序启动->dyld加载->runtime初始化(初识)

启动时间监测

Xcode获取 pre-main 时间

测试启动时间,xcode 只需要在 Edit scheme -> Run -> Arguments 中将环境变量 DYLD_PRINT_STATISTICS 设为 1,就可以看到 main 之前各个阶段的时间消耗。打印更详细的耗时 则将 DYLD_PRINT_STATISTICS_DETAILS 设为 1 。 设置: 打印时间截图:

上图可看出主要分为下面几个部分:

  • dylib loading time
  • rebase/binding time
  • ObjC setup time
  • initializer time
线上获取 pre-main 时间

不依靠 Xcode 我们怎么获取 main 之前的加载时间呢?
主要获取开发者可控的启动段。也就是图中展示的 Init 段,在这段时间里处理 C++ 静态对象的 initializer、ObjC Load 方法的执行。
思路:通过 hook 关键函数的调用,计算获得性能数据。统计每个 load 函数的时间、全部 load 函数的整体时间,上报统计分析。

启动时间优化

main()调用之前优化点:
  1. 减少不必要的framework,因为动态链接比较耗时
  2. check framework应当设为 optionalrequired ,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为 optional,因为 optional 会有些额外的检查
  3. 合并或者删减一些OC类,关于清理项目中没用到的类,使用工具AppCode代码检查功能(删减下线业务、删减冗余代码、模板化)
  4. 删减一些无用的静态变量
  5. 删减没有被调用到或者已经废弃的方法
    stackoverflow.com/questions/3…
    developer.Apple.com/library/ios…
  6. 将不必须在 +load 方法中做的事情延迟到 +initialize
  7. 尽量不要用 C++ 虚函数(创建虚函数表有开销)
  8. 二进制重排等
main()调用之后优化点:
  • 分析
    在main()被调用之后,App的主要工作就是初始化必要的服务,显示首页内容等。而我们的优化也是围绕如何能够快速展现首页来开展。
    App通常在 AppDelegate 类中的 didFinishLaunchingWithOptions: 方法中创建首页需要展示的view,然后在当前runloop的末尾,主动调用CA::Transaction::commit完成视图的渲染。
    而视图的渲染主要涉及三个阶段:
    1. 准备阶段 这里主要是图片的解码
    2. 布局阶段 首页所有UIView的 layoutSubViews 运行
    3. 绘制阶段 首页所有UIView的 drawRect: 运行 再加上启动之后必要服务的启动、必要数据的创建和读取,这些就是我们可以尝试优化的地方
  • main()函数调用之后可以优化的点:
    1. didFinishLaunching 里的函数考虑能否挖掘可以延迟加载或者懒加载(UI展示无关的业务,如升级弹窗、认证过期检查、图片最大缓存空间、三方库、注册推送通知等做延迟加载,比如放到首页控制器的viewDidAppear方法里)。
    2. 每次用NSLog方式打印会隐式的创建一个Calendar,因此需要删减启动时各业务方打的log,或者仅仅针对内测版输出log。
    3. 梳理应用启动时发送的所有网络请求,是否可以统一在异步线程请求。
    4. 首页加载相关:
      (1)使用一些策略。如广告图,加载广告的同时初始化首页的列表页,当广告展示完成之后列表页也就渲染完成了。
      (2)纯代码方式来构建首页,不使用 storyboard、xib。
      (3)部分可以延迟创建的视图应做延迟创建/懒加载处理。
      (4)NSUserDefaults实际上是在Library文件夹下会生产一个plist文件,如果文件太大的话一次能读取到内存中可能很耗时,这个影响需要评估,如果耗时很大的话需要拆分(需考虑老版本覆盖安装兼容问题)
文章推荐

阿里数据iOS端启动速度优化的一些经验
iOS端启动速度优化
iOS性能优化:Instruments使用实战

安装包优化

App安装包(ipa文件)是由资源(图片+文档)和可执行文件(二进制文件)两部分组成,安装包瘦身也是从这两部分进行。

1. 资源文件优化(主要指图片资源)

  • LSUnusedResource这个软件查找项目中没有用到的图片,然后删除,当然不一定特别准确,有一些[UIImage imageNamed:[NSString stringWithFormat:@"icon_%d",index]]这样使用的图片也会被列在未使用图片中。
  • 压缩图片资源(用imageoptim压缩图片的大小、一些比较大体积的背景图片压缩成.jpg格式的)
  • 使用Assets.xcassets来管理图片也可以减小安装包的体积

2. 文档资源优化

  • 删除不必要的文档
  • 优化精简文档内容

3. 代码优化

  • 技术手段排查冗余代码(删除无用类、方法、第三方库、readme文件)
  • 注意平时的开发习惯,废弃模块及早清理
  • 代码结构重构: 代码重构是对一个或者几个类的重复代码的抽象封装,使代码看上去更清晰,复用性更好。
  • 不要因为要用一个小方法引入一个库

4. Xcode编译选项优化

  1. 配置编译选项
    Levels选项内 -> Generate Debug Symbols 设置为NO,这个配置选项应该会让你减去小半的体积。注意这个如果设置成NO就不会在断点处停下;
  2. 编译器优化级别:Build Settings->Optimization
    Level有几个编译优化选项,release版应该选择Fastest, Smalllest[-Os],这个选项会开启那些不增加代码大小的全部优化,并让可执行文件尽可能小。
  3. 去除符号信息
    Strip Debug Symbols During Copy 和 Symbols Hidden by Default 在release版本应该设为yes,可以去除不必要的调试符号。Symbols Hidden by Default会把所有符号都定义成”private extern”,设了后会减小体积。
  4. Strip Linked Product
    DEBUG下设为NO,RELEASE下设为YES,用于RELEASE模式下缩减app的大小
  5. 编译器优化,去掉异常支持。
    Enable C++ Exceptions、Enable Objective-C Exceptions设置为NO,Other C Flags添加-fno-exceptions

解释:
Generate Debug Symbols:这个设置在DEBUG和RELEASE下均默认为YES。调试符号是在编译时生成的。
在Xcode中查看构建过程,可以发现,当Generate Debug Symbols选项设置为YES时,每个源文件在编译成.o文件时,编译参数多了-g和-gmodules两项,但链接等其他的过程没有变化。当然编译产生的.o文件会大一些,最终生成的可执行文件也大一些。
当Generate Debug Symbols设置为NO的时候,在Xcode中设置的断点不会中断。但是在程序中打印[NSThread callStackSymbols],依然可以看到类名和方法名。
Strip Linked Product:设为NO,在Xcode中设置的断点不会中断。
配置具体解释

思考

  1. 如何做到数据驱动启动速度优化和安装包优化。
  2. 如何卡控开发流程避免包大小增加等。

参考文章:

阿里数据iOS端启动速度优化的一些经验
iOS中 性能优化之浅谈load与initialize 韩俊强的博客 干货|今日头条iOS端安装包大小优化