啟動是App給用戶的第一印象,一款App的啟動速度,不單單是用戶體驗的事情,往往還決定了它能否獲取更多的用戶。所以到了一定階段App的啟動優化是必須要做的事情。 ...
1 前言
啟動是App給用戶的第一印象,一款App的啟動速度,不單單是用戶體驗的事情,往往還決定了它能否獲取更多的用戶。所以到了一定階段App的啟動優化是必須要做的事情。App啟動基本分為以下兩種
1.1 冷啟動
App 點擊啟動前,它的進程不在系統里,需要系統新創建一個進程分配給它啟動的情況。這是一次完整的啟動過程。
表現:App第一次啟動,重啟,更新等
1.2 熱啟動
App 在冷啟動後用戶將 App 退後臺,在 App 的進程還在系統里的情況下,用戶重新啟動進入 App 的過程,這個過程做的事情非常少。
所以我們主要說道說道冷啟動的優化
2 啟動流程
2.1 APP啟動都幹了什麼
要對啟動速度進行優化,我們需要知道啟動過程中的大致流程是什麼,做了什麼事情,是否能針對性優化。
下圖是啟動流程的詳細分解
- 點擊圖標,創建進程
- mmap 主二進位,找到 dyld 的路徑
- mmap dyld,把入口地址設為_dyld_start
dyld 是啟動的輔助程式,是 in-process 的,即啟動的時候會把 dyld 載入到進程的地址空間里,然後把後續的啟動過程交給 dyld。dyld 主要有兩個版本:dyld2 和 dyld3。
iOS 12之前主要是dyld2,iOS 13 開始 Apple 對三方 App 啟用了 dyld3,dyld3 的最重要的特性就是啟動閉包,閉包存儲在沙盒的 tmp/com.apple.dyld 目錄,清理緩存的時候切記不要清理這個目錄。
閉包里主要有以下內容:
- dependends,依賴動態庫列表
- fixup:bind & rebase 的地址
- initializer-order:初始化調用順序
- optimizeObjc: Objective C 的元數據
- 其他:main entry, uuid等等
上圖虛線之上的部分是out-of-process的,在App下載安裝和版本更新的時候會去執行,直接從緩存中讀取數據,加快載入速度
這些信息是每次啟動都需要的,把信息存儲到一個緩存文件就能避免每次都解析,尤其是 Objective-C 的運行時數據(Class/Method…)解析耗時, 所以對啟動速度是一個優化提升
4.把沒有載入的動態庫 mmap 進來,動態庫的數量會影響這個階段
dyld從主執行文件的header獲取到需要載入的所依賴動態庫列表,然後它需要找到每個 dylib,而應用所依賴的 dylib 文件可能會再依賴其他 dylib,所以所需要載入的是動態庫列表一個遞歸依賴的集合
5.對動態庫集合迴圈load, mmap 載入到虛擬記憶體里,對每個 Mach-O 做 fixup,包括 Rebase 和 Bind。
對每個二進位做 bind 和 rebase,主要耗時在 Page In,影響 Page In 數量的是 objc 的元數據
- Rebase 在Image內部調整指針的指向。在過去,會把動態庫載入到指定地址,所有指針和數據對於代碼都是對的,而現在地址空間佈局是隨機化(ASLR),所以需要在原來的地址根據隨機的偏移量做一下修正, 也就是說Mach-O 在 mmap 到虛擬記憶體的時候,起始地址會有一個隨機的偏移量 slide,需要把內部的指針指向加上這個 slide.
- Bind 是把指針正確地指向Image外部的內容。這些指向外部的指針被符號(symbol)名稱綁定,dyld需要去符號表裡查找,找到symbol對應的實現, 像 printf 等外部函數,只有運行時才知道它的地址是什麼,bind 就是把指針指向這個地址,這也是後面我們能用fishhook來hook一些動態符號的核心
如下圖,編譯的時候,字元串 1234 在__cstring的 0x10 處,所以 DATA 段的指針指向 0x10。但是 mmap 之後有一個偏移量 slide=0x1000,這時候字元串在運行時的地址就是 0x1010,那麼 DATA 段的指針指向就不對了。Rebase 的過程就是把指針從 0x10,加上 slide 變成 0x1010。運行時類對象的地址已經知道了,bind 就是把 isa 指向實際的記憶體地址。
6.初始化 objc 的 runtime,由於閉包已經初始化了大部分,這裡只會註冊 sel 和裝載 category
7.+load 和靜態初始化被調用,除了方法本身耗時,這裡可能還會引起大量 Page In,如果調用了dispatch_async則會延遲啟動後的runloop開啟後執行,如果觸發靜態初始化,則會延遲到運行時執行
8.初始化 UIApplication,啟動 Main Runloop,可以在之前章節利用runloop統計首屏耗時,也可以在啟動結束做一些預熱任務
9.執行 will/didFinishLaunch,這裡主要是業務代碼耗時。首頁的業務代碼都是要在這個階段,也就是首屏渲染前執行的,主要包括了:首屏初始化所需配置文件的讀寫操作;首屏列表大數據的讀取;首屏渲染的大量計算等;sdk的初始化;對於大型組件化工程,也包含了很多moudle的啟動載入項
10.Layout,viewDidLoad 和Layoutsubviews 會在這裡調用,Autolayout 太多會影響這部分時間
11.Display,drawRect 會調用
12.Prepare,圖片解碼發生在這一步
13.Commit,首幀渲染數據打包發給 RenderServer,走GPU渲染流水線流程,啟動結束
(tips: 2.2.10-2.2.13這裡主要是圖形渲染流水線的部分流程,Application產生圖元階段(CPU階段))。後續會交由單獨的RenderServer進程,再調用渲染框架(Metal/OpenGL ES)來生成 bitmap,放到幀緩衝區里,硬體根據時鐘信號讀取幀緩衝區內容,完成屏幕刷新
2.2 啟動各階段時長統計
上一小節對啟動各個階段過程的詳細闡述,歸納起來大致分為6個階段(WWDC2019):
通過對各個階段進行時長統計分析,進行優化然後對比。
可以在Xcode中設置環境變數DYLD_PRINT_STATISTICS和DYLD_PRINT_STATISTICS_DETAILS看下啟動階段和對應的耗時(iOS15後環境變數失效)
也可以通過Xcode MetricKit 本身也可以看到啟動耗時:打開 Xcode -> Window -> Origanizer -> Launch Time
如果公司有對應的成熟監控體系最好,這裡我們主要通過手動無侵入埋點去統計啟動時長,對啟動流程pre main-> after main進行統計分析
2.1.1 進程創建時間打點
通過 sysctl 系統調用拿到進程創建的時間戳
#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 {
NSAssert(NO, @"無法取得進程的信息");
return 0;
}
2.1.2 main()執行時間打點
// main之前調用
// pre-main()階段結束時間點:__t2
void static __attribute__ ((constructor)) before_main()
{
if (__t2 == 0)
{
__t2 = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
}
}
2.1.3 首屏渲染時間打點
啟動的終點對應用戶感知到的 Launch Image 消失的第一幀
iOS 12 及以下:root viewController 的 viewDidAppear
iOS 13+:applicationDidBecomeActive
Apple 官方的統計方式是第一個 CA::Transaction::commit,但對應的實現在系統框架內部,不過我們可以找到最接近這個的時間點
通過 Runloop 源碼分析和調試,我們發現 CFRunLoopPerformBlock,kCFRunLoopBeforeTimers 和 CA::Transaction::commit()為最近的時間點,所以在這裡打點即可.
具體就是可以通過在 didFinishLaunch 中向 Runloop 註冊 block 或者 BeforeTimer 的 Observer 來獲取這兩個時間點的回調,代碼如下:
註冊block:
//註冊block
CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
CFRunLoopPerformBlock(mainRunloop,NSDefaultRunLoopMode,^(){
NSTimeInterval stamp = [[NSDate date] timeIntervalSince1970];
NSLog(@"runloop block launch end:%f",stamp);
});
監聽BeforeTimer 的 Observer
//註冊kCFRunLoopBeforeTimers回調
CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
CFRunLoopActivity activities = kCFRunLoopAllActivities;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, activities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
if (activity == kCFRunLoopBeforeTimers) {
NSTimeInterval stamp = [[NSDate date] timeIntervalSince1970];
NSLog(@"runloop beforetimers launch end:%f",stamp);
CFRunLoopRemoveObserver(mainRunloop, observer, kCFRunLoopCommonModes);
}
});
CFRunLoopAddObserver(mainRunloop, observer, kCFRunLoopCommonModes);
綜上分析現有項目版本啟動時間均值:
[函數名:+[LaunchTrace mark]_block_invoke][行號:54]—————App啟動————-耗時:pre-main:4.147820
[函數名:+[LaunchTrace mark]_block_invoke][行號:55]—————App啟動————-耗時:didfinish:0.654687
[函數名:+[LaunchTrace mark]_block_invoke][行號:56]—————App啟動————-耗時:total:4.802507
3 啟動優化
上節我們主要分析了App啟動流程和時長統計,下麵就是我們要優化的方向,儘可能對各個階段進行優化,當然也不是過度優化,項目不同階段、不同規模相應的問題會不一樣,做針對性分析優化.
3.1 Pre Main 優化
3.1.1 調整動態庫
查看了現有工程,基本都以動態庫進行鏈接,總計48個,所以思路如下
- 減少動態庫,自有動態庫轉靜態庫
- 現有的庫是以CocoaPods管理的,所以通過hook pod構建流程修改Xcode config將部分pod的Mach-O type改為Static Library;
- 同時對一些代碼較大的動態庫進行ROI分析,分析是否可以不依賴,在代碼內即可實現替代邏輯,這樣刪除一些ROI很低的動態庫
- 合併動態庫
- 目前項目引入的動態庫較為簡單,不存在合併項,對於有些中大型工程,有很多自己的基建UI庫,很多過於分散,需要做的就是能聚合就聚合,譬如XXTableView, XXHUD, XXLabel,建議合併成一個XXUIKit;譬如一些工具庫,也可以根據實際情況聚合為一個
- 動態庫懶載入
- 經過分析目前項目階段規模還沒必要進行懶載入動態庫,畢竟優化要考慮收益,僅做優化思路參考
- 正常動態庫都是會被主二進位直接或者間接鏈接的,那麼這些動態庫會在啟動的時候載入。如果只打包進 App,不參與鏈接,那麼啟動的時候就不會自動載入,在運行時需要用到動態庫裡面的內容的時候,再手動懶載入
- 運行時通過-[NSBundle load]來載入,本質上調用的是底層的 dlopen。
3.1.2 rebase&binding&objc setup階段
- 無關的Class、Method的符號載入耗時也會帶來額外的啟動耗時;所以我們要減少__DATA段中的指針數量;對項目代碼分析發現很多類似的Category,每個Category裡面可能只有一個功能函數,所以具體根據項目情況分析進行Category合併
- +load 除了方法本身的耗時,還會引起大量 Page In,另外 +load 的存在對 App 穩定性也是衝擊,因為 Crash 了捕獲不到。
- 項目中不少類似以下load函數邏輯,具體分析後很多可以作為啟動器進行治理管理,runloop空閑去執行,
- 首屏後延時載入
- 另外一類是load邏輯操作:很多組件化通訊解耦方案之一就是在load函數內做協議和類的綁定,這部分可以利用 clang attribute,將其遷移到編譯期:
typedef struct{
const char * cls;
const char * protocol;
}_di_pair;
#if DEBUG
#define DI_SERVICE(PROTOCOL_NAME,CLASS_NAME)\
__used static Class<PROTOCOL_NAME> _DI_VALID_METHOD(void){\
return [CLASS_NAME class];\
}\
__attribute((used, section(_DI_SEGMENT "," _DI_SECTION ))) static _di_pair _DI_UNIQUE_VAR = \
{\
_TO_STRING(CLASS_NAME),\
_TO_STRING(PROTOCOL_NAME),\
};\
#else
__attribute((used, section(_DI_SEGMENT "," _DI_SECTION ))) static _di_pair _DI_UNIQUE_VAR = \
{\
_TO_STRING(CLASS_NAME),\
_TO_STRING(PROTOCOL_NAME),\
};\
#endif
原理很簡單:巨集提供介面,編譯期把類名和協議名寫到二進位的指定段里,運行時把這個關係讀出來就知道協議是綁定到哪個類了。
- 下線代碼
無用代碼刪除在所有的性能優化手段里基本上是ROI最低的。但是幾乎所 有ROI較高的技術手段都是一次性優化方案,經過幾個版本迭代後再做優化就會比較乏力。相比之下,針對代碼的檢測和刪除在很長的一段時間內提供了很大的優化空間
檢測手段:靜態掃描Mach-O文件對classlist和classrefs做差集,形成初步的無用類集合,並根據業務代碼特征做二次適配
當然還有其他常用的技術手段包括AppCode工具檢測以及以例如Pecker這樣的基於 IndexStoreDB 、線上統計等。
不過以上方案對Swift的檢測方案不太適用(和OC存儲差異),這裡可以參考github.com/wuba/WBBlad…
對項目進行檢測,發現還是很多無用類的:
然後二次分析驗證,進行優化
3.1.3 二進位重排
iOS系統中虛擬記憶體到物理記憶體的映射都是以頁為最小單位的。當進程訪問一個虛擬記憶體Page而對應的物理記憶體卻不存在時,就會出現Page Fault缺頁中斷,(對應System Trace的File Backed Page In) 然後操作系統把數據載入到物理記憶體中,如果已經已經載入到物理記憶體了,則會觸發Page Cache Hit,後者是比較快的,這也是熱啟動比冷啟動快的原因之一。
雖然缺頁中斷異常這個處理速度是很快的,但是在一個App的啟動過程中可能出現上千(甚至更多)次Page Fault,這個時間積累起來會比較明顯了。
基於上面原理. 我們的目標就是在啟動的時候增加Page Cache Hit,減少Page Fault,從而達到優化啟動時間的目的
我們需要確定,在啟動的時候,執行了哪些符號,儘可能讓這些符號的記憶體集中在一起,減少占用的頁數,就能減少Page Fault的命中次數
程式預設情況下是順序執行的:
如果啟動需要使用的方法分別在2頁Page1和Page2中(method1和method3),為了執行相應的代碼,系統就必須進行兩個Page Fault。
如果我們對方法進行重新排列,讓method1和method3在一個Page,那麼就可以較少一次Page Fault。
通過Instruments中的System Trace工具來看下當前的page fault載入情況
這裡有個註意點,為了確保App是真正的冷啟動,需要把記憶體清乾凈,不然結果會不太準,下圖是我直接殺掉App,重新打開得到的結果
可以看到,和第一次測試差的有點多,我們可以在殺掉App後,重新打開多個其他的App(儘可能多),或者卸載重裝,這樣在重新打開App的時候,就會冷啟動
綜上我們要做的就是將啟動時調用的函數符號集中靠前排列,減少缺頁中斷數量
- 獲取啟動代碼執行順序
- 確定App在啟動的時候,調用了哪些函數(使用了哪些符號),這裡推薦一個工具AppOrderFiles(https://github.com/yulingtianxia/AppOrderFiles ),使用Clang SanitizerCoverage,通過編譯器插裝的方式,獲取到調用函數的符號順序(當然我們也可以在Build Settings中修改Write Link Map File為YES編譯後會生成一個Link Map符號表txt,進行分析,創建我們自己的order文件)在App啟動後,到首屏VC的viewDidLoad方法內輸出order file。
輸出的文件在App沙盒,用模擬器運行更方便,得到文件app.order,這裡面就是排好序的符號列表,根據App的執行順序,如果項目比較大的話,會比較久.
把order文件放到工程目錄,配置到Xcode裡面Build Setting -> Order File -> $(PROJECT_DIR)/xxx.order
- 驗證\對比
Xcode裡面Build Setting有個Write Link Map File,可以生成Link Map文件的選項,路徑如下
Link Map文件
Intermediates.noindex/xxxx.build/Debug-iphoneos/xxx.build/xxx-LinkMap-normal-arm64.txt
生成app文件路徑
Products/Debug-iphoneos/xxx.app
這裡我們只關註Link Map File的符號表Symbols,這裡的順序就是Mach-O文件對應的順序,如果與xxx.order的順序一致,就表明改成功了
再次通過System Trace工具測試修改前後對比
優化前後對比,缺頁中斷明顯減少
獲取函數調用符號,採用Clang插樁可以直接hook到Objective-C方法、Swift方法、C函數、Block,可以不用區別對待
3.2 After Main優化
這部分是個大頭的優化項,實際場景需要我們根據自己的具體項目來分析,但大體遵循一些相同的思路
3.2.1 功能/方法優化
- 推遲&減少I/O操作
- 此處對項目after main後的啟動邏輯分析不涉及IO操作未做優化
- 控制線程數量
- 項目中啟動階段線程數量不多且必要,影響不大就未動,但根據各自的項目情況進行分析治理
- 啟動載入項治理
- 這裡主要是一些基建和三方/集團SDK初始化任務以及各業務組件工程的啟動載入項, 包括前面部分load函數的邏輯放到這裡的啟動器來進行調度管理。
- 我們可以把這部分做一個啟動器進行維護和監控,防劣化。
- 啟動器自註冊,註冊項包括啟動操作閉包,啟動執行優先順序,啟動操作是否後臺執行等可選項。
- 自註冊服務無非還是:”啟動項:啟動閉包 “ 這麼一個綁定實現,所以可以類似前面(class-protocol綁定)所講的思路,將這部分操作寫入到可執行文件的DATA段中,運行時再從DATA段取出數據進行相應的操作(調用函數),這樣也能夠覆蓋所有的啟動階段,例如main()之前的階段。
- 對項目分析後,將鍵盤初始化、地圖定位、意見反饋還有非首頁模塊初始化等非必要的啟動項降低優先順序延後時機執行。
- 串列->並行 同步->非同步
- 對於一些耗時操作非同步、並行操作,不阻塞主線程的執行
- 方法耗時統計分析
- 統計啟動過程業務代碼耗時並對耗時方法進行分析治理
- 高頻次方法調用
- 有些方法的單個耗時不高,但是頻繁調用就會顯現耗時,我們可以加記憶體緩存,當然了具體場景具體分析
- 利用閃屏頁的時間做一些首頁UI的預構建
- 項目中有啟動閃屏頁,還有第一次啟動彈框隱私頁這個間隙做一些首屏操作的前移
- 利用這一段時間來構建首頁UI了、首屏網路數據的預下載、緩存、啟動Flutter引擎等工作
3.2.2 首屏渲染優化
屏幕顯示遵循一套圖形渲染管線來完成最終的顯示工作:
1.Application階段(應用內):
Handle Events:
這個過程中會先處理點擊事件,這個過程中有可能會需要改變頁面的佈局和界面層次。
Commit Transaction:
此時 App 會通過 CPU 處理顯示內容的前置計算,比如佈局計算、圖片解碼等任務,之後將計算好的圖層進行打包發給 Render Server。(核心Core Animation負責)
Commit Transaction 這部分中主要進行的是:Layout、Display、Prepare、Commit 等四個具體的操作, 最後形成一條事務,通過 CA::Transaction::commit()提交渲染
- Layout:
構建視圖相關,layoutSubviews、addSubview 方法添加子視圖、AutoLayout根據 Layout Constraint 計算各個view的frame,文本計算(size)等。
layoutSubviews:在此階段會調用,但是滿足條件如frame,bounds,transform屬性改變、添加或者刪除view、顯式調用setNeedsLayout等
- Display:
繪製視圖:交給 Core Graphics 進行視圖的繪製,得到圖元 primitives 數據,註意不是點陣圖數據,點陣圖是GPU階段根據圖元組合而得。但是如果重寫了 drawRect: 方法,這個方法會直接調用 Core Graphics 繪製方法得到 bitmap 數據,同時系統會額外申請一塊記憶體,用於暫存繪製好的 bitmap,導致繪製過程從 GPU 轉移到了 CPU,這就導致了一定的效率損失。與此同時,這個過程會額外使用 CPU 和記憶體,因此需要高效繪製,否則容易造成 CPU 卡頓或者記憶體爆炸。
- Prepare:
Core Animation 額外的工作,主要是圖片解碼和轉換,儘量使用GPU支持的格式, Apple推薦JPG和PNG
譬如在UIImageView中展示圖片,會經歷如下過程: 載入、解碼、渲染 簡單說就是將普通的二進位數據 (存儲在dataBuffer 數據) 轉化成 RGB的數據(存儲在ImageBuffer), 這個被稱為圖像的解碼decode, 它有如下特點:
decode解碼過程是一個耗時過程, 並且是在CPU中完成的. 也就是我們這部分的prepare中完成。
解碼以後的RGB圖占用的記憶體大小隻與bitmap的像素格式(RGB32, RGB23, Gray8 …)和圖片寬高有關, 常見bitmap大小: 每個像素點大小 width height, 而與原來的壓縮格式PNG, JPG大小無關.
2.GPU渲染階段:
主要是一些圖元的操作、幾何處理、光柵化、像素處理等,不一一細說,這部分操作我們能做的工作畢竟是有限的
所以,我們大致可以做的優化點如下:
- 預渲染\非同步渲染:
- 大致思路就是在子線程將所有的視圖繪製成一張點陣圖,然後回到主線程賦值給 layer的 contents
- 圖片非同步解碼:
- 註意這裡並不是將圖片載入放到非同步線程中在非同步線程中生成一個 UIImage或者是 CGImage然後再主線程中設置給 UIImageView,而是在子線程中先將圖片繪製到CGBitmapContext,然後從bitmap 直接創建圖片,常用的圖片框架都類似。
- 按需載入
- 不需要或者非首屏較為複雜的視圖延後載入,減少首屏圖層的層級
- 其他:
- 離屏渲染 儘量減少透明視圖個數等等一些細節也要註意
4 成果
經過一些列優化,還是有一些速度的提升,雖然工程還不是大型工程,不過及早持續優化可以防止業務迭代到一定程度難以下手的地步。
iPhone 7p多次均值
優化前
[函數名:+[LaunchTrace mark]_block_invoke][行號:54]—————App啟動————-耗時:pre-main:4.147820
[函數名:+[LaunchTrace mark]_block_invoke][行號:55]—————App啟動————-耗時:didfinish:0.654687
[函數名:+[LaunchTrace mark]_block_invoke][行號:56]—————App啟動————-耗時:total:4.802507
優化後
[函數名:+[LaunchTrace mark]_block_invoke][行號:54]—————App啟動————-耗時:pre-main:3.047820
[函數名:+[LaunchTrace mark]_block_invoke][行號:55]—————App啟動————-耗時:didfinish:0.254687
[函數名:+[LaunchTrace mark]_block_invoke][行號:56]—————App啟動————-耗時:total:3.302507
pre main階段下降平均大概20%, after main階段平均下降大概60%, 總體均值下降30%.
當然目前還處於未上線版本,後續上線後藉助監控平臺藉助線上更多數據,更多機型來更好的的進行分析優化
5 總結
啟動速度瓶頸非一日之寒,需要持續的進行優化,這當中也少不了監控體系的持續建設和優化,日常線上數據的分析,防止業務快速迭代中的啟動速度劣化,對動態庫的引入、新增 +load 和靜態初始化、啟動任務的新增都要加入Code Review機制,優化啟動架構為啟動這些基礎性能保駕護航。
作者:京東物流 彭欣
來源:京東雲開發者社區 自猿其說Tech