/GHWAppLaunchManager

使用 __attribute__ 实现编译期自动注册,将数据写入 mach-o 文件,启动时读出数据执行启动项,通过自注册形式,实现模块的可插拔,实现 App 启动项执行流程规范化管理,该方案也是 load 方法的一个替代方案,简单理解为一种快速写入和读取配置表的类似技术。

Primary LanguageObjective-CMIT LicenseMIT

iOS App 的一种规范启动项执行流程方案

目录

前言
现状
    一. 目前的 App 启动项执行流程
    二. 存在的问题
优化方案
    一. demo
    二. 基本**
    三. 技术原理
    四. 技术实现
        1. __attribute__
        2. 编译期写入数据
        3. 运行时读出数据
        4. 读出数据要注意的地方(use_frameworks!)
    五. 总结
参考文章
后记

前言

随着业务的不断发展,我们的 App 启动要做的事情越来越多,启动时间也随之变长,维护成本越来越高,调试不方便,需要一套更好的方案来管理启动项。我们这里说的启动项是指 App 启动过程中需要被完成的某项工作,比如某个 SDK 的初始化、某个功能的预加载等。

现状

一. 目前的 App 启动项执行流程

现在我们 App 跟大多数 App 一样,启动的时候都是集中化管理启动项的,将一堆启动项写在 AppDelegate 里面,或者拆分到其他几个文件里面,如下所示

......
[ModuleA startInit];
[ModuleB configSomething];
[ModuleC register];
......

同时很多组件都在 main 函数执行之前做大量的注册工作,注册好相关数据以后到运行期间直接拿来使用,main 之前执行主要使用 load 方法。

二. 存在的问题

1. + (void)load 方法

目前很多模块里面都有使用 load 方法,用于在 main 函数执行前做一些配置,但是 load 方法的使用会带来一些弊端,主要有如下几点

  • 无法 Patch
  • 不能审计耗时
  • 调用 UIKit 相关方法会导致部分类提早初始化
  • main 函数之前执行,而且是主线程完全阻塞式执行,极大增加启动时间

经过排查,发现其实很多 load 函数里面做的事情其实可以延后执行,放到首页渲染完成以后再去做,比如一些路由注册,还有非首页相关业务里面 load 部分代码也可以往后延迟执行。

Xcode 提供了一个方法可以看到 main 之前各个阶段的时间消耗,只需要在 Edit scheme -> Run -> Arguments 中将环境变量 DYLD_PRINT_STATISTICS 设为1。还有一个方法获取更详细的时间,需要将环境变量 DYLD_PRINT_STATISTICS_DETAILS 设为1即可。

我们的 App 启动时间分布如下图,可以看出 load 都1.1秒了。

2. 维护困难

  • 所有启动项都要预先写在好几个文件里面,查看整个流程需要在几个文件里面来回切换,这种中心化的写法会导致代码臃肿,难以阅读和维护,新人如果要从头开始了解启动流程,非常费劲。
  • 启动流程没有明确定义各个阶段,新的启动项缺乏添加范式,修改风险大,如果要加一个启动项,不同的人为了保险都往前面加,希望保证自己模块能尽早执行,那些真正需要提前执行的启动项以后也会变成后面执行了。
  • 启动项都写在一个文件里面,大家都来修改这个文件,容易出现冲突,误操作,尤其是在大型团队里面。以后启动项不要了还要手动去删除,这个容易遗漏。
  • 不能精细化管理,一个模块启动可能有两三个任务,但是只有一个需要在 main 之前执行,其他可以往后放,这种分阶段的初始化不好实现。

优化方案

一. demo

可以先下载 demo 看看,注意 demo 中 Podfile 是使用动态库集成方式哦。 https://github.com/guohongwei719/GHWAppLaunchManager

二. 基本**

希望做到组件能够自己决定自己启动阶段和优先级,去中心化,可插拔,称之为启动项的自注册,添加删除启动项都能更加方便。并且启动阶段能够覆盖 main 之前。

三. 技术原理

那么如何给一个启动项声明启动阶段?又如何在正确的时机触发启动项的执行呢?

既然到了特定的阶段相关启动项能开始执行,那么启动项执行相关数据肯定还是得预先存储在某个地方,不然都不知道执行什么,关于写入启动项相关数据,也就是注册信息方面目前大多采用的方案是以下两种:

  • 使用 plist 文件,提前将相关数据手动写入进去,这种方案在大型团队很难维护,人多手杂,容易冲突或填错;
  • App 启动的时候,main 函数执行之前,执行注册代码,使用的是 load 方法,这种方案弊端上面已经提到。

不管哪种方案,都得是在 main 之前写入注册信息。

我们这里的方案是编译期写入注册信息,在代码上,一个启动项最终都会对应到一个函数的执行,所以在运行时只要能获取到函数的指针,就可以触发启动项。优化方案核心**就是在编译时把数据(如函数指针)写入到可执行文件的 __DATA 段中,运行时再从 __DATA 段取出数据进行相应的操作(调用函数)。

程序源代码被编译之后主要分为两个段:程序指令和程序数据。代码段属于程序指令,data 和 .bss 节属于数据段。

Mach-O 的组成结构如上图所示,包含了 Header、Load commands、Data(包含 Segment 的具体数据),我们平时了解到的可执行文件、库文件、Dsym 文件、动态库、动态链接器都是这种格式的。

四. 技术实现

1. __attribute__

Clang 提供了很多的编译函数,它们可以完成不同的功能,其中一项就是 section() 函数,section() 函数提供了二进制段的读写能力,它可以将一些编译期就可以确定的常量写入数据段。在具体的实现中,主要分为编译期和运行时两部分。在编译期,编译器会将标记了 attribute((section())) 的数据写到指定的数据段中,例如写一个{key(key代表不同的启动阶段), *pointer} 对到数据段。到运行时,在合适的时间节点,在根据 key 读取出函数指针,完成函数的调用。

Clang Attributes 是 Clang 提供的一种源码注解,方便开发者向编译器表达某种要求,参与控制如 Static Analyzer、Name Mangling、Code Generation 等过程,一般以 __attribute__(xxx) 的形式出现在代码中;为方便使用,一些常用属性也被 Cocoa 定义成宏,比如在系统头文件中经常出现的 NS_CLASS_AVAILABLE_IOS(9_0) 就是 __attribute__(availability(...)) 这个属性的简单写法。编译器提供了我们一种 __attribute__((section("xxx段,xxx节")的方式让我们将一个指定的数据储存到我们需要的节当中。

used

used的作用是告诉编译器,我声明的这个符号是需要保留的。被used修饰以后,意味着即使函数没有被引用,在Release下也不会被优化。如果不加这个修饰,那么Release环境链接器会去掉没有被引用的段。

section

通常情况下,编译器会将对象放置于DATA段的data或者bss节中。但是,有时我们需要将数据放置于特殊的节中,此时section可以达到目的。

constructor

constructor:顾名思义,加上这个属性的函数会在可执行文件(或 shared library)load时被调用,可以理解为在 main() 函数调用前执行。

constructor 和 +load 都是在 main 函数执行前调用,但 +load 比 constructor 更加早一点,因为 dyld(动态链接器,程序的最初起点)在加载 image(可以理解成 Mach-O 文件)时会先通知 objc runtime 去加载其中所有的类,每加载一个类时,它的 +load 随之调用,全部加载完成后,dyld 才会调用这个 image 中所有的 constructor 方法。所以 constructor 是一个干坏事的绝佳时机:

更多相关知识可以参考

http://liumh.com/2018/08/18/ios-attribute-section/
https://www.jianshu.com/p/965f6f903114
https://nshipster.com/attribute/

2. 编译期写入数据

首先我们定义函数存储的结构体,如下,function 是函数指针,指向我们要写入的函数,stage 为启动项执行的阶段,priority 为执行优先级,优先级高的启动项优先执行。结构体中还可以增加其他字段,比如类名方法名之类,调试的时候可以查看有哪些组件和方法有写入。

struct GHW_Function {
    char *stage;
    long priority;
    void (*function)(void);
};

定义函数 _GHWStage_A ,里面是需要在 Stage_A 阶段要执行的任务。

static void _GHWStage_A () {
    printf("ModuleA:Stage_A");

}

将包含函数指针的结构体写入到我们指定的数据区指定的段 __DATA, 指定的节 __launch,方法如下

__attribute__((used, section("__DATA,__launch"))) \
static const struct GHW_Function __FStage_A = (struct GHW_Function){(char *)(&("Stage_A")),  _priority_  , (void *)(&_GHWStage_A)}; \

上面步骤看起来很烦,而且代码晦涩难懂,所以要使用宏来定义一下,如下

#define GHW_FUNCTION_EXPORT(key) \
static void _GHW##key(void); \
__attribute__((used, section("__DATA,__launch"))) \
static const struct GHW_Function __F##key = (struct GHW_Function){(char *)(&#key), _priority_,  (void *)(&_GHW##key)}; \
static void _GHW##key \

然后我们将函数写入数据区方式变得很简单了,还是上面的代码,写入指定的段和节,方法如下

GHW_FUNCTION_EXPORT(Stage_A, kGHWLauncherPriorityHigh)() {
    printf("ModuleA:Stage_A\n");
}

现在可以非常方便简单了。

将工程打包,然后用 MachOView 打开 Mach-O 文件,可以看出数据写入到相关数据区了,如下

3. 运行时读出数据

启动项也需要根据所完成的任务被分类,有些启动项是需要刚启动就执行的操作,如 Crash 监控、统计上报等,否则会导致信息收集的缺失;有些启动项需要在较早的时间节点完成,例如一些提供用户信息的 SDK、定位功能的初始化、网络初始化等;有些启动项则可以被延迟执行,如一些自定义配置,一些业务服务的调用、支付 SDK、地图 SDK 等。我们所做的分阶段启动,首先就是把启动流程合理地划分为若干个启动阶段,然后依据每个启动项所做的事情的优先级把它们分配到相应的启动阶段,优先级高的放在靠前的阶段,优先级低的放在靠后的阶段。

如果要覆盖到 main 之前的阶段,之前我们是使用 load 方法,现在使用 __attribute__ 的 constructor 属性也可以实现这个效果,而且更方便,优势如下

  • 所有 Class 都已经加载完成
  • 不用像 load 还得挂在在一个 Class 中

相关代码如下

__attribute__((constructor))
void premain() {
    [[GHWLaunchManager sharedInstance] executeArrayForKey:kGHWLauncherStagePreMain];
}

表示在 main 之前去获取数据区 pre_main 节的函数指针执行。
app willFinish 和 didFinish 阶段也可以执行相关代码,如下

- (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [[GHWLaunchManager sharedInstance] executeArrayForKey:kGHWLauncherStageA];
    return YES;
}

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [[GHWLaunchManager sharedInstance] executeArrayForKey:kGHWLauncherStageB];
    return YES;
}

读出数据有两种方式:

  • 可以使用 _dyld_register_func_for_add_image 函数,这个函数是用来注册回调,在 dyld 加载镜像时,会执行注册过的回调函数,获取到每个镜像的 mach_header 然后读出数据;
  • 镜像加载完后遍历每个镜像;

本方案里面采用的第二种方式。

4. 读出数据要注意的地方(use_frameworks!)

实际在读的时候要根据我们 podfile 里面集成方式而有所区别,如果 Podfile 里面有 use_frameworks! 则是动态库集成方式,如果注释掉的话就是静态库的集成方式,静态库集成方式比较好办,因为只有一个主二进制文件,写入的数据区也是写到这个里面,只需要从这一个二进制文件里面读取就可以了。但是如果是动态库集成的各个组件,那么打成包以后各个组件最后跟主二进制文件是分开的,各个组件写入的数据区跟主二进制不是在一起的,而是写入到自己二进制文件里面相应的数据区,因此我们在读的时候需要遍历所有动态库。我们 App 启动时会加载所有动态库,一共有 569个,其中 83 个是 Podfile 里面集成的,其他都是系统库。这些库的路径也有区别,Podfile 集成进去的库路径类似下面这样

/private/var/containers/Bundle/Application/70C36D61-CD7A-49F7-A690-0C8B3D36C36A/HelloTrip.app/Frameworks/AFNetworking.framework/AFNetworking
/private/var/containers/Bundle/Application/70C36D61-CD7A-49F7-A690-0C8B3D36C36A/HelloTrip.app/Frameworks/APAddressBook.framework/APAddressBook
/private/var/containers/Bundle/Application/70C36D61-CD7A-49F7-A690-0C8B3D36C36A/HelloTrip.app/Frameworks/AliyunOSSiOS.framework/AliyunOSSiOS

系统库类似下面这样

/System/Library/Frameworks/AddressBookUI.framework/AddressBookUI
/System/Library/Frameworks/AVFoundation.framework/AVFoundation
/System/Library/Frameworks/AssetsLibrary.framework/AssetsLibrary
/usr/lib/libresolv.9.dylib

因此根据路径里面是否包含 /HelloTrip.app/ 来判断是否 Podfile 集成的库,是的话就去找对应的数据区。

经过多次测试,我们 App 在没有过滤路径情况下遍历动态库上的耗时如下

0.002198934555053711
0.002250075340270996
0.003002047538757324
0.006783008575439453
0.002267003059387207
0.003368020057678223
0.003902077674865723

有过滤路径情况下遍历时间如下:

0.0004119873046875
0.0007159709930419922
0.0004429817199707031
0.0004270076751708984
0.0004940032958984375
0.0004789829254150391
0.0004340410232543945
0.0004389286041259766

可见过滤情况下遍历一次所有动态库不到一毫秒,完全可以接受。因此如果 Podfile 中开启了 use_frameworks! ,使用动态库集成方式,那么读取 section 数据具体代码如下

static NSMutableArray<GHWModuleMetaDataModel *> * modulesInDyld() {
    NSString *appName = [[[NSBundle mainBundle] infoDictionary] objectForKey:(NSString *)kCFBundleExecutableKey];
    NSString *fullAppName = [NSString stringWithFormat:@"/%@.app/", appName];
    char *fullAppNameC = (char *)[fullAppName UTF8String];
    NSMutableArray<GHWModuleMetaDataModel *> * result = [[NSMutableArray alloc] init];
    int num = _dyld_image_count();
    for (int i = 0; i < num; i++) {
        const char *name = _dyld_get_image_name(i);
        if (strstr(name, fullAppNameC) == NULL) {
            continue;
        }
        const struct mach_header *header = _dyld_get_image_header(i);
        //        printf("%d name: %s\n", i, name);
        
        Dl_info info;
        dladdr(header, &info);
        const GHWExportValue dliFbase = (GHWExportValue)info.dli_fbase;
        const GHWExportSection *section = GHWGetSectByNameFromHeader(header, "__DATA", "__launch");
        if (section == NULL) continue;
        int addrOffset = sizeof(struct GHW_Function);
        for (GHWExportValue addr = section->offset;
             addr < section->offset + section->size;
             addr += addrOffset) {
            
            struct GHW_Function entry = *(struct GHW_Function *)(dliFbase + addr);
            GHWModuleMetaDataModel * metaData = [[GHWModuleMetaDataModel alloc] init];
            metaData.priority = entry.priority;
            metaData.imp = entry.function;
            metaData.stage = [NSString stringWithCString:entry.stage encoding:NSUTF8StringEncoding];
            [result addObject:metaData];
        }
    }
    return result;
}

第一次读取我们自定义的 __launch 节的数据,将启动项相关 C 结构体数据转为对应 OC 的数据模型,放到一个数组里面。然后将数组转化为一个字典,key 为启动相关阶段,obj 为该阶段所有启动项数组。后面再执行的时候直接去字典里面根据对应阶段获取所有启动项数组。代码如下

- (void)executeArrayForKey:(NSString *)key {
    NSMutableArray *arrayModule;
    if (![self.moduleDic count]) {
        arrayModule = modulesInDyld();
        if (!arrayModule.count) {
            return;
        }
        [arrayModule sortUsingComparator:^NSComparisonResult(GHWModuleMetaDataModel * _Nonnull obj1, GHWModuleMetaDataModel * _Nonnull obj2) {
            return obj1.priority < obj2.priority;
        }];
        for (NSInteger i = 0; i < [arrayModule count]; i++) {
            GHWModuleMetaDataModel *model = arrayModule[i];
            if (self.moduleDic[model.stage]) {
                NSMutableArray *stageArray = self.moduleDic[model.stage];
                [stageArray addObject:model];
            } else {
                NSMutableArray *stageArray = [NSMutableArray array];
                [stageArray addObject:model];
                self.moduleDic[model.stage] = stageArray;
            }
        }
    }
    arrayModule = self.moduleDic[key];

    for (NSInteger i = 0; i < [arrayModule count]; i++) {
        GHWModuleMetaDataModel *model = arrayModule[i];
        IMP imp = model.imp;
        void (*func)(void) = (void *)imp;
        func();
    }
}

五. 总结

在启动流程中,在启动阶段 Stage_A 触发所有注册到 Stage_A 时间节点的启动项,通过对这种方式,几乎没有任何额外的辅助代码,我们用一种很简洁的方式完成了启动项的自注册。

后续需要确定启动项的添加 & 维护规范,启动项分类原则,优先级和启动阶段,目的是管控性能问题增量,保证优化成果。

本方案核心**有两点:

  • 使用 __attribute__ 实现编译期自动注册;
  • 组件自注册,去中心化。

后者依赖前者的基础基础才能得以实现,可以简单理解为一种快速写入和读取配置表的类似技术。

参考文章

iOS App冷启动治理:来自美团外卖的实践
__attribute__详解及应用
一种延迟 premain code 的方法
聊聊AppDelegate解耦

后记

欢迎提一起探讨技术问题,觉得有用的话给我点个 star,谢谢。
微博:黑化肥发灰11
简书地址:https://www.jianshu.com/u/fb5591dbd1bf
掘金地址:https://juejin.im/user/595b50896fb9a06ba82d14d4