冷啓動時長是App性能的重要指標,做爲用戶體驗的第一道「門」,直接決定着用戶對App的第一印象。美團外賣iOS客戶端從2013年11月開始,歷經幾十個版本的迭代開發,產品形態不斷完善,業務功能日趨複雜;同時外賣App也已經由原來的獨立業務App演進成爲一個平臺App,陸續接入了閃購、跑腿等其餘新業務。所以,更多更復雜的工做須要在App冷啓動的時候被完成,這給App的冷啓動性能帶來了挑戰。對此,咱們團隊基於業務形態的變化和外賣App的特色,對冷啓動進行了持續且有針對性的優化工做,目的就是爲了呈現更加流暢的用戶體驗。html
通常而言,你們把iOS冷啓動的過程定義爲:從用戶點擊App圖標開始到appDelegate didFinishLaunching方法執行完成爲止。這個過程主要分爲兩個階段:ios
然而,當didFinishLaunchingWithOptions執行完成時,用戶尚未看到App的主界面,也不能開始使用App。例如在外賣App中,App還須要作一些初始化工做,而後經歷定位、首頁請求、首頁渲染等過程後,用戶才能真正看到數據內容並開始使用,咱們認爲這個時候冷啓動纔算完成。咱們把這個過程定義爲T3。git
綜上,外賣App把冷啓動過程定義爲:從用戶點擊App圖標開始到用戶能看到App主界面內容爲止這個過程,即T1+T2+T3。在App冷啓動過程中,這三個階段中的每一個階段都存在不少能夠被優化的點。github
美團外賣iOS客戶端通過幾十個版本的迭代開發後,在冷啓動過程當中已經積累了若干性能問題,解決這些性能瓶頸是冷啓動優化工做的首要目標,這些問題主要包括:swift
注:啓動項的定義,在App啓動過程當中須要被完成的某項工做,咱們稱之爲一個啓動項。例如某個SDK的初始化、某個功能的預加載等。緩存
通常狀況下,在App早期階段,冷啓動不會有明顯的性能問題。冷啓動性能問題也不是在某個版本忽然出現的,而是隨着版本迭代,App功能愈來愈複雜,啓動任務愈來愈多,冷啓動時間也一點點延長。最後當咱們注意到,並想要優化它的時候,這個問題已經變得很棘手了。外賣App的性能問題增量主要來自啓動項的增長,隨着版本迭代,啓動項任務簡單粗暴地堆積在啓動流程中。若是每一個版本冷啓動時間增長0.1s,那麼幾個版本下來,冷啓動時長就會明顯增長不少。微信
冷啓動性能問題的治理目標主要有三個:網絡
截止至2017年末,美團外賣用戶數已達2.5億,而美團外賣App也已完成了從支撐單一業務的App到支持多業務的平臺型App的演進(美團外賣iOS多端複用的推進、支撐與思考),公司的一些新興業務也陸續集成到外賣App當中。下面是外賣App的架構圖,外賣的架構主要分爲三層,底層是基礎組件層,中層是外賣平臺層,平臺層向下管理基礎組件,向上爲業務組件提供統一的適配接口,上層是基礎組件層,包括外賣業務拆分的子業務組件(外賣App和美團App中的外賣頻道能夠複用子業務組件)和接入的其餘非外賣業務。session
App的平臺化爲業務方提供了高效、標準的統一平臺,但與此同時,平臺化和業務的快速迭代也給冷啓動帶來了問題:多線程
面對這個問題,咱們首先梳理了目前啓動流程中全部的啓動項,而後針對App平臺化設計了新的啓動項管理方式:分階段啓動和啓動項自注冊
早期因爲業務比較簡單,全部啓動項都是不加以區分,簡單地堆積到didFinishLaunchingWithOptions方法中,但隨着業務的增長,愈來愈多的啓動項代碼堆積在一塊兒,性能較差,代碼臃腫而混亂。
經過對SDK的梳理和分析,咱們發現啓動項也須要根據所完成的任務被分類,有些啓動項是須要剛啓動就執行的操做,如Crash監控、統計上報等,不然會致使信息收集的缺失;有些啓動項須要在較早的時間節點完成,例如一些提供用戶信息的SDK、定位功能的初始化、網絡初始化等;有些啓動項則能夠被延遲執行,如一些自定義配置,一些業務服務的調用、支付SDK、地圖SDK等。咱們所作的分階段啓動,首先就是把啓動流程合理地劃分爲若干個啓動階段,而後依據每一個啓動項所作的事情的優先級把它們分配到相應的啓動階段,優先級高的放在靠前的階段,優先級低的放在靠後的階段。
下面是咱們對美團外賣App啓動階段進行的從新定義,對全部啓動項進行的梳理和從新分類,把它們對應到合理的啓動階段。這樣作一方面能夠推遲執行那些沒必要過早執行的啓動項,縮短啓動時間;另外一方面,把啓動項進行歸類,方便後續的閱讀和維護。而後把這些規則落地爲啓動項的維護文檔,指導後續啓動項的新增和維護。
經過上面的工做,咱們梳理出了十幾個能夠推遲執行的啓動項,佔全部啓動項的30%左右,有效地優化了啓動項所佔的這部分冷啓動時間。
肯定了啓動項分階段啓動的方案後,咱們面對的問題就是如何執行這些啓動項。比較容易想到的方案是:在啓動時建立一個啓動管理器,而後讀取全部啓動項,而後當時間節點到來時由啓動器觸發啓動項執行。這種方式存在兩個問題:
而咱們但願的方式是,啓動項維護方式可插拔,啓動項之間、業務模塊之間不耦合,且一次實現可在兩端複用。下圖是咱們採用的啓動項管理方式,咱們稱之爲啓動項的自注冊:一個啓動項定義在子業務模塊內部,被封裝成一個方法,而且自聲明啓動階段(例如一個啓動項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階段執行 // 啓動項代碼B }
在啓動流程中,在啓動階段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()以前操做系統所作的工做就是把可執行文件(Mach-O格式)加載到內存空間,而後加載動態連接庫dyld,再執行一系列動態連接操做和初始化操做的過程(加載、綁定、及初始化方法)。這方面的資料網上比較多,但重複性較高,此處附上一篇WWDC的Topic:Optimizing App Startup Time 。
真正的加載過程從exec()函數開始,exec()是一個系統調用。操做系統首先爲進程分配一段內存空間,而後執行以下操做:
下面咱們簡要分析一下Dyld在各階段所作的事情:
階段 | 工做 | |
---|---|---|
加載動態庫 | Dyld從主執行文件的header獲取到須要加載的所依賴動態庫列表,而後它須要找到每一個 dylib,而應用所依賴的 dylib 文件可能會再依賴其餘 dylib,因此所須要加載的是動態庫列表一個遞歸依賴的集合 | |
Rebase和Bind | - Rebase在Image內部調整指針的指向。在過去,會把動態庫加載到指定地址,全部指針和數據對於代碼都是對的,而如今地址空間佈局是隨機化,因此須要在原來的地址根據隨機的偏移量作一下修正 - Bind是把指針正確地指向Image外部的內容。這些指向外部的指針被符號(symbol)名稱綁定,dyld須要去符號表裏查找,找到symbol對應的實現 |
|
Objc setup | - 註冊Objc類 (class registration) - 把category的定義插入方法列表 (category registration) - 保證每個selector惟一 (selector uniquing) |
|
Initializers | - Objc的+load()函數 - C++的構造函數屬性函數 - 非基本類型的C++靜態全局變量的建立(一般是類或結構體) |
最後 dyld 會調用 main() 函數,main() 會調用 UIApplicationMain(),before main()的過程也就此完成。
瞭解完main()以前的加載過程後,咱們能夠分析出一些影響T1時間的因素:
針對以上幾點,咱們作了以下一些優化工做:
隨着業務的迭代,不斷有新的代碼加入,同時也會廢棄掉無用的代碼和資源文件,可是工程中常常有無用的代碼和文件被遺棄在角落裏,沒有及時被清理掉。這些無用的部分一方面增大了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+無用的方法。
目前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是Xcode自帶的時間性能分析工具,它按照固定的時間間隔來跟蹤每個線程的堆棧信息,經過統計比較時間間隔之間的堆棧狀態,來推算某個方法執行了多久,並得到一個近似值。Time Profiler的使用方法網上有不少使用教程,這裏咱們也不過多介紹,附上一篇使用文檔:Instruments Tutorial with Swift: Getting Started。
除了Time Profiler,火焰圖也是一個分析CPU耗時的利器,相比於Time Profiler,火焰圖更加清晰。火焰圖分析的產物是一張調用棧耗時圖片,之因此稱爲火焰圖,是由於整個圖形看起來就像一團跳動的火焰,火焰尖部是調用棧的棧頂,底部是棧底,縱向表示調用棧的深度,橫向表示消耗的時間。一個格子的寬度越大,越說明其多是瓶頸。分析火焰圖主要就是看那些比較寬大的火苗,特別留意那些相似「平頂山」的火苗。下面是美團平臺開發的性能分析工具-Caesium的分析效果圖:
經過對火焰圖的分析,咱們發現了冷啓動過程當中存在着很多問題,併成功優化了0.3S+的時間。優化內容總結以下:
優化點 | 舉例 |
---|---|
發現隱晦的耗時操做 | 發如今冷啓動過程當中archive了一張圖片,很是耗時 |
推遲&減小I/O操做 | 減小動畫圖片組的數量,替換大圖資源等。由於相比於內存操做,硬盤I/O是很是耗時的操做 |
推遲執行的一些任務 | 如一些資源的I/O,一些佈局邏輯,對象的建立時機等 |
在冷啓動過程當中,有不少操做是串行執行的,若干個任務串行執行,時間必然比較長。若是能變串行爲並行,那麼冷啓動時間就可以大大縮短。
如今許多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監控的核心指標之一,就是冷啓動時間。
#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性能監控,高可用基礎設施支撐相關推動工做。
美團外賣長期招聘Android、iOS、FE高級/資深工程師和技術專家,Base北京、上海、成都,歡迎有興趣的同窗投遞簡歷到chenhang03@meituan.com。
發現文章有錯誤、對內容有疑問,均可以關注美團技術團隊微信公衆號(meituantech),在後臺給咱們留言。咱們每週會挑選出一位熱心小夥伴,送上一份精美的小禮品。快來掃碼關注咱們吧!