iOS工程构建方案的现状
现有方案
目前已经有非常多的iOS团队基于CocoaPods完成了模块化工作,项目结构大概会是这样:
首先有一个主工程(或者称为运行工程、壳工程、宿主工程,如上图MainProject。这个工程的内容一般是应用逻辑的入口,可能有应用生命周期事件分发、模块初始化和调度处理,或者还有可能是一些未进行模块化的业务逻辑等。),然后便是Pods工程了,内容是依赖的基础三方组件(如上图的Alamofire)、以及已经进行模块化后的业务模块等(如上图中的ModuleA、ModuleB)。
我们都知道将iOS中的代码处理成可执行程序需要经历主要的四个步骤:预处理、编译、汇编、链接。其中Pods工程中的代码是由稳定可靠的三方组件以及封装好的业务模块组成的。但是每一次进行项目构建时,我们依然会花费大量的时间对“稳定的”Pods工程进行编译,这些编译的时间并不是必要的!为了优化这部分的编译时间我们会采用二进制化方案。二进制化方案原理也很简单,即是对“稳定的”代码进行提前编译成二进制文件,然后管理和复用这些二进制文件,在项目构建的最后阶段只要正确地对这些二进制文件进行链接即完成了整个工程的构建工作。
所以使用二进制化方案后。能极大地提高项目工程构建速度!
iOS项目的二进制化方案主要也就变成了提前对Pods工程进行预编译,然后让主工程直接依赖已经编译好的二进制文件。这样对主工程进行构建时则不再需要对Pods工程进行编译,而直接进行链接即可。
目前对Pods工程进行二进制化方案后集成到主工程大概会有以下几种方案:
方案一、将依赖的Pods
库编译成二进制文件,然后手动将这些二进制文件拖入到主工程项目中。项目结构大概会是这样:
方案二、将依赖的Pods
库编译成二进制文件,然后分别对这些二进制文件创建对应的podspec
文件生成对应二进制文件的私有Pods
库,最后让主工程使用CocoaPods
依赖这些私有库。项目的结构大概会是这样:
方案对比
这两种方案都能达到在构建主工程时不再对Pods工程进行编译而达到编译加速的效果,但是其中方案一需要手动管理主工程对二进制文件的依赖,不利于自动化管理,所以这套方案仅仅适用于确定二进制文件无比稳定,绝不再修改的项目,但这也太现实,因为业务模块的二进制文件是必然频繁改动的。
方案二可以利用CocoaPods对项目进行管理,非常便于自动化和项目配置,所以这套方案及其变种方案是目前二进制化方案中较为可行的实践方案。
未解决的痛点
但是这些方案中都有一个致命的缺点——开发及调试体验不佳!
因为基础组件以及模块都是以二进制文件方式存在,如果三方基础组件或者业务模块发生崩溃根本无法查看其源码逻辑,也就极难获得更多的有效调试信息。
而最近美团iOS技术团队的 zsource 工具解决了这个二进制化后带来的调试痛点。主要是对iOS工程调试的机制进行了一个巧妙的利用,原来二进制文件中保留了编译时原代码文件的路径,那么在调试时将源代码文件放回到生成二进制文件的原路径即可和源码调试一样将断点进入到源码逻辑中。更多详细细节请参阅 《美团 iOS 工程 zsource 命令背后的那些事儿》[1],这篇文章不仅仅技术角度新颖,而且解决问题的思路也是值得借鉴的。
但是凡事都有但是,我们觉得还有一些开发和调试的痛点是zsource没有解决的。比如以下两点:
1、无法真的像源码调试那样,在具体的源代码中的某一行添加断点。而只能通过一个入口断点然后使用step into的方式进入到源码中;
2、没法修改Pods库的源码而立即生效。是的这种骚操作也是调试中常用的,虽然这严重违反了CocoaPods的使用规范,但是通过临时修改Pods源代码可以快速临时地构建一些特殊场景以供调试和验证。而且有些业务模块隐藏比较深的问题是在模块开发环境中没有出现的,而到了主工程的集成环境中却出现了,这时候如果能够直接对业务模块的源代码进行即时的修改来验证猜想,真的可以节省很多很多的时间,这样就无须为了验证一个猜想而进行重新修改模块代码,模块提交,主工程刷新模块等耗时却没有多大意义的工作;
为了兼顾使用二进制化方案获得的编译加速以及如何获得更加的友好开发和调试体验,我们也简单地在这方面进行了一些探索。
方案探索
思路寻找
二进制化方案可以获得加速编译,现在问题是如何在进行了二进制化方案下获得源码般的开发调试体验?
首先想到的是在zsource工具的思路下继续研究,研究Xcode在给一行代码添加断点时是如何将源码断点和可执行程序中的断点对应起来的,在可执行文件中的中断点可以通过二进制文件包含的源码路径的信息找到对应的源码文件和行号,那反过来如何通过在一个源码文件中某一行添加一个断点而让lldb进入到可执行对应的逻辑中呢。劫持Xcode和lldb的debugserver的通信来模拟发送调试信息?这样势必就要研究Xcode的调试机制的底层,还要对通信信息进行劫持与分析,这样略显trick的方式,让我们预感到势必是无限的深坑,所以我们毅然放弃了继续深入。
那修改Pods库的源码无法立刻生效的痛点怎么解决呢?一个比较可行的方案是开发阶段构建主工程时检测Pods库是否有源码修改,如果有则对这个库的源码也进行编译,重新生产一个临时的二进制文件,然后让主工程在链接时链接临时的二进制文件,这样就可以达到解决修改Pods库的源码无法立刻生效的痛点。但是这样对工程破坏较大,修改了主工程的链接配置,而且这样切换后还需要开发者做一个恢复主工程配置的操作,如果开发者修改了主工程的链接配置,而忘记恢复,这样就极容易导致持续集成阶段失败率上升。所以看来这样也不可取。
返璞归真
难道就真的没有既要获得二进制化后带来的编译加速,又能有类似源码般的开发调试体验的解决方案了吗?
有一天读书读到一句话
“上帝基督化人,超凡入圣,返璞归真。”
对啊,为什么要苦苦的寻找怎么达到源码开发调试体验?
返璞归真使用源码开发调试不就好了吗?
所以只要设计一套能够快速切换源码模式与二进制模式的工程构建方案不就行了吗?
在debug阶段默认使用二进制模式,如果需要更好的开发调试体验可以直接切换至使用源码模式,release阶段则只使用二进制模式而获得更好的编译提速!
并且在debug阶段Xcode会有增量编译,所以编译速度也是极快的,而且 CocoaPods1.3.0 之后也加入了增量编译功能,进行pod install/update时只有改动的Pods库才会再次进行重新编译。这样除非是手动删除Xcode的编译缓存,或者编译缓存过期,或者是进行了其他会导致Xcode重新编译的改动才会触发整个主工程重新编译。
方案实践
生成二进制文件
首先是选定一个二进制文件的编译时机。
这里的生成二进制文件的时机方案有很多,有的团队有专门的自动化来实现,有的团队在业务组件开发结束后会有一个“组件发版”的过程而输出二进制文件,但是这一步骤不影响接下来的方案,只要将Pods
工程生成了二进制文件即可。
而我们选择在执行pod install/update
的时候进行依赖组件的编译。所以在post_install
回调之后进行二进制文件的编译,如:
post_install do |installer| #重建Pods项目的schemes installer.pods_project.recreate_user_schemes(:visible => true) installer.pods_project.save #Pods工程的目录 podsProjectPath = File.dirname(Pathname(installer.sandbox.project_path)) #记录此次进行了更新的库 updatedPodsNames = Array.new installer.installed_specs.each do |spec| updatedPodsNames.push(spec.name) end #编译进行了更新的库 installer.aggregate_targets.each do |aggregateTarget| aggregateTarget.pod_targets.each do |target| if updatedPodsNames.include?(target.name) #检测到这个target需要生成二进制文件 system('xcodebuild -project ' + podsProjectPath + '/Pods.xcodeproj ' + '-scheme ' + target.name + ' -configuration Release -quiet build') end end break #如果你有多个aggregateTarget 依据情况看是否需要构建不一样的二进制文件 endend
这样当你执行了pod install/update
之后,在Podfile
同级目录下就会多出一个Build
文件夹,文件夹内的便是编译好的二进制文件。如:
让项目依赖二进制文件
在这里我们需要定义两个模式:源码模式、二进制模式
当你执行了pod install/update
之后,打开项目,这个时候便是源码模式,直接使用源码依赖,开发调试阶段都是可以直接查看Pods
源代码,也可以直接在源代码中添加断点,当然最重要的是也可以直接“修改”源代码编译运行即可让改动生效。其实这也就是最原始的开发调试模式。
那当到了生产环境如何使用二进制文件加速编译呢?
我们需要在Podfile
中添加一个环境变量来判断区分模式。如下
#定义一个区分模式的环境变量$USE_LIB = ENV['USE_LIB']
有了USE_LIB
的环境变量后,我们便可以在Podfile
中区分当前的模式。当USE_LIB
为true时则说明是二进制模式,需要将主工程从源码模式切换到二进制模式。
让主工程依赖二进制文件,我们使用一个更简单的方式,直接使用CocoaPods
的Development Pods
。Development Pods
是CocoaPods
给SDK开发者提供的一个简单便捷的本地调试模式,你只要提供一个podspec
,然后在Podfile
中使用:path =>
方式依赖这个podspec
,便可以快速集成一个Development Pods
库,Development Pods
库和普通的Pods
库本质上没有区别,只是Development Pods
库的文件是没有lock的,可以进行实时的修改调试,更多Development Pods
信息参阅 CocoaPods Guides[2]。
我们创建一个podsepec
文件:
Pod::Spec.new do |s| s.name = "Binaries" s.version = "1.0.0" s.homepage = "HOMEPAGE" s.summary = "SUMMARY" s.author = { "AUTHOR" => "EMAIL" } s.source = { :git => "URL", :tag => s.version.to_s } s.vendored_frameworks = ['build/Release-iphoneos/Alamofire/Alamofire.framework', 'build/Release-iphoneos/ModuleA/ModuleA.framework', 'build/Release-iphoneos/RxSwift/RxSwift.framework', 'build/Release-iphoneos/ModuleB/ModuleB.framework', 'build/Release-iphoneos/SwiftyJSON/SwiftyJSON.framework']end
这里的核心是vendored_frameworks
,指向你已经编译好的二进制文件的路径。这个podspec
文件保存你愿意放置的位置。本方案放到主工程根目录:
我们在Podfile
中继续添加逻辑:
#定义一个区分模式的环境变量$USE_LIB = ENV['USE_LIB']target 'MainProject' do use_frameworks! if $USE_LIB #在二进制模式时使用一个临时的Development Pods pod 'Binaries', :path => './' else #源码模式时使用源码依赖 pod 'ModuleA' pod 'ModuleB' pod 'Alamofire' pod 'RxSwift' pod 'SwiftyJSON' endendpost_install do |installer| if !$USE_LIB #源码模式时才进行Pods库的二进制编译 #重建Pods项目的schemes installer.pods_project.recreate_user_schemes(:visible => true) installer.pods_project.save #Pods工程的目录 podsProjectPath = File.dirname(Pathname(installer.sandbox.project_path)) #记录此次进行了更新的库 updatedPodsNames = Array.new installer.installed_specs.each do |spec| updatedPodsNames.push(spec.name) end #编译进行了更新的库 installer.aggregate_targets.each do |aggregateTarget| aggregateTarget.pod_targets.each do |target| if updatedPodsNames.include?(target.name) #检测到这个target需要生成二进制文件 system('xcodebuild -project ' + podsProjectPath + '/Pods.xcodeproj ' + '-scheme ' + target.name + ' -configuration Release -quiet build') end end break #如果你有多个aggregateTarget 依据情况看是否需要构建不一样的二进制文件 end endend
这样只需要执行一次$env USE_LIB=true pod install/update
命令,便可以让项目依赖二进制文件了。
依赖了二进制文件后的项目结构如:
最佳实践
我们在开发阶段则可以继续使用以下命令:
$pod install/update
来使用源码模式,这样开发和调试都是原生体验。在源码模式下,会自动将有变更的Pods
库生成二进制文件,当然二进制文件的生成时机也不一定要放到开发者这边,这个生成二进制文件的时机和方法可以结合自己的项目特点和团队自动化环境来决定。
关于二进制文件的管理,在本示例项目中,二进制文件是在开发者执行pod install/update
后生成在/主工程根目录/build/
中的,当然读者完全可以使用自己项目现有的存放二进制文件的方式。不管二进制文件放置到何处,路径地址是很重要的,因为这个二进制文件的路径地址需要填入Binaries.podspec
的vendored_frameworks
中,这里最好使用动态生成这个Binaries.podspec
的方式。这样有二进制文件的删减都能及时更新这个Binaries.podspec
文件,确保二进制模式时可以正确依赖二进制文件。
vendored_frameworks
字段的设置可以使用动态赋值的方式,如我们的项目中vendored_frameworks
的动态设置逻辑如下:
if File.exist?('./frameworks.json') s.vendored_frameworks = JSON.parse(File.read('./frameworks.json'))['vendored_frameworks']else s.vendored_frameworks = []end
frameworks.json
是在生成二进制文件时动态生成的,这样Binaries.podspec
的文件就和一个静态资源文件一样,可以和主工程同时使用git进行管理。
Binaries.podspec
还需要对主工程的HEADER_SEARCH_PATH
进行设置,这样才能在主工程的OC代码中同时支持使用#import ""以及#import <>
s.xcconfig = { 'HEADER_SEARCH_PATHS' => ['build/Release-iphoneos/Alamofire/Alamofire.framework', 'build/Release-iphoneos/ModuleA/ModuleA.framework/Headers/'] }
而在生产环境中进行主工程构建前则可以添加以下命令:
$env USE_LIB=true pod install/update
来使用二进制模式,可以达到编译加速效果。大部分团队的构建工作都是处于持续集成中的一环,这样则只需要在持续集成构建命令开始前执行使用二进制模式的命令。这样的修改成本也是极小的。
关于环境变量配置,可以通过自己项目特点按需添加,如我们的项目中除了USE_LIB
表示是否使用二进制模式,还有NO_COMPILE
表示此次pod install/update
不会触发生成二进制文件的操作;COMPILE_ALL
表示强制重新生成所有二进制文件等。
关于一些闭源Pods
库,如一些第三方的SDK,他们本身便是二进制文件,则在Podfile
中需要保证不管选择何种模式都需要依赖这个闭源的Pods
库。大致如下:
#定义一个区分模式的环境变量$USE_LIB = ENV['USE_LIB']def binaryPods pod 'Bugly' pod 'Reveal-SDK', :configurations => ['Debug']endtarget 'MainProject' do use_frameworks! if $USE_LIB #在二进制模式时使用一个临时的Development Pods pod 'Binaries', :path => './' binaryPods else #源码模式时使用源码依赖 pod 'ModuleA' pod 'ModuleB' pod 'Alamofire' pod 'RxSwift' pod 'SwiftyJSON' binaryPods endend
在binaryPods
方法中指定需要进行依赖的闭源Pods
库,并且保证在不同模式的分支中都需要调用。
结语
本文从编译加速和开发调试的矛盾出发,提出了我们自己的构建解决方案,并进行了实践。其实这当中也是不断迭代优化的过程。
比如如何引用二进制文件,刚开始是使用脚本集成到主工程中,后来是使用一个私有Pods库来引用,再到后来使用Development Pods
的方式来引用二进制文件,这也要感谢团队中有着丰富iOS SDK开发经验的小伙伴的建议。
还有我们没有选择开发CocoaPods
插件的方式,而是直接在Podfile
文件中做文章。这样做主要是考虑调试效率问题,也为了方便使用xcodebuild
时自定义参数和配置。当然解决方案插件化也是我们的未来计划之一。
此文中构建方案的实践部分并未完全地给出一个工程化的知道,而且有部分问题也并没有做到“最佳实践”,如二进制文件的生成时机的最佳实践是什么?我们认为是不应该由开发者来生成,毕竟人容易出错。二进制文件如何进行版本管理?git管理?那更新二进制文件几次后,git仓库必然会极速膨胀,肯定也是不合理的。二进制文件的存储又该如何做呢?该构建方案如何集成到CI中?鉴于还有众多未解决的问题和篇幅限制,只能下回分解了。
这套构建方案的完整流程已经实现工程化,并且已经在珍爱网的众多的iOS项目中使用已久,结合我们的开发工作流和持续集成系统,可以很好的兼顾编译加速以及开发调试。
References
[1]
《美团 iOS 工程 zsource 命令背后的那些事儿》: https://tech.meituan.com/2019/08/08/the-things-behind-the-ios-project-zsource-command.html[2]
CocoaPods Guides: https://guides.cocoapods.org/making/making-a-cocoapod.html