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,那麼幾個版本下來,冷啓動時長就會明顯增加很多。

四、治理思路

冷啓動性能問題的治理目標主要有三個:

  1. 解決存量問題:優化當前性能瓶頸點,優化啓動流程,縮短冷啓動時間。
  2. 管控增量問題:冷啓動流程規範化,通過代碼範式和文檔指導後續冷啓動過程代碼的維護,控制時間增量。
  3. 完善監控:完善冷啓動性能指標監控,收集更詳細的數據,及時發現性能問題。

五、規範啓動流程

截止至2017年底,美團外賣用戶數已達2.5億,而美團外賣App也已完成了從支撐單一業務的App到支持多業務的平臺型App的演進(美團外賣iOS多端複用的推動、支撐與思考),公司的一些新興業務也陸續集成到外賣App當中。下面是外賣App的架構圖,外賣的架構主要分爲三層,底層是基礎組件層,中層是外賣平臺層,平臺層向下管理基礎組件,向上爲業務組件提供統一的適配接口,上層是基礎組件層,包括外賣業務拆分的子業務組件(外賣App和美團App中的外賣頻道可以複用子業務組件)和接入的其他非外賣業務。

App的平臺化爲業務方提供了高效、標準的統一平臺,但與此同時,平臺化和業務的快速迭代也給冷啓動帶來了問題:

  1. 現有的啓動項堆積嚴重,拖慢啓動速度。
  2. 新的啓動項缺乏添加範式,雜亂無章,修改風險大,難以閱讀和維護。

面對這個問題,我們首先梳理了目前啓動流程中所有的啓動項,然後針對App平臺化設計了新的啓動項管理方式:__分階段啓動和啓動項自注冊__

分階段啓動

早期由於業務比較簡單,所有啓動項都是不加以區分,簡單地堆積到didFinishLaunchingWithOptions方法中,但隨着業務的增加,越來越多的啓動項代碼堆積在一起,性能較差,代碼臃腫而混亂。

通過對SDK的梳理和分析,我們發現啓動項也需要根據所完成的任務被分類,有些啓動項是需要剛啓動就執行的操作,如Crash監控、統計上報等,否則會導致信息收集的缺失;有些啓動項需要在較早的時間節點完成,例如一些提供用戶信息的SDK、定位功能的初始化、網絡初始化等;有些啓動項則可以被延遲執行,如一些自定義配置,一些業務服務的調用、支付SDK、地圖SDK等。我們所做的分階段啓動,首先就是把啓動流程合理地劃分爲若干個啓動階段,然後依據每個啓動項所做的事情的優先級把它們分配到相應的啓動階段,優先級高的放在靠前的階段,優先級低的放在靠後的階段。

下面是我們對美團外賣App啓動階段進行的重新定義,對所有啓動項進行的梳理和重新分類,把它們對應到合理的啓動階段。這樣做一方面可以推遲執行那些不必過早執行的啓動項,縮短啓動時間;另一方面,把啓動項進行歸類,方便後續的閱讀和維護。然後把這些規則落地爲啓動項的維護文檔,指導後續啓動項的新增和維護。

通過上面的工作,我們梳理出了十幾個可以推遲執行的啓動項,佔所有啓動項的30%左右,有效地優化了啓動項所佔的這部分冷啓動時間。

啓動項自注冊

確定了啓動項分階段啓動的方案後,我們面對的問題就是如何執行這些啓動項。比較容易想到的方案是:在啓動時創建一個啓動管理器,然後讀取所有啓動項,然後當時間節點到來時由啓動器觸發啓動項執行。這種方式存在兩個問題:

  1. 所有啓動項都要預先寫到一個文件中(在.m文件import,或用.plist文件組織),這種中心化的寫法會導致臃腫的代碼,難以閱讀維護。
  2. 啓動項代碼無法複用:啓動項無法收斂到子業務庫內部,在外賣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()是一個系統調用。操作系統首先爲進程分配一段內存空間,然後執行如下操作:

  1. 把App對應的可執行文件加載到內存。
  2. 把Dyld加載到內存。
  3. Dyld進行動態鏈接。

下面我們簡要分析一下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時間的因素:

  1. 動態庫加載越多,啓動越慢。
  2. ObjC類,方法越多,啓動越慢。
  3. ObjC的+load越多,啓動越慢。
  4. C的constructor函數越多,啓動越慢。
  5. 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+的時間。優化內容總結如下:

優化點 舉例
發現隱晦的耗時操作 發現在冷啓動過程中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監控的核心指標之一,就是冷啓動時間。

冷啓動開始&結束時間節點

  1. 結束時間點:結束時間比較好確定,我們可以將首頁某些視圖元素的展示作爲首頁加載完成的標誌。
  2. 開始時間點:一般情況下,我們都是在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性能監控,高可用基礎設施支撐相關推進工作。

招聘

美團外賣長期招聘Android、iOS、FE高級/資深工程師和技術專家,Base北京、上海、成都,歡迎有興趣的同學投遞簡歷到[email protected]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章