iOS探索:iOS程序的Build过程

注1:本文由破船译自The Build Process

注2:

1
2
3
4
5
6
7
8
9
10
本文将轻度解密Xcode build日志,还原iOS程序build的过程。
另外将介绍如何对build过程的控制,进而定制出自己希望的流程,
例如通过Build phase的定制,给app icon打水印(包括版本号和日期)。
通过对工程文件的解读,为你揭开工程文件(.pbxproj)与
build settings的关系。这对于解决工程文件的merge冲突非常有帮助。
PS:实际上各开发平台的build过程都比较相似,如果你熟悉了
某个平台的build过程,那么同样的原理也适用于别的平台。
可以说换汤不换药,本质是一样的。

下面开始吧:

本文目录如下所示:

  1. 解密Build日志
  2. Build过程的控制
  3. 工程文件
  4. 小结

当我们进行开发时,如果需要运行程序,只要在Xcode中点击运行按钮(这个按钮看起来有点像在播放音乐),过一会,我们的程序就会运行在设备或者模拟器上了,看似简单的操作过程,不过在这背后隐藏了许多步骤!当然,有时候也会遇到一些错误。

本文,我将从稍微高一点的角度来解读整个Build过程,并探索一下Build过程与Xcode界面上显示的project setting有多大关系。当然,为了更加深入的了解每一步实际执行的任务,我会适当的引入一些别的文章。

###解密Build日志

为了了解Xcode build过程的内部工作原理,我们首先把突破点放在完整的log文件上。打开Log Navigator,从列表中选择一个Build,Xcode就会通过很漂亮的一种格式将log文件显示出来。如下图所示:

默认情况下,XCode会把大量的log信息隐藏起来,你只需要点击选中某条log,然后点击右边的展开按钮,就能看到该条log的详细信息了。当然,你也可以选中一条或者多条日志,然后通过Cmd+C,就能将相关的所有文本信息拷贝到粘贴板上。另外,还可以通过菜单Editor中的Copy transcript for shown results将所有的log信息复制到粘贴板上。

在我这儿的示例中,将近有10000行log信息(当然,大多数信息是由OpenSSL带来的,并非来自我们的代码)。下面我们就开始吧!

首先,你可能会发现输出的log信息,被工程中对应的target分割开了:

1
2
3
4
5
6
7
8
9
10
11
12
13
Build target Pods-SSZipArchive
...
Build target Makefile-openssl
...
Build target Pods-AFNetworking
...
Build target crypto
...
Build target Pods
...
Build target ssl
...
Build target objcio

在我这的工程中有好几个依赖项:如包含在Pods中的AFNetworking 和 SSZipArchive, 已经以子工程形式存在的OpenSSL等。

针对这里的每个target,Xcode都会执行一些列的操作,以将相关的源代码转换为机器可读的二进制(于所选平台相关)。我们来亲密接触一下第一个targetSSZipArchive吧。

在这个target的log输出中,我们可以看到每个任务执行的详细情况。例如,第一个是处理一个预编译头文件(为了增加其可读性,我省略了许多细节):

1
2
3
4
5
6
7
8
9
10
11
12
13
(1) ProcessPCH /.../Pods-SSZipArchive-prefix.pch.pch Pods-SSZipArchive-prefix.pch normal armv7 objective-c com.apple.compilers.llvm.clang.1_0.compiler
(2) cd /.../Dev/objcio/Pods
setenv LANG en_US.US-ASCII
setenv PATH "..."
(3) /.../Xcode.app/.../clang
(4) -x objective-c-header
(5) -arch armv7
... configuration and warning flags ...
(6) -DDEBUG=1 -DCOCOAPODS=1
... include paths and more ...
(7) -c
(8) /.../Pods-SSZipArchive-prefix.pch
(9) -o /.../Pods-SSZipArchive-prefix.pch.pch

在build过程中,每个任务都会出现类似上面的这些log信息,我们就通过上面的log信息了解详情吧。

  1. 每个log都会以这样的一行来对任务进行描述。
  2. 接着下面带缩进的这3行会被输出。此处,修改了工作路径,并对PANG和PATH环境变量进行设置。
  3. 这里才是真正焕发出魔力的地方。为了处理一个.pch文件,调用了clang,并且附带了大量的选项。这行log信息显示出了所有的调用参数,我们稍微看几个参数吧:
  4. -x标示符用来指定语言,此时是objective-c-header
  5. 目标架构指定为armv7
  6. 标示#defines的内容已经被添加了。
  7. -c标示符用来告诉clang具体如何运行。-c意味着:运行预处理器、词法分析、类型检查LLVM的生成和优化,以及特定target相关汇编代码的生成阶段,最后,运行这个汇编代码以生成.o目标文件。
  8. 输入文件。
  9. 输出文件。

虽然有大量的log信息,不过我不会把每个log信息都做详解。我们的目的是让你了解在build过程中,完整的了解什么工具被调用,以及都使用了什么参数。

针对这个target,虽然只有一个.pch文件,但实际上这里对objective-c-header文件处理了两次。下面来看看log信息告诉我们的详细情况:

1
2
ProcessPCH /.../Pods-SSZipArchive-prefix.pch.pch Pods-SSZipArchive-prefix.pch normal armv7 objective-c ...
ProcessPCH /.../Pods-SSZipArchive-prefix.pch.pch Pods-SSZipArchive-prefix.pch normal armv7s objective-c ...

可以看到,build了两种target:armv7和armv7s,所以clang为每种架构处理了一次这个文件。

紧接着预编译头文件的处理之后,我们可以找到SSZipArchive target相关的其它一些任务:

1
2
3
CompileC ...
Libtool ...
CreateUniversalBinary ...

通过名称,我们基本能够知道个大概:CompileC用来编译.m和.c文件,Libtool根据目标文件创建出一个库,而CreateUniversalBinary则将上一阶段产生的两个.a文件(对应着两个不同的架构)合并为一个通用的二进制文件(可以运行在armv7和armv7s上)。

上面这些类似的步骤会出现在工程中所有其它的依赖项中。

当所有的依赖项都准备好了,就可以开始构建我们程序的target了。针对该target输出的log信息包含了之前没有出现过的内容,这些内容非常有价值:

1
2
3
4
5
6
7
8
9
10
11
12
PhaseScriptExecution ...
DataModelVersionCompile ...
Ld ...
GenerateDSYMFile ...
CopyStringsFile ...
CpResource ...
CopyPNGFile ...
CompileAssetCatalog ...
ProcessInfoPlistFile ...
ProcessProductPackaging /.../some-hash.mobileprovision ...
ProcessProductPackaging objcio/objcio.entitlements ...
CodeSign ...

在上面的任务中,可能Ld不能一眼看出是什么意思,此处它是一个linker工具,跟libtool类似。实际上libtool会简单的调用ld和lipo。而ld用来构建可执行文件。更多编译和链接相关的文章可以看看 DanielChris写的。

上面这些步骤,实际上都会调用相关的命令行工具来做实际的工作,这跟之前我们看的步骤ProcessPCH类似。至此,我将不会继续介绍这些log信息了,我将带来大家从另外一个不同的角度来继续探索这些任务:Xcode是如何知道哪些任务需要被执行?

###Build过程的控制

当你选中在Xcode 5中的一个工程时,project editor会在顶部显示出6个tabs:General, Capabilities, Info, Build Settings, Build Phases 以及 Build Rules。如下图所示:

其中最后3项与build过程的相关度最大。

####Build Phases####

Build Phases代表着将代码构建为一个可执行文件的规则。它描述了build过程中必须执行的不同任务。

首先,指定了target的依赖项。这将告诉build系统在当前target可以build之前,必须先build target的依赖项。实际上这并不属于真正的build phase,在这里,Xcode只不过将其与build phase显示到一块罢了。

接着是一个CocoaPods相关的脚本需要在build phase执行——更多CocoaPods相关信息可以查看Michele的文章

然后在Compile Sources中指定了所有必须进行编译的文件。更多相关内容我们将在build rules和build settings中研究。在Compile Sources中指定的文件将根据这些rule和setting被处理。

当编译结束,下一步就是将所有的内容链接到一块:Link Binary with Libraries。在这里面列出了所有的静态库和动态库,这些库会与上面编译阶段生成的目标文件进行链接。实际上静态库和动态库的处理过程有非常大的区别,相关内容可以参考Daniel的文章 Mach-O executables

当链接完成之后,build phase中最后需要处理的就是将静态资源(例如图片和字体)拷贝到app bundle中。需要注意的是,如果图片资源是PNG格式,那么不仅仅对其进行拷贝,还会做一些优化(如果build settings中的PNG优化是打开的)。

虽然静态资源的拷贝是build phase中的最后一步,但这并不代表build过程已经完成了。例如,还没有进行code signing(这并不是build phase考虑的范畴),code signing属于build步骤中的最后一步Packaging

####定制Build Phases####

至此,你已经完全可以掌控build phases相关内容(先不考虑默认的设置项),例如,你可以在build phases中添加运行自定义脚本,就像CocoaPods使用的一样,来做额外的工作。当然也可以添加一些资源的拷贝任务,当你需要将某些确定的资源拷贝到制定的target目录中,这非常有用。

另外你可以通过定制build phase来添加带有水印(包括版本号和commit hash)的app icon。只需要在build phase中添加一个Run Script,然后用下面的命令来获取版本号和commit hash:

1
2
version=`/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "${INFOPLIST_FILE}"`
commit=`git rev-parse --short HEAD`

然后可以使用ImageMagick来修改app icon。这里有一个完整的示例,可以参考。

如果你希望编写的代码比较简洁点,那么可以添加一个Run Script,如果一个源文件超过指定行数,就发出警告。如下代码所示,设置的行数为200。

1
find "${SRCROOT}" \( -name "*.h" -or -name "*.m" \) -print0 | xargs -0 wc -l | awk '$1 > 200 && $2 != "total" { print $2 ":1: warning: file more than 200 lines" }'

####Build Rules####

Build rules指定了不同文件类型该如何编译。一般来说,开发者并不需要修改这里面的内容。如果你需要对特定的文件类型添加处理方法,那么可以在此处天剑一条新的规则。

一条build rule指定了其应用于那种文件类型,该文件类型是如何被处理的,以及输出内容被放置到何处。比方说,我们创建了一条预处理规则,该规则将Objective-C的实现文件当做输入,然后解析文件内部的注释内容,最后再输出一个.m文件,文件中包含了生成的代码。由于我们不能将.m文件既当做输入又当做输出,所以我使用了.mal后缀,定制的build rule如下所示:

上面的规则应用于所有后缀为*.mal的文件,这些文件会被自定义的脚本处理(调用我们的预处理器,并附带上输入和输出参数)。最后,该规则告诉build system在哪里可以找到此规则的输出文件。

由于这里的输出是一个.m文件,那么build使这些.m文件会被编译处理(就如刚开始介绍的那些预处理步骤)。

在脚本中,我使用了少量的变量来指定正确的路径和文件名。在苹果的Build Setting Reference.文档中可以找到所有可用的变量。build过程中,要想观察所有已存在的环境变量,你可以添加一个Run Script build phase,并勾选上Show environment variables in build log

####Build Settings####

至此,我们已经了解到build phases是如何被用来定义build 过程的步骤,以及build rules是如何指定哪些文件类型在编译阶段需要被预处理。在build settings中,我们可以配置每个任务(之前在build log输出中看到的任务)的详细内容。

在这里,你会发现build 过程的每一个阶段,都有许多选项:从编译、链接一直到code signing和packaging。注意,settings被分割为不同的部分,大部分会于build phases有关联,有时候也会指定编译的文件类型。

这些选项基本都有不错的文档介绍,你可以在右边面板中的quick help inspector或者 Build Setting Reference中查看到。

###工程文件

上面我们介绍的所有内容都被保存在工程文件(.pbxproj)中,除了其它一些工程相关信息(例如file groups),我们很少会深入该文件内部,除非在代码merge时发生冲突,或许会进去看看。

我建议你用文本编辑器打开一个工程文件,从头到尾的看一遍里面的内容。它的可读性非常高,里面的许多内容一看就知道什么意思了,不会存在太大的问题。通过阅读并完全理解工程文件,这对于合并工程文件的冲突非常有帮助。

首先,我们来看看文件中叫做rootObject的entry。在我的工程中,如下所示:

1
rootObject = 1793817C17A9421F0078255E /* Project object */;

根据这个ID(1793817C17A9421F0078255E),我们可以找到main工程的定义:

1
2
3
4
/* Begin PBXProject section */
1793817C17A9421F0078255E /* Project object */ = {
isa = PBXProject;
...

在这部分section中包含了一些keys,顺从这些key,我们可以了解到更多关于这个工程文件的组成。例如,mainGroup指向了root file group。如果你按照这个思路,你可以快速了解到在.pbxproj文件中工程的结构。下面我要来介绍一些与build过程相关的内容。其中target key指向了build target的定义:

1
2
3
4
targets = (
1793818317A9421F0078255E /* objcio */,
170E83CE17ABF256006E716E /* objcio Tests */,
);

根据第一个id,我们找到一个target的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
1793818317A9421F0078255E /* objcio */ = {
isa = PBXNativeTarget;
buildConfigurationList = 179381B617A9421F0078255E /* Build configuration list for PBXNativeTarget "objcio" */;
buildPhases = (
F3EB8576A1C24900A8F9CBB6 /* Check Pods Manifest.lock */,
1793818017A9421F0078255E /* Sources */,
1793818117A9421F0078255E /* Frameworks */,
1793818217A9421F0078255E /* Resources */,
FF25BB7F4B7D4F87AC7A4265 /* Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
1769BED917CA8239008B6F5D /* PBXTargetDependency */,
1769BED717CA8236008B6F5D /* PBXTargetDependency */,
);
name = objcio;
productName = objcio;
productReference = 1793818417A9421F0078255E /* objcio.app */;
productType = "com.apple.product-type.application";
};

其中buildConfigurationList指向了可用的配置项,一般包括DebugRelease。根据debug对应的id,我们可以找到build setting tab中所有选项存储的位置:

1
2
3
4
5
6
7
8
179381B717A9421F0078255E /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 05D234D6F5E146E9937E8997 /* Pods.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = YES;
ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage;
CODE_SIGN_ENTITLEMENTS = objcio/objcio.entitlements;
...

buildPhases属性则简单的列出了在Xcode中定义的所有build phases。这非常容易识别出来(Xcode中的参数使用了它们原本真正的名字,并以C风格进行注释)。

buildRules属性是空的:因为在该工程中,我没有自定义build rules。

dependencies列出了在Xcode build phase tab中列出的target依赖项。

没那么吓人,不是吗?工程中剩下的内容就留给你去当做练习来了解吧。只需要顺着ID走,即可,一旦你找到了敲门,理解了Xcode中工程设置的不同section,那么对于merge工程文件的冲突时,将变得非常简单。甚至可以在GitHub中就能阅读工程文件,而不用将工程文件clone到本地,并用Xcode打开。

###小结

当今的软件是都用其它复杂的一些软件和资源开发出来的,例如library和build工具等。反过来,这些工具是构建于底层架构的,这犹如剥洋葱一样,一层包着一层。虽然这样一层一层的,给人感觉太复杂,但是你完全可以去深入了解它们,这非常有助于你对软件的深入理解,实际上当你了解之后,这并没有想象中的那么神奇,只不过它是一层一层堆砌起来的,每一层都是基于下一层构建起来的。

在这里,我们只是轻微的探究了一下build过程,当我们点击Xcode中的允许按钮时,并没必要深入了解内部具体发生了什么。只需要了解到build的过程,以及可控的一些操作顺序即可。当然,要想进一步深入了解,可以试着阅读其它一些文章。