目标

iOS 单包构建加速、支持多包并行打包

基础知识

CI、CD 在稍微有点规模的公司内部都会内建一套自己的系统。目前主流的是在 Jenkins 的基础上进行的打包系统。公司只有1个 App 的情况下一台打包机就够了,但是有多个 SDK、App 那肯定不够的,各个业务线都需要测试、上架等等,任务太多了,一台机器别人要等到花儿谢了…

分布式构建系统可解决上述问题,即一个 master 为中心,多个 slave 来进行具体的构建操作。多台执行机来进行任务的构建以及自动化脚本的执行。Jenkins 具备分布式特性,是 Master/Slave 模式(主从模式,将设备分为主设备和从设备,主设备负责分配工作并整合结果,或作为指令的来源;从设备负责完成任务,从设备一般只和主设备通信)。这个模式有2个好处:

  • 能够有效分担主节点的压力,加快构建速度
  • 能够指定特定的任务在特定的主机上进行

背景

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hqCAuK4K-1585662119830)(https://github.com/FantasticLBP/knowledge-kit/blob/master/assets/2019-12-16-candle.png)]

  • 描述现状
    我们公司的 WAES 平台下子平台 candle 是专门用来打包构建的,可以打包 iOS SDK、iOS App、Android SDK、Android App、React Native 包、H5、Node 包、React 包等等。iOS 端将 SDK、App 通过 candle 打包平台进行任务创建、排队、打包,根据任务的特点和语言去调度合适的打包机器进行打包。 现状是整体速度觉得较慢,还有加速空间。
  • 问题原因
  1. iOS 打包目前都是在使用旧的打包构建系统,所以在单包构建方面会慢一些;
  2. 在 pod install 这一步,打包机使用的 1.3.1 版本的 cocoapods 版本在进行依赖分析,它本质上是操纵打包机上全局的 git 目录,由于本质如此所以没办法多包并行打包。
  • 风险预警
  1. cocoapods 升级到最新版本,目前的 cocoapods 的相关的 ruby 脚本可能会有问题,没办法良好运行。
  2. 开启 Xcode 的新构建系统,可能会造成现有工程报错,没办法编译成功,需要改动。这些改动可能不只是主工程,也许是各个 SDK 自身的修改,所以会比较零散。

改造

单包加速

一些背景:
  1. 打包机暂时不支持各个依赖的 SDK 以静态库的方式引入。
    原因是目前 APM 监控系统不支持。因为多个静态库则会生成多个 DYSM 文件,这样子 APM 定位 Crash、ANR 等都会生成多个 DYSM 文件的信息,配套的后端在符号化处理的时候只支持一个 DYSM 的模式,所以在如此背景下打包机不支持静态库。
  2. 看到博客说可以通过 generate_multiple_pod_projectsdisable_input_output_paths 来加速构建速度,这些在本地开发过程中是可以提高构建速度的。但是在打包机这种环境下是不太适用的。因为 iOS 在打包机环境下都会执行 pod install 的过程.
install! 'cocoapods', :generate_multiple_pod_projects => true, :incremental_installation => true, :disable_input_output_paths => true
  • generate_multiple_pod_projects
    生成多个 XcodeProj。在 1.7.0 之前,cocoapods 只生成一个 Pod.xcodeproj,随着 Library 增多,文件越来越大,解析时间越来越长,在 1.7.0 之后每个 Library 都允许生成单独的 Project,提高项目的编译时间。 默认关闭
  • incremental_installation
    增量安装,每次执行 pod install 都会生成整个 workspace,现在支持只更新的 Library 编译。节省时间
  1. 另外网上的部分优化提速手段也不太适合,因为这些手段基本上只是会加快一些速度,但是不可能把一个项目的构建速度提升明显,所以这次的方案主要是单包开启 New Build System 和支持多包并行能力。
理论基础

本质上就是开启 New Build System,苹果在 WWDC 2017 中描述新构建系统的有点为:降低构建开销,尤其可以降低大型项目的构建开销。但是在新构建系统下现有的工程会报错。经过查看报错信息,基本都是在资源方面的错误(图片等)和偶尔一些 SDK 不规范造成的问题。

苹果从 Xcode 9 开始推出了新构建系统(New Build System),并在 Xcode 10 使用其为默认构建系统来替代旧构建系统(Legacy Build System)。采用新构建系统能够减少构建时间。

简要介绍一下原理,对于旧构建系统,当我们构建一个程序的时候,会明确所需要构建的所有 Target Dependencies、Link Binary With Libraries,这些 Target 之间的依赖关系,以及这些 Target 构建的顺序。采用顺序会造成多处理器系统资源的浪费,从而表现为编译时间的浪费,解决这个问题的方式就是采用并行编译,这也是新构建系统优化的核心思想。详细了解新构建系统,探究 Xcode New Build System 对于构建速度的提升。

测试实验

注意: 报错提示找不到 coderay. 可以运行 sudo gem install coderay 解决该问题。

本地 cocoapods 版本为 1.4.0,打包机环境为 1.3.1,所以方案评估有问题。这几天花时间做了对比实验,数据如下

  1. New Build System 是否可以让单包构建变快?
    cocoapods 模拟打包机环境 1.3.1。在 Legacy Build System 和 New Build System 下运行项目。
    1.3.1 不能开启 New Build System。报错信息: New Build System Multiple commands produce script phase “[CP] Copy Pods Resources”
    1.3.1 Legacy Build System 构建时间为 335.4s.
    所以尝试升级 cocoapods 继续做对比实验
  2. cocoapods 小版本升级到 1.4.0 在 Legacy Build System 和 New Build System 下运行项目(为什么选择升级到 1.4.0? cocoapods 小版本升级则改动较小,业务线可以快速享受到 New Build System 改动带来的收益)
    New Build System: 383.5s
    Legacy Build System: 302.9s
  3. 升级 cocoapods 到 1.8.0,查看在 New Build System 和 Legacy Build System 下的构建时间
    cocoapods 升级到 1.8.0 会报错,修改错误后运行对比。
    New Build System: 324.4s
    Legacy Build System: 262.2s

实验数据如下:

App

构建系统

Cocoapods 版本

Build 结果

编译时间

**App

New Build System

1.3.1

失败

~

**App

Legacy Build System

1.3.1

成功

335.4s

**App

New Build System

1.4.0

失败

383.5s

**App

Legacy Build System

1.4.0

成功

302.9s

**App

New Build System

1.8.4

成功

324.4s

**App

Legacy Build System

1.8.4

成功

262.2s

结论:从实验数据来看, New Build System 并不能单包加速。所以 New Build System 不做了。构建加速是升级cocoapods 1.8.4 带来的,并不是 new build system 带来的。后续计划分2步:

  1. 升级 cocoapods 到 1.8.4,可以体验到单包构建加速的效果。
  2. 自建 CDN。

拿自己的电脑部署脚本,当作本地打包机;拿**App App 打包,指定打包机为自己的电脑

App

Cocoapods 版本

编译时间

**App

1.3.1

8m37s

**App

1.8.4

7min47s

开启 New Build System 带来的改动

  1. SDK 中图片是通过 resource 的方式管理的,cocoapods 1.8.4 会将它打包到 Assets.car 和 App 主工程图片打包的结果一致,导致 Xcode 主工程报错,大体意思是说工程包含多个 Assets.car. 原因在于 SDK 通过 resource 管理图片,打出包所以可以使用 resource_bundles 的形式管理
  • 涉及到的 SDK:TrinityConfiguration(公共 SDK)
  • 改造点:
    注意:改变 SDK 内部修改 xcassets 文件名是无用的,Xcode 编译后查看包内容,结果还是 Assests.car
    图片使用方式改变。由之前的 resources 方式改为 resource_bundles 的形式。这样做有2个优点:解决了图片资源打包后造成 Assets.car 冲突的问题;resource_bundles 还可以解决图片访问速度的优化。
  1. 图片资源重复
    **App工程中有些图片和 理财的 SDK SdkFinanceHome 里面的图片资源重名,但是内容却不一致,需要协商改动。
  • 涉及到的 SDK:SdkFinanceHome (理财业务线 SDK)
  • 改造点:
    有2张图片在 SdkFinanceHome SDK 内重复出现2次(形状、大小一致)。App 也存在同名的图片,图形一致、尺寸大小不一致。所以需要**App业务线开发者确认,保留什么图片或者资源重命名。建议图片资源也用 resource_bundles 的形式管理
s.resource_bundles = {
'SdkFinanceHome' => ['***/Assets/*.xcassets']
}

升级 1.8.4 带来的改动点:

  1. 部分 SDK 的头文件引用方式有问题
  • 涉及到的 SDK:SdkFundWax
  • 改造点:将 FCH5AuthRouter.m 文件中关于 NativeQS 中头文件的引入方式改变下。#import <NQSParser.h> 改为 #import <NativeQS/NativeQS.h>,或者改为 #import "NQSParser.h"
"dependencies": {
    "NativeQS": "~> 1.0"
  },

注意:
因为打包机目前是源码引入编译成 .a 文件。如果是以 framework 的形式,则必须以依赖描述的方式进行调整。

新版本 cocoapods 中:

  • cocoapods 在 SDK 里面引用别的 SDK 如果 podspec 里面存在 dependencies 描述,则可以使用 #import <NativeQS/NativeQS.h> 或者 #import "NativeQS.h";如果不存在 dependencies 描述,则需要使用 #import <NativeQS/NativeQS.h>
  • 在主工程中引用 SDK 的头文件,使用 #import <NativeQS/NativeQS.h>#import "NativeQS.h" 都可以

旧版本 cocoapods 中:

  • SDK、App 主工程都可以使用 #import <NativeQS/NativeQS.h>#import "NativeQS.h"#import <NativeQS.h>
  1. 部分 SDK 的使用了未在 podspec 文件中声明的依赖,在新版本 cocoapods 下会报错(某些 SDK 由于历史原因造成新版本丢失依赖描述)
  • 涉及到的 SDK:CMRCTToast
  • 改造点:
    问题基本定位是在于, App 主工程引用的 SdkBbs2 SDK 依赖了 SdkBbs2 版本(该版本的依赖描述为 CMRCTToast (~> 0.1) )
    历史原因: 早期在做 RN SDK 封装的时候在第一个版本的时候只有某个版本的 React Native 库,所以在 0.1.0 的时候依赖的描述可以看到如下的代码
s.dependency 'React/Core', '0.41.2'
s.dependency 'React/RCTNetwork', '0.41.2'
s.dependency 'React/RCTImage', '0.41.2'
s.dependency 'React/RCTText', '0.41.2'
s.dependency 'React/RCTWebSocket', '0.41.2'
s.dependency 'React/RCTAnimation', '0.41.2'

随着版本的不断迭代,在第二个版本 0.1.1 的时候可以看到下面的描述. 可以看到对 RN 的描述不存在了,因为当时的代码对 RN 的2个版本都做了兼容,所以 App 主工程肯定是有 RN 的库,所以索性就不在单独描述,直接随着 App 依赖的 RN 库而使用。之后的版本也是如此。

s.dependency 'CMDevice', '~> 0.1'

所以, 将 CMRCTToast.podspec 中的依赖修改掉。需要兼容不同 RN SDK 的版本。

  1. 部分 pod 的 hook 脚本会失败。
  • 涉及到的 SDK:无
  • 改造点:
    TrinityParams.rb 类方法 generate_mods 会报错。逻辑是通过遍历每个 pod_target,获取到 PBXNativeTarget,然后访问 source_build_phase 属性去遍历内部的每个文件,判断是否是 properties.yml
  1. 官方文档地址.
  2. 公共组做的库,Android 和 iOS 都是对应的,但是 SDK 的名字不一定严格一致。但是通跳后台是配置的时候不可能设置多个名字,所以设置了一个通用的名字,然后 iOS SDK 和 Android SDK 各自用一个描述文件将本地的 SDK 和下发需要命中的 SDK 名字做一个对映射关系。iOS 端用 properties.yml来描述
    cocoapods 新版本里面每个 pod_target 没有 native_target 属性,也就是没办法获取到 PBXNativeTarget。
    感觉之前的脚本写法有问题,内部基既有 file_accessors,也有 pod_target.native_target 的形式继续访问 yml 文件。所以升级 cocoapods 1.8.4 之后修改脚本直接改为用 file_accessors 寻找 yml

多包加速

目前不能开启多包并行的瓶颈在于打包机操作的是本地下载下来的 .cocoapods 文件夹,所以当一个项目操作的时候其他项目没办法操作。

CDN 提供了通过网络接口处理依赖的能力,通过网络去操作文件,所以是可以多包并行打包的。

但是由于以下2个原因,我们需要自建CDN的能力:

  1. 我们的依赖存在的位置有2个,1个是私有源、1个是官方源。目前使用官方CDN就是官方源,因为我们要并行打包,所以私有源也需要 CDN 化。不然难以免于多个项目进行文件的读写锁操作问题。
  2. 但是CDN跟网络的状态有关,依据所处位置附近的服务器有关系,严重依赖于外界因素,不可控。所以想拥有快速稳定的CDN 查询能力就需要自建CDN 了。

另外一个可预期的点就是自建了CDN ,wax SDK 发布的相关逻辑也需要修改。

根据 Cocoapods 的 changeLog 知道 CDN 的实现是借助 Netlify 实现的。所以接下去的研究方向就是如何利用 Netlify 自建 CDN。

Directory Listing Denied

It was obvious to many that the spec repo should be put behind a CDN, but there were several constraints:

  1. It had to be a free CDN, as the project is free and open-source.
  2. It had to allow some way of obtaining directory listings, for retrieving versions of pods.
  3. It had to auto-update from GitHub as the source of truth.

The first implementation was a shell script, polling GitHub and piping find into ls into index files. This ran on a machine that was not open or free and therefore could not be the true solution. Nevertheless, this auto-updated repo was put behind a jsDelivr CDN and the client interfacing with it was released in 1.7.0 labeled “highly experimental”.

Final Lap with Netlify

The final version of the CDN for CocoaPods/Specs was implemented on Netlify, a static site hosting service supporting flexible site generation. This solution ticked all the boxes: a generous open-source plan, fast CDN and continuous deployment from GitHub.

Upon each commit, Netlify runs a specialized script which generates a per-shard index for all the pods and versions in the repo. If you’ve ever noticed that the directory structure for our Podspecs repo was strange, this is what we call sharding. An example of a shard index can be found at https://cdn.cocoapods.org/all_pods_versions_2_2_2.txt. This would correspond to ~/.cocoapods/repos/master/Specs/2/2/2/ locally.

Additionally, we create an all_pods.txt file which contains a list of all pods.

Finally, any other request made is redirected to GitHub’s CDN.

接入方式

考虑到业务线 App 升级是分开的,不可能同步进行,所以需要考虑到接入计划。

  • 能否提供 wax 项目指定到特定环境打包机的能力(该打包机升级了 cocoapods 版本)
  • 假如没有上述能力,则考虑其他方式支持业务线自定义打包所需的 cocoapods 版本
  • 将 2个版本的 cocoapods 做成2个 Bundle 包,读取 wax 工程配置,指定某个 Bundle
  • 假如打包机由于某些原因没办法升级 cocoapods 版本,但是某个 wax 项目又需要新版的 cocoapods 进行打包,则需要则代码上传的时候提交 Pods 文件夹。这样在打包机上面不需要执行 install 的操作,将本地的 Pods 目录上传上来,全部使用本地的一套。

参考资料

1. cocoapods changeLog

2. 版本清单

3.探究Xcode New Build System对于构建速度的提升