雲音樂 iOS 啟動效能優化「開荒篇」

語言: CN / TW / HK

本文作者:Lazyyuuuuu

一.背景

  App 啟動作為使用者使用應用的第一個體驗點,直接決定著使用者對 App 的第一印象。雲音樂作為一個有著近10年發展歷史的 App,隨著各種業務不停的發展和複雜場景的堆疊,不同的業務和需求不停地往啟動鏈路上增加程式碼,這給 App 的啟動效能帶來了極大的挑戰。而隨著雲音樂使用者基數的不斷擴大和深度使用,越來越多的使用者反饋啟動速度慢,況且啟動速度過慢更甚至會降低使用者的留存意願。因此,雲音樂 iOS App 急需要進行一個專項針對啟動效能進行優化。

二.分析

2.1 啟動的定義

  大家都知道在 iOS13 之後,蘋果全面將 dyld3 替代之前的 dyld2^1,並且在 dyld3 中增加了啟動閉包的概念,在下載/更新 App、系統更新或者重啟手機後的第一次啟動 App 時建立。所以 iOS13 前後對冷啟動的概念會有所區別。

iOS13之前:
  • 冷啟動:App 點選啟動前,系統中不存在 App 的程序,使用者點選 App,系統給 App 建立程序啟動;
  • 熱啟動:App 在冷啟動後用戶將 App 退回後臺,App 程序還在系統中,使用者點選 App 重新返回 App 的過程;
iOS13及之後:
  • 冷啟動:重啟手機系統後,系統中沒有任何 App 程序的快取資訊,使用者點選 App,系統給 App 建立程序啟動;

  • 熱啟動:使用者把 App 程序殺死,系統中存在 App 程序的快取資訊,使用者點選 App,系統給 App 建立程序啟動;

  • 回前臺:App 在啟動後用戶將 App 退回後臺,App 程序還在系統中,使用者點選 App 重新返回 App 的過程;

  在雲音樂 App 啟動治理過程中始終以 iOS13 之後的冷啟動為對齊標準,不管是以使用者視角測量的啟動時間還是用 Instrument 中 App Launch 測量的啟動時間都是在手機重啟後進行的。

2.2 冷啟動的定義

  一般而言,大家把 iOS 冷啟動的過程定義為:從使用者點選 App 圖示到啟動圖完全消失後的第一幀渲染完成。整個過程可以分為兩個階段: - T1 階段:main() 函式之前,包括系統建立 App 程序,載入 MachO 檔案到記憶體,建立啟動閉包,再到 dyld 處理一系列的載入、符號繫結、初始化等工作,最後跳轉到執行 main() 之前。 - T2 階段:跳轉到 main() 函式之後,開始執行 App 中 UI 場景的建立以及 Delegate 相關生命週期方法,到完成首屏渲染的第一幀。 整體流程如下圖所示:

  本文如涉及到時間相關一般是以系統為 14.3 的 iPhone 8 Plus 作為基準測試裝置,並且在 Debug 模式下。

2.3 冷啟動的過程

  從冷啟動的定義後我們可以把整個冷啟動的過程分為 T1 和 T2 兩個過程,iOS 系統在兩個過程中分別會在不同的節點進行相應的處理和程式碼的呼叫,後續可以針對這兩個過程分別進行治理優化。

  T1 階段啟動過程如下圖所示:

  從上圖所示的流程中,我們可以看到在 T1 階段更多的是系統在為執行 App 做一些初始化的工作,所以我們能做的就是儘量減少對系統初始化工作的影響。從整個流程看來,啟動閉包之後的動態庫載入、rebase&bind、Objc Init、+load、static initializer 這幾個節點我們是可以做一些針對性的治理和優化工作的。

  T2 階段啟動過程如下圖所示:

  從上圖所示的流程中,我們可以看到在 T2 階段已經基本是屬於業務方的程式碼了,在這個階段中往往我們會把 Crash 相關、APP 配置資訊、AB 資料、定位、埋點、網路初始化、容器預熱以及二三方 SDK 初始化等一股腦的塞在裡面,而針對這個階段優化的 ROI 也是相對比較高的。

2.4 雲音樂的現狀

  雲音樂作為一個從 2013 年開始推出的 App 有著近 10 年的業務發展和程式碼堆疊,在此期間對啟動效能的關注和治理也比較有限,再加上雲音樂除了聽歌業務以外還有直播、K 歌等業務整合,所以總體來說整個啟動鏈路上的程式碼是比較複雜的。甚至由於雲音樂自身開屏廣告業務的特殊性,在筆者開始著手啟動優化專項後發現雲音樂的啟動紅屏由一般 App 的啟動開屏頁和假紅屏兩部分組成,整個啟動流程如下圖所示:

2.4.1 T1階段各情況分析

動態庫

  從WWDC2022^2我們也知道一個 App 中動態庫的數量是會影響整個 T1 階段的耗時的,因此我們一是需要知道目前動態庫對整個 T1 階段耗時的影響,二是需要知道有哪些動態庫造成了影響並且是可以優化的。通過 Xcode 提供的環境變數DYLD_PRINT_STATISTICS我們可以大致的知道所有動態庫在 T1 階段的耗時,如下圖所示:

  從 Xcode 輸出的結果可以看到,動態庫載入的耗時佔整個 pre-main 的比例還挺高的。這個時候我通過解壓雲音樂線上 IPA 包發現 Frameworks 目錄下動態庫的數量有 16 個之多。

+load方法

  iOS 開發人員對 +load 方法應該已經很熟悉了,因為 +load 方法提供了一個比較早的時機能夠讓我們前置去執行一些基礎配置的程式碼、註冊類程式碼或者方法交換等程式碼。也正是由於這個原因,我們在不停的業務迭代中發現大家想要找一個早一點的時機就會想到去用 +load 方法,導致專案中 +load 過多,嚴重影響啟動效能,雲音樂工程也有這樣的問題,下面我們來看下對 +load 方法使用情況的分析。

  我們知道對於實現了 +load 方法的類和分類會在編譯時被寫入到 MachO 中__DATA段的__objc_nlclslist__objc_nlcatlist兩個 section 中。因此,我們可以通過getsectbynamefromheader方法把定義了 +load 的所有的類和分類撈取出來,如下圖所示:

  當然當我們知道了所有定義了 +load 的類和分類以後,更想知道這些 +load 的耗時情況,這樣好方便我們優先優化耗時高的那部分 +load 方法。我們想到的是 Hook +load 方法,而要能夠 Hook 所有的 +load 方法肯定是需要在最早的時機去 Hook,那麼實現一個動態庫,並且在動態庫的 +load 中去 Hook 是最好的時機了,同時也要保證這個動態庫是最先載入的動態庫,如下圖所示:

  由於雲音樂工程已經用 Cocoapods 來實現元件化,所以只需要建立以 AAA 名稱開頭的倉庫就可以了,如 AAAHookLoad,並且在 Podfile 中引入對應的倉庫,就能實現動態庫最先載入,這裡可以參照開源庫 A4LoadMeasure^3。如果還是單工程則取什麼名稱都可以,只需要在工程設定Build Phases=>Link Binary With Libraries中把對應的庫移到第一個位置就可以,如下圖所示:

  經過 Hook +load 方法後,我們發現在雲音樂工程中竟有接近 800 處呼叫,並且整個耗時達到了 550ms+ 的級別,可見 +load 方法的亂用對整個啟動效能的影響有多大。

static initializer

  對於同一個二進位制檔案來說執行完 +load 方法就會進入 static initializer 階段,一般來說一個以 OC 為主開發語言的 App 相對比較少的會去用到 static initializer 的程式碼,但也不排除有些底層庫會用到。以下幾種程式碼型別會導致靜態初始化: - C/C++ 建構函式__attribute__((constructor)),如: __attribute__((constructor)) static void test() { NSLog(@"test"); } - 非基本型別的 C++ 靜態全域性變數,如: class Test1 { static const std::string testStr1; }; const std::string testStr2 = "test"; static Test1 test1; - 需要執行時進行初始化的全域性變數,如: bool test2 () { NSLog(@"is a test func"); return false; } bool g_testFlag = test2();   其實我們可以看到,不能在編譯期間確定值的全域性變數的初始化都可以認為是在這個階段執行的。 對於 static initializer 的分析來說,MachO 中__DATA段的__mod_init_func這個 section 中儲存著初始化相關的函式地址。跟 +load 一樣,我們只需要 Hook 掉對應的函式指標就能獲取到對應函式的耗時。在雲音樂工程中 static initializer 相關函式比較少,且耗時也不明顯,這塊就沒有重點去關注。

Page In的影響

  當用戶點選 App 啟動的時候,系統會建立程序併為程序申請一塊虛擬記憶體,虛擬記憶體和實體記憶體是需要對映的。當程序需要訪問的一塊虛擬記憶體頁還沒有對映對應的實體記憶體頁時,就會觸發一次缺頁中斷 Page In。這個過程中會發生 I/O 操作,將磁碟中的資料讀入到實體記憶體頁中。如果讀入的是 Text 段的頁,還需要解密,並且系統還會對解密後的頁進行簽名驗證。所以,如果在啟動過程中頻繁的發生 Page In 的話,Page In 引起的 I/O 操作以及解密驗證操作等的耗時也是影響很大的。需要注意的是,iOS13 及以後蘋果對這個過程進行了優化,Page In 的時候不再需要解密了。

  Page In 的具體情況我們可以通過 Instruments 中的 System Trace 工具來分析,其中找到 Main Thread 程序,再選擇 Summary:Virtual Memory 選項,下面看到的 File Backed Page In 就是對應的缺頁中斷資料了,從資料上看Page In對雲音樂的影響並非瓶頸,如下圖所示:

2.4.2 T2階段情況分析

  T2 階段主要是 Main 之後的方法執行,要分析這個階段可以用到兩個工具,一個是 Hook objc_msgSend 函式後輸出對應的火焰圖,另一個是利用蘋果提供的 Instruments 中的 App Launch 工具分析整個啟動流程。通過這兩個工具我們可以從時間線、方法呼叫堆疊、不同執行緒的執行狀態等各個細節點入手找到需要優化的點。

  火焰圖(Flame Graph)是由 Linux 效能優化大師 Brendan Gregg 發明的,和所有其他的 profiling 方法不同的是,火焰圖以一個全域性的視野來看待時間分佈,它從頂部往底部,列出所有可能導致效能瓶頸的呼叫棧。

Hook objc_msgSend生成火焰圖

  我們知道 OC 是一種動態語言,所有執行時的 OC 方法都會通過 objc_msgSend 來完成執行,objc_msgSend 會根據傳入的物件和對應方法的 selector 去查詢對應的函式指標執行。所以,我們只要通過 Hook 掉 objc_msgSend ,並且在原方法前後加入耗時統計程式碼再執行原方法就能得到對應的方法名以及耗時。一般想到要 Hook objc_msgSend 就會想到是 fishhook,由於 objc_msgSend 使用匯編實現的,所以用 fishhook 去 hook 的話還要處理暫存器的資料現場。其實通過 HookZz^4 這個庫也可以 hook objc_msgSend 並且比 fishhook 更方便。

  這裡我們通過開源庫 appletrace^5 來實現對 objc_msgSend 方法效能的分析以及火焰圖的生成,樣式如下圖所示:

Instruments中App Launch工具分析

  通過分析生成的火焰圖資料與實際 Debug 除錯發現火焰圖上對應方法的耗時也不是特別精確,會有一定的誤差,但是相對佔比還是能夠反映出相應方法在整個 T2 階段的影響的。同時,火焰圖只能看到整個啟動鏈路的時間線以及方法呼叫棧,執行緒間的狀態還是不夠直觀,也缺乏 C/C++ 相關方法效能的檢測,並且火焰圖對每個具體階段的描述也是缺乏的。這個時候就需要用到 Instruments 的 App Launch 工具再來分析一遍。

  Xcode 自帶 Instruments 一系列的分析工具,而 App Launch 分析後會把整個啟動鏈路的各個階段詳細展示,通過對各個階段區間的劃分可以很方便的找到每個階段主執行緒的效能瓶頸以及多執行緒的狀態,如下圖所示:

2.4.3 廣告業務現狀

  在上面提到雲音樂存在假紅屏的現象,而這個假紅屏就是由廣告業務產生。在諮詢了廣告業務相關同學後得知,雲音樂這邊的廣告業務是去實時請求後實時展示的,所以在請求之前展示假紅屏頁面,直到等待介面資料返回後假紅屏消失,後續展示廣告或者進入首頁。進一步瞭解後知道,實時請求是因為廣告業務需要去外部廣告聯盟拉取實時廣告,然後根據業務情況再去分發廣告。由於網路的波動和響應時間的存在,廣告業務對啟動效能的影響還是比較大的,整體流程如下圖所示:

三.實踐

3.1 T1階段治理

3.1.1 動態庫治理

  動態庫數量的增多不僅會影響系統建立啟動閉包的時間,同時也會增加動態庫載入階段的耗時,蘋果官方對於動態庫數量的建議是保持在 6 個以內。而云音樂目前共有 16 個動態庫,可見壓力之大。對於動態庫的治理,主要有以下幾種方式: - 動態庫轉靜態庫,推薦以這種方式治理,還能優化包大小; - 合併動態庫,由於動態庫的提供方有三方也有二方,要讓幾方一起處理實操難度很大; - 動態庫懶載入,這種方式的收益很明顯,但是需要各業務方改造並且統一入口;

  雲音樂在動態庫的治理當中還是主張把動態庫轉成靜態庫,更適合一個應用的長遠發展。在動態庫轉靜態庫的過程中發現很多的動態庫是因為需要用到 OpenSSL,而工程中已經有庫用到 OpenSSL 了會導致符號衝突,所以不得己做成了動態庫,對於這種情況首先就是找到 OpenSSL 符號衝突的庫,其次是全工程統一 OpenSSL 版本。

尋找 OpenSSL 符號衝突原因

  通過整合 OpenSSL 靜態庫以及把一個動態庫轉成靜態庫後發現由於部分符號在連結的時候沒有正確連結,導致執行時崩潰。查詢到對應的符號為 _RC4_set_key,通過 LinkMap 發現 _RC4_set_key 連結到了公司內部二方 SDK。

  開啟 LinkMap.txt 檔案首先查詢到 _RC4_set_key 符號,然後看到前面對應的 file 所在的序號為 2333,如下圖:

接著我們可以從 LinkMap 上方的 Object files 區塊找到對應序號的檔案,發現正是雲信的 IM SDK,如下圖所示:

由於雲音樂工程依賴了雲信 4 個動態庫,所以我們查看了 4 個庫的符號,發現有兩個庫都有依賴 OpenSSL。下面我們要做的工作就是使 OpenSSL 符號正確的連結到雲音樂自己的 OpenSSL 庫。

解決OpenSSL符號連結問題

  通過檢視工程配置發現,OpenSSL 符號的連結順序跟 Other Linker Flags 中的順序有關,而 Other Linker Flags 中的順序是根據 Cocoapods 中 Pods 的 xcconfig 中 OTHER_LDFLAGS 的順序來的。經過實際修改 xcconfig 中 OTHER_LDFLAGS 的順序驗證 OpenSSL 符號的連結問題得到解決。據此,有兩種方法能解決 OpenSSL 符號連線問題: - 通過修改 Podfile 在連結階段優先連結白名單內的庫; - 讓除了 OpenSSL 庫以外的其他動態庫隱藏相關的 OpenSSL 符號; 在考慮了後續長遠發展以及避免後續連結存在隱患,我們選擇了第二種方法,讓雲信匯出自身庫的時候都隱藏第三方庫的符號。

  經過 OpenSSL 符號的統一,我們把相關的 4 個動態庫轉成了靜態庫。同時,我們移除了一個已經不在用到的動態庫。有 3 個庫由於 ffmpeg 相關符號衝突並且涉及面較廣作為長期目標優化。依賴的一個迅雷網路庫作為下次優化目標。動態庫這一塊目前總的優化 5 個,收益有 200ms 左右。

3.1.2 +load方法治理

  從原則上來說,我們在開發過程中不應該使用 +load,很多大廠在建立規範後也都禁用掉了 +load 方法。+load 方法的影響如下: - +load 的執行時機非常靠前,應用 Crash 檢測 SDK 的初始化工作都還沒完成,一旦 +load 中的程式碼出現問題,SDK 都沒法捕獲相應的問題;

  • +load 的呼叫順序和對應檔案的連結順序相關,如果有一些註冊業務寫在其中,而當其他 +load 相關業務在獲取時,可能註冊業務的 +load 還沒執行;

  • 執行 +load 時的程式碼都是在主執行緒執行的,應用所有 +load 的執行都會加長整個啟動的耗時,而 +load 可以隨意在相應的業務類中新增,業務開發無意的程式碼新增說不定就會造成耗時的嚴重增加;

  • 從 Page In 的角度出發,執行一次 +load 不僅需要載入 +load 這個符號,還需要載入其中需要執行的符號,這也增加了不必要的耗時; 針對 +load 方法的優化,主要是採用如下幾種方案:

  • 刪除不必要的程式碼;

  • +load中程式碼延遲到 main 之後子執行緒處理或者首頁顯示之後;

  • 底層庫設計專有的初始化 API 統一去初始化;

  • 業務程式碼介面懶載入;

  • 改為 initialize 中執行,針對 initialize 中處理需要注意的是分類 initialize 會覆蓋主類 initialize 以及有子類後 initialize 執行多次的問題,需要使用 dispatch_once 來保證程式碼只執行一次;

  在具體分析了雲音樂中的部分 +load 方法的用處後發現,雲音樂中很多底層庫都是通過使用巨集定義來在 +load中實現一些註冊行為,或者就只提供註冊介面,業務使用方就會選擇在 +load 中去呼叫註冊介面。針對這種情況,我們優化了幾個庫的註冊方式。通過去中心化註冊,集中式統一初始化原則,不僅可以讓註冊時機統一,也能夠更好的管控業務使用方,為以後的監控做鋪墊。去中心化註冊利用 attribute 特性在編譯期間把相應的結構化資料寫到 DATA 段指定的 section 中: ```

define _MODULE_DATA_SECT(sectname) __attribute((used, section("__DATA," sectname) ))

define _ModuleEntrySectionName "_ModuleSection"

typedef struct { const char *className; } _ModuleRegisterEntry; #define __ModuleRegisterInternal(className) \ static _ModuleRegisterEntry _Module##className##Entry _MODULE_DATA_SECT(_ModuleEntrySectionName) = { \ #className \ };

同時,我們提供了一個統一初始化的介面,在介面實現中把資料中對應的 section 中撈出來並通過原有介面統一註冊: size_t dataLength = sizeof(_ModuleRegisterEntry); for (id headerItem in appImageHeaders) { const ne_mach_header mach_header = (__bridge const ne_mach_header )(headerItem); unsigned long size = 0; void dataPtr = getsectiondata(mach_header, SEG_DATA, _ModuleEntrySectionName, &size); if (!dataPtr) { continue; } size_t count = size / dataLength; for (size_t i = 0; i < count; ++i) { void data = &dataPtr[i * dataLength]; if (!data) { continue; } _ModuleRegisterEntry *entry = data; //呼叫原有註冊介面 } } 針對於原有使用巨集定義在 +load 註冊的方式,我們另外增加了方法廢棄的標註,這樣能讓業務開發同學在使用過程中感知使用姿勢的改變: static inline attribute((deprecated("NEModuleHubExport is deprecated, please use 'ModuleRegister'"))) void func_loadDeprecated (void) {} #define NEModuleHubExport \ +(void)load { \ // 呼叫原有註冊介面\ func_loadDeprecated(); \ }\ ``` 由於存量 +load 數量太多,我們在第一階段只針對耗時 2ms 以上的前 30 個重點 +load 方法進行了優化處理,我們會在後續的啟動防劣化相關工作中做針對 +load 的監控,並且推動業務方優化治理。

3.1.3 無用程式碼清理

  從前面的分析章節我們知道,不管是 rebase&bind 還是 Objc Init 階段,工程中類及分類的程式碼量都會影響這幾個階段的耗時,尤其是大型 App 中不斷髮展的業務導致程式碼量巨多,而很多業務和程式碼在上線後並沒有用到,所以對於這些無用程式碼的清理也能減少啟動耗時。另外,無用程式碼清理對於包大小的收益更大,雲音樂在包大小優化中做了無用程式碼的清理^6

  那麼,如何才能找出哪些程式碼沒有被用到呢?一般可以分為靜態程式碼掃描和線上大資料統計兩種方式。靜態程式碼掃描還是從 MachO 出發, MachO 中的_objc_selrefs_objc_classrefs兩個 section 中儲存了引用到的 sel 和 class,而在__objc_classlistsection 中儲存了所有的 sel 和 class,通過比較兩者資料的差集就可以獲取沒有被用到的類。而我們知道 OC 是一門動態語言,所以很多類都是執行時呼叫,在刪除類之前需要確保沒有被真正地呼叫。線上大資料統計則採用類元資料中相應的標記為是否被初始化來統計。我們知道,在 OC 中,每個類都有自己的元資料,在元資料中的一個標記位儲存著自己是否被初始化,這個標記位不受任何因素影響,只要有被初始化就會打標記,在 OC 的原始碼中獲取標記位的方式如下:

struct objc_class : objc_object { bool isInitialized() { return getMeta()->data()->flags & RW_INITIALIZED; } } 但這個方法我們是無法直接呼叫的,它是 OC 的方法。但是,要知道類的元資料結構是不會變的,所以我們可以通過自己模擬構建類的元資料結構來獲取 RW_INITIALIZED 標記位資料,從而來確定某個類是否已經初始化,程式碼如下: ```

define FAST_DATA_MASK 0x00007ffffffffff8UL

define RW_INITIALIZED (1<<29)

  • (BOOL)isUsedClass:(NSString )cls { Class metaCls = objc_getMetaClass(cls.UTF8String); if (metaCls) { uint64_t bits = (__bridge void )metaCls + 32; uint32_t data = (uint32_t )(bits & FAST_DATA_MASK); if ((*data & RW_INITIALIZED) > 0) { return YES; } } return NO; } ``` 通過上面的程式碼可以獲取到某個類是否被初始化過,從而統計應用類的使用情況,進一步通過大資料統計分析哪些類可以清理。通過這種方式,我們統計出數千多個類未被使用,在後續的清理中通過排除 AB 測試及業務預埋等業務側程式碼外,我們清理了 300+ 個類。

3.1.4 二進位制重排

  從前面對 Page In 的分析知道,在啟動過程中過多的 Page In 會產生過多的 I/O 操作以及解密驗證操作,這些操作的耗時影響也會比較大。針對 Page In 的影響,我們可以通過二進位制重排來減少這個過程的耗時。我們知道程序在訪問虛擬記憶體的時候是以頁為單位的,而啟動過程中的兩個方法如果在不同的頁,系統就會進行兩次缺頁中斷 Page In 操作來載入這兩個頁。而如果啟動鏈路上的方法分散在不同的頁的話,整個啟動的過程就會產生非常多的 Page In 操作。為了能減少系統因缺頁中斷產生的 Page In 操作,我們需要做的就是把啟動鏈路上所有用到的方法都排在連續的頁上,這樣系統在載入符號的時候就可以減少相應的記憶體頁數量的訪問,從而減少整個啟動過程的耗時,如下圖所示:

  要實現符號的重排,一是需要我們收集整個啟動鏈路上的方法和函式等符號,二是需要生成對應的 order 檔案來配置 ld 中的 Order File 屬性。當工程在編譯的時候,Xcode 會讀取這個 order 檔案,在連結過程中會根據這個檔案中的符號順序來生成對應的 MachO。一般業界中收集符號的方案有兩種:

  • Hook objc_msgSend,只能拿到 OC 以及 swift @objc dynamic 的符號;

  • Clang 插樁,能完美拿到 OC、C/C++、Swift、Block 的符號;

  由於雲音樂工程已經進行了元件化工作,並且二進位制化後全原始碼編譯還有點問題,為了快速驗證問題,我們先選擇了使用 Hook objc_msgSend 的方式去收集符號。Hook objc_msgSend 的方式可以參照上面火焰圖生成時的方案。通過 Hook objc_msgSend 方式收集了啟動鏈路上一萬四千多去重後的符號,並且配置主工程 Order File 屬性,如下圖所示:

在編譯完成後通過驗證 LinkMap 檔案中 #Symbols: 部分符號順序是否和 order 檔案中的符號順序一致來確定是否配置成功,如下圖所示:

  最後就是二進位制重排後的效果驗證了,從網上各類文章我們得知 Instruments 中的 System Trace 可以看到相應的效果。重啟手機後使用 System Trace 執行程式,直到首頁出現後結束執行,找到主執行緒,並且在左下方選擇 Summary:Virtual Memory 就能看到對應的 File Backed Page In 相關的資料了,如下圖所示:

通過多次重啟冷啟動測試我們發現 System Trace 中 File Backed Page In 的資料並不穩定,且波動範圍比較大,二進位制重排優化前後資料難以證明有優化效果。我們想到 Instruments 中 APP Launch 可能也有 Page In 相關的資料,於是,從 App Launch 中同樣找到 Main Thread 後選擇 Summary:Virtual Memory,如下圖所示:

不同的是,從 App Launch 我們發現 File Backed Page In 的資料量級比 System Trace 大很多,相對也穩定很多,並且 App Launch 可以選擇對應的 App LifeCycle 階段來檢視對應的資料,因此我們可以只看第一幀渲染出來之前的資料。經過我們多次的測試比較取平均數發現,優化後只比優化前減少了 50ms 不到。至此,我們十分懷疑二進位制重排的效果。分析了下測試條件,發現我們有兩個點可以改進,一是蘋果對 iOS13 做過優化,所以我們準備了一臺 iOS12 的裝置進行測試,二是 Hook objc_msgSend 符號不能全覆蓋的問題,所以我們花了點時間修復了工程全原始碼編譯,並且通過 Clang 插樁的形式匯出啟動鏈路上的符號。   Clang 插樁主要通過利用 Xcode 自帶的 SanitizerCoverage 工具進行。SanitizerCoverage 是 LLVM 內建的一個程式碼覆蓋率檢測工具,通過配置,在編譯時它能夠根據相應的編譯配置,在每一個自定義的函式內部插入__sanitizer_cov_trace_pc_guard回撥函式,通過實現該函式就能在執行時期拿到被插入該函式的原函式地址,通過函式地址解析出對應的符號,從而能夠收集整個啟動過程中的函式符號。通過在 Other C Flags 中配置-fsanitize-coverage=func, trace-pc-guard ;可以收集 C、C++、OC 方法對應的符號。而如果工程中有 Swift 程式碼的話也需要在 Other Swift Flags 中配置-sanitize-coverage=func; -sanitize=undefined ;這樣就能收集 Swift 方法的符號了。對於使用 Cocoapods 來管理程式碼的工程來說,可以參考開源專案 AppOrderFiles^7 的實現。另外需要注意的是,AppOrderFiles 中的實現是先通過函式地址解析出對應的符號再進行去重,而對於中大型工程來說,啟動過程中的符號呼叫數量可達幾百萬級別,所以這個過程特別的久,可以改為先進行去重再進行函式地址解析符號的方式節省時間。同時,由於雲音樂工程已經開啟了 Cocoapods 中的generate_multiple_pod_projects特性,所以相應的 Podfile 中的配置也需要修改為如下程式碼才能有效配置所有子工程的 Other C Flags/Other Swift Flags,程式碼如下: post_install do |installer| installer.pod_target_subprojects.flat_map { |project| project.targets }.each do |target| target.build_configurations.each do |config| config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard' config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined' end end end   通過 Clang 插樁的方式,我們收集了啟動鏈路上總共 2 萬左右經過去重後的符號,並且在一臺系統版本為iOS12.5.4 的 iPhone 6 Plus 裝置上測試。經過多次測試取平均值,發現二進位制重排後有 180ms 左右的優化。通過結果資料可見,二進位制重排的效果被神話了,並且 iOS13 之前蘋果對 Page In 過程的解密驗證操作才是耗時的大頭,符號的重排影響較小。

3.2 T2階段治理

  T2 階段的治理主要從各個啟動任務的配置和初始化、首頁載入兩個方向出發,這一塊的優化空間也是最大的。從前面可知,由於雲音樂業務的特殊性,廣告業務的影響在 T2 階段佔了很大的比重,所以我們在 T2 階段還對廣告業務做了治理。目前,雲音樂首頁已經做了快取,且因為廣告業務的存在,所以首頁在整個啟動過程中並不是瓶頸,我們把治理的重點放在了各個啟動任務上面。

  而云音樂除了在 AppDelegate 初始化中的部分程式碼沒有去管理以外,其他的啟動任務都已經通過一個啟動任務管理框架管理。所以,在 T2 階段我們主要是通過 Hook objc_msgSend 生成火焰圖和 Instruments 中 App Launch 工具結合啟動任務管理框架來分析整個啟動鏈路的效能,通過分析以及後續的優化,我們總結了以下幾個可優化的方向:

3.2.1 高頻OC方法優化

  OC 是一門動態語言,所有執行時的方法都會通過 objc_msgSend 轉發,從而我們實現了火焰圖來分析各方法的效能。大家都知道動態語言的優勢就是靈活,但是伴隨而來的是效能相對會差些,尤其是在底層庫的應用中影響和範圍也更明顯。

NEHeimdall庫優化

  我們從火焰圖的分析中看到一個底層庫的方法被頻繁的呼叫,彙總起來就有很大的耗時,如下圖所示:

  從放大圖上我們可以看到被頻繁呼叫的方法[[NEHeimdall]disableOptions]。NEHeimdall 是我們一個底層用來做執行時崩潰防護的庫,Hook 了包括容器類、NSString、UIVIew、NSObject 等類,並在方法中做了開關開啟判斷。而像系統底層容器類 NSArray 被廣泛的應用且呼叫頻繁,如果在每次的 objectAtIndex 方法中都去再次呼叫[[NEHeimdall]disableOptions]方法的確是更加耗時了。

  優化思路主要有兩點:一是在 Hook 階段判斷開關狀態來決定是否開啟防護,二是把原先[[NEHeimdall]disableOptions]方法改成 C 方法,相對能提升總的效能。由於第一種方式改動較大且因為 AB 的存在不能保證開關的實時性,最終我們選擇了第二種方式。

JSON解析優化

  在常規大型 App 中 ABTest 是必不可少的元件,而 AB 快取資料的獲取肯定是在啟動鏈路的前期,由於雲音樂工程歷史比較久,目前在 ABTest 資料序列化和反序列化中 JSON 資料的解析還在使用 SBJson 的庫,而 SBJson 會頻繁的呼叫子方法,如下圖所示:

從 N 早之前網友的測評資料^8來看,SBJson 庫的效能是比較差的,如下圖所示:

從上圖也可以看到,對於 JSON 資料的解析來說,系統提供的 NSJSONSerialization 庫的效能反倒是最好的,所以在 ABTest 元件中,我們主要是把 SBJson 移除並且通過 NSJSONSerialization 來做 JSON 資料的解析。工程中還有非啟動鏈路元件對 SBJson 庫有依賴,進一步需要做的就是整個工程都移除對 SBJson 庫的依賴。

3.2.2 runtime遍歷優化

  OC 的動態性給了開發者很多的可擴充套件性,因此大家也都會在平時的開發過程中去做一些騷操作,比如 Hook 以及遍歷符號等,而這些操作都是很耗效能的。

Hook優化

  雲音樂工程中需要 Hook 的場景特別多,不管是通過 Method Swizzle 還是 fishhook 這種遍歷符號表的方式。而我們在分析火焰圖和 Instrument 的時候發現兩種 hook 方式都很影響效能,如下圖所示:

  針對 Hook 的優化想到的有兩點,一是找到效能好的 Hook 庫替換,但是會引入新庫且有一定的改造成本。二是把原先 Hook 的程式碼非同步到子執行緒去執行,但是會遇到子執行緒時機不定的問題,需要確保在對應的類在被應用之前完成 Hook 操作。我們在方式二做了一些嘗試,但是最後沒有上線,後續會去對 Hook 統一管理以便減少重複 Hook 帶來的耗時。

EXTConcreteProtocol優化

  我們知道在 OC 中 protocol 是沒有預設實現的,但是很多場景下如果 protocol 有預設實現的話又特別方便。而 libextobjc 庫中的 EXTConcreteProtocol 可以提供協議預設實現的能力,通過 Instrument 我們發現 ext_loadConcreteProtocol 方法特別耗時,如下圖所示:

通過檢視原始碼發現 ext_loadConcreteProtocol 也是通過 runtime 遍歷去達到協議擁有預設實現的能力,考慮到現有業務只有一個地方使用到了 EXTConcreteProtocol,但是對啟動耗時的影響又特別大,所以對 EXTConcreteProtocol 的優化就是移除依賴,改造業務程式碼實現,通過對 NSObject 增加分類並繼承協議也能達到協議有預設實現的能力。

3.2.3 網路相關優化

  在雲音樂工程中,涉及到網路相關影響啟動效能的主要有兩點:Cookies 設定同步問題、UserAgent 生成和使用。

Cookies設定同步優化

  對常規 App 來說都會有三方跳轉到 H5 的需求,在雲音樂中之前為了同步 Cookies 會在啟動鏈路上預先生成一個 WKWebview 的物件,而 WKWebview 例項的建立是非常耗時的。針對這一塊,我們主要是做了懶載入來優化,把 WKWebview 物件的建立放到了真的有 H5 頁面開啟的時候,並且在建立的時候再去同步 Cookies。

UserAgent每次生成優化

  UserAgent 對於請求來說是必不可少的引數,而在雲音樂中 UserAgent 又是通過臨時建立 UIWebView 物件並通過執行navigator.userAgent來獲取的,並且每次啟動的時候都會去重新建立後重新獲取,耗時點主要也是在 UIWebView 物件的建立。通過檢視 UserAgent 具體內容發現,除了系統版本號和 App 版本號會隨著升級更新以外,其他的內容都不會變。因此,我們針對 UserAgent 的使用做了快取,並且在每次系統更新或者 App 更新的時候主動去更新快取,以降低對啟動效能的影響,如下圖所示:

3.2.4 系統介面

  在分析火焰圖和 Instrument 資料的過程中,我們也發現了一些系統介面的效能對整個啟動鏈路的耗時很有影響,目前發現的主要有兩個介面: - NSBundle 中的 bundleWithIdentifier: 介面;

  • UIApplication 中的 beginReceivingRemoteControlEvents 介面;

  雲音樂這邊拿Bundle的時候自己做了一層封裝,通過podName獲取對應Bundle。內部實現中先通過系統 bundleWithIdentifier: 介面的形式查詢,找不到的情況下再通過 mainBundle 尋找 URL 的方式查詢。通過分析發現系統介面 bundleWithIdentifier: 在第一次呼叫時的效能很差,而通過 mainBundle獲取 Bundle 的效能很高。經驗證 mainBundle 方式都能獲取到 Bundle,所以我們對此進行了順序切換,優先通過 mainBundle 查詢 Bundle,如下圖所示:

  beginReceivingRemoteControlEvents 介面的使用場景主要是需要在鎖屏介面上顯示相關的資訊和按鈕,就必須要先開啟遠端控制事件(Remote Control Event)。雲音樂作為一個音樂軟體在播放音樂的時候就需要顯示相關資訊。之前的做法是播放相關的服務會在啟動的時候往 IOC 中註冊對應的例項。為此我們對 IOC 底層做了改造,支援相關例項的懶載入,把相關服務在用到的時候再去初始化例項,這樣就把 beginReceivingRemoteControlEvents 介面對啟動的影響延後了,對比如下圖所示:

3.2.5 廣告業務優化

  在對廣告業務的深入分析以後,我們發現目前雲音樂的廣告投放物件包括會員和非會員使用者。會員使用者投放的廣告比較少,一般是內部運營活動,而內部運營活動是不需要去廣告聯盟拉取資料的。並且從程式碼層面來說,廣告業務的介面請求時機要等到執行到廣告業務程式碼才會去發出,時機已經偏晚了。針對這兩個情況,我們對廣告業務做了相應的優化: - 會員使用者廣告業務介面請求開關動態配置; - 廣告業務介面時機前置;

  內部運營活動一般會是運營配置,並且會有投放物件的選項,所以把這個開關動態配置的能力放到了後端,當運營配置的活動投放物件需要有會員的時候才會把對應的開關開啟,非運營活動狀態開關都是關閉狀態,會員使用者不會去請求介面。同時,對於非會員使用者來說廣告業務的影響也是不能忍的,在目前狀態基礎上我們把廣告業務介面的請求時機前置到了網路庫初始化之後即發出,可以縮短請求時長對啟動的影響,從灰度資料來看平均能優化 300~400ms 左右。

3.2.6 其他業務層面優化

  另外有一些業務拓展或者說功能新增帶來的對啟動效能有影響的點,比如 iPhone 支援一鍵登入後號碼的讀取。雲音樂在支援一鍵登入的需求後會通過 SDK 去讀取運營商是否支援一鍵登入並獲取號碼,在之前的設計中,不管使用者是否登入都會去判斷並獲取,從 SDK 獲取也有一定的耗時,我們改成了只在未登入使用者的情況下獲取。

  還有一些非共性的業務程式碼使用姿勢的問題我們也做了很多優化,就不在這裡一一羅列了。

四.總結

  經過階段性的啟動效能專項優化,雲音樂 App 的啟動效能相比之前是有了一定的提升,到目前為止效能提升30%+。不過對於啟動效能優化來說,所有優化的措施只是針對目前 App 遇到的情況處理的。而常規大型 App 的業務迭代非常的頻繁,業務需求量也特別的多,在日常開發階段如何能夠檢測、攔截對啟動效能有影響的程式碼,App 在上線後如何能夠快速定位到新版本有劣化且劣化後的歸因,甚至如何感知單使用者對啟動效能的體感資料。這是在經過了一階段啟動治理之後需要去考慮和實踐的,我們目前也正在完善整個啟動效能的防劣化系統,等到上線並穩定執行後也會進一步的分享一些防劣思路。

  從前面我們也可以知道,廣告業務對雲音樂 App 整個啟動效能的影響是特別大的,尤其是介面響應時間的不確定性,而廣告又涉及到收入,所以這塊的短期改動比較難,雖然我們這次針對會員使用者做了優化,後續還會進一步的分析廣告業務並做一定的優化。還有一些業務層面的優化比如 tabbar 懶載入、首頁載入,以及常規的 +load 等方面會進一步的治理。

PS:附上雲音樂優化實踐小總結表:

| 階段 | 優化方向 | 可能性收益 | 分析工具/方法 | | ------------ | ----------- | ----------- | ------------------------- | | T1/pre-main | 動態庫轉靜態庫 | 平均20-30ms/庫 | 解包/Xcode環境變數 | | | +load | 看具體業務 | Hook load彙總 | | | 無用程式碼清理 | 看具體業務 | 大資料統計類使用率 | | | 二進位制重排 | 50-200ms | Hook objc_msgSend/Clang插樁 | | T2/post-main | 高頻OC方法 | 200-300ms | 火焰圖 | | | runtime符號遍歷 | 300-500ms | 火焰圖/Instrument | | | 網路相關 | 200-300ms | 火焰圖/Instrument | | | 系統介面 | 100-200ms | 火焰圖/Instrument | | | 業務影響 | 300-400ms | 火焰圖/Instrument |

五.參考資料

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!