美團外賣iOS App冷啟動治理
一、背景
冷啟動時長是App性能的重要指標,作為用戶體驗的第一道“門”,直接決定著用戶對App的第一印象。美團外賣iOS客戶端從2013年11月開始,歷經幾十個版本的迭代開發,產品形態不斷完善,業務功能日趨復雜;同時外賣App也已經由原來的獨立業務App演進成為一個平臺App,陸續接入了閃購、跑腿等其他新業務。因此,更多更復雜的工作需要在App冷啟動的時候被完成,這給App的冷啟動性能帶來了挑戰。對此,我們團隊基于業務形態的變化和外賣App的特點,對冷啟動進行了持續且有針對性的優化工作,目的就是為了呈現更加流暢的用戶體驗。
二、冷啟動定義
一般而言,大家把iOS冷啟動的過程定義為:從用戶點擊App圖標開始到appDelegate didFinishLaunching方法執行完成為止。這個過程主要分為兩個階段:
- T1:main()函數之前,即操作系統加載App可執行文件到內存,然后執行一系列的加載&鏈接等工作,最后執行至App的main()函數。
- T2:main()函數之后,即從main()開始,到appDelegate的didFinishLaunchingWithOptions方法執行完畢。
然而,當didFinishLaunchingWithOptions執行完成時,用戶還沒有看到App的主界面,也不能開始使用App。例如在外賣App中,App還需要做一些初始化工作,然后經歷定位、首頁請求、首頁渲染等過程后,用戶才能真正看到數據內容并開始使用,我們認為這個時候冷啟動才算完成。我們把這個過程定義為T3。
綜上,外賣App把冷啟動過程定義為:從用戶點擊App圖標開始到用戶能看到App主界面內容為止這個過程,即T1+T2+T3。在App冷啟動過程當中,這三個階段中的每個階段都存在很多可以被優化的點。
三、問題現狀
性能存量問題
美團外賣iOS客戶端經過幾十個版本的迭代開發后,在冷啟動過程中已經積累了若干性能問題,解決這些性能瓶頸是冷啟動優化工作的首要目標,這些問題主要包括:
注:啟動項的定義,在App啟動過程中需要被完成的某項工作,我們稱之為一個啟動項。例如某個SDK的初始化、某個功能的預加載等。
性能增量問題
一般情況下,在App早期階段,冷啟動不會有明顯的性能問題。冷啟動性能問題也不是在某個版本突然出現的,而是隨著版本迭代,App功能越來越復雜,啟動任務越來越多,冷啟動時間也一點點延長。最后當我們注意到,并想要優化它的時候,這個問題已經變得很棘手了。外賣App的性能問題增量主要來自啟動項的增加,隨著版本迭代,啟動項任務簡單粗暴地堆積在啟動流程中。如果每個版本冷啟動時間增加0.1s,那么幾個版本下來,冷啟動時長就會明顯增加很多。
四、治理思路
冷啟動性能問題的治理目標主要有三個:
- 解決存量問題:優化當前性能瓶頸點,優化啟動流程,縮短冷啟動時間。
- 管控增量問題:冷啟動流程規范化,通過代碼范式和文檔指導后續冷啟動過程代碼的維護,控制時間增量。
- 完善監控:完善冷啟動性能指標監控,收集更詳細的數據,及時發現性能問題。
五、規范啟動流程
截止至2017年底,美團外賣用戶數已達2.5億,而美團外賣App也已完成了從支撐單一業務的App到支持多業務的平臺型App的演進(美團外賣iOS多端復用的推動、支撐與思考),公司的一些新興業務也陸續集成到外賣App當中。下面是外賣App的架構圖,外賣的架構主要分為三層,底層是基礎組件層,中層是外賣平臺層,平臺層向下管理基礎組件,向上為業務組件提供統一的適配接口,上層是基礎組件層,包括外賣業務拆分的子業務組件(外賣App和美團App中的外賣頻道可以復用子業務組件)和接入的其他非外賣業務。
App的平臺化為業務方提供了高效、標準的統一平臺,但與此同時,平臺化和業務的快速迭代也給冷啟動帶來了問題:
- 現有的啟動項堆積嚴重,拖慢啟動速度。
- 新的啟動項缺乏添加范式,雜亂無章,修改風險大,難以閱讀和維護。
面對這個問題,我們首先梳理了目前啟動流程中所有的啟動項,然后針對App平臺化設計了新的啟動項管理方式:分階段啟動和啟動項自注冊
分階段啟動
早期由于業務比較簡單,所有啟動項都是不加以區分,簡單地堆積到didFinishLaunchingWithOptions方法中,但隨著業務的增加,越來越多的啟動項代碼堆積在一起,性能較差,代碼臃腫而混亂。
通過對SDK的梳理和分析,我們發現啟動項也需要根據所完成的任務被分類,有些啟動項是需要剛啟動就執行的操作,如Crash監控、統計上報等,否則會導致信息收集的缺失;有些啟動項需要在較早的時間節點完成,例如一些提供用戶信息的SDK、定位功能的初始化、網絡初始化等;有些啟動項則可以被延遲執行,如一些自定義配置,一些業務服務的調用、支付SDK、地圖SDK等。我們所做的分階段啟動,首先就是把啟動流程合理地劃分為若干個啟動階段,然后依據每個啟動項所做的事情的優先級把它們分配到相應的啟動階段,優先級高的放在靠前的階段,優先級低的放在靠后的階段。
下面是我們對美團外賣App啟動階段進行的重新定義,對所有啟動項進行的梳理和重新分類,把它們對應到合理的啟動階段。這樣做一方面可以推遲執行那些不必過早執行的啟動項,縮短啟動時間;另一方面,把啟動項進行歸類,方便后續的閱讀和維護。然后把這些規則落地為啟動項的維護文檔,指導后續啟動項的新增和維護。
通過上面的工作,我們梳理出了十幾個可以推遲執行的啟動項,占所有啟動項的30%左右,有效地優化了啟動項所占的這部分冷啟動時間。
啟動項自注冊
確定了啟動項分階段啟動的方案后,我們面對的問題就是如何執行這些啟動項。比較容易想到的方案是:在啟動時創建一個啟動管理器,然后讀取所有啟動項,然后當時間節點到來時由啟動器觸發啟動項執行。這種方式存在兩個問題:
- 所有啟動項都要預先寫到一個文件中(在.m文件import,或用.plist文件組織),這種中心化的寫法會導致臃腫的代碼,難以閱讀維護。
- 啟動項代碼無法復用:啟動項無法收斂到子業務庫內部,在外賣App和美團App中要重復實現,和外賣App平臺化的方向不符。
而我們希望的方式是,啟動項維護方式可插拔,啟動項之間、業務模塊之間不耦合,且一次實現可在兩端復用。下圖是我們采用的啟動項管理方式,我們稱之為啟動項的自注冊:一個啟動項定義在子業務模塊內部,被封裝成一個方法,并且自聲明啟動階段(例如一個啟動項A,在獨立App中可以聲明為在willFinishLaunch階段被執行,在美團App中則聲明在resignActive階段被執行)。這種方式下,啟動項即實現了兩端復用,不相關的啟動項互相隔離,添加/刪除啟動項都更加方便。
那么如何給一個啟動項聲明啟動階段?又如何在正確的時機觸發啟動項的執行呢?在代碼上,一個啟動項最終都會對應到一個函數的執行,所以在運行時只要能獲取到函數的指針,就可以觸發啟動項。美團平臺開發的組件啟動治理基建Kylin正是這樣做的:Kylin的核心思想就是在編譯時把數據(如函數指針)寫入到可執行文件的__DATA段中,運行時再從__DATA段取出數據進行相應的操作(調用函數)。
為什么要用借用__DATA段呢?原因就是為了能夠覆蓋所有的啟動階段,例如main()之前的階段。
Kylin實現原理簡述:Clang 提供了很多的編譯器函數,它們可以完成不同的功能。其中一種就是 section() 函數,section()函數提供了二進制段的讀寫能力,它可以將一些編譯期就可以確定的常量寫入數據段。 在具體的實現中,主要分為編譯期和運行時兩個部分。在編譯期,編譯器會將標記了 attribute((section())) 的數據寫到指定的數據段中,例如寫一個{key(key代表不同的啟動階段), *pointer}對到數據段。到運行時,在合適的時間節點,在根據key讀取出函數指針,完成函數的調用。
上述方式,可以封裝成一個宏,來達到代碼的簡化,以調用宏 KLN_STRINGS_EXPORT("Key", "Value")為例,最終會被展開為:
- __attribute__((used, section("__DATA" "," "__kylin__"))) static const KLN_DATA __kylin__0 = (KLN_DATA){(KLN_DATA_HEADER){"Key", KLN_STRING, KLN_IS_ARRAY}, "Value"};
使用示例,編譯器把啟動項函數注冊到啟動階段A:
- KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在a.m文件中,通過注冊宏,把啟動項A聲明為在STAGE_KEY_A階段執行
- // 啟動項代碼A
- }
- KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在b.m文件中,把啟動項B聲明為在STAGE_KEY_A階段執行
在啟動流程中,在啟動階段STAGE_KEY_A觸發所有注冊到STAGE_KEY_A時間節點的啟動項,通過對這種方式,幾乎沒有任何額外的輔助代碼,我們用一種很簡潔的方式完成了啟動項的自注冊。
- - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
- // 其他邏輯
- [[KLNKylin sharedInstance] executeArrayForKey:STAGE_KEY_A]; // 在此觸發所有注冊到STAGE_KEY_A時間節點的啟動項
- // 其他邏輯
- return YES;
- }
完成對現有的啟動項的梳理和優化后,我們也輸出了后續啟動項的添加&維護規范,規范后續啟動項的分類原則,優先級和啟動階段。目的是管控性能問題增量,保證優化成果。
六、優化main()之前
在調用main()函數之前,基本所有的工作都是由操作系統完成的,開發者能夠插手的地方不多,所以如果想要優化這段時間,就必須先了解一下,操作系統在main()之前做了什么。main()之前操作系統所做的工作就是把可執行文件(Mach-O格式)加載到內存空間,然后加載動態鏈接庫dyld,再執行一系列動態鏈接操作和初始化操作的過程(加載、綁定、及初始化方法)。這方面的資料網上比較多,但重復性較高,此處附上一篇WWDC的Topic:Optimizing App Startup Time 。
加載過程—從exec()到main()
真正的加載過程從exec()函數開始,exec()是一個系統調用。操作系統首先為進程分配一段內存空間,然后執行如下操作:
- 把App對應的可執行文件加載到內存。
- 把Dyld加載到內存。
- Dyld進行動態鏈接。
下面我們簡要分析一下Dyld在各階段所做的事情:
最后 dyld 會調用 main() 函數,main() 會調用 UIApplicationMain(),before main()的過程也就此完成。
了解完main()之前的加載過程后,我們可以分析出一些影響T1時間的因素:
- 動態庫加載越多,啟動越慢。
- ObjC類,方法越多,啟動越慢。
- ObjC的+load越多,啟動越慢。
- C的constructor函數越多,啟動越慢。
- C++靜態對象越多,啟動越慢。
針對以上幾點,我們做了如下一些優化工作:
代碼瘦身
隨著業務的迭代,不斷有新的代碼加入,同時也會廢棄掉無用的代碼和資源文件,但是工程中經常有無用的代碼和文件被遺棄在角落里,沒有及時被清理掉。這些無用的部分一方面增大了App的包體積,另一方便也拖慢了App的冷啟動速度,所以及時清理掉這些無用的代碼和資源十分有必要。
通過對Mach-O文件的了解,可以知道__TEXT:__objcmethname:中包含了代碼中的所有方法,而\_DATA__objc_selrefs中則包含了所有被使用的方法的引用,通過取兩個集合的差集就可以得到所有未被使用的代碼。核心方法如下,具體可以參考:objc_cover:
- def referenced_selectors(path):
- re_sel = re.compile("__TEXT:__objc_methname:(.+)") //獲取所有方法
- refs = set()
- lines = os.popen("/usr/bin/otool -v -s __DATA __objc_selrefs %s" % path).readlines() # ios & mac //真正被使用的方法
- for line in lines:
- results = re_sel.findall(line)
- if results:
- refs.add(results[0])
- return refs
- }
通過這種方法,我們排查了十幾個無用類和250+無用的方法。
+load優化
目前iOS App中或多或少的都會寫一些+load方法,用于在App啟動執行一些操作,+load方法在Initializers階段被執行,但過多+load方法則會拖慢啟動速度,對于大中型的App更是如此。通過對App中+load的方法分析,發現很多代碼雖然需要在App啟動時較早的時機進行初始化,但并不需要在+load這樣非常靠前的位置,完全是可以延遲到App冷啟動后的某個時間節點,例如一些路由操作。其實+load也可以被當做一種啟動項來處理,所以在替換+load方法的具體實現上,我們仍然采用了上面的Kylin方式。
使用示例:
- // 用WMAPP_BUSINESS_INIT_AFTER_HOMELOADING聲明替換+load聲明即可,不需其他改動
- WMAPP_BUSINESS_INIT_AFTER_HOMELOADING() {
- // 原+load方法中的代碼
- }
- // 在某個合適的時機觸發注冊到該階段的所有方法,如冷啟動結束后
- [[KLNKylin sharedInstance] executeArrayForKey:@kWMAPP_BUSINESS_INITIALIZATION_AFTER_HOMELOADING_KEY]
- }
七、優化耗時操作
在main()之后主要工作是各種啟動項的執行(上面已經敘述),主界面的構建,例如TabBarVC,HomeVC等等。資源的加載,如圖片I/O、圖片解碼、archive文檔等。這些操作中可能會隱含著一些耗時操作,靠單純閱讀非常難以發現,如何發現這些耗時點呢?找到合適的工具就會事半功倍。
Time Profiler
Time Profiler是Xcode自帶的時間性能分析工具,它按照固定的時間間隔來跟蹤每一個線程的堆棧信息,通過統計比較時間間隔之間的堆棧狀態,來推算某個方法執行了多久,并獲得一個近似值。Time Profiler的使用方法網上有很多使用教程,這里我們也不過多介紹,附上一篇使用文檔:Instruments Tutorial with Swift: Getting Started。
火焰圖
除了Time Profiler,火焰圖也是一個分析CPU耗時的利器,相比于Time Profiler,火焰圖更加清晰。火焰圖分析的產物是一張調用棧耗時圖片,之所以稱為火焰圖,是因為整個圖形看起來就像一團跳動的火焰,火焰尖部是調用棧的棧頂,底部是棧底,縱向表示調用棧的深度,橫向表示消耗的時間。一個格子的寬度越大,越說明其可能是瓶頸。分析火焰圖主要就是看那些比較寬大的火苗,特別留意那些類似“平頂山”的火苗。下面是美團平臺開發的性能分析工具-Caesium的分析效果圖:
通過對火焰圖的分析,我們發現了冷啟動過程中存在著不少問題,并成功優化了0.3S+的時間。優化內容總結如下:
八、優化串行操作
在冷啟動過程中,有很多操作是串行執行的,若干個任務串行執行,時間必然比較長。如果能變串行為并行,那么冷啟動時間就能夠大大縮短。
閃屏頁的使用
現在許多App在啟動時并不直接進入首頁,而是會向用戶展示一個持續一小段時間的閃屏頁,如果使用恰當,這個閃屏頁就能幫我們節省一些啟動時間。因為當一個App比較復雜的時候,啟動時首次構建App的UI就是一個比較耗時的過程,假定這個時間是0.2秒,如果我們是先構建首頁UI,然后再在Window上加上這個閃屏頁,那么冷啟動時,App就會實實在在地卡住0.2秒,但是如果我們是先把閃屏頁作為App的RootViewController,那么這個構建過程就會很快。因為閃屏頁只有一個簡單的ImageView,而這個ImageView則會向用戶展示一小段時間,這時我們就可以利用這一段時間來構建首頁UI了,一舉兩得。
緩存定位&首頁預請求
美團外賣App冷啟動過程中一個重要的串行流程就是:首頁定位-->首頁請求-->首頁渲染過程,這三個操作占了整個首頁加載時間的77%左右,所以想要縮短冷啟動時間,就一定要從這三點出發進行優化。
之前串行操作流程如下:
優化后的設計,在發起定位的同時,使用客戶端緩存定位,進行首頁數據的預請求,使定位和請求并行進行。然后當用戶真實定位成功后,判斷真實定位是否命中緩存定位,如果命中,則剛才的預請求數據有效,這樣可以節省大概40%的時間首頁加載時間,效果非常明顯;如果未命中,則棄用預請求數據,重新請求。
九、數據監控
Time Profiler和Caesium火焰圖都只能在線下分析App在單臺設備中的耗時操作,局限性比較大,無法在線上監控App在用戶設備上的表現。外賣App使用公司內部自研的Metrics性能監控系統,長期監控App的性能指標,幫助我們掌握App在線上各種環境下的真實表現,并為技術優化項目提供可靠的數據支持。Metrics監控的核心指標之一,就是冷啟動時間。
冷啟動開始&結束時間節點
結束時間點:結束時間比較好確定,我們可以將首頁某些視圖元素的展示作為首頁加載完成的標志。
開始時間點:一般情況下,我們都是在main()之后才開始接管App,但以main()函數作為冷啟動起始點顯然不合適,因為這樣無法統計到T1時間段。那么,起始時間如何確定呢?目前業界常見的有兩種方法,一是以可執行文件中任意一個類的+load方法的執行時間作為起始點;二是分析dylib的依賴關系,找到葉子節點的dylib,然后以其中某個類的+load方法的執行時間作為起始點。根據Dyld對dylib的加載順序,后者的時機更早。但是這兩種方法獲取的起始點都只在Initializers階段,而Initializers之前的時長都沒有被計入。Metrics則另辟蹊徑,以App的進程創建時間(即exec函數執行時間)作為冷啟動的起始時間。因為系統允許我們通過sysctl函數獲得進程的有關信息,其中就包括進程創建的時間戳。
- #import <sys/sysctl.h>
- #import <mach/mach.h>
- + (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo
- {
- int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
- size_t size = sizeof(*procInfo);
- return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
- }
- + (NSTimeInterval)processStartTime
- {
- struct kinfo_proc kProcInfo;
- if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
- return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
- } else {
- NSAssert(NO, @"無法取得進程的信息");
- return 0;
- }
- }
進程創建的時機非常早。經過實驗,在一個新建的空白App中,進程創建時間比葉子節點dylib中的+load方法執行時間早12ms,比main函數的執行時間早13ms(實驗設備:iPhone 7 Plus (iOS 12.0)、Xcode 10.0、Release 模式)。外賣App線上的數據則更加明顯,同樣的機型(iPhone 7 Plus)和系統版本(iOS 12.0),進程創建時間比葉子節點dylib中的+load方法執行時間早688ms。而在全部機型和系統版本中,這一數據則是878ms。
冷啟動過程時間節點
我們也在App冷啟動過程中的所有關鍵節點打上一連串測速點,Metrics會記錄下測速點的名稱,及其距離進程創建時間的時長。我們沒有采用自動打點的方式,是因為外賣App的冷啟動過程十分復雜,而自動打點無法做到如此細致,并不實用。另外,Metrics記錄的是時間軸上以進程創建時間為原點的一組順序的時間點,而不是一組時間段,是因為順序的時間點可以計算任意兩個時間點之間的距離,即可以將時間點處理成時間段。但是,一組時間段可能無法還原為順序的時間點,因為時間段之間可能并不是首尾相接的,特別是對于異步執行或者多線程的情況。
在測速完畢后,Metrics會統一將所有測速點上報到后臺。下圖是美團外賣App 6.10版本的部分過程節點監控數據截圖:
Metrics還會由后臺對數據做聚合計算,得到冷啟動總時長和各個測速點時長的50分位數、90分位數和95分位數的統計數據,這樣我們就能從宏觀上對冷啟動時長分布情況有所了解。下圖中橫軸為時長,縱軸為上報的樣本數。
十、總結
對于快速迭代的App,隨著業務復雜度的增加,冷啟動時長會不可避免的增加。冷啟動流程也是一個比較復雜的過程,當遇到冷啟動性能瓶頸時,我們可以根據App自身的特點,配合工具的使用,從多方面、多角度進行優化。同時,優化冷啟動存量問題只是冷啟動治理的第一步,因為冷啟動性能問題并不是一日造成的,也不能簡單的通過一次優化工作就能解決,我們需要通過合理的設計、規范的約束,來有效地管控性能問題的增量,并通過持續的線上監控來及時發現并修正性能問題,這樣才能夠長期保證良好的App冷啟動體驗。
作者簡介
郭賽,美團點評資深工程師。2015年加入美團,目前作為外賣iOS團隊主力開發,負責移動端業務開發,業務類基礎設施的建設與維護。
徐宏,美團點評資深工程師。2016年加入美團,目前作為外賣iOS團隊主力開發,負責移動端APM性能監控,高可用基礎設施支撐相關推進工作。