深刻探索 iOS 啓動速度優化

 

 

介紹

App 的啓動時間是體現其性能優劣的一個重要指標,啓動時間越快用戶的等待時間就越短,提高用戶體驗感,大廠應用甚至會作到「 毫秒必究 」。git

咱們將 App 啓動方式分爲:github

名稱 說明
冷啓動 App 啓動時,應用進程不在系統中(初次打開或程序被殺死),須要系統分配新的進程來啓動應用。
熱啓動 App 退回後臺後,對應的進程還在系統中,啓動則將應用返回前臺展現。

本篇文章主要針對冷啓動方式進行優化分析,介紹經常使用的檢測工具及優化方法。面試

冷啓動流程

Apple 官方的《WWDC Optimizing App Startup Time》 將 iOS 應用的啓動可分爲 pre-main 階段和 main 兩個階段,最佳的啓動速度是400ms之內,最慢不得大於20s,不然會被系統進程殺死(最低配置設備)。swift

爲了更好的區分,筆者將整個啓動流程分爲三個階段, App總啓動流程 = pre-main + main函數代理(didFinishLaunchingWithOptions)+ 首屏渲染(viewDidAppear),後兩個階段都屬於 main函數 執行階段。緩存

pre-main 執行內容

此時對應的 App 頁面是閃屏頁的展現。性能優化

  • 加載可執行文件網絡

    加載 Mach-O 格式文件,既 App 中全部類編譯後生成的格式爲 .o 的目標文件集合。數據結構

  • 加載動態庫架構

    dyld 加載 dylib 會完成以下步驟:app

    1. 分析 App 依賴的全部 dylib。
    2. 找到 dylib 對應的 Mach-O 文件。
    3. 打開、讀取這些 Mach-O 文件,並驗證其有效性。
    4. 在系統內核中註冊代碼簽名。
    5. 對 dylib 的每個 segment 調用 mmap()。

    系統依賴的動態庫因爲被優化過,能夠較快的加載完成,而開發者引入的動態庫須要耗時較久。

  • Rebase和Bind操做

    因爲使用了ASLR 技術,在 dylib 加載過程當中,須要計算指針偏移獲得正確的資源地址。 Rebase 將鏡像讀入內存,修正鏡像內部的指針,消耗 IO 性能;Bind 查詢符號表,進行外部鏡像的綁定,須要大量 CPU 計算。

  • Objc setup

    進行 Objc 的初始化,包括註冊 Objc 類、檢測 selector 惟一性、插入分類方法等。

  • Initializers

    往應用的堆棧中寫入內容,包括執行 +load 方法、調用 C/C++ 中的構造器函數(用 attribute((constructor)) 修飾的函數)、建立非基本類型的 C++ 靜態全局變量等。

main函數代理執行內容

從 main() 函數開始執行到 didFinishLaunchingWithOptions 方法執行結束的耗時。一般會在這個過程當中進行各類工具(監控工具、推送、定位等)初始化、權限申請、判斷版本、全局配置等。

首屏渲染執行內容

首屏 UI 構建階段,須要 CPU 計算佈局並由 GPU 完成渲染,若是數據來源於網絡,還需進行網絡請求。

優化方案

pre-main階段

檢測方法

得到 main() 方法執行前的耗時比較簡單,經過 Xcode 自帶的測量方法既能夠。將 Xcode 中 Product -> Scheme -> Edit scheme -> Run -> Environment Variables 將環境變量 DYLD_PRINT_STATISTICS 或 DYLD_PRINT_STATISTICS_DETAILS 設爲 1 便可得到執行每項耗時:

// example 
// DYLD_PRINT_STATISTICS
Total pre-main time: 383.50 milliseconds (100.0%)
         dylib loading time: 254.02 milliseconds (66.2%)
        rebase/binding time:  20.88 milliseconds (5.4%)
            ObjC setup time:  29.33 milliseconds (7.6%)
           initializer time:  79.15 milliseconds (20.6%)
           slowest intializers :
             libSystem.B.dylib :   8.06 milliseconds (2.1%)
    libMainThreadChecker.dylib :  22.19 milliseconds (5.7%)
                  AFNetworking :  11.66 milliseconds (3.0%)
                  TestDemo :  38.19 milliseconds (9.9%)

// DYLD_PRINT_STATISTICS_DETAILS
  total time: 614.71 milliseconds (100.0%)
  total images loaded:  401 (380 from dyld shared cache)
  total segments mapped: 77, into 1785 pages with 252 pages pre-fetched
  total images loading time: 337.21 milliseconds (54.8%)
  total load time in ObjC:  12.81 milliseconds (2.0%)
  total debugger pause time: 307.99 milliseconds (50.1%)
  total dtrace DOF registration time:   0.07 milliseconds (0.0%)
  total rebase fixups:  152,438
  total rebase fixups time:   2.23 milliseconds (0.3%)
  total binding fixups: 496,288
  total binding fixups time: 218.03 milliseconds (35.4%)
  total weak binding fixups time:   0.75 milliseconds (0.1%)
  total redo shared cached bindings time: 221.37 milliseconds (36.0%)
  total bindings lazily fixed up: 0 of 0
  total time in initializers and ObjC +load:  43.56 milliseconds (7.0%)
                         libSystem.B.dylib :   3.67 milliseconds (0.5%)
               libBacktraceRecording.dylib :   3.41 milliseconds (0.5%)
                libMainThreadChecker.dylib :  21.19 milliseconds (3.4%)
                              AFNetworking :  10.89 milliseconds (1.7%)
                              TestDemo :   2.37 milliseconds (0.3%)
total symbol trie searches:    1267474
total symbol table binary searches:    0
total images defining weak symbols:  34
total images using weak symbols:  97

優化點

  • 合併動態庫,並減小使用 Embedded Framework,即非系統建立的動態 Framework,若是對包體積要求不嚴格還可使用靜態庫代替。

  • 刪除無用代碼(未使用的靜態變量、類和方法等)並抽取重複代碼。

  • 避免在 +load 執行方法,使用 +initialize 代替。

  • 避免使用 attribute((constructor)),可將要實現的內容放在初始化方法中配合 dispatch_once 使用。

  • 減小非基本類型的 C++ 靜態全局變量的個數。(由於這類全局變量一般是類或者結構體,若是在構造函數中有繁重的工做,就會拖慢啓動速度)

main函數代理階段

檢測方法

  • 手動插入代碼計算耗時

    在 man() 函數開始執行時就開始時間:

    CFAbsoluteTime StartTime;  //  記錄全局變量
    int main(int argc, char * argv[]) {
        @autoreleasepool {
            StartTime = CFAbsoluteTimeGetCurrent();
            return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
        }
    }

    再在 didFinishLaunchingWithOptions 返回以前獲取結束時間,二者的差值即爲該階段的耗時:

    extern CFAbsoluteTime startTime; // 申明全局變量
    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
        //...
        //...
    
        double launchTime = (CFAbsoluteTimeGetCurrent() - startTime);
        return YES;
    }

    經過這種手動埋點的方式也能夠對每一個函數進行埋點獲取耗時,但當函數多時也須要不小的工做量,且後續上線還須要移除代碼,不可複用。

  • Time Profiler

    Xcode自帶的工具,原理是定時抓取線程的堆棧信息,經過統計比較時間間隔之間的堆棧狀態,計算一段時間內各個方法的近似耗時。精確度取決於設置的定時間隔。

    經過 Xcode → Open Developer Tool → Instruments → Time Profiler 打開工具,注意,需將工程中 Debug Information Format 的 Debug 值改成 DWARF with dSYM File,不然只能看到一堆線程沒法定位到函數。

 

經過雙擊具體函數能夠跳轉到對應代碼處,另外能夠將 Call Tree 的 Seperate by Thread 和 Hide System Libraries 勾選上,方便查看。

 

正常Time Profiler是每1ms採樣一次, 默認只採集全部在運行線程的調用棧,最後以統計學的方式彙總。因此會沒法統計到耗時太短的函數和休眠的線程,好比下圖中的5次採樣中,method3都沒有采樣到,因此最後聚合到的棧裏就看不到method3。

 

咱們能夠將 File -> Recording Options 中的配置調高,便可獲取更精確的調用棧。

 
  • System Trace

    有時候當主線程被其餘線程阻塞時,沒法經過 Time Profiler 一眼看出,咱們還可使用 System Trace,例如咱們故意在 dyld 連接動態庫後的回調裏休眠10ms:

    static void add(const struct mach_header* header, intptr_t imp) {
        usleep(10000);
    }
    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
    {
        dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            _dyld_register_func_for_add_image(add);
        });
      ....
    }
 

能夠看到整個記錄過程耗時7s,但 Time Profiler 上只顯示了1.17s,且看到啓動後有一段時間是空白的。這時經過 System Trace 查看各個線程的具體狀態。

 

能夠看到主線程有段時間被阻塞住了,存在一個互斥鎖,切換到 Events:Thread State觀察阻塞的下一條指令,發現0x5d39c 執行完成釋放鎖後,主線程纔開始執行。

 

接着咱們觀察 0x5d39c 線程,發如今主線程阻塞的這段時間,該線程執行了屢次10ms的 sleep 操做,到此就找到了主線程被子線程阻塞致使啓動緩慢的緣由。

 

從此,當咱們想更清楚的看到各個線程之間的調度就可使用 System Trace,但仍是建議優先使用 Time Profiler,使用簡單易懂,排查問題效率更高。

  • App Launch

    Xcode11 以後新出的工具,功能至關於 Time Profiler 和 System Trace 的整合。

  • Hook objc_msgSend

    能夠對 objc_msgSend 進行 Hook 獲取每一個函數的具體耗時,優化在啓動階段耗時多的函數或將其置後調用。實現方法可查看 經過objc_msgSend實現iOS方法耗時監控

優化點

  • 經過檢測工具找到耗時多的函數,拆分其功能,將優先級低的功能延後執行。
  • 梳理業務邏輯,把能夠延遲執行的邏輯,作延遲執行處理。好比檢查新版本、註冊推送通知等邏輯。
  • 梳理各個二方/三方庫,找到能夠延遲加載的庫,作延遲加載處理,好比放到首頁控制器的viewDidAppear方法後。

首屏渲染階段

檢測方法

記錄首屏 viewDidLoad 開始時間和viewDidAppear 開始時間,二者的差值即爲整個首屏渲染耗時,若是要得到具體每一個步驟耗時,則可同main函數代理階段使用 Time Profiler 或 Hook objc_msgSend

優化點

  • 使用簡單的廣告頁做爲過渡,將首頁的計算操做及網絡請求放在廣告頁展現時異步進行。
  • 涉及活動需變動頁面展現時(例如雙十一),提早下發數據緩存。
  • 首頁控制器用純代碼方式來構建,而不是 xib/Storyboard,避免佈局轉換耗時。
  • 避免在主線程進行大量的計算,將與首屏無關的計算內容放在頁面展現後進行,縮短 CPU 計算時間。
  • 避免使用大圖片,減小視圖數量及層級,減輕 GPU 的負擔。
  • 作好網絡請求接口優化(DNS 策略等),只請求與首屏相關數據。
  • 本地緩存首屏數據,待渲染完成後再去請求新數據。

其它優化

二進制重排

去年年末二進制重排的概念被宇宙廠帶火了起來,我的以爲噱頭大於效果,詳細內容可參考文章

總結

啓動優化不該該是一次性的,最好的方案也不是在出現纔去解決,而應該包括:

  • 解決現存的問題
  • 後續開發的管控
  • 完整的監控體系

只有在開發的前中後同時介入,才能保證 App 的出品質量,畢竟開發是前人挖坑給後人填坑的過程 😂。

部分工具

  • Xcode自帶工具 Time Profiler 和 System Trace

  • Xcode11 以後新增工具 App Launch

  • Static Initializer Tracing

  • AppCode 的 Inspect Code 掃描無用代碼

  • fui 掃描無用的類

  • TinyPNG 壓縮圖片,減小 IO 操做量

參考資料

推薦👇:

  • 020 持續更新,精品小圈子每日都有新內容,乾貨濃度極高。

  • 結實人脈、討論技術 你想要的這裏都有!

  • 搶先入羣,跑贏同齡人!(入羣無需任何費用)

  • (直接搜索羣號:789143298,快速入羣)
  • 點擊此處,與iOS開發大牛一塊兒交流學習

申請即送:

  • BAT大廠面試題、獨家面試工具包,

  • 資料免費領取,包括 數據結構、底層進階、圖形視覺、音視頻、架構設計、逆向安防、RxSwift、flutter,

     

做者:SimonYe
連接:https://juejin.im/post/5e950106f265da47b725eaff

相關文章
相關標籤/搜索