1. 程式人生 > >今日頭條iOS客戶端啟動速度優化

今日頭條iOS客戶端啟動速度優化


應用啟動時間,直接影響使用者對一款應用的判斷和使用體驗。頭條主app本身就包含非常多並且複雜度高的業務模組(如新聞、視訊等),也接入了很多第三方的外掛,這勢必會拖慢應用的啟動時間,本著精益求精的態度和對使用者體驗的追求,我們希望在業務擴張的同時最大程度的優化啟動時間。

技術調研

先說結論,t(App總啟動時間) = t1(main()之前的載入時間) + t2(main()之後的載入時間)。 t1 = 系統dylib(動態連結庫)和自身App可執行檔案的載入; 
t2 = main方法執行之後到AppDelegate類中的- (BOOL)Application:(UIApplication *)Application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions

方法執行結束前這段時間,主要是構建第一個介面,並完成渲染展示。

main()呼叫之前的載入過程

App開始啟動後,系統首先載入可執行檔案(自身App的所有.o檔案的集合),然後載入動態連結庫dyld,dyld是一個專門用來載入動態連結庫的庫。 執行從dyld開始,dyld從可執行檔案的依賴開始, 遞迴載入所有的依賴動態連結庫。 
動態連結庫包括:iOS 中用到的所有系統 framework,載入OC runtime方法的libobjc,系統級別的libSystem,例如libdispatch(GCD)和libsystem_blocks (Block)。

其實無論對於系統的動態連結庫還是對於App本身的可執行檔案而言,他們都算是image(映象),而每個App都是以image(映象)為單位進行載入的,那麼image究竟包括哪些呢?

什麼是image

1.executable可執行檔案 比如.o檔案。 
2.dylib 動態連結庫 framework就是動態連結庫和相應資源包含在一起的一個資料夾結構。 
3.bundle 資原始檔 只能用dlopen載入,不推薦使用這種方式載入。

除了我們App本身的可行性檔案,系統中所有的framework比如UIKit、Foundation等都是以動態連結庫的方式整合進App中的。

系統使用動態連結有幾點好處:

程式碼共用:很多程式都動態連結了這些 lib,但它們在記憶體和磁碟中中只有一份。 易於維護:由於被依賴的 lib 是程式執行時才連結的,所以這些 lib 很容易做更新,比如libSystem.dylib 是 libSystem.B.dylib 的替身,哪天想升級直接換成libSystem.C.dylib 然後再替換替身就行了。 減少可執行檔案體積:相比靜態連結,動態連結在編譯時不需要打進去,所以可執行檔案的體積要小很多。

如上圖所示,不同程序之間共用系統dylib的_TEXT區,但是各自維護對應的_DATA區。

所有動態連結庫和我們App中的靜態庫.a和所有類檔案編譯後的.o檔案最終都是由dyld(the dynamic link editor),Apple的動態連結器來載入到記憶體中。每個image都是由一個叫做ImageLoader的類來負責載入(一一對應),那麼ImageLoader又是什麼呢?

什麼是ImageLoader

image 表示一個二進位制檔案(可執行檔案或 so 檔案),裡面是被編譯過的符號、程式碼等,所以 ImageLoader 作用是將這些檔案載入進記憶體,且每一個檔案對應一個ImageLoader例項來負責載入。 
兩步走: 在程式執行時它先將動態連結的 image 遞迴載入 (也就是上面測試棧中一串的遞迴呼叫的時刻)。 再從可執行檔案 image 遞迴載入所有符號。

當然所有這些都發生在我們真正的main函式執行前。

動態連結庫載入的具體流程

動態連結庫的載入步驟具體分為5步:

  1. load dylibs image 讀取庫映象檔案
  2. Rebase image
  3. Bind image
  4. Objc setup
  5. initializers

load dylibs image

在每個動態庫的載入過程中, dyld需要:

  1. 分析所依賴的動態庫
  2. 找到動態庫的mach-o檔案
  3. 開啟檔案
  4. 驗證檔案
  5. 在系統核心註冊檔案簽名
  6. 對動態庫的每一個segment呼叫mmap()

通常的,一個App需要載入100到400個dylibs, 但是其中的系統庫被優化,可以很快的載入。 針對這一步驟的優化有:

  1. 減少非系統庫的依賴
  2. 合併非系統庫
  3. 使用靜態資源,比如把程式碼加入主程式

rebase/bind

由於ASLR(address space layout randomization)的存在,可執行檔案和動態連結庫在虛擬記憶體中的載入地址每次啟動都不固定,所以需要這2步來修復映象中的資源指標,來指向正確的地址。 rebase修復的是指向當前映象內部的資源指標; 而bind指向的是映象外部的資源指標。 
rebase步驟先進行,需要把映象讀入記憶體,並以page為單位進行加密驗證,保證不會被篡改,所以這一步的瓶頸在IO。bind在其後進行,由於要查詢符號表,來指向跨映象的資源,加上在rebase階段,映象已被讀入和加密驗證,所以這一步的瓶頸在於CPU計算。 
通過命令列可以檢視相關的資源指標:

xcrun dyldinfo -rebase -bind -lazy_bind myApp.App/myApp

優化該階段的關鍵在於減少__DATA segment中的指標數量。我們可以優化的點有:

  1. 減少Objc類數量, 減少selector數量
  2. 減少C++虛擬函式數量
  3. 轉而使用swift stuct(其實本質上就是為了減少符號的數量)

Objc setup

這一步主要工作是:

  1. 註冊Objc類 (class registration)
  2. 把category的定義插入方法列表 (category registration)
  3. 保證每一個selector唯一 (selctor uniquing)

由於之前2步驟的優化,這一步實際上沒有什麼可做的。

initializers

以上三步屬於靜態調整(fix-up),都是在修改__DATA segment中的內容,而這裡則開始動態調整,開始在堆和堆疊中寫入內容。 在這裡的工作有:

  1. Objc的+load()函式
  2. C++的建構函式屬性函式 形如attribute((constructor)) void DoSomeInitializationWork()
  3. 非基本型別的C++靜態全域性變數的建立(通常是類或結構體)(non-trivial initializer) 比如一個全域性靜態結構體的構建,如果在建構函式中有繁重的工作,那麼會拖慢啟動速度

Objc的load函式和C++的靜態建構函式採用由底向上的方式執行,來保證每個執行的方法,都可以找到所依賴的動態庫。

上圖是在自定義的類XXViewController的+load方法斷點的呼叫堆疊,清楚的看到整個呼叫棧和順序:

  1. dyld 開始將程式二進位制檔案初始化
  2. 交由 ImageLoader 讀取 image,其中包含了我們的類、方法等各種符號
  3. 由於 runtime 向 dyld 綁定了回撥,當 image 載入到記憶體後,dyld 會通知 runtime 進行處理
  4. runtime 接手後呼叫 mapimages 做解析和處理,接下來 loadimages 中呼叫 callloadmethods 方法,遍歷所有載入進來的 Class,按繼承層級依次呼叫 Class 的 +load 方法和其 Category 的 +load 方法

至此,可執行檔案中和動態庫所有的符號(Class,Protocol,Selector,IMP,…)都已經按格式成功載入到記憶體中,被 runtime 所管理,再這之後,runtime 的那些方法(動態新增 Class、swizzle 等等才能生效)。

整個事件由 dyld 主導,完成執行環境的初始化後,配合 ImageLoader 將二進位制檔案按格式載入到記憶體, 動態連結依賴庫,並由 runtime 負責載入成 objc 定義的結構,所有初始化工作結束後,dyld 呼叫真正的 main 函式。

如果程式剛剛被執行過,那麼程式的程式碼會被dyld快取,因此即使殺掉程序再次重啟載入時間也會相對快一點,如果長時間沒有啟動或者當前dyld的快取已經被其他應用佔據,那麼這次啟動所花費的時間就要長一點,這就分別是熱啟動和冷啟動的概念,如下圖所示:

main()之前的載入時間如何衡量

那麼問題就來了,那怎麼衡量main()之前也就是time1的耗時呢,蘋果官方提供了一種方法,那就是在真機除錯的時候勾選dyldPRINTSTATISTICS選項。

會得到如下形式的輸出:

由此可見對於系統級別的動態連結庫,因為蘋果做了優化,所以耗時並不多,在這個awesome的例子中,自身App中的程式碼佔用了整體時間的94.2% 我們應用中一次典型的Log如下:

由此可見,最多的用時還是在image載入和OC類的初始化,共佔用總時長的79.3%,精簡framework的引入和OC類有優化的空間。

總結一下:對於main()呼叫之前的耗時我們可以優化的點有:

  1. 減少不必要的framework,因為動態連結比較耗時
  2. check framework應當設為optional和required,如果該framework在當前App支援的所有iOS系統版本都存在,那麼就設為required,否則就設為optional,因為optional會有些額外的檢查
  3. 合併或者刪減一些OC類,關於清理專案中沒用到的類,使用工具AppCode程式碼檢查功能,查到當前專案中沒有用到的類如下:

  1. 刪減一些無用的靜態變數
  2. 刪減沒有被呼叫到或者已經廢棄的方法

       方法見:
    
    
       http://stackoverflow.com/questions/35233564/how-to-find-unused-code-in-xcode-7
       https://developer.Apple.com/library/ios/documentation/ToolsLanguages/Conceptual/Xcode_Overview/CheckingCodeCoverage.html
    
  3. 將不必須在+load方法中做的事情延遲到+initialize中

  4. 儘量不要用C++虛擬函式(建立虛擬函式表有開銷)

main()呼叫之後的載入時間

在main()被呼叫之後,App的主要工作就是初始化必要的服務,顯示首頁內容等。而我們的優化也是圍繞如何能夠快速展現首頁來開展。 App通常在AppDelegate類中的- (BOOL)Application:(UIApplication *)Application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions方法中建立首頁需要展示的view,然後在當前runloop的末尾,主動呼叫CA::Transaction::commit完成檢視的渲染。 
而檢視的渲染主要涉及三個階段:

  1. 準備階段 這裡主要是圖片的解碼
  2. 佈局階段 首頁所有UIView的- (void)layoutSubViews()執行
  3. 繪製階段 首頁所有UIView的- (void)drawRect:(CGRect)rect執行 
    再加上啟動之後必要服務的啟動、必要資料的建立和讀取,這些就是我們可以嘗試優化的地方

因此,對於main()函式呼叫之前我們可以優化的點有:

  1. 不使用xib,直接視用程式碼載入首頁檢視
  2. NSUserDefaults實際上是在Library資料夾下會生產一個plist檔案,如果檔案太大的話一次能讀取到記憶體中可能很耗時,這個影響需要評估,如果耗時很大的話需要拆分(需考慮老版本覆蓋安裝相容問題)
  3. 每次用NSLog方式列印會隱式的建立一個Calendar,因此需要刪減啟動時各業務方打的log,或者僅僅針對內測版輸出log
  4. 梳理應用啟動時傳送的所有網路請求,是否可以統一在非同步執行緒請求

實測資料

建立了一個空的HelloWorld工程,只加入了pods中的程式碼,不包含主端的業務邏輯程式碼,一次典型的冷啟動基本接近2s iPhone6 iOS9.3.5系統測試主要時間在載入動態庫,類/方法的初始化還有符號地址繫結階段。

一次典型的熱啟動資料如下:可以看到因為系統做了快取方面的優化,比冷啟動快了500ms加上頭條主端業務邏輯程式碼之後一次典型的熱啟動耗時2.1s。

以上用時均為main()之前的載入耗時。

main()函式之後載入時間優化記錄

NSUserDefaults是否是瓶頸

蘋果官方文件提到NSUserDefaults載入的時候是整個plist配置檔案全部load到記憶體中,目前頭條主端當中NSUserDefaults儲存了200多項快取資料,因此懷疑可能拖慢啟動速度,但是測試結果顯示並不會。 通過符號斷點+[NSUserDefaults standardUserDefaults]確定最早一次的+load()從執行到結束耗時1.8ms,可見NSUserDefaults的初始化僅耗時1.8ms,並不是啟動耗時的瓶頸。

如何找到拖慢啟動應用時長的瓶頸

為了找到瓶頸,我們在啟動之後的didFinishLauhcning方法開始執行到首頁列表頁的NewsListViewController的viewDidAppear方法,幾乎每個可能比較耗時的流程進行拆分和統計,得到統計資料之後發現: 主要耗時在首頁UI構造和渲染(storyboard載入,tabBar/topBar渲染,開屏廣告載入/cell註冊/日誌模組初始化這幾個步驟)。

具體優化點

因此,針對於今日頭條這個App我們可以優化的點如下:

  1. 純程式碼方式而不是storyboard載入首頁UI。
  2. 對didFinishLaunching裡的函式考慮能否挖掘可以延遲載入或者懶載入,需要與各個業務方pm和rd共同check 對於一些已經下線的業務,刪減冗餘程式碼。 
    對於一些與UI展示無關的業務,如微博認證過期檢查、圖片最大快取空間設定等做延遲載入
  3. 對實現了+load()方法的類進行分析,儘量將load裡的程式碼延後呼叫。
  4. 上面統計資料顯示展示feed的導航控制器頁面(NewsListViewController)比較耗時,對於viewDidLoad以及viewWillAppear方法中儘量去嘗試少做,晚做,不做。

優化結果

之前曾經有一位同事已經做了一定的優化,比如啟動之後展示閃屏廣告圖的同時初始化首頁的列表頁,當廣告展示完成之後列表頁也就渲染完成了。經過這一次優化之後的main()之後的啟動總時長通過上線之後收集資料的驗證達到了預期的效果。