一文搞定App启动流程、时间监测、优化措施

启动流程

一、背景:介绍怎么fork App进程的

  • launchd是什么

    launchd 是 iOS 和 macOS 系统中最早启动的第一个用户态进程,由内核在系统启动完成内核态初始化后直接启动 launchd 进程 PID 永远是 1 **

  • SpringBoard是什么

    SpringBoard 是 iOS 的主界面管理器(Home Screen Manager,同时也是系统级事件的中心调度器。你在 iPhone 上看到的 桌面图标(App Icon)布局,文件夹管理,App启动和退出,Dock栏,小组件(Widget),其都是 SpringBoard 负责的

当用户点击图标后唤起 App,SpringBoard和launchd进程配合工作完成启动

  • SpringBoard负责查找 app的相关信息 如:Bundle Identifie、Executable路径
  • 通过进程间通信发送个launchd进程
  • launchd进程校验 App 权限和签名,找到可执行 Mach-O 路径
  • launchd进程fork 出一个新子进程
  • 子进程内执行 execve() 系统调用,加载 App 的 Mach-O文件

二、内核加载App的Mach-O文件

1. 加载dyld动态链接器
  • 读取 Mach-O 文件头
  • 解析 LC_LOAD_DYLINKER,发现要加载 /usr/lib/dyld
  • 把 dyld 映射进内存
2. dyld加载动态库接管控制权-加载动态库
  • 读取其中的 LC_LOAD_DYLIB,找出依赖的动态库路径
  • 读取__mod_init_func被写入的方法进行调用 如: attribute((constructor)) c++构造函数 _objc_init方法
  • runtime动态库通过该_objc_ini方法通过向动态链接器注册回调函数用于类注册
ini 复制代码
_dyld_objc_callbacks_v4 callbacks = {
        map_images,
        load_images,
        unmap_image,
        _objc_patch_root_of_class,
    };
    _dyld_objc_register_callbacks((_dyld_objc_callbacks*)&callbacks);

最主要是map_image、load_images两个方法接下啦解释

3. rebase

1.作用?

修正 Mach-O 中原本写死的指针地址,使它指向正确的实际内存地址。

2.为什么要 rebase?

因为 App 或动态库被加载进内存的地址是随机的(ASLR 安全机制),不能用 Mach-O 编译时写死的地址,必须运行时修正。

3.举例说明

假设有一个动态库 libA.dylib

c 复制代码
int globalValue = 100;

int getValue() {
    return globalValue;
}

编译时:
- `globalValue` 假设地址是 `0x1000`
- `getValue()` 方法里 `mov eax, [0x1000]`

编译时:
- `globalValue` 假设地址是 `0x1000`
- `getValue()` 方法里 `mov eax, [0x1000]`

运行时:
- 由于 ASLR,libA 被加载到了 `0x300000000`
- `globalValue` 实际地址变成 `0x300001000`

rebase:
- dyld 扫描 Mach-O 的 `__DATA` 和 `__DATA_CONST` 段  
- 找到所有相对地址值  
- 把 `0x1000` 改成 `0x300001000`
4. bind符号绑定

1.作用?

把 Mach-O 文件中的符号(函数地址 / 全局变量地址)绑定到真正对应的动态库符号地址。

2.为什么要 bind?

因为 Mach-O 里写的只是个占位符,真正符号地址要根据当前进程中其他库的加载情况确定。

3.举例说明

c 复制代码
int globalValue = 100;

int getValue() {
    return globalValue;
}

bind:

如果 `globalValue` 是从其他库导入的符号(比如 Foundation.framework 中的 `_NSConcreteStackBlock`)
- Mach-O 符号表里写的是 `UNDEFINED`,指向一个占位 slot  
- dyld 找到 Foundation.framework 中 `_NSConcreteStackBlock` 实际地址,比如 `0x310000000`  
- 把符号表 slot 写成 `0x310000000`,完成绑定

三、runtime注册

runtime的注册是通过 map_images, load_images两个回调函数实现 可以参考:juejin.cn/post/749722...

四、调用main函数

在 iOS 中,main 函数通常会通过以下代码启动应用程序:

objectivec 复制代码
int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

UIApplicationMain 函数的调用流程如下:

  1. 创建应用程序实例

    UIApplicationMain 会创建一个 UIApplication 的实例,通常是 UIApplication.shared,这是应用程序的核心对象,负责管理应用程序的生命周期和事件循环。

  2. 设置应用程序代理

    UIApplicationMain 会创建一个 AppDelegate 的实例,并将其设置为应用程序的代理。代理对象负责处理应用程序生命周期事件,如启动、终止、进入后台等。

  3. 启动事件循环

    UIApplicationMain 会启动应用程序的主事件循环,开始接收和处理用户输入事件,如触摸、按键等。

四、首帧渲染时间

首页加载完毕

App启动流程时间监测

T1:进程 --> 第一个load方法执行

  • 线程启动的时间
ini 复制代码
+ (CFAbsoluteTime)processStartTime {
    if (__t1 == 0) {
        struct kinfo_proc procInfo;
        int pid = [[NSProcessInfo processInfo] processIdentifier];
        int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
        size_t size = sizeof(procInfo);
        if (sysctl(cmd, sizeof(cmd)/sizeof(*cmd), &procInfo, &size, NULL, 0) == 0) {
            __t1 = procInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + procInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
        }
    }
    return __t1;
}
  • 第一个load方法执行的时间

创建这个命名靠前的静态库,load的方法执行和静态库的链接顺序有关,链接顺序又和静态库得命名有关以A开头的会在最前面

T2:load -> main

T3:didFinishLaunch start -> 首诊出现

  • 13.0 以上系统使用监听kCFRunLoopBeforeTimers回调作为首帧完成时机 13.0以下系统,使用注册block方法获取首帧完成时机
swift 复制代码
- (void)startLaunchDetector {
    //近似作为main方法开始时间
    if (@available(iOS 13.0, *)) {
        //13.0 以上系统使用监听kCFRunLoopBeforeTimers回调作为首帧完成时机
        CFRunLoopRef mainRunLoop = [[NSRunLoop mainRunLoop] getCFRunLoop];
        CFRunLoopActivity allActivitys = kCFRunLoopAllActivities;
        CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, allActivitys, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
            if (activity == kCFRunLoopBeforeTimers) {
                self.firstFrameEndTimeInterval = [[NSDate date] timeIntervalSince1970];
                CFRunLoopRemoveObserver(mainRunLoop, observer, kCFRunLoopCommonModes);
                
                [[NSNotificationCenter defaultCenter] postNotificationName:BMLauchFirshFrameNotification object:nil];
            }
        });
        CFRunLoopAddObserver(mainRunLoop, observer, kCFRunLoopCommonModes);
    } else {
        //13.0以下系统,使用注册block方法获取首帧完成时机
        CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
        CFRunLoopPerformBlock(mainRunloop,NSDefaultRunLoopMode,^(){
            self.firstFrameEndTimeInterval = [[NSDate date] timeIntervalSince1970];
            [[NSNotificationCenter defaultCenter] postNotificationName:BMLauchFirshFrameNotification object:nil];
        });
    }
}

存量问题治理和优化

1、二进制重排

  1. mach_o加载 Mach-O 文件在加载到内存时,系统是按 虚拟内存页(通常为 4KB 一页)加载的: dyld 会将 __TEXT 和 __DATA 等 segment 按需 mmap 到内存。 只有首次访问时才真正触发 page fault → 加载对应页面。一般是 26 页,约 824KB)
  2. 减少page falut 方案: 将启动过程中的方法排列在代码段的前边通过xcode 配置 order file文件, 调整函数的排列顺序
  3. order file文件的获取 线下通过启动app调用的方法得到函数符号手动放到order file里 OC插桩 xcode中Build Settings --> Other C Flags添加标记
ini 复制代码
-fsanitize-coverage=trace-pc-guard

swift插桩

ini 复制代码
 -sanitize-coverage=func
    -sanitize=undefined
  1. 插桩原理 修改了二进制文件,在所有的 方法、函数、block 的实现的第一句插入一句 _sanitizer_cov_trace_pc_guard() 代码

  2. 查看App启动中断次数 通过xcode - Instruments - System Trace来查看App启动时的pageFault次数

2、删除无用类

  • 通过对Mach-O文件的了解,可以知道__TEXT:__objc_methname:中包含了代码中的所有方法,而__DATA__objc_selrefs中则包含了所有被使用的方法的引用,通过取两个集合的差集就可以得到所有未被使用的代码.

存量问题治理

梳理相关业务,将不必要的内容尽量放在首页加载完成之后

三、管控增量问题

梳理初始化业务分为几个阶段

设计分发机制讲启动阶段时机分发给业务规范启动项的添加

PreMain

我们利用最后一个执行的启动构造函数时机分发给业务 方法交换

DidFinish

我们hook了代理方法 先于首页业务 bugly

首页加载完成之后

我们注册了runloop监听回调中

框架设计
一、目标

你希望实现:

c 复制代码
// 在注册函数中标记自己属于哪个阶段
MY_EXPORT static void register_module_A() {
    printf("注册阶段 = PRELOAD\n");
}

然后在执行时,比如:

c 复制代码
run_all_register_functions_for_stage(PRELOAD);

只调用属于 PRELOAD 阶段的函数。


二、解决方案:封装结构体 + 放入 section
步骤 1:定义阶段枚举 + 注册结构体类型
c 复制代码
typedef enum {
    STAGE_PRELOAD,
    STAGE_BOOTSTRAP,
    STAGE_POST_LAUNCH
} InitStage;

typedef struct {
    InitStage stage;
    void (*func)(void);
} RegisterItem;

步骤 2:定义宏放进 section
c 复制代码
#define STAGE_EXPORT(stage, fn) \
    __attribute__((used, section("__DATA,MyRegister"))) \
    static const RegisterItem _reg_item_##fn = {stage, fn}

步骤 3:写你的注册函数
c 复制代码
static void register_module_A() {
    printf("Module A 注册 - preload\n");
}

static void register_module_B() {
    printf("Module B 注册 - bootstrap\n");
}

STAGE_EXPORT(STAGE_PRELOAD, register_module_A);
STAGE_EXPORT(STAGE_BOOTSTRAP, register_module_B);

步骤 4:在启动时按阶段执行
c 复制代码
extern const RegisterItem __start_MyRegister __asm("section$start$__DATA$MyRegister");
extern const RegisterItem __stop_MyRegister __asm("section$end$__DATA$MyRegister");

void run_all_register_functions_for_stage(InitStage stage) {
    const RegisterItem *p = &__start_MyRegister;
    while (p < &__stop_MyRegister) {
        if (p->stage == stage && p->func) {
            p->func();
        }
        p++;
    }
}

注意:section$start$__DATA$... 这种方式在 Clang + Mach-O 格式(iOS/macOS) 下可用,能正确获取段首尾地址。


三、输出示例
c 复制代码
run_all_register_functions_for_stage(STAGE_PRELOAD);
// 输出:Module A 注册 - preload

run_all_register_functions_for_stage(STAGE_BOOTSTRAP);
// 输出:Module B 注册 - bootstrap
相关推荐
恋猫de小郭6 小时前
腾讯 Kuikly 正式开源,了解一下这个基于 Kotlin 的全平台框架
android·前端·ios
一牛10 小时前
Appkit: 菜单是如何工作的
macos·ios·objective-c
JQShan13 小时前
React Native小课堂:箭头函数 vs 普通函数,为什么你的this总迷路?
javascript·react native·ios
画个大饼16 小时前
Swift与iOS内存管理机制深度剖析
开发语言·ios·swift
Ya-Jun1 天前
常用第三方库:flutter_boost混合开发
android·flutter·ios
玫瑰花开一片一片1 天前
Flutter IOS 真机 Widget 错误。Widget 安装后系统中没有
flutter·ios·widget·ios widget
烎就是我2 天前
100行代码swift从零实现一个iOS日历
ios·swift
鸿蒙布道师2 天前
鸿蒙NEXT开发通知工具类(ArkTs)
android·ios·华为·harmonyos·arkts·鸿蒙系统·huawei