之前公司的 UI 設計師和我們提過好幾次啟動時間的事情,當時在開發業務,所以沒有時間去做這件事。最近發完版本,終於有時間搞一搞啟動時間了。
一般而言,啟動時間是指從用戶點擊 APP 那一刻開始到用戶看到第一個界面這中間的時間。我們進行優化的時候,我們將啟動時間分為 pre-main 時間和 main 函數到第一個界面渲染完成時間這兩個部分。
為什麼這麼劃分呢?大家都知道 APP 的入口是 main 函數,在 main 之前,我們自己的代碼是不會執行的。而進入到 main 函數以後,我們的代碼都是從
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions;
開始執行的,所以很明顯,優化這兩部分的思路是不一樣的。
為了方便起見,我們將 pre-main 時間成為 t1 時間,而將main 函數到第一個界面渲染完成這段時間稱為 t2 時間。
01.磨刀不誤砍柴工
我們先來看第一部分,也就是從 main 函數到第一個界面渲染完成這段時間。在開始之前,我們先來磨練一個我們自己的工具。
生活中,我們計量一段時間一般是用計時器。這裡我們要想知道哪些操作,或者說哪些代碼是耗時的,我們也需要一個打點計時器。用過 profile 的朋友都知道這個工具很強大,可以使用它來分析出哪些代碼是耗時的。但是它不夠靈活,我們來看一下我們的這個計時器應該怎麼設計。
如上圖所示,在時間軸上,我們從 start 開始打點計時,然後我們在第一個小紅旗那裡打了一個點,記錄這段代碼的耗時,然後又在第二個小紅旗那裡打了一個點,記錄這中間代碼的耗時。然後在結束的地方打一個點,然後把所有打點的結果展示出來。同時,我們為每段計時加上標注,用來區分這段時間是執行了什麼操作花費的時間。這樣一來,我們就能快速精准的知道究竟是誰拖慢了啟動。
02.定位元凶
下面這張截圖是貝聊老師端沒有開始優化的耗時,因為涉及到公司具體的業務,所以我將部分信息加了遮擋。借助於我們的工具,我們可以定位任何一行代碼的耗時。
我們看 t2 耗時那裡,總共花費了 6.361 秒,這是從 didFinishLaunchingWithOptions 到第一個界面渲染出來花費的時間。從這個結果來看,我們的啟動時間的優化已經到了刻不容緩的地步了。
再仔細分析一下上面的結果, t2 時間也分為了兩個部分,didFinishLaunchingWithOptions 花了 4.010秒,第一個頁面渲染耗時花了 2.531 秒。好,看樣子大魔頭住在 didFinishLaunchingWithOptions 這個方法裡,另外,第一頁面的渲染中也有不少問題。下面我們分別展開。
02.1.didFinishLaunchingWithOptions
上面說到大魔頭住在 didFinishLaunchingWithOptions,現在我們仔細看一下 didFinishLaunchingWithOptions 方法裡的代碼耗時,有兩行代碼的耗時居然為一秒以上,而且耗時最多的居然有 1.620 秒之多。
其實 didFinishLaunchingWithOptions 方法裡我們一般都有以下的邏輯:
初始化第三方 SDK
配置 APP 運行需要的環境
自己的一些工具類的初始化
...
02.2.第一個頁面渲染
如果我們的 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 的設置,然後再進入到每個 viewController 的 viewDidLoad 方法裡進行更多的初始化操作。那麼你覺得從 didFinishLaunchingWithOptions 到最後顯示展示的 viewController 的 viewDidLoad 這些方法的執行順序是怎麼樣的呢?
下面是我寫的一個 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 中操作 BLViewController 的 view,如果我們在BLTabBarController 中操作了 BLViewController 的 view 的話,那麼調用順序將會是這樣:
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 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。
還需要勾選下面這個選項:
然後再運行項目,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 方法中執行的代碼
組件化
其他還有,請大神補充。上面幾點中,我們能做的也就是把所有類的 load 方法掃一遍,不要在這裡面執行耗時的操作。其他的不是短時間能改變的。
如果你想在這些方面有所突破的話,請看下面參考文章。
參考文章:
App Startup Time: Past, Present, and Future
iOS App 啟動性能優化
WWDC 之優化 App 啟動速度
iOS Dynamic Framework 對App啟動時間影響實測
優化 App 的啟動時間
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。