作者:光富
团队:零售技术
自有赞零售正式发布以来,已迭代百余个版本,业务的发展免不了带来工程代码的飞速增加,时至今日,有赞零售工程的业务代码数量已达24w行,所使用的的二方/三方 Pod 库的数量达到了100+,业务模块包括商品,交易,库存,会员等模块一共有15+;工程的急速膨胀给我们的日常开发中带来了诸多痛点:
在硬件资源有限的情况下,并且在不影响业务方开发习惯的前提下,如何解决这些摆在团队面前的难题,便成了我们迫在眉睫的迫切需求。
在查阅相关资料并且经过一番尝试之后,总结出了以下几点提高编译速度的优化方式:
Xcode-BuildSetting
中,将 Architectures
的选项,改为 armv7
,由于架构是向下兼容的,所以,只包含 armv7
架构能够牺牲一定的运行时性能,换取不错的编译提速效果;BuildSetting
中,将 DebugInformationFormat
改为 DWARF
,能够一定程度上提高编译速度AppleLLVM
中 CodeGeneration
中将 TimeOptimization
设为 NO在尝试过上述几种方法后,虽然编译速度一定程度上提高了,但是并未达到我们的预期,并且有的方式还需要我们牺牲一些运行时性能为代价,对此,我们不得不寻找更好的方式去达到我们的目的。
CCache 是一个编译缓存器,支持 C/C++/Objective-C/Objective-C++。
大致原理就是将上次的编译产物缓存起来,在下一次编译时会检查是否命中缓存,如果命中缓存会优先取上一次的编译产物。
经过在工程中的一番尝试,总结出了以下几个特点:
虽然 CCache 经过尝试,确实能够给我们带来比较不错的编译提效,然而目前的工程使用的 PCH 会让 CCache 失效,从而让缓存命中变低不少,加上后续工程接入的 Swift 模块,考虑到之后的发展,我们不得不另辟蹊径。
据我了解,目前业界组件化使用最多的载体还是 Cocoapods,大多的做法都是以 Cocoapods 私有库的形式管理与维护业务库,本地开发时,用 local development pods 做开发,顺应而生的就是去做 Cocoapods 库的二进制化,既包含了三方库代码,也包含了封装的通用组件库以及业务库代码。
不过要去做完全的组件化 Pod 工程,必须从一开始起就需要做好 Pod 化的一系列工作,Router / Target-Action 的通信方式,持续化集成脚本,模块拆分的规划,版本的管理依赖,团队对于 Pod 使用的熟悉程度等等因素,在现有工程组件化结构的情况下,去将整个子工程中的业务代码全部迁移成 Pod 私有库进行日常开发,显然迁移量成本较大,并且也会有团队接受的过程。
综上所述,我们需要思考一套迁移成本小,团队成员开发感知不明显的方式去做业务库/组件二进制化方式,实现我们的需求,原有组件库与三方库原本就是 Pod 库形式,直接二进制化,原有业务子工程,本地开发的模块以子工程接入,不需要的业务子工程自动转换为 Pod 二进制库,便是我们想出的解决方案。
经过对业界常用方法的探索,总结出了以下三种二进制化使用的常见方案:
vendor_libraries
/ vendor_frameworks
等信息会存在于各个 Pod库对于 PodSpec 的 SubSpec 中,在 Podfile 中读取二进制相关配置去决定是否使用二进制SubSpec。
缺点是源码与二进制并存与一处,不仅会让 PodSpec 显得臃肿,并且会增大 Source 源的体积,降低 Pod 库的 Download 速度以及 Lint 速度,以及多 SubSpec 的模式也会影响最终生成 xcworkspace 的速度。在了解完业界通用方案后,再看回我们的工程结构:
如上图所示,我们的工程由 Retail.workspace 统一管理,包含业务壳工程 Retail.xcodeproj 以及 Pods 工程,其中,业务壳工程以引用的方式引入 RetailHome(首页),RetailGoods(商品)等业务子工程,每个业务子工程包含三个 target(通用, Phone, Pad),在实践Pod二进制的基础上,我们还需要额外考虑如何将这些业务子工程二进制化,将这些子工程动态转换成 Pod 库,由 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
便能够使用插件相关的功能。
如上图所示,工程源码,二方库 pod repo 以及三方库镜像 pod repo 均存放在 GitLab 上,分别说下触发打包的方式:
git log
, grep
出发生改动的模块,对这些改动的模块进行二进制打包如上图所示,我们会提供统一的 cocoapods-yzpodbin 插件,读取本地的配置文件,根据配置的开关以及白名单,决定哪些库来自源码 Source,哪些库来自二进制 Source,如此一来,既对工程没有侵入,又能够在开发人员无明细感知的情况下集成使用二进制库。
二进制包的生成一般分为以下几步:
首先先来说下生成二进制包方式的选择:
//构建模拟器静态库文件
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。
对于二方库,三方库,我们能够通过需要打包的 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 是可选项,这里的情况较多,需要针对业务场景进行对应的处理
如大家在上文中所看到的工程目录,我们的业务代码是以子工程的形式接入在对应 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后都会做如下操作:
其中最为关键的步骤是删除工程中的 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库的形式引入到工程中来呢,手动在 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 模块进行开发,我们的二进制工的结构如下:
在准备好二进制后,我们必须要有一个优雅的接入方式,我们不希望开发人员过多的感知二进制的工作,也不希望二进制会带来任何非必要的 git diff,所以我们依赖一个不加入版本控制管理的配置文件去做到二进制开关,二/三方库白名单,业务工程白名单等配置。
#二进制 Source
$BINARY_SOURCE = "xxx"
#是否使用二进制
$USE_BINARY = true
#组件库源码白名单
$CODE_PODS = []
#业务库源码白名单
$CODE_MODULES = []
举个栗子:
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 的工作流程之后,解决大致有以下几种:
podfile.root_target_definitions.each do |root_target_definition|
//hook install过程 用该方法获取到每个 Pod 的依赖 对其修改
end
def resolver_specs_by_target
//hook 该返回 spec 的方法 在返回结果中替换来自不同 Source 的 spec
end
def aggregate_for_dependency
//hook 该返回依赖集合的方法 依赖集合会接受一个类似 Source 数组的参数,修改 Sources 的顺序便可以达到我们想要的顺序
end
pod cache clean 'xxx'
share_schema_path = "#{bin-xxx}/xcshareddata/xcschemes"
if File.directory?(share_schema_path)
puts "清除共享 schema"
FileUtils.rm_rf(share_schema_path)
end
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
十一、扩展功能
除了完成基本的二进制库打包,业务子工程转化,工程文件的生成之外,该服务还提供了:
=> YZPodA库没有二进制化
=> YZPodB库没有二进制化
=> 当前指定业务工程为 RetailStockRetailCommon
经过有赞零售半年以来的使用尝试,目前的二进制化服务已趋于稳定,在只保留一到两个业务子工程(使用源码)做开发的时候, 全量编译时间从 25+ min 左右降到了 3-5 min,提速效果相当明显, 显著提高了团队的开发效率。
基于Pod二进制的编译提效策略的内容分享就到此结束了,我们在未来也会不断优化自己的方案,感谢大家的阅读以及对有赞技术的持续关注。