iOS之性能优化·优化App的启动速度
拋磚引玉
- 啟動(dòng)是 App 給用戶的第一印象,啟動(dòng)越慢用戶流失的概率就越高,良好的啟動(dòng)速度是用戶體驗(yàn)不可缺少的一環(huán)。
- 蘋(píng)果是一家特別注重用戶體驗(yàn)的公司,過(guò)去幾年一直在優(yōu)化 App 的啟動(dòng)時(shí)間,特別是去年的 WWDC 2019 keynote [1] 上提到,在過(guò)去一年蘋(píng)果開(kāi)發(fā)團(tuán)隊(duì)對(duì)啟動(dòng)時(shí)間提升了 200%;
- 雖然說(shuō)是提升了 200%,但是有些問(wèn)題還是沒(méi)有說(shuō)清楚,比如:
- 為什么優(yōu)化了這么多時(shí)間?
- 作為開(kāi)發(fā)者的我們,我們還可以做哪些針對(duì)啟動(dòng)速度的優(yōu)化?
- 所以我們今天結(jié)合 WWDC2019 - 423 - Optimizing App Launch [2] 聊一下和啟動(dòng)相關(guān)的東西。
概念引入
一、Mach-O
- Mach-O 是 iOS 系統(tǒng)不同運(yùn)行時(shí)期可執(zhí)行文件的文件類型統(tǒng)稱。主要分以下三類:
- Executable :可執(zhí)行文件,是 App 中的主要二進(jìn)制文件;
- Dylib :動(dòng)態(tài)庫(kù),在其他平臺(tái)也叫 DSO 或者 DLL;
- Bundle :蘋(píng)果平臺(tái)特有的類型,是無(wú)法被連接的 Dylib。只能在運(yùn)行時(shí)通過(guò) dlopen() 加載。
- Mach-O 的基本結(jié)構(gòu)如下圖所示,分為三個(gè)部分:
- 結(jié)構(gòu)分析:
- Header: 包含了 Mach-O 文件的基本信息,如 CPU 架構(gòu),文件類型,加載指令數(shù)量等;
- Load Commands: 是跟在 Header 后面的加載命令區(qū),包含文件的組織架構(gòu)和在虛擬內(nèi)存中的布局方式,在調(diào)用的時(shí)候知道如何設(shè)置和加載二進(jìn)制數(shù)據(jù);
- Data:包含 Load Commands 中需要的各個(gè) Segment 的數(shù)據(jù);
- 絕大多數(shù) Mach-O 文件包括以下三種 Segment:
- __TEXT :代碼段,包括頭文件、代碼和常量,只讀不可修改。
- __DATA :數(shù)據(jù)段,包括全局變量, 靜態(tài)變量等,可讀可寫(xiě)。
- __LINKEDIT :如何加載程序, 包含了方法和變量的元數(shù)據(jù)(位置,偏移量),以及代碼簽名等信息,只讀不可修改。
二、Image
- 指的是 Executable,Dylib 或者 Bundle 的一種。
三、Framework
- 有很多東西都叫做 Framework,但在本文中,Framework 指的是一個(gè) dylib,它周圍有一個(gè)特殊的目錄結(jié)構(gòu)來(lái)保存該 dylib 所需的文件。
- 一般會(huì)用 Root Controller 的 viewDidApper 作為渲染的終點(diǎn),但其實(shí)這時(shí)候首幀已經(jīng)渲染完成一小段時(shí)間了,Apple 在 MetricsKit 里對(duì)啟動(dòng)終點(diǎn)定義是第一個(gè)CA::Transaction::commit()。
- 什么是 CATransaction 呢?我們先來(lái)看一下渲染的大致流程:
- iOS 的渲染是在一個(gè)單獨(dú)的進(jìn)程 RenderServer 做的,App 會(huì)把 Render Tree 編碼打包給 RenderServer,RenderServer 再調(diào)用渲染框架(Metal/OpenGL ES)來(lái)生成 bitmap,放到幀緩沖區(qū)里,硬件根據(jù)時(shí)鐘信號(hào)讀取幀緩沖區(qū)內(nèi)容,完成屏幕刷新。CATransaction 就是把一組 UI 上的修改,合并成一個(gè)事務(wù),通過(guò) commit 提交。
- 渲染可以分為四個(gè)步驟:
- Layout(布局),源頭是 Root Layer 調(diào)用[CALayer layoutSubLayers],這時(shí)候 UIViewController 的 viewDidLoad 和 LayoutSubViews 會(huì)調(diào)用,autolayout 也是在這一步生效;
- Display(繪制),源頭是 Root Layer 調(diào)用[CALayer display],如果 View 實(shí)現(xiàn)了 drawRect 方法,會(huì)在這個(gè)階段調(diào)用;
- Prepare(準(zhǔn)備),這個(gè)過(guò)程中會(huì)完成圖片的解碼;
- Commit(提交),打包 Render Tree 通過(guò) XPC 的方式發(fā)給 Render Server。
四、虛擬內(nèi)存(Virtual Memory)
- 虛擬內(nèi)存是建立在物理內(nèi)存和進(jìn)程之間的中間層。是一個(gè)連續(xù)的邏輯地址空間,而且邏輯地址可以沒(méi)有對(duì)應(yīng)的實(shí)際物理內(nèi)存地址,也可以讓多個(gè)邏輯地址對(duì)應(yīng)到一個(gè)物理內(nèi)存地址上。
- 內(nèi)存可以分為虛擬內(nèi)存和物理內(nèi)存,其中物理內(nèi)存是實(shí)際占用的內(nèi)存,虛擬內(nèi)存是在物理內(nèi)存之上建立的一層邏輯地址,保證內(nèi)存訪問(wèn)安全的同時(shí)為應(yīng)用提供了連續(xù)的地址空間。
- 物理內(nèi)存和虛擬內(nèi)存以頁(yè)為單位映射,但這個(gè)映射關(guān)系不是一一對(duì)應(yīng)的:一頁(yè)物理內(nèi)存可能對(duì)應(yīng)多頁(yè)虛擬內(nèi)存;一頁(yè)虛擬內(nèi)存也可能不占用物理內(nèi)存。
- iPhone 6s 開(kāi)始,物理內(nèi)存的 Page 大小是 16K,6 和之前的設(shè)備都是 4K,這是 iPhone 6 相比 6s 啟動(dòng)速度斷崖式下降的原因之一。
五、Page Fault
- 當(dāng)進(jìn)程訪問(wèn)一個(gè)沒(méi)有對(duì)應(yīng)物理地址的邏輯地址時(shí),會(huì)發(fā)生 Page Fault。
六、Lazy Reading
- 某個(gè)想要讀取的頁(yè)沒(méi)有在內(nèi)存中就會(huì)觸發(fā) Page Fault,系統(tǒng)通過(guò)調(diào)用 mmap() 函數(shù)讀取指定頁(yè),這個(gè)過(guò)程叫做 Lazy Reading。
七、COW(Copy-On-Write)
- 當(dāng)進(jìn)程需要對(duì)某一頁(yè)內(nèi)容進(jìn)行修改時(shí),內(nèi)核會(huì)把需要修改的部分先復(fù)制一份,然后再修改,并把邏輯地址重新映射到新的物理內(nèi)存去,這個(gè)過(guò)程叫做 Copy-On-Write。
八、Dirty Page & Clean Page
- Image 加載后,被修改過(guò)內(nèi)容的 Page 叫做 Dirty Page,會(huì)包含著進(jìn)程特定的信息。
- 與之相對(duì)的叫 Clean Page,可以從磁盤(pán)重新生成。
九、共享內(nèi)存(Share RAM)
- 當(dāng)多個(gè) Mach-O 都依賴同一個(gè) Dylib(eg. UIKit)時(shí),系統(tǒng)會(huì)讓這幾個(gè) Mach-O 的調(diào)用 Dylib 的邏輯地址都指向同一塊物理內(nèi)存區(qū)域,從而實(shí)現(xiàn)內(nèi)存共享。
- Dirty Page 為進(jìn)程獨(dú)有,不能被共享。
十、地址空間布局隨機(jī)化(ASLR)
- 當(dāng) Image 加載到邏輯地址空間的時(shí)候,系統(tǒng)會(huì)利用 ASLR 技術(shù),使得 Image 的起始地址總是隨機(jī)的,以避免黑客通過(guò)起始地址+偏移量找到函數(shù)的地址。
- 當(dāng)系統(tǒng)利用 ASLR 分配了隨機(jī)地址后,從 0 到該地址的整個(gè)區(qū)間會(huì)被標(biāo)記為不可訪問(wèn),意味著不可讀,不可寫(xiě),不可被執(zhí)行。這個(gè)區(qū)域就是 __PAGEZERO 段,它的大小在 32 位系統(tǒng)是 4KB+,而在 64 位系統(tǒng)是 4GB+
十一、代碼簽名(Code Sign)
- 代碼簽名可以讓 iOS 系統(tǒng)確保要被加載的 Image 的安全性,用 Code Sign 設(shè)置簽名時(shí),每頁(yè)內(nèi)容都會(huì)生成一個(gè)單獨(dú)的加密散列值,并存儲(chǔ)到 __LINKEDIT 中去,系統(tǒng)在加載時(shí)會(huì)校驗(yàn)每頁(yè)內(nèi)容確保沒(méi)有被篡改。
十二、dyld(dynamic loader)
- dyld 是 iOS 上的二進(jìn)制加載器,用于加載 Image。有不少人認(rèn)為 dyld 只負(fù)責(zé)加載應(yīng)用依賴的所有動(dòng)態(tài)鏈接庫(kù),這個(gè)理解是錯(cuò)誤的。dyld 工作的具體流程如下:
- dyld 啟動(dòng)請(qǐng)參考:dyld啟動(dòng)流程 [3];
- dyld 啟動(dòng)也可以參考我之前的博客:iOS之深入解析App啟動(dòng)dyld加載流程的底層原理。
十三、Load dylibs
- dyld 在加載 Mach-O 之前會(huì)先解析 Header 和 Load Commands, 然后就知道了這個(gè) Mach-O 所依賴的 dylibs,以此類推,通過(guò)遞歸的方式把全部需要的 dylib 都加載進(jìn)來(lái)。
- 一般來(lái)說(shuō),一個(gè) App 所依賴的 dylib 在 100 - 400 左右,其中大多數(shù)都是系統(tǒng)的 dylib,因?yàn)橛芯彺婧凸蚕淼木壒?#xff0c;讀取速度比較高。
十四、Fix-ups
- 因?yàn)?ASLR 和 Code Sign 的原因,剛被加載進(jìn)來(lái)的 dylib 都處于相對(duì)獨(dú)立的狀態(tài),為了把它們綁定起來(lái),需要經(jīng)過(guò)一個(gè) Fix-ups 過(guò)程。
- Fix-ups 主要有兩種類型:Rebase 和 Bind。
十五、PIC(Position Independent Code)
- 因?yàn)榇a簽名的原因,dyld 無(wú)法直接修改指令,但是為了實(shí)現(xiàn)在運(yùn)行時(shí)可以 Fix-ups,在 code gen 時(shí),通過(guò)動(dòng)態(tài) PIC(Position Independent Code)技術(shù),使本來(lái)因?yàn)榇a簽名限制不能再修改的代碼,可以被加載到間接地址上。
- 當(dāng)要調(diào)用一個(gè)方法時(shí),會(huì)先在 __DATA 段中建立一個(gè)指針指向這個(gè)方法,再通過(guò)這個(gè)指針實(shí)現(xiàn)間接調(diào)用。
十六、Rebase
- Rebase:修復(fù)內(nèi)部指針。這是因?yàn)?Mach-O 在 mmap 到虛擬內(nèi)存的時(shí)候,起始地址會(huì)有一個(gè)隨機(jī)的偏移量 slide,需要把內(nèi)部的指針指向加上這個(gè) slide。
- Rebase 是針對(duì)“因?yàn)?ASLR 導(dǎo)致 Mach-O 在加載到內(nèi)存中是一個(gè)隨機(jī)的首地址”這一個(gè)問(wèn)題做一個(gè)數(shù)據(jù)修正的過(guò)程。會(huì)將內(nèi)部指針地址都加上一個(gè)偏移量,偏移量的計(jì)算方法如下:
- 所有需要 Rebase 的指針信息已經(jīng)被編碼到 __LINKEDIT 里。然后就是不斷重復(fù)地對(duì) __DATA 中需要 Rebase 的指針加上這個(gè)偏移量。這個(gè)過(guò)程中可能會(huì)不斷發(fā)生 Page Fault 和 COW,從而導(dǎo)致 I/0 的性能損耗問(wèn)題,不過(guò)因?yàn)?Rebase 處理的是連續(xù)地址,所以內(nèi)核會(huì)預(yù)先讀取數(shù)據(jù),減少 I/O 的消耗。
十七、Binding
- Binding:修復(fù)外部指針。這個(gè)比較好理解,因?yàn)橄?printf 等外部函數(shù),只有運(yùn)行時(shí)才知道它的地址是什么,Binding 就是把指針指向這個(gè)地址。
- Binding 就是對(duì)調(diào)用的外部符號(hào)進(jìn)行綁定的過(guò)程。比如我們要使用到 UITableView,即符號(hào) OBJC_CLASS$_UITableView,但這個(gè)符號(hào)又不在 Mach-O 中,需要從 UIKit.framework 中獲取,因此需要通過(guò) Binding 把這個(gè)對(duì)應(yīng)關(guān)系綁定到一起。
- 在運(yùn)行時(shí),dyld 需要找到符號(hào)名對(duì)應(yīng)的實(shí)現(xiàn)。而這需要很多計(jì)算,包括去符號(hào)表里找。找到后就會(huì)將對(duì)應(yīng)的值記錄到 __DATA 的那個(gè)指針里。Binding 的計(jì)算量雖然比 Rebasing 更多,但實(shí)際需要的 I/O 操作很少,因?yàn)橹?Rebasing 已經(jīng)做過(guò)了。
- 舉個(gè)例子:一個(gè) Objective C 字符串@“1234”,編譯到最后的二進(jìn)制的時(shí)候是會(huì)存儲(chǔ)在兩個(gè) section 里的:
- __TEXT,__cstring,存儲(chǔ)實(shí)際的字符串"1234"
- __DATA,__cfstring,存儲(chǔ) Objective C 字符串的元數(shù)據(jù),每個(gè)元數(shù)據(jù)占用 32Byte,里面有兩個(gè)指針:內(nèi)部指針,指向__TEXT,__cstring中字符串的位置;外部指針 isa,指向類對(duì)象的,這就是為什么可以對(duì) Objective C 的字符串字面量發(fā)消息的原因。
- 如下圖,編譯的時(shí)候,字符串 1234 在__cstring的 0x10 處,所以 DATA 段的指針指向 0x10。但是 mmap 之后有一個(gè)偏移量 slide=0x1000,這時(shí)候字符串在運(yùn)行時(shí)的地址就是 0x1010,那么 DATA 段的指針指向就不對(duì)了。Rebase 的過(guò)程就是把指針從 0x10,加上 slide 變成 0x1010。運(yùn)行時(shí)類對(duì)象的地址已經(jīng)知道了,bind 就是把 isa 指向?qū)嶋H的內(nèi)存地址。
十八、dyld2 & dyld3
- 在 iOS 13 之前,所有的第三方 App 都是通過(guò) dyld 2 來(lái)啟動(dòng) App 的,主要過(guò)程如下:
- 解析 Mach-O 的 Header 和 Load Commands,找到其依賴的庫(kù),并遞歸找到所有依賴的庫(kù)
- 加載 Mach-O 文件
- 進(jìn)行符號(hào)查找
- 綁定和變基
- 運(yùn)行初始化程序
- 上面的所有過(guò)程都發(fā)生在 App 啟動(dòng)時(shí),包含了大量的計(jì)算和I/O,所以蘋(píng)果開(kāi)發(fā)團(tuán)隊(duì)為了加快啟動(dòng)速度,在 WWDC2017 - 413 - App Startup Time: Past, Present, and Future [4] 上正式提出了 dyld3。
- dyld2 & dyld3 比較如下:
- dyld3 被分為了三個(gè)組件:
-
一個(gè)進(jìn)程外的 MachO 解析器
- 預(yù)先處理了所有可能影響啟動(dòng)速度的 search path、@rpaths 和環(huán)境變量
- 然后分析 Mach-O 的 Header 和依賴,并完成了所有符號(hào)查找的工作
- 最后將這些結(jié)果創(chuàng)建成了一個(gè)啟動(dòng)閉包
- 這是一個(gè)普通的 daemon 進(jìn)程,可以使用通常的測(cè)試架構(gòu)
-
一個(gè)進(jìn)程內(nèi)的引擎,用來(lái)運(yùn)行啟動(dòng)閉包
- 這部分在進(jìn)程中處理
- 驗(yàn)證啟動(dòng)閉包的安全性,然后映射到 dylib 之中,再跳轉(zhuǎn)到 main 函數(shù)
- 不需要解析 Mach-O 的 Header 和依賴,也不需要符號(hào)查找。
-
一個(gè)啟動(dòng)閉包緩存服務(wù)
- 系統(tǒng) App 的啟動(dòng)閉包被構(gòu)建在一個(gè) Shared Cache 中, 我們甚至不需要打開(kāi)一個(gè)單獨(dú)的文件
- 對(duì)于第三方的 App,我們會(huì)在 App 安裝或者升級(jí)的時(shí)候構(gòu)建這個(gè)啟動(dòng)閉包。
- 在 iOS、tvOS、watchOS中,這這一切都是 App 啟動(dòng)之前完成的。在 macOS 上,由于有 Side Load App,進(jìn)程內(nèi)引擎會(huì)在首次啟動(dòng)的時(shí)候啟動(dòng)一個(gè) daemon 進(jìn)程,之后就可以使用啟動(dòng)閉包啟動(dòng)了。
-
- dyld 3 把很多耗時(shí)的查找、計(jì)算和 I/O 的事前都預(yù)先處理好了,這使得啟動(dòng)速度有了很大的提升。
十九、mmap
- mmap 的全稱是 memory map,是一種內(nèi)存映射技術(shù),可以把文件映射到虛擬內(nèi)存的地址空間里,這樣就可以像直接操作內(nèi)存那樣來(lái)讀寫(xiě)文件。當(dāng)讀取虛擬內(nèi)存,其對(duì)應(yīng)的文件內(nèi)容在物理內(nèi)存中不存在的時(shí)候,會(huì)觸發(fā)一個(gè)事件:File Backed Page In,把對(duì)應(yīng)的文件內(nèi)容讀入物理內(nèi)存。
- 啟動(dòng)的時(shí)候,Mach-O 就是通過(guò) mmap 映射到虛擬內(nèi)存里的(如下圖)。下圖中部分頁(yè)被標(biāo)記為 zero fill,是因?yàn)槿肿兞康某跏贾低际?0,那么這些 0 就沒(méi)必要存儲(chǔ)在二進(jìn)制里,增加文件大小。操作系統(tǒng)會(huì)識(shí)別出這些頁(yè),在 Page In 之后對(duì)其置為 0,這個(gè)行為叫做 zero fill。
二十、Page In
- 啟動(dòng)的路徑上會(huì)觸發(fā)很多次 Page In,其實(shí)也比較容易理解,因?yàn)閱?dòng)的會(huì)讀寫(xiě)二進(jìn)制中的很多內(nèi)容。Page In 會(huì)占去啟動(dòng)耗時(shí)的很大一部分,我們來(lái)看看單個(gè) Page In 的過(guò)程:
- 分析如下:
- MMU 找到空閑的物理內(nèi)存頁(yè)面;
- 觸發(fā)磁盤(pán) IO,把數(shù)據(jù)讀入物理內(nèi)存;
- 如果是 TEXT 段的頁(yè),要進(jìn)行解密;
- 對(duì)解密后的頁(yè),進(jìn)行簽名驗(yàn)證;
- 其中解密是大頭,IO 其次。為什么要解密呢?因?yàn)?iTunes Connect 會(huì)對(duì)上傳 Mach-O 的 TEXT 段進(jìn)行加密,防止 IPA 下載下來(lái)就直接可以看到代碼。這也就是為什么逆向里會(huì)有個(gè)概念叫做“砸殼”,砸的就是這一層 TEXT 段加密。iOS 13 對(duì)這個(gè)過(guò)程進(jìn)行了優(yōu)化,Page In 的時(shí)候不需要解密了。
二十一、二進(jìn)制重排
- 既然 Page In 耗時(shí),有沒(méi)有什么辦法優(yōu)化呢?
- 啟動(dòng)具有局部性特征,即只有少部分函數(shù)在啟動(dòng)的時(shí)候用到,這些函數(shù)在二進(jìn)制中的分布是零散的,所以 Page In 讀入的數(shù)據(jù)利用率并不高。如果我們可以把啟動(dòng)用到的函數(shù)排列到二進(jìn)制的連續(xù)區(qū)間,那么就可以減少 Page In 的次數(shù),從而優(yōu)化啟動(dòng)時(shí)間:
- 以下圖為例,方法 1 和方法 3 是啟動(dòng)的時(shí)候用到的,為了執(zhí)行對(duì)應(yīng)的代碼,就需要兩次 Page In。假如我們把方法 1 和 3 排列到一起,那么只需要一次 Page In,從而提升啟動(dòng)速度。
- 鏈接器 ld 有個(gè)參數(shù)-order_file 支持按照符號(hào)的方式排列二進(jìn)制。獲取啟動(dòng)時(shí)候用到的符號(hào)的有很多種方式,這里不做說(shuō)明。
IPA 構(gòu)建
- 既然要構(gòu)建,那么必然會(huì)有一些地方去定義如何構(gòu)建,對(duì)應(yīng) Xcode 中的兩個(gè)配置項(xiàng):
- Build Phase:以 Target 為維度定義了構(gòu)建的流程。可以在 Build Phase 中插入腳本,來(lái)做一些定制化的構(gòu)建,比如 CocoaPod 的拷貝資源就是通過(guò)腳本的方式完成的。
- Build Settings:配置編譯和鏈接相關(guān)的參數(shù)。特別要提到的是 other link flags 和 other c flags,因?yàn)榫幾g和鏈接的參數(shù)非常多,有些需要手動(dòng)在這里配置。很多項(xiàng)目用的 CocoaPod 做的組件化,這時(shí)候編譯選項(xiàng)在對(duì)應(yīng)的.xcconfig 文件里。
- 以單 Target 為例,來(lái)看下構(gòu)建流程:
- 流程說(shuō)明:
- 源文件(.m/.c/.swift 等)是單獨(dú)編譯的,輸出對(duì)應(yīng)的目標(biāo)文件(.o)
- 目標(biāo)文件和靜態(tài)庫(kù)/動(dòng)態(tài)庫(kù)一起,鏈接出最后的 Mach-O
- Mach-O 會(huì)被裁剪,去掉一些不必要的信息
- 資源文件如 storyboard,asset 也會(huì)編譯,編譯后加載速度會(huì)變快
- Mach-O 和資源文件一起,打包出最后的.app
- 對(duì).app 簽名,防篡改
編譯
- 編譯器可以分為兩大部分:前端和后端,二者以 IR(中間代碼)作為媒介。這樣前后端分離,使得前后端可以獨(dú)立的變化,互不影響。C 語(yǔ)言家族的前端是 clang,swift 的前端是 swiftc,二者的后端都是 llvm。
- 前端負(fù)責(zé)預(yù)處理,詞法語(yǔ)法分析,生成 IR;
- 后端基于 IR 做優(yōu)化,生成機(jī)器碼;
- 那么如何利用編譯優(yōu)化啟動(dòng)速度呢?
代碼數(shù)量會(huì)影響啟動(dòng)速度,為了提升啟動(dòng)速度,我們可以把一些無(wú)用代碼下掉。那怎么統(tǒng)計(jì)哪些代碼沒(méi)有用到呢?可以利用 LLVM 插樁來(lái)實(shí)現(xiàn)。LLVM 的代碼優(yōu)化流程是一個(gè)一個(gè) Pass,由于 LLVM 是開(kāi)源的,我們可以添加一個(gè)自定義的 Pass,在函數(shù)的頭部插入一些代碼,這些代碼會(huì)記錄這個(gè)函數(shù)被調(diào)用了,然后把統(tǒng)計(jì)到的數(shù)據(jù)上傳分析,就可以知道哪些代碼是用不到的了 。 - Facebook 給 LLVM 提的 order_file[2]的 feature 就是實(shí)現(xiàn)了類似的插樁。
鏈接
- 經(jīng)過(guò)編譯后,我們有很多個(gè)目標(biāo)文件,接著這些目標(biāo)文件會(huì)和靜態(tài)庫(kù),動(dòng)態(tài)庫(kù)一起,鏈接出一個(gè) Mach-O。鏈接的過(guò)程并不產(chǎn)生新的代碼,只會(huì)做一些移動(dòng)和補(bǔ)丁。
- tbd 的全稱是 text-based stub library,是因?yàn)殒溄拥倪^(guò)程中只需要符號(hào)就可以了,所以 Xcode 6 開(kāi)始,像 UIKit 等系統(tǒng)庫(kù)就不提供完整的 Mach-O,而是提供一個(gè)只包含符號(hào)等信息的 tbd 文件。
- 最開(kāi)始講解 Page In 的時(shí)候,我們提到 TEXT 段的頁(yè)解密很耗時(shí),有沒(méi)有辦法優(yōu)化呢?可以通過(guò) ld 的-rename_section,把 TEXT 段中的內(nèi)容,比如字符串移動(dòng)到其他的段(啟動(dòng)路徑上難免會(huì)讀很多字符串),從而規(guī)避這個(gè)解密的耗時(shí)。
App 啟動(dòng)
一、啟動(dòng)定義
- 啟動(dòng)有兩種定義:
- 廣義:點(diǎn)擊圖標(biāo)到首頁(yè)數(shù)據(jù)加載完畢;
- 狹義:點(diǎn)擊圖標(biāo)到 Launch Image 完全消失第一幀;
- 不同產(chǎn)品的業(yè)務(wù)形態(tài)不一樣,對(duì)于抖音來(lái)說(shuō),首頁(yè)的數(shù)據(jù)加載完成就是視頻的第一幀播放;對(duì)其他首頁(yè)是靜態(tài)的 App 來(lái)說(shuō),Launch Image 消失就是首頁(yè)數(shù)據(jù)加載完成。由于標(biāo)準(zhǔn)很難對(duì)齊,所以我們一般使用狹義的啟動(dòng)定義:即啟動(dòng)終點(diǎn)為啟動(dòng)圖完全消失的第一幀。
- 啟動(dòng)最佳時(shí)間是 400ms 以內(nèi),因?yàn)閱?dòng)動(dòng)畫(huà)時(shí)長(zhǎng)是 400ms。
- 這是從用戶感知維度定義啟動(dòng),那么代碼上如何定義啟動(dòng)呢?Apple 在 MetricKit 中給出了官方計(jì)算方式:
- 起點(diǎn):進(jìn)程創(chuàng)建的時(shí)間;
- 終點(diǎn):第一個(gè)CA::Transaction::commit();
- CATransaction 是 Core Animation 提供的一種事務(wù)機(jī)制,把一組 UI 上的修改打包,一起發(fā)給 Render Server 渲染。
二、App 啟動(dòng)為什么這么重要?
- App 啟動(dòng)是和用戶的第一個(gè)交互過(guò)程,所以要盡量縮短這個(gè)過(guò)程的時(shí)間,給用戶一個(gè)良好的第一印象;
- 啟動(dòng)代表了你的代碼的整體性能,如果啟動(dòng)的性能不好,其他部分的性能可能也不會(huì)太好
啟動(dòng)會(huì)占用 CPU 和內(nèi)存,從而影響系統(tǒng)性能和電池; - 所以我們要好好優(yōu)化啟動(dòng)時(shí)間。
三、啟動(dòng)類型
App 的啟動(dòng)類型分為三類
- Cold Launch 也就是冷啟動(dòng),即為系統(tǒng)里沒(méi)有任何進(jìn)程的緩存信息,典型的是重啟手機(jī)后直接啟動(dòng) App。冷啟動(dòng)需要滿足以下幾個(gè)條件:
- 重啟之后
- App 不在內(nèi)存中
- 沒(méi)有相關(guān)的進(jìn)程存在
- Warm Launch 也就是熱啟動(dòng),即為如果把 App 進(jìn)程殺了,然后立刻重新啟動(dòng),這次啟動(dòng)就是熱啟動(dòng),因?yàn)檫M(jìn)程緩存還在。熱啟動(dòng)需要滿足以下幾個(gè)條件:
- App 剛被終止
- App 還沒(méi)完全從內(nèi)存中移除
- 沒(méi)有相關(guān)的進(jìn)程存在
- Resume Launch 指的是被掛起的 App 繼續(xù)的過(guò)程,大多數(shù)時(shí)候不會(huì)被定義為啟動(dòng),因?yàn)榇藭r(shí) App 仍然活著,只不過(guò)處于 suspended 狀態(tài)。需要滿足以下幾個(gè)條件:
- App 被掛起
- App 還全部都在內(nèi)存中
- 還存在相關(guān)的進(jìn)程
四、App 啟動(dòng)階段
- App 啟動(dòng)分為三個(gè)階段:
- 初始化 App 的準(zhǔn)備工作;
- 繪制第一幀 App 的準(zhǔn)備工作及繪制(這里的第一幀并不是獲取到數(shù)據(jù)之后的第一幀,可以是一張占位視圖),這時(shí)候用戶與App已經(jīng)可以交互了,比如 tabbar 切換;
- 獲取到頁(yè)面的所有數(shù)據(jù)之后的完整的繪制第一幀頁(yè)面。
- 在這個(gè)地方,蘋(píng)果再次強(qiáng)調(diào)了一下,建議「用戶從點(diǎn)擊 App 圖標(biāo)到可以再次交互,也就是第二階段結(jié)束」的時(shí)間最好在 400ms 以內(nèi)。目前來(lái)看,大部分 App 都沒(méi)有達(dá)到這個(gè)目標(biāo)。
- 下面,我們把上面三個(gè)階段分成下面這 6 個(gè)部分,講一下這幾個(gè)階段做了什么以及有什么可以優(yōu)化的地方。
五、啟動(dòng)優(yōu)化
① System Interface
- 初始化 App 的準(zhǔn)備工作,系統(tǒng)主要做了兩個(gè)事情:Load dylibs 和 libSystem init;
- 在 2017 年蘋(píng)果介紹過(guò) dyld3 給系統(tǒng) App 帶來(lái)了多少優(yōu)化,今年 dyld3 正式開(kāi)發(fā)給開(kāi)發(fā)者使用,這意味著 iOS 系統(tǒng)會(huì)將熱啟動(dòng)的運(yùn)行時(shí)依賴給緩存起來(lái),以達(dá)到減少啟動(dòng)時(shí)間的目的,這也就是提升 200% 的原因之一。
- 除此之外,在 Load dylibs 階段,開(kāi)發(fā)者還可以做以下優(yōu)化:
- 避免鏈接無(wú)用的 frameworks,在 Xcode 中檢查一下項(xiàng)目中的「Linked Frameworks and Librares」部分是否有無(wú)用的鏈接。
- 避免在啟動(dòng)時(shí)加載動(dòng)態(tài)庫(kù),將項(xiàng)目的 Pods 以靜態(tài)編譯的方式打包,尤其是 Swift 項(xiàng)目,這地方的時(shí)間損耗是很大的。
- 硬鏈接你的依賴項(xiàng),這里做了緩存優(yōu)化。
- 也許有人會(huì)困惑是不是使用了 dyld3 了,我們就不需要做 Static Link 了,其實(shí)還是需要的,感興趣的可以看一下 Static linking vs dyld3 [5] 這篇文章,里面有一個(gè)詳細(xì)的數(shù)據(jù)對(duì)比。
- libSystem init 部分,主要是加載一些優(yōu)先級(jí)比較低的系統(tǒng)組件,這部分時(shí)間是一個(gè)固定的成本,所以我們開(kāi)發(fā)人員不需要關(guān)心。
② Static Runtime Initializaiton
- 這個(gè)階段主要是 Objective-C 和 Swift Runtime 的初始化,會(huì)調(diào)用所有的 +load 方法,將類的信息注冊(cè)到 runtime 中。
- 在這個(gè)階段,原則上不建議開(kāi)發(fā)者做任何事情,所以為了避免一些啟動(dòng)時(shí)間的損耗,你可以做以下幾個(gè)事情:
- 在 framework 開(kāi)發(fā)時(shí),公開(kāi)專有的初始化 API;
- 減少在 +load 中做的事情;
- 使用 initialize 進(jìn)行懶加載初始化工作;
③ UIKit Initializaiton
- 這個(gè)階段主要做了兩個(gè)事情:
- 實(shí)例化 UIApplication 和 UIApplicationDelegate;
- 開(kāi)始事件處理和系統(tǒng)集成。
- 所以這個(gè)階段的優(yōu)化也比較簡(jiǎn)單,需要做兩個(gè)事情:
- 最大限度的減少 UIApplication 子類初始化時(shí)候的工作,更甚至與不子類化 UIApplication;
- 減少 UIApplicationDelegate 的初始化工作。
④ Application Initializaiton
- 這個(gè)階段主要是生命周期方法的回調(diào),也正是我們開(kāi)發(fā)者最熟悉的部分。
- 調(diào)用 UIApplicationDelegate 的 App 生命周期方法:
- UIApplicationDelegate 的 UI 生命周期方法:
- 同時(shí),iOS 13 針對(duì) UISceneDelegate 增加了新的回調(diào):
- 也會(huì)在這個(gè)階段調(diào)用。感興趣的可以關(guān)注一下 Getting the Most out of Multitasking 這個(gè) Session,暫時(shí)沒(méi)有視頻資源,懷疑是現(xiàn)場(chǎng)演示翻車了,所以沒(méi)有把視頻資源放出來(lái)。
- 在這個(gè)階段,我們可以做的優(yōu)化:
- 推遲和啟動(dòng)時(shí)無(wú)關(guān)的工作
- Senens 之間共享資源
⑤ Fisrt Frame Render
- 這個(gè)階段主要做了創(chuàng)建、布局和繪制視圖的工作,并把準(zhǔn)備好的第一幀提交給渲染層渲染。會(huì)頻繁調(diào)用以下幾個(gè)函數(shù):
- 在這個(gè)階段,開(kāi)發(fā)者可以做的優(yōu)化:
- 減少視圖層級(jí),懶加載一些不需要的視圖;
- 優(yōu)化布局,減少約束。
- 更多細(xì)節(jié)可以從 WWDC2018 - 220 - High Performance Auto Layout [6] 中了解。
⑥ Extend
- 大部分 App 都會(huì)通過(guò)異步的方式獲取數(shù)據(jù),并最終呈現(xiàn)給用戶。我們把這一部分稱為 Extend。
- 因?yàn)檫@一部分每個(gè) App 的表現(xiàn)都不一樣,所以蘋(píng)果建議開(kāi)發(fā)者使用 os_signpost 進(jìn)行測(cè)量然后慢慢分析慢慢優(yōu)化。
⑦ load 舉例
- 如果+load 方法里的內(nèi)容很簡(jiǎn)單,會(huì)影響啟動(dòng)時(shí)間么?比如這樣的一個(gè)+load 方法?
- 編譯完了之后,這個(gè)函數(shù)會(huì)在二進(jìn)制中的 TEXT 兩個(gè)段存在:__text存函數(shù)二進(jìn)制,cstring存儲(chǔ)字符串 1234。為了執(zhí)行函數(shù),首先要訪問(wèn)__text觸發(fā)一次 Page In 讀入物理內(nèi)存,為了打印字符串,要訪問(wèn)__cstring,還會(huì)觸發(fā)一次 Page In。
- 為了執(zhí)行這個(gè)簡(jiǎn)單的函數(shù),系統(tǒng)要額外付出兩次 Page In 的代價(jià),所以 load 函數(shù)多了,page in 會(huì)成為啟動(dòng)性能的瓶頸。
- static initializer 產(chǎn)生的條件:靜態(tài)初始化是從哪來(lái)的呢?以下幾種代碼會(huì)導(dǎo)致靜態(tài)初始化
- attribute((constructor))
- static class object
- static object in global namespace
- 注意,并不是所有的 static 變量都會(huì)產(chǎn)生靜態(tài)初始化,編譯器很智能,對(duì)于在編譯期間就能確定的變量是會(huì)直接 inline。
- std::string 會(huì)合成 static initializer 是因?yàn)槌跏蓟臅r(shí)候必須執(zhí)行構(gòu)造函數(shù),這時(shí)候編譯器就不知道怎么做了,只能延遲到運(yùn)行時(shí)。
- +load 和 static initializer 執(zhí)行完畢之后,dyld 會(huì)把啟動(dòng)流程交給 App,開(kāi)始執(zhí)行 main 函數(shù)。main 函數(shù)里要做的最重要的事情就是初始化 UIKit。UIKit 主要會(huì)做兩個(gè)大的初始化:
- 初始化 UIApplication;
- 啟動(dòng)主線程的 Runloop;
- 由于主線程的 dispatch_async 是基于 runloop 的,所以在+load 里如果調(diào)用了 dispatch_async 會(huì)在這個(gè)階段執(zhí)行。
- 線程在執(zhí)行完代碼就會(huì)退出,很明顯主線程是不能退出的,那么就需要一種機(jī)制:事件來(lái)的時(shí)候執(zhí)行任務(wù),否則讓線程休眠,Runloop 就是實(shí)現(xiàn)這個(gè)功能的。
- Runloop 本質(zhì)上是一個(gè)While 循環(huán),在圖中橙色部分的 mach_msg_trap 就是觸發(fā)一個(gè)系統(tǒng)調(diào)用,讓線程休眠,等待事件到來(lái),喚醒 Runloop,繼續(xù)執(zhí)行這個(gè) while循環(huán)。
- Runloop 主要處理幾種任務(wù):Source0,Source1,Timer,GCD MainQueue,Block。在循環(huán)的合適時(shí)機(jī),會(huì)以 Observer 的方式通知外部執(zhí)行到了哪里。
- 那么,Runloop 與啟動(dòng)又有什么關(guān)系呢?
- App 的 LifeCycle 方法是基于 Runloop 的 Source0 的;
- 首幀渲染是基于 Runloop Block 的。
- Runloop 在啟動(dòng)上主要有幾點(diǎn)應(yīng)用:
- 精準(zhǔn)統(tǒng)計(jì)啟動(dòng)時(shí)間;
- 找到一個(gè)時(shí)機(jī),在啟動(dòng)結(jié)束去執(zhí)行一些預(yù)熱任務(wù);
- 利用 Runloop 打散耗時(shí)的啟動(dòng)預(yù)熱任務(wù)。
測(cè)量 App 啟動(dòng)時(shí)間
- 要找到啟動(dòng)過(guò)程中的問(wèn)題,就要進(jìn)行多次測(cè)量并前后比較。但是如果變量沒(méi)有控制好,就會(huì)導(dǎo)致誤差。
- 所以為了保證測(cè)量的數(shù)據(jù)能夠真實(shí)的反應(yīng)問(wèn)題,我們要減少不穩(wěn)定性因素,保證在可控的相近的環(huán)境下進(jìn)行測(cè)量,最后使用一致的結(jié)果來(lái)分析。
- ① 條件一致性
- 為了保證環(huán)境一致,我們可以做下面這幾個(gè)事情:
- 重啟手機(jī),并等待 2-3 分鐘
- 啟用飛行模式或者使用模擬網(wǎng)絡(luò)
- 不使用或者不變更 iCloud 的賬戶
- 使用 release 模式進(jìn)行 build
- 測(cè)量熱啟動(dòng)時(shí)間
- iColud 賬戶切換會(huì)影響性能,所以不要切換賬號(hào)或者不開(kāi)啟 iCloud。
- 為了保證環(huán)境一致,我們可以做下面這幾個(gè)事情:
- ② 測(cè)量注意點(diǎn)
- 盡可能的使用具有代表性的數(shù)據(jù)進(jìn)行測(cè)試;
- 如果不使用具有代表性的數(shù)據(jù)進(jìn)行測(cè)試,就會(huì)出現(xiàn)偏差;
- 使用不同的新舊設(shè)備進(jìn)行測(cè)試;
- 最后你還可以使用 XCTest 來(lái)測(cè)試,多運(yùn)行幾次,取平均結(jié)果。
- ③ 關(guān)于使用 XCTest 測(cè)試啟動(dòng)時(shí)間的信息,可以看一下 WWDC2019 - 417 - Improving Battery Life and Performance [7],但是我測(cè)試了一下,目前好像還有一部分 API 還沒(méi)有開(kāi)放出來(lái),暫時(shí)還不能使用。
使用 Instruments 分析和優(yōu)化 App 啟動(dòng)過(guò)程
一、Minimize Work
- 推遲與第一幀無(wú)關(guān)的工作
- 從主線程移開(kāi)阻塞工作
- 減少內(nèi)存使用量
二、Prioritize Work
- 定義好任務(wù)的優(yōu)先級(jí)
- 利用好 GCD 來(lái)優(yōu)化你的啟動(dòng)速度
- 讓重要的事情保持優(yōu)先
三、Optimize Work
- 簡(jiǎn)化現(xiàn)有工作,比如只請(qǐng)求必要的數(shù)據(jù)
- 優(yōu)化算法和數(shù)據(jù)結(jié)構(gòu)
- 緩存資源和計(jì)算
四、使用 Instruments 分析 App 啟動(dòng)過(guò)程
- 當(dāng)知道如何優(yōu)化之后,我們需要針對(duì)我們的啟動(dòng)過(guò)程進(jìn)行分析。Xcode 11 的 Instruments 為此新增了一個(gè) App launch 模板,讓開(kāi)發(fā)者可以更好的分析自己 App 的啟動(dòng)速度。
- 運(yùn)行后可以看到各個(gè)階段的具體時(shí)間,根據(jù)數(shù)據(jù)進(jìn)行優(yōu)化,還能看到耗時(shí)的函數(shù)調(diào)用。
系統(tǒng)優(yōu)化
- 蘋(píng)果做了很多優(yōu)化,下面這幾個(gè)高亮的是和啟動(dòng)速度有關(guān)的優(yōu)化:
- 但是不知道是不是時(shí)間原因,在 session 中對(duì)于這部分的解釋特別少,很難理解 200% 到底做了什么。
- 但是 Craig Federighi 在 The Talk Show Live From WWDC 2019, With Craig Federighi and Greg Joswiak[9] 中針對(duì)為什么優(yōu)化了 200% 說(shuō)了這樣一段話:
Isn’t that crazy that was quite a discovery for us. No it turns out that over times as in terms of the way the apps were encrypted and the way fair play worked and so forth. The encryption became part of the critical path actually of launching the apps. I mean the processors are capable or up and through the thing that actually it was a problem. And then there are other optimizations that based on what was visible to system at certain things. And so it actually cut out optimization opportunities and so when we really identified that opportunity we said okay. We can actually come up with better format that’s gonna eliminate that being on the critical path, It’s going to enable all these pre-binding things. And then we did a whole bunch of other work to optimize the objective-c runtime to optimize the linker the dynamic linker a bunch of other things and you put it all together. And yeah that I mean a cold launch this is we’ve never had a win like this to launch time in a single release.
- 從這段話中,除了 dyld3 的功勞之外,減少對(duì)代碼簽名加密也是優(yōu)化之一。
監(jiān)控線上用戶 App 的啟動(dòng)
- Xcode 11 在 Xcode Organizer 新增了一個(gè)監(jiān)控面板,在這個(gè)面板里面可以查看多個(gè)維度的用戶數(shù)據(jù),其中還包括平均啟動(dòng)時(shí)間。
- 當(dāng)你通過(guò) Instruments 分析完你的啟動(dòng)過(guò)程,并做了大量?jī)?yōu)化之后,你就可以通過(guò) Xcode Organizer 來(lái)分析你這次優(yōu)化效果到底怎么樣。
- 當(dāng)然你可以通過(guò)去年新出的 MetricKit [10] 獲取一些自定義的數(shù)據(jù),具體參照 WWDC2019 - 417 -Improving Battery Life and Performance [11]。
參考資料
- [1] WWDC 2019 keynote
- [2] WWDC2019 - 423 - Optimizing App Launch
- [3] dyld啟動(dòng)流程
- [4] WWDC2017 - 413 - App Startup Time: Past, Present, and Future
- [5] Static linking vs dyld3
- [6] WWDC2018 - 220 - High Performance Auto Layout
- [7] WWDC2019 - 417 - Improving Battery Life and Performance
- [8] WWDC2017 - 706 - Modernizing Grand Central Dispatch Usage
- [9] The Talk Show Live From WWDC 2019, With Craig Federighi and Greg Joswiak
- [10] MetricKit
- [11] WWDC2019 - 417 -Improving Battery Life and Performance
總結(jié)
以上是生活随笔為你收集整理的iOS之性能优化·优化App的启动速度的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: iOS之深入解析dyld与ObjC关联的
- 下一篇: iOS之LLVM编译流程和Clang插件