用 bazel 更快更稳定的构建 iOS 项目

各位 iOS 工程师你们好,不知道大家有没有体会过被 Xcode 支配的恐惧。Xcode 作为 iOS 开发的首选 IDE,功能还是很全面很强大的,但是在面对大型工程时真的会有点力不从心。

首先就是巨大的 Xcode 工程文件,真的是我见过的最复杂的工程文件,动不动上 10MB 的 size。如果你和另一个同事同时改动了工程的结构,解决冲突的人应该是痛不欲生的。Xcode 本身对大工程的支持也不是那么好,常常在早上打开工程,到了下午 Xcode 还在做索引,无法正常的进行代码提示和跳转也是家常便饭。

随着项目变大,大家也不可能一直在同一个工程里面工作。Xcode 提供的解决方案是建立一个 workspace,建立大量子工程,然后工程嵌工程嵌工程嵌工程,宛如套娃一般无止尽。如果大家有时想要二进制中间文件来加快编译,有时又想要展开源代码看一下子工程的代码逻辑,那么也只能手动来回修改工程文件来满足不同的需要。

因为以上种种原因,iOS 工程师应该或多或少用过 CocoaPods、Carthage 这类包管理工具。无论是代为生成 Xcode 工程文件,还是产出编译好的 framework 文件,都给 Xcode 工程的管理提供了便利性。

先来说说 CocoaPods,大部分使用 CocoaPods 的公司都是走上了建立私有 spec 的路,一方面是一些内部代码不方便公布到 GitHub,一方面是 GitHub 的开源库需要进行一定的修改才能完全符合项目的使用需要。代码管理的问题搞定了之后,就是优化编译速度了。通常大家会为同一个库提供源代码和 framework 两个版本,这样就可以修改 Podfile 后 pod install 快速切换源代码和二进制两种引用方式,不关心代码实现细节的同事就可以用二进制来加速编译了。

再来说说 Carthage,Carthage 相比较 CocoaPods 来说主要特点是去中心化,对原工程侵入更低,更轻量一些。因为 Carthage 本身不重度依赖 GitHub 或者其它中心化设施,所以大家可以更自由的规划模块的发布和引入方案。对于工程结构不经常大改的工程来说,大部分情况下只用它就够了;对于复杂点的工程用 xcodeproj 脚本做一些简单的工具,基本也就能满足工作需要了。

但是到最后随着工程体量的增加,编译复杂度提高,沟通成本增加,CocoaPods 和 Carthage 终归也会暴露出很多问题。比如一个工程里引用上百个 pods,pod install 一次就两三分钟。比如模块间的依赖关系过于复杂导致的依赖地狱,导致某些底层库的版本升级成为不可能。为了加速编译和代码权限隔离,大部分代码已经打包成二进制文件,基础库无法知晓上层的调用方式,上层无法知道基础库的内部逻辑,导致双方修改代码时均无法预估完整的影响面。特别是面对 ObjC 这种动态语言,风险更是指数级上升。

那么有没有一个更好的方案可以解决这些问题呢?bilibili 把目光放到了 google 开源的 bazel 上,并且用它找到了大部分答案。

bazel 的简介

bazel 是 google 开源的一套的构建工具,它的愿景就是让编译和测试更快更稳定,拥有超强的多平台和多语言兼容性,面对多大的工程都得心应手。google 早就在内部使用这套系统了,不过用的是 bazel 的前身 blaze,blaze 甚至可以做到所有源码都在服务器上,大家可以很方便的提交代码然后远程编译和测试。为什么我们叫它“大仓”?就是因为工程内除了一些三方 SDK,所有的公司代码都可以整合放在一个仓库里,甚至于服务端、客户端和前端的代码都可以一起放在一个仓库里,因为他们都可以用 bazel 来编译,就是这么强大和自信。在没有 bazel 的时代把所有代码都放在一个仓库里,然后让工程师们拉这么多代码在自己本地全量编译就是个梦,所以 bazel 究竟做了什么使得这个梦可以成真呢?

灵活并强大的编译规则

bazel 使用 BUILD 文件来组织工程,每一个 BUILD 文件类似于 Xcode 里的一个 project。拿一个比较常见的开源库 AFNetworking 为例,把 AFNetworking 编译成一个独立库只需要这么一段:

objc_library(
    name = "AFNetworking",
    srcs = glob(["AFNetworking/*.m"]),
    hdrs = glob(["AFNetworking/*.h"]),
    sdk_frameworks = ["CoreGraphics", "SystemConfiguration", "Security", "CoreServices"]
)

是不是觉得和 pod spec 有点相似,而且更加精简。BUILD 文件是一个 pod spec 和 Podfile 的结合体,不仅描述了如何编译一个库也描述了库之间的依赖关系。因为全部代码都是在本地路径下组织的,不需要描述一些发布信息和版本信息之类,所以它的内容会更精简些。不过 BUILD 的强大和灵活绝不仅仅只有这种程度,给大家举几个例子:

  • 更灵活精细的配置粒度:一般来说一个 Podfile 是对应一个 workspace 的,如果需要处理复杂的依赖关系需要每个人维护自己的 workspace 和 Podfile,如果一个人需要维护好几个库,估计会来回 pod install 到蛋疼。而 BUILD 文件可以放在各个子目录下,而且每个 BUILD 文件里面还可以定义多个 target,让我们对工程目录的组织有了各种可能性。举个例子,上面 AFNetworking 的 BUILD 文件里,可以轻松加几行再定义各一个 AFNetworkingWithUIExtension,供有需要的人引用。也可以定义一个 AFNetworkingTests,方便做更小粒度的单元测试保证模块稳定。在编译 AFNetworking 的时候 BUILD 文件就是 Podfile 的作用,在其它模块引用 AFNetworking 时 BUILD 文件就是 pod spec 的作用,无缝切换。虽然 BUILD 文件变多了,但是整个工程还是在一个 workspace 下的,编译时不同的 BUILD 文件以及文件里不同的 target 也可以做到无缝切换,不需要多次 pod install
  • 可以对外配置可见性:iOS 项目里的库只有 public 这么一种可见性,只要你的库放在了工程里,那么想引用它的人就可以随时引用到它。这对于一些需要收敛的功能来说很致命,比如说大家可能需要封装下 AFNetworking 加一些公共字段,如果有同事直接使用了 AFNetworking 就会丢失这些公共字段。在 bazel 里,只要给上面的 AFNetworking 库 BUILD 文件加一行“visibility = ["//visibility:private"]”,就可以将 AFNetworking 库限定为只在同一个 BUILD 文件内可见。当然如果想要定义我们的库只被指定的其它库或者指定的路径可见,这些功能用 BUILD 文件都可以做到,详情可以参照:visibility
  • 复杂的条件编译功能:大家可能遇到过有些库在 debug 包里和 release 包里行为不一致,这些一般都是用 DEBUG 宏来进行条件编译做到的。如果我们需要定义更多的差异化行为,该怎么办呢?BUILD 文件可以让我们拥有在编译指令里写 if...else... 的能力,配合不同 target 或多个编译环境不同的环境变量,我们可以轻松做到各种差异化编译。以下面的 BUILD 文件为例,我们在编译带 UI 的 app 时可以设置好 enable_ui_extension,那么编译 API 库和图片库时就会带上 UI 扩展功能;我们在编译不带 UI 的命令行工具时,就可以不做设置,这样的话编译这两个库时就不会带上对应的 UI 扩展功能。
objc_library(
    name = "BiliApi",
    srcs = glob(["BiliApi/*.m"]) + select({
        "//:enable_ui_extension": glob(["BiliApiUIExtension/*.m"]),
        "//conditions:default": [],
    }),
    hdrs = glob(["BiliApi/*.h"]) + select({
        "//:enable_ui_extension": glob(["BiliApiUIExtension/*.h"]),
        "//conditions:default": [],
    })
)
 
objc_library(
    name = "BiliImage",
    srcs = glob(["BiliImage/*.m"]) + select({
        "//:enable_ui_extension": glob(["BiliImageUIExtension/*.m"]),
        "//conditions:default": [],
    }),
    hdrs = glob(["BiliImage/*.h"]) + select({
        "//:enable_ui_extension": glob(["BiliImageUIExtension/*.h"]),
        "//conditions:default": [],
    })
)

当然 BUILD 文件的强大不止这一点,但是继续深入讲这套编译规则的话,会讲大半天都讲不完,所以我们点到为止,继续聊聊 bazel 其它的优势。

对代码和编译过程的全面控制

前面提到过,bazel 可以把整个 app 的所有代码全部展开,这自然而然的就解决了代码不一致还有依赖地狱等问题。基础库的代码想升级,做一些前后不兼容的接口调整,顺带把业务层使用基础库的代码一并修改了就好;业务层想要了解基础库的代码细节,甚至于想要扩展基础库功能来满足自己的业务需要,也都是可以做到的。这样就可以很大程度避免各种升级库的痛苦,还有各种版本不兼容导致的错误,让公司以最小的代价更快的效率向前跑。

前面还说过,服务端和客户端的代码都可以放在一个大仓库里,这样的话也会带来一些优势,例如一些基于 protobuf 的接口可以轻易的做到各端同步。另外之前在 Xcode 里使用 protobuf 需要使用工具先把 pb 文件翻译成 ObjC 或者 Swift 源代码,然后再把这些源代码添加到工程里,其实还是有些麻烦的。在 bazel 里当然不用这么麻烦,直接使用 pb 对应的规则就可以一条龙编译,可以直接把 pb 文件直接当做源代码进行编辑,完全不用关心 pb 转成 native 代码的过程。不止是 pb,如果动手能力强的话,完全可以自定义一套 dsl,然后制作自己的翻译工具和编译规则,工程里就可以用到各种形形色色的“源代码”。

用了 bazel 之后也完全不用打开整个工程来编辑,只打开和编辑需要关注的部分,然后进行编译调试就好了。你可以使用自己喜欢的编辑器,vim、Sublime 或 AppCode 任君选择,当然肯定也少不了 Xcode。如果大家还是习惯 Xcode 的整个编辑和调试环境,那么 bazel 有提供配套工具 Tulsi,用它可以根据 bazel 的 BUILD 文件生成对应的 Xcode 工程,然后接下来像往常一样用 Xcode 开始我们的工作就好了。Tulsi 生成的工程保持了 BUILD 文件的各种优势,你可以只把自己关心的代码生成为一个 Xcode 工程,但是进行编译调试的时候运行的是整个工程,因为虽然我们用着 Xcode 的外壳,但是编译的工作还是交给 bazel 来完成的。这样的话,就再也不怕 Xcode 打开大工程后卡死,或者无法进行代码补全和跳转啦。

那么代码全部展开之后,编译起来岂不是会特别的久?这点也不用担心,因为 bazel 实现了强大的编译 cache 共享功能。在建设好了 cache 服务器之后,工程师们在用 bazel 编译的时候会和 cache 服务器进行通讯,服务器已经有编译缓存的话会直接从服务器拉取到对应的编译产物,那么只要你的网速够快,百万行代码级别的项目,分分钟编译好不再是梦。如果我们修改了一些文件呢?bazel 的增量编译算法十分的优秀,因为 BUILD 文件的强大,使得代码库拆分的更精细,库之间依赖关系也更加清晰,可以保证仅仅编译修改过的文件以及相关依赖,最终保证了超高的增量编译效率。一点没有命中编译缓存的时候又怎么办呢?bazel 也是支持分布式编译的,一个工程 8 台机器一起编译,那就是享受接近 8 倍的编译速度。不过目前来说bilibili 还没有准备这么多机器做分布式编译的部署,希望在以后真的需要大幅提高编译速度的时候能用得上这技术。

我们对整个工程的掌控力提升不仅仅是在代码编辑和编译提速方面,我们可以做的事情还有很多。比如说 bazel 可以更方便的自定义各种编译规则,可以方便我们加入新的 lint 规则,可以方便我们对整个工程进行统一配置等等。bazel 还提供了各种分析工具,帮助我们分析工程的依赖,快速找出被过度依赖的库,或者无人依赖的死代码。

我们为了 bazel 做出的改进

当然,说了这么多 bazel 的好处,也并不代表 bazel 就是完美适用于所有人的。

我们自 2018 年中开始切换到 bazel 工具链到现在,算起来也两个年头了。我们在 bazel 的基础上制作了很多一键安装脚本之类,方便大家快速的准备环境开始工作,降低大家使用 bazel 的成本。但是因为 bazel 不再只是依赖本地的环境,编译过程中遇到的错误形形色色,甚至于多次编译遇到的错误可能还会不一样,导致解决问题的难度还是上升了不少。全面使用 bazel 的公司也很少,特别是国内的公司少之又少,相关的资料也比较匮乏。客观来说,bazel 的学习和使用成本还是比 CocoaPods 高不少的。

在实际的使用过程中我们也发现了其它的问题,比如大家的代码都放在一个仓库里,那代码的写入权限怎么管理?我们开发了机器人来处理 merge request,保证拥有对应代码权限的 owner 才能指挥机器人合入代码。还为机器人添加了多人合作的规则和处理逻辑,因为有的 merge request 是需要跨组合作的。因为 bazel 只是要求代码都组织存放在一个路径下,并没有规定所有的代码必须来自同一个 git 仓库,所以我们还开发了多 git 仓库代码同步工具,用来满足多个 git 仓库合作开发的需要。我们借助种种方案,极大可能的保留了 bazel 的优势也实现了我们的需求。我们甚至利用到了 bazel 的远程 cache 功能,针对没有代码权限的仓库直接使用远程 cache 来保证编译通过,做到真正的代码和二进制产物无缝切换。

一些其它问题

bazel 虽好,但是它是基于一个前提的,那就是大家的代码是可以整合到一个统一的“大仓”,并且大家的编译环境和参数都是一致的。在大家的代码风格不统一或者使用的工具链不统一的情况下,反而会导致大家都很难受。如果一整个 app 的上层下层都使用 bazel 的话,那么 BUILD 文件里加一行 deps 就可以搞定引用问题。如果一个 app 的上层是使用 CocoaPods 甚至是直接用 Xcode 修改工程的,而下层是用 bazel 的,那要么下层需要专门编译出一堆静态库动态库甚至 cocoapods spec 产物供上层使用,或者要么上层也整体迁移到 bazel。但是说起来一整个工程迁移到 bazel 不是那么简单一件事,它不仅仅是依赖客户端的改动,还依赖了一堆服务器建设和工具链建设,bilibili 也是用了几个月去准备才逐步切换到了 bazel 环境。

目前 GitHub 上大量的开源库已经都支持了 CocoaPods 和 Carthage,但是支持 bazel 的寥寥无几,所以我们需要去写一些 BUILD 文件来支持这些开源库,好在现在有对应的 git repo rules 可以帮助我们比较方便的引用这些库。如果开源项目都可以默认支持 bazel 的话那么一定会是一个 bazel 的美好未来,希望会有更多的人加入到 bazel 阵营来。

说到最后,bilibili 算是国内比较早吃螃蟹的公司了,我们在 bazel 上走了很多弯路踩了很多坑,也从 bazel 上获益良多。希望以后可以在这条路上走得更远,可以和更多的人分享我们的经验。也在此欢迎其它使用 bazel 的团队来和我们探讨 bazel,更欢迎感兴趣的各路工程师来我们这里体验“大仓”的快感并和我们一起建设“大仓”(加入我们)。感谢大家看到这里~