iOS 如何優化 App 的啟動時間

来源:https://www.cnblogs.com/jukaiit/archive/2017/12/29/8110401.html
-Advertisement-
Play Games

App 運行理論 main() 執行前發生的事 Mach-O 格式 虛擬記憶體基礎 Mach-O 二進位的載入 main() 執行前發生的事 Mach-O 格式 虛擬記憶體基礎 Mach-O 二進位的載入 理論速成 Mach-O 術語 Mach-O 是針對不同運行時可執行文件的文件類型。 文件類型: E ...


App 運行理論


  • main() 執行前發生的事

  • Mach-O 格式

  • 虛擬記憶體基礎

  • Mach-O 二進位的載入

理論速成

Mach-O 術語

Mach-O 是針對不同運行時可執行文件的文件類型。

文件類型:

  • Executable: 應用的主要二進位

  • Dylib: 動態鏈接庫(又稱 DSO 或 DLL)

  • Bundle: 不能被鏈接的 Dylib,只能在運行時使用 dlopen() 載入,可當做 macOS 的插件。

Image: executable,dylib 或 bundle
Framework: 包含 Dylib 以及資源文件和頭文件的文件夾

Mach-O 鏡像文件

Mach-O 被劃分成一些 segement,每個 segement 又被劃分成一些 section。

segment 的名字都是大寫的,且空間大小為頁的整數。頁的大小跟硬體有關,在 arm64 架構一頁是 16KB,其餘為 4KB。

section 雖然沒有整數倍頁大小的限制,但是 section 之間不會有重疊。

幾乎所有 Mach-O 都包含這三個段(segment): __TEXT,__DATA 和 __LINKEDIT:

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

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

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

Mach-O Universal 文件

FAT 二進位文件,將多種架構的 Mach-O 文件合併而成。它通過 Fat Header 來記錄不同架構在文件中的偏移量,Fat Header 占一頁的空間。

按分頁來存儲這些 segement 和 header 會浪費空間,但這有利於虛擬記憶體的實現。

虛擬記憶體

虛擬記憶體就是一層間接定址(indirection)。軟體工程中有句格言就是任何問題都能通過添加一個間接層來解決。虛擬記憶體解決的是管理所有進程使用物理 RAM 的問題。通過添加間接層來讓每個進程使用邏輯地址空間,它可以映射到 RAM 上的某個物理頁上。這種映射不是一對一的,邏輯地址可能映射不到 RAM 上,也可能有多個邏輯地址映射到同一個物理 RAM 上。針對第一種情況,當進程要存儲邏輯地址內容時會觸發 page fault;第二種情況就是多進程共用記憶體。

對於文件可以不用一次性讀入整個文件,可以使用分頁映射(mmap())的方式讀取。也就是把文件某個片段映射到進程邏輯記憶體的某個頁上。當某個想要讀取的頁沒有在記憶體中,就會觸發 page fault,內核只會讀入那一頁,實現文件的懶載入。

也就是說 Mach-O 文件中的 __TEXT 段可以映射到多個進程,並可以懶載入,且進程之間共用記憶體。__DATA 段是可讀寫的。這裡使用到了 Copy-On-Write 技術,簡稱 COW。也就是多個進程共用一頁記憶體空間時,一旦有進程要做寫操作,它會先將這頁記憶體內容複製一份出來,然後重新映射邏輯地址到新的 RAM 頁上。也就是這個進程自己擁有了那頁記憶體的拷貝。這就涉及到了 clean/dirty page 的概念。dirty page 含有進程自己的信息,而 clean page 可以被內核重新生成(重新讀磁碟)。所以 dirty page 的代價大於 clean page。

Mach-O 鏡像 載入

所以在多個進程載入 Mach-O 鏡像時 __TEXT 和 __LINKEDIT 因為只讀,都是可以共用記憶體的。而 __DATA 因為可讀寫,就會產生 dirty page。當 dyld 執行結束後,__LINKEDIT 就沒用了,對應的記憶體頁會被回收。

安全

ASLR(Address Space Layout Randomization):地址空間佈局隨機化,鏡像會在隨機的地址上載入。這其實是一二十年前的舊技術了。

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

從 exec() 到 main()

exec() 是一個系統調用。系統內核把應用映射到新的地址空間,且每次起始位置都是隨機的(因為使用 ASLR)。並將起始位置到0x000000 這段範圍的進程許可權都標記為不可讀寫不可執行。如果是 32 位進程,這個範圍至少是 4KB;對於 64 位進程則至少是 4GB。NULL 指針引用和指針截斷誤差都是會被它捕獲。

dyld 載入 dylib 文件

Unix 的前二十年很安逸,因為那時還沒有發明動態鏈接庫。有了動態鏈接庫後,一個用於載入鏈接庫的幫助程式被創建。在蘋果的平臺里是 dyld,其他 Unix 系統也有 ld.so。 當內核完成映射進程的工作後會將名字為 dyld 的Mach-O 文件映射到進程中的隨機地址,它將 PC 寄存器設為 dyld 的地址並運行。dyld 在應用進程中運行的工作是載入應用依賴的所有動態鏈接庫,準備好運行所需的一切,它擁有的許可權跟應用一樣。

下麵的步驟構成了 dyld 的時間線:

Load dylibs -> Rebase -> Bind -> ObjC -> Initializers

載入 Dylib

從主執行文件的 header 獲取到需要載入的所依賴動態庫列表,而 header 早就被內核映射過。然後它需要找到每個 dylib,然後打開文件讀取文件起始位置,確保它是 Mach-O 文件。接著會找到代碼簽名並將其註冊到內核。然後在 dylib 文件的每個 segment 上調用mmap()。應用所依賴的 dylib 文件可能會再依賴其他 dylib,所以 dyld 所需要載入的是動態庫列表一個遞歸依賴的集合。一般應用會載入 100 到 400 個 dylib 文件,但大部分都是系統 dylib,它們會被預先計算和緩存起來,載入速度很快。

Fix-ups

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

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

所以 dyld 做的事情就是修正(fix-up)指針和數據。Fix-up 有兩種類型,rebasing 和 binding。

Rebasing 和 Binding

Rebasing:在鏡像內部調整指針的指向
Binding:將指針指向鏡像外部的內容

可以通過命令行查看 rebase 和 bind 等信息:

1 xcrun dyldinfo -rebase -bind -lazy_bind myapp.app/myapp

通過這個命令可以查看所有的 Fix-up。rebase,bind,weak_bind,lazy_bind 都存儲在 __LINKEDIT 段中,並可通過LC_DYLD_INFO_ONLY 查看各種信息的偏移量和大小。

建議用 MachOView 查看更加方便直觀。

從 dyld 源碼層面簡要介紹下 Rebasing 和 Binding 的流程。

ImageLoader 是一個用於載入可執行文件的基類,它負責鏈接鏡像,但不關心具體文件格式,因為這些都交給子類去實現。每個可執行文件都會對應一個 ImageLoader 實例。ImageLoaderMachO 是用於載入 Mach-O 格式文件的 ImageLoader 子類,而ImageLoaderMachOClassic 和 ImageLoaderMachOCompressed 都繼承於 ImageLoaderMachO,分別用於載入那些 __LINKEDIT 段為傳統格式和壓縮格式的 Mach-O 文件。

因為 dylib 之間有依賴關係,所以 ImageLoader 中的好多操作都是沿著依賴鏈遞歸操作的,Rebasing 和 Binding 也不例外,分別對應著 recursiveRebase() 和 recursiveBind() 這兩個方法。因為是遞歸,所以會自底向上地分別調用 doRebase() 和 doBind()方法,這樣被依賴的 dylib 總是先於依賴它的 dylib 執行 Rebasing 和 Binding。傳入 doRebase() 和 doBind() 的參數包含一個LinkContext 上下文,存儲了可執行文件的一堆狀態和相關的函數。

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

ImageLoaderMachO 實例不使用預綁定會有五個原因:

  1. Mach-O Header 中 MH_PREBOUND 標誌位為 0

  2. 鏡像載入地址有偏移(這個後面會講到)

  3. 依賴的庫有變化

  4. 鏡像使用 flat-namespace,預綁定的一部分會被忽略

  5. LinkContext 的環境變數禁止了預綁定

ImageLoaderMachO 中 doRebase() 做的事情大致如下:

  1. 如果使用預綁定,fgImagesWithUsedPrebinding 計數加一,並 return;否則進入第二步

  2. 如果 MH_PREBOUND 標誌位為 1(也就是可以預綁定但沒使用),且鏡像在共用記憶體中,重置上下文中所有的 lazy pointer。(如果鏡像在共用記憶體中,稍後會在 Binding 過程中綁定,所以無需重置)

  3. 如果鏡像載入地址偏移量為0,則無需 Rebasing,直接 return;否則進入第四步

  4. 調用 rebase() 方法,這才是真正做 Rebasing 工作的方法。如果開啟 TEXT_RELOC_SUPPORT 巨集,會允許 rebase() 方法對__TEXT 段做寫操作來對其進行 Fix-up。所以其實 __TEXT 只讀屬性並不是絕對的。

ImageLoaderMachOClassic 和 ImageLoaderMachOCompressed 分別實現了自己的 doRebase() 方法。實現邏輯大同小異,同樣會判斷是否使用預綁定,併在真正的 Binding 工作時判斷 TEXT_RELOC_SUPPORT 巨集來決定是否對 __TEXT 段做寫操作。最後都會調用setupLazyPointerHandler 在鏡像中設置 dyld 的 entry point,放在最後調用是為了讓主可執行文件設置好 __dyld 或__program_vars。

Rebasing

在過去,會把 dylib 載入到指定地址,所有指針和數據對於代碼來說都是對的,dyld 就無需做任何 fix-up 了。如今用了 ASLR 後悔將 dylib 載入到新的隨機地址(actual_address),這個隨機的地址跟代碼和數據指向的舊地址(preferred_address)會有偏差,dyld 需要修正這個偏差(slide),做法就是將 dylib 內部的指針地址都加上這個偏移量,偏移量的計算方法如下:

Slide = actual_address - preferred_address

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

Binding

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

ObjC Runtime

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

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

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

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

ObjC 中的 selector 必須是唯一的。

Initializers

C++ 會為靜態創建的對象生成初始化器。而在 ObjC 中有個叫 +load 的方法,然而它被廢棄了,現在建議使用 +initialize。對比詳見:http://stackoverflow.com/questions/13326435/nsobject-load-and-initialize-what-do-they-do

現在有了主執行文件,一堆 dylib,其依賴關係構成了一張巨大的有向圖,那麼執行初始化器的順序是什麼?自頂向上!按照依賴關係,先載入葉子節點,然後逐步向上載入中間節點,直至最後載入根節點。這種載入順序確保了安全性,載入某個 dylib 前,其所依賴的其餘 dylib 文件肯定已經被預先載入。

最後 dyld 會調用 main() 函數。main() 會調用 UIApplicationMain()。

改善啟動時間


從點擊 App 圖標到載入 App 閃屏之間會有個動畫,我們希望 App 啟動速度比這個動畫更快。雖然不同設備上 App 啟動速度不一樣,但啟動時間最好控制在 400ms。需要註意的是啟動時間一旦超過 20s,系統會認為發生了死迴圈並殺掉 App 進程。當然啟動時間最好以 App 所支持的最低配置設備為準。直到 applicationWillFinishLaunching 被調動,App 才啟動結束。

測量啟動時間

Warm launch: App 和數據已經在記憶體中
Cold launch: App 不在內核緩衝存儲器中

冷啟動(Cold launch)耗時才是我們需要測量的重要數據,為了準確測量冷啟動耗時,測量前需要重啟設備。在 main() 方法執行前測量是很難的,好在 dyld 提供了內建的測量方法:在 Xcode 中 Edit scheme -> Run -> Auguments 將環境變數DYLD_PRINT_STATISTICS 設為 1。控制台輸出的內容如下:

1 2 3 4 5 6 7 8 Total pre-main time: 228.41 milliseconds (100.0%)          dylib loading time:  82.35 milliseconds (36.0%)         rebase/binding time:   6.12 milliseconds (2.6%)             ObjC setup time:   7.82 milliseconds (3.4%)            initializer time: 132.02 milliseconds (57.8%)            slowest intializers :              libSystem.B.dylib : 122.07 milliseconds (53.4%)                 CoreFoundation :   5.59 milliseconds (2.4%)

優化啟動時間

可以針對 App 啟動前的每個步驟進行相應的優化工作。

載入 Dylib

之前提到過載入系統的 dylib 很快,因為有優化。但載入內嵌(embedded)的 dylib 文件很占時間,所以儘可能把多個內嵌 dylib 合併成一個來載入,或者使用 static archive。使用 dlopen() 來在運行時懶載入是不建議的,這麼做可能會帶來一些問題,並且總的開銷更大。

Rebase/Binding

之前提過 Rebaing 消耗了大量時間在 I/O 上,而在之後的 Binding 就不怎麼需要 I/O 了,而是將時間耗費在計算上。所以這兩個步驟的耗時是混在一起的。

之前說過可以從查看 __DATA 段中需要修正(fix-up)的指針,所以減少指針數量才會減少這部分工作的耗時。對於 ObjC 來說就是減少 Class,selector 和 category 這些元數據的數量。從編碼原則和設計模式之類的理論都會鼓勵大家多寫精緻短小的類和方法,並將每部分方法獨立出一個類別,其實這會增加啟動時間。對於 C++ 來說需要減少虛方法,因為虛方法會創建 vtable,這也會在__DATA 段中創建結構。雖然 C++ 虛方法對啟動耗時的增加要比 ObjC 元數據要少,但依然不可忽視。最後推薦使用 Swift 結構體,它需要 fix-up 的內容較少。

ObjC Setup

針對這步所能事情很少,幾乎都靠 Rebasing 和 Binding 步驟中減少所需 fix-up 內容。因為前面的工作也會使得這步耗時減少。

Initializer

顯式初始化

  • 使用 +initialize 來替代 +load

  • 不要使用 __atribute__((constructor)) 將方法顯式標記為初始化器,而是讓初始化方法調用時才執行。比如使用dispatch_once(),pthread_once() 或 std::once()。也就是在第一次使用時才初始化,推遲了一部分工作耗時。

隱式初始化

對於帶有複雜(non-trivial)構造器的 C++ 靜態變數:

  1. 在調用的地方使用初始化器。

  2. 只用簡單值類型賦值(POD:Plain Old Data),這樣靜態鏈接器會預先計算 __DATA 中的數據,無需再進行 fix-up 工作。

  3. 使用編譯器 warning 標誌 -Wglobal-constructors 來發現隱式初始化代碼。

  4. 使用 Swift 重寫代碼,因為 Swift 已經預先處理好了,強力推薦。

不要在初始化方法中調用 dlopen(),對性能有影響。因為 dyld 在 App 開始前運行,由於此時是單線程運行所以系統會取消加鎖,但 dlopen() 開啟了多線程,系統不得不加鎖,這就嚴重影響了性能,還可能會造成死鎖以及產生未知的後果。所以也不要在初始化器中創建線程。

Reference:https://developer.apple.com/videos/play/wwdc2016/406/

 


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • Where exists 2之前按照個人理解講了基本的select 用法。當然 exists 並不僅僅只能更在select之後。比如update 也可以使用 where exists 繼續之前的講解,我從網上看到說。Where exists 和 In 效率不一樣,就來做個試驗對比一下如何不同。首先創 ...
  • 最近新接觸Mysql,昨天新建一個表用於存儲表結構信息: 然後查詢tablist表: 看看有哪些列沒有comment於是: select * from tablist where COLUMN_COMMENT is null; 查到的結果居然是Empty set。不過從以上查詢結果和navicat都 ...
  • CocoaPods安裝使用詳解 2017.12 首先,很有必要瞭解一下CocoaPods、Ruby和RubyGems,以及它們之間的關係。 CocoaPods是第三方庫的輔助管理工具,依賴於Ruby。 Ruby是一種簡捷的面向對象腳本語言。 RubyGems相當於Ruby的一個管理工具。 以下幾個官 ...
  • 在工作中有時候需要把activity當成dialog使用,其實做法挺簡單的。 1、設置activity的style 2、把該style應用給該activity 要註意一點,如果style的parent是:@android:style/Theme.Dialog,如上面所示 那麼該DialogActiv ...
  • 一:ndk環境搭建 1:開發環境 我使用的是android studio 2.3.3版本,搭建ndk開發環境比較簡單,打開File Settings Appearance&Behavior System Settings Android SDK,選擇SDK Tools,將CMake,LLDB,NDK ...
  • 當下最流行的網路請求組合,retrofit2+okhttp+rxjava+mvp 這裡是封裝記錄篇 首先分模塊,比如登錄 先來說封裝後的使用 package com.fragmentapp.login.presenter; import android.util.Log; import com.fr ...
  • 一個App的穩定性,主要決定於整體的系統架構設計,同時也不可忽略編程的細節,正所謂“千里之堤,潰於蟻穴”,一旦考慮不周,看似無關緊要的代碼片段可能會帶來整體軟體系統的崩潰。尤其因為蘋果限制了熱更新機制,App本身的穩定性及容錯性就顯的更加重要,之前可以通過發佈熱補丁的方式解決線上代碼問題,現在就需要 ...
  • 在之前的iPhone中、我們可以根據導航欄上方的網路狀態view、來判斷網路狀態。(這種方案本來就不太好) 並且,這種方案在iPhone X 手機上、不可使用。 那麼,在iPhone X 或者之前的手機上面該怎麼辦呢? 我們可以通過 Reachability 來判斷網路狀態 Reachability ...
一周排行
    -Advertisement-
    Play Games
  • 前言 React在很早之前的版本中加了useId,用於生成唯一ID。在Vue3.5版本中,終於也有了期待已久的useId。這篇文章來帶你搞清楚useId有哪些應用場景,以及他是如何實現的。 關註公眾號:【前端歐陽】,給自己一個進階vue的機會 useId的作用 他的作用也是生成唯一ID,同一個Vue ...
  • title: 使用 Nuxt Kit 的構建器 API 來擴展配置 date: 2024/9/24 updated: 2024/9/24 author: cmdragon excerpt: 摘要:本文詳細介紹瞭如何使用 Nuxt Kit 的構建器 API 來擴展和定製 Nuxt 3 項目的 webp ...
  • 混淆指定js文件 fomartJs.bat @echo off REM 定義一個包含文件名的數組 set jsFiles=("polyfills.b4665eab.js" "manifest.b09f6bad.js" "index.f8bec5fb.js") REM 遍曆數組中的每個文件 for % ...
  • title: Nuxt Kit 實用工具的使用示例 date: 2024/9/25 updated: 2024/9/25 author: cmdragon excerpt: 摘要:本文介紹了Nuxt Kit工具在開發集成工具或插件時,如何訪問和修改Nuxt應用中使用的Vite或webpack配置,以 ...
  • 一、流水管線 實現邏輯: 1)先自定義幾個點,通過CatmullRomCurve3生成一條平滑曲線 2)根據生成的曲線在XY面擴展一個面,其中需要註意頂點索引、UV坐標添加的順序,否則可能會導致繪製的圖片混亂,不是完整的圖片 3)添加紋理同時設置偏移量實現流動效果 4)為了保證顯示的箭頭圖標不失真, ...
  • title: 深入理解 Nuxt 中的 app created 鉤子 date: 2024/9/26 updated: 2024/9/26 author: cmdragon excerpt: 摘要:本文深入介紹了 Nuxt.js 中的 app:created 鉤子,包括其觸發時機、用途及使用方法。通 ...
  • title: 深入理解 Nuxt.js 中的 app:error 鉤子 date: 2024/9/27 updated: 2024/9/27 author: cmdragon excerpt: 摘要:本文深入講解了Nuxt.js框架中的app:error鉤子,介紹其在處理web應用中致命錯誤的重要作 ...
  • 我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。 本文作者:修能 What's? 數棧產品里的 Descriptions 組件實際上就是 antd 的 Descriptions 組件,那麼 antd 的 Descrip ...
  • title: 深入理解 Nuxt.js 中的 app:error:cleared 鉤子 date: 2024/9/28 updated: 2024/9/28 author: cmdragon excerpt: Nuxt.js 中的 app:error:cleared 鉤子的用途及其實現方式。這個鉤子 ...
  • 原創tauri2.0+vue3+pinai2仿QQ/微信客戶端聊天Exe程式TauriWinChat。 tauri2-vue3-winchat自研vite5+tauri2.0+vue3 setup+element-plus跨平臺仿QQ|微信桌面端聊天軟體。全新封裝tauri2多開視窗管理、自定義圓角 ...