歡迎光臨
每天分享高質量文章

iOS啟動時間最佳化

來自:第七章

連結:http://www.zoomfeng.com/blog/launch-time.html

背景

一個專案做的時間長了,啟動流程往往容易雜亂,庫也用的越來越多,APP的啟動時間也會慢慢變長。本次將針對iOS APP的啟動時間最佳化一波。

通常針對一個技術點做最佳化的時候,都要先瞭解清楚這個技術點有哪些流程,最佳化的方嚮往往是減少流程的數量,以及減少每個流程的消耗。

本次最佳化從結果上來看,main階段的最佳化效果最顯著,尤其是啟動時的一些IO操作處理,對啟動時間的減少有很大作用。多執行緒啟動的設計和驗證最有意思,但是在實踐上由於我們業務本身的原因,只開了額外一個子執行緒來並行啟動,且僅在子執行緒做了少量的獨立操作,主要還是我們的業務之間耦合性太強了,不太適合拆分到子執行緒。

一般說來,pre-main階段的定義為APP開始啟動到系統呼叫main函式這一段時間;main階段則代表從main函式入口到主UI框架的viewDidAppear函式呼叫的這一段時間。(本文後續main階段的時間統計都用viewDidAppear作為基準而非的applicationWillFinishLaunching)

本文前半部分講原理(內容基本是從網上借鑒/摘錄),後半部分講實踐,pre-main階段的原理比較難理解,不過實踐倒是根據結論直接做就好了。

App啟動過程

①解析Info.plist 

  • 載入相關資訊,例如閃屏

  • 沙箱建立、許可權檢查


②Mach-O載入 

  • 如果是胖二進位制檔案,尋找合適當前CPU架構的部分

  • 載入所有依賴的Mach-O檔案(遞迴呼叫Mach-O載入的方法)

  • 定位內部、外部指標取用,例如字串、函式等

  • 執行宣告為__attribute__((constructor))的C函式

  • 載入類擴充套件(Category)中的方法

  • C++靜態物件載入、呼叫ObjC的 +load 函式


③程式執行 

  • 呼叫main()

  • 呼叫UIApplicationMain()

  • 呼叫applicationWillFinishLaunching


換成另一個說法就是:

App開始啟動後,系統首先載入可執行檔案(自身App的所有.o檔案的集合),然後載入動態聯結器dyld,dyld是一個專門用來載入動態連結庫的庫。 執行從dyld開始,dyld從可執行檔案的依賴開始, 遞迴載入所有的依賴動態連結庫。

動態連結庫包括:iOS 中用到的所有系統 framework,載入OC runtime方法的libobjc,系統級別的libSystem,例如libdispatch(GCD)和libsystem_blocks (Block)。

可執行檔案的核心流程

如圖,當啟動一個應用程式時,系統最後會根據你的行為呼叫兩個函式,fork和execve。fork功能建立一個行程;execve功能載入和執行程式。這裡有多個不同的功能,比如execl,execv和exect,每個功能提供了不同傳參和環境變數的方法到程式中。在OSX中,每個這些其他的exec路徑最終呼叫了核心路徑execve。

1、執行exec系統呼叫,一般都是這樣,用fork()函式新建立一個行程,然後讓行程去執行exec呼叫。我們知道,在fork()建立新行程之後,父行程與子行程共享程式碼段(TEXT),但資料空間(DATA)是分開的,但父行程會把自己資料空間的內容copy到子行程中去,還有背景關係也會copy到子行程中去。

2、為了提高效率,採用一種寫時copy的策略,即建立子行程的時候,並不copy父行程的地址空間,父子行程擁有共同的地址空間,只有當子行程需要寫入資料時(如向緩衝區寫入資料),這時候會複製地址空間,複製緩衝區到子行程中去。從而父子行程擁有獨立的地址空間。而對於fork()之後執行exec後,這種策略能夠很好的提高效率,如果一開始就copy,那麼exec之後,子行程(確定不是父行程?)的資料會被放棄,被新的行程所代替。

啟動時間的分佈,pre-main和main階段原理淺析

rebase修複的是指向當前映象內部的資源指標; 而bind指向的是映象外部的資源指標。

rebase步驟先進行,需要把映象讀入記憶體,並以page為單位進行加密驗證,保證不會被篡改,所以這一步的瓶頸在IO。bind在其後進行,由於要查詢符號表,來指向跨映象的資源,加上在rebase階段,映象已被讀入和加密驗證,所以這一步的瓶頸在於CPU計算。這兩個步驟在下麵會詳細闡述。

pre-main過程


main過程

一些概念

什麼是dyld?

動態連結庫的載入過程主要由dyld來完成,dyld是蘋果的動態聯結器。

系統先讀取App的可執行檔案(Mach-O檔案),從裡面獲得dyld的路徑,然後載入dyld,dyld去初始化執行環境,開啟快取策略,載入程式相關依賴庫(其中也包含我們的可執行檔案),並對這些庫進行連結,最後呼叫每個依賴庫的初始化方法,在這一步,runtime被初始化。


當所有依賴庫的初始化後,輪到最後一位(程式可執行檔案)進行初始化,在這時runtime會對專案中所有類進行類結構初始化,然後呼叫所有的load方法。最後dyld傳回main函式地址,main函式被呼叫,我們便來到了熟悉的程式入口。

當載入一個 Mach-O 檔案 (一個可執行檔案或者一個庫) 時,動態聯結器首先會檢查共享快取看看是否存在其中,如果存在,那麼就直接從共享快取中拿出來使用。每一個行程都把這個共享快取對映到了自己的地址空間中。這個方法大大優化了 OS X 和 iOS 上程式的啟動時間。

問題:測試發現,由於手機從開機後,連續兩次啟動同一個APP的pre-main實際時間的差值比較大,這一步可以在真機上復現,那麼這兩次啟動pre-main的時間差值,是跟系統的framework關係比較大,還是跟APP自身依賴的第三方framework關係比較大呢?

Mach-O 映象檔案

Mach-O 被劃分成一些 segement,每個 segement 又被劃分成一些 section。segment 的名字都是大寫的,且空間大小為頁的整數。頁的大小跟硬體有關,在 arm64 架構一頁是 16KB,其餘為 4KB。

section 雖然沒有整數倍頁大小的限制,但是 section 之間不會有重疊。幾乎所有 Mach-O 都包含這三個段(segment): __TEXT,__DATA和__LINKEDIT。

  • __TEXT 包含 Mach essay-header,被執行的程式碼和只讀常量(如C 字串)。只讀可執行(r-x)。

  • __DATA 包含全域性變數,靜態變數等。可讀寫(rw-)。

  • __LINKEDIT 包含了載入程式的『元資料』,比如函式的名稱和地址。只讀(r–)。

ASLR(Address Space Layout Randomization):地址空間佈局隨機化,映象會在隨機的地址上載入。

傳統方式下,行程每次啟動採用的都是固定可預見的方式,這意味著一個給定的程式在給定的架構上的行程初始虛擬記憶體都是基本一致的,而且在行程正常執行的生命週期中,記憶體中的地址分佈具有非常強的可預測性,這給了駭客很大的施展空間(程式碼註入,重寫記憶體);

如果採用ASLR,行程每次啟動,地址空間都會被簡單地隨機化,但是隻是偏移,不是攪亂。大體佈局——程式文字、資料和庫是一樣的,但是具體的地址都不同了,可以阻擋駭客對地址的猜測 。

程式碼簽名:可能我們認為 Xcode 會把整個檔案都做加密 hash 並用做數字簽名。其實為了在執行時驗證 Mach-O 檔案的簽名,並不是每次重覆讀入整個檔案,而是把每頁內容都生成一個單獨的加密雜湊值,並儲存在 __LINKEDIT 中。這使得檔案每頁的內容都能及時被校驗確並保不被篡改。

關於虛擬記憶體

我們開發者開發過程中所接觸到的記憶體均為虛擬記憶體,虛擬記憶體使App認為它擁有連續的可用的記憶體(一個連續完整的地址空間),這是系統給我們的饋贈,而實際上,它通常是分佈在多個物理記憶體碎片,系統的虛擬記憶體空間對映vm_map負責虛擬記憶體和物理記憶體的對映關係。

ARM處理器64bit的架構情況下,也就是0x000000000 – 0xFFFFFFFFF,每個16進位制數是4位,即2的36次冪,就是64GB,即App最大的虛擬記憶體空間為64GB。

共享動態庫其實就是共享的物理記憶體中的那份動態庫,App虛擬記憶體中的共享動態庫並未真實分配物理記憶體,使用時虛擬記憶體會訪問同一份物理記憶體達到共享動態庫的目的。


iPhone7 PLUS(之前的產品最大為2GB)的物理記憶體RAM也只有3GB,那麼超過3GB的物理記憶體如何處理呢,系統會使用一部分硬碟空間ROM來充當記憶體使用,在需要時進行資料交換,當然磁碟的資料交換是遠遠慢於物理記憶體的,這也是我們記憶體過載時,App卡頓的原因之一。

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

程式碼共用:很多程式都動態連結了這些 lib,但它們在記憶體和磁碟中中只有一份。

易於維護:由於被依賴的 lib 是程式執行時才連結的,所以這些 lib 很容易做更新,比如libSystem.dylib 是 libSystem.B.dylib 的替身,哪天想升級直接換成libSystem.C.dylib 然後再替換替身就行了。

減少可執行檔案體積:相比靜態連結,動態連結在編譯時不需要打進去,所以可執行檔案的體積要小很多。

上圖中,TEXT段兩個行程共用,DATA段每個行程各一份。

下麵開始詳細分析pre-main的各個階段

載入 Dylib

從主執行檔案的 essay-header 獲取到需要載入的所依賴動態庫串列,而 essay-header 早就被核心對映過。然後它需要找到每個 dylib,然後開啟檔案讀取檔案起始位置,確保它是 Mach-O 檔案。接著會找到程式碼簽名並將其註冊到核心。然後在 dylib 檔案的每個 segment 上呼叫 mmap()。


應用所依賴的 dylib 檔案可能會再依賴其他 dylib,所以 dyld 所需要載入的是動態庫串列一個遞迴依賴的集合。一般應用會載入 100 到 400 個 dylib 檔案,但大部分都是系統 dylib,它們會被預先計算和快取起來,載入速度很快。

載入系統的 dylib 很快,因為有最佳化(因為作業系統自己要用部分framework所以在作業系統開機後就已經快取了?)。但載入內嵌(embedded)的 dylib 檔案很佔時間,所以盡可能把多個內嵌 dylib 合併成一個來載入,或者使用 static archive。使用 dlopen() 來在執行時懶載入是不建議的,這麼做可能會帶來一些問題,並且總的開銷更大。

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


  1. 分析所依賴的動態庫

  2. 找到動態庫的mach-o檔案

  3. 開啟檔案

  4. 驗證檔案

  5. 在系統核心註冊檔案簽名

  6. 對動態庫的每一個segment呼叫mmap()


針對這一步驟的最佳化有:


  1. 減少非系統庫的依賴;

  2. 使用靜態庫而不是動態庫;

  3. 合併非系統動態庫為一個動態庫;


Rebase && Binding

Fix-ups

在載入所有的動態連結庫之後,它們只是處在相互獨立的狀態,需要將它們系結起來,這就是 Fix-ups。程式碼簽名使得我們不能修改指令,那樣就不能讓一個 dylib 的呼叫另一個 dylib。這時需要加很多間接層。

現代 code-gen 被叫做動態 PIC(Position Independent Code),意味著程式碼可以被載入到間接的地址上。當呼叫發生時,code-gen 實際上會在 __DATA 段中建立一個指向被呼叫者的指標,然後載入指標並跳轉過去。

所以 dyld 做的事情就是修正(fix-up)指標和資料。Fix-up 有兩種型別,rebasing 和 binding。

Rebase

Rebasing:在映象內部調整指標的指向,針對mach-o在載入到記憶體中不是固定的首地址(ASLR)這一現象做資料修正的過程;

由於ASLR(address space layout randomization)的存在,可執行檔案和動態連結庫在虛擬記憶體中的載入地址每次啟動都不固定,所以需要這2步來修複映象中的資源指標,來指向正確的地址。 rebase修複的是指向當前映象內部的資源指標; 而bind指向的是映象外部的資源指標。

在iOS4.3前會把dylib載入到指定地址,所有指標和資料對於程式碼來說都是固定的,dyld 就無需做rebase/binding了。

iOS4.3後引入了 ASLR ,dylib會被載入到隨機地址,這個隨機的地址跟程式碼和資料指向的舊地址會有偏差,dyld 需要修正這個偏差,做法就是將 dylib 內部的指標地址都加上這個偏移量,偏移量的計算方法如下:

Slide = actual_address – preferred_address

然後就是重覆不斷地對 __DATA 段中需要 rebase 的指標加上這個偏移量。這就又涉及到 page fault 和 COW。這可能會產生 I/O 瓶頸,但因為 rebase 的順序是按地址排列的,所以從內核的角度來看這是個有次序的任務,它會預先讀入資料,減少 I/O 消耗。

在 Rebasing 和 Binding 前會判斷是否已經 Prebinding。如果已經進行過預系結(Prebinding),那就不需要 Rebasing 和 Binding 這些 Fix-up 流程了,因為已經在預先系結的地址載入好了。

rebase步驟先進行,需要把映象讀入記憶體,並以page為單位進行加密驗證,保證不會被篡改,所以這一步的瓶頸在IO。bind在其後進行,由於要查詢符號表,來指向跨映象的資源,加上在rebase階段,映象已被讀入和加密驗證,所以這一步的瓶頸在於CPU計算。

Binding

Binding:將指標指向映象外部的內容,binding就是將這個二進位制呼叫的外部符號進行系結的過程。比如我們objc程式碼中需要使用到NSObject, 即符號_OBJC_CLASS_$_NSObject,但是這個符號又不在我們的二進制中,在系統庫 Foundation.framework中,因此就需要binding這個操作將對應關係系結到一起;

lazyBinding就是在載入動態庫的時候不會立即binding, 當時當第一次呼叫這個方法的時候再實施binding。 做到的方法也很簡單: 透過dyld_stub_binder這個符號來做。lazyBinding的方法第一次會呼叫到dyld_stub_binder, 然後dyld_stub_binder負責找到真實的方法,並且將地址bind到樁上,下一次就不用再bind了。

Binding 是處理那些指向 dylib 外部的指標,它們實際上被符號(symbol)名稱系結,也就是個字串。__LINKEDIT段中也儲存了需要 bind 的指標,以及指標需要指向的符號。dyld 需要找到 symbol 對應的實現,這需要很多計算,去符號表裡查詢。找到後會將內容儲存到 __DATA 段中的那個指標中。Binding 看起來計算量比 Rebasing 更大,但其實需要的 I/O 操作很少,Binding的時間主要是耗費在計算上,因為IO操作之前 Rebasing 已經替 Binding 做過了,所以這兩個步驟的耗時是混在一起的。

可以從檢視 __DATA 段中需要修正(fix-up)的指標,所以減少指標數量才會減少這部分工作的耗時。對於 ObjC 來說就是減少 Class,selector 和 category 這些元資料的數量。從編碼原則和設計樣式之類的理論都會鼓勵大家多寫精緻短小的類和方法,並將每部分方法獨立出一個類別,其實這會增加啟動時間。


對於 C++ 來說需要減少虛方法,因為虛方法會建立 vtable,這也會在 __DATA 段中建立結構。雖然 C++ 虛方法對啟動耗時的增加要比 ObjC 元資料要少,但依然不可忽視。最後推薦使用 Swift 結構體,它需要 fix-up 的內容較少。

Objective-C 中有很多資料結構都是靠 Rebasing 和 Binding 來修正(fix-up)的,比如 Class 中指向父類的指標和指向方法的指標。

Rebase&&Binding;該階段的最佳化關鍵在於減少__DATA segment中的指標數量。我們可以最佳化的點有:

  1. 減少Objc類數量, 減少selector數量,把未使用的類和函式都可以刪掉 

  2. 減少C++虛函式數量

  3. 轉而使用swift stuct(其實本質上就是為了減少符號的數量,使用swift語言來開發?)


未使用類的掃描,可以利用linkmap檔案和otool工機具反編譯APP的可進行二進位制檔案得出一個大概的結果,但是不算非常精確,掃描出來後需要手動一個個確認。掃描原理大致是classlist和classref兩者的差值,所有的類和使用了的類的差值就是未使用的類啦。因為未使用的類主要最佳化的是pre-main的時間,根據測試我們的工程pre-main時間並不長,所以本次並沒有針對這一塊做最佳化。(TODO:寫指令碼來驗證這一點)。

ObjC SetUp

主要做以下幾件事來完成Objc Setup:

  1. 讀取二進位制檔案的 DATA 段內容,找到與 objc 相關的資訊

  2. 註冊 Objc 類,ObjC Runtime 需要維護一張對映類名與類的全域性表。當載入一個 dylib 時,其定義的所有的類都需要被註冊到這個全域性表中; 

  3. 讀取 protocol 以及 category 的資訊,把category的定義插入方法串列 (category registration), 

  4. 確保 selector 的唯一性 


ObjC 是個動態語言,可以用類的名字來實體化一個類的物件。這意味著 ObjC Runtime 需要維護一張對映類名與類的全域性表。當載入一個 dylib 時,其定義的所有的類都需要被註冊到這個全域性表中。

C++ 中有個問題叫做易碎的基類(fragile base class)。ObjC 就沒有這個問題,因為會在載入時透過 fix-up 動態類中改變實體變數的偏移量。

在 ObjC 中可以透過定義類別(Category)的方式改變一個類的方法。有時你想要新增方法的類在另一個 dylib 中,而不在你的映象中(也就是對系統或別人的類動刀),這時也需要做些 fix-up。

ObjC 中的 selector 必須是唯一的。

由於之前2步驟的最佳化,這一步實際上沒有什麼可做的。幾乎都靠 Rebasing 和 Binding 步驟中減少所需 fix-up 內容。因為前面的工作也會使得這步耗時減少。

Initializers

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

  1. Objc的+load()函式

  2. C++的建構式屬性函式 形如attribute((constructor)) void DoSomeInitializationWork()

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


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

  1. dyld開始將程式二進位制檔案初始化

  2. 交由ImageLoader讀取image,其中包含了我們的類、方法等各種符號

  3. 由於runtime向dyld系結了回呼,當image載入到記憶體後,dyld會通知runtime進行處理

  4. runtime接手後呼叫mapimages做解析和處理,接下來loadimages中呼叫 callloadmethods方法,遍歷所有載入進來的Class,按繼承層級依次呼叫Class的+load方法和其 Category的+load方法


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

C++ 會為靜態建立的物件生成初始化器。而在 ObjC 中有個叫 +load 的方法,然而它被廢棄了,現在建議使用 +initialize。對比詳見StackOverflow的一個連線;

這一步可以做的最佳化有:

①使用 +initialize 來替代 +load

②不要使用 atribute((constructor)) 將方法顯式標記為初始化器,而是讓初始化方法呼叫時才執行。比如使用 dispatch_once(),pthread_once() 或 std::once()。也就是在第一次使用時才初始化,推遲了一部分工作耗時。也儘量不要用到C++的靜態物件。


從效率上來說,在+load 和+initialize裡執行同樣的程式碼,效率是一樣的,即使有差距,也不會差距太大。 但所有的+load 方法都在啟動的時候呼叫,方法多了就會嚴重影響啟動速度了。 就說我們專案中,有200個左右+load方法,一共耗時大概1s 左右,這塊就會嚴重影響到使用者感知了。 


而+initialize方法是在對應 Class 第一次使用的時候呼叫,這是一個懶載入的方法,理想情況下,這200個+load方法都使用+initialize來代替,將耗時分攤到使用者使用過程中,每個方法平均耗時只有5ms,使用者完全可以無感知。 因為load是在啟動的時候呼叫,而initialize是在類首次被使用的時候呼叫,不過當你把load中的邏輯移到initialize中時候,一定要註意initialize的重覆呼叫問題,能用dispatch_once()來完成的,就儘量不要用到load方法。

如果程式剛剛被執行過,那麼程式的程式碼會被dyld快取,因此即使殺掉行程再次重啟載入時間也會相對快一點,如果長時間沒有啟動或者當前dyld的快取已經被其他應用佔據,那麼這次啟動所花費的時間就要長一點,這就分別是熱啟動和冷啟動的概念。下文中的啟動時間統計,均統計的是第二次啟動後的資料。(具體dyld快取的是動態庫還是APP的可執行程式碼,快取多長時間,需要再研究,有懂的大神可以告知一下?)

見下圖,出處是這裡:

其實在我們APP的實踐過程中也會遇到類似的事情,只不過我只統計了第二次啟動後的時間,也就是定義中的熱啟動時間。

註:

透過在工程的scheme中新增環境變數DYLD_PRINT_STATISTICS,設定值為1,App啟動載入時Xcode的控制檯就會有pre-main各個階段的詳細耗時輸出。但是DYLD_PRINT_STATISTICS 這個變數對真機的iOS9.3系統不行,iOS10就沒問題。估計是iOS系統的bug.

pre-main階段耗時的影響因素:

  1. 動態庫載入越多,啟動越慢。

  2. ObjC類越多,函式越多,啟動越慢。

  3. 可執行檔案越大啟動越慢。

  4. C的constructor函式越多,啟動越慢。

  5. C++靜態物件越多,啟動越慢。

  6. ObjC的+load越多,啟動越慢。


整體上pre-main階段的最佳化有:

①減少依賴不必要的庫,不管是動態庫還是靜態庫;如果可以的話,把動態庫改造成靜態庫;如果必須依賴動態庫,則把多個非系統的動態庫合併成一個動態庫;


②檢查下 framework應當設為optional和required,如果該framework在當前App支援的所有iOS系統版本都存在,那麼就設為required,否則就設為optional,因為optional會有些額外的檢查; 


③合併或者刪減一些OC類和函式;關於清理專案中沒用到的類,使用工具AppCode程式碼檢查功能,查到當前專案中沒有用到的類(也可以用根據linkmap檔案來分析,但是準確度不算很高);有一個叫做[FUI](https://github.com/dblock/fui)的開源專案能很好的分析出不再使用的類,準確率非常高,唯一的問題是它處理不了動態庫和靜態庫裡提供的類,也處理不了C++的類模板。


④刪減一些無用的靜態變數,


⑤刪減沒有被呼叫到或者已經廢棄的方法,方法見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。


⑥將不必須在+load方法中做的事情延遲到+initialize中,儘量不要用C++虛函式(建立虛函式表有開銷)


⑦類和方法名不要太長:iOS每個類和方法名都在__cstring段裡都存了相應的字串值,所以類和方法名的長短也是對可執行檔案大小是有影響的;因還是object-c的動態特性,因為需要透過類/方法名反射找到這個類/方法進行呼叫,object-c物件模型會把類/方法名字串都儲存下來;


⑧用dispatch_once()代替所有的 attribute((constructor)) 函式、C++靜態物件初始化、ObjC的+load函式;


⑨在設計師可接受的範圍內壓縮圖片的大小,會有意外收穫。壓縮圖片為什麼能加快啟動速度呢?因為啟動的時候大大小小的圖片載入個十來二十個是很正常的,圖片小了,IO操作量就小了,啟動當然就會快了,比較靠譜的壓縮演演算法是TinyPNG。


我們的實踐

統計了各個庫所佔的size(使用之前做安裝包size最佳化的一個指令碼),基本上一個公共庫越大,類越多,啟動時在pre-main階段所需要的時間也越多。

所以去掉了Realm,DiffMatchPatch原始碼庫,以及AlicloudHttpDNS,BCConnectorBundl,FeedBack,SGMain和SecurityGuardSDK幾個庫;

結果如下:

靜態庫,少了7M左右:

第三方framework(其實也是靜態庫,只是指令碼會分開統計而已),少了1M左右:

我們使用cocoapodbs並沒有設定user_frameworks,所以pod管理的有原始碼的第三方庫都是靜態庫的形式,而framework形式的靜態庫基本都是第三方公司提供的服務,上圖可以看到,size佔比最大的還是阿裡和騰訊兩家的SDK,比如阿裡的推送和騰訊的直播和IM。

上圖在統計中,AliCloudHttpDNS的可執行檔案在Mac的Finder下的大小大概是10M,AlicloudUtils是3.4M,UTMini是16M,而UTDID只有1.6M。依賴關係上,AliCloudHttpDNS依賴AlicloudUtils,而AlicloudUtils依賴UTMini和UTDID,UTMini依賴UTDID。

上圖中在統計上,應該是有所AlicloudUtils在左右兩個圖中size差值過大,應該是依賴關係中UTMini導致的統計偏差。兩邊幾個庫加起來的差值大概是200kb,這也應該是AlicloudHttpDNS這個庫所佔的size大小。

main階段


總體原則無非就是減少啟動的時候的步驟,以及每一步驟的時間消耗。

main階段的最佳化大致有如下幾個點:

  1. 減少啟動初始化的流程,能懶載入的就懶載入,能放後臺初始化的就放後臺,能夠延時初始化的就延時,不要卡主執行緒的啟動時間,已經下線的業務直接刪掉; 

  2. 最佳化程式碼邏輯,去除一些非必要的邏輯和程式碼,減少每個流程所消耗的時間; 

  3. 啟動階段使用多執行緒來進行初始化,把CPU的效能儘量發揮出來; 

  4. 使用純程式碼而不是xib或者storyboard來進行UI框架的搭建,尤其是主UI框架比如TabBarController這種,儘量避免使用xib和storyboard,因為xib和storyboard也還是要解析成程式碼來渲染頁面,多了一些步驟; 


這裡重點講一下多執行緒啟動設計的原理

首先,iPhone有雙核,除了維持作業系統運轉和後臺行程(包括守護行程等),在開啟APP時,猜想雙核中的另一個核應該有餘力來幫忙分擔啟動的任務【待驗證】;

其次,iPhone7開始使用A10,iPhone8開始使用A11處理器,根據維基百科的定義,A10 CPU包括兩枚高效能核心及兩枚低功耗核心,A11則包括兩個高效能核心和四個高能效核心,而且相比A10,A11兩個效能核心的速度提升了25%,四個能效核心的速度提升了70%。而且蘋果還準備了第二代效能控制器,因此可以同時發揮六個核心的全部威力,效能提升最高可達70%,多執行緒能力可見一斑。

多執行緒測試結果如下圖:

結論如下:

  1. 純演演算法的CPU運算,指定計算的總數量,對於iPhone6和iPhone X來說,把計算量平均分配到多執行緒比全部放在主執行緒執行要快;

  2. .iPhone6三個執行緒跟兩個執行緒的總體耗時總體一致,甚至要多一點點,所以對於iPhone6來說用兩個執行緒來做啟動設計足夠了;

  3. iPhone X三個執行緒的耗時要比兩個執行緒短一些,但是差值已經不算太大了;四個執行緒跟三個執行緒的總體一致,偶爾能比三個執行緒快一點點;


綜上,利用多個執行緒來加速啟動時間的設計,是合理的。

但是多執行緒啟動的設計有幾個需要註意的點:

  1. 黑屏問題;

  2. 用狀態機來設計,每個狀態機有2或3個執行緒在跑不同的任務,所有執行緒任務都完成後,進入到下一個狀態,方便擴充套件;

  3. 執行緒保活問題,以及用完後要銷毀;

  4. 資源競爭或執行緒同步造成卡死的問題。


針對第一點,多執行緒跑初始化任務的時候,可能主執行緒會有空閑等待子執行緒的階段,而主執行緒一旦空閑,iOS系統的啟動畫面就會消失,如果此時APP尚未初始化完全,則可能會黑屏。為了避免黑屏,我們需要一個假的啟動畫面,在剛開始跑初始化任務時,就生成這個啟動畫面,啟動過程完全結束後再去掉。或者當一個狀態機裡的主執行緒跑完時檢查下是否所有執行緒都執行完任務了,如果沒有則去生成這個假的初始化頁面,避免黑屏。

第二點用狀態機來設計啟動,每個狀態跑兩個或者多個執行緒,單個狀態裡每個執行緒的耗時是不一樣的,跑完一個狀態再繼續下一個狀態,可以多次測試去動態調整分派到各個執行緒裡的任務。

第三點執行緒保活則跟runloop有關,每個執行緒啟動後,跑完一個狀態,不能立馬就回收,不然下一個狀態的子執行緒就永遠不會執行了;另外就是,APP初始化完成後,執行緒要註意回收。

第四點跟具體的業務有關,只要不是一個執行緒去做初始化,就有可能遇到執行緒間死鎖的問題,比如下麵採坑記錄裡就有提到一個例子。

我們在實踐中大概做了以下的幾點:

1.把啟動時RN包的刪除和複製操作,僅在APP安裝後第一次啟動時才做,之後的啟動不再做這操作,而是等到網路請求RN資料回來,根據是否需要更新RN包的判斷,再去做這些IO操作從而避免啟動的耗時。iPhone5C能節省1.4s;


2.OSS token的獲取不是一個需要在啟動的時候必須要做的操作,放到子執行緒去處理,大部分時候是節省10-15ms,偶爾能去到50ms;


3.去掉啟動狀態機裡的原有定位服務,原來SSZLocationManager的定位服務因為內部依賴高德的SDK,需要初始化SDK,iPhone5C大概耗時100ms。同時SSZLocationManager這個類程式碼保留,但是APP的工程去除對其的依賴;


4.打點統計模組裡的定位服務許可權請求改成非同步,大概有50ms;


5.阿裡百川的Feedback,在網校並沒有使用,直接去掉其初始化流程,大概5ms左右;


6.友盟的分享服務,沒有必要在啟動的時候去初始化,初始化任務丟到後臺執行緒解決,大概600-800ms;


7.狀態機跑完後的啟動記憶體統計,放到後臺去做,大概50ms;


8.UserAgentManager裡對於webview是否為UIWebview的判斷,以前是新建立一個物件使用物件方法來判斷,修改為直接使用類方法,避免建立物件的消耗,節省約200ms;


9.阿裡雲的HTTPDNS已經沒有再使用了,所以這裡也可以直接去掉。大概20-40ms;


10.SSZAppConfig裡把網路請求放到後臺執行緒,這樣子就可以配合啟動狀態機把該任務放到子執行緒進行初始化了,否則子執行緒消耗的時間太長了;


11.採用兩個執行緒來進行啟動流程的初始化,用狀態機來控制狀態的變化。但是要針對業務區分開,並不是把一部分業務拆分到子執行緒,就可以讓整體的啟動速度更快;因為如果子執行緒有一些操作是要在主執行緒做的,有可能會出現等待主執行緒空閑再繼續的情況;或者當兩個執行緒的耗時操作都是IO時,拆開到兩個執行緒,並不一定比單個執行緒去做IO操作要快。


12.主UI框架tabBarController的viewDidLoad函式裡,去掉一些不必要的函式呼叫。


13.NSUserDefaults的synchronize函式儘量不要在啟動流程中去呼叫,統一在APP進入後臺,willTerminate和完全進入前臺後把資料落地;


因為我們的專案用到了React Native技術(簡稱RN),所以會有RN包的複製和更新這一操作,之前的邏輯是每次啟動都從bundle裡複製一次到Document的指定目錄,本次最佳化修正為除了安裝後第一次啟動複製,其他時候不在做這個複製操作,不過RN包熱更新的改寫操作,還是每次都要做檢查的,如果有需要則執行更新操作

其中遇到幾個坑:

①並不是什麼任務都適合放子執行緒,有些任務在主執行緒大概10ms,放到子執行緒需要幾百ms,因為某些任務內部可能會用到UIKit的api,又或者某些操作是需要提交到主執行緒去執行的,關鍵是要搞清楚這個任務裡邊究竟做了啥,有些SDK並不見得適合放到子執行緒去初始化,需要具體情況具體去測試和分析。

②AccountManager初始化導致的主執行緒卡死,子執行緒的任務依賴AccountManager,主執行緒也依賴,當子執行緒比主執行緒先呼叫時,造成了主執行緒卡死,其原因是子執行緒會提交一個同步阻塞的操作到主執行緒,而此時主執行緒被dipatch_one的鎖鎖住了,所以造成子執行緒不能傳回,主執行緒也無法繼續執行。除錯的時候還會用到符號斷點和LLDB去列印函式入參的值。

實際最佳化效果對比

由於只是去掉了幾個靜態庫,而且本來pre-main階段的耗時就不長,基本在200ms-500ms左右,所以pre-main階段最佳化前後效果並不明顯,有時候還沒有前後測試的誤差大。。。

main的階段的最佳化效果還是很明顯的:

  • iPhone5C iOS10.3.3系統最佳化前main階段的時間消耗為4秒左右,最佳化後基本在1.8秒內;

  • iPhone7 iOS10.3.3系統最佳化前main階段的時間消耗為1.1秒左右,最佳化後基本在600ms內;

  • iPhoneX iOS11.3.1系統最佳化前main階段的時間消耗基本在1.5秒以上,最佳化後在1秒內;

可以看到,同樣arm64架構的機器,main階段是iPhone7比iPhoneX更快,說明就作業系統來說,iOS11.3要比iOS10.3慢不少;

詳細測試資料見下圖

上圖中iPhone5C為啥前後測試pre-main時間差值那麼大?而且是最佳化後的值比最佳化前還要大?我也不知道,大概機器自己才知道吧。。。

註意:

1.關於冷啟動和熱啟動,業界對冷啟動的定義沒有問題,普遍認為是手機開機後第一次啟動某個APP,但是對熱啟動有不同的看法,有些人認為是按下home鍵把APP掛到後臺,之後點選APP的icon再拉回來到前臺算是熱啟動,也有些人認為是手機開機後在短時間內第二次啟動APP(殺掉行程重啟)算是熱啟動(此時dyld會對部分APP的資料和庫進行快取,所以比第一次啟動要快)。


筆者認為APP從後臺拉起到前臺的時間沒啥研究的意義,而即使是短時間內第二次啟動APP,啟動時間也是很重要的,所以在統計啟動時間時,筆者會傾向於後一種說法,不過具體怎麼定義還是看個人吧,知道其中的區別就好。

2.關於如何區分framework是靜態庫還是動態庫見[這裡](https://www.jianshu.com/p/1cfdf363143b )。原理就是在終端使用指令file,輸出如果是ar archive就是靜態庫,如果是動態庫則會輸出dynamically linked相關資訊。

特別鳴謝

在做啟動最佳化的過程中,得到了很多朋友們的幫助和支援。借鑒了淮哥狀態機的設計思路,同時也感謝singro大神的指點,感謝劉金哥叫我玩LLDB,感謝長元兄對於動態庫和靜態庫的指教,感謝森哥的鞭策和精神鼓舞,以及展少爺在整個過程中的技術支援,引導和不耐其煩的解釋,再次謝謝大家,愛你們喲?!

參考連結:

  1. 最佳化 App 的啟動時間;

  2. iOS啟動最佳化;

  3. iOSApp啟動效能最佳化;

  4. 今日頭條iOS客戶端啟動速度最佳化;

  5. XNU、dyld原始碼分析Mach-O和動態庫的載入過程(上);

  6. 靜態庫,動態庫和framework;

  7. iOS 靜態庫,動態庫與 Framework;


編號277,輸入編號直達本文

●輸入m獲取文章目錄

推薦↓↓↓

Web開發

更多推薦18個技術類微信公眾號

涵蓋:程式人生、演演算法與資料結構、駭客技術與網路安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。

贊(0)

分享創造快樂