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

前言

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

啓動應用是用戶使用任何一款應用最必不可少的操做,從點擊 App 圖標到首頁展現,整個啓動過程的性能,嚴重影響着用戶的體驗。支付寶客戶端做爲一個超級 App,啓動的性能固然是咱們關注的重要指標之一,下文將從三方面來介紹支付寶在 iOS 端啓動性能優化的具體設計思路。安全

啓動時間優化

分析啓動時間以前,先看一下 App 啓動的兩種方式。性能優化

  • 熱啓動:啓動應用時,應用的進程和數據已經存在於系統內存中,系統只是將應用的狀態從後臺切換到前臺。
  • 冷啓動:啓動應用時,應用不存在於系統內核的 buffer cache 中,好比應用首次啓動或者重啓設備以後的啓動。

相比而言,冷啓動比較重要,一般咱們分析啓動時間,都是指的冷啓動。bash

要想分析啓動時間,還須要瞭解啓動的過程,iOS應用的啓動大概分如下幾個階段:服務器

phase

  • 針對 pre-main() :

整個 pre-main() 階段的耗時能夠經過添加環境變量 DYLD_PRINT_STATISTICS=1 來獲取,以下圖所示。網絡

env
premain

這些階段都是系統進行管控,具體在這些階段內如何進行優化,能夠參照 WWDC2013 Session(文章尾部附地址)中提供的方案進行,這裏不詳細說明。架構

  • 針對 post-main() :

這部分主要是啓動的框架初始化,首頁數據獲取,首頁渲染等業務邏輯,這一部分咱們只把必要的初始化操做保留,儘可能把邏輯後置或者放在 background 線程執行。 這裏的優化方案須要結合實際的業務場景和應用的架構來進行分析,採起對應的策略。app

Background Fetch

除了這些通用的優化方案以外,咱們也探索了一些創新的方式。 在介紹 Background Fetch 以前,咱們先看這樣一個案例:框架

操做:async

首先,啓動支付寶,按 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
相關文章
相關標籤/搜索