博客> 按下 ⌘ + R 后发生的事情
按下 ⌘ + R 后发生的事情
2019-08-23 21:47 评论:0 阅读:1021 boolchow
ios 生命周期 编译过程 布局渲染

作者:bool周 原文链接:按下 ⌘ + R 后发生的事情

作为一名 coder,每天的工作不是解 bug,就是写 bug。有些东西,了解了并不一定有利于写 bug,但是有利于解 bug。对于一个工程,当你按下 ⌘ + R 到主界面显示出来,你可曾想过这一过程发生了哪些事情?这些原理性的东西,对我们 coding 并没有直接帮助,了解与否都可以 coding。但是一个 coder 的工作不只是 coding,还有 debug。了解这些东西,对我们排查一些问题很有帮助。

按照阶段划分,这一过程大致可以划为三个阶段:编译阶段APP 启动阶段图层渲染阶段。下面针对这三个过程进行详细描述。

编译阶段

学过编译原理的同学都应该知道,编译主要分为四个过程:预处理、编译、汇编、链接。下面大致也是按照这个路子来。iOS 编译过程,使用的 clang 做前端,LLVM 作为后端进行完成的。使用 clang 处理前几个阶段,LLVM 处理后面几个阶段。

1.预处理

又称为预编译,主要做一些文本替换工作。处理 # 开头的指令,例如:

  • 宏定义的展开 (#define)
  • 头文件展开 (#include,#import)
  • 处理条件编译指令 (#if,#else,#endif)

例如我们在代码中定义了如下宏:

#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 进行宏展开的预处理结果如下:

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

宏的使用有很多坑,尽量用其他方式代替。

2.词法分析

完成预处理后,词法分析器(也叫扫描器)会对 .m 中的源代码进行从左到右扫描,按照语言的词法规则识别各类单词、关键字,并生成对应的单词的属性字。例如下面一段代码:

#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>
identifier 'main'  [LeadingSpace] Loc=<main>
l_paren '('  Loc=<main>
int 'int'  Loc=<main>
identifier 'argc'  [LeadingSpace] Loc=<main>
comma ','  Loc=<main>
char 'char'  [LeadingSpace] Loc=<main>
star '*'  [LeadingSpace] Loc=<main>
identifier 'argv'  [LeadingSpace] Loc=<main>
l_square '['  Loc=<main>
r_square ']'  Loc=<main>
r_paren ')'  Loc=<main>
l_brace '{'  [LeadingSpace] Loc=<main>
char 'char'  [StartOfLine] [LeadingSpace] Loc=<main>
star '*'  [LeadingSpace] Loc=<main>
identifier 'version'  Loc=<main>
equal '='  [LeadingSpace] Loc=<main>
string_literal '"V1.0.0"'  [LeadingSpace] Loc=<main Spelling=main.m:12:21>>
semi ';'  Loc=<main>
identifier 'printf'  [StartOfLine] [LeadingSpace] Loc=<main>
l_paren '('  Loc=<main>
string_literal '"version is %s"'  Loc=<main>
comma ','  Loc=<main>
identifier 'version'  Loc=<main>
r_paren ')'  Loc=<main>
semi ';'  Loc=<main>
return 'return'  [StartOfLine] [LeadingSpace] Loc=<main>
numeric_constant '0'  [LeadingSpace] Loc=<main>
semi ';'  Loc=<main>
r_brace '}'  [StartOfLine] Loc=<main>
eof ''  Loc=<main>

从上面可以看出每个单词或者字符,都标记出了具体列数和行数,这样如果在编译过程中遇到什么问题,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> line:14:5 main 'int (int, char **)'
  |-ParmVarDecl 0x7ffe55884028 <col> col:14 argc 'int'
  |-ParmVarDecl 0x7ffe55884110 <col> col:27 argv 'char **':'char **'
  `-CompoundStmt 0x7ffe55884568 <col>
    |-DeclStmt 0x7ffe55884390 <line>
    | `-VarDecl 0x7ffe558842e8 <col> line:18:11 used version 'char *' cinit
    |   `-ImplicitCastExpr 0x7ffe55884378 <line> 'char *' <ArrayToPointerDecay>
    |     `-StringLiteral 0x7ffe55884348 <col> 'char [7]' lvalue "V1.0.0"
    |-CallExpr 0x7ffe558844b0 <line> 'int'
    | |-ImplicitCastExpr 0x7ffe55884498 <col> 'int (*)(const char *, ...)' <FunctionToPointerDecay>
    | | `-DeclRefExpr 0x7ffe558843a8 <col> 'int (const char *, ...)' Function 0x7ffe55088570 'printf' 'int (const char *, ...)'
    | |-ImplicitCastExpr 0x7ffe55884500 <col> 'const char *' <BitCast>
    | | `-ImplicitCastExpr 0x7ffe558844e8 <col> 'char *' <ArrayToPointerDecay>
    | |   `-StringLiteral 0x7ffe55884408 <col> 'char [14]' lvalue "version is %s"
    | `-ImplicitCastExpr 0x7ffe55884518 <col> 'char *' <LValueToRValue>
    |   `-DeclRefExpr 0x7ffe55884440 <col> 'char *' lvalue Var 0x7ffe558842e8 'version' 'char *'
    `-ReturnStmt 0x7ffe55884550 <line>
      `-IntegerLiteral 0x7ffe55884530 <col> '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.png

日志是按照 target 进行分段的。当前工程中,通过 Pod 引入了 YYCacheYYImageAFNetworking 三个库,除此之外还有一个 Pods-Test 和项目本身的 target。每个 target 之间的日志格式都是一样的,因此我们只针对一个 target 进行分析。这里只针对项目本身 target,也就是 Test 进行分析。也就是下面这个样子:

 test_build_log.png

看着很乱套,整理完之后,屡一下大概是这个流程:

  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.png

将 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()。

官方的一张流程图:

 加载过程.png

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.jpg

  • 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 之前准备工作.png

大部分同学没有深入研究过这部分内容,我也没有深入研究过。所以我尽量复杂问题简单化,以最简单的方式将这些过程讲述明白。

  • 点击 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.png

简言之就是,前面步骤加载动态库时地址指偏了,这里进行 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.png

两者的区别,通俗一点说就是: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.png

在 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。它们的大体流程如下:

 布局过程.png

上面说的这三个过程的方法,有些类似,记起来比较乱,可以通过下面的表格对比记忆:

方法作用 约束 布局 渲染
刷新方法,可以重载,不可直接调用 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
收藏
0
sina weixin mail 回到顶部