如何實現 iOS App 的冷啟動優化

FiTeen發表於2020-03-09

歡迎訪問我的部落格原文

當 App 中的業務模組越來越多、越來越複雜,整合了更多的三方庫,App 啟動也會越來越慢,因此我們希望能在業務擴張的同時,保持較優的啟動速度,給使用者帶來良好的使用體驗。

熱啟動與冷啟動

當使用者按下 home 鍵,iOS App 不會立刻被 kill,而是存活一段時間,這段時間裡使用者再開啟 App,App 基本上不需要做什麼,就能還原到退到後臺前的狀態。我們把 App 程式還在系統中,無需開啟新程式的啟動過程稱為熱啟動

冷啟動則是指 App 不在系統程式中,比如裝置重啟後,或是手動殺死 App 程式,又或是 App 長時間未開啟過,使用者再點選啟動 App 的過程,這時需要建立一個新程式分配給 App。我們可以將冷啟動看作一次完整的 App 啟動過程,本文討論的就是冷啟動的優化。

冷啟動概要

WWDC 2016 中首次出現了 App 啟動優化的話題,其中提到:

  • App 啟動最佳速度是400ms以內,因為從點選 App 圖示啟動,然後 Launch Screen 出現再消失的時間就是400ms;
  • App 啟動最慢不得大於20s,否則程式會被系統殺死;(啟動時間最好以 App 所支援的最低配置裝置為準。)

冷啟動的整個過程是指從使用者喚起 App 開始到 AppDelegate 中的 didFinishLaunchingWithOptions 方法執行完畢為止,並以執行 main() 函式的時機為分界點,分為 pre-mainmain() 兩個階段。

也有一種說法是將整個冷啟動階段以主 UI 框架的 viewDidAppear 函式執行完畢才算結束。這兩種說法都可以,前者的界定範圍是 App 啟動和初始化完畢,後者的界定範圍是使用者視角的啟動完畢,也就是首屏已經被載入出來。

注意:這裡很多文章都會把第二個階段描述為 main 函式之後,個人認為這種說法不是很好,容易讓人誤解。要知道 main 函式在 App 執行過程中是不會退出的,無論是 AppDelegate 中的 didFinishLaunchingWithOptions 方法還是 ViewController 中的viewDidAppear 方法,都還是在 main 函式內部執行的。

pre-main 階段

pre-main 階段指的是從使用者喚起 App 到 main() 函式執行之前的過程。

檢視階段耗時

我們可以在 Xcode 中配置環境變數 DYLD_PRINT_STATISTICS 為 1(Edit Scheme → Run → Arguments → Environment Variables → +)。

設定環境變數

這時在 iOS 10 以上系統中執行一個 TestDemo,pre-main 階段的啟動時間會在控制檯中列印出來。

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%)

複製程式碼

如果要更詳細的資訊,就設定 DYLD_PRINT_STATISTICS_DETAILS 為 1。

  total time: 1.6 seconds (100.0%)
  total images loaded:  388 (381 from dyld shared cache)
  total segments mapped: 23, into 413 pages
  total images loading time: 805.78 milliseconds (48.6%)
  total load time in ObjC: 152.74 milliseconds (9.2%)
  total debugger pause time: 780.26 milliseconds (47.1%)
  total dtrace DOF registration time:   0.00 milliseconds (0.0%)
  total rebase fixups:  54,265
  total rebase fixups time:  20.77 milliseconds (1.2%)
  total binding fixups: 527,211
  total binding fixups time: 513.54 milliseconds (31.0%)
  total weak binding fixups time:   0.31 milliseconds (0.0%)
  total redo shared cached bindings time: 521.93 milliseconds (31.5%)
  total bindings lazily fixed up: 0 of 0
  total time in initializers and ObjC +load: 163.24 milliseconds (9.8%)
                         libSystem.B.dylib :   7.98 milliseconds (0.4%)
               libBacktraceRecording.dylib :  13.53 milliseconds (0.8%)
                libMainThreadChecker.dylib :  41.11 milliseconds (2.4%)
              libViewDebuggerSupport.dylib :   6.68 milliseconds (0.4%)
                                  TestDemo :  88.76 milliseconds (5.3%)
total symbol trie searches:    1306942
total symbol table binary searches:    0
total images defining weak symbols:  41
total images using weak symbols:  105
複製程式碼

這裡統計到的啟動耗時出現一定波動是正常的,無須過分在意。

理論知識

為了更準確地瞭解 App 啟動的流程,我們先熟悉一下幾個概念。

Mach-O

Mach-O(Mach Object File Format)是一種用於記錄可執行檔案、物件程式碼、共享庫、動態載入程式碼和記憶體轉儲的檔案格式。App 編譯生成的二進位制可執行檔案就是 Mach-O 格式的,iOS 工程所有的類編譯後會生成對應的目標檔案 .o 檔案,而這個可執行檔案就是這些 .o 檔案的集合。

在 Xcode 的控制檯輸入以下命令,可以列印出執行時所有載入進應用程式的 Mach-O 檔案。

image list -o -f
複製程式碼

Mach-O 檔案主要由三部分組成:

  • Mach header:描述 Mach-O 的 CPU 架構、檔案型別以及載入命令等;
  • Load commands:描述了檔案中資料的具體組織結構,不同的資料型別使用不同的載入命令;
  • Data:Data 中的每個段(segment)的資料都儲存在這裡,每個段都有一個或多個 Section,它們存放了具體的資料與程式碼,主要包含這三種型別:
    • __TEXT 包含 Mach header,被執行的程式碼和只讀常量(如C 字串)。只讀可執行(r-x)。
    • __DATA 包含全域性變數,靜態變數等。可讀寫(rw-)。
    • __LINKEDIT 包含了載入程式的後設資料,比如函式的名稱和地址。只讀(r–-)。

dylib

dylib 也是一種 Mach-O 格式的檔案,字尾名為 .dylib 的檔案就是動態庫(也叫動態連結庫)。動態庫是執行時載入的,可以被多個 App 的程式共用。

如果想知道 TestDemo 中依賴的所有動態庫,可以通過下面的指令實現:

otool -L /TestDemo.app/TestDemo
複製程式碼

動態連結庫分為系統 dylib內嵌 dylib(embed dylib,即開發者手動引入的動態庫)。系統 dylib 有:

  • iOS 中用到的所有系統 framework,比如 UIKit、Foundation;
  • 系統級別的 libSystem(如 libdispatch(GCD) 和 libsystem_blocks(Block));
  • 載入 OC runtime 方法的 libobjc;
  • ……

dyld

dyld(Dynamic Link Editor):動態連結器,其本質也是 Mach-O 檔案,一個專門用來載入 dylib 檔案的庫。 dyld 位於 /usr/lib/dyld,可以在 mac 和越獄機中找到。dyld 會將 App 依賴的動態庫和 App 檔案載入到記憶體後執行。

dyld shared cache

dyld shared cache 就是動態庫共享快取。當需要載入的動態庫非常多時,相互依賴的符號也更多了,為了節省解析處理符號的時間,OS X 和 iOS 上的動態連結器使用了共享快取。OS X 的共享快取位於 /private/var/db/dyld/,iOS 的則在 /System/Library/Caches/com.apple.dyld/

當載入一個 Mach-O 檔案時,dyld 首先會檢查是否存在於共享快取,存在就直接取出使用。每一個程式都會把這個共享快取對映到了自己的地址空間中。這種方法大大優化了 OS X 和 iOS 上程式的啟動時間。

images

images 在這裡不是指圖片,而是映象。每個 App 都是以 images 為單位進行載入的。images 型別包括:

  • executable:應用的二進位制可執行檔案;
  • dylib:動態連結庫;
  • bundle:資原始檔,屬於不能被連結的 dylib,只能在執行時通過 dlopen() 載入。

framework

framework 可以是動態庫,也是靜態庫,是一個包含 dylib、bundle 和標頭檔案的資料夾。

啟動過程分析與優化

啟動一個應用時,系統會通過 fork() 方法來新建立一個程式,然後執行映象通過 exec() 來替換為另一個可執行程式,然後執行如下操作:

  1. 把可執行檔案載入到記憶體空間,從可執行檔案中能夠分析出 dyld 的路徑;
  2. 把 dyld 載入到記憶體;
  3. dyld 從可執行檔案的依賴開始,遞迴載入所有的依賴動態連結庫 dylib 並進行相應的初始化操作。

結合上面 pre-main 列印的結果,我們可以大致瞭解整個啟動過程如下圖所示:

如何實現 iOS App 的冷啟動優化

Load Dylibs

這一步,指的是動態庫載入。在此階段,dyld 會:

  1. 分析 App 依賴的所有 dylib;
  2. 找到 dylib 對應的 Mach-O 檔案;
  3. 開啟、讀取這些 Mach-O 檔案,並驗證其有效性;
  4. 在系統核心中註冊程式碼簽名;
  5. 對 dylib 的每一個 segment 呼叫 mmap()

一般情況下,iOS App 需要載入 100-400 個 dylibs。這些動態庫包括系統的,也包括開發者手動引入的。其中大部分 dylib 都是系統庫,系統已經做了優化,因此開發者更應關心自己手動整合的內嵌 dylib,載入它們時效能開銷較大。

App 中依賴的 dylib 越少越好,Apple 官方建議儘量將內嵌 dylib 的個數維持在6個以內。

優化方案

  • 儘量不使用內嵌 dylib;
  • 合併已有內嵌 dylib;
  • 檢查 framework 的 optionalrequired 設定,如果 framework 在當前的 App 支援的 iOS 系統版本中都存在,就設為 required,因為設為 optional 會有額外的檢查;
  • 使用靜態庫作為代替;(不過靜態庫會在編譯期被打進可執行檔案,造成可執行檔案體積增大,兩者各有利弊,開發者自行權衡。)
  • 懶載入 dylib。(但使用 dlopen() 對效能會產生影響,因為 App 啟動時是原本是單執行緒執行,系統會取消加鎖,但 dlopen() 開啟了多執行緒,系統不得不加鎖,這樣不僅會使效能降低,可能還會造成死鎖及未知的後果,不是很推薦這種做法。)

Rebase/Binding

這一步,做的是指標重定位

在 dylib 的載入過程中,系統為了安全考慮,引入了 ASLR(Address Space Layout Randomization)技術和程式碼簽名。由於 ASLR 的存在,映象會在新的隨機地址(actual_address)上載入,和之前指標指向的地址(preferred_address)會有一個偏差(slide,slide=actual_address-preferred_address),因此 dyld 需要修正這個偏差,指向正確的地址。具體通過這兩步實現:

第一步:Rebase,在 image 內部調整指標的指向。將 image 讀入記憶體,並以 page 為單位進行加密驗證,保證不會被篡改,效能消耗主要在 IO。

第二步:Binding,符號繫結。將指標指向 image 外部的內容。查詢符號表,設定指向映象外部的指標,效能消耗主要在 CPU 計算。

通過以下命令可以檢視 rebase 和 bind 等資訊:

xcrun dyldinfo -rebase -bind -lazy_bind TestDemo.app/TestDemo
複製程式碼

通過 LC_DYLD_INFO_ONLY 可以檢視各種資訊的偏移量和大小。如果想要更方便直觀地檢視,推薦使用 MachOView 工具。

指標數量越少,指標修復的耗時也就越少。所以,優化該階段的關鍵就是減少 __DATA 段中的指標數量。

優化方案

  • 減少 ObjC 類(class)、方法(selector)、分類(category)的數量,比如合併一些功能,刪除無效的類、方法和分類等(可以藉助 AppCode 的 Inspect Code 功能進行程式碼瘦身);
  • 減少 C++ 虛擬函式;(虛擬函式會建立 vtable,這也會在 __DATA 段中建立結構。)
  • 多用 Swift Structs。(因為 Swift Structs 是靜態分發的,它的結構內部做了優化,符號數量更少。)

ObjC Setup

完成 Rebase 和 Bind 之後,通知 runtime 去做一些程式碼執行時需要做的事情:

  • dyld 會註冊所有宣告過的 ObjC 類;
  • 將分類插入到類的方法列表中;
  • 檢查每個 selector 的唯一性。

優化方案

Rebase/Binding 階段優化好了,這一步的耗時也會相應減少。

Initializers

Rebase 和 Binding 屬於靜態調整(fix-up),修改的是 __DATA 段中的內容,而這裡則開始動態調整,往堆和棧中寫入內容。具體工作有:

  • 呼叫每個 Objc 類和分類中的 +load 方法;
  • 呼叫 C/C++ 中的構造器函式(用 attribute((constructor)) 修飾的函式);
  • 建立非基本型別的 C++ 靜態全域性變數。

優化方案

  • 儘量避免在類的 +load 方法中初始化,可以推遲到 +initiailize 中進行;(因為在一個 +load 方法中進行執行時方法替換操作會帶來 4ms 的消耗)
  • 避免使用 __atribute__((constructor)) 將方法顯式標記為初始化器,而是讓初始化方法呼叫時再執行。比如用 dispatch_once()pthread_once()std::once(),相當於在第一次使用時才初始化,推遲了一部分工作耗時。:
  • 減少非基本型別的 C++ 靜態全域性變數的個數。(因為這類全域性變數通常是類或者結構體,如果在建構函式中有繁重的工作,就會拖慢啟動速度)

總結一下 pre-main 階段可行的優化方案:

  • 重新梳理架構,減少不必要的內建動態庫數量
  • 進行程式碼瘦身,合併或刪除無效的ObjC類、Category、方法、C++ 靜態全域性變數等
  • 將不必須在 +load 方法中執行的任務延遲到 +initialize
  • 減少 C++ 虛擬函式

main() 階段

對於 main() 階段,主要測量的就是從 main() 函式開始執行到 didFinishLaunchingWithOptions 方法執行結束的耗時。

檢視階段耗時

這裡介紹兩種檢視 main() 階段耗時的方法。

方法一:手動插入程式碼,進行耗時計算。

// 第一步:在 main() 函式裡用變數 MainStartTime 記錄當前時間
CFAbsoluteTime MainStartTime;
int main(int argc, char * argv[]) {
    MainStartTime = CFAbsoluteTimeGetCurrent();
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

// 第二步:在 AppDelegate.m 檔案中用 extern 宣告全域性變數 MainStartTime
extern CFAbsoluteTime MainStartTime;

// 第三步:在 didFinishLaunchingWithOptions 方法結束前,再獲取一下當前時間,與 MainStartTime 的差值就是 main() 函式階段的耗時
double mainLaunchTime = (CFAbsoluteTimeGetCurrent() - MainStartTime);
NSLog(@"main() 階段耗時:%.2fms", mainLaunchTime * 1000);
複製程式碼

方法二:藉助 Instruments 的 Time Profiler 工具檢視耗時。

開啟方式為:Xcode → Open Developer Tool → Instruments → Time Profiler

Time Profiler

操作步驟:

  1. 配置 Scheme。點選 Edit Scheme 找到 Profile 下的 Build Configuration,設定為 Debug

  2. 配置 PROJECT。點選 PROJECT,在 Build Settings 中找到 Build Options 選項裡的 Debug Information Format,把 Debug 對應的值改為 DWARF with dSYM File

  3. 啟動 Time Profiler,點選左上角紅色圓形按鈕開始檢測,然後就可以看到執行程式碼的完整路徑和對應的耗時。

為了方面檢視應用程式中實際程式碼的執行耗時和程式碼路徑實際所在的位置,可以勾選上 Call Tree 中的 Separate ThreadHide System Libraries

如何實現 iOS App 的冷啟動優化

啟動優化

main() 被呼叫之後,didFinishLaunchingWithOptions 階段,App 會進行必要的初始化操作,而 viewDidAppear 執行結束之前則是做了首頁內容的載入和顯示。

關於 App 的初始化,除了統計、日誌這種須要在 App 一啟動就配置的事件,有一些配置也可以考慮延遲載入。如果你在 didFinishLaunchingWithOptions 中同時也涉及到了首屏的載入,那麼可以考慮從這些角度優化:

  • 用純程式碼的方式,而不是 xib/Storyboard,來載入首頁檢視
  • 延遲暫時不需要的二方/三方庫載入;
  • 延遲執行部分業務邏輯和 UI 配置;
  • 延遲載入/懶載入部分檢視;
  • 避免首屏載入時大量的本地/網路資料讀取;
  • 在 release 包中移除 NSLog 列印;
  • 在視覺可接受的範圍內,壓縮頁面中的圖片大小;
  • ……

如果首屏為 H5 頁面,針對它的優化,參考 VasSonic 的原理,可以從這幾個角度入手:

  • 終端耗時

    • webView 預載入:在 App 啟動時期預先載入了一次 webView,通過建立空的 webView,預先啟動 Web 執行緒,完成一些全域性性的初始化工作,對二次建立 webView 能有數百毫秒的提升。
  • 頁面耗時(靜態頁面)

    • 靜態直出:服務端拉取資料後通過 Node.js 進行渲染,生成包含首屏資料的 HTML 檔案,釋出到 CDN 上,webView 直接從 CDN 上獲取;
    • 離線預推:使用離線包。
  • 頁面耗時(經常需要動態更新的頁面)

    • 並行載入:WebView 的開啟和資源的請求並行;
    • 動態快取:動態頁面快取在客戶端,使用者下次開啟的時候先開啟快取頁面,然後再重新整理;
    • 動靜分離:將頁面分為靜態模板和動態資料,根據不同的啟動場景進行不同的重新整理方案;
    • 預載入:提前拉取需要的增量更新資料。

小結

隨著業務的增長,App 中的模組越來越多,冷啟動的時間也必不可少地增加。冷啟動本就是一個比較複雜的流程,它的優化沒有固定的公式,我們需要結合業務,配合一些效能分析工具和線上監控日誌,有耐心、多維度地進行分析和解決。


參考連結:

相關文章