草庐IT

iOS App启动流程优化

林君毅小号_001 2023-09-17 原文

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

有关iOS App启动流程优化的更多相关文章

  1. ruby-on-rails - 启动 Rails 服务器时 ImageMagick 的警告 - 2

    最近,当我启动我的Rails服务器时,我收到了一长串警告。虽然它不影响我的应用程序,但我想知道如何解决这些警告。我的估计是imagemagick以某种方式被调用了两次?当我在警告前后检查我的git日志时。我想知道如何解决这个问题。-bcrypt-ruby(3.1.2)-better_errors(1.0.1)+bcrypt(3.1.7)+bcrypt-ruby(3.1.5)-bcrypt(>=3.1.3)+better_errors(1.1.0)bcrypt和imagemagick有关系吗?/Users/rbchris/.rbenv/versions/2.0.0-p247/lib/ru

  2. UE4 源码阅读:从引擎启动到Receive Begin Play - 2

    一、引擎主循环UE版本:4.27一、引擎主循环的位置:Launch.cpp:GuardedMain函数二、、GuardedMain函数执行逻辑:1、EnginePreInit:加载大多数模块int32ErrorLevel=EnginePreInit(CmdLine);PreInit模块加载顺序:模块加载过程:(1)注册模块中定义的UObject,同时为每个类构造一个类默认对象(CDO,记录类的默认状态,作为模板用于子类实例创建)(2)调用模块的StartUpModule方法2、FEngineLoop::Init()1、检查Engine的配置文件找出使用了哪一个GameEngine类(UGame

  3. ruby - 使用 Capistrano 启动 sidekiq - 2

    我想用Capistrano启动sidekiq。下面是代码namespace:sidekiqdotask:startdorun"cd#{current_path}&&bundleexecsidekiq-c10-eproduction-Llog/sidekiq.log&"pcapture("psaux|grepsidekiq|awk'{print$2}'|sed-n1p").strip!endend它执行成功但sidekiq仍然没有在服务器上启动。输出:$capsidekiq:starttriggeringloadcallbacks*2014-06-0315:03:01executing`

  4. ruby-on-rails - fastercsv 的 Rails 3 服务器启动问题 - 2

    我有一个正在升级到Rails3的Rails2.3.5应用程序。我做了所有我需要做的升级以及当我使用启动Rails服务器时要做的事情railsserver它给了我这个PleaseswitchtoRuby1.9'sstandardCSVlibrary.It'sFasterCSVplussupportforRuby1.9'sm17nencodingengine.我正在使用ruby-1.9.2-p0并安装了fastercsv(1.5.3)gem。在puts语句的帮助下,我能够追踪到错误发生的位置。我发现执行在这一行停止了Bundler.require(:default,Rails.env)if

  5. 电脑启动后显示器黑屏怎么办?排查下面4个问题,快速解决 - 2

    电脑启动出现显示器黑屏是一个相当常见的问题。如果您遇到了这个问题,不要惊慌,因为它有很多可能的原因,可以采取一些简单的措施来解决它。在本文中,小编将介绍下面4种常见的电脑启动后显示器黑屏的原因,排查这些原因,快速解决! 演示机型:联想Ideapad700-15ISK-ISE系统版本:Windows10一、显示器问题如果出现电脑启动后显示器黑屏的情况。那么首先您需要检查一下显示器是否正常工作。您可以通过更换另一个显示器或将当前显示器连接到另一台计算机来检查显示器是否存在问题。如果问题仍然存在,那么您可以排除显示器故障的可能性。 二、显卡问题如果您的电脑配备了独立显卡,那么显卡故障也可能是导致电脑

  6. 如何判断oracle是否启动及启动oracle数据库 - 2

    plsql连接Oracle超时,完犊子了肯定是服务器断电了。得马上检查Oracle服务器状态1、检查数据库是否启动su-oracle切换到Oracle用户,输入sqlplus/assysdba显示连接状态。如果末尾显示的状态是Connectedtoanidleinstance.证明未启动2、启动数据库startup启动数据库,末尾出现Databaseopened说明数据库启动成功3、查看数据库监听是否正常先quit;断开Oracle连接,使用lsnrctlstatus查看监听状态,如果出现TNS-开头的Nolistener、Connectionrefused等错误,说明监听未启动4、启动数据库

  7. Ruby 缺少常量表达式优化? - 2

    我希望Ruby的解析器会进行这种微不足道的优化,但似乎并没有(谈到YARV实现,Ruby1.9.x、2.0.0):require'benchmark'deffib1a,b=0,1whileb由于这两种方法除了在第二种方法中使用预定义常量而不是常量表达式外是相同的,因此Ruby解释器似乎在每个循环中一次又一次地计算幂常数。是否有一些Material说明为什么Ruby根本不进行这种基本优化或只在某些特定情况下进行? 最佳答案 很抱歉给出了另一个答案,但我不想删除或编辑我之前的答案,因为它下面有有趣的讨论。正如JörgWMittag所说,

  8. ruby-on-rails - 为什么 Rails 在启动时预加载它的所有依赖项(gems)? - 2

    当Rails启动时,它会预加载所有依赖项(gems),这会导致启动时间非常缓慢。在我正在处理的一个中型项目中,Rails的启动时间为10-15秒,具体取决于机器。虽然这在生产中不是问题,但在开发中却是一个巨大的痛苦。特别是在工作TDD/BDD时。有加速测试的解决方案(如spork),但它们会引入自己的问题。我的问题是:为什么不在每个代码文件中要求所需的依赖项,而不是在启动时预加载所有内容?手动要求的缺点是什么?额外的代码行? 最佳答案 Rails不是PHP。一些资源是自动加载的,但是您可能需要的所有资源都在启动/初始化时加载,因为最

  9. ruby - 无法启动 sinatra 进程 - eventmachine "no acceptor" - 2

    我有一个作为守护程序运行的Sinatra应用程序,使用Apache端口转发在端口80和端口7655之间进行调解。这在过去一直运行良好。今天,不太好。我不明白为什么。问题:sudoruby​​my_process.rb返回:/var/lib/gems/1.9.1/gems/eventmachine-1.0.0/lib/eventmachine.rb:526:in`start_tcp_server':noacceptor(portisinuseorrequiresrootprivileges)(RuntimeError)尝试过:更新所有系统包,更新所有gem。没有帮助(除了来自eventm

  10. ruby - 无法让测试单元启动在 ruby​​ 1.9.2 中工作 - 2

    我正在使用Ruby1.9.2(ruby-vyields:ruby1.9.2p290(2011-07-09revision32553)[x86_64-linux]),我正在尝试让它工作:require'test/unit'classTestStartup当我运行它时,我得到了Loadedsuitetest_startupStartedintest1.Finishedin0.000395seconds.1tests,0assertions,0failures,0errors,0skips我很难找到有关此功能的文档,除了SO等上的零散帖子。是的,我想使用这个功能而不是设置。TIA

随机推荐