Cocoapods 1.3.0 版本之后官方已经支持用 pod 集成单元测试,详情看 官方指南,此篇文章只针对当时 1.0.0 版本下的情况,仅做保存记录。**
上周接到了个需求,老大要我把项目代码里某个库覆盖上单元测试。而那个库没 Demo,平时都是集成在工程里开发的。为啥没有 Demo,因为那个库依赖很重,说是个库,实际只是把代码用 Cocoapods 拆分罢了……平时开发的时候,大家都是把库集成在主工程里运行。我想,单测写在主工程的 target 里,这样会显得很杂,给人感觉是给整个工程做单元测试。那能不能弄一个 Pod,专门把单测代码写在里面呢,既能 git 管理,又可以分类管理。殊不知,这个过程,坑如此多。
让 Pod 的 target 类型变为 XCTest
像往常一样我在命令行里,敲下一个熟悉的命令
pod lib create UnitTestPod
经过简单的四个问题后,一个 Pod 生成了。
并在 Demo 里的 Podfile 熟练的写下
target 'UnitTestPodDemo' do
pod 'UnitTestPod' , :path => 'UnitTestPod/UnitTestPod.podspec'
end
经过一番 pod install 后,打开了 xcworkspace。
???这个 Target 的 icon,有点不对劲啊…原来 Cocoapods 默认生成的 Pod 是作为一个 Target 集成进 Pods 这个 XcodeProject 里的,而且 Target 的默认类型是 Static Libaray,也就是一个静态库。咋办呢?我要的是 XCTest 的 Target 类型。好吧,那看看 Cocoapods 的 源码 吧。怎么找呢,有点 Ruby 基础的人知道,Podfile 里面写东西实际上都是用 Ruby 语法实现的 DSL(Domain Specific Language 领域特定语言)。也就是相当于实现了一套语法规则,比如 target do,比如 pod,比如 :path=> 这些,在 Cocoapods 都有对应的语法实现。在执行 pod install 的过程中,podfile 中的信息被解析,然后把 pod 的信息进行处理,并生成 target 并生成集成后的 project 文件。那对 target 的类型的写入,也肯定也在生成 Pods.xcodeproj 的过程中。查看 Cocoapods 的 installer.rb,发现里面有个 install
方法如下:
def install!
prepare
resolve_dependencies
download_dependencies
verify_no_duplicate_framework_and_library_names
verify_no_static_framework_transitive_dependencies
verify_framework_usage
generate_pods_project
if installation_options.integrate_targets?
integrate_user_project
else
UI.section 'Skipping User Project Integration'
end
perform_post_install_actions
end
看起来这个过程可能是 install
的过程,里面的 generate_pods_project
方法,应该就是生成 pod_project 的方法了。这个方法实现如下:
def generate_pods_project(generator = create_generator)
UI.section 'Generating Pods project' do
generator.generate!
@pods_project = generator.project
run_podfile_post_install_hooks
generator.write
generator.share_development_pod_schemes
write_lockfiles
end
end
再查看其类 pods_project_generator.rb 的 generate
方法(相当于初始化方法):
def generate!
prepare
install_file_references
install_libraries
set_target_dependencies
end
这里面大概就是将依赖的文件和 library 加入工程,设置目标依赖。再看看 install_libraries
方法
def install_libraries
UI.message '- Installing targets' do
pod_targets.sort_by(&:name).each do |pod_target|
target_installer = PodTargetInstaller.new(sandbox, pod_target)
target_installer.install!
end
aggregate_targets.sort_by(&:name).each do |target|
target_installer = AggregateTargetInstaller.new(sandbox, target)
target_installer.install!
end
add_system_framework_dependencies
end
end
在看看之类这个 target_Installer 实例,就是 PodTargetInstaller
类生成的。看来这个类,应该是生成 target 的类。再看看这个 pod_target_installer.rb,里面搜索一下 type,居然没有???难道找错了?我又仔细看了一下,发现开始处有怎么一行代码:
class PodTargetInstaller < TargetInstaller
看到这行代码,我感觉和 OC 里的 AClass : BClass
,这大概是继承吧。又找到了 target_installer.rb 里面的 TargetInstaller
类,终于发现了一行蛛丝马迹。
def add_target
product_type = target.product_type
也就是说,target 的类型,在 Cocoapods 里,是 target 的 product_type 属性。剩下的就是要把 target 的 product_type,设置成 Cocoapods 里的 XCTest 类型了。那么接下来,就有两个问题要解决
-
Cocoapods 里的 XCTest 类型,是怎么表示的
-
在哪里插入这个改变类型的操作
看 Cocoapods 源码里发现,实际上对于 Xcodeprojcet 相关文件的写入和修改,是通过 Cocoapods 另一个单独的库 Xcodeproj 实现的,再看看它的源码。搜索 Unit Test,在 constants.rb 里发现这里定义了所有 ProductType 的别名。
也就是说,product_type 属性其实是个 String
类型,而 XCTest 的类型,就是 'com.apple.product-type.bundle.unit-test'
那么第一个问题就解决了。
剩下就是在哪里改变它了。还记得刚才的 generate_pods_project
方法么:
def generate_pods_project(generator = create_generator)
UI.section 'Generating Pods project' do
generator.generate!
@pods_project = generator.project
run_podfile_post_install_hooks
generator.write
generator.share_development_pod_schemes
write_lockfiles
end
end
里面有个 run_podfile_post_install_hooks
方法,难道,官方提供了 podfile 里的 hook 方法?查看 Cocoapods 官网,发现官方还真提供了 hooks!
有三种类型,plugin 是加上插件,pre_install 是提供 hooks 在下载好pod 但还没 install 的时候,而 post_install 是东西都生成好了,还没被写入磁盘的时候。看来在生成好后改变就可以了。
根据官网的例子和前面的探索,可以在 podfile 里加上 hook 方法如下
post_install do |installer|
installer.pods_project.targets.each do |target|
if target.name == "UnitTestPod"
target.product_type = 'com.apple.product-type.bundle.unit-test'
end
puts "`#{target.name}` change type to `#{target.product_type}`"
end
end
end
就是对 installer 的 pod_project 这个 project 对象的 targerts 数组每一个执行一个循环,当找到我们需要的 target 时,改变 target 的类型。puts
是 ruby 里的 log,相当于 NSLog
,printf
。
好了,重新 pod install,可以发现,target 已经变成了 test 的 type 了,而 Xcode 里的 test 栏也有了库里的 Test 了。
但这个时候,我们是无法引入并识别其它第三方库的方法的,也找不到其它第三方库的符号表。
让 Pod 识别到别的静态库
这个时候,我们其它普通静态库的方法,在 podspec 里写上 s.dependency 'CDZPicker'
某个库,再次 pod install。这次,IDE 识别了,再次运行 test,发现编译报错了,找不到符号表。如下图:
同时可以发现,实际上运行 test 的时候,把依赖的库编译了一遍。也就是说,现在这个 test 的 target 没办法找到编译的产物(.o)。而在 Cocoapods 里生成的主工程的 target 里,为啥就能找到第三方库的编译后产物呢?先讲讲正常引用静态库的因素,一个是告诉 Xcode 去哪里找,也就是 Target 里 Building Setting 里的 Library SearchPaths,里面指定了找编译产物的路径,另一个是告诉 Xcode,要链接的静态库的名字,也就是 Building Setting 里的 Other Linker Flags 里用 -l “静态库编译产物名(去掉前面 lib)“标识。查看其 BuildingSetting,发现其两个决定的因素,都被 Cocoapods 配置好了,也就是 target xxx do 里做的。
而默认导入的 Pod 的 target,这两个参数都是”",也就是空的。这个时候,我们看看把这两个设置设成默认值会怎么样呢?在 post_install 的 hook 方法里加入下面的代码
target.build_configurations.each do |config|
config.build_settings['OTHER_LDFLAGS'] = '$(inherited)'
config.build_settings['LIBRARY_SEARCH_PATHS'] = '$(inherited)'
end
怎么知道这些设置对应的键值是这些呢?Cocoapods 实际上是通过生成 XCConfig 文件还配置这些的,在项目里搜索后缀名是 xcconifg 的文件,发现了 Cocoapods 写的那些部分。
完整的 XCConfig 编写可以参考 Github 上这个 仓库。而为啥设成默认就可以了呢?在 Building Setting 里点击 Level 模式查看,发现对于每一项设置,都有 5 个地方可以设置,层级从左到右,如果在左边的层级设置了,就取最左边的设置覆盖右边的,而绿色的框代表每行的设置正在应用的来源是来自哪里,也就是绿色框的代表的是最终的设置。
而加上 ‘$(inherited)’ 的作用,就是让值从下往上透上来,也就是,会把右边的一栏的设置也加进来,右边的如果也有 ‘$(inherited)',那么依次类推。上面五栏里分别 Resolve,Target,ConfigFile,Project,Default。第一层 Resolve 我不太清楚,可能是编译最终修复之类的,第二层就是 target 里修改的,第三层就是读取对应的 xcconfig 后缀文件里的配置,第四层是 Project 里修改的,最后是系统默认的。而这也是为什么一个 Project 可以对应多个 Target 的原因,Target 只是覆盖了设置而已。而 Cocoapods 是把 xcconfig 写好,并让上层设置为 ‘$(inherited)',从而达到把配置写进 Xcode。而在 hook 方法里,把 target 的 config 更改,相当于把上层设置好,取下层 XCConfig 里的值。而我们看到 Cocoapods 默认帮我们生成的 Pod 的 XCConfig 里,里面已经写好了正确的 Library Search Path。
剩下就是 Other Link Flag 了,这个也很好解决,在 podspec 里写上
s.pod_target_xcconfig = {'OTHER_LDFLAGS' => '$(inherited) -l"CDZPicker"'}
就好了。重新 pod install 一下,写上单元测试代码,点击 test,OK,一切顺利。
等等,点击主工程的 Run,好像跑不起来了……
曲线救国解决 Test 库编译问题
看看报错,找不到库的编译产物。思考了一下,应该是因为 Test 的 target 比较特殊,并不会编译自己并产生编译产物。而记得刚才主工程的 Other Linker Flag 吗,里面因为 Cocoapods 认为这个库是个静态库,是有编译产物的且要链接的,所以有”-l’UnitTestPod’"。去掉之后,果然就可以编译成功了。
但是这样不够优雅,每次 Pod install 之后,难道都要这样删掉吗?
这时我想起 Podfile 里一个参数 configurations,一般我们会指定例如 :configurations => ['Debug']
这样来说明这个库在 Debug 下才被编译并链接进主工程。既然这样 Release 模式下就不会被链接,我们就可以利用这个特性,新建一个没用的 Configuration,让 Podfile 指定就可以了。在 Project 的 Info 里新建一个 Test 的 Configuration,并在 Podfile 里的 Pod 指定 :configurations => ['Test']
。
重新 Pod install,可以看到 Debug 的 Other Linker Flag 里已经没有 “-l’UnitTestPod’” 来指定链接 Pod 了。编译自然也就成功了。
最后
最后的结果是,因为我们工程里有很多预编译的库,而预编译的库通过我们公司的一套方案来设置,可以根据每个人的设置,可以选择源码或预编译,而生成的编译产物预编译的有一个前缀。因为没办法确定每个人某个库的依赖的库是否是预编译的,所以静态库的名字是不确定的,可能是有前缀可能没有,和每个人开发环境有关。在 podspec 里也没办法通过“-l‘静态库编译产物名’”来链接到正确的编译产物,也就没办法用这种方式进行下去管理单元测试。没想到最后填坑的结果,却和公司另一套别的方案冲突了。虽然有些难过,但是在研究这个问题的几天里,我一个完全没看过 Ruby 也不了解链接,Cocoapods 也是按着例子写的小白,开始到了解一些 Ruby,Cocoapods 做了什么,Bulding Setting,XCConfig,静态库怎么被链接的知识,还是感觉很开心的。
所有源码和 Demo 如果您觉得有帮助,不妨给个 star 鼓励一下,欢迎关注&交流