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
- 注册ObjC类
- 把category的定义插入方法列表
- selector唯一性检查
-
initializer
- 调用所有类、分类的+load方法
- 调用
__attribute__((constructor))
修饰的函数 - 非基本类型的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进行分析。
-
统计线上用户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文件格式
可以用MachOView查看Mach-O文件,其中__TEXT segment 包含可执行代码块和只读数据,__DATA segment是可读可写的。
启动优化思路
-
pre-main流程优化
- 第三方动态库不宜过多,加载越多的第三方动态库,启动越慢。且由于iOS的沙盒机制,第三方动态库需要采用嵌入的方式置入app内,并不能减少app的体积。
- 代码瘦身,删除无用的代码和资源,减少ObjC类以提高ObjC setup的速度。
- 减少+load方法。尽量用+initialize或者其他替代实现。
- 减少
__attribute__((constructor))
函数和非基本类型的C++静态全局变量的创建。
-
main流程优化
main阶段从main函数开始直到application:didFinishLaunchingWithOptions:执行完才结束。在这个阶段主要做的工作有:初始化配置、启动项注册、rootViewController创建等。优化思路如下:
- 减少耗时操作,如果必须在启动时执行,那么在情况允许的情况下应将其放在并发队列中异步执行,避免阻塞主线程。
- 减少IO操作,如大图的读取等,从磁盘读取数据会耗费大量时间。
- 对启动项进行分类,部分启动项注册可以延后执行。
- 缓存首页数据
等。
-
利用App Launch定位耗时代码
Instrument—App Launch,选择需要分析的app,点击左上角按钮就能进行分析。Call Tree建议将 Separate by Thread 和 Hide System Libraries勾选上,分析之后的调用栈会忽略掉系统调用和按线程划分,便于我们分析自己的代码。
其中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。
启动项注册
随着业务的发展,启动项难免越来越多。如果把启动项的注册都写在一个方法内的话,那将造成代码臃肿。另外不同的启动项的注册时机并不相同,部分启动项需要尽早注册(例如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,
};
写入的效果如下:
-
启动项读取
在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方法本身也需要做一些检测,比如模块依赖检测、路由检测等。