我是如何讓微博綠洲的啟動速度提升30%的

iOS面試簡歷專員發表於2020-05-09
我是如何讓微博綠洲的啟動速度提升30%的

綠洲iOS研發工程師,綠洲ID:收納箱KeepFit。

0. 序言

啟動是App給使用者的第一印象,對使用者體驗至關重要。試想一個App需要啟動5s以上,你還想用它麼?

最初的工程肯定是沒有這些問題的,但隨著業務需求不斷豐富,程式碼越來越多。如果放任不管的話,啟動時間會不斷上漲,最後讓人無法接受。

本文從最佳化原理出發,介紹了我是如何透過修改庫的型別和Clang插樁找到啟動所需符號,然後修改編譯引數完成二進位制檔案的重新排布提升應用的啟動速度的。

下面我們先上結論:

  • 最佳化前:

    Total pre-main time: 1.2 seconds (100.0%)
             dylib loading time: 567.72 milliseconds (45.5%)
            rebase/binding time: 105.14 milliseconds (8.4%)
                ObjC setup time:  40.01 milliseconds (3.2%)
               initializer time: 532.47 milliseconds (42.7%)
               slowest intializers :
                 libSystem.B.dylib :   4.70 milliseconds (0.3%)
              libglInterpose.dylib : 295.89 milliseconds (23.7%)
                      AFNetworking :  48.75 milliseconds (3.9%)
                             Oasis : 285.94 milliseconds (22.9%)
  • 最佳化後

    Total pre-main time: 822.34 milliseconds (100.0%)
             dylib loading time: 196.71 milliseconds (23.9%)
            rebase/binding time: 104.95 milliseconds (12.7%)
                ObjC setup time:  31.14 milliseconds (3.7%)
               initializer time: 489.53 milliseconds (59.5%)
               slowest intializers :
                 libSystem.B.dylib :   4.65 milliseconds (0.5%)
              libglInterpose.dylib : 230.19 milliseconds (27.9%)
                      AFNetworking :  41.60 milliseconds (5.0%)
                             Oasis : 335.84 milliseconds (40.8%)

透過 staticlib最佳化二進位制重排兩項技術,我成功將綠洲的 pre-main時間從 1.2s降到了大約 0.82s,提升了大約 31.6%

我是如何讓微博綠洲的啟動速度提升30%的

兩臺手機都是iPhone 11 Pro,右邊是最佳化後的效果。(原諒我右邊點開還慢了一點?)

1. 動態庫轉靜態庫

蘋果建議將應用程式的總啟動時間設定在400毫秒以下,並且我們必須在20秒之內完成啟動,否則系統會殺死我們的應用程式。我們可以儘量最佳化應用 main函式到 didFinishLaunchingWithOptions的時間,但如何除錯在呼叫程式碼之前發生的啟動速度慢的情況呢?

1.1 Pre-main時間的檢視

在系統執行應用程式的 main函式並呼叫應用程式委託函式( applicationWillFinishLaunching)之前,會發生很多事情。我們可以將 DYLD_PRINT_STATISTICS環境變數新增到專案 scheme中。

我是如何讓微博綠洲的啟動速度提升30%的

執行一下,我們可以看到控制檯的輸出:

Total pre-main time: 1.2 seconds (100.0%)
         dylib loading time: 567.72 milliseconds (45.5%)
        rebase/binding time: 105.14 milliseconds (8.4%)
            ObjC setup time:  40.01 milliseconds (3.2%)
           initializer time: 532.47 milliseconds (42.7%)
           slowest intializers :
             libSystem.B.dylib :   4.70 milliseconds (0.3%)
          libglInterpose.dylib : 295.89 milliseconds (23.7%)
                  AFNetworking :  48.75 milliseconds (3.9%)
                         Oasis : 285.94 milliseconds (22.9%)

這是我使用iPhone 11 Pro的執行結果。這裡只是講解各個部分的作用,不討論如何最佳化和對比,不用深究這個時間。

注意:如果你要測試應用的最慢啟動時間,記得使用你支援的最慢的裝置來進行測試。

輸出顯示系統呼叫應用程式 main時所用的總時間,然後是主要步驟的分解。

WWDC 2016 Session 406最佳化應用程式啟動時間詳細介紹了每個步驟以及改進時間的提示,以下是簡要的總結說明:

  • dylib loading time 動態載入程式查詢並讀取應用程式使用的依賴動態庫。每個庫本身都可能有依賴項。雖然蘋果系統框架的載入是高度最佳化的,但載入嵌入式框架可能會很耗時。為了加快動態庫的載入速度,蘋果建議您使用更少的動態庫,或者考慮合併它們。
    • 建議的目標是六個額外的(非系統)框架
  • Rebase/binding time 修正調整映象內的指標(重新調整)和設定指向映象外符號的指標(繫結)。為了加快重新定位/繫結時間,我們需要更少的指標修復。
    • 如果有大量(大的是20000)Objective-C類、選擇器和類別的應用程式可以增加 800ms的啟動時間。
    • 如果應用程式使用C++程式碼,那麼使用更少的虛擬函式。
    • 使用Swift結構體通常也更快。
  • ObjC setup time Objective-C執行時需要進行設定類、類別和選擇器註冊。我們對重新定位繫結時間所做的任何改進也將最佳化這個設定時間。
  • initializer time 執行初始化程式。如果使用了Objective-C的  +load 方法,請將其替換為  +initialize 方法。

在系統呼叫 main之後, main將依次呼叫 UIApplicationMain和應用程式委託方法。

1.2 動態庫與靜態庫載入的耗時

1.2.1 載入動態庫耗時

我們先來看看工程裡面有多少動態庫:

  1. 在專案的 Product資料夾找到我們的工程 .app檔案,右鍵選擇 Show in Finder
  2. 來到相應目錄後右鍵選擇 顯示包內容
  3. 找到 Frameworks資料夾,開啟。
  4. 專案是純Swift編寫,下面都是系統Swift庫,我們沒法最佳化,可以不管。
我是如何讓微博綠洲的啟動速度提升30%的

可以看到我們的專案中有了36個動態庫,下面是 pre-main的總時間:

Total pre-main time: 1.2 seconds (100.0%)
         dylib loading time: 567.72 milliseconds (45.5%)
        rebase/binding time: 105.14 milliseconds (8.4%)
            ObjC setup time:  40.01 milliseconds (3.2%)
           initializer time: 532.47 milliseconds (42.7%)
           slowest intializers :
             libSystem.B.dylib :   4.70 milliseconds (0.3%)
          libglInterpose.dylib : 295.89 milliseconds (23.7%)
                  AFNetworking :  48.75 milliseconds (3.9%)
                         Oasis : 285.94 milliseconds (22.9%)

1.2.2 使用靜態庫耗時

在Pod的工程中,選擇我們使用的庫,然後點選 Build Settings,搜尋或者找到 Mach-O Type設定,修改 Mach-O TypeStatic Library

我是如何讓微博綠洲的啟動速度提升30%的

按照上面的步驟,把我們的動態庫的 Mach-O Type都改成靜態庫, ⇧+⌘+K執行一次 Clean Build Folder,然後重新構建一次。

我是如何讓微博綠洲的啟動速度提升30%的

這裡還保留了3個動態庫,是因為Objective-C沒有名稱空間,有符號衝突,就保留了下來。下面是 pre-main的總時間:

Total pre-main time: 877.84 milliseconds (100.0%)
         dylib loading time: 220.07 milliseconds (25.0%)
        rebase/binding time: 112.29 milliseconds (12.7%)
            ObjC setup time:  30.78 milliseconds (3.5%)
           initializer time: 514.70 milliseconds (58.6%)
           slowest intializers :
             libSystem.B.dylib :   4.33 milliseconds (0.4%)
          libglInterpose.dylib : 253.44 milliseconds (28.8%)
                  AFNetworking :  37.08 milliseconds (4.2%)
                        OCLibs :  61.75 milliseconds (7.0%)
                         Oasis : 246.28 milliseconds (28.0%)

可以看到,透過修改 Mach-O Type從動態庫改為靜態庫, dylib loading time得到了很大的提升,而其他部分的耗時變化不大。總時間從 1.2s降到了大約 0.9s,最佳化了大約 0.3s的啟動時間。

1.2.3 遇到的坑

但是如果只改 Mach-O Type的話, Archive之後在 Organizer中嘗試 Validate App會報錯:

  • Found an unexpected Mach-O header code: 0x72613c21
我是如何讓微博綠洲的啟動速度提升30%的

其實這裡是 CocoaPods的一個配置問題, CocoaPods會在專案中的 Build Phases新增一個  [CP] Embed Pods Frameworks 執行指令碼。

"${PODS_ROOT}/Target Support Files/Pods-專案名/Pods-專案名-frameworks.sh"

我們在執行 pod install後會生成一個 Pods-專案名-frameworks.sh的指令碼檔案。由於我們是手動修改的 Mach-O Type型別,這個指令碼中的 install_framework仍然會執行,所以我們要把轉換成靜態庫的這些庫從 Pods-專案名-frameworks.sh檔案中刪除。

AFNetworking為例,需要從檔案中刪除:

install_framework "${BUILT_PRODUCTS_DIR}/AFNetworking/AFNetworking.framework"

當然你也可以寫一個 ruby指令碼在使用 CocoaPodspost_install進行處理。

  1. 把相關的庫轉成靜態的。

    target.build_configurations.each do |config|
        config.build_settings['MACH_O_TYPE'] = 'staticlib'end
  2. 讀取 Pods-專案名-frameworks.sh檔案,刪除相關的字串。

    regex = /install_framework.*\/#{pod_name}\.framework\"/pod_frameworks_content.gsub!(regex, "")

2. 二進位制重排

2.1 App啟動

程式如果能直接訪問實體記憶體無疑是很不安全的,所以作業系統在實體記憶體的上又建立了一層虛擬記憶體。蘋果在這個基礎上還有  ASLR(Address Space Layout Randomization) 技術的保護,不過不是這次的重點。

iOS系統中 虛擬記憶體到實體記憶體的對映都是以頁為最小單位的。當程式訪問一個虛擬記憶體Page而對應的實體記憶體卻不存在時,就會出現 Page Fault缺頁中斷,然後載入這一頁。雖然本身這個處理速度是很快的,但是在一個App的啟動過程中可能出現上千(甚至更多)次 Page Fault,這個時間積累起來會比較明顯了。

iOS系統中一頁是16KB。

我們常說的啟動是指點選App到第一頁顯示為止,包含 pre-mainmaindidFinishLaunchingWithOptions結束的整個時間。 maindidFinishLaunchingWithOptions結束,這個部分是我們可以控制的,已經有很多文章講解應該怎麼最佳化了,不是本文的重點。這裡講的 二進位制重排主要是針對如何減少 Page Fault的最佳化。

另外,還有兩個重要的概念: 冷啟動熱啟動。可能有些同學認為殺掉再重啟App就是 冷啟動了,其實是不對的。

  • 冷啟動

    程式完全退出,之間載入的分頁資料被其他程式所使用覆蓋之後,或者重啟裝置、第一次安裝,才算是冷啟動。

  • 熱啟動

    程式殺掉之後,馬上又重新啟動。這個時候相應的實體記憶體中仍然保留之前載入過的分頁資料,可以進行重用,不需要全部重新載入。所以熱啟動的速度比較快。

後面會利用 Instruments工具 System Trace更直觀地比較這兩種啟動。

2.2 二進位制重排相關概念

2.2.1 二進位制重排的意義

程式預設情況下是順序執行的。

我是如何讓微博綠洲的啟動速度提升30%的

如果 啟動需要使用的方法分別在2頁 Page1Page2中( method1method3),為了執行相應的程式碼,系統就必須進行兩個 Page Fault

我是如何讓微博綠洲的啟動速度提升30%的

如果我們對方法進行重新排列,讓 method1method3在一個Page,那麼就可以較少一次 Page Fault

那麼怎麼衡量重排效果並驗證呢?

  • 檢視 Page Fault次數是否減少。
  • 檢視編譯過程的中間產物 LinkMap檔案進行確認。

2.2.2 System Trace

那麼如何衡量頁的載入時間呢?這裡就用到了 Instruments中的 System Trace工具。

首先,重新啟動裝置(冷啟動)。 ⌘+I開啟 Instruments,選擇 System Trace工具。

點選錄製⏺後,出現第一個頁面,馬上停止⏹。過濾只顯示 Main Thread相關,選擇 Summary: Virtual Memory

  • File Backed Page In次數就是觸發 Page Fault的次數了。
  • Page Cache Hit就是頁快取命中的次數了。
我是如何讓微博綠洲的啟動速度提升30%的

下面我們看看熱啟動的情況。殺掉App,接著直接重新執行一遍之前的操作(不重啟):

我是如何讓微博綠洲的啟動速度提升30%的

對比冷啟動和熱啟動的 File Backed Page In次數,可以看到熱啟動情況下,觸發的 Page Fault的次數就變得很小了。

2.2.3 啟動順序

2.2.3.1 檔案順序

Build PhasesCompile Sources列表順序決定了檔案執行的順序(可以調整)。如果不進行重排,檔案的順序決定了方法、函式的執行順序。

我是如何讓微博綠洲的啟動速度提升30%的

我們在 ViewControllerAppDelegate中加入以下程式碼,並執行。

+ (void)load {
    NSLog(@"%s", __FUNCTION__);}//輸出2020-04-23 22:56:13.551729+0800 BinaryOptimization[59505:5477304] +[ViewController load]2020-04-23 22:56:13.553714+0800 BinaryOptimization[59505:5477304] +[AppDelegate load]

我們調整 Compile Sources中這兩個類的順序,然後再執行。

我是如何讓微博綠洲的啟動速度提升30%的
2020-04-23 23:00:08.248118+0800 BinaryOptimization[59581:5482198] +[AppDelegate load]
2020-04-23 23:00:08.249015+0800 BinaryOptimization[59581:5482198] +[ViewController load]

可以看到,隨著 Compile Sources中的檔案順序的修改, +load方法的執行順序也發生了改變。

2.2.3.2 符號表順序

Build Settings中修改 Write Link Map FileYES編譯後會生成一個 Link Map符號表 txt檔案。

執行 ⌘ + B構建後,選擇 Product中的App,在 Finder中開啟,選擇 Intermediates.noindex資料夾,

我是如何讓微博綠洲的啟動速度提升30%的

找到 LinkMap檔案,這裡是 BinaryOptimization-LinkMap-normal-arm64.txt

我是如何讓微博綠洲的啟動速度提升30%的

開啟檔案之後來到第一部分的最後。

我是如何讓微博綠洲的啟動速度提升30%的

我們可以看到這個順序和我們 Compile Sources中的順序是一致的。接下來的部分:

# Sections:
# Address   Size        Segment Section
0x100005ECC 0x0000065C  __TEXT  __text
0x100006528 0x0000009C  __TEXT  __stubs
0x1000065C4 0x000000B4  __TEXT  __stub_helper
0x100006678 0x000000BE  __TEXT  __cstring
0x100006736 0x00000D2B  __TEXT  __objc_methname
0x100007461 0x00000070  __TEXT  __objc_classname
0x1000074D1 0x00000ADA  __TEXT  __objc_methtype
0x100007FAC 0x00000054  __TEXT  __unwind_info
0x100008000 0x00000008  __DATA_CONST    __got
0x100008008 0x00000040  __DATA_CONST    __cfstring
0x100008048 0x00000018  __DATA_CONST    __objc_classlist
0x100008060 0x00000010  __DATA_CONST    __objc_nlclslist
0x100008070 0x00000020  __DATA_CONST    __objc_protolist
0x100008090 0x00000008  __DATA_CONST    __objc_imageinfo
0x10000C000 0x00000068  __DATA  __la_symbol_ptr
0x10000C068 0x00001348  __DATA  __objc_const
0x10000D3B0 0x00000018  __DATA  __objc_selrefs
0x10000D3C8 0x00000010  __DATA  __objc_classrefs
0x10000D3D8 0x00000008  __DATA  __objc_superrefs
0x10000D3E0 0x00000004  __DATA  __objc_ivar
0x10000D3E8 0x000000F0  __DATA  __objc_data
0x10000D4D8 0x00000188  __DATA  __data

這個是 Mach-O的一些資訊,不是這次的重點。接在這部分之後的符號才是,由於比較多,我只擷取了部分。

# Symbols:# Address     Size        File  Name
0x100005ECC 0x0000003C  [  1] +[AppDelegate load]
0x100005F08 0x00000088  [  1] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x100005F90 0x00000108  [  1] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x100006098 0x00000080  [  1] -[AppDelegate application:didDiscardSceneSessions:]
0x100006118 0x0000003C  [  2] +[ViewController load]
0x100006154 0x0000004C  [  2] -[ViewController viewDidLoad]
0x1000061A0 0x000000A0  [  3] _main
0x100006240 0x000000B4  [  4] -[SceneDelegate scene:willConnectToSession:options:]
0x1000062F4 0x0000004C  [  4] -[SceneDelegate sceneDidDisconnect:]
0x100006340 0x0000004C  [  4] -[SceneDelegate sceneDidBecomeActive:]
0x10000638C 0x0000004C  [  4] -[SceneDelegate sceneWillResignActive:]
0x1000063D8 0x0000004C  [  4] -[SceneDelegate sceneWillEnterForeground:]
0x100006424 0x0000004C  [  4] -[SceneDelegate sceneDidEnterBackground:]
0x100006470 0x0000002C  [  4] -[SceneDelegate window]
0x10000649C 0x00000048  [  4] -[SceneDelegate setWindow:]
0x1000064E4 0x00000044  [  4] -[SceneDelegate .cxx_destruct]
0x100006528 0x0000000C  [  5] _NSLog
0x100006534 0x0000000C  [  5] _NSStringFromClass
0x100006540 0x0000000C  [  7] _UIApplicationMain
0x10000654C 0x0000000C  [  6] _objc_alloc
0x100006558 0x0000000C  [  6] _objc_autoreleasePoolPop
0x100006564 0x0000000C  [  6] _objc_autoreleasePoolPush
...

可以看到,整體的順序和 Compile Sources的中的順序是一樣的,並且方法是按照檔案中方法的順序進行連結的。 AppDelegate中的方法新增完後,才是 ViewController中的方法,以此類推。

  • Address� 表示檔案中方法的地址。
  • Size 表示方法的大小。
  • File 表示在第幾個檔案中。
  • Name 表示方法名。

2.2.4 二進位制重排初體驗

在專案根目錄建立一個 order檔案。

touch BinaryOptimization.order

然後在 Build Settings中找到 Order File,填入 ./BinaryOptimization.order

我是如何讓微博綠洲的啟動速度提升30%的

BinaryOptimization.order檔案中填入:

+[ViewController load]
+[AppDelegate load]
_main
-[ViewController someMethod]

然後執行 ⌘ + B構建。

我是如何讓微博綠洲的啟動速度提升30%的

可以看到 Link Map中的最上面幾個方法和我們在 BinaryOptimization.order檔案中設定的方法順序一致!

Xcode的聯結器 ld還忽略掉了不存在的方法  -[ViewController someMethod]

如果提供了link選項  -order_file_statistics,會以warning的形式把這些沒找到的符號列印在日誌裡。

2.3 二進位制重排實戰

要真正的實現二進位制重排,我們需要拿到啟動的所有方法、函式等符號,並儲存其順序,然後寫入 order檔案,實現二進位制重排。

抖音有一篇文章 抖音研發實踐:基於二進位制檔案重排的解決方案 APP啟動速度提升超15%,但是文章中也提到了瓶頸:

基於靜態掃描+執行時trace的方案仍然存在少量瓶頸:

  • initialize hook不到
  • 部分block hook不到
  • C++透過暫存器的間接函式呼叫靜態掃描不出來

目前的重排方案能夠覆蓋到80%~90%的符號,未來我們會嘗試編譯期插樁等方案來進行100%的符號覆蓋,讓重排達到最優效果。

同時也給出瞭解決方案 編譯期插樁

2.3.1 Clang插樁

其實就是一個程式碼覆蓋工具,更多資訊可以檢視 官網

Build Settings中  Other C Flags新增 -fsanitize-coverage=trace-pc-guard配置,編譯的話會報錯。

Undefined symbol: ___sanitizer_cov_trace_pc_guard_init
Undefined symbol: ___sanitizer_cov_trace_pc_guard

檢視官網會需要我們新增一個兩個函式:

#include <stdint.h>#include <stdio.h>#include <sanitizer/coverage_interface.h>// This callback is inserted by the compiler as a module constructor// into every DSO. 'start' and 'stop' correspond to the// beginning and end of the section with the guards for the entire// binary (executable or DSO). The callback will be called at least// once per DSO and may be called multiple times with the same parameters.extern "C" 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.}// This callback is inserted by the compiler on every edge in the// control flow (some optimizations apply).// Typically, the compiler will emit the code like this://    if(*guard)//      __sanitizer_cov_trace_pc_guard(guard);// But for large functions it will emit a simple call://    __sanitizer_cov_trace_pc_guard(guard);extern "C" void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;  // Duplicate the guard check.
  // If you set *guard to 0 this code will not be called again for this edge.
  // Now you can get the PC and do whatever you want:
  //   store it somewhere or symbolize it and print right away.
  // The values of `*guard` are as you set them in
  // __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive
  // and use them to dereference an array or a bit vector.
  void *PC = __builtin_return_address(0);
  char PcDescr[1024];
  // This function is a part of the sanitizer run-time.
  // To use it, link with AddressSanitizer or other sanitizer.
  __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);}

我們把程式碼新增到 ViewController.m中,我們不需要  extern "C" 所以可以刪掉,  __sanitizer_symbolize_pc() 還會報錯,不重要先註釋瞭然後繼續。

#include <stdint.h>#include <stdio.h>#include <sanitizer/coverage_interface.h>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.}void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;  // Duplicate the guard check.//  void *PC = __builtin_return_address(0);
  char PcDescr[1024];//  __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);}

函式  __sanitizer_cov_trace_pc_guard_init統計了方法的個數。執行後,我們可以看到:

INIT: 0x104bed670 0x104bed6b0
(lldb) x 0x104bed670
0x104bed670: 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00  ................
0x104bed680: 05 00 00 00 06 00 00 00 07 00 00 00 08 00 00 00  ................
(lldb) x 0x104bed6b0-0x4
0x104bed6ac: 10 00 00 00 10 00 00 00 00 00 00 00 00 00 00 00  ................
0x104bed6bc: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................

讀取記憶體之後,我們可以看到一個類似計數器的東西。最後一個列印的是結束位置,按顯示是4位4位的,所以向前移動4位,列印出來的應該就是最後一位。

根據小端模式, 10 00 00 00對應的是 00 00 00 10即16。我們在 ViewController中新增一些方法:

void(^block)(void) = ^(void){};void test(){
    block();}- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    test();}

再列印一次:

(lldb) x 0x10426d6dc-0x4
0x10426d6d8: 13

可以看到增加了3(block是匿名函式),計數器統計了函式/方法的個數,這裡新增了三個,索引增加了3。

我們再點選一下螢幕:

guard: 0x1007196ac 8 PC �guard: 0x1007196a8 7 PC �guard: 0x1007196a4 6 PC H�q

我們發現,每點選一次螢幕就有3個列印。我們在 touchesBegan:touches withEvent:開頭設定一個點斷,並開啟彙編顯示(選單欄 DebugDebug WorkflowAlways Show Disassembly)。

我是如何讓微博綠洲的啟動速度提升30%的

如果我們檢視其他函式也會發現彙編程式碼中有類似的顯示。

也就是說Clang插樁就是在彙編程式碼中插入了  __sanitizer_cov_trace_pc_guard函式的呼叫。

拿到了全部的符號之後需要儲存,但是不能用陣列,因為有可能會有在子執行緒執行的,所以用陣列會有執行緒問題 。這裡我們使用原子佇列:

#import <libkern/OSAtomic.h>#import <dlfcn.h>/*
 原子佇列特點
 1、先進後出
 2、執行緒安全
 3、只能儲存結構體
 */static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;// 符號結構體連結串列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};
    // 入隊
    OSAtomicEnqueue(&symbolList, node, offsetof(SymbolNode, next));
    // 以下是一些列印,只是看一下,實際中可以註釋
    // dlopen 透過動態庫拿到控制程式碼 透過控制程式碼拿到函式的記憶體地址
    // dladdr 透過函式記憶體地址拿到函式
    typedef struct dl_info {
        const char      *dli_fname;     /* Pathname of shared object      函式的路徑  */
        void            *dli_fbase;     /* Base address of shared object  函式的地址  */
        const char      *dli_sname;     /* Name of nearest symbol         函式符號    */
        void            *dli_saddr;     /* Address of nearest symbol      函式起始地址 */
    } Dl_info;
    Dl_info info;
    dladdr(PC, &info);
    printf("fnam:%s \n fbase:%p \n sname:%s \n saddr:%p \n",
           info.dli_fname,
           info.dli_fbase,
           info.dli_sname,
           info.dli_saddr);}

執行後這裡我們可以看到很多列印,只取一條來說明,很明顯其中 sname就是我們需要的符號名了。

fnam:/private/var/containers/Bundle/Application/3EAE3817-0EF7-4892-BC55-368CC504A568/BinaryOptimization.app/BinaryOptimization 
 fbase:0x100938000 
 sname:+[AppDelegate load] 
 saddr:0x10093d81c

下面我們透過點選螢幕匯出所需要的符號,需要注意的是 C函式和Swift方法前面需要加下劃線。(這裡點可以在前面提到的 LinkMap檔案中確認)

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    NSMutableArray <NSString *>* symbolNames = [NSMutableArray array];
    while (YES) {
        SymbolNode * node = OSAtomicDequeue(&symbolList, offsetof(SymbolNode, next));
        if (node == NULL) {
            break;
        }
        Dl_info info;
        dladdr(node->pc, &info);
        NSString * name = @(info.dli_sname);
        BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["]; //OC方法不處理
        NSString * symbolName = isObjc? name : [@"_" stringByAppendingString:name]; //c函式、swift方法前面帶下劃線
        [symbolNames addObject:symbolName];
        printf("%s \n",info.dli_sname);
    }
    NSEnumerator * emt = [symbolNames reverseObjectEnumerator];
    NSMutableArray<NSString*>* funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
    NSString * name;
    while (name = [emt nextObject]) {
        if (![funcs containsObject:name]) {
            [funcs addObject:name];
        }
    }
    // 刪掉當前方法,因為這個點選方法不是啟動需要的
    [funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"BinaryOptimization.order"];
    NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    // 在路徑上建立檔案
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    NSLog(@"%@",filePath);}

這時如果你直接點選螢幕,有個巨坑,會看到控制檯一直在輸出,出現了死迴圈:

-[ViewController touchesBegan:withEvent:] 
-[ViewController touchesBegan:withEvent:] 
...

我們在while裡面設定一個斷點:

我是如何讓微博綠洲的啟動速度提升30%的

發現  __sanitizer_cov_trace_pc_guard居然有10個,這個地方會觸發  __sanitizer_cov_trace_pc_guard中的入隊,這裡又進行出隊,最後就死迴圈了。

解決辦法:

Build SettingsOther C Flags新增 func配置,即 -fsanitize-coverage=func,trace-pc-guard

官網對 func的引數的解釋:只檢測每個函式的入口。

再次執行點選螢幕就不會有問題了。

2.3.2 從真機上獲取order檔案

我們把 order檔案存在了真機上的 tmp資料夾中,要怎麼拿到呢?

WindowDevices And Simulators(快捷鍵 ⇧+⌘+2)中:

我是如何讓微博綠洲的啟動速度提升30%的

2.3.3 Swift

Swift也可以重排麼?當然可以!

我們在專案中新增一個Swift類,然後在 viewDidLoad呼叫一下:

class SwiftTest: NSObject {
    @objc class public func swiftTestLoad(){
        print("swiftTest");
    }}- (void)viewDidLoad {
    [super viewDidLoad];
    [SwiftTest swiftTestLoad];}

Build SettingOther Swift Flags設定:

-sanitize-coverage=func-sanitize=undefined

執行後點選一下螢幕,檢視控制檯:

-[ViewController touchesBegan:withEvent:] -[SceneDelegate window] -[SceneDelegate window] -[SceneDelegate sceneDidBecomeActive:] -[SceneDelegate sceneWillEnterForeground:] // 下面這4個就是Swift的$ss5print_9separator10terminatoryypd_S2StFfA1_ 
$ss5print_9separator10terminatoryypd_S2StFfA0_ 
$s18BinaryOptimization9SwiftTestC05swiftD4LoadyyFZ 
$s18BinaryOptimization9SwiftTestC05swiftD4LoadyyFZTo 
-[ViewController viewDidLoad] -[SceneDelegate window] -[SceneDelegate window] -[SceneDelegate window] -[SceneDelegate scene:willConnectToSession:options:] -[SceneDelegate window] -[SceneDelegate window] -[SceneDelegate setWindow:] -[SceneDelegate window] -[AppDelegate application:didFinishLaunchingWithOptions:] main 
2020-04-24 13:08:43.923191+0800 BinaryOptimization[459:65420] /private/var/mobile/Containers/Data/Application/DA2EC6F0-93C9-45A0-9D95-C21883E0532C/tmp/BinaryOptimization.order

所有處理完之後,最後需要 Write Link Map File改為 NO,把 Other C Flags/Other Swift Flags的配置刪除掉。

因為這個配置會在我們程式碼中自動插入跳轉執行  __sanitizer_cov_trace_pc_guard。重排完就不需要了,需要去除掉。 同時把 ViewController中的  __sanitizer_cov_trace_pc_guard也要去除掉。

2.3.4 二進位制重排前後的對比

在專案中進行實踐並測試之後:

  • 進行二進位制重排前, File Backed Page In( Page Fault Count)發生了 2569次,耗時 298ms
我是如何讓微博綠洲的啟動速度提升30%的
  • 進行二進位制重排後, File Backed Page In( Page Fault Count)發生了 2311次,耗時 248ms
我是如何讓微博綠洲的啟動速度提升30%的

可以看到,經過 二進位制重排減少了Page Fault的次數,總時間從 298ms降到了大約 248ms,最佳化了大約 50ms的啟動時間。

3. 總結

  1. 透過將動態庫轉為靜態庫,我們最佳化了 dylib loading time
    • 蘋果官方建議為6個以下,這裡我們因為符號衝突,只保留了3個動態庫。
  2. 透過二進位制重排,讓啟動需要的方法排列更緊湊,減少了 Page Fault的次數。
    • 獲取符號表時,採用 Clang插樁可以直接hook到Objective-C方法、Swift方法、C函式、Block,可以不用區別對待。相比於抖音之前提出的方案確實簡單很多,門檻也要低一些。

重要:

有朋友問到Pod中的三方庫能否加入 order檔案中,答案是可以的!

文中的二進位制重排實踐過程,考慮了三方庫的啟動時需要的符號。文章裡面沒有特別說明,但原理是一樣的。

推薦?:

如果你想一起進階,不妨新增一下交流群 1012951431

面試題資料或者相關學習資料都在群檔案中 進群即可下載!

我是如何讓微博綠洲的啟動速度提升30%的


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69971523/viewspace-2690903/,如需轉載,請註明出處,否則將追究法律責任。

相關文章