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

歡迎訪問個人博客原文html

當 App 中的業務模塊愈來愈多、愈來愈複雜,集成了更多的三方庫,App 啓動也會愈來愈慢,所以咱們但願能在業務擴張的同時,保持較優的啓動速度,給用戶帶來良好的使用體驗。ios

熱啓動與冷啓動

當用戶按下 home 鍵,iOS App 不會馬上被 kill,而是存活一段時間,這段時間裏用戶再打開 App,App 基本上不須要作什麼,就能還原到退到後臺前的狀態。咱們把 App 進程還在系統中,無需開啓新進程的啓動過程稱爲熱啓動git

冷啓動則是指 App 不在系統進程中,好比設備重啓後,或是手動殺死 App 進程,又或是 App 長時間未打開過,用戶再點擊啓動 App 的過程,這時須要建立一個新進程分配給 App。咱們能夠將冷啓動看做一次完整的 App 啓動過程,本文討論的就是冷啓動的優化。github

冷啓動概要

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

  • 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 函數內部執行的。bash

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 都是以 image 爲單位進行加載的。images 類型包括:

  • executable:應用的二進制可執行文件;
  • dylib:動態連接庫;
  • bundle:資源文件,屬於不能被連接的 dylib,只能在運行時經過 dlopen() 加載。

framework

framework 能夠是動態庫,也是靜態庫,是一個包含 dylib、bundle 和頭文件的文件夾。

啓動過程分析與優化

啓動一個應用時,系統會經過 fork() 方法來新建立一個進程,而後執行鏡像經過 exec() 來替換爲另外一個可執行程序,而後執行以下操做:

  1. 把可執行文件加載到內存空間,從可執行文件中可以分析出 dyld 的路徑;
  2. 把 dyld 加載到內存;
  3. dyld 從可執行文件的依賴開始,遞歸加載全部的依賴動態連接庫 dylib 並進行相應的初始化操做。

結合上面 pre-main 打印的結果,咱們能夠大體瞭解整個啓動過程以下圖所示:

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

啓動優化

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 中的模塊愈來愈多,冷啓動的時間也必不可少地增長。冷啓動本就是一個比較複雜的流程,它的優化沒有固定的公式,咱們須要結合業務,配合一些性能分析工具和線上監控日誌,有耐心、多維度地進行分析和解決。


參考連接:

相關文章
相關標籤/搜索