脈脈iOS如何啟動(dòng)秒開(kāi)
李揚(yáng),脈脈客戶端高級(jí)開(kāi)發(fā)工程師。2018年加入脈脈,目前作為脈脈平臺(tái)組iOS開(kāi)發(fā),負(fù)責(zé)移動(dòng)端平臺(tái)開(kāi)發(fā),平臺(tái)類(lèi)基礎(chǔ)設(shè)施建設(shè)、維護(hù)、性能調(diào)優(yōu)和新技術(shù)探索。
https://www.zhihu.com/column/p/396550853
前言
啟動(dòng)是 App 給用戶的第一印象,啟動(dòng)越慢,用戶流失的概率就越高,良好的啟動(dòng)速度是用戶體驗(yàn)不可缺少的一環(huán)。
通過(guò)調(diào)研業(yè)內(nèi)現(xiàn)有的啟動(dòng)優(yōu)化方案,針對(duì)啟動(dòng)各個(gè)階段,結(jié)合脈脈自身app的情況,總結(jié)出了具體的可行性建議和可優(yōu)化的項(xiàng)目。
加上后期不斷的調(diào)優(yōu)和實(shí)踐,最終在app啟動(dòng)過(guò)程涉及到現(xiàn)有復(fù)雜業(yè)務(wù)環(huán)境下,實(shí)現(xiàn)了900ms的秒開(kāi)成績(jī)。
防劣化,建立健全app啟動(dòng)監(jiān)控體系。通過(guò)監(jiān)控大盤(pán),及時(shí)發(fā)現(xiàn)問(wèn)題解決問(wèn)題并總結(jié)經(jīng)驗(yàn) 。
二、認(rèn)識(shí) App是如何啟動(dòng)的
啟動(dòng)過(guò)程

啟動(dòng)過(guò)程以main為界限,分為pre-main和main之后兩部分
pre-main
加載dyld
動(dòng)態(tài)庫(kù)載入過(guò)程,會(huì)去裝載app使用的動(dòng)態(tài)庫(kù)。而每一個(gè)動(dòng)態(tài)庫(kù)有它自己的依賴(lài)關(guān)系,會(huì)消耗時(shí)間去查找和讀取。
rebase&binding
rebase:主要是調(diào)整鏡像內(nèi)部的指針,這里使用了ASLR(Address Space Layout Randomization 地址空間布局隨機(jī)化)。程序每次啟動(dòng)后地址都會(huì)隨機(jī)變化,這樣程序里的所有代碼地址都需要重新進(jìn)行計(jì)算修復(fù)
binding:修復(fù)指向外部的指針。比如app中調(diào)用了NSLog函數(shù)打印信息,NSLog是系統(tǒng)函數(shù),在程序開(kāi)始運(yùn)行的時(shí)候app是不知道NSLog函數(shù)指針是多少,此時(shí)就需要通過(guò)dyld_stub_binder技術(shù)找到NSLog指針地址進(jìn)行調(diào)用。
Objc setup
runtime在此處初始化,對(duì)class和category進(jìn)行注冊(cè),selector唯一性判斷
load&constructor&initialize
調(diào)用所有類(lèi)的load的方法,初始化C&C++的靜態(tài)化變量,然后調(diào)用 constructor 函數(shù)
main之后
main函數(shù)
創(chuàng)建整個(gè)app的autoreleasepool,初始化初始window,app界面開(kāi)始展示
LifeCyle
指定rootviewcontroller,調(diào)用業(yè)務(wù)代碼,完成各階段業(yè)務(wù)
First Frame
main頁(yè)面viewDidAppear 完成頁(yè)面第一幀渲染。至此啟動(dòng)完成。
三、衡量 App啟動(dòng)時(shí)間
打點(diǎn)系統(tǒng)監(jiān)控

上圖具體指明了目前能夠做到的打點(diǎn)的地方,可以簡(jiǎn)化為下圖形式:

進(jìn)程創(chuàng)建
通過(guò) sysctl 系統(tǒng)調(diào)用拿到進(jìn)程創(chuàng)建的時(shí)間戳
#import <sys/sysctl.h>
#import <mach/mach.h>
+ (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
{
return 0;
}
}
最早的 +load
和上面的分階段監(jiān)控一樣,通過(guò) AAA 為前綴命名 Pod,讓 +load 第一個(gè)被執(zhí)行
didFinishLaunching
此處監(jiān)控可以使用第三方SDK,也可以手動(dòng)加入打點(diǎn)來(lái)衡量
另外,對(duì)于pre-main階段,Apple提供了一種測(cè)量方法,在 Xcode 中 Edit scheme -> Run -> Auguments 將環(huán)境變量 DYLD_PRINT_STATISTICS 設(shè)為1 。之后控制臺(tái)會(huì)輸出類(lèi)似內(nèi)容,我們可以清晰的看到每個(gè)耗時(shí):

如果將 Edit scheme -> Run > Auguments 將環(huán)境變量 DYLD_PRINT_STATISTICS_DETAILS 設(shè)為1,則可以更多詳細(xì)的pre-main階段的耗時(shí):

工具
TimeProfiler
Time Profiler是Xcode自帶的時(shí)間性能分析工具,正常Time Profiler會(huì)1ms采樣一次,默認(rèn)只采集所有在運(yùn)行線程的調(diào)用棧,最后以統(tǒng)計(jì)學(xué)的方式匯總。通過(guò)統(tǒng)計(jì)比較時(shí)間間隔之間的堆棧狀態(tài),來(lái)推算某個(gè)方法執(zhí)行了多久,并獲得一個(gè)近似值。Time Profiler的使用方法網(wǎng)上有很多使用教程,這里我們也不過(guò)多介紹,附上一篇使用文檔:Instruments Tutorial with Swift: Getting Started。
取從開(kāi)始啟動(dòng)后的2s內(nèi)的時(shí)間為啟動(dòng)樣本

通過(guò)展開(kāi) Main Thread 經(jīng)過(guò)分析發(fā)現(xiàn),耗時(shí)方法在一個(gè)內(nèi)存泄露檢測(cè)模塊,修改之后如下圖:

當(dāng)然其他線程也有在做并發(fā)的處理,也要注意線程個(gè)數(shù)的控制
System Trace
System Trace一直作為Instruments中一個(gè)默默無(wú)聞的功能出現(xiàn),模板提供了系統(tǒng)行為的全面信息。它顯示線程的調(diào)度、系統(tǒng)線程的轉(zhuǎn)化和內(nèi)存使用情況。這個(gè)模板可以使用在OS X或iOS中。簡(jiǎn)單點(diǎn)說(shuō)就是記錄一個(gè)App運(yùn)行過(guò)程中所有底層系統(tǒng)線程、內(nèi)存的調(diào)度使用過(guò)程的工具。
脈脈iOS分析案例
現(xiàn)象
脈脈iOS在蜂窩網(wǎng)絡(luò)數(shù)據(jù)狀態(tài)下Debug,每次啟動(dòng),相比WiFi狀態(tài)下的啟動(dòng)時(shí)間都要長(zhǎng)了2s左右
診斷過(guò)程
首先選中 System Load 選取時(shí)間線,到第一次出現(xiàn) 脈脈 線程的點(diǎn),從這個(gè)時(shí)間點(diǎn)開(kāi)始脈脈app正式啟動(dòng),開(kāi)始處理app內(nèi)部邏輯。也就是main之后的階段。
統(tǒng)計(jì)活躍的高優(yōu)線程數(shù)量和CPU核心數(shù)對(duì)比,如果高于核心數(shù)量會(huì)顯示成黃色,小于等于核心數(shù)量會(huì)是綠色。這個(gè)工具是用來(lái)幫助調(diào)試線程的優(yōu)先級(jí)的。
系統(tǒng)維護(hù)了 5 個(gè)不同的線程優(yōu)先級(jí)/QoS: background,utility,default,user-initiated,user-interactive。

切換到主線程 Main Thread 看到一大塊block的灰色狀態(tài),選取發(fā)現(xiàn)2.01s的卡頓,難道這是巧合?大膽猜測(cè),這就是我們要找的卡頓2s

不要移動(dòng)時(shí)間線,切換到 Events:Thread States,可以看到線程切換的每一個(gè)事件,和狀態(tài)切換的發(fā)生的原因,觀察這個(gè)事件的下一個(gè)事件,因?yàn)橄聜€(gè)事件通常是鎖被釋放,線程重新進(jìn)入可執(zhí)行的狀態(tài)。發(fā)現(xiàn)卡頓的下一步執(zhí)行時(shí) CPU0 上的 0x90998 的線程,換句話說(shuō)是 0x90998 的線程使得主線程結(jié)束卡頓重新進(jìn)入了可執(zhí)行的狀態(tài)。

切換到 0x90998 線程,選中主線程block狀態(tài)釋放開(kāi)始執(zhí)行的時(shí)間點(diǎn),可以看到 parked waiting for new woirk for dispatch,在往上追溯基本都是這句話的重復(fù)調(diào)用,可以大膽猜測(cè)基本上是在等待一個(gè)信號(hào)量。

再次來(lái)到 Main Thread Block位置,雙擊右側(cè)的堆棧棧頂那句話,可以看到具體的代碼位置。

的確是在等待一個(gè)信號(hào)量,并且是 RCT_DEV 環(huán)境下才會(huì)生效,線上不受影響。通過(guò)Debug發(fā)現(xiàn),這里請(qǐng)求的URL是 http://localhost:8081。
如果我們把這段代碼注釋?zhuān)琖iFi和5G流量下,啟動(dòng)速度相差無(wú)幾。之前的猜測(cè)卡主2s是在等待信號(hào)的想法得到印證。
這也說(shuō)明,有時(shí)就是要 敢猜敢想敢做!
通過(guò)SystemTrace工具 Debug 蜂窩數(shù)據(jù)流量環(huán)境下應(yīng)用啟動(dòng)會(huì)卡主2s的問(wèn)題終于查明!
手動(dòng)下點(diǎn)分析
通過(guò)插樁代碼,我們發(fā)現(xiàn)使用 OpenUDID 獲取udid的時(shí)候耗時(shí)有時(shí)竟然達(dá)到400ms。分析后發(fā)現(xiàn),讀取 UIPasteboard 非常耗時(shí),根據(jù)調(diào)研 UIPasteboard 使用場(chǎng)景在脈脈中基本可以忽略,故考慮去掉 UIPasteboard 讀取邏輯。
但在 Time Profiler 里檢測(cè),OpenUDID 獲取udid在所在的子線程,只消耗了 7ms。依靠 Time Profiler分析也有一定的局限性。
四、制定 App啟動(dòng)優(yōu)化方案
整體思路

刪掉啟動(dòng)項(xiàng),把不需要的過(guò)時(shí)的直接刪除
如果不能刪除,嘗試延遲,延遲包括第一次訪問(wèn)以及啟動(dòng)結(jié)束后找個(gè)合適的時(shí)間加載
不能延遲的可以嘗試并發(fā),利用好多核多線程。但也要注意控制好線程的數(shù)量和優(yōu)先級(jí)
如果并發(fā)也不行,可以嘗試讓代碼執(zhí)行更快。比如,頻繁訪問(wèn)的可以只獲取一次就存下來(lái)
pre-main

動(dòng)態(tài)庫(kù)
防止劣化,需要嚴(yán)格管控動(dòng)態(tài)庫(kù)的引入
減少動(dòng)態(tài)庫(kù)
Apple官方建議盡量少的使用自定義的動(dòng)態(tài)庫(kù),或者考慮合并多個(gè)動(dòng)態(tài)庫(kù),其中一個(gè)建議是當(dāng)大于6個(gè)的時(shí)候,則需要考慮合并它們。
自有動(dòng)態(tài)庫(kù)轉(zhuǎn)靜態(tài)庫(kù),或者合并動(dòng)態(tài)庫(kù)
源碼形式的是可以通過(guò)CocoaPods命令轉(zhuǎn)靜態(tài)庫(kù)的,如下
# CocoaPods 打包靜態(tài)庫(kù) 命令
# 其中 –library 指定打包成.a文件,如果不帶上將會(huì)打包成.framework文件。–force 是指強(qiáng)制覆蓋。
pod package xxxx.podspec --force
CocoaPods不使用 use_frameworks! 字段,全部引入靜態(tài)庫(kù)
rebase&binding 和 Objc setup:
減少代碼量
1、基于Mach-O文件分析
objcselrefs 和 objcclassrefs 存儲(chǔ)了所有引用到的 sel方法簽名 和 class
__objc_classlist 存儲(chǔ)了所已有的 sel 和 class
二者做個(gè)差集就知道哪些類(lèi)和哪些類(lèi)的 sel 用不到,但objc 支持運(yùn)行時(shí)調(diào)用,刪除之前還要在二次確認(rèn)
2、通過(guò)打點(diǎn)SDK,收集代碼使用數(shù)據(jù)情況,再?zèng)Q定要不要?jiǎng)h除某些代碼
脈脈會(huì)定期通過(guò)數(shù)據(jù)庫(kù)腳本統(tǒng)計(jì)出日活小于10uv的頁(yè)面vc和相關(guān)的view,和相關(guān)的業(yè)務(wù)線確認(rèn)后會(huì)進(jìn)行刪除,以確保代碼的有效性和簡(jiǎn)潔性。
3、通過(guò)脈脈自研的解耦合分析工具(后面會(huì)考慮開(kāi)源,可以關(guān)注作者github: Andy.Li)
大致原理:數(shù)學(xué)集合運(yùn)算
假如只有兩個(gè)類(lèi)a和類(lèi)b,分析出類(lèi)a提供的方法列表記為集合A,再分析出類(lèi)b使用的類(lèi)a的方法列表記為集合B。
集合A和集合B做差集C,集合C就類(lèi)a中不再被類(lèi)b使用的方法集合
此時(shí)就可以根據(jù)集合C從類(lèi)a中刪除對(duì)應(yīng)的方法。
如果類(lèi)a的所有方法都沒(méi)有被類(lèi)b使用,也就是類(lèi)a完全不被類(lèi)b使用,則可以直接刪除類(lèi)a。
類(lèi)比到到項(xiàng)目中所有的類(lèi),做遍歷遞歸差集,就可以得到全部的未被使用的類(lèi)和方法,考慮刪除之。
重新排列函數(shù)符號(hào)位置,降低MACH-O文件載入內(nèi)存時(shí)PageFault缺頁(yè)中斷頻率 - 二進(jìn)制重排
原理
二進(jìn)制重排實(shí)際上是在windows和linux上就存在的技術(shù),旨在將啟動(dòng)用到的函數(shù)方法盡可能的放置在二進(jìn)制文件加載的前面,并且是將函數(shù)符號(hào)地址連續(xù)的編譯在一起,以減少Page Fault的次數(shù)和頻率,加快啟動(dòng)速度?,F(xiàn)在這項(xiàng)技術(shù)已經(jīng)移植運(yùn)用到了移動(dòng)端app上。
如何理解PageFault缺頁(yè)中斷
操作系統(tǒng)為了解決安全問(wèn)題和效率問(wèn)題,抽象出了虛擬內(nèi)存頁(yè)的概念。內(nèi)存都是分頁(yè)訪問(wèn)的。這里的page指的就是內(nèi)存頁(yè)。(就像磁盤(pán)存儲(chǔ)的最小單位 磁盤(pán)簇,大小是4k一樣)
MacOS 、linux (4K為一頁(yè))
iOS(16K為一頁(yè))
PageFault就是缺頁(yè)中斷:當(dāng)app調(diào)用一個(gè)方法,發(fā)現(xiàn)該方法沒(méi)有在內(nèi)存中,此時(shí)操作系統(tǒng)就會(huì)立刻阻塞整個(gè)app進(jìn)程,觸發(fā)一個(gè)缺頁(yè)中斷。操作系統(tǒng)會(huì)從磁盤(pán)中讀取這頁(yè)數(shù)據(jù)到物理內(nèi)存上 , 然后再將其映射到虛擬內(nèi)存上 ( 如果當(dāng)前內(nèi)存已滿 , 操作系統(tǒng)會(huì)通過(guò)置換頁(yè)算法 找一頁(yè)數(shù)據(jù)進(jìn)行覆蓋,這也是為什么開(kāi)再多的應(yīng)用也不會(huì)崩掉 , 但是之前開(kāi)的應(yīng)用再打開(kāi)時(shí) , 就重新啟動(dòng)了的根本原因 )。
假如,app啟動(dòng)時(shí)期需要調(diào)用 method1、method5和method6,這三個(gè)方法分布在page1、page2和page3上。每裝載一個(gè)內(nèi)存頁(yè)page都會(huì)發(fā)生一次PageFault(缺頁(yè)終端)。通常一個(gè)PageFault的處理時(shí)間是0.1ms~1ms,取0.5ms計(jì)算。這三次處理PageFault時(shí)間是 3 * 0.5ms = 1.5ms。

二進(jìn)制重排后

method1、method5和method6全都集中在了page1,這樣只需裝載page1就可以了。相比之前少了page2和page3的裝載。少了兩次處理PageFault時(shí)間。這次消耗的時(shí)間是 1 * 0.5ms = 0.5ms。節(jié)省了1ms
iOS App之所以能夠使用二進(jìn)制重排,是因?yàn)閄code 已經(jīng)提供好這個(gè)機(jī)制 , 并且 libobjc 實(shí)際上也是用了二進(jìn)制重排進(jìn)行優(yōu)化 .

獲取啟動(dòng)加載所有的函數(shù)的符號(hào)
只有準(zhǔn)確獲取了app啟動(dòng)所用到的函數(shù)方法,對(duì)其進(jìn)行重新排列,才能做到啟動(dòng)加速,那么如何獲取這些函數(shù)符號(hào)呢?
Hook
oc 或者 swift @objc dynamic 修飾的方法,調(diào)用都會(huì)通過(guò) objc_MsgSend 發(fā)送消息,hook objc_MsgSend 可以做到這個(gè)方法的檢測(cè)。但如果是可變參數(shù)個(gè)數(shù),則需要匯編來(lái)獲取參數(shù)
二進(jìn)制靜態(tài)掃描
Mach-O文件在特定段Segment和Section里存儲(chǔ)著符號(hào)及函數(shù)數(shù)據(jù),通過(guò)靜態(tài)掃描Mach-O文件,主要是分析獲取load方法和c++ constructor 構(gòu)造方法。

clang 匯編插樁
clang 本身已經(jīng)提供了一個(gè)代碼覆蓋率檢測(cè)機(jī)制(SanitizerCoverage),來(lái)實(shí)現(xiàn)我們獲取所有符號(hào)的需求
前兩種都或多或少存在一些問(wèn)題,并不是完美的狀態(tài),網(wǎng)上的資料有很多,可以自行查閱。接下來(lái)主要是通過(guò)clang 插樁的方式來(lái)hook所有的函數(shù)符號(hào)
clang插樁
Xcode如何配置
在目標(biāo)工程 Target -> Build Settings -> Other C Flags 添加 -fsanitize-coverage=func, trace-pc-guard。

如果有swfit代碼,也要在 Other Swift Flags 添加 -sanitize-coverage=func 和 __-sanitize=undefined__

(如果有源碼編譯的Framework也要添加這些配置。CocoaPods引入的第三方庫(kù)不建議添加上述配置)
添加hook代碼
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)。
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 symboList = OS_ATOMIC_QUEUE_INIT;
//定義符號(hào)結(jié)構(gòu)體
typedef struct{
void * pc;
void * next;
}SymbolNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//if (!*guard) return; // Duplicate the guard check.
void *PC = __builtin_return_address(0);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入隊(duì)
// offsetof 用在這里是為了入隊(duì)添加下一個(gè)節(jié)點(diǎn)找到 前一個(gè)節(jié)點(diǎn)next指針的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
運(yùn)行工程,通過(guò)Hopper反編譯工具可以看到在函數(shù)內(nèi)部一開(kāi)始就添加了 額外方法的匯編代碼,這樣就做到了 靜態(tài)插樁

脈脈自研二進(jìn)制重排預(yù)分析工具(已開(kāi)源)
此工具詳細(xì)介紹了如何生成 linked_map.txt 和 lb.order文件,以及如何預(yù)驗(yàn)證重排效果。
目的
在沒(méi)有上線之前可以分析出對(duì)App啟動(dòng)優(yōu)化節(jié)省的大致時(shí)間,起到指導(dǎo)作用。(具體能優(yōu)化多少,以線上數(shù)據(jù)為準(zhǔn))
建議
因?yàn)楣こ痰拇a隨著開(kāi)發(fā)的進(jìn)行會(huì)不斷的改變位置或者刪減,之前已經(jīng)排好的順序有些會(huì)失效。
每隔三個(gè)月執(zhí)行一次二進(jìn)制重排更新,確保 PageFault 次數(shù)維持在一個(gè)較低的穩(wěn)定的水平。
輸出示例
---> 分析結(jié)果:
linked map __Text(鏈接文件):
起始地址:0x100006A60
結(jié)束地址:0x1021E75E8
分配的虛擬內(nèi)存頁(yè)個(gè)數(shù):2169
order symbol(重排文件):
需要重排的符號(hào)個(gè)數(shù):4630
分布的虛擬內(nèi)存頁(yè)個(gè)數(shù):392
二進(jìn)制重排后分布的虛擬內(nèi)存頁(yè)個(gè)數(shù):99
內(nèi)存缺頁(yè)中斷減少的個(gè)數(shù):293
預(yù)估節(jié)省的時(shí)間:146ms
如何反向驗(yàn)證
用二進(jìn)制重排之后的工程,再次分別編譯出 linked_map.txt 和 lb.order 文件,使用此工具再次運(yùn)行檢查??梢缘玫饺缦陆Y(jié)果
---> 分析結(jié)果:
linked map __Text(鏈接文件):
起始地址:0x100006A60
結(jié)束地址:0x1021E75E8
分配的虛擬內(nèi)存頁(yè)個(gè)數(shù):2169
order symbol(重排文件):
需要重排的符號(hào)個(gè)數(shù):4630
分布的虛擬內(nèi)存頁(yè)個(gè)數(shù):99
二進(jìn)制重排后分布的虛擬內(nèi)存頁(yè)個(gè)數(shù):99
內(nèi)存缺頁(yè)中斷減少的個(gè)數(shù):0
預(yù)估節(jié)省的時(shí)間:0ms
可以看出重排后的二進(jìn)制文件已經(jīng)不需要再次進(jìn)行重排了。至此,二進(jìn)制重排線下預(yù)評(píng)估結(jié)束。
工具開(kāi)源地址:https://github.com/lyandy/Linked_Order_Analyze
load&constructor&initialize
+load 盡量不要使用
在 pre-main 時(shí)期,objc 會(huì)向 dyld 注冊(cè)一個(gè) init 回調(diào),當(dāng) dyld 將要執(zhí)行載入 image 的 initializers 流程時(shí) (依賴(lài)的所有 image 已走完 initializers 流程時(shí)),init 回調(diào)被觸發(fā),在這個(gè)回調(diào)中,objc 會(huì)按照父類(lèi)-子類(lèi)-分類(lèi)順序調(diào)用 +load 方法。因?yàn)?+load 方法執(zhí)行地足夠早,并且只執(zhí)行一次,所以我們通常會(huì)在這個(gè)方法中進(jìn)行 method swizzling 或者自注冊(cè)操作。也正是因?yàn)?+load 方法調(diào)用時(shí)間點(diǎn)的特殊性,導(dǎo)致此方法的耗時(shí)監(jiān)測(cè)較為困難,而如何使監(jiān)測(cè)代碼先于 +load 方法執(zhí)行成為解決此問(wèn)題的關(guān)鍵點(diǎn)。
脈脈自研了一套 hook 監(jiān)測(cè) +load 執(zhí)行時(shí)間方案,并結(jié)合 CocoaPods 實(shí)現(xiàn)了一行代碼集成耗時(shí)監(jiān)測(cè)的功能。(后續(xù)會(huì)開(kāi)源)
__attribute__((constructor)) 盡量不要使用
如果函數(shù)被設(shè)定為 constructor 屬性,則該函數(shù)會(huì)在 main 函數(shù)執(zhí)行之前被自動(dòng)的執(zhí)行。執(zhí)行時(shí)間太長(zhǎng),會(huì)大大增加啟動(dòng)時(shí)間。
C/C++靜態(tài)化變量遷移
1、std:string 轉(zhuǎn)換成 const char *
2、靜態(tài)變量移動(dòng)到方法內(nèi)部
因?yàn)榉椒▋?nèi)部的靜態(tài)變量會(huì)在方法第一次調(diào)用的時(shí)候初始化
main 之后
啟動(dòng)器
啟動(dòng)是需要一個(gè)框架來(lái)管控的,脈脈采用的是流控制方案。
為什么需要啟動(dòng)器呢?
全局并發(fā)調(diào)度
比如 AB 任務(wù)并發(fā),C 任務(wù)等待 AB 執(zhí)行完畢,框架調(diào)度還能減少線程數(shù)量和控制優(yōu)先級(jí)
延遲執(zhí)行
提供一些時(shí)機(jī),業(yè)務(wù)可以做預(yù)熱性質(zhì)的初始化
精細(xì)化監(jiān)控
所有任務(wù)的耗時(shí)都能監(jiān)控到,線下自動(dòng)化監(jiān)控也能受益
管控
啟動(dòng)任務(wù)的順序調(diào)整,新增/刪除都能通過(guò) Code Review 管控
脈脈自研的流控制器方案流程

脈脈的啟動(dòng)流程大致分為三個(gè)階段
流控制器
從Task1到Task8分為兩個(gè)Flow Category,基礎(chǔ)支撐Category和業(yè)務(wù)流Category。這些業(yè)務(wù)流flow中有廣告、新手引導(dǎo)、資料補(bǔ)全引導(dǎo)、業(yè)務(wù)拉新等眾多邏輯。解耦合和精細(xì)化管控每個(gè)Task所執(zhí)行的代碼,啟動(dòng)流程得以規(guī)范化治理,啟動(dòng)速度也大大加快。
主容器加載
包含了底部5個(gè)tab容器的加載,其中首頁(yè)tab會(huì)被優(yōu)先加載,其他4個(gè)懶加載。
RN首頁(yè)首幀渲染
通常首頁(yè)容器加載完畢,就認(rèn)為是啟動(dòng)結(jié)束的點(diǎn),如上圖虛線的位置。但脈脈的啟動(dòng)結(jié)束點(diǎn)是首頁(yè)RN Feed流第一幀渲染結(jié)束的點(diǎn)。
優(yōu)化方式
三方SDK
有些三方 SDK 的啟動(dòng)耗時(shí)很高,將第三方SDK延后或并發(fā)。有些SDK已經(jīng)分布在了不同的flow里并發(fā),但flow內(nèi)部還可以做并發(fā),但要注意線程數(shù)量的控制
高頻次方法
有些方法的單個(gè)耗時(shí)不高,但是在啟動(dòng)路徑上會(huì)調(diào)用很多次的,這種累計(jì)起來(lái)的耗時(shí)也不低,比如讀 Info.plist 里面的配置:
+ (NSString *)plistChannel
{
return [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CHANNEL_NAME"];
}
鎖
線程間一些信號(hào)量等待鎖,有可能會(huì)長(zhǎng)時(shí)間卡主啟動(dòng)流程的方法,需要移除或者換種方式去做。
線程數(shù)量
線程的數(shù)量和優(yōu)先級(jí)都會(huì)影響啟動(dòng)時(shí)間。在介紹 System Trace 工具的環(huán)節(jié),有講到高優(yōu)先級(jí)線程和CPU數(shù)量的關(guān)系。瞬時(shí)開(kāi)啟過(guò)多的線程,占用了太多的內(nèi)存和CPU,反而會(huì)拖慢啟動(dòng)速度
圖片
啟動(dòng)難免會(huì)用到很多圖,有沒(méi)有辦法優(yōu)化圖片加載的耗時(shí)呢?
用 Asset 管理圖片而不是直接放在 bundle 里。Asset 會(huì)在編譯期做優(yōu)化,讓加載的時(shí)候更快。
此外在 Asset 中加載圖片是要比 Bundle 快的,因?yàn)?UIImage imageNamed 要遍歷 Bundle 才能找到圖。
加載 Asset 中圖的耗時(shí)主要在在第一次張圖,因?yàn)橐⑺饕梢酝ㄟ^(guò)把啟動(dòng)的圖放到一個(gè)小的 Asset 里來(lái)減少這部分耗時(shí)。每次創(chuàng)建 UIImage 都需要 IO,在首幀渲染的時(shí)候會(huì)解碼。所以可以通過(guò)提前子線程預(yù)加載(創(chuàng)建 UIImage)來(lái)優(yōu)化這部分耗時(shí)。
Fishhook
fishhook 是一個(gè)用來(lái) hook C 函數(shù)的庫(kù),但這個(gè)庫(kù)的第一次調(diào)用耗時(shí)很高,最好不要帶到線上。fishhook 是遍歷 Mach-O 的多個(gè)段來(lái)找函數(shù)指針和函數(shù)符號(hào)名的映射關(guān)系,帶來(lái)的副作用就是要大量的 Page In,對(duì)于大型 App 來(lái)說(shuō)在 iPhone X 冷啟耗時(shí) 200ms+。
如果不得不用 fishhook,請(qǐng)?jiān)谧泳€程調(diào)用,且不要在在 dyldregister_func_for_add_image 直接調(diào)用 fishhook。因?yàn)檫@個(gè)方法會(huì)持有 dyld 的一個(gè)全局互斥鎖,主線程在啟動(dòng)的時(shí)候系統(tǒng)庫(kù)經(jīng)常會(huì)調(diào)用 dlsym 和 dlopen。其內(nèi)部也需要這個(gè)鎖,造成上文提到的子線程阻塞主線程。
首幀渲染
不同 App 的業(yè)務(wù)形態(tài)不同,優(yōu)化方式也相差的比較多,幾個(gè)常見(jiàn)的優(yōu)化點(diǎn):
LottieView
lottie 是 airbnb 用來(lái)做 AE 動(dòng)畫(huà)的庫(kù),但是加載動(dòng)畫(huà)的 json 和讀圖是比較慢的,可以先顯示一幀靜態(tài)圖,啟動(dòng)結(jié)束后再開(kāi)始動(dòng)畫(huà),或者子線程預(yù)先把圖和 json 設(shè)置到 lottie cache 里
Lazy 初始化 View
不要先創(chuàng)建設(shè)置成 hidden,這是很不好的習(xí)慣
AutoLayout
AutoLayout 的耗時(shí)也是比較高的,但這塊往往歷史包袱比較重,可以評(píng)估 ROI 看看要不要改成 frame
Loading 動(dòng)畫(huà)
App 一般都會(huì)有個(gè) loading 動(dòng)畫(huà)表示加載中,這個(gè)動(dòng)畫(huà)最好不要用 gif,線下測(cè)量一個(gè) 60 幀的 gif 加載耗時(shí)接近 70ms
五、驗(yàn)證 脈脈App啟動(dòng)優(yōu)化效果
pre-main
二進(jìn)制重排效果
應(yīng)用啟動(dòng)90分位耗時(shí)降低 600ms,且非常穩(wěn)定

未重排的版本:5.3.64、5.3.66
已重排的版本:5.3.70、5.3.74
main 之后
啟動(dòng)耗時(shí)再次降低 500ms,實(shí)現(xiàn)了秒開(kāi)

優(yōu)化前的版本:6.0.62、6.0.64
優(yōu)化后的版本:6.0.70、6.0.72

總體啟動(dòng)耗時(shí)native部分的優(yōu)化,在2021年6月10號(hào) 6.0.70 版本上線后,由之前的600ms降到了270ms,降了接近300ms多。
同時(shí)通過(guò)統(tǒng)計(jì)從feed vc didappear到RN首幀渲染也降低了200ms左右。
從Xcode自帶的統(tǒng)計(jì)工具Organizer也可以看出,6.0.70版本啟動(dòng)時(shí)間90分位為900ms, 實(shí)現(xiàn)秒開(kāi)

六、補(bǔ)充 非常規(guī)優(yōu)化手段
+load 方法遷移
+load 除了方法本身的耗時(shí),還會(huì)引起大量 PageFault,
另外 +load 的存在對(duì) App 穩(wěn)定性也是沖擊,因?yàn)?Crash 了捕獲不到。
舉個(gè)例子,很多 容器需要把協(xié)議綁定到類(lèi),所以需要在啟動(dòng)的早期(+load)里注冊(cè)
+ (void)load
{
[ProtocolClass registerClass:IMPClass forProtocol:@protocol(myProcotol)]
}
本質(zhì)上只要知道協(xié)議和類(lèi)的對(duì)應(yīng)關(guān)系即可,利用 clang attribute,這個(gè)過(guò)程可以遷移到編譯期, 在Mach-O文件的末尾再添加一個(gè)Section段,Mach-O裝載進(jìn)內(nèi)存加載Section段的時(shí)候,再去做Class和Protocol的對(duì)應(yīng)關(guān)系。這種方式已經(jīng)運(yùn)用到脈脈自動(dòng)化解耦合工具里。
typedef struct{
const char * cls;
const char * protocol;
}_mm_pair;
#if DEBUG
#define MM_SERVICE(PROTOCOL_NAME,CLASS_NAME)\
__used static Class<PROTOCOL_NAME> _MM_VALID_METHOD(void){\
return [CLASS_NAME class];\
}\
__attribute((used, section(_MM_SEGMENT "," _MM_SECTION ))) static _mm_pair _MM_UNIQUE_VAR = \
{\
_TO_STRING(CLASS_NAME),\
_TO_STRING(PROTOCOL_NAME),\
};\
#else
__attribute((used, section(_MM_SEGMENT "," _MM_SECTION ))) static _mm_pair _MM_UNIQUE_VAR = \
{\
_TO_STRING(CLASS_NAME),\
_TO_STRING(PROTOCOL_NAME),\
};\
#endif
當(dāng)時(shí)脈脈iOS做解耦合的時(shí)候采用的,添加 ProtocolSect Section Data段

__Text段 重命名遷移
App Store 會(huì)對(duì)上傳的 App 的 TEXT 段加密,在發(fā)生 PageFault 的時(shí)候會(huì)解密,解密的過(guò)程是很耗時(shí)的。
既然會(huì) TEXT 段加密,那么直接的思路就是把 TEXT 段中的內(nèi)容移動(dòng)到其它段,ld 也有個(gè)參數(shù) rename_section 支持重命名。

不建議使用此種方式優(yōu)化,原因如下:
1、__TEXT 段遷移最難解決的問(wèn)題是ld鏈接失敗問(wèn)題,是由 CPU 對(duì)尋址范圍的限制以及 ld64 鏈接器的缺陷導(dǎo)致。
2、被遷移的__TEXT 段段,無(wú)法配合dSYM文件做符號(hào)化
PGO優(yōu)化啟動(dòng)時(shí)間
PGO是蘋(píng)果官方提供的工具,具體使用方法是點(diǎn)擊xcode工具欄
Product -> Perform Action -> Generate Optimization Profile 按xcode提示操作即可
不建議使用此種方式優(yōu)化,原因如下:
1、如果項(xiàng)目中有 swift 代碼,那么這種方式就不能用了,因?yàn)?swift 不支持 PGO。
2、代碼發(fā)生變更,Xcode 會(huì)提示 profdata file out of date,需要每個(gè)版本或者每隔一段時(shí)間重新生成
