iOS App啟動(dòng)優(yōu)化方案

冷啟動(dòng)

  • 熱啟動(dòng):系統(tǒng)里面存在APP的進(jìn)程緩存信息,比如殺掉APP后短時(shí)間內(nèi)重啟APP。

  • 冷啟動(dòng):系統(tǒng)里面沒有APP的進(jìn)程緩存信息,例如重啟手機(jī)打開應(yīng)用、APP長(zhǎng)時(shí)間不用系統(tǒng)替換掉已有的進(jìn)程緩存。

APP的啟動(dòng)流程圖如下:

image.png

pre-main 階段

pre-main 階段指的是從用戶喚起 App 到 main() 函數(shù)執(zhí)行之前的過程。
對(duì)于pre-main階段,Xcode9之后,Apple提供了一種測(cè)量方法,在 Xcode 中 Edit scheme -> Run -> Auguments 將環(huán)境變量DYLD_PRINT_STATISTICS設(shè)為1 。

如下圖所示,包含 main 函數(shù)執(zhí)行之前各項(xiàng)的加載時(shí)間,我們可以多次運(yùn)行取一下平均值,蘋果推薦這個(gè)時(shí)間應(yīng)在 400ms 以內(nèi)

Total pre-main time: 354.21 milliseconds (100.0%)
         dylib loading time:  25.52 milliseconds (7.2%)
        rebase/binding time:  12.70 milliseconds (3.5%)
            ObjC setup time: 152.74 milliseconds (43.1%)
           initializer time: 163.24 milliseconds (46.0%)
           slowest intializers :
             libSystem.B.dylib :   7.98 milliseconds (2.2%)
   libBacktraceRecording.dylib :  13.53 milliseconds (3.8%)
    libMainThreadChecker.dylib :  41.11 milliseconds (11.6%)
                      TestDemo :  88.76 milliseconds (25.0%)

pre-main 階段所干的事大概可以總結(jié)為:

  • dyld(動(dòng)態(tài)庫(kù)加載):動(dòng)態(tài)鏈接器會(huì)把所有的可執(zhí)行文件所依賴的動(dòng)態(tài)庫(kù)dylib遞歸加載到內(nèi)存中,打開、讀取這些 Mach-O 文件,并驗(yàn)證其有效性 。
    系統(tǒng)的動(dòng)態(tài)庫(kù)存在于共享緩存,但是自定義的動(dòng)態(tài)庫(kù)就要通過依賴關(guān)系一個(gè)一個(gè)的加載

  • rebase/bindging(重定位/符號(hào)綁定)
    rebase(指針重定位) 是指調(diào)整image(鏡像)內(nèi)部的指針,采用ASLR技術(shù),保證地址的隨機(jī)化,加強(qiáng)了內(nèi)存訪問的安全性。
    binding(符號(hào)綁定)是指綁定外部函數(shù)的指針。使用外部符號(hào),編譯時(shí)無法找到函數(shù)地址。在運(yùn)行時(shí),dyld加載共享緩存,加載鏈接動(dòng)態(tài)庫(kù)之后,進(jìn)行binding操作,重新綁定外部符號(hào)。

  • ObjC Setup:通知 Runtime 去做一些代碼運(yùn)行時(shí)需要做的事情,比如注冊(cè)所有聲明過的 ObjC 類、將Category分類插入到類的方法列表中、通過IMP到SEL的映射表,檢查每個(gè) selector 的唯一性。

  • Initializers:調(diào)用每個(gè) ObjC 類和分類中的 +load方法、調(diào)用 C/C++ 中的構(gòu)造器函數(shù)(用 attribute((constructor)) 修飾的函數(shù))、初始化C++ 靜態(tài)全局變量。

ASLR(地址空間配置隨機(jī)加載)

ASLR(Address space layout randomization):地址空間配置隨機(jī)加載,是一種防范內(nèi)存損壞漏洞被利用的計(jì)算機(jī)安全技術(shù)。

地址空間配置隨機(jī)加載利用隨機(jī)方式配置數(shù)據(jù)地址空間,使某些敏感數(shù)據(jù)配置到一個(gè)惡意程序無法事先獲知的地址,令攻擊者難以進(jìn)行攻擊。

總結(jié)為如下圖:

image.png

pre-main 階段的優(yōu)化方案:

  • 減少不必要的內(nèi)置動(dòng)態(tài)庫(kù)數(shù)量
  • 減少 ObjC類(class)、方法(selector)、分類(category)的數(shù)量,比如合并一些功能,刪除無效的類、方法和分類等(可以借助 AppCode 的 Inspect Code 功能進(jìn)行代碼瘦身)
  • 減少C++ 虛函數(shù)(虛函數(shù)會(huì)創(chuàng)建 vtable,這也會(huì)在 __DATA 段中創(chuàng)建結(jié)構(gòu)。)
  • 多用 Swift Structs(因?yàn)?Swift Structs 是靜態(tài)分發(fā)的,它的結(jié)構(gòu)內(nèi)部做了優(yōu)化,符號(hào)數(shù)量更少。)
  • 將不必須在+load方法中執(zhí)行的任務(wù)延遲到+initialize 中, +initialize可能會(huì)多次加載,可以配合dispatch_once控制一次加載邏輯

main() 階段

對(duì)于 main() 階段,主要測(cè)量的就是從 main()函數(shù)開始執(zhí)行到 didFinishLaunchingWithOptions方法執(zhí)行結(jié)束的耗時(shí)。

image.png

對(duì)于main() 階段時(shí)間,比較好測(cè)量,我們可以在 main 函數(shù)開始執(zhí)行和 applicationDidBecomeActive: 方法執(zhí)行末尾時(shí)分別記錄一個(gè)時(shí)間點(diǎn),然后計(jì)算兩者時(shí)間差即可,大致如下:

image.png

其中,關(guān)于 StartupTimeMonitor 的定義如下:

#import <Foundation/Foundation.h>

@interface StartupTimeMonitor : NSObject

+ (instancetype)sharedMonitor;

- (void)appWillStartLoading;
- (void)appDidFinishLoading;

@end


#import "StartupTimeMonitor.h"

@interface StartupTimeMonitor () {
 CFAbsoluteTime _startTime;
 CFAbsoluteTime _stopTime;
}

@end

@implementation StartupTimeMonitor

+ (instancetype)sharedMonitor {
 static StartupTimeMonitor *sharedMonitor = nil;
 static dispatch_once_t onceToken;
 dispatch_once(&onceToken, ^{
 sharedMonitor = [[StartupTimeMonitor alloc] init];
 });
 return sharedMonitor;
}

- (void)appWillStartLoading {
 _startTime = CFAbsoluteTimeGetCurrent();
}

- (void)appDidFinishLoading {
 _stopTime = CFAbsoluteTimeGetCurrent();

 NSUInteger milliseconds = (NSUInteger)((_stopTime - _startTime) * 1000);
 NSLog(@"Loading done in %lu ms", milliseconds);
}

@end

main() 階段的優(yōu)化方案:

純代碼的方式,而不是 xib/Storyboard,來加載首頁(yè)視圖
延遲暫時(shí)不需要的三方庫(kù)加載;
延遲執(zhí)行部分業(yè)務(wù)邏輯和 UI 配置,首屏渲染完成前只處理首屏相關(guān)的業(yè)務(wù);
延遲加載 / 懶加載部分視圖;
避免首屏加載時(shí)大量的本地/網(wǎng)絡(luò)數(shù)據(jù)讀取;

二進(jìn)制重排優(yōu)化啟動(dòng)

什么是二進(jìn)制重排

重新排列函數(shù)符號(hào)的位置,降低Mach-O文件載入物理內(nèi)存時(shí)觸發(fā)的PageFault次數(shù),這個(gè)叫二進(jìn)制重排。

物理內(nèi)存&虛擬內(nèi)存

  • 物理內(nèi)存:指的是通過物理內(nèi)存條獲得的內(nèi)存空間。
  • 虛擬內(nèi)存:跟物理內(nèi)存相反,虛擬內(nèi)存指的一種計(jì)算機(jī)系統(tǒng)內(nèi)存管理技術(shù),它使得應(yīng)用程序認(rèn)為它擁有連續(xù)可用的內(nèi)存,實(shí)際上它通常被分隔成多個(gè)物理內(nèi)存碎片。

虛擬內(nèi)存的概述圖如下:

image.png

虛擬內(nèi)存的技術(shù)出現(xiàn)之后,每個(gè)進(jìn)程并不是直接全部扔進(jìn)物理內(nèi)存,而是給每個(gè)應(yīng)用分配一個(gè)虛擬的內(nèi)存,虛擬內(nèi)存通過虛擬頁(yè)表來把相應(yīng)數(shù)據(jù)放進(jìn)物理內(nèi)存里面。

虛擬內(nèi)存的技術(shù)出現(xiàn)之后,也有了內(nèi)存分頁(yè)的概念,虛擬頁(yè)表把一個(gè)進(jìn)程分成若干頁(yè),比如:Page1、Page2、Page3…,當(dāng)啟動(dòng)進(jìn)程1的時(shí)候,只需要把Page1裝載進(jìn)物理內(nèi)存,以此類推,如上圖。

缺頁(yè)中斷(PageFault)

假設(shè)在啟動(dòng)時(shí)期我們需要調(diào)用兩個(gè)函數(shù) method1 與 method4 ,函數(shù)編譯在 mach-o 中的位置是根據(jù) ld ( Xcode 的鏈接器) 的編譯順序并非調(diào)用順序來的 ,因此很可能這兩個(gè)函數(shù)分布在不同的內(nèi)存頁(yè)上 。

image

那么啟動(dòng)時(shí) , page1 與 page2 則都需要從無到有加載到物理內(nèi)存中 , 從而觸發(fā)兩次 page fault 。

而二進(jìn)制重排的做法就是將 method1 與 method4 放到一個(gè)內(nèi)存頁(yè)中 ,那么啟動(dòng)時(shí)則只需要加載 page1 即可 ,也就是只觸發(fā)一次 page fault ,達(dá)到優(yōu)化目的 。

實(shí)際項(xiàng)目中的做法是將啟動(dòng)時(shí)需要調(diào)用的函數(shù)放到一起 ( 比如 前10頁(yè)中 ) 以盡可能減少 page fault ,達(dá)到優(yōu)化目的 , 而這個(gè)做法就叫做 : 二進(jìn)制重排 。

缺頁(yè)中斷時(shí)間消耗的檢測(cè)

前面我們已經(jīng)提到了缺頁(yè)中斷,接下來我們通過Profile來檢測(cè)一下缺頁(yè)中斷的發(fā)生。
Xcode頂部菜單Product->Profile->Instruments->System Trace

image

可以看到我們的項(xiàng)目冷啟動(dòng)時(shí),缺頁(yè)次數(shù)大概是1200多次,耗時(shí)130毫秒,如果項(xiàng)目再大一些,缺頁(yè)發(fā)生的更多那么也是一個(gè)不小的影響啟動(dòng)時(shí)間的一個(gè)因素。

為什么二進(jìn)制重排能優(yōu)化啟動(dòng)時(shí)間

我們先來舉個(gè)例子,一個(gè)應(yīng)用啟動(dòng)需要調(diào)用方法1、方法3、方法4、方法6、方法7,其中方法1在Page1頁(yè)上,方法6在Page2頁(yè)上,方法3、方法7在Page3頁(yè)上,方法4在Page4頁(yè)上,如下圖所示:

image.png

二進(jìn)制排列后,如下圖所示:

image.png

經(jīng)過二進(jìn)制重排之后,我們把啟動(dòng)需要調(diào)用的方法全部集中在了Page1里面,這樣在啟動(dòng)時(shí)只需要裝載Page1即可,相比之前減少了Page2、Page3、Page4的裝載,這就減少了PageFault的次數(shù),節(jié)省的時(shí)間大約為:0.5ms * 3 = 1.5ms。

這也就解釋了為什么二進(jìn)制重排能夠優(yōu)化啟動(dòng)時(shí)長(zhǎng)。

Link Map File

鏈接映射文件:Link Map File,是編譯期間產(chǎn)生的產(chǎn)物,里面記錄的是每個(gè)類所生成的可執(zhí)行文件的路徑、CPU架構(gòu)、符號(hào)等信息,可以簡(jiǎn)單的理解為這個(gè)文件告訴了我們一個(gè)應(yīng)用的可執(zhí)行文件的排列順序。

BuildSetting - Write Link Map File設(shè)置為YES

image.png

修改完畢后 clean 一下 , 運(yùn)行工程 , Products --> Show Build folder in Finder, 找到 macho 的上上層目錄.

image.png

link map文件的文件名為: xxxDemo-LinkMap-normal-x86_64.txt

image.png

編譯項(xiàng)目之后根據(jù)上圖的地址找到我們需要的Link Map 文件,如圖所示:

image.png

文件資源的編譯順序如下圖所示:

image.png

從上圖可以看出一個(gè)項(xiàng)目可執(zhí)行文件的排列順序?yàn)椋?/p>

  • 先按照項(xiàng)目 - Build Phases - Compile Sources中的順序排列
  • 再按每個(gè)文件里面從上至下的方法順序排列

Order File

在項(xiàng)目根目錄通過touch link.order生成link.order文件,這里面就是方法符號(hào)的排序

image.png

然后通過Target -> Build Setting -> Linking -> Order File 設(shè)置 order file 的路徑

image.png

編寫order_file

我們嘗試修改函數(shù)順序,link.order文件里寫入以下的內(nèi)容,

-[ViewController viewDidLoad]
+[ViewController boringColor]
-[ViewController learningGCD]
image.png

command + Kcommand + B 再查看一下Link Map File,順序已經(jīng)換過來了

image.png

可以看到 , 我們所寫的這三個(gè)方法已經(jīng)被放到最前面了 , 至此 , 生成的 macho 中距離首地址偏移量最小的代碼就是我們所寫的這三個(gè)方法 , 假設(shè)這三個(gè)方法原本在不同的三頁(yè) , 那么我們就已經(jīng)優(yōu)化掉了兩個(gè) page fault。

到這里為止,二進(jìn)制重排的整個(gè)核心我們就分析得差不多了,但是這個(gè)二進(jìn)制重排有個(gè)最大的問題,那就是:我們?nèi)绾尾拍軠?zhǔn)確獲取項(xiàng)目啟動(dòng)時(shí)刻調(diào)用的方法順序,換句話說我怎么知道我這個(gè)項(xiàng)目啟動(dòng)需要調(diào)用到哪些方法。

Clang插樁

LLVM內(nèi)置了一個(gè)簡(jiǎn)單的代碼覆蓋率檢測(cè)工具(SanitizerCoverage)。它在函數(shù)級(jí)、基本塊級(jí)和邊緣級(jí)插入對(duì)用戶定義函數(shù)的調(diào)用,并提供了這些回調(diào)的默認(rèn)實(shí)現(xiàn)。在認(rèn)為啟動(dòng)結(jié)束的位置添加代碼,就能夠拿到啟動(dòng)到指定位置調(diào)用到的所有函數(shù)符號(hào)。

看一看 ~ clang文檔

image.png
  • Xcode配置
    在項(xiàng)目Buiding Setting中Other C Flags里面添加 -fsanitize-coverage=func,trace-pc-guard標(biāo)識(shí),如下:
    image.png

Swift混編的項(xiàng)目,在Buiding Setting中Other Swift Flags里面添加

  • -sanitize-coverage=func
  • -sanitize=undefined
    如下:
image.png
  • 添加Hook代碼

在項(xiàng)目啟動(dòng)首頁(yè)后的地方調(diào)用一下下面的代碼,生成OrderFile文件

#import "dlfcn.h"
#import <libkern/OSAtomic.h>

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;  // Guards should start from 1.
}

//初始化原子隊(duì)列
static OSQueueHead list = OS_ATOMIC_QUEUE_INIT;
//定義節(jié)點(diǎn)結(jié)構(gòu)體
typedef struct {
    void *pc;   //存下獲取到的PC
    void *next; //指向下一個(gè)節(jié)點(diǎn)
} Node;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
     void *PC = __builtin_return_address(0);
     Node *node = malloc(sizeof(Node));
     *node = (Node){PC, NULL};
     // offsetof() 計(jì)算出列尾,OSAtomicEnqueue() 把 node 加入 list 尾巴
     OSAtomicEnqueue(&list, node, offsetof(Node, next));
}

- (void)startCreateOrderFile {
    NSLog(@"開始...");
    NSMutableArray *arr = [NSMutableArray array];
    while(1){
        //有進(jìn)就有出,這個(gè)方法和 OSAtomicEnqueue() 類比使用
        Node *node = OSAtomicDequeue(&list, offsetof(Node, next));
        //退出機(jī)制
        if (node == NULL) {
            break;
        }
        //獲取函數(shù)信息
        Dl_info info;
        dladdr(node->pc, &info);
        NSString *sname = [NSString stringWithCString:info.dli_sname encoding:NSUTF8StringEncoding];
        printf("%s \n", info.dli_sname);
        //處理c函數(shù)及block前綴
        BOOL isObjc = [sname hasPrefix:@"+["] || [sname hasPrefix:@"-["];
        //c函數(shù)及block需要在開頭添加下劃線
        sname = isObjc ? sname: [@"_" stringByAppendingString:sname];
        
        //去重
        if (![arr containsObject:sname]) {
            //因?yàn)槿霔5臅r(shí)候是從上至下,取出的時(shí)候方向是從下至上,那么就需要倒序,直接插在數(shù)組頭部即可
            [arr insertObject:sname atIndex:0];
        }
    }
    
    //去掉 touchesBegan 方法 啟動(dòng)的時(shí)候不會(huì)用到這個(gè)
    [arr removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
    //數(shù)組合成字符串
    NSString * funcStr = [arr  componentsJoinedByString:@"\n"];
    //寫入文件
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"link.order"];
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    NSLog(@"結(jié)束...");
    NSLog(@">> 生成的文件路徑為:%@", filePath);
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
}
image.png

運(yùn)行代碼后記錄 link.order 的路徑,本demo生成的文件路徑為:/Users/xxxx/Library/Developer/CoreSimulator/Devices/D5B9DEA2-86A4-4A9F-8E71-EF6C18449D80/data/Containers/Data/Application/F938AF62-2A5A-4C62-969D-65DA8987D620/tmp/link.order

Finder 前往路徑取出 order file

image.png

放在項(xiàng)目根目錄,修改函數(shù)調(diào)用順序


image.png

如何統(tǒng)計(jì)pod庫(kù)的函數(shù)調(diào)用

由于我們是通過編譯選項(xiàng)去做的插樁,它只會(huì)生效于有該選項(xiàng)的工程,而pod庫(kù)則是單獨(dú)的工程,我們只需要在Podfile文件后面加上下面這段

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
        if config.name == 'Debug'
          # 將依賴的pod項(xiàng)目的Other C Flags加上’-fsanitize-coverage=func,trace-pc-guard‘選項(xiàng)
          config.build_settings['OTHER_CFLAGS'] ||= ['$(inherited)', '-fsanitize-coverage=func,trace-pc-guard']
          config.build_settings['OTHER_SWIFT_FLAGS'] ||= ['$(inherited)', '-fsanitize-coverage=func,trace-pc-guard']
        end
      #end
    end
  end
end

APP啟動(dòng)的監(jiān)控手段

為了可以監(jiān)控到日常開發(fā)過程中啟動(dòng)耗時(shí)變化,監(jiān)控了啟動(dòng)過程中的方法調(diào)用耗時(shí),通過每天構(gòu)建對(duì)比當(dāng)天版本和昨天版本的差異分析耗時(shí)原因,流程如下:

image.png
  • Jenkins 編譯構(gòu)建,構(gòu)建完成后,上報(bào) LinkMap
  • 打包完成后,通過 ios-deploy,真機(jī)安裝 App
  • 啟動(dòng) Appium, 用于多次啟動(dòng) App
  • 運(yùn)行測(cè)試腳本,通過控制 Appium, Appium 控制設(shè)備,重復(fù)冷啟動(dòng)多次,上報(bào)數(shù)據(jù),取平均值,減少浮動(dòng)影響
  • 分析數(shù)據(jù),耗時(shí)新增,減少,增加和 Diff 等
  • 分析結(jié)果郵件發(fā)送
  • 優(yōu)化代碼

統(tǒng)計(jì)pre-main耗時(shí):

  • 啟動(dòng)時(shí)間:通過當(dāng)前進(jìn)程標(biāo)識(shí)(NSProcessInfo\processIdentifier),讀取進(jìn)程信息內(nèi)的進(jìn)程創(chuàng)建時(shí)間(__p_starttime)為啟動(dòng)時(shí)間。

pre-main耗時(shí) = 進(jìn)入main 函數(shù)的時(shí)間 - 進(jìn)程創(chuàng)建時(shí)間,以下是獲取進(jìn)程創(chuàng)建時(shí)間實(shí)現(xiàn)

+ (BOOL)processInfoForPID:(int)pid procInfo:(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)processStartTime {
    struct kinfo_proc kProcInfo;
    if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
        return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
    } else {
        NSAssert(NO, @"無法取得進(jìn)程的信息");
        return 0;
    }
}
  • 1、定時(shí)抓取主線程上的方法調(diào)用堆棧,計(jì)算一段時(shí)間里各個(gè)方法的耗時(shí)
  • 2、對(duì) objc_msgSend 方法進(jìn)行hook 來掌握所有方法的執(zhí)行耗時(shí)。

參考:
BLStopwatch
ios-deploy
iOS 啟動(dòng)優(yōu)化 + 監(jiān)控實(shí)踐

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容