基本上所有的 iOSer 都是用过 CocoaPods 来管理 iOS 工程中的三方库,但是很少有系列文章来详细的解读 CocoaPods。笔者决定自行尝试一下。本文所有 CocoaPods 源码使用的是 1.5.3 版本。
Why Ruby?
笔者在日常工作中,在做一关于项目组件管理的效率工具,由于组内技术选型也确定了使用 Ruby 来开发,所以也花了一些时间入门了一下这门语言。纵观 CocoaPods 的代码,其中用到了很多 Ruby 的特性,并且 CocoaPods 的作者们也是深受 RoR 开发模式影响的一些工程师。为了整体把握 CocoaPods 这个项目,Ruby 这门脚本语言也建议去入门学习一下。
在 CocoaPods 中,很多代码片段用到了以下 Ruby 的特性或是 RoR 开发模式:
1. eval
特性
记得我第一次接触 eval
概念是在 SICP 中介绍的元语言抽象。例子讲述了用 Scheme 写出一个 Scheme 的解释器,其中依赖的自循环 Eval/Apply 解释器中,eval 过程即将一个表达式转换为取得其值的过程。
Ruby 也是具有 eval 的动态性,在 pry 中可以使用 eval
方法来测试它:
通过 eval
使得 Ruby 获得了更强大的动态能力,在运行时可以使用字符串来改变逻辑,而不是与传统的手动解析输入和输入语法树。
这个特性会在 cocoapods-core 中大量使用,用来解析 Podfile
、Podfile.lock
和 .spec
文件。
2. Bundler 思想的铺垫
当笔者在入门 Ruby 开始学习写项目的开始,需要查询各种所需要的依赖库。而 rubygems.org 简直就是一个宝地,它例如我们 iOS 开发中 cocoapods.org 的角色,用于托管一个个 Gem 组件,从而避免了重复造轮子。但是只是托管 Gem 组件是远远不够的,因为本地和远程服务器上的组件由于时间原因总会出现版本差异的问题。
Bundler 诞生的原因之一就是需要解决依赖的版本问题。Bundler 使用了很多技巧性和启发式算法来解这个依赖图。从 CocoaPods 中的 Molinillo 算法中可以看出 Bundler 依赖图算法的影子,但是目前还不是特别的敏捷,个人觉得算法应该还有优化空间。另外,当 Bundler 每次运行后会通过一个 Gemfile.lock
来存储可行解,同样的 CocoaPods 中的 Podfile.lock
也是如此,通过 YAML 格式记录。这里推荐一篇文章: 为什么我们要使用 RVM / Bundler。
3. 强大的 plugin 开发能力
对 Class 和 Module 的扩展
这里我所理解的 plugin 开发也就是类似于我们在 iOS 中的 Category (Swift 的 Extension)。我们可以通过依赖库来对主仓 CocoaPods 的 Class 和 Module 进行动态操作。举个例子:
这个是在 CocoaPods 主仓中 downloader.rb
中的代码,其中定义了一些类方法:
但是在 cocoapods-downloader 模块中,Downloader
这个 module 的方法并不能满足全部需求,于是在 api.rb
中会看到这样的代码扩展:
这也就好比在 iOS 开发中对某一个 Class 的 Category 扩展,可以为其增加方法、增加属性,甚至是重写方法。在 GitHub 有近百个关于 CocoaPods 的扩展和优化,这种代码随处可见。
值得一提的,在 Ruby 中使用类似于 ObjC 的 Method Swizzling 也随处可见。Ruby 中可简单的通过 alias_method
来对成员方法、类方法做到方法替换。
Bundler 对于 Gem 开发的本地映射
在 CocoaPods 中,我们可以看到它的 gemfile
的写法是这样:
在 Gemfile
中我们可以看到很多通过 cp_gem
装载的 Gem 库,并且如果发现有与 CocoaPods 项目目录同级的目录,则会使用对应的项目直接通过 Gem 加载。通过简单的目录分割和 Gemfile
管理,就实现了最基本又最直观的热插拔,对组件开发十分友好。所以你只要将多个仓库如下图方式排列,即可实现跨仓库组件开发:
当然选型为 Ruby 一定不仅仅有以下这些好处,有很多东西是我这个非专业 Rubyer 选手可以看出来的。如果有其他的优势可以帮我补充。
组件构成和对应职责
通过上面对于 Gemfile
的简单分析,可以看出 CocoaPods 不仅仅是一个仓库那么简单,它作为一个三方库版本管理工具,对自身组件的管理和组件化也是十分讲究的。我们继续来看这份 Gemfile
:
通过查看 Gemfile
可以看出 CocoaPods 对于组件的拆分粒度是比较细微的,通过对各种组件的组合达到现在的完整版本。这些组件中,笔者也仅仅零散的看过一点,由于组件群过于庞大。但是都能达到拆开去使用的程度。
例如,如果当对构建 Xcode 工程有校本化处理需求的时候,可以使用 Xcodeproj 来打开、修改、保存一个工程。例如使用 xcodeproj
来查看 Sepicat
项目的一些信息:
这里我调用了 Project
这个 Class 的 to_hash
方法,它可以讲 xcodeproj
所有的环境变量以 Hash 方式返回并输出。当然,Xcodeproj 功能不止于此,在后续的系列文章中会有更多的使用方法。这里仅仅是来展示,所有的 Gem 组件是可以单独抽离出来使用的。
从一次 Install 命令来初探 CocoaPods 源码
命令入口
pod install
其实是 iOS 开发者在学习 CocoaPods 时学习的第一个命令,虽然网上已经有很多关于 pod install
的原理解析,但是作为系列第一篇,这个分析仍旧重要。我们打开终端到项目中的 Podfile
所在目录下,输入 pod install
:
每次当输入一个 pod xxx
命令的时候,首先系统会调用这个 pod
命令。所有的命令都是在 /bin
目录下存放的脚本,当然 Ruby 环境的也不例外。我们可以通过 command which pod
来查看命令所在位置:
出现 /Users/gua/.rvm/gems/ruby-2.4.1/bin/pod
而不是 /usr/local/bin/pod
的原因是因为我电脑中的 Ruby 版本是使用 RVM 进行版本控制的,所以会装在这么一个冗长的目录下。我们来看一下这个入口脚本执行了什么:
入口中将命令的执行指向了 Gem 组件的 Path 中,这样就找到了 CocoaPods 的入口脚本,即在 cocoapods/bin
目录下的 pod
。
调用栈输出了两个方法:
ruby_executable_hooks
通过 bin
目录下的 pod
入口唤醒,再通过 eval
的手段调起我们需要的 CocoaPods 工程。这是 Bundler
的自身行为。
在入口的最后部分,发现通过调用 Pod::Command.run(ARGV)
,实例化了一个 CLAide::Command
对象,于是用户输入的命令及层数从此进入CLAide 解析阶段。这里不对 CLAide 这个命令解析工具做过多的分析,这个是后面系列文章的内容,我们仅仅知道,它是一个根据继承来自动生成命令层级的命令解析工具,每次命令的执行,其实是对应到具体 Class 的 run
方法。
首先根据上面的描述,Install
是继承于 Command
命令的,所以对应的命令为 pod install
。pod install
过程是依赖于 Podfile
文件的,所以在入口处会做检测,这个错误信息也是我们经常见到的错误信息之一。在 installer
实例组装完成之后,调用其 installer.install!
方法,这时候才进入了我们 pod install
命令的主体部分。
执行功能主体
接着 installer.install!
,发现其实这也是一个高度封装的入口方法,具体代码如下:
Install 环境准备流程 - prepare
先来看 prepare
方法的实现:
在 prepare
阶段会将 pod install
的准备工作完成,包括版本一致性、目录接口以及 pre-install 的装载插件脚本全部取出。之后我们来到 CocoaPods 中最复杂的依赖解析环节。
依赖解析流程 - resolve_dependencies
依赖解析过程我们暂时只要知道通过 Podfile
、Podfile.lock
、manifest
文件来生成一个 Analyzer 对象。Analyzer 内部会使用 Molinillo (具体的是 Molinillo::DependencyGraph
图算法)从而得到解析结果。我们可以分别输出 @analysis_result
的 podfile_dependency_cache.podfile_dependencies
来查看 Podfile 文件的依赖分析结果,也可以从 specs_by_target
来查看各个 target 相关的 specs。总之,通过 Analyzer 能获取到很多关于依赖的信息。
另外,需要区分的是,在 pod install
的过程有有一个 pre-download 的阶段,也就是我们在 verbose 下看到的 Fetching external sources 过程。这个 pre-download 阶段不属于 Download 过程,而是在当前的 依赖分析过程。这里主要是解决当我们通过 git 地址引入的 Pod 仓的情况,系统无法从默认的 Source 拿到对应的 Spec,需要直接访问我们的 git 地址下载仓库的 zip 包,并取出对应的 podspec
文件,从而拿来分析。
按需下载依赖过程 - download_dependencies
在这一步骤中,最重要的是 install_pod_sources
过程。install_pod_sources
过程会调用对应 Pod 的 install!
方法。在 create_file_accessors
中会创建 sandbox 目录的文件访问器,通过构造 FileAccessor
实例来解析沙盒中的各种文件。
接下来我们主要来看看 install_pod_sources
这个方法的实现:
install_pod_sources
总结下来,就是根据依赖分析的结果,通过 name 拿到所有依赖的 installer
实例。在方法的开始,root_specs
方法是通过 analysis_result
拿出所有根 spec。
下面再来看看 install!
到底做了什么工作。调用 install!
是所有生成 Installer
实例的目的。在 pod_source_installer.rb
中有 install!
方法的实现:
其实 install!
中主要就是将 Pod 通过对应的 Source 下载下来,其中会调用 cocoapods-downloader
组件来处理所有的下载逻辑。
验证 Target 流程 - validate_targets
validate_targets
过程用来验证之前流程中的产物 pod 所生成的 Targets 的合法性方法。这个方法只是简单的验证了一下。validate_targets
方法的主要作用就是构造 TargetValidator
,并执行 validate!
方法:
验证环节在整个 Install 过程中仅占很小的一部分。因为只是验证部分,是完全解耦的。(我尝试过将整个 validate_targets
注释掉,install 过程)
-
verify_no_duplicate_framework_and_library_names
用来验证是否有重名的 framework
,如果有冲突会直接抛出 frameworks with conflicting names 异常。
-
verify_no_static_framework_transitive_dependencies
用来验证动态库中是否有静态库或者 framework 静态库,如果有直接抛出异常。给定一个场景,如果 A 组件依赖 B 组件,B 组件中通过 vendored_libraries
加载的静态库(.a
或者 .framework
),如果此时使用了 use_framework!
,打包的时候,framework
会将 vendored_libraries
中的内容包括进来,这要就在符号决议的时候产生冲突。但其实动态库中依赖静态库这是一种很常见的情况,所以在搜索引擎中检索异常 Log transitive dependencies that include static binaries:
可以查到很多对应解决方案,但主要方法是:
- 修改 pod 库中
podspec
,增加 pod_target_xcconfig
,定义好 FRAMEWORK_SEARCH_PATHS
和 OTHER_LDFLAGS
两个环境变量;
- hook
verify_no_static_framework_transitive_dependencies
的方法,将其干掉!对应 issue
vendored_libraries
用来在 spec
文件中指定依赖的静态库,例如微博 SDK 中的 s.vendored_libraries = 'libWeiboSDK/libWeiboSDK.a'
。
-
verify_swift_pods_have_module_dependencies
用来检测 Swift 的 framework 中是否有 Module Dependencies,如果有的话需要检查这些依赖是否需要进行 build
,判断的条件是:1. 是否有代码文件;2. 是否定义了 module
。目前还没有明确的场景,笔者对 Module Dependencies 的定义也比较模糊,后续跟进;
-
verify_no_pods_used_with_multiple_swift_versions
用来检测是否所有的 Pod Target 中版本一致性问题。
工程文件生成 - generate_pods_project
工程文件的生成是 pod install
的最后一步,他会将之前版本仲裁后的所有组件通过 Project 文件的形式组织起来,并且会对 Project 中做一些用户指定的配置。
在 install
过程中,除去依赖仲裁部分和下载部分的时间消耗,在工程文件生成也会有相对较大的时间开销。这里往往也是速度优化核心位置。在最新的 1.6.1
版本中,这个环节被大量的重构优化,后续我们看以对比两个版本一探究竟。
文件释义
-
Podfile
- 由用户实现的关于 Target 与 Pod 关系的声明规范。注意,这里有 Target 和 Pod 的关系,这些是 Podfile.lock
中无法拿到的。
-
Podfile.lock
- 包括安装 Pod 的所有信息,对应 Pod 的版本号、Git 地址、Branch 信息、Commit 信息、Tag 信息(Git、Branch、Commit、Tag 均为 Git Label 依赖)、Local Path 信息(Development Pod 模式)。也包含了所有 Pod 的最终决议版本、二级子依赖列表、使用的 Source、spec 文件的 Checksum。
- 这里的 Checksum 可以理解成是一个文件的 MD5 编码,不同文件的 Checksum 不同。在 CocoaPods 1.5.3 和 Cocoapods 1.6.1 中,Sepc 文件的 Checksum发生了变化,由之前的原文件变成了 spec 序列化之后的 json 文件的 MD5,这个后续系列文中会讲到。
- Source 的意思是对应 spec 仓库的 Git 地址。一般大厂中往往会自己维护一个或多个 spec 仓库,从而有利于组件版本的控制。
Manifest.lock
- Pods
目录中包含的文件,用于跟踪本地安装的 Pod。如果在 install
过程中检查到与之前的缓存相同就会直接使用缓存版本,而其根据就是 Manifest.lock
文件。在每次 install
完毕后,Manifest.lock
会被重写成最新的 Podfile.lock
文件。
结语
当我们知道 CocoaPods 在 install
的大致过程后,我们可以对其做一些修改和控制。例如知道了 pre_install
和 post_install
的具体时机,我们就可以在 Podfile
中执行对应的 Ruby 脚本,达到我们的预期。
后续,学习 CocoaPods 中每一个组件的实现,将所有的问题在代码中找到答案。