当前位置:AIGC资讯 > AIGC > 正文

Monorepo 解决方案 — 基于 Bazel 的 Xcode 性能优化实践

背景介绍

书接上回《Monorepo 解决方案 — Bazel 在头条 iOS 的实践》,在头条工程切换至 Bazel 构建系统后,为了支持用户使用 Xcode 开发的习惯,我们使用了开源项目 Tulsi 作为生成工具,用于将 Bazel 工程转换为 Xcode 工程。但是在使用的过程中,我们发现了一些问题,其中影响较大的是,

  • Xcode 工程卡顿:对于头条这种大型项目来说,Xcode 卡顿一直是本地研发的痛点问题,在切换 Bazel 构建系统之后卡顿现象明显加剧。
  • Xcode 功能支持受限:Tulsi 支持的功能有限,很多功能都年久失修,并未持续适配 Bazel 与 rules 的更新。

在做了一些前期调研后,我们发现 rules_xcodeproj 提供了更好的解决方案。rules_xcodeproj 是一个由一系列 Bazel rules 组成的开源项目,使用它可以从一个 Bazel 工程生成对应的 Xcode 工程,实现在 Xcode 中写代码同时使用 Bazel 进行真正的构建任务,相比于 Tulsi,rules_xcodeproj 在以下几个方面有着更为明显的优势。

  • Xcode 工程更加流畅: 头条工程迁移到 rules_xcodeproj 后,工程首次冷启、二次启动和文件增删操作的时间有了明显缩短,工程卡顿情况也明显好转。
  • Xcode 功能支持度更高: rules_xcodeproj 对 Xcode 的支持更全面(包括单元测试、SwiftUI Previews),能够更好地满足我们的需求。
  • 社区更加活跃:随着 rules_xcodeproj 在 2023 年 2 月份发布正式版,Tulsi 项目也正式宣布停止维护,这意味着对于新版本的 Bazel,Tulsi 将不再提供适配和支持;同时 rules_xcodeproj 几乎每月都会更新一个中版本,对于后续适配 Bazel 更新的成本会更低。
  • 更符合 BitSky 的演进路线:由 Bazel 驱动的工程生成方式更符合 BitSky 的 Bazel Native 演进路线,可以完全在 Bazel 环境下生成工程。

因此,我们将 Xcode 生成工具从 Tulsi 迁移到了 rules_xcodeproj,并对 BitSky 工具链进行了适配。适配过程中,我们在修复 rules_xcodeproj 索引问题的同时,对工程结构进行了一些优化,进一步提升了工程流畅度。头条 iOS 工程的测试数据如下:

测试设备 MacBook Pro,芯片 M1 Pro,内存 32GB

ps. 本文介绍均基于 rules_xcodeproj 1.4.0 版本,build with bazel 模式

pps. 由于 Xcode 主线程的卡顿较难监测,此处用主动操作的执行时间衡量流畅度


Tulsi

rules_xcodeproj

工程首次冷启

47s

16s

二次启动

33s

12s

文件操作

新增 20s

删除 23s

新增 8s

删除 6s

下面来看下我们具体做的适配工作。

适配过程

从 Tulsi 迁移到 rules_xcodeproj 后,我们发现 Xcode 卡顿有明显改善,仔细分析发现 Tulsi 工程的卡顿主要有两方面原因:

  1. 头条工程中组件数量多、依赖关系复杂:Tulsi 的索引方案需要 Xcode Target 之间保留这些依赖关系,但这些依赖关系并不会被 Xcode 构建消费,却增加了 Xcode 对工程进行解析和计算的成本。

图中 Pod X_A 与 Pod X_B 为 Pod X 分别在 App A 与 App B 下被引用的 Target

  1. 全源码构建:我们在切换 Bazel 时还推进了工程的全源码构建,源码数量大幅增加,也给索引任务带来更大的压力。

接下来详细说明整个分析和适配过程,带大家更全面地了解我们结合 rules_xcodeproj 在 Xcode 背后做了什么。

在 Xcode 中开发时主要会用到三部分功能:构建、索引和调试。而 Xcode 并不能直接理解 Bazel 项目的工程文件(BUILD 和 WORKSPACE),所以我们需要通过工具(rules_xcodeproj 或 Tulsi)将 Bazel 的工程文件转换为 Xcode 可以理解的 .xcodeproj来支持这些功能正常工作。

各功能的适配点如下表所示。

rules_xcodeproj 原生方案的调试模块基本能正常工作,而索引和构建两个模块中我们对原生方案的改造较大,因此本文主要对这两个模块进行展开介绍。

索引

前面提到支持 Xcode 索引功能需要提供各个源文件的编译参数,rules_xcodeproj 实现这一点的工作流程是:

  1. rules_xcodeproj 在 Bazel 的分析阶段获取到源码文件和编译参数;
  2. 用这些信息创建 Xcode Target 的 Compile Sources 和 Build Setting 产出工程文件 .xcodeproj
  3. Xcode 加载工程文件,获取源码文件对应的索引参数,调用 clang 或 swiftc 执行索引命令。

迁移过程中我们注意到 rules_xcodeproj 工程的索引在多 Target 共用源文件的场景存在语法高亮异常的问题,对比 Tulsi 工程的处理方式后我们得出两个结论:

  • 索引异常是由于 rules_xcodeproj 移除了所有 Library Target 间的依赖导致的;
  • Tulsi 工程中 Library Target 间的依赖关系正是导致 Tulsi 工程更为卡顿的关键原因。

后文会先分析依赖关系在索引中发挥的作用,以及 rules_xcodeproj 如何处理依赖关系移除带来的问题,然后介绍我们如何通过源码合并方案解决 rules_xcodeproj 索引方面的缺陷。

依赖关系在索引中的作用

这里提到的“依赖关系”主要是指 Xcode 工程文件(.xcodeproj,准确来说是其中的project.pbxproj文件)中记录的 Xcode Target 之间的依赖关系。

需要明确的是这部分依赖关系只会在 Xcode 索引过程被用到,被移除后也只会影响 Xcode 的索引功能。构建时用到依赖关系 Bazel 会从 BUILD 文件中获取,不会关心 .xcodeproj 中的信息。

这些依赖关系在 Xcode 中的表现形式如下图所示,既有直接在 Build Phases 的 Target Dependencies 中声明的依赖,也会对声明在 Link Binary With Libraries 中 Target 产生依赖。

概括来说依赖关系在 Xcode 的索引过程中发挥着以下两方面作用:

  • 正确的中间产物生成顺序:clang/Swift Module 中间产物的生成需要依赖关系来确定构建顺序;
  • 正确的多 Target 编译参数:多 Target 共用源文件时应用正确的编译参数,以便于正确地高亮代码。

下面分别对其进行展开介绍。

中间产物生成顺序

以一个 Swift 组件为例,当它被 OC 组件引用时,需要生成一个 XX-Swift.h 文件,把方法和声明暴露给 OC 组件,当它被其他 Swift 组件引用时,也需要生成一个 swiftmodule 文件供其他 Swift 组件引用。XX-Swift.h 和 swiftmodule 并不是原始的源码文件,也不是最终的二进制产物,是构建时的中间产物。在 Xcode 对一个组件的源代码索引时,需要这个组件的依赖组件已经准备好中间产物供索引时消费。这里,我们先分析下 Xcode 是怎样解决这个问题的。

首先,Xcode 通过 Target 来描述产物是如何构建出来的,每个 Target 拥有自己的 Build Phases  Build Settings(通常一个组件对应一个 Target)。Build Phases 中的 Compile Sources 记录了构建这个 Target 需要编译哪些源码文件。Build Settings 里记录了构建这个 Target 时的各种配置,这其中就包括了编译参数。Xcode 可以从 Build Settings 里去获取编译参数,然后对 Compile Sources 中记录的源码文件进行索引编译。

一个 Target 引用另外一个 Target 时,需要将依赖关系添加到 Build Phases  Target Dependencies 中。Xcode 在构建时会根据这些依赖关系来决定构建 Target 的顺序,保证一个 Target 构建时,它依赖的 Target 已经完成构建。

索引时也是类似的处理,Xcode 有一个 Index Build 的阶段,在这个阶段也会根据依赖关系按照顺序去触发 Target 的 Build Phase,完成之后才会开始索引这个 Target 的源码文件。

这里有一个例子,SwiftDemo 依赖了 HelloLib,两个 Target 均为 Swift 组件。

在对 SwiftDemo 的源码文件进行索引编译之前,会先触发 Index BuildIndex Build 时根据依赖关系,先 Build HelloLib,这个时候会进行 Compile Swift source files,参数中包含 -emit-module -emit-module-path /path,最终会生成 swiftmodule 。

如果将 HelloLib 从 SwiftDemo 的 Target Dependencies 中移除,在 SwiftDemo 的 Index Build 时,不会先 Build HelloLib,并且在 HelloLib 的 Index Build 中,也不会生成 swiftmodule。

可以看出,Xcode 正是通过依赖关系来保证构建时的顺序,索引也依赖构建顺序来保证 Module 中间产物的生成时机。

在 Tulsi 生成的 Xcode 工程中,依然保留了 Target 之间的依赖关系,用于解决索引的中间产物生成问题。这里就不做过多介绍。

而在 rules_xcodeproj 生成的工程中,Target 之间的依赖关系是完全去掉的,每个 Target 都有且仅有一个额外添加的依赖 BazelDependencies。对于 Module 中间产物的处理,在生成的 Xcode Target 中,我们可以看到这样一条 Build Phase

原理是在 Index Build 阶段,执行到这 Target 时去跑一个 shell 脚本。

以 NewsInHouse 这个 Target 为例,这个脚本里经过一系列的处理,最终会去调用这样一条 Bazel Build 命令,

在这条命令中有一些关键信息:

@rules_xcodeproj_generated//generator/xcodeproj:xcodeproj

Bazel Build 的 Target,可以认为是我们使用 rules_xcodeproj 定义的生成工程的 Bazel Target。

bc //Article:NewsInHouse applebin_ios-*

在上述 Target 里 rules_xcodeproj 添加了一些 OutputGroupInfo。Bazel Build 时可以通过 --output_groups 参数指定输出产物。

这一条 output group 对应的是 //Article:NewsInHouse 及其依赖 Target 的产物中, swiftmodule 之类的编译依赖部分。

bg //Article:NewsInHouse applebin_ios-*

这一条 output group 对应的是 //Article:NewsInHouse 及其依赖 Target 的输入文件中的非源文件的部分,比如编译时依赖的 hmap 。

这样的 Bazel Build 命令可以在 Index Build 时将 hmap 和 swiftmodule 之类的索引中间产物生成出来,然后再索引具体文件时就不会因为缺少这些中间产物而失败。

并且这里的依赖关系是由 Bazel 去处理的,不是必须像 Xcode 原生机制那样,按照依赖关系来决定 Index Build 中 Target 的顺序。

所以仅从 "Module 中间产物" 这方面来说,Xcode 中的依赖关系并不是必需的。

多 Target 编译参数

除了中间产物生成之外,依赖关系在多个 Target 共用源文件时的编译参数计算也发挥着作用。这里的“编译参数计算”是指当一个源文件被不同 Target 引用时,应用的编译参数可能不同的情况。这么介绍比较抽象,来看下具体的例子:

下面 Demo 工程中有两个 App Target:AppA 和 AppB

  • 在两个 App Target 的 Build Settings 中分别注入了宏IS_APP_AIS_APP_B

  • 有一份公共的代码文件 public.m 分别被添加到两个 App Target 的 Compile Sources 中。

  • public.m 内用预编译宏隔离了存在差异的逻辑
  • 随着我们切换构建的 App Scheme,由于编译参数的差异,宏作用域中高亮的代码区域也会随之变化(如下图)。

此时的工程结构如下图所示,Xcode 可以通过选中的 AppA Scheme 获取到 AppA Target 的 Build Settings(图中红线路径),正确地传递编译参数-DIS_APP_A=1

实际的情况会复杂一些,因为工程的组件化建设将代码下沉到了一个个组件内,而非直接与 App Target 关联。此时同一份代码文件在不同 App Target 内的索引参数计算,则是通过 Target 之间的依赖关系实现的。

对应到 Xcode 中,Xcode 可以通过 App Target -> Library Target 的依赖关系,应用对应的 Build Settings 生成索引。

此时的工程结构则变成了下图的模式,Xcode 依然可以通过 AppA Target 与 PublicLibraryA Target 的依赖关系应用正确的编译参数(图中红线路径)。

Xcode 原生工程和 Tulsi 生成的 Xcode 工程都是通过这种依赖关系来保证编译参数的正确计算的。

而 rules_xcodeproj 生成的工程完全移除了 Target 之间的依赖关系,转而给所有 Target 都添加了对 BazelDependencies 的依赖(如下图所示)。

从图中可以看到,在缺少 AppA Target 对 PublicLibraryA Target 依赖的情况下,对于同时被 PublicLibraryA 和 PublicLibraryB 引用的 public.m ,Xcode 无法感知应该应用哪个 Target 中的编译参数(图中红线路径无法关联 AppA Scheme 与 public.m)。此时 Xcode 触发索引时应用的 Build Settings 是固定的,不会随着构建 App Scheme 切换而更改。

具体的表现当构建目标从 AppB 切换到 AppA 时,IS_APP_B宏中的代码仍然会展示为高亮,而不会随之切换,从而给开发者带来困惑。

对于这个问题,rules_xcodeproj 可以通过构建索引解决,因为依赖信息在 Bazel 侧(BUILD 文件中)是完整的,所以触发构建后可以让代码高亮正确展示。

但由于编辑索引使用的参数是 Xcode 从文件所属的 Library Target 的 Build Settings 中获取的,因此在代码编辑过程中仍然会出现高亮错误的问题。

构建索引:指在构建过程中输出索引产物,需要通过 index-import 导入 Xcode 缓存目录(Derived Data)下供 Xcode 消费。

编辑索引:在代码编辑过程中实时生成的索引,在内存中消费索引结果,不会将产物写入磁盘。

在这个场景下,rules_xcodeproj 移除依赖的做法是有缺陷的

那么我们要在 rules_xcodeproj 恢复 Target 间的依赖关系么?

答案是不需要。首先,前文有提及大量复杂的依赖关系会导致 Xcode 卡顿,不应恢复;其次,要解决这类代码高亮错误的问题,需要的其实并不是所有 Target 之间的依赖关系,而是源码文件当前构建 App Target 的关系。

回顾一下 Demo 工程最简单的结构,当源码文件直接被对应 App Target 引用时,是不需要 Library Target 间的依赖关系来建立联系的。

基于这一思路,我们将所有 Library Target 的源码合并到了对应 App Target。当然,直接合并源码以后索引并不能正常工作,需要对受影响的功能点进行适配,这些适配将在下一节源码合并方案中展开介绍。

源码合并方案

索引参数接管

将所有源码合并至 App Target 虽然能解决文件与 App 的关联问题,但各个 Library Target 编译参数是不同的,聚合之后不同 Target 下源文件的参数就无法通过 Build Settings 区分了。

对于这个问题,我们是通过 XCBBuildServiceProxy 接管索引参数计算解决的。

索引构建时,Xcode 会先将文件所属 Target 的 Build Settings 发送给 XCBBuildService 处理成编译器理解的参数,再交给 SKAgent 触发编译器进行实际编译行为。而我们在 XCBBuildServiceProxy 的基础上实现了 BitSkyXCBBuildService,可以拦截 Xcode 发给 XCBBuildService 的请求,通过 Bazel aquery 查询到具体文件的编译参数,直接返回给 Xcode 完成后续的索引构建行为。

完成源码合并索引参数接管这两步改造以后,工程结构如下图所示。可以看到 AppA Scheme 能够直接通过 AppA Target 关联到  public.m(图中红线),从而正确地应用编译参数,高亮对应代码块。rules_xcodeproj 移除依赖关系的副作用也完全被修复。

Library Target 移除

完成源码合并以及索引参数的接管之后,Library Target 中的主要信息( Build Settings 和源码)都不再有意义了,是否能将这些 Target 信息直接移除呢?

经过梳理,Library Target 主要有以下三个作用,在完成源码合并以及索引参数接管后,仅需对“Module 中间产物生成”进行一些改造即可将几百个 Library Target 的信息进行移除,大幅精简工程文件的内容。

Library Target 作用

说明

适配方案

触发 Xcode 索引

Xcode 只会对添加到 Build Phase - Compile Sources 中的源码文件生成索引

将源码添加到 App Target 的 Compile Sources 中有相同的效果;

按 Target 维度隔离 Build Settings

Xcode 原生的索引功能会通过 Build Settings 生成文件的编译参数

通过 XCBBuildServiceProxy 接管索引参数请求,交由 Bazel aquery 查询具体文件的编译参数

Module 中间产物生成

在 Library Target 的 Build Phase 触发各个 Target 维度的中间产物生成

将所需产物聚合到各个 App Target 的 Build Phase 触发生成

最终的工程结构如下图所示。

整体方案上线后,头条工程文件(project.pbxproj)行数从 45w 减少至 35w,工程启动与文件操作耗时也比原来的 Tulsi 工程减少了 60% 以上


Tulsi

rules_xcodeproj(原生)

rules_xcodeproj(源码合并)

工程首次冷启

47s

22s

16s

二次启动

33s

16s

12s

文件操作

新增 20s

删除 23s

新增 13s

删除 11s

新增 8s

删除 6s

p.s. 源码合并后存在一个副作用是 Xcode Build Phase 页面加载时间会增加很多,但考虑到使用 bazel 构建后我们并不需要在 Build Phase 修改配置,这个副作用是可以接受的。

构建

rules_xcodeproj 目前提供了两种 Build 模式,分别是 "Build with Xcode" 和 "Build with Bazel"。

  • "Build with Xcode" 模式下,构建行为是由 Xcode 接管的。
  • "Build with Bazel" 模式下,构建行为是由 Bazel 接管的。

据 rules_xcodeproj 官方介绍,"Build with Xcode" 模式在 Bazel 7 下将很难支持,并且即将到来的新的增量生成模式也会放弃 "Build with Xcode" 。

所以这里主要看一下 "Build with Bazel" ,这个模式生成的工程中,宿主 Target 对应一个 XCScheme,这个 Scheme 的 Build Pre-actions 里生成一个 SCHEME_Target_IDS_FILE 用于记录 Target 的 Bazel Label。然后宿主 Target 依赖了 Target BazelDependencies,Xcode 在构建宿主 Target 之前会先构建 BazelDependencies。BazelDependencies 通过 Build Phase 去调用 Bazel Build,这个时候会解析 SCHEME_Target_IDS_FILE 获取需要构建的 Bazel Target。BazelDependencies 构建完成后,宿主 Target 的 Build Phase 里会去把相应的 Bazel 的产物拷贝到 Xcode Derivedata 目录下。

另外,在 rules_xcodeproj 的规划中,未来还会提供一种新的模式,叫做 "Build with Proxy",在这个模式下,会通过 XCBBuildServiceProxy 完全绕过 Xcode build system,由 Bazel 控制整个 build 过程。相比 "Build with Bazel" ,这个模式可以带来一些更贴近原生的 Xcode 使用体验,比如:

  • 无需添加 BazelDependencies Target
  • 可以去掉重复的 warnings/errors
  • 可以有更稳定的索引效果
  • 可以在进度条展示更多信息
  • 可以有更详细的 Build 报告

当然这种模式也存在比较大的问题

  • 在不同 Xcode 版本之间,Xcode 和 XCBBuildService 交互的 API 可能会有一些破坏性的变更,需要逐一适配
  • 需要在 Xcode 启动时注入环境变量,将 XCBBuildService 指向自定义的 XCBBuildServiceProxy

BitSky 目前采用的方案和 "Build with Proxy" 是类似的,通过 BitSkyXCBBuildService 接管 Xcode 的 build 行为。在用户点击 Build 时,BitSkyXCBBuildService 里可以从宿主 Target 的 Build Settings 里解析获取对应的 Bazel Target,然后再由 BitSky 生成调用 Bazel Build 的命令,这样可以保证 Bazel Build 的参数完全由 BitSky 控制,同时可以通过 Bazel 的 Build Event Protocol 来更好的提供 Xcode 的进度 和 Build 日志展示。

同时为了保证在打开生成的 Xcode 工程时,都能够使用 BitSkyXCBBuildService,BitSky 在生成工程同时,会生成一个 Xcode 的影子分身 BitSkyXcode。使用这个 BitSkyXcode 打开工程,无需手动注入环境变量,体验上和使用原生 Xcode 打开工程基本一致。

总结

本文主要介绍了我们将 Xcode 工程生成工具切换到 rules_xcodeproj 过程中做的一些适配和优化工作:

  • 索引方面:
  • 在分析 Tulsi 与 rules_xcodeproj 工程文件的过程中我们注意到最大的差异在于 rules_xcodeproj 移除了 Library Target 间的依赖关系,这也是 Tulsi 工程更加卡顿的罪魁祸首。
  • rules_xcodeproj 移除依赖关系后会导致多 Target 共用的源文件语法高亮异常,我们通过源码合并方案解决了这个问题,并且精简了工程文件信息,提升了 Xcode 流畅度。
  • 构建方面:
  • 我们通过 BitSkyXCBBuildService 接管了 Xcode 的 build 行为,能够更好地管理构建参数并在 Xcode 提供构建进度和日志的展示。

在完成切换之后,虽然 Xcode 代码编辑过程中的卡顿得到了明显的缓解,但本地研发的调试过程,仍然存在 Xcode 卡顿/卡死等现象,对研发同学的开发工作存在较大困扰。后续,我们将针对调试体验,从生成工程的角度做一些优化工作。

目前考虑基于 Focus Mode 的理念,从底层能力上支持研发同学仅关注与当前需求开发相关联的部分代码,比如:

  • 裁剪 Xcode 工程中需要索引的源代码;
  • 裁剪构建过程中需要执行编译源代码;
  • 裁剪调试时调试器需要加载调试信息;

另外在用户侧,通过策略智能帮助研发同学,选择和添加需要 "Focus" 的源码。

参考文档

Tulsi (https://github.com/bazelbuild/tulsi)

rules_xcodeproj (https://github.com/MobileNativeFoundation/rules_xcodeproj)

Migrating from Xcode to Bazel (https://bazel.build/migrate/xcode)

Monorepo 解决方案 — Bazel 在头条 iOS 的实践

哔哩哔哩 iOS Bazel 进化之路

用VSCode基于Bazel打造Apple生态开发环境

更新时间 2024-03-21