啓動是 App 給用戶的第一印象,啓動越慢用戶流失的機率就越高,良好的啓動速度是用戶體驗不可缺乏的一環。啓動優化涉及到的知識點很是多面也很廣,一篇文章難以包含所有,因此拆分紅兩部分:原理和實踐。php
本文從基礎知識出發,先回顧一些核心概念,爲後續章節作鋪墊;接下來介紹 IPA 構建的基本流程,以及這個流程裏可用於啓動優化的點;最後大篇幅講解 dyld3 的啓動 pipeline,由於啓動優化的重點還在運行時。html
小編推薦一個技術交流圈子會來淺談一下iOS開發中有哪些方向和職業規劃,同時小編也歡迎你們加入小編的能夠加QQ羣:1001906160! 羣裏會免費提供相關面試資料,書籍歡迎你們入駐!前端
啓動有兩種定義:面試
不一樣產品的業務形態不同,對於抖音來講,首頁的數據加載完成就是視頻的第一幀播放;對其餘首頁是靜態的 App 來講,Launch Image 消失就是首頁數據加載完成。因爲標準很難對齊,因此咱們通常使用狹義的啓動定義:即啓動終點爲啓動圖徹底消失的第一幀。swift
以抖音爲例,用戶感覺到的啓動時間:後端
Tips:啓動最佳時間是 400ms 之內,由於啓動動畫時長是 400ms。緩存
這是從用戶感知維度定義啓動,那麼代碼上如何定義啓動呢?Apple 在 MetricKit 中給出了官方計算方式:安全
CA::Transaction::commit()
Tips:
CATransaction
是 Core Animation 提供的一種事務機制,把一組 UI 上的修改打包,一塊兒發給 Render Server 渲染。閉包
根據場景的不一樣,啓動能夠分爲三種:冷啓動,熱啓動和回前臺。app
那麼,線上用戶的冷啓動多仍是熱啓動多呢?
答案是和產品形態有關係,打開頻次越高,熱啓動比例就越高。
Mach-O 是 iOS 可執行文件的格式,典型的 Mach-O 是主二進制和動態庫。Mach-O 能夠分爲三部分:
Header 的最開始是 Magic Number,表示這是一個 Mach-O 文件,除此以外還包含一些 Flags,這些 flags 會影響 Mach-O 的解析。
Load Commands 存儲 Mach-O 的佈局信息,好比 Segment command 和 Data 中的 Segment/Section 是一一對應的。除了佈局信息以外,還包含了依賴的動態庫等啓動 App 須要的信息。
Data 部分包含了實際的代碼和數據,Data 被分割成不少個 Segment,每一個 Segment 又被劃分紅不少個 Section,分別存放不一樣類型的數據。
標準的三個 Segment 是 TEXT,DATA,LINKEDIT,也支持自定義:
dyld 是啓動的輔助程序,是 in-process 的,即啓動的時候會把 dyld 加載到進程的地址空間裏,而後把後續的啓動過程交給 dyld。dyld 主要有兩個版本:dyld2 和 dyld3。
dyld2 是從 iOS 3.1 引入,一直持續到 iOS 12。dyld2 有個比較大的優化是dyld shared cache,什麼是 shared cache 呢?
iOS 13 開始 Apple 對三方 App 啓用了 dyld3,dyld3 的最重要的特性就是啓動閉包,閉包裏包含了啓動所須要的緩存信息,從而提升啓動速度。
內存能夠分爲虛擬內存和物理內存,其中物理內存是實際佔用的內存,虛擬內存是在物理內存之上創建的一層邏輯地址,保證內存訪問安全的同時爲應用提供了連續的地址空間。
物理內存和虛擬內存以頁爲單位映射,但這個映射關係不是一一對應的:一頁物理內存可能對應多頁虛擬內存;一頁虛擬內存也可能不佔用物理內存。
iPhone 6s 開始,物理內存的 Page 大小是 16K,6 和以前的設備都是 4K,這是 iPhone 6 相比 6s 啓動速度斷崖式降低的緣由之一。
mmap 的全稱是 memory map,是一種內存映射技術,能夠把文件映射到虛擬內存的地址空間裏,這樣就能夠像直接操做內存那樣來讀寫文件。當讀取虛擬內存,其對應的文件內容在物理內存中不存在的時候,會觸發一個事件:File Backed Page In,把對應的文件內容讀入物理內存。
啓動的時候,Mach-O 就是經過 mmap 映射到虛擬內存裏的(以下圖)。下圖中部分頁被標記爲 zero fill,是由於全局變量的初始值每每都是 0,那麼這些 0 就不必存儲在二進制裏,增長文件大小。操做系統會識別出這些頁,在 Page In 以後對其置爲 0,這個行爲叫作 zero fill。
啓動的路徑上會觸發不少次 Page In,其實也比較容易理解,由於啓動的會讀寫二進制中的不少內容。Page In 會佔去啓動耗時的很大一部分,咱們來看看單個 Page In 的過程:
其中解密是大頭,IO 其次。
爲何要解密呢?由於 iTunes Connect 會對上傳 Mach-O 的 TEXT 段進行加密,防止 IPA 下載下來就直接能夠看到代碼。這也就是爲何逆向裏會有個概念叫作「砸殼」,砸的就是這一層 TEXT 段加密。iOS 13 對這個過程進行了優化,Page In 的時候不須要解密了。
既然 Page In 耗時,有沒有什麼辦法優化呢?啓動具備局部性特徵,即只有少部分函數在啓動的時候用到,這些函數在二進制中的分佈是零散的,因此 Page In 讀入的數據利用率並不高。若是咱們能夠把啓動用到的函數排列到二進制的連續區間,那麼就能夠減小 Page In 的次數,從而優化啓動時間:
如下圖爲例,方法 1 和方法 3 是啓動的時候用到的,爲了執行對應的代碼,就須要兩次 Page In。假如咱們把方法 1 和 3 排列到一塊兒,那麼只須要一次 Page In,從而提高啓動速度。
連接器 ld 有個參數-order_file 支持按照符號的方式排列二進制。獲取啓動時候用到的符號的有不少種方式,感興趣的同窗能夠看看抖音以前的文章:基於二進制文件重排的解決方案 APP 啓動速度提高超 15%。
既然要構建,那麼必然會有一些地方去定義如何構建,對應 Xcode 中的兩個配置項:
以單 Target 爲例,咱們來看下構建流程:
編譯器能夠分爲兩大部分:前端和後端,兩者以 IR(中間代碼)做爲媒介。這樣先後端分離,使得先後端能夠獨立的變化,互不影響。C 語言家族的前端是 clang,swift 的前端是 swiftc,兩者的後端都是 llvm。
那麼如何利用編譯優化啓動速度呢?
代碼數量會影響啓動速度,爲了提高啓動速度,咱們能夠把一些無用代碼下掉。那怎麼統計哪些代碼沒有用到呢?能夠利用 LLVM 插樁來實現。
LLVM 的代碼優化流程是一個一個 Pass,因爲 LLVM 是開源的,咱們能夠添加一個自定義的 Pass,在函數的頭部插入一些代碼,這些代碼會記錄這個函數被調用了,而後把統計到的數據上傳分析,就能夠知道哪些代碼是用不到的了 。
Facebook 給 LLVM 提的order_file的 feature 就是實現了相似的插樁。
通過編譯後,咱們有不少個目標文件,接着這些目標文件會和靜態庫,動態庫一塊兒,連接出一個 Mach-O。連接的過程並不產生新的代碼,只會作一些移動和補丁。
舉一個基於連接優化啓動速度的例子:
最開始講解 Page In 的時候,咱們提到 TEXT 段的頁解密很耗時,有沒有辦法優化呢?
能夠經過 ld 的-rename_section,把 TEXT 段中的內容,好比字符串移動到其餘的段(啓動路徑上不免會讀不少字符串),從而規避這個解密的耗時。
抖音的重命名方案:
"-Wl,-rename_section,__TEXT,__cstring,__RODATA,__cstring",
"-Wl,-rename_section,__TEXT,__const,__RODATA,__const",
"-Wl,-rename_section,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab",
"-Wl,-rename_section,__TEXT,__objc_methname,__RODATA,__objc_methname",
"-Wl,-rename_section,__TEXT,__objc_classname,__RODATA,__objc_classname",
"-Wl,-rename_section,__TEXT,__objc_methtype,__RODATA,__objc_methtype"
複製代碼
編譯完 Mach-O 以後會進行裁剪(strip),是由於裏面有些信息,如調試符號,是不須要帶到線上去的。裁剪有多種級別,通常的配置以下:
爲何二方庫在出靜態庫的時候要選擇 Debugging Symbols 呢?是由於像 order_file 等連接期間的優化是基於符號的,若是把符號裁剪掉,那麼這些優化也就不會生效了。
裁剪完二進制後,會和編譯好的資源文件一塊兒打包成.app 文件,接着對這個文件進行簽名。簽名的做用是保證文件內容很少很多,沒有被篡改過。接着會把包上傳到 iTunes Connect,上傳後會對__TEXT
段加密,加密會減弱 IPA 的壓縮效果,增長包大小,也會下降啓動速度 (iOS 13 優化了加密過程,不會對包大小和啓動耗時有影響)。
Apple 在 iOS 13 上對第三方 App 啓用了 dyld3,官方數據顯示,過去四年新發布的設備中有 93%的設備是 iOS 13,因此咱們重點看下 dyld3 的啓動流程。
用戶點擊圖標以後,會發送一個系統調用 execve 到內核,內核建立進程。接着會把主二進制 mmap 進來,讀取 load command 中的 LC_LOAD_DYLINKER,找到 dyld 的的路徑。而後 mmap dyld 到虛擬內存,找到 dyld 的入口函數_dyld_start
,把 PC 寄存器設置成_dyld_start
,接下來啓動流程交給了 dyld。
注意這個過程都是在內核態完成的,這裏提到了 PC 寄存器,PC 寄存器存儲了下一條指令的地址,程序的執行就是不斷修改和讀取 PC 寄存器來完成的。
dyld 會首先建立啓動閉包,閉包是一個緩存,用來提高啓動速度的。既然是緩存,那麼必然不是每次啓動都建立的,只有在重啓手機或者更新/下載 App 的第一次啓動纔會建立。閉包存儲在沙盒的 tmp/com.apple.dyld 目錄,清理緩存的時候切記不要清理這個目錄。
閉包是怎麼提高啓動速度的呢?咱們先來看一下閉包裏都有什麼內容:
動態庫的依賴是樹狀的結構,初始化的調用順序是先調用樹的葉子結點,而後一層層向上,最早調用的是 libSystem,由於他是全部依賴的源頭。
爲何閉包能提升啓動速度呢?
由於這些信息是每次啓動都須要的,把信息存儲到一個緩存文件就能避免每次都解析,尤爲是 Objective C 的運行時數據(Class/Method...)解析很是慢。
有了閉包以後,就能夠用閉包啓動 App 了。這時候不少動態庫尚未加載進來,會首先對這些動態庫 mmap 加載到虛擬內存裏。接着會對每一個 Mach-O 作 fixup,包括 Rebase 和 Bind。
舉個例子:一個 Objective C 字符串@"1234",編譯到最後的二進制的時候是會存儲在兩個 section 裏的
__TEXT,__cstring
,存儲實際的字符串"1234"__DATA,__cfstring
,存儲 Objective C 字符串的元數據,每一個元數據佔用 32Byte,裏面有兩個指針:內部指針,指向__TEXT,__cstring
中字符串的位置;外部指針 isa,指向類對象的,這就是爲何能夠對 Objective C 的字符串字面量發消息的緣由。以下圖,編譯的時候,字符串 1234 在__cstring
的 0x10 處,因此 DATA 段的指針指向 0x10。可是 mmap 以後有一個偏移量 slide=0x1000,這時候字符串在運行時的地址就是 0x1010,那麼 DATA 段的指針指向就不對了。Rebase 的過程就是把指針從 0x10,加上 slide 變成 0x1010。運行時類對象的地址已經知道了,bind 就是把 isa 指向實際的內存地址。
Bind & Rebase 以後,首先會執行 LibSystem 的 Initializer,作一些最基本的初始化:
注意這裏沒有初始化 objc 的類方法等信息,是由於啓動閉包的緩存數據已經包含了 optimizeObjc。
接下來會進行 main 函數以前的一些初始化,主要包括+load 和 static initializer。這兩類初始化函數都有個特色:調用順序不肯定,和對應文件的連接順序有關係。那麼就會存在一個隱藏的坑:有些註冊邏輯在+load 裏,對應會有一些地方讀取這些註冊的數據,若是在+load 中讀取,頗有可能讀取的時候尚未註冊。
那麼,如何找到代碼裏有哪些 load 和 static initializer 呢?
在 Build Settings 裏能夠配置 write linkmap,這樣在生成的 linkmap 文件裏就能夠找到有哪些文件裏包含 load 或者 static initializer:
__mod_init_func
,static initializer__objc_nlclslist
,實現+load 的類__objc_nlcatlist
,實現+load 的 Category若是+load 方法裏的內容很簡單,會影響啓動時間麼?好比這樣的一個+load 方法?
+ (void)load
{
printf("1234");
}
複製代碼
編譯完了以後,這個函數會在二進制中的 TEXT 兩個段存在:__text
存函數二進制,cstring
存儲字符串 1234。爲了執行函數,首先要訪問__text
觸發一次 Page In 讀入物理內存,爲了打印字符串,要訪問__cstring
,還會觸發一次 Page In。
靜態初始化是從哪來的呢?如下幾種代碼會致使靜態初始化
__attribute__((constructor))
static class object
static object in global namespace
注意,並非全部的 static 變量都會產生靜態初始化,編譯器很智能,對於在編譯期間就能肯定的變量是會直接 inline。
//會產生靜態初始化
class Demo{
static const std::string var_1;
};
const std::string var_2 = "1234";
static Logger logger;
//不會產生靜態初始化
static const int var_3 = 4;
static const char * var_4 = "1234";
複製代碼
std::string 會合成 static initializer 是由於初始化的時候必須執行構造函數,這時候編譯器就不知道怎麼作了,只能延遲到運行時~
+load 和 static initializer 執行完畢以後,dyld 會把啓動流程交給 App,開始執行 main 函數。main 函數裏要作的最重要的事情就是初始化 UIKit。UIKit 主要會作兩個大的初始化:
因爲主線程的 dispatch_async 是基於 runloop 的,因此在+load 裏若是調用了 dispatch_async 會在這個階段執行。
線程在執行完代碼就會退出,很明顯主線程是不能退出的,那麼就須要一種機制:事件來的時候執行任務,不然讓線程休眠,Runloop 就是實現這個功能的。
Runloop 本質上是一個 While
循環,在圖中橙色部分的 mach_msg_trap
就是觸發一個系統調用,讓線程休眠,等待事件到來,喚醒 Runloop,繼續執行這個 while
循環。
Runloop 主要處理幾種任務:Source0,Source1,Timer,GCD MainQueue,Block。在循環的合適時機,會以 Observer 的方式通知外部執行到了哪裏。
那麼,Runloop 與啓動又有什麼關係呢?
Runloop 在啓動上主要有幾點應用:
Tips: 會有一些邏輯要在啓動以後 delay 一小段時間再回到主線程上執行,對於性能較差的設備,主線程 Runloop 可能一直處於忙的狀態,因此這個 delay 的任務並不必定能按時執行。
UIKit 初始化以後,就進入了咱們熟悉的 UIApplicationDelegate 回調了,在這些會調裏去作一些業務上的初始化:
willFinishLaunch
didFinishLaunch
didFinishLaunchNotification
要特別提一下 didFinishLaunchNotification
,是由於你們在埋點的時候一般會忽略還有這個通知的存在,致使把這部分時間算到 UI 渲染裏。
通常會用 Root Controller 的 viewDidApper 做爲渲染的終點,但其實這時候首幀已經渲染完成一小段時間了,Apple 在 MetricsKit 裏對啓動終點定義是第一個CA::Transaction::commit()
。
什麼是 CATransaction 呢?咱們先來看一下渲染的大體流程
iOS 的渲染是在一個單獨的進程 RenderServer 作的,App 會把 Render Tree 編碼打包給 RenderServer,RenderServer 再調用渲染框架(Metal/OpenGL ES)來生成 bitmap,放到幀緩衝區裏,硬件根據時鐘信號讀取幀緩衝區內容,完成屏幕刷新。CATransaction 就是把一組 UI 上的修改,合併成一個事務,經過 commit 提交。
渲染能夠分爲四個步驟
[CALayer layoutSubLayers]
,這時候 UIViewController
的 viewDidLoad
和 LayoutSubViews
會調用,autolayout
也是在這一步生效[CALayer display]
,若是 View 實現了 drawRect
方法,會在這個階段調用詳細回顧下整個啓動過程,以及各個階段耗時的影響因素:
_dyld_start
UIApplication
,啓動 Main Runloopwill/didFinishLaunch
,這裏主要是業務代碼耗時viewDidLoad
和 Layoutsubviews
會在這裏調用,Autolayout
太多會影響這部分時間drawRect
會調用dyld2 和 dyld3 的主要區別就是沒有啓動閉包,就致使每次啓動都要:
本文回顧了 Mach-O,虛擬內存,mmap,Page In,Runloop 等基礎概念,接下來介紹了 IPA 的構建流程,以及兩個典型的利用編譯器來優化啓動的方案,最後詳細的講解了 dyld3 的啓動 pipeline。
之因此花這麼大篇幅講原理,是由於任何優化都同樣,只有深刻理解系統運做的原理,才能找到性能的瓶頸,下一篇咱們會介紹下如何利用這些原理解決實際問題。