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

螞蟻金服移動開發平臺mPaaS發表於2018-11-16

前言

《支付寶客戶端架構解析》系列將從支付寶客戶端的架構設計方案入手,細分拆解客戶端在“容器化框架設計”、“網路優化”、“效能啟動優化”、“自動化日誌收集”、“RPC 元件設計”、“移動應用監控、診斷、定位”等具體實現,帶領大家進一步瞭解支付寶在客戶端架構上的迭代與優化歷程。

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

啟動時間優化

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

  • 熱啟動:啟動應用時,應用的程式和資料已經存在於系統記憶體中,系統只是將應用的狀態從後臺切換到前臺。
  • 冷啟動:啟動應用時,應用不存在於系統核心的 buffer cache 中,比如應用首次啟動或者重啟裝置之後的啟動。

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

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

phase

  • 針對 pre-main() :

整個 pre-main() 階段的耗時可以通過新增環境變數 DYLD_PRINT_STATISTICS=1 來獲取,如下圖所示。

env
premain

這些階段都是系統進行管控,具體在這些階段內如何進行優化,可以參照 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,系統就會學習並且適應這個習慣,在使用者使用之前,後臺進行排程來啟動應用並執行資料更新。下圖比較清晰的說明了系統是如何學習使用者的使用模式的。

pattern

針對這樣的策略,大家可能會有疑慮,這種頻繁的後臺啟動會不會增加耗電量? 當然不會,系統會根據裝置的電量和資料使用情況來呼叫頻率控制,避免在非活躍時間頻繁的獲取資料。而且,程式啟動後後存活的時間很短,多數情況下會立即 suspend,對電量影響很少(相比壓後臺後很多 app 還要存活接近3分鐘的情況很少)。

Background Fetch 使用

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

fetch

  1. Info.plist 中 UIBackgroundModes 節點配置 fetch 數值
  2. didFinishLaunching 時配置
[[UIApplication sharedApplication] setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum];
複製程式碼

這一步配置的minimum interval,單位是秒,只是給系統的建議,系統並不會按照給定的時間間隔按規律的喚醒程式。

  1. 實現下面的回撥,並呼叫 completionHandler
- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
複製程式碼

由於 Background Fetch 機制是為了讓App在後臺拉取準備資料,但支付寶只是為了實現”秒起“。呼叫 completionHandler 後系統將把 App 程式掛起。且系統必須在30秒內呼叫 completionHandler,否則程式將被殺死。此外根據文件,系統會根據後臺呼叫 completionHandler 的時間來決定後臺喚起App的頻率。因此,認為可以“偽造“1秒的延遲時間,即1秒後呼叫 completionHandler。類似下面的程式碼:

- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler{
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        completionHandler(UIBackgroundFetchResultNewData);
    });
}
複製程式碼

Background Fetch 實踐

蘋果推出這種特性的動機在於,後臺觸發獲取資料並更新頁面,確保使用者使用時看到的永遠是最新的內容。然而,支付寶只是為了實現“秒起”,所以看似簡單的實現,卻隱藏著巨大的風險。 在測試過程中就發現了這些問題:

  1. 程式快速掛起導致 Sync 成功率下降

灰度期間,開發同學發現同步服務 Sync 成功率下降很多,找來找去發現原因:由於程式喚醒後,網路長連線執行緒被啟用並馬上建立長連線,而1秒後呼叫completionHandler,程式又被掛起。伺服器端的sync訊息則傳送超時。

  1. 程式頻繁掛起、喚醒導致網路建連次數增加

系統預測使用者使用 App 的時間,並在使用者實現 App 前喚醒 App,給予 App 後臺準備資料的機會。再加上預測的準確性問題,這樣程式被喚醒的次數遠大於使用者使用的次數。程式喚醒後,網路長連線會立即建立。因此導致網路建連次數大增,甚至翻倍。

  1. 由於程式掛起,導致定時器、延遲呼叫等時間“與預想的時間不同”

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

  1. 獲取時間戳

由於程式掛起,導致前後獲取的時間戳間隔很大。

為解決以上遇到的、以及預測到的問題,經過討論,決定在 Background Fetch 後臺喚醒的時候,不建立長連線。

  • 延後 10 秒呼叫 completionHandler。

後臺喚醒存在兩種情況:程式從無到有,程式從掛起到恢復。前者需要有充足的時間完成 App 的後臺冷啟動過程,因此定義了 10 秒的時間。

  • 後臺 Background Fetch 的時間內不建立長連線。

”後臺 Background Fetch 的時間“定義為:performFetchWithCompletionHandler 被回撥並一直到 completionHandler 呼叫的時間內。

我們維護了一個全域性變數 underBackgroundFetch 用於標識這段時間。處於這段時間的所有網路請求都被阻塞,並增加重試判斷。App 進入前臺(willEnterForeground)時主動重新建立長連線。在一些其他後臺需要建立長連線的情況下(例如 WatchApp 的連線、PUSH 快速回復),也主動修改標記,並通知網路層建立長連線。underBackgroundFetch 的修改是在主執行緒執行,但網路長連線的建立是在子執行緒,且程式被喚醒後早於 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 是否相同。大致程式碼如下:

- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
    if(_backgroundFetchCompletionHandler && _backgroundFetchCompletionHandler != completionHandler){
        // 避免performFetch被快速重複呼叫,如果completionHandler不同,則先完成上一個completionHandler;如果相同,則避免呼叫兩次。
        [self callBackgroundFetchCompletionHandler]; // 內部呼叫completionHandler
    }
    _backgroundFetchCompletionHandler = completionHandler; // 複製給成員變數
    //...
複製程式碼
  1. iOS7 閃退

這個閃退 StackOverflow 上有人遇到,但點贊最多的答案實際上也沒解決問題。

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

if ios 7 : 
[[UIApplication sharedApplication] setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalNever];
else ...
複製程式碼

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

圖片預載入

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

設計思想

在看 [UIImage imageNamed:] 文件時發現一句話

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

看到它之後立刻想到,能否在程式啟動早期通過子執行緒預先載入首頁圖片。為什麼在早期呢?通過 Instruments 分析可看到在支付寶啟動早期,CPU 佔用是不那麼滿的,為了讓啟動過程中充分利用 CPU,就儘量在早期啟動子執行緒。

首先通過 hook 方式,獲取首頁的所有 imageNamed 載入的圖片,然後,大致程式碼如下:

int main(){
    @autoreleasepool{
        //if >= iOS9
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            NSArray<NSString*> *images = @[
                                           // 10.0
                                           @"Launcher.bundle/TabBar_BG",
                                           @"Launcher.bundle/TabBar_HomeBar",
                                           //.... 省略10多個圖片
                                           ];
            for (NSString *name in images) {
                [UIImage imageNamed:name];
            }
        }

        // AppDelegate....
    }
}
複製程式碼

問題與解決

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

  • App 啟動會有小概率的 Crash。

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

  • iPhone7 不需要預載入

在 iPhone7 裝置出來後,我們發現 iPhone7 的啟動效能反而不如 iPhone6S。分析後發現,在效能更好的 iPhone7 上,由於啟動很快,導致子執行緒的 imageNamed 與 主執行緒的 imageNamed 相互穿插呼叫,而 imageNamed 內部的執行緒安全鎖的粒度很小,導致鎖的消耗過大。如下圖:

imagenamed

因此,在效能更好的 iPhone7 上不再啟用預載入。

總結

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

由於篇幅限制,很多技術要點我們無法一一展開。而相應的技術核心,我們同樣應用在了 mPaaS 並對外輸出,歡迎大家上手體驗:

tech.antfin.com/docs/2/4954…

關於 iOS 端啟動效能優化的設計思路和具體實踐,同樣期待你們的反饋,歡迎一起探討交流。

附註:WWDC2013 Session developer.apple.com/videos/play…

往期閱讀

《開篇 | 模組化與解耦式開發在螞蟻金服 mPaaS 深度實踐探討》

《支付寶移動端動態化方案實踐》

《支付寶客戶端架構解析:iOS 容器化框架初探》

《支付寶客戶端架構解析:Android 容器化框架初探》

《支付寶客戶端架構解析:Android 客戶端啟動速度優化之「垃圾回收」》

關注我們公眾號,獲得第一手 mPaaS 技術實踐乾貨

QRCode

相關文章