冷启动优化(比较全)-03

目的:

减少App冷启动时间, 经业界实际反馈, 可降低约15%的启动耗时

解释:

将启动代码重新排列到可执行文件的前面几页, 这种技术就叫做二进制重排

原理:

虚拟内存及物理内存
早期计算机没有虚拟内存, 都是直接加载到物理内存的,而且进程是按顺序排列的,这样做特别占用内存空间而且很不安全,别的进程只需要在自己的地址上加一些就能进入别的进程,例如早期的外挂.
所以虚拟内存出现了, 软件打开后, 会有一个虚拟内存,再映射到物理内存(页表)
看如下图示例:

11.jpg

说明:
假如开启了两个进程1和进程2, 运行的时候会开辟一块内存空间.但是并不是直接加载到物理内存的, 而是通过页表映射到不同的物理空间, 这个过程也叫地址翻译. 因为其他进程无法访问此进程的映射表, 因此别的进程也访问不到真实的物理内存,这样做很安全.

    另外, 页表是以页为单位做分页管理的,macos是4k,iOS是16k.应用启动的时候, 数据其实不是一次都加载到内存的,而是懒加载的,访问到哪块数据,就先看页表, 如果页表中数据为0, 说明此数据不在物理内存,此时, 系统会阻塞进程,然后将数据加载到物理内存(如果物理内存没有空间, 会覆盖其他进程数据),最后将虚拟内存指向物理内存, 这个过程叫**缺页中断(pagefault)**;如果页表中数据为1,说明之前加载过这块数据, 物理内存中有;

    注意: 这样做解决了物理内存不够用的情况,但是同时也有其他问题.首先虚拟内存在编译链接的时候就确定了,黑客同样可能拿到虚拟内存去操作;其次就是缺页中断带来的问题就是这个操作很耗时,不仅要将数据加载到内存, 同时还要对这也做签名(单页耗时差不多0.1-0.8ms,如果启动加载了很多页,这个时间还是比较长)

    第一个问题苹果通过**ASLR(地址空间随机化解决)**,暂时先跳过;第二个问题就是我们要说的二进制重排:代码在macho可执行文件中的位置不是根据调用顺序来的,而是通过文件编译顺序来的(xcode中build phases - compile sources中文件的顺序),如果启动的时候加载了其他没用代码, 会造成页表的浪费,同时也会造成太多没必要的缺页中断, 所以二进制重排就是在启动的时候将需要的代码集中放在一页或两页, 这样就会大大降低缺页中断带来的耗时,这就叫**二进制重排**.如下图:
12.jpg

说明: 优化掉1个page,大概能节省0.6-0.8ms

实践:

统计

    首先先学会如何统计premian耗时,添加环境变量 DYLD_PRINT_STATISTICS (iOS15之前可用), 如图:
image.png

启动app后, 控制台会打印启动耗时, 如下所示:


image.png

说明, premain过程分4步:

  1. 动态库加载链接 (减少动态库/进行合并)
  2. 位移纠偏/符号绑定 (ASLR篇章讲)
  3. OC类初始化 (去掉没用的类)
  4. load方法、c++函数初始化 (延后到main之后、少用c++)

iOS15之后, 系统把这个禁止了, 但是可以通过instrument检测到:

iOS 15 and macOS Monterey have a new version of dyld. You can see the man page for dyld in macOS Monterey to see the current set of environment variables that you can use.
Several years ago, the Time Profiler instrument gained the ability to profile the time spent during app launch before main(). You should profile your app launches this way instead.翻译后意思就是:iOS15和macOS Monterey有一个新版本的dyld,可以让你用profile里面的instrument工具去监测

如图:


image.png

调试

    其次先知道怎么调试, 如何查看pagefault的次数: 打开instrument- system trace(查看线程数据), 开始后搜索Main thread, 并选中我们的App, 然后点击Main thread , 选择virtual memory: 会看到有一个File Backed Page in就是page fault的次数, 如图所示:
image.png

注意: 此过程需要将App杀死后再启动, 因为不是冷启动的话有一部分数据系统会缓存;
技巧: 可以多次尝试尽可能的多打开其他应用后再打开我们的App, 会发现由于物理内存不够用pagefault的次数明显提高.

优化

知道了怎么查看, 如何调试后, 最关键的就是怎么优化

配置

查看可执行文件中函数及符号的排列顺序:
xcode中build setting中找到 link map file, 设置成yes, 如图:

image.png

编译后在app中可找到linkmap文件,如图:
image.png

打开后可看到函数及符号排列顺序, 如图:
image.png

image.png

仔细观察后可以发现, 这个符号顺序是按照Compile Sources的文件顺序来排列的 .启动加载的前几页数据中有很多是无用的

重写.order文件

将启动的所有函数符号写入.order文件
clang插桩(静态插桩): 最完美的解决办法

原理:
编译期在每一个函数内部添加hook代码(__sanitizer_cov_trace_pc_guard),实现全局方法hook

思路:
利用clang本身提供的机制/工具来获取所有符号的需求, clang可以hook任意(包括+load, block,循环) 官网地址:https://clang.llvm.org/docs/SanitizerCoverage.html#tracing-pcs

  1. 先配置Other C Flags: -fsanitize-coverage=trace-pc-guard (只对function 做hook)
    原因: 一个while循环也会被hook, 会被静态加入__sanitizer_cov_trace_pc_guard多次调用, 导致死循环
  2. 添加hook代码:
#import <dlfcn.h>
// 定义原子队列
// 由于项目的方法有可能在不同的线程执行, 因此 __sanitizer_cov_trace_pc_guard 有可能受多线程
// 影响, 而且会特别多次调用, 使用锁的话会有性能影响, 所以可以用底层原子队列(栈结构)
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;

// 定义符号结构体
typedef struct{
    void *pc;
    void *next;
} SYNode;

void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
  
  static uint64_t N;  // Counter for the guards.
  if (start == stop || *start) return;  // Initialize only once.
  // printf("INIT: %p %p\n", start, stop);
  for (uint32_t *x = start; x < stop; x++)
    *x = ++N;
}

// 真正的静态hook方法, 所有方法执行前都会先执行这个
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
   
   // 注意: +load 想要被hook, 需要注释掉此行代码
   // if (!*guard) return;
  
   // 当前函数返回到上一个调用的地址!!
   void *PC = __builtin_return_address(0);
   
   // 创建结构体!
   SYNode * node = malloc(sizeof(SYNode));
   *node = (SYNode){PC,NULL};
    
   // 加入结构!
   OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}

+ (void)mkOrderFile {
  
    // 定义数组
    NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
    
    while (YES) {//一次循环!也会被HOOK一次!!
        SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
        
        if (node == NULL) {
            break;
        }
      
        Dl_info info = {0};
       // 这个函数通过地址找到函数符号
        dladdr(node->pc, &info);
        // printf("%s \n",info.dli_sname);
        // sname就是我们要的函数符号 
        NSString * name = @(info.dli_sname);
        free(node);
        
        // .order文件格式要求c函数 ,block调用前面还需要加 _ , 下划线 
        BOOL isObjc = [name hasPrefix:@"+["]||[name hasPrefix:@"-["];
        NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
        [symbolNames addObject:symbolName];
        /*
        if ([name hasPrefix:@"+["]||[name hasPrefix:@"-["]) {
            // 如果是OC方法名称直接存!
            [symbolNames addObject:name];
            continue;
        }
        //如果不是OC直接加个_存!
        [symbolNames addObject:[@"_" stringByAppendingString:name]];
         */
    }
    // 反向数组 (多线程处理, 先进后出, 需要倒序一下)
    // symbolNames = (NSMutableArray<NSString *>*)[[symbolNames reverseObjectEnumerator] allObjects];
    NSEnumerator * enumerator = [symbolNames reverseObjectEnumerator];
    
    // 创建一个新数组
    NSMutableArray * funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
    NSString * name;
    // 去重!
    while (name = [enumerator nextObject]) {
        if (![funcs containsObject:name]) {//数组中不包含name
            [funcs addObject:name];
        }
    }
    [funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
    // 数组转成字符串
    NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
    // 结果写入文件
    // 文件路径
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"hank.order"];
    // 文件内容
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
}

这行代码的意思的拿到执行hook函数的上一个调用的地址, 由于每个函数执行前都会先调用此hook函数, 所以这行代码拿到的其实就是我们想要的每一个函数的地址.
在汇编中, 函数嵌套调用,在跳转子函数的时候, 会首先在下x30寄存器中保存下一条指令的地址, 跳转子函数return的时候,会读取这个地址, 执行下一条指令,而下一条指令地址其实就是一开始调用子函数的原函数, 所以执行完子函数后, 会顺利跳回原函数.

三方库

以上仅仅针对主工程代码有效, 如果需要获取三方pod库的符号, 需要在pod下的每一个target里配置build setting, 如果核心代码做成组件库, 还得配置每一个target的依赖及search path. 不过如果我们的项目有太多三方pod, 这种方法显然太费劲. 解决方案如下:

  1. 使用下面即将提到的组件库: KCCLHook
  2. podfile中添加如下代码:
post_install do |installer|
    require './Pods/xxx/xxx/xxx.rb'
    symbol_tracker(installer)
end

xxx.rb代码如下:

def symbol_tracker(installer)
    pods_project = installer.pods_project
    name = 'xxx'
    symbol_tracker_target = pods_project.targets.find { |t| t.name == name }
    if !symbol_tracker_target.nil?
        pods_target_config(pods_project, name, symbol_tracker_target)
    end
end

def pods_target_config(pods_project, name, symbol_tracker_target)
    symbol_tracker_target
    
    build_settings = Hash[
    'OTHER_CFLAGS' => '-fsanitize-coverage=func,trace-pc-guard',
    #'OTHER_SWIFT_FLAGS' => '-sanitize=undefined -sanitize-coverage=func',
    'ENABLE_BITCODE' => 'NO']
    
    isDynamic = symbol_tracker_target.product_type.include?('framework')
    
    if isDynamic
        build_settings['FRAMEWORK_SEARCH_PATHS'] = '${PODS_CONFIGURATION_BUILD_DIR}/' + name
    end
    
    symbol_tracker = symbol_tracker_target.product_reference
    pods_project.targets.each do |target|
        if target.name != name and !target.name.include?('Pods-')
            if isDynamic and !target.instance_of? Xcodeproj::Project::Object::PBXAggregateTarget
                # 添加依赖
                dependency = target.dependency_for_target(symbol_tracker_target)
                if dependency
                    puts "[WARN] Already depended on #{target.name}"
                    else
                    target.add_dependency(symbol_tracker_target)
                end
                # 添加framework
                build_phase = target.frameworks_build_phase
                if build_phase
                    build_phase.add_file_reference(symbol_tracker)
                end
            end
            
            # 修改build_settings
            target.build_configurations.each do |config|
                build_settings.each do |pair|
                    key = pair[0]
                    value = pair[1]
                    if config.build_settings[key].nil?
                        config.build_settings[key] = ['$(inherited)']
                    end
                    if !config.build_settings[key].include?(value)
                        config.build_settings[key] << value
                    end
                end
            end
            
            puts '[Interpose]: ' + target.name + ' success.'
        end
    end
end

  1. 重新跑一遍工程, 可以看到.order中已有三方依赖的方法符号, 截图示例如下:


    image.png

配置.order文件

xcode-buildseting-搜order, 如下图:


image.png

将生成的.order文件放入项目根目录, 同时配置好build setting

组件化

使用过程中可以将这些代码封装成私有组件, 此处就省略了

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

推荐阅读更多精彩内容