按下 ⌘ + R 後發生的事情

做者:bool周  原文連接:按下 ⌘ + R 後發生的事情html

做爲一名 coder,天天的工做不是解 bug,就是寫 bug。有些東西,瞭解了並不必定有利於寫 bug,可是有利於解 bug。前端

對於一個工程,當你按下 ⌘ + R 到主界面顯示出來,你可曾想過這一過程發生了哪些事情?這些原理性的東西,對咱們 coding 並無直接幫助,瞭解與否均可以 coding。可是一個 coder 的工做不僅是 coding,還有 debug。瞭解這些東西,對咱們排查一些問題頗有幫助。ios

按照階段劃分,這一過程大體能夠劃爲三個階段:編譯階段APP 啓動階段圖層渲染階段。下面針對這三個過程進行詳細描述。git

編譯階段

學過編譯原理的同窗都應該知道,編譯主要分爲四個過程:預處理、編譯、彙編、連接。下面大體也是按照這個路子來。iOS 編譯過程,使用的 clang 作前端,LLVM 做爲後端進行完成的。使用 clang 處理前幾個階段,LLVM 處理後面幾個階段。github

1.預處理

又稱爲預編譯,主要作一些文本替換工做。處理 # 開頭的指令,例如:面試

  • 宏定義的展開 (#define)
  • 頭文件展開 (#include,#import)
  • 處理條件編譯指令 (#if,#else,#endif)

例如咱們在代碼中定義了以下宏:objective-c

#define APP_VERSION "V1.0.0"

int main(int argc, char * argv[]) {
  char *version = APP_VERSION;
  printf("app version is %s",version);
}
複製代碼

使用 clang -E main.m 進行宏展開的預處理結果以下:sql

int main(int argc, char * argv[]) {
    char *version = "V1.0.0";
    printf("version is %s",version);
    return 0;
}
複製代碼

宏的使用有不少坑,儘可能用其餘方式代替。macos

2.詞法分析

完成預處理後,詞法分析器(也叫掃描器)會對 .m 中的源代碼進行從左到右掃描,按照語言的詞法規則識別各種單詞、關鍵字,並生成對應的單詞的屬性字。例以下面一段代碼:bootstrap

#define APP_VERSION "V1.0.0"

int main(int argc, char * argv[]) {
    char *version = APP_VERSION;
    printf("version is %s",version);
    return 0;
}
複製代碼

通過預處理階段,而後使用 clang 命令 clang -Xclang -dump-tokens main.m 進行掃描分析,導出結果以下:

int 'int'  [StartOfLine]  Loc=<main.m:14:1>
identifier 'main'  [LeadingSpace] Loc=<main.m:14:5>
l_paren '('   Loc=<main.m:14:9>
int 'int'   Loc=<main.m:14:10>
identifier 'argc'  [LeadingSpace] Loc=<main.m:14:14>
comma ','   Loc=<main.m:14:18>
char 'char'  [LeadingSpace] Loc=<main.m:14:20>
star '*'   [LeadingSpace] Loc=<main.m:14:25>
identifier 'argv'  [LeadingSpace] Loc=<main.m:14:27>
l_square '['    Loc=<main.m:14:31>
r_square ']'    Loc=<main.m:14:32>
r_paren ')'   Loc=<main.m:14:33>
l_brace '{'  [LeadingSpace] Loc=<main.m:14:35>
char 'char'  [StartOfLine] [LeadingSpace] Loc=<main.m:18:5>
star '*'   [LeadingSpace] Loc=<main.m:18:10>
identifier 'version'    Loc=<main.m:18:11>
equal '='  [LeadingSpace] Loc=<main.m:18:19>
string_literal '"V1.0.0"'  [LeadingSpace] Loc=<main.m:18:21 <Spelling=main.m:12:21>>
semi ';'    Loc=<main.m:18:32>
identifier 'printf'  [StartOfLine] [LeadingSpace] Loc=<main.m:19:5>
l_paren '('   Loc=<main.m:19:11>
string_literal '"version is %s"'    Loc=<main.m:19:12>
comma ','   Loc=<main.m:19:27>
identifier 'version'    Loc=<main.m:19:28>
r_paren ')'   Loc=<main.m:19:35>
semi ';'    Loc=<main.m:19:36>
return 'return'  [StartOfLine] [LeadingSpace] Loc=<main.m:20:5>
numeric_constant '0'   [LeadingSpace] Loc=<main.m:20:12>
semi ';'    Loc=<main.m:20:13>
r_brace '}'  [StartOfLine]  Loc=<main.m:21:1>
eof ''    Loc=<main.m:21:2>
複製代碼

從上面能夠看出每一個單詞或者字符,都標記出了具體列數和行數,這樣若是在編譯過程當中遇到什麼問題,clang 能夠快速定位錯誤在代碼中的位置。

3.語法分析

接下來是進行語法分析。經過這一階段,會將上一階段的導出的結果解析成一棵抽象語法樹(abstract syntax tree – AST)。假設咱們的源代碼以下,而且已經通過了預處理:

#define APP_VERSION "V1.0.0"

int main(int argc, char * argv[]) {
    char *version = APP_VERSION;
    printf("version is %s",version);
    return 0;
}
複製代碼

使用 clang 命令 clang -Xclang -ast-dump -fsyntax-only mian.m 處理事後,輸入的語法樹以下:

...
FunctionDecl 0x7ffe55884228 <main.m:14:1, line:21:1> line:14:5 main 'int (int, char **)'
  |-ParmVarDecl 0x7ffe55884028 <col:10, col:14> col:14 argc 'int'
  |-ParmVarDecl 0x7ffe55884110 <col:20, col:32> col:27 argv 'char **':'char **'
  `-CompoundStmt 0x7ffe55884568 <col:35, line:21:1>
    |-DeclStmt 0x7ffe55884390 <line:18:5, col:32>
    | `-VarDecl 0x7ffe558842e8 <col:5, line:12:21> line:18:11 used version 'char *' cinit
    |   `-ImplicitCastExpr 0x7ffe55884378 <line:12:21> 'char *' <ArrayToPointerDecay>
    |     `-StringLiteral 0x7ffe55884348 <col:21> 'char [7]' lvalue "V1.0.0"
    |-CallExpr 0x7ffe558844b0 <line:19:5, col:35> 'int'
    | |-ImplicitCastExpr 0x7ffe55884498 <col:5> 'int (*)(const char *, ...)' <FunctionToPointerDecay>
    | | `-DeclRefExpr 0x7ffe558843a8 <col:5> 'int (const char *, ...)' Function 0x7ffe55088570 'printf' 'int (const char *, ...)'
    | |-ImplicitCastExpr 0x7ffe55884500 <col:12> 'const char *' <BitCast>
    | | `-ImplicitCastExpr 0x7ffe558844e8 <col:12> 'char *' <ArrayToPointerDecay>
    | |   `-StringLiteral 0x7ffe55884408 <col:12> 'char [14]' lvalue "version is %s"
    | `-ImplicitCastExpr 0x7ffe55884518 <col:28> 'char *' <LValueToRValue>
    |   `-DeclRefExpr 0x7ffe55884440 <col:28> 'char *' lvalue Var 0x7ffe558842e8 'version' 'char *'
    `-ReturnStmt 0x7ffe55884550 <line:20:5, col:12>
      `-IntegerLiteral 0x7ffe55884530 <col:12> 'int' 0
複製代碼

抽象語法樹中每個節點也標記出了在源碼中的具體位置,便於問題定位。抽象語法樹的相關知識有不少,這裏就不詳細解釋了。

4.靜態分析

把源碼轉化爲抽象語法樹以後,編譯器就能夠對這個樹進行分析處理。靜態分析會對代碼進行錯誤檢查,如出現方法被調用可是未定義、定義可是未使用的變量等,以此提升代碼質量。固然,還能夠經過使用 Xcode 自帶的靜態分析工具(Product -> Analyze)或者一些第三方的靜態分析工具(例如 Facebook 的 infer進行深度分析。

有時候編譯器自帶的靜態分析,並不能知足咱們的平常開發需求。所以咱們能夠經過使用腳本定製一套分析方案,放到集成環境中。每次提交代碼時,會觸發腳本進行靜態分析,若是出現錯誤邊報出警告,而且提交代碼失敗。依次過高開發質量。

若是有興趣,能夠看一下 clang 靜態分析源碼,看其中對哪些語法作了靜態分析。

5.生成代碼和優化

使用 clang 完成預處理和分析以後,接着會生成 LLVM 代碼。仍是以前那段代碼:

#define APP_VERSION "V1.0.0"

int main(int argc, char * argv[]) {
    char *version = APP_VERSION;
    printf("version is %s",version);
    return 0;
}
複製代碼

咱們能夠用 clang 命令 clang -O3 -S -emit-llvm main.m -o main.ll 進行轉化,而後打開以後看到內容以下:

; ModuleID = 'main.m'
source_filename = "main.m"
target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-apple-macosx10.13.0"

@.str = private unnamed_addr constant [7 x i8] c"V1.0.0\00", align 1
@.str.1 = private unnamed_addr constant [14 x i8] c"version is %s\00", align 1

; Function Attrs: nounwind ssp uwtable

// main 方法
define i32 @main(i32, i8** nocapture readnone) local_unnamed_addr #0 {
  %3 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([14 x i8], [14 x i8]* @.str.1, i64 0, i64 0), i8* getelementptr inbounds ([7 x i8], [7 x i8]* @.str, i64 0, i64 0))
  ret i32 0
}

; Function Attrs: nounwind
declare i32 @printf(i8* nocapture readonly, ...) local_unnamed_addr #1

attributes #0 = { nounwind ssp uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+fxsr,+mmx,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #1 = { nounwind "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+fxsr,+mmx,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }

!llvm.module.flags = !{!0, !1, !2, !3, !4, !5}
!llvm.ident = !{!6}

!0 = !{i32 1, !"Objective-C Version", i32 2}
!1 = !{i32 1, !"Objective-C Image Info Version", i32 0}
!2 = !{i32 1, !"Objective-C Image Info Section", !"__DATA, __objc_imageinfo, regular, no_dead_strip"}
!3 = !{i32 4, !"Objective-C Garbage Collection", i32 0}
!4 = !{i32 1, !"Objective-C Class Properties", i32 64}
!5 = !{i32 1, !"PIC Level", i32 2}
!6 = !{!"Apple LLVM version 9.0.0 (clang-900.0.39.2)"}
複製代碼

能夠簡單看一下 main 方法,看不懂無所謂,我也看不懂。只是瞭解這個過程就能夠了。

接下來 LLVM 會對代碼進行編譯優化,例如針對全局變量優化、循環優化、尾遞歸優化等,這些我瞭解的不是太多,因此不能亂說。想要了解的同窗,能夠看一下這篇文章:《LLVM 全時優化》

最後就是輸出彙編代碼。

6.彙編

在這一階段,彙編器將可讀的彙編代碼轉化爲機器代碼。最終產物就是 以 .o 結尾的目標文件

針對下部分代碼:

#define APP_VERSION "V1.0.0"

int main(int argc, char * argv[]) {
    char *version = APP_VERSION;
    printf("version is %s",version);
    return 0;
}
複製代碼

咱們可使用 clang 命令 clang -c main.m 生成目標文件 mian.o。我就不寫打開後的內容了,都是二進制,也看不懂。

7.連接

這一階段是將上個階段生成的目標文件和引用的靜態庫連接起來,最終生成可執行文件。

咱們能夠用 clang 命令 clang main.m 生成可執行文件 a.out (不指定名字默認命名爲 a.out)。而後使用 file a.out 命令查看其類型:

a.out: Mach-O 64-bit executable x86_64
複製代碼

能夠看出可執行文件類型爲 Mach-O 類型,在 MAC OS 和 iOS 平臺的可執行文件都是這種類型。由於我使用的是模擬器,因此處理器指令集爲 x86_64

至此編譯階段完成。

8.Xcode 中一次完整的 build

最後咱們先來看一下 Xcode 中的 build 日誌,完整的看一遍這個過程。打開 Xcode 的 Log Navigator,選中 Build 這一項咱們能夠看到此次 build 的日誌:

build_log

日誌是按照 target 進行分段的。當前工程中,經過 Pod 引入了 YYCacheYYImageAFNetworking三個庫,除此以外還有一個 Pods-Test 和項目自己的 target。每一個 target 之間的日誌格式都是同樣的,所以咱們只針對一個 target 進行分析。這裏只針對項目自己 target,也就是 Test 進行分析。也就是下面這個樣子:

test_build_log

看着很亂套,整理完以後,屢一下大概是這個流程:

  1. 編譯信息寫入輔助文件,建立編譯後的文件架構 (test.app)。
  2. 處理打包信息。
  3. 執行 CocoaPods 編譯前腳本。例如這裏的 Check Pods Manifest.lock
  4. 編譯各類 .m 文件(.h 文件不參與編譯)。
  5. 連接所須要的 framework。
  6. 編譯 ImageAssets。
  7. 編譯 Storyboard 等相關文件。
  8. 處理 info.plist 文件。
  9. 連接 Storyboards。
  10. 執行 CocoaPods 相關腳本,能夠在 Build Phases 中查看這些腳本。
  11. 建立 .app 文件。
  12. 對 .app 文件進行簽名

這裏咱們針對第 4 步詳細說一下。咱們選取其中一個文件 ViewController.m 的日誌進行分析:

viewcontroller_build_log

將 log 信息整理一下:

1. CompileC /.../Test.build/Objects-normal/x86_64/ViewController.o Test/ViewController.m normal x86_64 objective-c com.apple.compilers.llvm.clang.1_0.compiler

2. cd /Users/zhoubo/Test
3. export LANG=en_US.US-ASCII
   export PATH="/Applications/Xcode.app/Contents/Developer/../sbin"
4. clang -x objective-c 
     -arch x86_64 -fmessage-length=0...
     -fobjc-arc...
     -Wno-missing-field-initializers...
     -DDEBUG=1...
     -isysroot .../iPhoneSimulator11.2.sdk
     -I ONE PATH
     -F ONE PATH
     -c /../ViewController.m
     -o /../ViewController.o
複製代碼

對應解釋以下:

  1. 經過 log 表述任務起點。
  2. 進入對應工做目錄。
  3. 對 LANG 和 PATH 環境變量執行設置。
  4. clang 命令開始:

    -x : 所使用語言,此處爲 Objective-C
    -arch x86_64 : 處理器指令集爲 x86_64
    -fobjc-arc : 一系列以 -f 開頭,指定此文件使用 ARC 環境。你能夠經過 Build Phases 設置對每一個文件是否支持 ARC。
    -Wno-missing-field-initializers : 一系列以 -w 開頭指令,編譯警告選項,能夠經過這個指令定製編譯選項
    -DDEBUG=1 : 一些以 -D 開頭的,指的是預編譯宏。
    -isysroot .../iPhoneSimulator11.2.sdk : 編譯時採用的 iOS SDK 版本。
    -I : 把編譯信息寫入文件
    -F : 連接過程當中所須要的 framework
    -c : 編譯文件
    -o : 編譯中間產物
    複製代碼

9.關於 dSYM 文件

每次咱們編譯事後,都會生成一個 dSYM 文件。這個文件中,存儲了 16 進制的函數地址映射表。在 APP 執行的二進制文件中,是經過地址來調用方法的。當發生了 crash,能夠經過 dSYM 文件進行地址映射,找到具體的函數調用棧。

App 啓動階段

上個階段,最終產物爲可執行文件,文件格式爲 Mach-o。這一階段,就以這個文件開始,詳細描述一下 APP 啓動過程。

1.過程概覽

這一過程分爲多個階段,簡單梳理一下,可使大腦有一個清晰的腦回路,不至於越看越懵逼。

  • 系統準備階段。
  • 將 dyld 加載到 App 進程中 (Dyld)。
  • 加載 App 所須要的動態庫 (Load Dylibs)。
  • Rebase & Bind。
  • Objc setup。
  • Initializers。
  • mian()。

官方的一張流程圖:

加載過程

2.概念解釋

在講述整個過程以前,先解釋兩個概念:Mach-O 文件dyld

.Mach-O

Mach-O 是一種文件格式,主要用於 iOS、MacOS、WatchOS 等 Apple 操做系統。這種文件格式可用於一下幾種文件:

  • 能夠行文件 (Mach-O Executable)
  • Dylib 動態庫
  • Bundle 沒法被鏈接的動態庫,只能經過 dlopen() 加載
  • Image,這裏指的是 Executable,Dylib 或者 Bundle 的一種,下文中會提到。
  • Framework 動態庫和對應的頭文件和資源文件的集合。

Mach-O 文件的格式以下:

Mach-O文件格式

  • Header,包含文件的 CPU 架構,例如 x86,arm7,arm64 等。
  • Load commands,包含文件的組織架構和在虛擬內存佈局方式。
  • Data,包含 Load commands 中須要的各個 segment,每一個 segment 中又包含多個 section。當運行一個可執行文件時,虛擬內存 (virtual memory) 系統將 segment 映射到進程的地址空間上。

上個階段中咱們知道如何產生可執行文件(a.out),這裏咱們能夠用 size 工具來查看這個可執行文件的 segment 內容,執行以下命令:

xcrun size -x -l -m a.out
複製代碼

能夠獲得以下結果:

Segment __PAGEZERO: 0x100000000 (vmaddr 0x0 fileoff 0)
Segment __TEXT: 0x1000 (vmaddr 0x100000000 fileoff 0)
  Section __text: 0x43 (addr 0x100000f30 offset 3888)
  Section __stubs: 0x6 (addr 0x100000f74 offset 3956)
  Section __stub_helper: 0x1a (addr 0x100000f7c offset 3964)
  Section __cstring: 0x15 (addr 0x100000f96 offset 3990)
  Section __unwind_info: 0x48 (addr 0x100000fac offset 4012)
  total 0xc0
Segment __DATA: 0x1000 (vmaddr 0x100001000 fileoff 4096)
  Section __nl_symbol_ptr: 0x10 (addr 0x100001000 offset 4096)
  Section __la_symbol_ptr: 0x8 (addr 0x100001010 offset 4112)
  Section __objc_imageinfo: 0x8 (addr 0x100001018 offset 4120)
  total 0x20
Segment __LINKEDIT: 0x1000 (vmaddr 0x100002000 fileoff 8192)
total 0x100003000
複製代碼

長話短說:

  • Segment __PAGEZERO。大小爲 4GB,規定進程地址空間的前 4GB 被映射爲不可讀不可寫不可執行。
  • Segment __TEXT。包含可執行的代碼,以只讀和可執行方式映射。
  • Segment __DATA。包含了將會被更改的數據,以可讀寫和不可執行方式映射。
  • Segment __LINKEDIT。包含了方法和變量的元數據,代碼簽名等信息。
dyld

動態加載器(dynamic loader)。它是開源的,若是有興趣,你能夠閱讀它的源碼。dyld1 已通過時,不用去理解。目前大多用的是 dyld2。在 WWDC2017 上 Apple 新推出了 dyld3,目前只在 iOS 系統 App 上使用,後面應該會普及。這一階段最後會詳細介紹一下 dyld3,這裏就不描述了。

下面開始正式講解啓動過程。

3.系統準備階段

點擊 APP 以後,到加載 dyld 動態加載器這一過程當中,系統作了不少事情,大致分爲以下圖幾個階段:

dyld 加載以前準備工做

大部分同窗沒有深刻研究過這部份內容,我也沒有深刻研究過。因此我儘可能複雜問題簡單化,以最簡單的方式將這些過程講述明白。

  • 點擊 APP 以後,系統會建立一個進程。而後使用 load_init_program 函數加載系統初始化的進程。而後再方法內調用 load_init_program_at_path。經過 load_init_program_at_path 方法調用 __mac_execve
  • __mac_execve 函數會啓動新的進程和 task,調用 exec_activate_image
  • exec_activate_image 函數會按照二進制的格式分發映射內存的函數。Mach-O 文件會由 exec_mach_imgact 處理。
  • exec_mach_imgact 函數中,會檢測 Mach-O header,解析其架構等信息,文件是否合法等;先拷貝 Mach-O 文件到內存中;而後拷貝 Mach-O 文件到內存中;以後是 dyld 相關處理工做;最後釋放資源。
  • load_machfile 函數負責 Mach-O 文件加載相關工做。爲當前 task 分配可執行內存;加載 Mach-O 中 load command 部分的命令;進制數據段執行,防止溢出漏洞攻擊,設置 ASLR 等;最後爲 exec_mach_imgact 回傳結果。
  • parse_machfile 根據 load_command 的信息選擇不一樣函數加載數據。其中使用的是 switch-case 語句,處理的類型有 LC_LOAD_DYLINKERLC_ENCRYPTION_INFO_64 等。
  • 上一步處理中,有一個 case 爲 LC_LOAD_DYLINKER。進入這個 case 三次,並存在 dylinker_command 命令,以後會執行 load_dylinker() 加載 dyld

4.將 dyld 加載到 App 進程中

在 dyld 的源碼中,有一個 dyldStartup.s 文件。這個文件針對不一樣的 CPU 架構,定義了不一樣的啓動方法,大同小異。這裏會執行到 __dyld_start 方法,而後調用 dyldbootstrap::start() 方法,最終調用到 dyld.cppp 中的 dyld::_main() 方法。部分代碼以下:

__dyld_start:
  pushq $0    # push a zero for debugger end of frames marker
  movq  %rsp,%rbp # pointer to base of kernel frame
  andq    $-16,%rsp       # force SSE alignment
  
  # call dyldbootstrap::start(app_mh, argc, argv, slide)
  movq  8(%rbp),%rdi  # param1 = mh into %rdi
  movl  16(%rbp),%esi # param2 = argc into %esi
  leaq  24(%rbp),%rdx # param3 = &argv[0] into %rdx
  movq  __dyld_start_static(%rip), %r8
  leaq  __dyld_start(%rip), %rcx
  subq   %r8, %rcx  # param4 = slide into %rcx
  call  __ZN13dyldbootstrap5startEPK12macho_headeriPPKcl  

      # clean up stack and jump to result
  movq  %rbp,%rsp # restore the unaligned stack pointer
  addq  $16,%rsp  # remove the mh argument, and debugger end frame marker
  movq  $0,%rbp   # restore ebp back to zero
  jmp *%rax   # jump to the entry point
複製代碼

_main() 方法包含了 App 的啓動流程,最終返回應用程序 main 方法的地址,這裏省略代碼,只標註流程:

uintptr_t
_main(const macho_header* mainExecutableMH, uintptr_t mainExecutableSlide, int argc, const char* argv[], const char* envp[], const char* apple[])
{ 
    // 上下文創建,初始化必要參數,解析環境變量等
  ......  
  
  try {
    // instantiate ImageLoader for main executable
    sMainExecutable = instantiateFromLoadedImage(mainExecutableMH, mainExecutableSlide, sExecPath);
    sMainExecutable->setNeverUnload();
    gLinkContext.mainExecutable = sMainExecutable;
    gLinkContext.processIsRestricted = sProcessIsRestricted;
    
    // load shared cache
    checkSharedRegionDisable();
  #if DYLD_SHARED_CACHE_SUPPORT
    if ( gLinkContext.sharedRegionMode != ImageLoader::kDontUseSharedRegion )
      mapSharedCache();
  #endif
  
    // load any inserted libraries
    if  ( sEnv.DYLD_INSERT_LIBRARIES != NULL ) {
      for (const char* const* lib = sEnv.DYLD_INSERT_LIBRARIES; *lib != NULL; ++lib) 
        loadInsertedDylib(*lib);
    }
    
    ......

    // link main executable
    gLinkContext.linkingMainExecutable = true;
    link(sMainExecutable, sEnv.DYLD_BIND_AT_LAUNCH, ImageLoader::RPathChain(NULL, NULL));
    gLinkContext.linkingMainExecutable = false;
    if ( sMainExecutable->forceFlat() ) {
      gLinkContext.bindFlat = true;
      gLinkContext.prebindUsage = ImageLoader::kUseNoPrebinding;
    }
    
    // get main address
    result = (uintptr_t)sMainExecutable->getMain();

    ......
      
  return result;
}
複製代碼

5.加載 App 所須要的動態庫

上文提到過,image 實際是 Mach-O 文件的一種,包括 Executable,Dylib 或者 Bundle。在上節的 dyld::_main() 函數中能夠看出,dyld 會經過調用 instantiateFromLoadedImage 選擇imageLoader加載對應可執行文件。

而後經過 mapSharedCache() 函數將 /System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64 共享的動態庫加載到內存,這也是不一樣的 App 實現動態庫共享機制,不一樣的 App 的虛擬內存中共享動態庫會經過系統的 vm_map 來映射同一塊物理內存,從而實現共享動態庫。

以後會調用 loadInsertedDylib() 函數加載環境變量 DYLD_INSERT_LIBRARIES 中的動態庫。loadInsertedDylib 動態庫並未作太多工做,主要工做都是調用 load 函數來處理,dlopen 也會調用 load 函數來進行動態庫加載。

再後面調用 link() 函數遞歸連接程序所依賴的庫。通常一個 App 所依賴的動態庫在 100-400 個左右。使用命令 otool -L Test 能夠查看 Test 工程所須要的動態庫以下:

/usr/lib/libsqlite3.dylib (compatibility version 9.0.0, current version 274.6.0)
/usr/lib/libz.1.dylib (compatibility version 1.0.0, current version 1.2.11)
/System/Library/Frameworks/Accelerate.framework/Accelerate (compatibility version 1.0.0, current version 4.0.0)
/System/Library/Frameworks/AssetsLibrary.framework/AssetsLibrary (compatibility version 1.0.0, current version 1.0.0)
/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation (compatibility version 150.0.0, current version 1450.14.0)
/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics (compatibility version 64.0.0, current version 1129.2.1)
/System/Library/Frameworks/ImageIO.framework/ImageIO (compatibility version 1.0.0, current version 0.0.0)
/System/Library/Frameworks/MobileCoreServices.framework/MobileCoreServices (compatibility version 1.0.0, current version 822.19.0)
/System/Library/Frameworks/QuartzCore.framework/QuartzCore (compatibility version 1.2.0, current version 1.11.0)
/System/Library/Frameworks/Security.framework/Security (compatibility version 1.0.0, current version 58286.32.2)
/System/Library/Frameworks/SystemConfiguration.framework/SystemConfiguration (compatibility version 1.0.0, current version 963.30.1)
/System/Library/Frameworks/UIKit.framework/UIKit (compatibility version 1.0.0, current version 3698.33.6)
/System/Library/Frameworks/Foundation.framework/Foundation (compatibility version 300.0.0, current version 1450.14.0)
/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
/usr/lib/libSystem.dylib (compatibility version 1.0.0, current version 1252.0.0)
複製代碼

對於 CocoaPods 中的第三方庫,通常是以靜態庫的方式加載,因此使用 otool -L [文件名] 並不會看到 Pod 中的庫。可是若是 Podfile 中加入了 use_frameworks!,即以動態庫方式加載,纔會看到,也就是上面所示。

最後,獲取到應用程序 main 函數地址,返回。

6.Rebase & Bind

這兩個過程,並非在上面 _main() 方法返回以後進行的,而是在上一節中 「link main executable」 這一步進行的。

Apple 爲了保證應用安全,應用了兩種技術:ASLR (Address space layout randomization) 和 Code sign。

ASLR 是指 「地址空間佈局隨機化」。App 啓動的時候,程序會被映射到一個邏輯地址空間。若是這個地址固定,很容易根據地址+偏移量計算出函數地址,被攻擊。 ASLR 使得這個地址是隨機的,防止攻擊者直接定位攻擊代碼位置。

Code sign 是指代碼簽名。Apple 使用兩層非對稱加密,以保證 App 的安全安裝。在進行 Code sign 時,是針對每一個 page 進行加密,這樣在 dyld 加載時,能夠針對每一個 page 進行獨立驗證。

由於使用 ASLR 致使的地址隨機,須要加上偏移量纔是真正方法地址。調用的一個方法,這個方法的地址可能屬於 Mach-O 文件內部,也可能屬於其餘 Mach-O 文件。

Rebase 是修復內部符號地址,即修復的是指向當前 Mach-O 文件內部的資源指針,修復過程只是加一個偏移量就能夠。

Bind 是修復外部符號地址,即修復的是指向外部 Mach-O 文件指針。這一過程須要查詢符號表,指向其餘 Mach-O 文件,比較耗費時間。

官方給出的一張圖以下:

rebase_bind

簡言之就是,前面步驟加載動態庫時地址指偏了,這裏進行 fix-up,不然調不到。

至此,Mach-O 的加載就完事兒了,下面就是 iOS 系統的事情了。

7.Objc Setup

Objc 是一門動態語言,這一步主要來加載 Runtime 相關的東西。主要作一下幾件事情:

  • 把相關的類註冊到全局 table 中。
  • 將 Category 和 Protocol 中的方法註冊到對應的類中。
  • 確保 Selector 的惟一性。

這一步主要處理自定義的一些類和方法。大部分系統類的 Runtime 初始化已經在 Rebase 和 Bind 中完成了。

8.Initializers

這一步進行一些類的初始化。這是一個遞歸過程,先將依賴的動態庫初始化,再對本身自定義的類初始化。主要作的事情有:

  • 調用 Objc 類中的 +[load] 方法。
  • 調用 C/C++ 標記爲 __attribute__(constructor) 的方法。
  • 非基本類型的 C++ 靜態全局比變量的建立。

Swift 用已經幹掉了 +load 方法,官方建議使用 initialize 方法,減小 App 啓動時間。

9.Main

千辛萬苦,咱們終於來到了 main() 方法。

基於 C 的程序通常都以 main() 方法爲入口,iOS 系統會爲你自動建立 main() 方法。代碼很簡單:

int main(int argc, char * argv[])
{
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}
複製代碼

這裏用的 UIApplicationMain 方法聲明以下:

UIKIT_EXTERN int UIApplicationMain(int argc, char * _Nonnull * _Null_unspecified argv, NSString * _Nullable principalClassName, NSString * _Nullable delegateClassName);
複製代碼

  • argc、argv 直接傳遞給 UIApplicationMain 進行相關處理。
  • principalClassName 指定應用程序的類名。這個類必須爲 UIApplication 類型或者其子類。若是爲 nil,則使用 UIApplication 類。
  • delegateClassName,指定應用程序代理類。這個類必須遵循 UIApplicationDelegate 協議。
  • UIApplicationMain 會根據 principalClassName 建立 UIApplication 對象,並根據 delegateClassName 建立 delegate 對象,將這個對象賦值給 UIApplication 對象的 delegate 屬性。
  • 而後將 App 放入 Main Run Loop 環境中來響應和處理用戶交互事件。

關於 AppDelegate 中的一些方法:

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // 通知進程已啓動,可是還未完成顯示。
    return YES;
}

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // 啓動完成,程序準備開始運行。頁面顯示前最後一次操做機會。
    return YES;
}


- (void)applicationWillResignActive:(UIApplication *)application {
    // App 失去焦點,進入非活動狀態。主要實例有:來電話,某些系統彈窗,雙擊 home 鍵,下拉顯示系統通知欄,上拉顯示系統控制中心等。
}


- (void)applicationDidEnterBackground:(UIApplication *)application {
    // App 進入後臺。
}


- (void)applicationWillEnterForeground:(UIApplication *)application {
    // App 進入前臺。冷啓動不會收到這個通知。
}


- (void)applicationDidBecomeActive:(UIApplication *)application {
    // App 得到焦點,處於活動狀態。冷熱啓動都會收到這個通知。
}


- (void)applicationWillTerminate:(UIApplication *)application {
    // 應用將要退出時,能夠在這個方法中保存數據和一些退出前清理工做。
}

- (void)applicationDidReceiveMemoryWarning:(UIApplication *)application {
    // 收到內存警告,釋放一些內存。
}
@end
複製代碼

10.One more thing

上文說有詳細講一下 dyld3,放到這裏了。dyld3 是 WWDC 2017 介紹的新的動態加載器。與 dyld2 對好比下圖:

dyld_2_3

二者的區別,通俗一點說就是:dyld2 全部的過程都是在啓動時進行的,每次啓動都會講全部過程走一遍;dyld3 分紅了兩部分,虛線上面的部分在 App 下載安裝和版本更新時執行並將結果寫入緩存,虛線下面的部分在每次 App 啓動執行。

這樣減小了 dyld 加載步驟,也就加快了 APP 啓動時間。不過目前 dyld3 只在 Apple 系統 App 纔會使用,開發者不能使用。後面應該會普及。

根據上面的分析過程,咱們能夠大致總結出,若是要針對 App 作啓動優化,能夠從哪些方面入手:

  • 減小動態庫的引入。若是是公司內部自定義組件,能夠將某些同類的組件合併爲一個。
  • 爲了減小 Rebase & Bind 時間,減小 __DATA 中的指針數量。
  • 爲了減小 Runtime 註冊時間,減小 Category,減小無用的 Class 和 Selector。
  • 儘可能不要在 +[load] 方法中寫東西,減小 __atribute__((constructor)),減小非基本類型 C++ 靜態常量建立。
  • 將一些第三方庫在使用的時候再初始化,lazy load,不要都放在 AppDelegate 中。
  • 使用 Swift。

圖層渲染階段

作了一堆準備工做,可算是到了渲染展現界面了。

圖層的佈局過程(這裏指自動佈局),主要分爲三步:設置約束、更新佈局、渲染視圖。這裏會結合 view controller 的生命週期來說解。

1.視圖佈局過程

Update Cycle

在程序啓動時,會將 App 放到 Main Run Loop 中來響應和處理用戶交互事件。關於 RunLoop,簡單說來就是一個循環,只要 App 未被殺死,這個循環就一直存在。每一次循環能夠認爲是一個迭代週期,這個週期中會相應和處理用戶交互事件。當完成了各類事件處理以後控制流回到 Main Run Loop 那個時間點,開始更新視圖,更新完進入下一個循環。整個過程以下圖所示:

update_cycle

在 update cycle 這個階段,系統會根據計算出來的新的 frame 對視圖進行重繪。這個過程很快,因此用戶感受不到延遲卡頓。由於視圖的更新是按照週期來的,因此有時候修改了約束、添加了視圖或者修改了 frame 並不會當即重繪視圖。接下來就詳細介紹這一過程。

約束

一個視圖的 frame 包含了視圖的位置和大小,經過這個 frame(和當前座標系) 能夠肯定視圖的具體位置。約束的本質就是設置一系列的關係,計算佈局時會將這些關係轉化爲一系列線性方程式,經過線性方程式求解得出 x,y,width,height,從而肯定視圖位置。這一階段是從下向上(from subview to super view),爲下一步佈局準備消息。

updateConstraints()

這個方法用來在自動佈局中動態改變視圖約束。通常狀況下,這個方法只應該被重載,不該該手動調用。在開發過程當中,一些靜態約束,能夠在視圖初始化方法或者 viewDidLoad() 方法中設置;對於一些動態約束,例如 UILabel 有時須要隨着文案字數改變大小,須要動態修改約束,這時候能夠重載此方法,將動態修改約束代碼寫在次方法裏。

還有一些操做會將視圖標記,在下一個 update cycle 中自動觸發這個方法:

  • 激活/禁用約束。
  • 改變約束的大小或者優先級。
  • 改變視圖層級。

setNeedsUpdateConstraints()

若是你但願視圖在下一個 update cycle 中必定要調用 updateConstraints() 方法,你能夠調用此方法,這樣就給視圖打上一個標記,若是有必要在下一個 update cycle 便會調用 updateConstraints()方法。

這裏說「若是有必要「,是由於若是系統檢測視圖沒有任何變化,即便標記了,也不會調用此方法,避免耗費性能。因此標記了,只是告訴系統到時候 check 一下,是否要更新約束。下面一些方法同理。

updateConstraintsIfNeeded()

若是你不想等到 run loop 末尾,進入 update cycle 的時候,再去檢查標記並更新約束。你想馬上檢查被打上標記的視圖,更新約束,能夠調用此方法。一樣的,調用此方法只會檢查那些被標記的視圖,若是有必要,纔會調用 updateConstraints() 方法。

invalidateIntrinsicContentSize()

有些視圖(例如 UILabel)有 intrinsicContentSize 屬性,這是根據視圖內容獲得的固有大小。你也能夠經過重載來自定義這個大小,重載以後,你須要調用 invalidateIntrinsicContentSize() 方法來標記 intrinsicContentSize 已通過期,須要再下一個 update cycle 中從新計算。

佈局

根據約束計算出視圖大小和位置,下一步就是佈局。這一部分是從上向下(from super view to subview),使用上一步計算出來的大小和位置去設置視圖的 center 和 bounds。

layoutSubviews()

這個方法會對視圖和其子視圖進行從新定位和大小調整。這個方法很昂貴,由於它會處理當前視圖和其自視圖的佈局狀況,還會調用自視圖的 layoutSubviews(),層層調用。一樣,這個方法只應該被重載,不該該手動調用。當你須要更新視圖 frame 時,能夠重載這個方法。

一些操做可能會觸發這個方法,間接觸發比手動調用資源消耗要小得多。有如下幾種狀況會觸發此方法:

  • 修改視圖大小。
  • 添加視圖 (addSubview)。
  • UIScrollView 滾動。
  • 設備旋轉。
  • 更新視圖約束

這些狀況有的會告訴系統視圖 frame 須要從新計算,從而調用 layoutSubviews(),也有的會直接觸發 layoutSubviews() 方法。

setNeedsLayout()

此方法會將視圖標記,告訴系統視圖的佈局須要從新計算。而後再下一個 update cycle 中,系統就會調用視圖的 layoutSubviews() 方法。一樣的,若是有必要,系統纔會去調用

layoutIfNeeded()

setNeedsLayout 是標記視圖,在下個 update cycle 中可能會調用 layoutSubviews() 方法。而 layoutIfNeeded() 是告訴系統當即調用 layoutSubviews() 方法。固然,調用了 layoutIfNeeded()方法只會,系統會 check 視圖是否有必要刷新,若是有必要,系統纔會調用 layoutSubviews() 方法。若是你再同一個 run loop 中調用了兩次 layoutIfNeeded(),兩次之間沒有視圖更新,那麼第二次則不會觸發 layoutSubviews()

在作約束動畫時,這個方法頗有用。在動畫以前,調用此方法以確保其餘視圖已經更新。而後在 animation block 中設置新的約束後,調用此方法來動畫到新的狀態。例如:

[self.view layoutIfNeeded];
  [UIView animateWithDuration:1.0 animations:^{
    [self changeConstraints];
    [self.view layoutIfNeeded];
  }];
複製代碼

渲染

視圖的顯示包含了顏色、文本、圖片和 Core Graphics 繪製等。與約束、佈局兩個步驟相似,這裏也有一些方法用來刷新渲染。這一過程是從上向下(from super view to subview)。

draw(_:)

UIView 的 draw 方法(OC 中的 drawRect)用來繪製視圖顯示的內容,只做用於當前視圖,不會影響子視圖。依然,這個方法應該經過其餘方法觸發,而不該該手動調用。

setNeedsDisplay()

這個方法相似於佈局中的 setNeedsLayout()。調用此方法會將視圖標記,而後在下一個 update cycle 系統遍歷被標記的視圖,調用其 draw() 方法進行重繪。大部分 UI 組件若是有更新,都會進行標記,在下個 update cycle 進行重繪。通常不須要顯式調用此方法。

這一步驟沒有相似於 layoutIfNeeded() 這樣的方法來當即刷新。一般等到下一個 update cycle 再刷新也沒影響。

三者聯繫

佈局過程並非單向的,而是一個 約束-佈局 的迭代過程。佈局過程有可能會影響約束,從而觸發 updateConstraints()。只要肯定好佈局,判斷是否須要重繪,而後展現。這一輪完畢後進入下一個 runloop。它們的大致流程以下:

佈局過程

上面說的這三個過程的方法,有些相似,記起來比較亂,能夠經過下面的表格對比記憶:

方法做用 約束 佈局 渲染
刷新方法,能夠重載,不可直接調用 updateConstraints layoutSubviews draw
標記刷新方法,使視圖在下一個 update cycle 調用刷新方法 setNeedsUpdateConstraints
invalidateIntrinsicContentSize
setNeedsLayout setNeedsDisplay
updateConstraintsIfNeeded layoutIfNeeded
觸發刷新方法的操做 激活/禁用約束
改變約束的大小或者優先級
改變視圖層級
修改視圖大小
添加視圖 (addSubview)
UIScrollView 滾動
設備旋轉
更新視圖約束
修改視圖 bounds

2.View Controller 生命週期

校招找工做時,常常被問到 VC 的生命週期。最近面試其餘人,也常常問這個問題。不管是校招時候的我,仍是我面試的其餘人,哪怕是工做三五年的,都回答很差這個問題。

這是一個基礎問題,沒有太多技術難度,應該掌握。

單個 View Controller 生命週期

以方法調用順序描述單個 View Controller 生命週期,依次爲:

  • load
    類加載時調用,在 main 函數以前。

  • initialize
    類第一次初始化時調用,在main 函數以後。

  • 類初始化相關方法
    [initWithCoder:] 在使用 storeboard 調用。[initWithNibName: bundle:] 在使用自定義 nib 文件時調用。還有其餘 init 方法則是普通初始化類時調用。

  • loadView
    開始加載視圖,在這以前都沒有視圖。除非手動調用,不然在 View Controller 生命週期只會調用一次。在

  • viewDidLoad
    View Controller 生命週期中只會調用一次。類中成員變量、子視圖等一些數據的初始化都放在這個方法裏。

  • viewWillAppear
    視圖將要展現前調用。

  • viewWillLayoutSubviews
    將要對子視圖進行佈局。

  • viewDidLayoutSubviews
    已完成子視圖佈局,第一時間拿到 view 的具體 frame。一些依賴佈局或者大小的代碼都應該放在這個方法。放在以前的方法中,視圖尚未佈局,frame 都是 0;放在後面的方法中,可能由於一些改動,佈局或者位置變量發生改變。

  • viewDidAppear
    視圖顯示完成調用。

  • viewWillDisappear
    視圖即將消失時調用。

  • viewDidDisappear
    視圖已經消失時調用。

  • dealloc
    View Controller 被釋放時調用。

兩個 View Controller 進行轉場時各自方法調用時機

不一樣的轉場方式,兩個 VC 之間方法調用順序不一樣。常見的有如下幾種方式:

  • Navigation

    push 操做

    • New viewDidLoad
    • Current viewWillDisappear
    • New viewWillAppear
    • New viewWillLayoutSubviews
    • New viewDidLayoutSubviews
    • Current viewDidDisappear
    • New viewDidAppear

      Pop 操做(上一步的 New 在這裏變爲 Current,下同)

    • Current viewWillDisappear

    • New viewWillAppear
    • Current viewDidDisappear
    • New viewDidappear
  • Page Curling (UIPageViewControllerTransitionStylePageCurl)

    Normal 正常翻頁操做

    • New viewDidLoad
    • Current viewWillDisappear
    • New viewWillAppear
    • New viewWillLayoutSubviews
    • New viewDidLayoutSubviews
    • Current viewDidDisappear
    • New viewDidAppear

      Canceled 翻到一半取消

    • New viewWillAppear

    • New viewWillAppear
    • Current viewWillDisappear
    • New viewWillLayoutSubviews
    • New viewDidLayoutSubviews
    • New viewWillDisappear
    • Current viewWillAppear
    • New viewDidDisappear
    • Current viewDidAppear
  • Page Scrolling (UIPageViewControllerTransitionStyleScroll)

    Normal 正常滑動翻頁操做

    • New viewDidLoad
    • New viewWillAppear
    • Current viewWillDisappear
    • New viewWillLayoutSubviews
    • New viewDidLayoutSubviews
    • New viewDidAppear
    • Current viewDidDisappear

      Canceled 滑到一半取消

    • New viewWillAppear

    • Current viewWillDisappear
    • Current viewWillAppear
    • Current viewDidAppear
    • New viewWillDisappear
    • New viewDidDisappear

能夠看出,不一樣的專場方式,兩個 View Cotroller 之間的生命週期方法調用順序是不同的。很混亂是吧,不用強記,只須要知道這個 case,在開發是注意就行了。

總結

以上基本就是一個工程從編譯到啓動的全部過程。深刻理解這一過程,能夠幫助咱們更好的開發。由於文章比較長,中間不免有一些紕漏。若是發現請指出,我會盡快修改。

參考文獻

  1. objc-Issues-Build-Tools
  2. 深刻理解iOS App的啓動過程
  3. XNU、dyld源碼分析Mach-O和動態庫的加載過程(上)
  4. XNU、dyld 源碼分析,Mach-O 和動態庫的加載過程 (下)
  5. Demystifying iOS Layout
  6. The Inconsistent Order of View Transition Events
相關文章
相關標籤/搜索