iOS App 啓動性能優化 原

本文來自於騰訊Bugly公衆號(weixinBugly),未經作者同意,請勿轉載,原文地址:https://mp.weixin.qq.com/s/Kf3EbDIUuf0aWVT-UCEmbA

作者:samsonxu

導語

本文介紹瞭如何優化iOS App的啓動性能。

本文分爲四個部分:

  • 第一部分科普了一些和App啓動性能相關的前置知識
  • 第二部分主要講如何定製啓動性能的優化目標
  • 第三部分通過在WiFi管家這個具體項目的優化過程,分享一些有用的經驗
  • 第四部分是關鍵點的總結。

【第一部分】一些小科普

因爲篇幅的限制,沒有辦法很詳盡的說明一些原理性的東西,只是方便大家瞭解哪些事情可能跟啓動性能有關。同時,內容相對也比較入門,大神們請跳過這一部分。

1. App啓動過程

  • 解析Info.plist
    • 加載相關信息,例如如閃屏
    • 沙箱建立、權限檢查
  • Mach-O加載
  • 如果是胖二進制文件,尋找合適當前CPU類別的部分
  • 加載所有依賴的Mach-O文件(遞歸調用Mach-O加載的方法)
  • 定位內部、外部指針引用,例如字符串、函數等
  • 執行聲明爲__attribute__((constructor))的C函數
  • 加載類擴展(Category)中的方法
  • C++靜態對象加載、調用ObjC的 +load 函數
  • 程序執行
  • 調用main()
  • 調用UIApplicationMain()
  • 調用applicationWillFinishLaunching

2. 如何測量啓動過程耗時

冷啓動比熱啓動重要

當用戶按下home鍵的時候,iOS的App並不會馬上被kill掉,還會繼續存活若干時間。理想情況下,用戶點擊App的圖標再次回來的時候,App幾乎不需要做什麼,就可以還原到退出前的狀態,繼續爲用戶服務。這種持續存活的情況下啓動App,我們稱爲熱啓動,相對而言冷啓動就是App被kill掉以後一切從頭開始啓動的過程。我們這裏只討論App冷啓動的情況。

main()函數之前

在不越獄的情況下,以往很難精確的測量在main()函數之前的啓動耗時,因而我們也往往容易忽略掉這部分數據。小型App確實不需要太過關注這部分。但如果是大型App(自定義的動態庫超過50個、或編譯結果二進制文件超過30MB),這部分耗時將會變得突出。所幸,蘋果已經在Xcode中加入這部分的支持。

蘋果提供的方法
  • 在Xcode的菜單中選擇ProjectSchemeEdit Scheme...,然後找到 RunEnvironment Variables+,添加name爲DYLD_PRINT_STATISTICSvalue1的環境變量。

  • 在Xcode運行App時,會在console中得到一個報告。例如,我在WiFi管家中加入以上設置之後,會得到這樣一個報告:

  Total pre-main time:  94.33 milliseconds (100.0%)
           dylib loading time:  61.87 milliseconds (65.5%)
          rebase/binding time:   3.09 milliseconds (3.2%)
              ObjC setup time:  10.78 milliseconds (11.4%)
             initializer time:  18.50 milliseconds (19.6%)
             slowest intializers :
               libSystem.B.dylib :   3.59 milliseconds (3.8%)
     libBacktraceRecording.dylib :   3.65 milliseconds (3.8%)
                      GTFreeWifi :   7.09 milliseconds (7.5%)
如何解讀
  1. main()函數之前總共使用了94.33ms
  2. 在94.33ms中,加載動態庫用了61.87ms,指針重定位使用了3.09ms,ObjC類初始化使用了10.78ms,各種初始化使用了18.50ms。
  3. 在初始化耗費的18.50ms中,用時最多的三個初始化是libSystem.B.dylib、libBacktraceRecording.dylib以及GTFreeWifi。
main()函數之後

main()函數開始至applicationWillFinishLaunching結束,我們統一稱爲main()函數之後的部分。

3. 影響啓動性能的因素

App啓動過程中每一個步驟都會影響啓動性能,但是有些部分所消耗的時間少之又少,另外有些部分根本無法避免,考慮到投入產出比,我們只列出我們可以優化的部分:

main()函數之前耗時的影響因素
  • 動態庫加載越多,啓動越慢。
  • ObjC類越多,啓動越慢
  • C的constructor函數越多,啓動越慢
  • C++靜態對象越多,啓動越慢
  • ObjC的+load越多,啓動越慢

實驗證明,在ObjC類的數目一樣多的情況下,需要加載的動態庫越多,App啓動就越慢。同樣的,在動態庫一樣多的情況下,ObjC的類越多,App的啓動也越慢。需要加載的動態庫從1個上升到10個的時候,用戶幾乎感知不到任何分別,但從10個上升到100個的時候就會變得十分明顯。同理,100個類和1000個類,可能也很難查察覺得出,但1000個類和10000個類的分別就開始明顯起來。

同樣的,儘量不要寫__attribute__((constructor))的C函數,也儘量不要用到C++的靜態對象;至於ObjC的+load方法,似乎大家已經習慣不用它了。任何情況下,能用dispatch_once()來完成的,就儘量不要用到以上的方法。

main()函數之後耗時的影響因素
  • 執行main()函數的耗時
  • 執行applicationWillFinishLaunching的耗時
  • rootViewController及其childViewController的加載、view及其subviews的加載
applicationWillFinishLaunching的耗時

如果有這樣這樣的代碼:

//AppDelegate.m
@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    self.rootViewController = [[[MQQTabBarController alloc] init] autorelease];

    self.window = [[[UIWindow alloc] init] autorelease];
    [self.window makeKeyAndVisible];
    self.window.rootViewController = self.rootViewController;

    UITabBarController *tabBarViewController = [[[UITabBarController alloc] init] autorelease];


    NSLog(@"%s", __PRETTY_FUNCTION__);
    return YES;
}

...

//MQQTabBarController.m
@implementation MQQTabBarController

- (void)viewDidLoad {
    NSLog(@"%s", __PRETTY_FUNCTION__);
    [super viewDidLoad];
    // Do any additional setup after loading the view.

    UIViewController *tab1 = [[[MQQTab1ViewController alloc] init] autorelease];
    tab1.tabBarItem.title = @"red";
    [self addChildViewController:tab1];

    UIViewController *tab2 = [[[MQQTab2ViewController alloc] init] autorelease];
    tab2.tabBarItem.title = @"blue";
    [self addChildViewController:tab2];

    UIViewController *tab3 = [[[MQQTab3ViewController alloc] init] autorelease];
    tab3.tabBarItem.title = @"green";
    [self addChildViewController:tab3];
}

...
@end

那麼-[MQQTabBarController viewDidLoad]-[AppDelegate application:didFinishLaunchingWithOptions:]-[MQQTab1ViewController viewDidLoad]-[MQQTab2ViewController viewDidLoad]-[MQQTab2ViewController viewDidLoad] 完成的先後順序是怎樣的呢?

答案是:

  1. -[MQQTabBarController viewDidLoad]
  2. -[MQQTab1ViewController viewDidLoad]
  3. -[AppDelegate application:didFinishLaunchingWithOptions:]
  4. -[MQQTab2ViewController viewDidLoad] (點擊了第二個tab之後加載)
  5. -[MQQTab3ViewController viewDidLoad] (點擊了第三個tab之後加載)

一般而言,大部分情況下我們都會把界面的初始化過程放在viewDidLoad,但是這個過程會影響消耗啓動的時間。特別是在類似TabBarController這種會嵌套childViewController的ViewController的情況,它也會把部分children也初始化,因此各種viewDidLoad會遞歸的進行。

最簡單的解決的方法,是把viewController延後加載,但實際上這屬於一種掩耳盜鈴,確實,applicationWillFinishLaunching的耗時是降下來了,但用戶體驗上並沒有感覺變快。

更好一點的解決方法有點類似facebook,主視圖會第一時間加載,但裏面的數據和界面都會延後加載,這樣用戶就會階段性的獲得視覺上的變化,從而在視覺體驗上感覺App啓動得很快。

【第二部分】優化的目標

由於每個App的情況有所不同,需要加載的數據量也有所不同,事實上我們無法使用一種統一的標準來衡量不同的App。蘋果。

  • 應該在400ms內完成main()函數之前的加載
  • 整體過程耗時不能超過20秒,否則系統會kill掉進程,App啓動失敗

400ms內完成main()函數前的加載的建議值是怎樣定出來的呢?其實我也沒有太深究過這個問題,但是,當用戶點擊了一個App的圖標時,iOS做動畫到閃屏圖出現的時長正好是這個數字,我想也許跟這個有關。

針對不同規模的App,我們的目標應該有所取捨。例如,對於像手機QQ這種集整個SNG的代碼大成擼出來的App,對動態庫的使用在所難免,但對於WiFi管家,由於在用戶連接WiFi的時候需要非常快速的響應,所以快速啓動就非常重要。

那麼,如何定製優化的目標呢?首先,要確定啓動性能的界限,例如,在各種App性能的指標中,哪一此屬於啓動性能的範疇,哪一些則於App的流暢度性能?我認爲應該首先把啓動過程分爲四個部分:

  1. main()函數之前
  2. main()函數之後至applicationWillFinishLaunching完成
  3. App完成所有本地數據的加載並將相應的信息展示給用戶
  4. App完成所有聯網數據的加載並將相應的信息展示給用戶

1+2一起決定了我們需要用戶等待多久才能出現一個主視圖,同時也是技術上可以精確測量的時長,1+2+3決定了用戶視覺上的等待出現有用信息所需要的時長,1+2+3+4決定了我們需要多少時間才能讓我們需要展示給用戶的所有信息全部出現。

淘寶的iOS客戶端無疑是各部分都做得非常優秀的典型。它所承載的業務完全不比微信和手機QQ少,但幾乎瞬間完成了啓動,並利用緩存機制使得用戶馬上看到“貌似完整”的界面,然後立即又刷新了剛剛聯網更新回來的信息。也就是說,無論是技術上還是視覺上,它都非常的“快”。

【第三部分】WiFi管家啓動優化實踐

先show一下成果:

1. 移除不需要用到的動態庫

因爲WiFi管家是個小項目,用到的動態庫不多,自動化處理的優勢不大,我這裏也就簡單的把依賴的動態移除出項目,再根據編譯錯誤一個一個加回來。如果有靠譜的方法,歡迎大家補充一下。

2. 移除不需要用到的類

項目做久了總有一些弔詭的類像幽靈一樣驅之不去,由於【不要相信產品經理】的思想作怪,需求變更後,有些類可能用不上了,但卻因爲擔心需求再變回來就沒有移除掉,後來就徹底忘記要移除了。

爲了解決這個歷史問題,在這個過程中我試過多種方法來掃描沒有用到的類,其中有一種是編譯後對ObjC類的指針引用進行反向掃描,可惜實際上收穫不是很明顯,而且還要寫很多例外代碼來處理一些特殊情況。後來發現一個叫做fui(Find Unused Imports)的開源項目能很好的分析出不再使用的類,準確率非常高,唯一的問題是它處理不了動態庫和靜態庫裏提供的類,也處理不了C++的類模板。

使用方法是在Terminal中cd到項目所在的目錄,然後執行fui find,然後等上那麼幾分鐘(是的你沒有看錯,真的需要好幾分鐘甚至需要更長的時間),就可以得到一個列表了。由於這個工具還不是100%靠譜,可根據這個列表,在Xcode中手動檢查並刪除不再用到的類。

實際上,日常對代碼工程的維護非常重要,如果制定好一套半廢棄代碼的維護方法,小問題就不會積累成大問題。有時候對於一些暫時不再使用的代碼,我也很糾結於要不要svn rm,因爲從代碼歷史中找刪除掉的文件還是不太方便。不知道大家有沒有相關的經驗可以分享,也請不吝賜教。

3. 合併功能類似的類和擴展(Category)

由於Category的實現原理,和ObjC的動態綁定有很強的關係,所以實際上類的擴展是比較佔用啓動時間的。儘量合併一些擴展,會對啓動有一定的優化作用。不過個人認爲也不能因爲它佔用啓動時間而去逃避使用擴展,畢竟程序員的時間比CPU的時間值錢,這裏只是強調要合併一些在工程、架構上沒有太大意義的擴展。

4. 壓縮資源圖片

壓縮圖片爲什麼能加快啓動速度呢?因爲啓動的時候大大小小的圖片加載個十來二十個是很正常的,圖片小了,IO操作量就小了,啓動當然就會快了。

事實上,Xcode在編譯App的時候,已經自動把需要打包到App裏的資源圖片壓縮過一遍了。然而Xcode的壓縮會相對比較保守。另一方面,我們正常的設計師由於需要符合其正常的審美需要生成的正常的PNG圖片,因此圖片大小是比較大的,然而如果以程序員的直男審美而採用過激的壓縮會直接激怒設計師。

解決各種矛盾的方法就是要找出一種相當靠譜的壓縮方法,而且最好是基本無損的,而且壓縮率還要特別高,至少要比Xcode自動壓縮的效果要更好纔有意義。經過各種試驗,最後發現唯一可靠的壓縮算法是TinyPNG,其它各種方法,要麼沒效果,要麼產生色差或模糊。但是非常可惜的是TinyPNG並不是完全免費的,而且需要通過網絡請求來壓縮圖片(應該是爲了保護其牛逼的壓縮算法)。

爲了解決這個問題,我寫了一個類來執行這個請求,請參見閱讀原文裏的SSTinyPNGRequest和SSPNGCompressor。因爲這個項目只有我一個人在用所以代碼寫得有點隨意,有問題可以私聊也可以在評論裏問,有改進的方法也非常歡迎指正。另外說明一下,使用這個類需要你自行到 這裏 申請APIKey,每一個用戶每月有500張圖片壓縮是免費的,而每個郵箱可以註冊一個用戶,你懂的。

5. 優化applicationWillFinishLaunching

隨着項目做的時間長了,applicationWillFinishLaunching裏要處理的代碼會越積越多,WiFi管家的iOS版本有一段時間沒有控制好,裏面的邏輯亂得有點丟人。因爲可能涉及到一些項目的安全性問題,這裏不能分享所有的優化細節及發現的思路。僅列出在applicationWillFinishLaunching中主要需要處理的業務及相關問題的改進方案。

這裏大部分都是一些苦逼活,但有一點特別值得分享的是,有一些優化,是無法在數據上體現的,但是視覺上卻能給用戶較大的提升。例如在【各種業務請求配置更新】的部分,經過分析優化後,啓動過程併發的http請求數量從66條壓縮到了23條,如此一來爲啓動成功後新聞資訊及其圖片的加載留出了更多的帶寬,從而保證了在第一時間完成新聞資訊的加載。實際測試表明,光做KPI的事情是不夠的,人還是需要有點理想,經過優化,在視覺體驗上進步非常明顯。

另外,過程中請教過SNG的大牛們,聽說他們因爲需要在applicationWillFinishLaunching裏處理的業務更多,所以還做了管理器管理這些任務,不過因爲WiFi管家是個小項目,有點殺雞用牛刀的感覺,因此沒有深入研究。

6. 優化rootViewController加載

考慮到我作爲一隻高級程序猴,工資很高,爲了給公司節約成本,在優化之前,當然需要先測試一下哪些ViewController的加載耗時比較大,然後再深入到具體業務中看哪些部分存在較大的優化空間。同時,先做優化效果明顯的部分也有利於增強自己的信心。

在開始講述問題之前,我們先來看一下WiFi管家的UI層次結構:

一個看似簡單的界面由於承載了很多業務需求,代碼量其實已經非常驚人。這裏我不具體講述這些驚人的業務量了,抽象而言可WiFi管家的UI架構總體而言基於TabBarController的框架,三個tab分別是“連接”、“發現”及“我的”。App啓動的時候,根據加載原理,會加載TabBarController、第一個Tab(“連接”)的ViewController及其所有childViewController。

UI構架請看如下示意圖,其中藍色的部分需要在App啓動的時候立即加載:

對所有啓動相關的模塊打錨點計算耗時後,發現tabBarController和connectingViewController分別佔用了applicationWillFinishLaunching耗時的31%和24%。加載耗費了大量時間,這跟它所需要承載的邏輯任務似乎並不對稱。於是檢查相關代碼進行深入分析,發現了幾個問題比較嚴重:

  1. 有些程序員可能架構意識不是太強,直接在tabBarController的啓動過程中插入了各種奇怪的業務,例如檢查WiFi連接狀態變化、配置拉取,而這些業務顯然應該在另外的某些地方統一處理,而不應該在一個ViewController上。

  2. 由於一些歷史原因,連接頁的視圖控制器connectingViewController包含了三個childViewController:WiFiViewController、3GViewController、errorViewController,分別在WiFi狀態、3G狀態和出錯狀態下展示界面(三選一,其中一個展示的時候其它兩個視圖會隱藏)。

  3. 大部分view都是直接加載完的。有些界面的加載非常複雜,比如再進入App時會展示一個檢查WiFi可用性和安全性的動畫,由於需要疊加較多圖片,這部分視圖的加載耗時較多。

由於隨着幾次改版之後,連接頁的UI架構已經變得很不合理,歷史包袱還是比較重的,而且耦合比較嚴重,幾乎無法改動,因此決定重構。至於tabBarController,檢查代碼後決定簡單的把不相關的業務做一些遷移,優化childViewController的加載過程,不作重構。

改進後的結構大致如下圖,其中藍色部分需要在App啓動的時候立即加載:

由於本篇主要講啓動性能優化,重構涉及的軟件工程和設計模式方面的東西就不詳細論述了,對啓動優化的過程,主要是使用了更合理的分層結構,使得啓動得以在更短的時間內完成。

至此,WiFi管家的啓動性能基本優化完畢。

7. 挖掘最後一點性能優化

由於WiFi管家是一個具有WiFi連接能力的App,因此有可能在後臺過程中完成冷啓動過程(實際上是在用戶進入系統的WiFi設置時,iOS會啓動WiFi管家,以便請求WiFi密碼)。在這種情況下,整個rootViewController都是不需要加載的。

【第四部分】總結

  • 利用DYLD_PRINT_STATISTICS分析main()函數之前的耗時
  • 重新梳理架構,減少動態庫、ObjC類的數目,減少Category的數目
  • 定期掃描不再使用的動態庫、類、函數,例如每兩個迭代一次
  • 用dispatch_once()代替所有的 attribute((constructor)) 函數、C++靜態對象初始化、ObjC的+load
  • 在設計師可接受的範圍內壓縮圖片的大小,會有意外收穫
  • 利用錨點分析applicationWillFinishLaunching的耗時
  • 將不需要馬上在applicationWillFinishLaunching執行的代碼延後執行
  • rootViewController的加載,適當將某一級的childViewController或subviews延後加載
  • 如果你的App可能會被後臺拉起並冷啓動,可考慮不加載rootViewController
  • 不應放過的一些小細節
  • 異步操作並不影響指標,但有可能影響交互體驗,例如大量網絡請求導致數據擁堵
  • 有時候一些交互上的優化比技術手段效果更明顯,視覺上的快決不是冰冷的數據可以解釋的,好好和你們的設計師談談動畫

更多精彩內容歡迎關注騰訊 Bugly的微信公衆賬號:

騰訊 Bugly是一款專爲移動開發者打造的質量監控工具,幫助開發者快速,便捷的定位線上應用崩潰的情況以及解決方案。智能合併功能幫助開發同學把每天上報的數千條 Crash 根據根因合併分類,每日日報會列出影響用戶數最多的崩潰,精準定位功能幫助開發同學定位到出問題的代碼行,實時上報可以在發佈後快速的瞭解應用的質量情況,適配最新的 iOS, Android 官方操作系統,鵝廠的工程師都在使用,快來加入我們吧!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章