iOS App冷啟動治理:來自美團外賣的實踐
本文主要分享美團外賣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階段執行
// 啟動項程式碼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()之前做了什麼。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:__objc_methname:中包含了程式碼中的所有方法,而__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效能監控,高可用基礎設施支撐相關推進工作。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31559353/viewspace-2284525/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 美團外賣Flutter動態化實踐Flutter
- iOS 類似美團外賣 app 兩個 tableView 聯動效果實現iOSAPPView
- Flutter Web在美團外賣的實踐FlutterWeb
- 美團外賣Android Crash治理之路Android
- 如何實現 iOS App 的冷啟動優化iOSAPP優化
- 美團外賣廣告智慧算力的探索與實踐(二)
- 美團外賣Android平臺化的複用實踐Android
- 美團外賣iOS多端複用的推動、支撐與思考iOS
- 技術解讀:美團外賣Android Crash治理之路!Android
- 美團配送資料治理實踐
- 設計模式在美團外賣營銷業務中的實踐設計模式
- 美團外賣基於GPU的向量檢索系統實踐GPU
- 美團外賣Android Lint程式碼檢查實踐Android
- 美團外賣小程式的探索與實踐丨掘金開發者大會
- iOS高仿美團外賣店鋪主頁iOS
- 廣告平臺化的探索與實踐 | 美團外賣廣告工程實踐專題連載
- 美團外賣推薦情境化智慧流量分發的實踐與探索
- 物料冷啟的推薦實踐
- mpvue實戰開發美團外賣小程式Vue
- TensorFlow在美團外賣推薦場景的GPU訓練優化實踐GPU優化
- Android APP 冷啟動流程AndroidAPP
- Flutter 實現類似美團外賣店鋪頁面滑動效果Flutter
- 高仿美團外賣小程式
- iOS開發實踐-OOM治理iOSOOM
- 美團外賣小程式的探索和實踐(演講內容整理)丨掘金開發者大會
- Twitter iOS App 啟動動畫的實現iOSAPP動畫
- Android自動化頁面測速在美團的實踐Android
- 高德APP啟動耗時剖析與優化實踐(iOS篇)APP優化iOS
- 美團外賣騎手背後的AI技術AI
- 美團酒旅起源資料治理平臺的建設與實踐
- 移動APP啟動慢解決實踐APP
- 馬蜂窩 iOS App 啟動治理:迴歸使用者體驗iOSAPP
- 實況窗助力美團打造鴻蒙原生外賣新體驗,使用者可實時掌握外賣進展鴻蒙
- 小程式外賣專案實踐之-左右選單聯動
- 美團開放平臺SDK自動生成技術與實踐
- iOS應用自動推送的方法研究與實踐iOS
- 推薦系統實踐 0x0a 冷啟動問題
- WMRouter:美團外賣Android開源路由框架Android路由框架