[iOS]一次立竿見影的啟動時間優化

NewPan發表於2017-12-21

@NewPan 貝聊科技 iOS 菜鳥工程師

之前公司的使用者體驗師和我們提過好幾次啟動時間的事情,當時在開發業務,所以沒有時間去做這件事。最近發完版本,終於有時間搞一搞啟動時間了。

一般而言,啟動時間是指從使用者點選 APP 那一刻開始到使用者看到第一個介面這中間的時間。我們進行優化的時候,我們將啟動時間分為 pre-main 時間和 main 函式到第一個介面渲染完成時間這兩個部分。

為什麼這麼劃分呢?大家都知道 APP 的入口是 main 函式,在 main 之前,我們自己的程式碼是不會執行的。而進入到 main 函式以後,我們的程式碼都是從

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions;
複製程式碼

開始執行的,所以很明顯,優化這兩部分的思路是不一樣的。

為了方便起見,我們將 pre-main 時間成為 t1 時間,而將main 函式到第一個介面渲染完成這段時間稱為 t2 時間。

01.磨刀不誤砍柴工

我們先來看第一部分,也就是從 main 函式到第一個介面渲染完成這段時間。在開始之前,我們先來磨練一個我們自己的工具。

生活中,我們計量一段時間一般是用計時器。這裡我們要想知道哪些操作,或者說哪些程式碼是耗時的,我們也需要一個打點計時器。用過 profile 的朋友都知道這個工具很強大,可以使用它來分析出哪些程式碼是耗時的。但是它不夠靈活,我們來看一下我們的這個計時器應該怎麼設計。

[iOS]一次立竿見影的啟動時間優化

如上圖所示,在時間軸上,我們從 start 開始打點計時,然後我們在第一個小紅旗那裡打了一個點,記錄這段程式碼的耗時,然後又在第二個小紅旗那裡打了一個點,記錄這中間程式碼的耗時。然後在結束的地方打一個點,然後把所有打點的結果展示出來。同時,我們為每段計時加上標註,用來區分這段時間是執行了什麼操作花費的時間。這樣一來,我們就能快速精準的知道究竟是誰拖慢了啟動。

02.定位元凶

下面這張截圖是貝聊老師端沒有開始優化的耗時,因為涉及到公司具體的業務,所以我將部分資訊加了遮擋。藉助於我們的工具,我們可以定位任何一行程式碼的耗時。

我們看 t2 耗時那裡,總共花費了 6.361 秒,這是從 didFinishLaunchingWithOptions 到第一個介面渲染出來花費的時間。從這個結果來看,我們的啟動時間的優化已經到了刻不容緩的地步了。

[iOS]一次立竿見影的啟動時間優化

再仔細分析一下上面的結果, t2 時間也分為了兩個部分,didFinishLaunchingWithOptions 花了 4.010秒,第一個頁面渲染耗時花了 2.531 秒。好,看樣子大魔頭住在 didFinishLaunchingWithOptions 這個方法裡,另外,第一頁面的渲染中也有不少問題。下面我們分別展開。

02.1.didFinishLaunchingWithOptions

上面說到大魔頭住在 didFinishLaunchingWithOptions,現在我們仔細看一下 didFinishLaunchingWithOptions 方法裡的程式碼耗時,有兩行程式碼的耗時居然為一秒以上,而且耗時最多的居然有 1.620 秒之多。

[iOS]一次立竿見影的啟動時間優化

其實 didFinishLaunchingWithOptions 方法裡我們一般都有以下的邏輯:

  • 初始化第三方 SDK
  • 配置 APP 執行需要的環境
  • 自己的一些工具類的初始化
  • ...

02.2.第一個頁面渲染

[iOS]一次立竿見影的啟動時間優化

如果我們的 UI 架構是上面這樣的話。然後我們在 AppDelegate 裡寫下這麼一段程式碼:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    NSLog(@"didFinishLaunchingWithOptions 開始執行");

    self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
    BLTabBarController *tabBarVc = [BLTabBarController new];
    self.window.rootViewController = tabBarVc;
    [self.window makeKeyAndVisible];

    NSLog(@"didFinishLaunchingWithOptions 跑完了");

    return YES;
    }
複製程式碼

然後我們來到 BLTabBarController 裡的 viewDidLoad 方法裡進行它的 viewControllers 的設定,然後再進入到每個 viewControllerviewDidLoad 方法裡進行更多的初始化操作。那麼你覺得從 didFinishLaunchingWithOptions 到最後顯示展示的 viewControllerviewDidLoad 這些方法的執行順序是怎麼樣的呢?

下面是我寫的一個 demo,用來展示載入的順序:

2017-08-15 10:46:57.860 Demo[1404:325698] didFinishLaunchingWithOptions 開始執行
2017-08-15 10:46:57.862 Demo[1404:325698] 開始載入 BLTabBarController 的 viewDidLoad
2017-08-15 10:46:57.874 Demo[1404:325698] didFinishLaunchingWithOptions 跑完了
2017-08-15 10:46:57.876 Demo[1404:325698] 開始載入 BLViewController 的 viewDidLoad, 然後執行一堆初始化的操作
複製程式碼

上面的情況是能保證我們不在 BLTabBarController 中操作 BLViewControllerview,如果我們在BLTabBarController 中操作了 BLViewControllerview 的話,那麼呼叫順序將會是這樣:

2017-08-15 11:09:03.661 Demo[1458:349413] didFinishLaunchingWithOptions 開始執行
2017-08-15 11:09:03.663 Demo[1458:349413] 開始載入 BLTabBarController 的 viewDidLoad
2017-08-15 11:09:03.664 Demo[1458:349413] 開始載入 BLViewController 的 viewDidLoad, 然後執行一堆初始化的操作
2017-08-15 11:09:03.676 Demo[1458:349413] didFinishLaunchingWithOptions 跑完了
複製程式碼

這是很可怕的一件事情,為什麼呢?因為一般我們都把介面的初始化、網路請求、資料解析、檢視渲染等操作放在了 viewDidLoad 方法裡,這樣一來每次啟動 APP 的時候,在使用者看到第一個頁面之前,我們要把這些事件全部都處理完,才會進入到檢視渲染階段。

03.解決策略

上面分析了拖慢 t2 的兩個因素,它們是 didFinishLaunchingWithOptions裡面的初始化以及第一個頁面渲染耗時。對於這兩個不同的方面,我們的優化思路也是不一樣的。

03.1.didFinishLaunchingWithOptions

對於 didFinishLaunchingWithOptions,這裡面的初始化是必須執行的,但是我們可以適當的根據功能的不同對應的適當延遲啟動的時機。對於我們專案,我將初始化分為三個型別:

  • 日誌、統計等必須在 APP 一起動就最先配置的事件
  • 專案配置、環境配置、使用者資訊的初始化 、推送、IM等事件
  • 其他 SDK 和配置事件

對於第一類,由於這類事件的特殊性,所以必須第一時間啟動,仍然把它留在 didFinishLaunchingWithOptions 裡啟動。第二類事件,這些功能在使用者進入 APP 主體的之前是必須要載入完的,所以我們可以把它放在第二批,也就是使用者已經看到廣告頁面,再進行廣告倒數計時的時候再啟動。第三類事件,由於不是必須的,所以我們可以放在第一個介面渲染完成以後的 viewDidAppear 方法裡,這裡完全不會影響到啟動時間。

就這樣,進行過這一輪優化以後,我們的 t2 事件就從 6 秒多 降到 2 秒多

03.2.第一個頁面渲染

我們的思路是這樣的,使用者點選 APP,我先儘快把廣告頁面載入出來。這樣,使用者就不會覺得啟動慢了,同時我們可以在廣告讀秒的過程中進行第二批啟動事件的載入,這個載入使用者也感覺不到。但還沒完,等會廣告展示完,切到主 APP 的時候,如果一系列 viewDidLoad 裡方法裡有很多耗時的操作,那使用者還是會感覺到卡頓。

所以對於第一個頁面渲染的優化思路就是,先立馬展示一個空殼的 UI 給使用者,然後在 viewDidAppear 方法裡進行資料載入解析渲染等一系列操作,這樣一來,使用者已經看到介面了,就不會覺得是啟動慢,這個時候的等待就變成等待資料請求了,這樣就把這部分時間轉嫁出去了。

經過這兩輪優化,我們的 t2 時間就從 6 秒多 變成了 0.1 秒不到,也即是總共砍掉了 6 秒多 的啟動時間。

03.3.總結

為此,我專門建了一個類來負責啟動事件,為什麼呢?如果不這麼做,那麼此次優化以後,以後再引入第三方的時候,別的同事可能很直覺的就把第三方的初始化放到了 didFinishLaunchingWithOptions 方法裡,這樣久而久之, didFinishLaunchingWithOptions 又變得不堪重負,到時候又要專門花時間來做重複的優化。

下面是這個類的標頭檔案:

/**
 * 注意: 這個類負責所有的 didFinishLaunchingWithOptions 延遲事件的載入.
 * 以後引入第三方需要在 didFinishLaunchingWithOptions 裡初始化或者我們自己的類需要在 didFinishLaunchingWithOptions 初始化的時候,
 * 要考慮儘量少的啟動時間帶來好的使用者體驗, 所以應該根據需要減少 didFinishLaunchingWithOptions 裡耗時的操作.
 * 第一類: 比如日誌 / 統計等需要第一時間啟動的, 仍然放在 didFinishLaunchingWithOptions 中.
 * 第二類: 比如使用者資料需要在廣告顯示完成以後使用, 所以需要伴隨廣告頁啟動, 只需要將啟動程式碼放到 startupEventsOnADTimeWithAppDelegate 方法裡.
 * 第三類: 比如直播和分享等業務, 肯定是使用者能看到真正的主介面以後才需要啟動, 所以推遲到主介面載入完成以後啟動, 只需要將程式碼放到 startupEventsOnDidAppearAppContent 方法裡.
*/

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface BLDelayStartupTool : NSObject

/**
* 啟動伴隨 didFinishLaunchingWithOptions 啟動的事件.
* 啟動型別為:日誌 / 統計等需要第一時間啟動的.
*/
+ (void)startupEventsOnAppDidFinishLaunchingWithOptions;

/**
* 啟動可以在展示廣告的時候初始化的事件.
* 啟動型別為: 使用者資料需要在廣告顯示完成以後使用, 所以需要伴隨廣告頁啟動.
*/
+ (void)startupEventsOnADTime;

/**
* 啟動在第一個介面顯示完(使用者已經進入主介面)以後可以載入的事件.
* 啟動型別為: 比如直播和分享等業務, 肯定是使用者能看到真正的主介面以後才需要啟動, 所以推遲到主介面載入完成以後啟動.
*/
+ (void)startupEventsOnDidAppearAppContent;

@end

NS_ASSUME_NONNULL_END
複製程式碼

下面是 .m 檔案,這裡做了一層自動校驗,如果 30 秒 以後,這些啟動項有沒有被啟動的,就會在 DEBUG 環境下彈出警告資訊。同時也會將那些沒有啟動的啟動項進行啟動。

#import "BLDelayStartupTool.h"

static BOOL _isCalledStartupEventsOnAppDidFinishLaunchingWithOptions = NO;
static BOOL _isCalledStartupEventsOnADTimeWithAppDelegate = NO;
static BOOL _isCalledStartupEventsOnDidAppearAppContent = NO;
const NSTimeInterval kBLDelayStartupEventsToolCheckCallTimeInterval = 30;
@implementation BLDelayStartupTool

+ (void)load {
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kBLDelayStartupEventsToolCheckCallTimeInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [self checkStartupEventsDidLaunched];
    });
}

+ (void)checkStartupEventsDidLaunched {
    NSString *alertString = @"";
    if (!_isCalledStartupEventsOnAppDidFinishLaunchingWithOptions) {
        alertString = [alertString stringByAppendingString:@"AppDidFinishLaunching, "];
        [self startupEventsOnAppDidFinishLaunchingWithOptions];
    }
    if (!_isCalledStartupEventsOnADTimeWithAppDelegate) {
        alertString = [alertString stringByAppendingString:@"ADTime, "];
        [self startupEventsOnADTime];
    }
    if (!_isCalledStartupEventsOnDidAppearAppContent) {
        alertString = [alertString stringByAppendingString:@"DidAppearAppContent"];
        [self startupEventsOnDidAppearAppContent];
    }
    
    if (alertString.length > 0) {
#if DEBUG
        alertString = [alertString stringByAppendingString:@" 等延遲啟動項沒有啟動, 這會造成應用奔潰"];
        UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"注意" message:alertString delegate:nil cancelButtonTitle:@"好的" otherButtonTitles:nil];
        [alertView show];
#endif
    }
}

+ (void)startupEventsOnAppDidFinishLaunchingWithOptions {
    _isCalledStartupEventsOnAppDidFinishLaunchingWithOptions = YES;
}

+ (void)startupEventsOnADTime {
    _isCalledStartupEventsOnADTimeWithAppDelegate = YES;
}

+ (void)startupEventsOnDidAppearAppContent {
    _isCalledStartupEventsOnDidAppearAppContent = YES;
}

@end
複製程式碼

04. pre-main 時間

上面已經將 t2 時間處理好了,接下來看看 pre-main

蘋果為檢視 pre-main 提供了支援,具體配置如下,配置的 key 為:DYLD_PRINT_STATISTICS

[iOS]一次立竿見影的啟動時間優化

還需要勾選下面這個選項:

[iOS]一次立竿見影的啟動時間優化

然後再執行專案,Xcode 就會在控制檯輸出這部分 pre-main 的耗時:

Total pre-main time: 2.2 seconds (100.0%)
 dylib loading time: 1.0 seconds (45.2%)
rebase/binding time: 100.05 milliseconds (4.3%)
    ObjC setup time: 207.21 milliseconds (9.0%)
   initializer time: 946.39 milliseconds (41.3%)
slowest intializers :
            libSystem.B.dylib :   8.54 milliseconds (0.3%)
  libBacktraceRecording.dylib :  46.30 milliseconds (2.0%)
         libglInterpose.dylib : 187.42 milliseconds (8.1%)
                      beiliao : 896.56 milliseconds (39.1%)
複製程式碼

但是這部分不是那麼好處理,因為這部分主要是由以下幾個方面影響的:

  • 用到的系統的動態庫的數量,比如 UIKit.framework
  • cocoapods 裡引用的第三方框架數量
  • 專案中類的數量
  • load 方法中執行的程式碼
  • 元件化
  • swift 混編

其他還有,請大神補充。上面幾點中,我們能做的也就是把所有類的 load 方法掃一遍,不要在這裡面執行耗時的操作。其他的不是短時間能改變的。

如果你想在這些方面有所突破的話,請看下面參考文章。

參考文章: App Startup Time: Past, Present, and Future iOS App 啟動效能優化 WWDC 之優化 App 啟動速度 iOS Dynamic Framework 對App啟動時間影響實測 優化 App 的啟動時間

05.最後

打點計時器的 GitHub 地址在這裡 BLStopwatch

NewPan 的文章集合

下面這個連結是我所有文章的一個集合目錄。這些文章凡是涉及實現的,每篇文章中都有 Github 地址,Github 上都有原始碼。

NewPan 的文章集合索引

如果你有問題,除了在文章最後留言,還可以在微博 @盼盼_HKbuy 上給我留言,以及訪問我的 Github

相關文章