iOS App启动流程优化

iOS App的启动流程可以分成两个阶段 pre-main阶段和main阶段。

pre-main阶段

系统将App的可执行文件(Mach-O文件)和dyld加载到内存,由dyld进行动态链接。

  • 设置相关环境变量

    根据环境变量设置相应的值以及获取当前运行架构。例如配置环境变量打印启动流程耗时: DYLD_PRINT_STATISTICS和DYLD_PRINT_STATISTICS_DETAILS。

  • 加载共享缓存库

    加载动态共享缓存库到动态库共享缓存区,例如UIKit、CoreFoundation等官方库。

  • 加载动态库

    把所有的可执行文件所依赖的动态库递归加载到内存中。

  • rebase和binding

    iOS采用ASLR技术(地址空间布局随机化),加载App的内存地址是随机的,rebase会根据随机的偏移量对原来的地址做重定向。
    binding进行符号绑定。指向image外部动态库的指针被符号(symbol)绑定。dyld需要去符号表里查找,找到对应的实现。

  • Objc setup
    1. 注册ObjC类
    2. 把category的定义插入方法列表
    3. selector唯一性检查
  • initializer
    1. 调用所有类、分类的+load方法
    2. 调用__attribute__((constructor))修饰的函数
    3. 非基本类型的C++静态全局变量的创建(通常是类或结构体)

map_images与load_images
map_images : dyld 将 image 加载进内存时 , 会触发该函数.
load_images : dyld 初始化 image 会触发该方法. ( 我们所熟知的 load 方法也是在此处调用 ) .
dyld在初始化其他动态库之前,会最先初始化系统库libsystem,运行Runtime。系统库libsystem初始化完成后,就会初始化其他动态库,然后由Runtime调用map_images来读取类、方法、协议以及分类并存储到对应的表中(注意:分类并不是直接存,而是通过attachLists方法把分类的数据添加到类里面),然后Runtime会继续调用load_images调用所有类的load方法以及分类的load方法,这些都做完之后,通过dyld提供的回调_dyld_objc_notify_register,告诉dyld加载完毕,然后dyld就开始找主程序的入口main函数,最后进入程序的main函数。

load方法的调用顺序
+load方法是在load_images中调用的。
load方法调用顺序为:先处理类,后处理分类;处理类的顺序是先父类,后子类
在调用类的load方法时,做了递归处理,会先调用父类的load,然后再调用子类的load,所有类的load方法调用完成后,才会开始处理所有类的分类,分类的处理顺序取决于Mach-O头文件,和类的顺序没有直接关系。先后顺序即:父类->子类->所有类的分类。

pre-main时间统计

iOS10至iOS14,可通过Edit Scheme->Arguments->Environment Variables添加环境变量 DYLD_PRINT_STATISTICS和DYLD_PRINT_STATISTICS_DETAILS,value都为YES。
iOS15以上可通过instrument->app launch进行分析。


ab8ac863d.png
  • 统计线上用户App启动时间

添加环境变量或者通过app launch,可以在开发阶段进行分析,那么如何在App发布后,统计线上用户App的启动时间?
实际上,在App冷启动时系统会为App开启一个进程,而这个进程的信息可以通过代码获得,因此可以通过以下代码获取pre-main耗时。同理,只需在application:didFinishLaunchingWithOptions:执行完毕后调用statisticsLaunchTime方法即可获得整个app的启动时间。之后通过日志服务上传,即可统计线上数据。

BOOL getProcessInfo(int pid , struct kinfo_proc*procInfo)
{
    int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
    size_t size = sizeof(*procInfo);
    return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}

NSTimeInterval statisticsLaunchTime(void)
{
    struct kinfo_proc kProcInfo;
    if (getProcessInfo([[NSProcessInfo processInfo] processIdentifier],&kProcInfo)) {
        NSTimeInterval startTime = kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0; //转为毫秒
        NSTimeInterval curTime = [[NSDate date] timeIntervalSince1970] * 1000;
        return (curTime - startTime) / 1000.0;
    }
    return -1;
}

int main(int argc, char * argv[]) {
    NSLog(@"Pre Main Launch Time : %.4f", statisticsLaunchTime());
    
    NSString * appDelegateClassName;
    @autoreleasepool {
        // Setup code that might create autoreleased objects goes here.
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

main阶段

在pre-main阶段完成之后,dyld 会调用 main() 函数,main() 会调用 UIApplicationMain(),直至application:didFinishLaunchingWithOptions:执行完毕,整个启动流程就完成了。当然从用户体验的角度来说,首屏渲染完成后才算是启动完成。

Mach-O文件格式

mach-o format.png

Load Commands.png

可以用MachOView查看Mach-O文件,其中__TEXT segment 包含可执行代码块和只读数据,__DATA segment是可读可写的。

启动优化思路

  • pre-main流程优化
    1. 第三方动态库不宜过多,加载越多的第三方动态库,启动越慢。且由于iOS的沙盒机制,第三方动态库需要采用嵌入的方式置入app内,并不能减少app的体积。
    2. 代码瘦身,删除无用的代码和资源,减少ObjC类以提高ObjC setup的速度。
    3. 减少+load方法。尽量用+initialize或者其他替代实现。
    4. 减少__attribute__((constructor))函数和非基本类型的C++静态全局变量的创建。
  • main流程优化

main阶段从main函数开始直到application:didFinishLaunchingWithOptions:执行完才结束。在这个阶段主要做的工作有:初始化配置、启动项注册、rootViewController创建等。优化思路如下:

  1. 减少耗时操作,如果必须在启动时执行,那么在情况允许的情况下应将其放在并发队列中异步执行,避免阻塞主线程。
  2. 减少IO操作,如大图的读取等,从磁盘读取数据会耗费大量时间。
  3. 对启动项进行分类,部分启动项注册可以延后执行。
  4. 缓存首页数据

等。

  • 利用App Launch定位耗时代码

Instrument—App Launch,选择需要分析的app,点击左上角按钮就能进行分析。Call Tree建议将 Separate by ThreadHide System Libraries勾选上,分析之后的调用栈会忽略掉系统调用和按线程划分,便于我们分析自己的代码。

AppLaunch_01.png

AppLaunch_02.png

其中p_checkServiceFinderDependences是DEBUG环境下检测模块依赖和路由合法性,需要遍历类表,耗费大量时间。这个方法不会影响主流程,没必要在主线程里运行,故应将其放入并发队列中异步执行。

    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        [self p_checkServiceFinderDependences];
    });

getDeviceUserAgent则是获取User-Agent字符串的过程,这里本身AppConfig就需要初始化一个单例,而getDeviceUserAgent方法内部还有dispatch_once代码,需要花费一定的时间。而且内部需要临时构造一个WKWebView,这就限制了其必须在主线程中执行。但该方法不会影响到后续步骤,故放在主队列中异步执行就可。

    dispatch_async(dispatch_get_main_queue(), ^{
        [[AppConfig sharedInstance] getDeviceUserAgent];
    });

优化之后Main Thread的时间降到1.88s。


fc7a108e58.png

启动项注册

随着业务的发展,启动项难免越来越多。如果把启动项的注册都写在一个方法内的话,那将造成代码臃肿。另外不同的启动项的注册时机并不相同,部分启动项需要尽早注册(例如crash统计,日志上报,热修复等),部分启动项则可以延后注册(在首屏渲染完成后注册或者使用时才注册)。再有,当把某个启动项对应的功能模块化做成独立的framework之后,每个App使用它都必须写一遍注册方法。
目前我们App的处理方案是:利用plist记录启动项,并使用FBModuleManager对启动项进行管理。FBModuleManager会根据启动项的配置将其分成立即启动和LazyLoad两种。这里就不赘述。

  • 启动项注册

下面将介绍另一种启动项管理的思路。

__attribute__((used, section("__DATA,__launch")))

实现在编译期间往Mach-O文件写入字段,used防止在release环境下函数被链接器优化掉,section指定写入的位置,此处我们将数据写入__DATA segment下的__launch section。
为了编码方便,我们定义如下宏:

#define LAUNCH_MODULE_EXPORT(module, stage, priority) \
static id _LAUNCH_START_##module(void); \
__attribute__((used, section("__DATA,__launch"))) \
static const struct LAUNCH_MODULE _LAUNCH_MODULE_##module = (struct LAUNCH_MODULE){(char *)&#module, stage, priority, (void *)(&_LAUNCH_START_##module)}; \
static id _LAUNCH_START_##module(void) \

struct LAUNCH_MODULE {
    char *module;             //模块名
    int stage;                //注册时机
    int priority;             //优先级
    id (*startFunc)(void);    //启动方法,返回初始化后的模块实例,Nullable
};

之后我们便可以在模块内部简单地通过如下代码实现自注册,在这里我们注册了一个在preMain阶段的启动项。

LAUNCH_MODULE_EXPORT(TestPreMainModule, FBLaunchStagePreMain, FBLaunchPriorityLow) {
    return [TestPreMainModule start];
}

对于启动阶段和执行优先级的枚举如下,同一个启动阶段下,越高的优先级越先执行代码。

typedef NS_ENUM(NSInteger, FBLaunchStage) {
    FBLaunchStagePreMain = 0,
    FBLaunchStageWillFinishLaunch = 1,
    FBLaunchStageDidFinishLaunch = 2,
    FBLaunchStageWillShowFirstScreen = 3,
    FBLaunchStageDidShowFirstScreen = 4,
    FBLaunchStageLazyLoad = 5,
};

typedef NS_ENUM(NSInteger, FBLaunchPriority) {
    FBLaunchPriorityLow = 0,
    FBLaunchPriorityMid = 1,
    FBLaunchPriorityHigh = 2,
};

写入的效果如下:


截屏2023-01-10 11.44.13.png
  • 启动项读取

在App启动时,我们需要读取所有的Mach-O文件注册的启动项,关键代码如下:

@interface FBLaunchModule : NSObject

@property (nonatomic, strong) NSString *module;
@property (nonatomic, assign) FBLaunchStage stage;
@property (nonatomic, assign) FBLaunchPriority priority;
@property (nonatomic, assign) id(*startMethod)(void);
@property (nonatomic, assign) BOOL alreadStart;
@property (nonatomic, strong) id moduleInstance;

@end
- (void)getAllModules {
    NSString *appName = [[[NSBundle mainBundle] infoDictionary] objectForKey:(NSString *)kCFBundleExecutableKey];
    NSString *fullAppName = [NSString stringWithFormat:@"/%@.app/", appName];
    char *fullAppNameC = (char *)[fullAppName UTF8String];
    
    NSMutableArray<FBLaunchModule *> *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);
        
        Dl_info info;
        dladdr(header, &info);
        
        const FBMachOExportValue dliFbase = (FBMachOExportValue)info.dli_fbase;
        const FBMachOExportSection *section = FBGetSectByNameFromHeader(header, "__DATA", "__launch");
        if (section == NULL) continue;
        int addrOffset = sizeof(struct LAUNCH_MODULE);
        for (FBMachOExportValue addr = section->offset;
             addr < section->offset + section->size;
             addr += addrOffset) {
            
            struct LAUNCH_MODULE entry = *(struct LAUNCH_MODULE *)(dliFbase + addr);
            FBLaunchModule *module = [[FBLaunchModule alloc] init];
            module.module = [NSString stringWithCString:entry.module encoding:NSUTF8StringEncoding];
            module.stage = entry.stage;
            module.priority = entry.priority;
            module.checkFunc = entry.checkFunc;
            module.startFunc = entry.startFunc;
            [result addObject:module];
        }
    }
    
    _modules = [NSArray arrayWithArray:result];
}
  • 启动项执行

我们实现了一个管理类FBLaunchManager,用于统一读取、保存、执行启动项。

@interface FBLaunchManager : NSObject

+ (id)sharedInstance;
- (void)executeLaunchersForStage:(FBLaunchStage)stage;
- (id)getModuleByName:(NSString *)moduleName;

@end

执行不同阶段启动项的代码如下:

- (void)executeLaunchersForStage:(FBLaunchStage)stage {
    if (_modules.count == 0) {
        return;
    }
    NSMutableArray *moduleAry = [NSMutableArray new];
    
    //阶段
    for (FBLaunchModule *m in _modules) {
        if (m.stage == stage) {
            [moduleAry addObject:m];
        }
    }
    
    //优先级
    [moduleAry sortUsingComparator:^NSComparisonResult(FBLaunchModule * _Nonnull obj1, FBLaunchModule * _Nonnull obj2) {
        return obj1.priority < obj2.priority;
    }];
    
    for (NSInteger i = 0; i < [moduleAry count]; i++) {
        FBLaunchModule *module = moduleAry[i];
        module.moduleInstance = module.startFunc();
        module.alreadStart = YES;
    }
}

如果一个启动项被声明为FBLaunchStageLazyLoad,那么只有在使用它的时候才初始化,在getModuleByName:中实现了懒加载的逻辑。

- (id)getModuleByName:(NSString *)moduleName {
    for (FBLaunchModule *m in _modules) {
        if ([m.module isEqualToString:moduleName]) {
            if (m.alreadStart) {
                return m.moduleInstance;
            }
            m.moduleInstance = m.startFunc();
            m.alreadStart = YES;
            return m.moduleInstance;
        }
    }
    return nil;
}

PreMain阶段启动:

__attribute__((constructor)) static void executePreMainLaunchers() {
    [[FBLaunchManager sharedInstance] executeLaunchersForStage:FBLaunchStagePreMain];
}

此处之所以使用__attribute__((constructor)) 函数,是因为其会在所有类和分类的+load方法执行完毕后才调用,可以避免因代码执行时序而引起的问题。

类似地,其他阶段启动的代码也是在相应时机调用executeLaunchersForStage:方法。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Override point for customization after application launch.   
    [[FBLaunchManager sharedInstance] executeLaunchersForStage:FBLaunchStageDidFinishLaunch];    
    return YES;
}
  • 总结

通过这种思路,我们就可以实现组件自注册与分阶段启动,一定程度上做到模块解耦。需要注意的是,这种注入方式主工程几乎是无知觉的,所以需要自注册的组件必须明确自己的启动阶段与启动的必要性。对于非必要的启动项,无需注册或者注册时声明为LazyLoad。
为了安全性考虑,可以再getAllModules方法内做一些校验工作,例如模块名合法性检测、同名模块去重等。模块start方法本身也需要做一些检测,比如模块依赖检测、路由检测等。

Demo代码:https://github.com/linjunyi/LaunchManagerDemo

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

推荐阅读更多精彩内容