用戶從點(diǎn)擊圖標(biāo)到看到第一個(gè)可用界面,平均等待耐心只有三秒。一旦超時(shí),App 就會(huì)被殺掉或卸載。你的 iOS 應(yīng)用下載量再高,啟動(dòng)性能這一關(guān)過不去,獲客成本就全浪費(fèi)在啟動(dòng)流失上。

啟動(dòng)路徑拆解:哪些階段在消耗你的時(shí)間

iOS 應(yīng)用的啟動(dòng)是一個(gè)由內(nèi)核、動(dòng)態(tài)鏈接器(dyld)、runtime 協(xié)作完成的復(fù)雜過程,并不是“打開 Xcode 點(diǎn)運(yùn)行”那么簡單。理解每個(gè)階段的具體任務(wù),才有辦法定位瓶頸。

啟動(dòng)分為 預(yù)熱啟動(dòng)(Warm Launch)冷啟動(dòng)(Cold Launch)。預(yù)熱啟動(dòng)指應(yīng)用已經(jīng)駐留在內(nèi)存,只是切回前臺(tái);冷啟動(dòng)則需要從頭構(gòu)建進(jìn)程。我們需要重點(diǎn)處置的是冷啟動(dòng)路徑,它又分為 pre-main 和 post-main 兩段。

pre-main 階段主要發(fā)生在 main() 函數(shù)執(zhí)行之前,dyld 負(fù)責(zé):

  1. 加載所有依賴的動(dòng)態(tài)庫(dylib),遞歸解析它們的依賴關(guān)系。
  2. Rebase 和 Bind:糾正鏡像在內(nèi)存中的基地址偏移,綁定符號(hào)指針。
  3. Objective-C 運(yùn)行時(shí)初始化:注冊(cè)所有類、類別,加載分類方法,然后調(diào)用所有類的 +load 方法。
  4. 執(zhí)行 __attribute__((constructor)) 標(biāo)記的函數(shù),最后將控制權(quán)交給 main()。

這段階段你無法直接介入時(shí)間線,但可以通過減少動(dòng)態(tài)庫數(shù)量、合并 Objective-C 類、避免 +load 和 constructor 函數(shù),間接縮短耗時(shí)。

post-main 階段則從 main() 開始,直到第一個(gè)屏幕內(nèi)容全部渲染完成。這階段你擁有完全控制權(quán),但也是大部分開發(fā)者引入延遲的關(guān)鍵區(qū)間。典型耗時(shí)點(diǎn)包括:

  • willFinishLaunchingdidFinishLaunching 中的同步初始化邏輯;
  • 首屏 UI 構(gòu)建與 Auto Layout 計(jì)算;
  • 網(wǎng)絡(luò)或本地?cái)?shù)據(jù)預(yù)加載;
  • 第三方 SDK 注冊(cè)序列化。

下面提供一種最小介入的優(yōu)化路徑,讓冷啟動(dòng)時(shí)間收斂到 400ms 以內(nèi)。

動(dòng)態(tài)庫治理:少就是快

每一個(gè)嵌入式動(dòng)態(tài)庫都會(huì)引入一次從磁盤讀取并按需解析的開銷。dyld 的加載行為是串行且持鎖的,所以庫數(shù)量對(duì) pre-main 時(shí)間的影響近似線性。

評(píng)估你當(dāng)前項(xiàng)目的動(dòng)態(tài)庫數(shù)量,可以在 Xcode 構(gòu)建日志中找到 dyld 加載明細(xì)。在終端中針對(duì) .app 包運(yùn)行:

otool -L YourApp.app/YourApp

列出的路徑中,凡是位于 Frameworks 子目錄下的都是嵌入式動(dòng)態(tài)庫。統(tǒng)計(jì)數(shù)量后,可以定一個(gè)硬性目標(biāo):將第三方庫的嵌入數(shù)量控制在 6 個(gè)以內(nèi)。超過這個(gè)閾值,pre-main 耗時(shí)通常會(huì)超過 200ms(視設(shè)備而變)。

合并方案有兩類:

  • 靜態(tài)鏈接(Static Library):把第三方代碼直接編譯進(jìn)主可執(zhí)行文件,消除獨(dú)立庫的加載開銷。CocoaPods 的 use_frameworks! :linkage => :static 或 Swift Package Manager 的靜態(tài)模式都支持。但要注意靜態(tài)庫之間的符號(hào)沖突,以及某些 SDK 強(qiáng)制要求動(dòng)態(tài)庫(例如有資源 bundle 或需要多進(jìn)程共享內(nèi)存)。
  • 啟動(dòng)時(shí)按需加載:如果某個(gè)動(dòng)態(tài)庫確實(shí)不需要在啟動(dòng)階段訪問,可以讓它延后加載。不過 iOS 不支持真正的懶加載 dylib,變通方式是將其改為用 dlopen 在 post-main 中手動(dòng)打開。這引入了額外的代碼復(fù)雜度,只適用于體積較大且僅用于特定流程(如視頻處理、地圖)的庫。

用 Instruments 的 App Launch 模板立刻看到這些調(diào)整的效果。模板會(huì)單獨(dú)列出 dyld 階段耗時(shí),任何一次優(yōu)化后都必須跑一次數(shù)據(jù)對(duì)比,不要憑感覺判斷。

post-main 啟動(dòng)閉包優(yōu)化:精準(zhǔn)控制初始化時(shí)機(jī)

進(jìn)入 main() 之后,你最容易犯的錯(cuò)誤是將所有初始化邏輯堆疊在一個(gè) dispatch_once 塊或 AppDelegate 的回調(diào)里同步執(zhí)行。這會(huì)讓主線程呆坐在那里等待所有無關(guān)任務(wù)完成,才去繪制第一個(gè) view。

把你的 didFinishLaunching 中執(zhí)行的每一個(gè)任務(wù)拆開,根據(jù)“首屏是否需要這份數(shù)據(jù)”來分級(jí):

  • 等級(jí) A:渲染強(qiáng)制依賴。沒有這些數(shù)據(jù),UI 是空白或崩潰的。例如根控制器初始化、主題加載、必要的本地持久化框架設(shè)置。這些必須同步完成。
  • 等級(jí) B:盡快需要但可延遲。例如用戶令牌刷新、配置下發(fā)、A/B 實(shí)驗(yàn)數(shù)據(jù)拉取。這些任務(wù)不應(yīng)阻塞首幀渲染,可以在第一個(gè)界面呈現(xiàn)后立即執(zhí)行。
  • 等級(jí) C:空閑時(shí)間執(zhí)行。例如日志系統(tǒng)上傳、預(yù)采集緩存、不緊急的 SDK 啟動(dòng)。放在 viewDidAppear 之后或利用 NSDefaultRunLoopMode 的任務(wù)塊即可。

一個(gè)典型的錯(cuò)誤例子是:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // 等級(jí) C 任務(wù)卻阻塞了啟動(dòng)
    [LoggingSDK start]; 
    [ImageCache warmUp]; 
    // 等級(jí) A
    self.window.rootViewController = [MainViewController new];
    [self.window makeKeyAndVisible];
    return YES;
}

將其修正為延遲模式:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // 等級(jí) A:必須同步
    self.window.rootViewController = [MainViewController new];
    [self.window makeKeyAndVisible];

    // 等級(jí) C:移到下一個(gè)主線程 RunLoop 周期,不阻塞首幀
    dispatch_async(dispatch_get_main_queue(), ^{
        [LoggingSDK start];
        [ImageCache warmUp];
    });
    return YES;
}

如果你用 Swift,層級(jí)判定邏輯完全一致,只是將 dispatch_async 換成 DispatchQueue.main.async。這里要特別注意:main.async 會(huì)把任務(wù)送到主隊(duì)列末尾,但如果主線程上已有連續(xù)同步隊(duì)列積壓,它仍然可能在首幀前執(zhí)行。更嚴(yán)格的做法是結(jié)合 CFRunLoopObserver 監(jiān)聽 kCFRunLoopBeforeWaiting 狀態(tài),實(shí)現(xiàn)真正的“首幀后執(zhí)行”,不過多數(shù)項(xiàng)目用 async 已經(jīng)能將啟動(dòng)耗時(shí)降低 30%–50%。

測(cè)量閉環(huán)與回歸預(yù)防

沒有量化就沒有優(yōu)化資格。你需要兩個(gè)關(guān)鍵指標(biāo):

  • 冷啟動(dòng)首幀耗時(shí):從點(diǎn)擊圖標(biāo)到 CADisplayLink 第一次回調(diào)且 UI 樹完成布局的時(shí)間。
  • 系統(tǒng)報(bào)出啟動(dòng)時(shí)間:Xcode Organizer 中的“啟動(dòng)時(shí)間”指標(biāo),或者用 MetricKit 的 MXAppLaunchMetric 收集。

建議在開發(fā)階段加一個(gè)環(huán)境變量 DYLD_PRINT_STATISTICS 來打印 pre-main 細(xì)節(jié):

export DYLD_PRINT_STATISTICS=1

然后在 Xcode scheme 的 Run Arguments 中勾選此變量,每次冷啟動(dòng)控制臺(tái)會(huì)輸出類似:

total time: 340ms (100%)
image loading: 180ms (52%)
rebase/binding: 70ms (20%)
ObjC setup: 60ms (17%)
initializer: 30ms (8%)

這個(gè)數(shù)據(jù)直接指導(dǎo)你下一輪優(yōu)化方向:image loading 高就先治動(dòng)態(tài)庫;ObjC setup 高就去檢查 +load 方法和 category 數(shù)量。

最后,把冷啟動(dòng)耗時(shí)寫進(jìn) CI 的基準(zhǔn)線。每次合并請(qǐng)求在真機(jī)(建議用 iPhone 8 或同代舊設(shè)備作為最低基準(zhǔn))上運(yùn)行自動(dòng)化啟動(dòng)測(cè)試,如果新增耗時(shí)超過 50ms,阻止合并。這樣你的優(yōu)化成果才不會(huì)在三個(gè)月后被新的第三方庫默默吃掉。

立刻能做的三件事:一,用 otool -L 列出動(dòng)態(tài)庫數(shù)量并制定合并計(jì)劃;二,把 AppDelegate 中所有非 A 級(jí)任務(wù)劃掉,確認(rèn)它們不阻塞首幀;三,打開 DYLD_PRINT_STATISTICS,記錄你當(dāng)前版本的 pre-main 基線,開始動(dòng)手。

← 上一篇 做 APP 定制開發(fā),為什么你的預(yù)算總是失控 下一篇 → 你的 Android 項(xiàng)目正在失控:從混沌代碼到可維護(hù)架構(gòu)的實(shí)戰(zhàn)重構(gòu)