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

語言: CN / TW / HK

一.背景

  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_classlist section 中存儲了所有的 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

五.參考資料

https://developer.apple.com/videos/play/wwdc2019/423/ https://developer.apple.com/videos/play/wwdc2022/110362/ https://github.com/tripleCC/Laboratory/tree/master/HookLoadMethods/A4LoadMeasure https://iosre.com/t/hookzz-hack-objc-msgsend/9422 https://github.com/everettjf/AppleTrace https://mp.weixin.qq.com/s/GTbhvzMA-W0ANlars7mKog https://github.com/yulingtianxia/AppOrderFiles https://blog.csdn.net/arthurchenjs/article/details/7009995

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