iOS 开发常用的语言是 Objective-C 和 Swift,两者都是编译语言。编译语言在执行的时候,必须先通过编译器生成机器码,机器码可以直接在 CPU 上执行,所以执行效率很高。本篇主要用于梳理 Objective-C 的编译过程。

编译器的概述

编译器的作用是把我们的高级语言转换成机器可以识别的机器码,经典的设计结构如下:

  • 前端(Frontend):语法分析,语义分析和生成中间代码。在这个过程中,也会对代码进行检查,如果发现出错的或需要警告的会标注出来。
  • 优化器(Optimizer):会进行 BitCode 的生成,链接期优化等工作。
  • 后端(Backend):针对不同的架构,生成对应的机器码。

Clang + LLVM 的编译过程

接着通过如下一个实际的例子来看下编译的过程。

#import <Foundation/Foundation.h>

int main (int argc, const char * argv[])
{
@autoreleasepool
{
NSLog(@"Hello, Obj");
}
return 0;
}

命令行执行:

clang -ccc-print-phases -framework Foundation main.m -o main

可以看到编译源文件需要的几个阶段为:

$  Desktop clang -ccc-print-phases -framework Foundation main.m -o main

0: input, "Foundation", object
1: input, "main.m", objective-c
2: preprocessor, {1}, objective-c-cpp-output
3: compiler, {2}, ir
4: backend, {3}, assembler
5: assembler, {4}, object
6: linker, {0, 5}, image
7: bind-arch, "x86_64", {6}, image

注释:

  • 2: preprocessor, {1}, objective-c-cpp-output:预处理,编译器前端
  • 3: compiler, {2}, ir:编译生成中间码 ir
  • 4: backend, {3}, assembler:LLVM 后端生成汇编
  • 5: assembler, {4}, object:生成机器码
  • 6: linker, {0, 5}, image:链接器
  • 7: bind-arch, "x86_64", {6}, image:生成可执行的二进制文件(image)

梳理下流程:

  1. 预处理阶段:import 头文件替换;macro 宏展开;处理预编译指令
  2. 词法分析:预处理完成后进入词法分析,将输入的代码转化为一系列符合特定语言的词法单元(token 流)。样式如下:
    $ xcrun -sdk iphonesimulator clang -fmodules -fsyntax-only -Xclang -dump-tokens main.m

    annot_module_include '#import <Foundation/Foundation.h>' Loc=<main.m:1:1>
    int 'int' [StartOfLine] Loc=<main.m:3:1>
    identifier 'main' [LeadingSpace] Loc=<main.m:3:5>
    l_paren '(' [LeadingSpace] Loc=<main.m:3:10>
    int 'int' Loc=<main.m:3:11>
    identifier 'argc' [LeadingSpace] Loc=<main.m:3:15>
    comma ',' Loc=<main.m:3:19>
    const 'const' [LeadingSpace] Loc=<main.m:3:21>
    char 'char' [LeadingSpace] Loc=<main.m:3:27>
    star '*' [LeadingSpace] Loc=<main.m:3:32>
    identifier 'argv' [LeadingSpace] Loc=<main.m:3:34>
    l_square '[' Loc=<main.m:3:38>
    r_square ']' Loc=<main.m:3:39>
    r_paren ')' Loc=<main.m:3:40>
    l_brace '{' [StartOfLine] Loc=<main.m:4:1>
    at '@' [StartOfLine] [LeadingSpace] Loc=<main.m:5:5>
    identifier 'autoreleasepool' Loc=<main.m:5:6>
    l_brace '{' [StartOfLine] [LeadingSpace] Loc=<main.m:6:5>
    identifier 'NSLog' [StartOfLine] [LeadingSpace] Loc=<main.m:7:9>
    l_paren '(' Loc=<main.m:7:14>
    at '@' Loc=<main.m:7:15>
    string_literal '"Hello, Obj"' Loc=<main.m:7:16>
    r_paren ')' Loc=<main.m:7:28>
    semi ';' Loc=<main.m:7:29>
    r_brace '}' [StartOfLine] [LeadingSpace] Loc=<main.m:8:5>
    return 'return' [StartOfLine] [LeadingSpace] Loc=<main.m:9:5>
    numeric_constant '0' [LeadingSpace] Loc=<main.m:9:12>
    semi ';' Loc=<main.m:9:13>
    r_brace '}' [StartOfLine] Loc=<main.m:10:1>
    eof '' Loc=<main.m:10:2>
  3. 语法分析:将词法分析得到的 token 流进行语法静态分析(Static Analysis),输出抽象语法树(AST),过程中会校验语法是否错误。
    $ xcrun -sdk iphonesimulator clang -fmodules -fsyntax-only -Xclang -ast-dump main.m

    TranslationUnitDecl 0x7fbdd301ba08 <<invalid sloc>> <invalid sloc> <undeserialized declarations>
    |-TypedefDecl 0x7fbdd301c2a0 <<invalid sloc>> <invalid sloc> implicit __int128_t '__int128'
    | `-BuiltinType 0x7fbdd301bfa0 '__int128'
    |-TypedefDecl 0x7fbdd301c310 <<invalid sloc>> <invalid sloc> implicit __uint128_t 'unsigned __int128'
    | `-BuiltinType 0x7fbdd301bfc0 'unsigned __int128'
    |-TypedefDecl 0x7fbdd301c3b0 <<invalid sloc>> <invalid sloc> implicit SEL 'SEL *'
    | `-PointerType 0x7fbdd301c370 'SEL *' imported
    | `-BuiltinType 0x7fbdd301c200 'SEL'
    |-TypedefDecl 0x7fbdd301c498 <<invalid sloc>> <invalid sloc> implicit id 'id'
    | `-ObjCObjectPointerType 0x7fbdd301c440 'id' imported
    | `-ObjCObjectType 0x7fbdd301c410 'id' imported
    |-TypedefDecl 0x7fbdd301c578 <<invalid sloc>> <invalid sloc> implicit Class 'Class'
    | `-ObjCObjectPointerType 0x7fbdd301c520 'Class' imported
    | `-ObjCObjectType 0x7fbdd301c4f0 'Class' imported
    |-ObjCInterfaceDecl 0x7fbdd301c5d0 <<invalid sloc>> <invalid sloc> implicit Protocol
    |-TypedefDecl 0x7fbdd301c948 <<invalid sloc>> <invalid sloc> implicit __NSConstantString 'struct __NSConstantString_tag'
    | `-RecordType 0x7fbdd301c740 'struct __NSConstantString_tag'
    | `-Record 0x7fbdd301c6a0 '__NSConstantString_tag'
    |-TypedefDecl 0x7fbdd3059000 <<invalid sloc>> <invalid sloc> implicit __builtin_ms_va_list 'char *'
    | `-PointerType 0x7fbdd301c9a0 'char *'
    | `-BuiltinType 0x7fbdd301baa0 'char'
    |-TypedefDecl 0x7fbdd30592e8 <<invalid sloc>> <invalid sloc> implicit __builtin_va_list 'struct __va_list_tag [1]'
    | `-ConstantArrayType 0x7fbdd3059290 'struct __va_list_tag [1]' 1
    | `-RecordType 0x7fbdd30590f0 'struct __va_list_tag'
    | `-Record 0x7fbdd3059058 '__va_list_tag'
    |-ImportDecl 0x7fbdd30b4218 <main.m:1:1> col:1 implicit Foundation
    `-FunctionDecl 0x7fbdd30b44e0 <line:3:1, line:10:1> line:3:5 main 'int (int, const char **)'
    |-ParmVarDecl 0x7fbdd30b4270 <col:11, col:15> col:15 argc 'int'
    |-ParmVarDecl 0x7fbdd30b4390 <col:21, col:39> col:34 argv 'const char **':'const char **'
    `-CompoundStmt 0x7fbdd30c9190 <line:4:1, line:10:1>
    |-ObjCAutoreleasePoolStmt 0x7fbdd30c9148 <line:5:5, line:8:5>
    | `-CompoundStmt 0x7fbdd30c9130 <line:6:5, line:8:5>
    | `-CallExpr 0x7fbdd30c90f0 <line:7:9, col:28> 'void'
    | |-ImplicitCastExpr 0x7fbdd30c90d8 <col:9> 'void (*)(id, ...)' <FunctionToPointerDecay>
    | | `-DeclRefExpr 0x7fbdd30c8fb0 <col:9> 'void (id, ...)' Function 0x7fbdd30b4620 'NSLog' 'void (id, ...)'
    | `-ImplicitCastExpr 0x7fbdd30c9118 <col:15, col:16> 'id':'id' <BitCast>
    | `-ObjCStringLiteral 0x7fbdd30c9060 <col:15, col:16> 'NSString *'
    | `-StringLiteral 0x7fbdd30c9038 <col:16> 'char [11]' lvalue "Hello, Obj"
    `-ReturnStmt 0x7fbdd30c9180 <line:9:5, col:12>
    `-IntegerLiteral 0x7fbdd30c9160 <col:12> 'int' 0
  4. CodeGen 生成 IR 中间代码:CodeGen 负责将语法树自顶向下遍历翻译成 LLVM IRIR 是编译过程中前端的输出后端的输入。
  5. Optimize 优化 IR:到这里 LLVM 会做一些优化工作,在 Xcode 的编译设置里可以设置优化级别 -01, -03, -0s,也可以写自己的 Pass,Pass 是 LLVM 优化工作的一个节点,一个节点做些事,一起加起来就构成了 LLVM 完整的优化和转化。附件:官方 Pass 教程
  6. LLVM Bitcode 生成字节码:如果开启了 bitcode,苹果会做进一步优化。若有新的后端架构,依旧可以用这份优化过的 bitcode 去生成。
  7. 生成汇编
  8. 生成目标文件
  9. 生成可执行文件

我们通过一个命令行操作来复现上述的编译过程:

# 词法分析
xcrun -sdk iphonesimulator clang -fmodules -fsyntax-only -Xclang -dump-tokens main.m
# 语法分析
xcrun -sdk iphonesimulator clang -fmodules -fsyntax-only -Xclang -ast-dump main.m
# CodeGen 生成 IR
xcrun -sdk iphonesimulator clang -S -fobjc-arc -emit-llvm main.m -o main.ll
# Optimize 优化 IR
xcrun -sdk iphonesimulator clang -O3 -S -fobjc-arc -emit-llvm main.m -o main.ll
# LLVM Bitcode
xcrun -sdk iphonesimulator clang -emit-llvm -c main.m -o main.bc
# 生成汇编
xcrun -sdk iphonesimulator clang -S -fobjc-arc main.m -o main.s
# 生成目标文件
xcrun -sdk iphonesimulator clang -fmodules -c main.m -o main.o
# 生成可执行文件
xcrun -sdk iphonesimulator clang main.o -o main
# 至此,即生成了可供 iphonesimulator 执行的 `可执行文件`

Xcode Build 的流程

我们在 Xcode 中使用 Command + BCommand + R 时,即完成了一次编译,我们来看下这个过程做了哪些事情。

编译过程分为四个步骤:

  • 预编译(Pre-process):宏替换、删除注释、展开头文件,产生 .i 文件。
  • 编译(Compliling):把前面生成的 .i 文件转化为汇编语言,产生 .s 文件。
  • 汇编(Asembly):把汇编语言 .s 文件转化为机器码文件,产生 .0 文件。
  • 链接(Link):对 .o 文件中的对于其他库的引用的地方进行引用,生成最后的可执行文件。也包括多个 .o 文件进行 link。

通过解析 Xcode 编译 log,可以发现 Xcode 是根据 Target 进行编译的。我们可以通过 Xcode 中的 Build Phases、Build Settings 及 Build Rules 来控制编译过程。

  • Build Settings:这一栏下是对编译的细节进行设定,包含 build 过程的每个阶段的设置选项(包含编译、链接、代码签名、打包)。
  • Build Phases:用于控制从源文件到可执行文件的整个过程,如编译哪些文件,编译过程中执行哪些自定义脚本。例如 CocoaPods 在这里会进行相关配置。
  • Build Rules:指定了不同的文件类型该如何编译。一般我们不需要修改这里的内容。如果需要对特定类型的文件添加处理方法,可以在这里添加规则。

每个 Target 的具体编译过程也可以通过 log 日志获得。大致过程为:

  • 编译信息写入辅助文件(如Entitlements.plist),创建编译后的文件架构
  • 写入辅助信息(.hmap 文件)。将项目的文件结构对应表、将要执行的脚本、项目依赖库的文件结构对应表写成文件。
  • 运行预设的脚本。如 Cocoapods 会在 Build Phases 中预设一些脚本(CheckPods Manifest.lock)。
  • 编译 .m 文件,生成可执行文件 Mach-O。每次进行了 LLVM 的完整流程:前端(词法分析 - 语法分析 - 生成 IR)、优化器(优化 IR)、后端(生成汇编 - 生成目标文件 - 生成可执行文件)。使用 CompileCclang 命令。
    CompileC 是 xcodebuild 内部函数的日志记录表示形式,它是 build.log 文件中有关编译的基本信息来源。
  • 链接需要的库。如 Foundation.framework,AFNetworking.framework…
  • 拷贝资源文件到目标包
  • 编译 storyboard 文件
  • 链接 storyboard 文件
  • 编译 Asset 文件。如果使用 Asset.xcassets 来管理图片,这些图片会被编译为机器码,除了 icon 和 launchIamge。
  • 处理 infoplist
  • 执行 CocoaPods 脚本,将在编译项目前已编译好的依赖库和相关资源拷贝到包中。
  • 拷贝 Swift 标准库
  • 创建 .app 文件并对其签名

dSYM 文件

在每次编译完成之后,都会生成一个 dsym 文件。dsym 文件中,存储了 16 进制的函数地址映射。

当 App 执行打包后的二进制文件时,实际是通过地址来调用方法的。我们可能会用三方的统计工具,例如 Fabric 在 App Crash 时会帮我们抓到 crash 时的调用栈,这些调用栈里会包含 crash 地址的调用信息。这时可以通过 dSYM 文件由地址映射到具体的函数位置。

预处理

预处理,即在编译前的处理。预处理能够让我们定义编译器变量,实现条件编译。比如我们常会用到下面这样的判断:

#ifdef DEBUG
//...
#else
//...
#endif

比如我们常会有这样的场景:测版版本使用测试服务器数据,正式版本使用生产服务器数据。我们可以分别为 debug 和 release 设置相关的预处理宏。假设名为 DEVSERVER

再通过如下代码,就实现了服务器地址的按需切换:

#ifdef DEVSERVER
// 测试服务器
#else
// 生产服务器
#endif

插入脚本

如果我们的项目中使用了 CocoaPods,在 Build Phase 里会看到 [CP] 开头的脚本。这些是 CocoaPods 插入的脚本。

  • Check Pods Manifest.lock:检查 cocoapod 管理的三方库是否需要更新
  • Embed Pods Framework:运行脚本来链接三方库的静态/动态库

这些配置信息都存在 .xcodeproj 文件里。CocoaPods 通过修改 .xcodeproj,配置编译期的脚本,以保证三方库正确的编译链接。

这里也有个比较常用的脚本操作。例如,每当我们 Archive 时,通常情况下都需要手动修改 target 的 build 版本,我们可以让这一步实现自动化。

添加方式:

  • Xcode -> Target -> Build Phase -> New Run Script Phase
  • 写入如下脚本代码
  • 勾选 Run script only when installing
  • 重命名脚本为 [ProjectName]Increase build number
# 脚本信息
buildNumber=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "${PROJECT_DIR}/${INFOPLIST_FILE}")
buildNumber=$(($buildNumber + 1))
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "${PROJECT_DIR}/${INFOPLIST_FILE}"

这段脚本实现的是:读取当前 plist 的 build 版本号,在其基础上 +1,再写入 plist 文件中。

脚本编译打包

对于 CI 来说,脚本编译打包十分有用,一个自动打包的配置可做参考:

提高项目 Build 速度

随着项目的迭代,源代码及三方库的引入都在持续增加,很明显会发现编译速度变慢。基于之前对编译过程的了解,可以一定程度上优化编译速度。

指标建立

首先,编译的快慢我们需要确定一个度量,最直观的表现是编译时间。我们通过在终端输入如下指令:

$ defaults write com.apple.dt.Xcode ShowBuildOperationDuration YES

完成后重启 Xcode,进行一次编译,编译完成后在顶栏即可看到对应的编译时间。

代码层面的优化

前向声明(Forward Declaration)

Objective-C 中 #import@class 都可以引入一个类。他们区别是:

  • #import 会包含这个类的所有信息,包括实体变量方法;而 @class 只是告诉编译器,其后面声明的名称是类的名称,至于这些类是如何定义的,暂不考虑。
  • 在头文件中,一般只需要知道被引用的类的名称就可以。 不需要知道其内部的实体变量和方法,所以在头文件中一般使用 @class 来声明这个名称是类的名称。 而在实现类里面使用 #import 来包含这个被引用类的头文件,因为会用到这个引用类的内部的实体变量方法
  • 在编译效率方面考虑,如果你有 100 个头文件都 #import 了同一个头文件,或者这些文件是依次引用的,如 A–>B, B–>C, C–>D 这样的引用关系。当最开始的那个头文件有变化时,后面所有引用它的类都需要重新编译,如果类有很多,这将耗费大量的时间。而是用 @class 不会。
  • 如果有循环依赖关系,如:A–>B, B–>A 这样的相互依赖关系,如果使用 #import 来相互包含,那么就会出现编译错误,如果使用 @class 在两个类的头文件中相互声明,则不会有编译错误出现。

所以,一般 @class 是放在 interface 中的,只是为了在 interface 中引用这个类,把这个类作为一个类型来用的。在实现这个接口的实现类中,如果需要引用这个类的实体变量或者方法之类的,还是需要 import@class 中声明的类进来。使用 @class,只能用来定义变量,不能继承,也不能调用该类的方法和变量。使用 #import 则可以进行。

简而言之,是为了编译器能大大提高 #import 的替换速度。

对常用工具类进行打包(Framework/.a)

常用的工具类一般不会有改动,我们可以提前将这部分模块打包成 Framework 或静态库。这样编译时这部分代码不需要重新进行编译了。

常用头文件放到预编译文件里

Xcode 中 .pch 文件是预编译文件。这里的内容在执行 Xcode Build 之前就已经被预编译,并引入到每一个 .m 文件中。

编译器选项的优化

控制 dSYM 文件的生成

Debug 模式下,不生成 dSYM 文件。dSYM 文件内存储的是调试信息,在 Debug 模式下,我们可以借助 Xcode 和 LLDB 完成调试,不需要生成额外的 dSYM 文件。这样可以提高一部分的编译速度。

编译器优化

Debug 模式下,关闭编译器优化。

Xcode 11 中已默认关闭。

参考内容:

评论