天天看點

支付寶用戶端架構解析:iOS 用戶端啟動性能優化初探

前言

《支付寶用戶端架構解析》系列将從支付寶用戶端的架構設計方案入手,細分拆解用戶端在“容器化架構設計”、“網絡優化”、“性能啟動優化”、“自動化日志收集”、“RPC 元件設計”、“移動應用監控、診斷、定位”等具體實作,帶領大家進一步了解支付寶在用戶端架構上的疊代與優化曆程。

啟動應用是使用者使用任何一款應用最必不可少的操作,從點選 App 圖示到首頁展示,整個啟動過程的性能,嚴重影響着使用者的體驗。支付寶用戶端作為一個超級 App,啟動性能當然是我們關注的重要名額之一,下文将從三方面來介紹支付寶在 iOS 端啟動性能優化的具體設計思路。

啟動時間優化

分析啟動時間之前,先看一下 App 啟動的兩種方式。

 ●  熱啟動:啟動應用時,應用的程序和資料已經存在于系統記憶體中,系統隻是将應用的狀态從背景切換到前台。

 ●  冷啟動:啟動應用時,應用不存在于系統核心的 buffer cache 中,比如應用首次啟動或者重新開機裝置之後的啟動。

相比而言,冷啟動比較重要,通常我們分析啟動時間,都是指的冷啟動。

要想分析啟動時間,還需要了解啟動的過程,iOS應用的啟動大概分以下幾個階段:

支付寶用戶端架構解析:iOS 用戶端啟動性能優化初探

 ●  針對

pre-main()

整個

pre-main()

階段的耗時可以通過添加環境變量

DYLD_PRINT_STATISTICS=1

來擷取,如下圖所示:

支付寶用戶端架構解析:iOS 用戶端啟動性能優化初探

這些階段都是系統進行管控,具體在這些階段内如何進行優化,可以參照 WWDC2013 Session(文章尾部附位址)中提供的方案進行,這裡不詳細說明。

post-main()

:

這部分主要是啟動的架構初始化,首頁資料擷取,首頁渲染等業務邏輯,這一部分我們隻把必要的初始化操作保留,盡量把邏輯後置或者放在 background 線程執行。

這裡的優化方案需要結合實際的業務場景和應用的架構來進行分析,采取對應的政策。

Background Fetch

除了這些通用的優化方案之外,我們也探索了一些創新的方式。

在介紹 Background Fetch 之前,我們先看這樣一個案例:

操作:

首先,啟動支付寶,按 Home 鍵切入背景。然後,重新啟動手機,進入桌面。放置 10-30 秒。

現象:

此時,點選桌面的支付寶(以及淘寶等幾乎所有 App)都與平時的冷啟動一樣,整個啟動過程至少1秒以上。

雖然對冷啟動的時間已經進行了優化,但是能不能每次啟動都做到“秒起”呢?(秒起定義為:啟動時顯示 LaunchScreen 約 500ms 後馬上進入首頁)

我們發現系統提供了這樣一個 Background Fetch 特性,決定在這個上面做一些嘗試。

 ●  Background Fetch 簡介

Background Fetch 類似一種智能的輪詢機制,系統會根據使用者的使用習慣進行适應,在使用者真正啟動應用之前,觸發背景更新,來擷取資料并且更新頁面。

摘自蘋果官方文檔:

Background Fetch lets your app run periodically in the background so that it can update its content. Apps that update their content frequently, such as news apps or social media apps, can use this feature to ensure that their content is always up to date. Downloading data in the background before it is needed minimizes the lag time in displaying that data when the user launches the app.

Background Fetch 具有下面幾個特性:

 ●  系統排程

 ●  适應裝置上各應用的實際使用模式

 ●  對電量和資料的使用敏感

 ●  與應用實際的運作狀态無關

舉個例子,比如使用者習慣在下午1點使用某新聞類app,系統就會學習并且适應這個習慣,在使用者使用之前,背景進行排程來啟動應用并執行資料更新。下圖比較清晰的說明了系統是如何學習使用者的使用模式的。

支付寶用戶端架構解析:iOS 用戶端啟動性能優化初探

針對這樣的政策,大家可能會有疑慮,這種頻繁的背景啟動會不會增加耗電量?

當然不會,系統會根據裝置的電量和資料使用情況來調用頻率控制,避免在非活躍時間頻繁的擷取資料。而且,程序啟動後後存活的時間很短,多數情況下會立即 suspend,對電量影響很少(相比壓背景後很多 app 還要存活接近3分鐘的情況很少)。

 ●  Background Fetch 使用

按照官方資料,Background Fetch 的用法很簡單,整體流程如下圖所示。

支付寶用戶端架構解析:iOS 用戶端啟動性能優化初探

1. Info.plist 中 UIBackgroundModes 節點配置 fetch 數值

2. didFinishLaunching 時配置

  1. [[UIApplication sharedApplication] setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum];

這一步配置的minimum interval,機關是秒,隻是給系統的建議,系統并不會按照給定的時間間隔按規律的喚醒程序。

3. 實作下面的回調,并調用 completionHandler

  1. - (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler

由于 Background Fetch 機制是為了讓App在背景拉取準備資料,但支付寶隻是為了實作”秒起“。調用 completionHandler 後系統将把 App 程序挂起。且系統必須在30秒内調用 completionHandler,否則程序将被殺死。此外根據文檔,系統會根據背景調用 completionHandler 的時間來決定背景喚起App的頻率。是以,認為可以“僞造“1秒的延遲時間,即1秒後調用 completionHandler。類似下面的代碼:

  1. - (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler{

  2. dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{

  3. completionHandler(UIBackgroundFetchResultNewData);

  4. });

  5. }

 ●  Background Fetch 實踐

蘋果推出這種特性的動機在于,背景觸發擷取資料并更新頁面,確定使用者使用時看到的永遠是最新的内容。然而,支付寶隻是為了實作“秒起”,是以看似簡單的實作,卻隐藏着巨大的風險。

在測試過程中就發現了這些問題:

1、程序快速挂起導緻 Sync 成功率下降

灰階期間,開發同學發現同步服務 Sync 成功率下降很多,找來找去發現原因:由于程序喚醒後,網絡長連接配接線程被激活并馬上建立長連接配接,而1秒後調用completionHandler,程序又被挂起。伺服器端的sync消息則發送逾時。

2、程序頻繁挂起、喚醒導緻網絡建連次數增加

系統預測使用者使用 App 的時間,并在使用者實作 App 前喚醒 App,給予 App 背景準備資料的機會。再加上預測的準确性問題,這樣程序被喚醒的次數遠大于使用者使用的次數。程序喚醒後,網絡長連接配接會立即建立。是以導緻網絡建連次數大增,甚至翻倍。

3、由于程序挂起,導緻定時器、延遲調用等時間“與預想的時間不同”

例如,一個間隔間隔時間為60秒的定時器,由于程序挂起時間超過60秒,則下次程序喚醒時會立刻觸發到時。(延遲調用dispatch_after等類似)。對于程序自身來說,可能定時器有點不正常,需要排查所有的定時器邏輯,是否會因為挂起導緻“業務層面的異常”。

4、擷取時間戳

由于程序挂起,導緻前後擷取的時間戳間隔很大。

為解決以上遇到的、以及預測到的問題,經過讨論,決定在 Background Fetch 背景喚醒的時候,不建立長連接配接。

 ●   延後 10 秒調用 completionHandler

背景喚醒存在兩種情況:程序從無到有,程序從挂起到恢複。前者需要有充足的時間完成 App 的背景冷啟動過程,是以定義了 10 秒的時間。

背景 Background Fetch 的時間内不建立長連接配接

”背景 Background Fetch 的時間“定義為:performFetchWithCompletionHandler 被回調并一直到 completionHandler 調用的時間内。

我們維護了一個全局變量 underBackgroundFetch 用于辨別這段時間。處于這段時間的所有網絡請求都被阻塞,并增加重試判斷。App 進入前台(willEnterForeground)時主動重建立立長連接配接。在一些其他背景需要建立長連接配接的情況下(例如 WatchApp 的連接配接、PUSH 快速回複),也主動修改标記,并通知網絡層建立長連接配接。underBackgroundFetc 的修改是在主線程執行,但網絡長連接配接的建立是在子線程,且程序被喚醒後早于 underBackgroundFetch 的修改。目前首次回調 performFetchWithCompletionHandler 時,仍然會存在這個“間隙”導緻網絡長連接配接建立,但後續的 Background Fetch 時狀态是準确的。(這個間隙如何更加準确,必要性及方案在讨論中,目前還沒有帶來無法解決的問題)

背景不建連導緻的網絡請求阻塞異常,避免産生 Toast 等彈窗

為擷取所有在背景 Background Fetch 時間内被攔截的 RPC,攔截操作增加了埋點。灰階期間收集出所有的 RPC,并逐個找到 Owner,讓大家評估影響、以及避免産生 Toast 等彈窗提示。確定所有 RPC 異常的最外層異常捕獲處,不因 RPC 攔截的異常而 Toast。

逾時判斷

由于程序挂起導緻的定時器、延遲調用的逾時判斷,需要修改業務邏輯。不能過度依賴假想的時序,程序運作在作業系統上,不能受程序的挂起與恢複影響。

雖然使用這麼多的方案來保證應用的穩定性,但是實際上線也避免不了一些奇怪的問題:

1、completionHandler 調用兩次

灰階期間發現少量使用者存在 completionHandler 調用兩次導緻閃退。撈取使用者日志發現 performFetchWithCompletionHandler 在1秒内連續被系統回調了兩次。而 completionHandler 被存儲為 AppDelegate 的成員變量,在10秒逾時到期後,同一個 completionHandler 被調用了兩次。

為避免此問題,可以避免采用成員變量存儲 completionHandler ,而采用 dispatch_after 來直接讓 block 捕獲 completionHandler,但這樣又會帶來另一個 libdispatch 中 block 為空的極小機率的閃退。

是以采用成員變量存儲 completionHandler,而在 performFetchWithCompletionHandler 的首行判斷存儲的 completionHandler 與傳入的 completionHandler 是否相同。大緻代碼如下:

  1. - (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler

  2. {

  3. if(_backgroundFetchCompletionHandler && _backgroundFetchCompletionHandler != completionHandler){

  4. // 避免performFetch被快速重複調用,如果completionHandler不同,則先完成上一個completionHandler;如果相同,則避免調用兩次。

  5. [self callBackgroundFetchCompletionHandler]; // 内部調用completionHandler

  6. }

  7. _backgroundFetchCompletionHandler = completionHandler; // 複制給成員變量

  8. //...

2、iOS7 閃退

這個閃退 StackOverflow 上有人遇到,但點贊最多的答案實際上也沒解決問題,( http://stackoverflow.com/questions/18974251/app-crashes-after-executing-background-fetch-completionhandler )。

這個閃退僅在 iOS7 上産生,經過各方資料認為是 iOS7 系統的 bug。那麼在 iOS7 裝置上則不再啟用 BackgroundFetch。

  1. if ios 7 :

  2. [[UIApplication sharedApplication] setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalNever];

  3. else ...

Background Fetch 機制讓 iOS App 也能做到“熱啟動”,但帶來的程序挂起、喚醒次數大量增加,給已經穩定運作很久的代碼帶來一種”不穩定“的運作方式,必須要認真考慮每一個細節。

圖檔預加載

[UIImageimageNamed:@"xxx"]

是 iOS 中加載圖檔的 API,它的使用頻率是比較高的,那麼它的性能如何呢。我們在分析啟動性能的過程中,發現這個方法的耗時很多,iPhone5S 下每個耗時都在 20ms 到 50ms 之間,首頁加載過程中有10多張這種方式加載的圖檔。針對整個現象,在支付寶中,我們使用了一種圖檔預加載的方式來進行優化。

 ●  設計思想

在看

[UIImageimageNamed:]

文檔時發現一句話

In iOS 9 and later, this method is thread safe.

看到它之後立刻想到,能否在程序啟動早期通過子線程預先加載首頁圖檔。為什麼在早期呢?通過 Instruments 分析可看到在支付寶啟動早期,CPU 占用是不那麼滿的,為了讓啟動過程中充分利用 CPU,就盡量在早期啟動子線程。

首先通過 hook 方式,擷取首頁的所有 imageNamed 加載的圖檔,然後,大緻代碼如下:

  1. int main(){

  2. @autoreleasepool{

  3. //if >= iOS9

  4. dispatch_async(dispatch_get_global_queue(0, 0), ^{

  5. NSArray<NSString*> *images = @[

  6. // 10.0

  7. @"Launcher.bundle/TabBar_BG",

  8. @"Launcher.bundle/TabBar_HomeBar",

  9. //.... 省略10多個圖檔

  10. ];

  11. for (NSString *name in images) {

  12. [UIImage imageNamed:name];

  13. }

  14. }

  15. // AppDelegate....

  16. }

  17. }

 ●  問題與解決

在優化之後,也伴随而來一些不穩定的問題:

App 啟動會有小機率的 Crash

根據分析,我們決定把這段代碼移到 AppDelegate 的 didFinishLaunching 中,并且增加開關。

iPhone7 不需要預加載

在 iPhone7 裝置出來後,我們發現 iPhone7 的啟動性能反而不如 iPhone6S。分析後發現,在性能更好的 iPhone7 上,由于啟動很快,導緻子線程的 imageNamed 與 主線程的 imageNamed 互相穿插調用,而 imageNamed 内部的線程安全鎖的粒度很小,導緻鎖的消耗過大。如下圖:

支付寶用戶端架構解析:iOS 用戶端啟動性能優化初探

是以,在性能更好的 iPhone7 上不再啟用預加載。

小結

通過 Background Fetch 和圖檔預加載這兩種方式對啟動性能進行優化,給我們提供了另外一種思路,對于優化不要僅限制在條框内,需要适當的創新。但是,對于這種有點“創新”的代碼,一定要有“開關”,增強風險意識。當然,性能優化不是一蹴而就的,它是一個持續的課題,值得我們時刻來關注。

原文釋出時間為:2018-11-16

本文作者:石扉

本文來自雲栖社群合作夥伴“

mPaaS

”,了解相關資訊可以關注“

”。