有赞iOS-基于二进制的编译提效策略

一、需求背景


自有赞零售正式发布以来,已迭代百余个版本,业务的发展免不了带来工程代码的飞速增加,时至今日,有赞零售工程的业务代码数量已达24w行,所使用的的二方/三方 Pod 库的数量达到了100+,业务模块包括商品,交易,库存,会员等模块一共有15+;工程的急速膨胀给我们的日常开发中带来了诸多痛点:

  • 工程编译速度降低,clean-build 一次需要25min左右
  • 打包速度降低,在打包提测窗口增加了等待的时长
  • Merge Request 时触发的编译检查速度降低,多人员合并时造成堵塞

在硬件资源有限的情况下,并且在不影响业务方开发习惯的前提下,如何解决这些摆在团队面前的难题,便成了我们迫在眉睫的迫切需求。

二、探索与尝试

1.Xcode 编译优化


在查阅相关资料并且经过一番尝试之后,总结出了以下几点提高编译速度的优化方式

  • BuildSetting - Architectures

    在Debug模式下,我们可以在Xcode-BuildSetting中,将Architectures的选项,改为armv7,由于架构是向下兼容的,所以,只包含armv7架构能够牺牲一定的运行时性能,换取不错的编译提速效果;

  • 加载RAM磁盘编译 Xcode 项目

    为了避免硬盘 IO,我们可以提前将编译环境设置到内存中去,在内存中操作会地一定程度上加快编译速度

  • 提高编译线程数

    Xcode的编译线程数默认为 CPU 内核的数量,我们可以适当增加编译线程来提高编译速度

  • Debug模式下不生成 dsym 文件

    在工程对应 Target 的BuildSetting中,将Debug Information Format改为DWARF,能够一定程度上提高编译速度

  • Link-Time Optimizations设置

    对于 Link 阶段耗时较长的项目,将Apple LLVMCode Generation中将Time Optimization设为 NO

在尝试过上述几种方法后,虽然编译速度一定程度上提高了,但是并未达到我们的预期,并且有的方式还需要我们牺牲一些运行时性能为代价,对此,我们不得不寻找更好的方式去达到我们的目的

2.CCache


CCache 是一个编译缓存器,支持 C/C++/Objective-C/Objective-C++

大致原理就是将上次的编译产物缓存起来,在下一次编译时会检查是否命中缓存,如果命中缓存会优先取上一次的编译产物

经过在工程中的一番尝试,总结出了以下几个特点

  • CCache 确实能够很大程度上提高编译速度
  • CCache 的缓存命中率相对稳定
  • CCache 不支持 PCH 文件
  • CCache 不支持 Clang-Moudle 的方式
  • CCache 目前不支持 Swift

虽然 CCache 经过尝试,确实能够给我们带来比较不错的编译提效,然而目前的工程使用的 PCH 会让 CCache 失效,从而让缓存命中变低不少,加上后续工程接入的 Swift 模块,考虑到之后的发展,我们不得不另辟蹊径

3.二进制方案探索


据我了解,目前业界组件化使用最多的载体还是 Cocoapods,大多的做法都是以 Cocoapods 私有库的形式管理与维护业务库,本地开发时,用 local development pods 做开发,顺应而生的就是去做 Cocoapods 库的二进制化,既包含了三方库代码,也包含了封装的通用组件库以及业务库代码。

不过要去做完全的组件化 Pod 工程,必须从一开始起就需要做好 Pod 化的一系列工作,Router / Target-Action 的通信方式,持续化集成脚本,模块拆分的规划,版本的管理依赖,团队对于 Pod 使用的熟悉程度等等因素,在现有工程组件化结构的情况下,去将整个子工程中的业务代码全部迁移成 Pod 私有库进行日常开发,显然迁移量成本较大,并且也会有团队接受的过程。

综上所述,我们需要思考一套迁移成本小,团队成员开发感知不明显的方式去做业务库/组件二进制化方式,实现我们的需求,原有组件库与三方库原本就是 Pod 库形式,直接二进制化,原有业务子工程,本地开发的模块以子工程接入,不需要的业务子工程自动转换为 Pod 二进制库,便是我们想出的解决方案

三、Pod 库二进制化的方式


经过对业界常用方法的探索,总结出了以下三种二进制化使用的常见方案

  • 即时生成二进制包并缓存

    类 Carthage 的实现思路,以 Cocoapods-Binary(Cocoapods 官方推荐的二进制插件),在 pod install 后,对本次编译,即时生成二进制包并缓存,缺点是在没有对应二进制包版本时,pod install 后会额外去做二进制包的生成,一定程度上会影响 pod install的速度,并且如果开发者切回源码调试,二进制缓存会一并清空

  • 单私有源,PodSpec 包含源码及二进制信息

    单私有源指的是 Pod 库均包含在同一个 Repo 内,二进制的 vendor_libraries / vendor_frameworks 等信息会存在于各个 Pod库对于 PodSpec 的 SubSpec 中,在 Podfile 中读取二进制相关配置去决定是否使用二进制SubSpec

    缺点是源码与二进制并存与一处,不仅会让 PodSpec 显得臃肿,并且会增大 Source 源的体积,降低 Pod 库的 Download 速度以及 Lint 速度,以及多 SubSpec 的模式也会影响最终生成 xcworkspace 的速度

  • 多私有源

    多私有源指的是源码与二进制分别独立,使用两个不同的 Source,二进制文件一般压缩存于静态服务器中,以空间去换取时间效率,同时存在的问题是,Source 之间的切换问题,二进制包以及 Spec 的生成时机问题

在了解完业界通用方案后,再看回我们的工程结构

img

如上图所示,我们的工程由 Retail.workspace 统一管理,包含业务壳工程 Retail.xcodeproj 以及 Pods 工程,其中,业务壳工程以引用的方式引入 RetailHome(首页),RetailGoods(商品)等业务子工程,每个业务子工程包含三个 target(通用, Phone, Pad),在实践Pod二进制的基础上,我们还需要额外考虑如何将这些业务子工程二进制化,将这些子工程动态转换成 Pod 库,由 Cocoapods 统一管理业务,是最快捷的方法

经过对以上三种方案以及我们工程结构的分析和思考,并在一定的调研和实践后,我们选定了第三种多私有源的方式,同时为了满足我们的需求,需要做到以下几点:

  • 无侵入无感知,使用方不需要了解任何内部实现,不需要改动任何工程代码实现接入,并且二进制化对平日里的开发方式不会发生变动
  • 业务 Project 的二进制,非 Pod 形式的业务子工程也需要支持二进制
  • 支持组件库与业务库白名单,方便开发人员随时调试目标库或者业务模块
  • 不修改 Podfile,避免生成任何非 gitignore 的文件,以免产生提交冲突
  • 全自动化,二进制包的生成无需人为打包,podspec 的生成,转换,lint,push一套流程全自动部署
  • 稳定性高,不会出现编译报错问题,错误的提交会及时在打包阶段发出消息警示

四、Cocoapods插件介绍


针对我们的需求,由于需要Cocoapods作为方案的载体,并且原生提供的 Cocoapods 功能显然不能够满足我们的需求,以Cocoapods 插件集成二进制打包,二进制使用等功能是一种很方便的方式

Cocoapods 的组件之一: Cocoapods-Plugin 给开发者提供了编写自定义插件的能力,使用起来也很简单

pod plugins create 'demo'  

执行完毕之后,变会生成 cocoapods-demo 的插件工程目录

大致文件结构和功能如下图所示

.
├── Gemfile (该插件依赖其他 gem 库放置处)
├── LICENSE.txt
├── README.md
├── Rakefile (执行测试用例入口)
├── cocoapods-demo.gemspec (用于管理发布版本等描述信息)
├── lib
│   ├── cocoapods-demo
│   │   ├── command
│   │   │   └── demo.rb (插件实现入口,管理参数信息)
│   │   ├── command.rb
│   │   └── gem_version.rb
│   ├── cocoapods-demo.rb 
│   └── cocoapods_plugin.rb (用于自己被识别为插件的标识文件)
└── spec
    ├── command
    │   └── demo_spec.rb (一般测试代码放置处)
    └── spec_helper.rb

如上图所示,我们一般在demo.rb文件中,管理新的命令,接受处理参数,并根据功能调用不同自己设计的功能模块,具体使用Ruby开发Plugins的过程就不在此展开了,感兴趣的同学可以自行去了解

在完成自己的自定义插件之后,可以利用gem build demo.gemspec构建出 gem 文件,执行 gem instsll gem.gem 安装相应的插件,成功之后, 我们在 Podifle 中使用 plugin cocoapods-demo 便能够使用插件相关的功能

五、整体二进制组件设计


1.远端服务

img

如上图所示,工程源码,二方库 pod repo 以及三方库镜像 pod repo 均存放在 GitLab 上,分别说下触发打包的方式

  • 二方库/三方库镜像: GitLab 监听 PushEvent 事件,通过 webhook 触发 jenkins 执行打包任务,对二方库/三方库进行打包
  • 业务库: GitLab 监听 MergeEvent 事件 (dev),通过 webhook 告知 Jenkins 的 commitId,通过 git loggrep 出发生改动的模块,对这些改动的模块进行二进制打包

2.本地使用

img 如上图所示,我们会提供统一的 cocoapods-yzpodbin 插件,读取本地的配置文件,根据配置的开关以及白名单,决定哪些库来自源码 Source,哪些库来自二进制 Source,如此一来,既对工程没有侵入,又能够在开发人员无明细感知的情况下集成使用二进制库

六、二进制包的生成


二进制包的生成一般分为以下几步

  • 编译源码,生成二进制包,形式为 .a + .h + .bundle 或者 .framework 可选
  • 压缩二进制包并上传静态服务器,返回二进制包的下载链接
  • 将源码 PodSpec 转换为二进制 PodSpec
  • 经过 Lint,将二进制 PodSpec 推送到远端二进制 Repo-BinaryRepo

首先先来说下生成二进制包方式的选择

  • cocoapods-packager 插件

    cocoapods-packager 是一款开源的二进制打包的 pod 插件,通过源码 podspec 生成 Podfile,pod install 生成包含对应 Pod 库的工程,之后通过 xcodebuild 去构建 .a / .framework,在看过该库的源码后发现该逻辑并不复杂,但是在尝试之后会发现几个问题:

    • 当选择 .a 形式作为产物时,我们 podspec 中所指定的 .h 并不会被正确拷贝到目标文件夹
    • 该组件对 Subspec 的处理较为暴力,会将多个 Subspec 合并为一个,例如我一个组件库,Phone 工程需要引用SubSpecA,Pad工程需要引用 SubSpecB,在使用该组件打包时,会将 SubSpecA 与 SubSpecB 合并为一个 framework/.a,这种情况显然不是我们所需要的,更为合理的做法是可通过配置去设置,是否将 SubSpec 进行合并或拆分
    • cocoapods-packager 已经停止维护,在对 Cocoapods 新特性或者 Swift 的支持上无法达到同步更新
  • 自行编写打包脚本

    由于生成二进制包不仅仅是针对源码 Pod 库,一些业务工程也需要经过编译,生成二进制包,所以源码工程是我们最好的载体,在打包机上部署源码工程,触发打包时对相应的 Pod 库执行相关打包命令

//构建模拟器静态库文件
xcodebuild -project '目标工程' -target '目标target' ONLY_ACTIVE_ARCH=NO  -sdk iphonesimulator VALID_ARCHS='i386 x86_64' ARCHS='i386 x86_64'd
//构建真机静态库文件
xcodebuild -project '目标工程' -target '目标target' ONLY_ACTIVE_ARCH=NO  -sdk iphoneos VALID_ARCHS='armv7 armv7s arm64' ARCHS='armv7 armv7s arm64'  

在构建完对应的架构,用lipo对架构.a/.framework进行合并操作

lipo -create '模拟器.a' '真机.a' -output '目标静态库'.a  

得到目标产物后.a+.h+.bundle或是.framework,我们需要对目标产物进行压缩上传 压缩(这里我们采取7z压缩):

7z a '压缩文件名' '压缩文件目录'  

上传 (这里我们采取wput的方式,curl也可以)

wput '压缩文件名' '服务器存储地址' --tries=3 --binary  

至此,我们的二进制文件的生成与上传过程已经完成,接下来我们需要生成二进制 podspec,并 push 到我们的二进制 repo

七、PodSpec 的转换


对于二方库,三方库,我们能够通过需要打包的 podName 和 version 去寻找到我们需要的源码对应的源码 pod 对应的 spec ,在拿到源码 pod 的 spec 后,我们如何去修改成我们想要的二进制版本呢

其实 .podspec 或是 .podspec.json,我们都可以视作为 json 文件进行读写操作,针对于源码 podspec 我们只需要改动其中的某几项关键点,便可生成为新的二进制 podspec

#读取 spec
spec = Pod::Specification.from_file specpath  
#修改 spec 中关键项
#修改 source 源,从之前的 github / gitlab 地址指向你上传静态库的地址 (git / ftp 等)
s.source = {  
   "http": '二进制文件存储地址'
}
#修改 .a / .framework
s.vendored_libraries: ".a 名称"  
#s.vendored_frameworks: ".framework 名称",
#修改公开头文件,这里可以保留之前的文件路径
s.public_header_files: "*.{h}",  
#修改源文件,这里可以保留之前的路径,只需要保留 .h
s.source_files: "*.{h}"

#如果对应 pod 包含有 subspec,是否合并 subspec 是可选项,这里的情况较多,需要针对业务场景进行对应的处理

八、业务 Project 与 Pod 库转化


如大家在上文中所看到的工程目录,我们的业务代码是以子工程的形式接入在对应 phone 和 pad 的 xcodeproj 中,并没有对应的pod库,这样我们怎么和 pod 二进制搭上关系呢?

实际上,子工程形式的业务源码在编译后与 Pod 库的处理方式并无差别,都是以 .a 静态库的形式存在(Pod 为一个大工程,旗下的各个 pod 都为该工程的 target),那我们反过来思考,我们可否直接在远端生成 .a / .framework,这样我们距离一个二进制 pod 库,只是差一个 podspec

如上方所示,podspec 的转换过程实际上只是 json 的读写,既然我们没有 podspec,新建一个 json,填好对应的信息不就可以了吗,所以业务子工程的二进制 pod 库的生成过程并无差异,依旧是一样的过程

xcodebuild -project '业务子工程' -target '业务target'  
lipo -create '模拟器.a' '真机.a' -output '目标静态库'.a  

说完了业务二进制 Pod 的制作,接下来我们来说说如何使用业务二进制 Pod

为了避免对源码工程文件产生任何修改造成 git diff,如果开启了二进制开关,我们在每次pod install后都会做如下操作

  • 镜像拷贝一份xcodeproj文件并重命名为Bin-Retail(Bin-RetailHD)

  • 生成Bin-Retailworkspace专门提供给开发者进行开发,

  • 删除镜像工程中对应的业务project,

  • 新增下方 pod 库中的业务pod库

其中最为关键的步骤是删除工程中的 project 以及不在 podfile 中指明,动态增加 pod 库

删除业务子工程

如何用代码的方式去操作我们的工程呢,Cocoapods 的组件之一:Xcodeproj组件给开发者提供了非常便利的功能

phone_proj = Xcodeproj::Project.open("镜像二进制工程")  
phone_proj.main_group.children.objects.each do |item|  
  #遍历工程的子工程
  if (item.name) && (item.name.include? ".xcodeproj")
    item_name_without_ext = item.name.split('.').first
    #判断名称是否在白名单中
    unless code_targets && code_targets.include?(item_name_without_ext)
      item.remove_from_project
    end
  end
end  
#删除完毕后重新保存工程
phone_proj.save(binary_proj_name)  

增加业务 Pod 库

删除了对应的业务子工程,如何让它以Pod库的形式引入到工程中来呢,手动在 podfile 中写判断条件,在手动添加pod 业务库当然能够行得通,但我们之前说了,我们避免任何podfile的修改,所以我们可以通过 hook install 的过程,手动添加我们需要的 pod 业务库

bussiness_targets.each do |target|  
  #判断是否在白名单内
  unless $code_bussiness_modules.include?(xcoproj_name)
    #手动拼接名称,phone 和 pad 以 subspec 的形式存在
    pod_name = "YZ#{xcoproj_name}/#{target_name_phone}"
    #查找本地最新的版本
    pod_version = find_last_version_for_pod("YZ#{xcoproj_name}")
    #调用 pod() 方法 加入 pod 业务库
    if pod_version.nil?
      pod(pod_name)
    else
      pod(pod_name,pod_version)
    end
  end
end  

开启业务二进制之后,例如我只希望保留 Goods 模块进行开发,我们的二进制工的结构如下:

img

九、本地配置


在准备好二进制后,我们必须要有一个优雅的接入方式,我们不希望开发人员过多的感知二进制的工作,也不希望二进制会带来任何非必要的 git diff,所以我们依赖一个不加入版本控制管理的配置文件去做到二进制开关,二/三方库白名单,业务工程白名单等配置

#二进制 Source
$BINARY_SOURCE = "xxx"
#是否使用二进制
$USE_BINARY = true
#组件库源码白名单
$CODE_PODS = []
#业务库源码白名单
$CODE_MODULES = []

十、遇到的问题及解决方式


  • Source的切换

    项目起初,我们对 Source 切换的构想比较简单,认为通过配置文件调配 Podfile 顶部 Source 的顺序就可以切换Cocoapods 对 Pod 库 Source 依赖寻找的顺序,官网也说明了 Cocoapods 寻找 Source 中的 Spec 是按照由上自下的顺序进行寻找,但实际上,有些涉及到版本依赖的情况,并不如我们所想的这样工作

    举个栗子:

    Podfile 中

#yz-source-A 和 yz-source-B同时有yz-pod-A的 1.0.0版本
source yz-source-A  
source yz-source-B

#yz-pod-B 依赖于yz-pod-A 
pod yz-pod-A 1.0.0  
pod yz-pod-B  
#yz-pod-B 依赖于 yz-pod-A 并且在B的 podspec 中并未指明依赖版本
s.dependency ~> A  

如此,执行 Pod install,最终取到1.0.0版本的 yz-pod-A 是来源于yz-source-B

解决以上问题的方法有很多思路,在了解 Cocoapods 的工作流程之后,解决大致有以下几种

  • 替换 pod 的 dependency
podfile.root_target_definitions.each do |root_target_definition|

//hook install过程 用该方法获取到每个 Pod 的依赖 对其修改

end  
  • 替换最终使用的spec
def resolver_specs_by_target

//hook 该返回 spec 的方法 在返回结果中替换来自不同 Source 的 spec

end  
  • 替换 pod 的 source 顺序
def aggregate_for_dependency

//hook 该返回依赖集合的方法 依赖集合会接受一个类似 Source 数组的参数,修改 Sources 的顺序便可以达到我们想要的顺序 

end  
  • PodSpec Lint/Push 会产生Pod Cache

    这一点是由于我们业务库源码自身是没有 pod 库的,并且版本号相对于二方三方库来说改动较为频繁,每一次触发远程业务库打包,在 lint 相应 podspec 时,都会在本地保留 pod cache,由于业务库的静态库相对较大,一定要在打包完成后采取清除策略,如若不然会使部署打包的机器产生冗余文件

pod cache clean 'xxx'  
  • 镜像二进制 workspace 的 Target 名

    上文提到了,我们会为了不会对源码工程产生任何git变动而去镜像一份 workspace 和 xcodeproj 文件并重命名,但是 schema 并不需要重命名,然而在我们打开镜像的二进制 workspace 文件时,会发现自己的 schema 名后可能会加上12这样的占位数字,这是怎么一回事呢?

    这其实是我们原始工程在管理 schema 时,将原本的 schema 设置为了 shared,为了避免该种情况的发生,我们可以将该target 右侧的 shared 取消,更好的做法是在不做任何原工程改动的情况下,在生成镜像时,手动清除对应 xcodeproj 内部xcshareddata 中的 xcschemes

share_schema_path = "#{bin-xxx}/xcshareddata/xcschemes"  
if File.directory?(share_schema_path)  
   puts "清除共享 schema"
   FileUtils.rm_rf(share_schema_path)
end  
  • Swift的支持

    随着2019年Swift5的问世,ABI 的稳定毫无疑问点燃了大批开发者使用 Swift 的热情,那么我们的二进制方案也需要与时俱进兼容 Swift

    了解 Swift Cocoapods使用的小伙伴可能知道,我们在 Podfile 中的声明

use_modular_headers! #全员开启 modular_header

pod A ~> 1.0.0 :modular_headers => true  #针对单个库开启 modular_header  

我们需要做的就是生成modulemap文件实现 Swift 静态库的兼容

该文件的生成可以放在插件内部,生成静态库文件的时候去做,也可以在 preinstall/postinstall 的时候动态生成

动态生成做法

post_install do |installer|  
  #获取 Pods 下公开头文件目录
  headers_path = "#{Dir::pwd}/Pods/Headers/Public/"
  installer.pods_project.targets.each do |target|
      generate_umbrella('目标pod库名', headers_path)
      generate_modulemap('目标pod库名', headers_path)
  end
end  
def generate_modulemap(name, path)  
  f = File.new(File.join("#{path}/module.modulemap"), "w+")
  module_name = "#{name}"
  while(module_name["+"])
    module_name["+"] = "_"
  end
  f.puts("module #{module_name} {")
  f.puts("    umbrella header \"#{name}_umbrella.h\"")
  f.puts("    export *")
  f.puts("}")
end  
def generate_umbrella(name, path)  
  f = File.new(File.join("#{path}/#{name}_umbrella.h"), "w+")
  f.puts("#import <Foundation/Foundation.h>")
  Dir.foreach(path) do |filename|
    if filename != "." and filename != ".."
      f.puts("#import \"#{filename}\"")
    end
  end
end  

十一、扩展功能


除了完成基本的二进制库打包,业务子工程转化,工程文件的生成之外,该服务还提供了:

  • 自更新功能

    工程文件目录下的配置文件,会维护一个版本号,在每次 Pod install 后会比对本地服务与远端 Tag 号,如果发现有更新,将会在 install 完毕后自行更新本地Pod插件。

  • 统计功能

    在每次开发人员进行 pod install 后,都会在控制台输出如下统计信息,以便大家查看 pod 库二进制与否,同时,这些信息也会被即时统计到内部平台,便于了解大家的使用情况以改进。

=> YZPodA 库没有二进制化
=> YZPodB 库没有二进制化
=> 当前指定业务工程为 RetailStock RetailCommon

十二、使用效果


经过有赞零售半年以来的使用尝试,目前的二进制化服务已趋于稳定,在只保留一到两个业务子工程(使用源码)做开发的时候, 全量编译时间从 25+ min 左右降到了 3-5 min,提速效果相当明显, 显著提高了团队的开发效率

img

十三、未来计划


  • 以二进制包为载体的业务提测打包平台
  • 服务内集成代码检查,资源检查等功能
  • 支持二进制库不切换源码库的调试

十四、结语


基于Pod二进制的编译提效策略的内容分享就到此结束了,我们在未来也会不断优化自己的方案,感谢大家的阅读以及对有赞技术的持续关注。

欢迎关注我们的公众号