iOS 基于 Cocoapods 插件进行组件二进制的探索

7,713 阅读27分钟

前言

目前已知的组件二进制开源方案都是采用 Cocoapods Plugin 的方式实现的,比如说二进制插件 cocoapods-bin,基于插件化能力和 Ruby 语言的一些特性,使它能够很容易的围绕 Cocoapods 的源码来制作。Cocoapods 插件需要使用 Ruby 编写,对 Cocoapods 源码以及 Ruby 语言特性不熟悉的同学(我理解多数 iOS 同学可能都不会太熟悉)会比较难理解。

各个公司的开发环境、开发习惯等不同可能会导致二进制方案产生差异性,所以对二进制插件进行定制化在所难免。无意造轮子,有意理解轮子,本篇主要围绕 cocapods-bin 这个 gem,从 iOS 程序员的角度进行细节分析,力争让读者看完本篇后,能够明白二进制插件背后到底是什么。如果你的公司也需要做组件二进制,那这将会很有帮助。

组件私有化

在组件化架构上,各家几乎都是基于 Cocoapods 工具来进行代码隔离和版本管理,如果你对组件私有化很了解可以跨过此节直接看二进制方案。我们最初使用 :path => 的方式将代码进行了本地隔离,虽然只是完成了组件拆分,但也带来了一定的收益,比如说不同业务线间减少了代码冲突。另外 Cocoapods 的文件管理方式,让.xcodeproj 不再因为有人增删文件而产生不必要的冲突。

虽然可以将业务线间的本地代码隔离,但并未让代码仓库实现隔离,所有人仍然是在同一代码仓库下开发,随着公司业务增长,仓库代码量也在迅速增加,组件在一年多的时间突破到了 40 多个,考虑提升代码编译速度和大家工作效率问题,需要完成组件的二进制工作。

在实现二进制前,还有很多工作要做,首当其冲的就是要将组件私有化。cd 到 ~/.cocoapods/repos 目录下,在第一次安装完 cocoapods 工具后,会去国内的源(未科学上网)拉一个 master 下来,这个 master 就是被官方管理的公有源仓库。所有开源代码,如果支持 pod 管理,它的 podspec 文件都会在这里面(没有的话 pod update 一下就有了)。那么不妨回顾一下,在将代码开源到 git 上的时候,都需要做什么:

  1. 将最后一次修改提交。
  2. 增加 tag 并推送到远程。
  3. 执行 pod spec lint 命令验证 podspec 合法性。
  4. 执行 pod trunk push 提交 podspec。

执行 pod trunk push 的时候,实际上就是将 podspec 提交到了 Cocoapods 的官方源。私有组件和它一样的道理,只不过是将 podspec 文件提交到了自己创建的仓库,这个仓库通常称之为私有源。关于组件私有化这块,网上有很多相关教程这里不做详细说明,其实整个过程可以简单描述如下:

  1. 准备两个 git 仓库,一个用来装提交的 podspec 文件,后面会将其称之为私有源。一个用来装真实的组件,后面会将其称之为组件仓库。
  2. 执行 pod lib create 命令构建模版工程,并完善组件功能。
  3. 组件完善后编辑 podspec,指定 versionsourcesource_files 等,提交组件到组件 git 仓库,podspec 是对组件的描述(实际它是一段 ruby 脚本,后面会细说 podspec 读取),其中包括组件版本、位置、代码位置等等。可以理解为其他人拿到你的 podspec 就可以通过 Cocoapods 安装你的代码了。
  4. 执行 git taggit push --tags 命令,打上 tag,tag 打上后实际上远程 git 仓库会构建对应 tag 版本的压缩包,我们平时在 podfile 中指定三方库版本对应的就是这个tag。
  5. 执行 pod spec lint --allow-warnings 命令验证 podspec 文件合法性,通常会加上 allow-warnings option。
  6. 执行 pod repo push 命令将 podspec 推送到私有源仓库。

这时候团队其他人如果依赖了你的组件,执行 pod repo update 即可(前提他的 podfile 已经配置了私有源,不然会报错找不到,因为 Cocoapods 内部会遍历所有源包括公有源去找这个 podspec,如果没有指定肯定就是找不到了,当然也可以通过自定义 Pod Plugin 来解决,关于 Cocoapods 获取源的过程后面会细说)。pod update 的过程就是将你提交的最新版本 podspec 更新到本地私有源,再更新 podfile.lock,执行 pod install。

在实施组件私有化后,就真正实现了代码仓库隔离,工程架构演变至如下:

各业务线同学都会在自己的业务组件内开发,需求开发完成后将 podspec 提交到私有源,壳工程执行 pod update 即可将新开发的业务组件更新下来,就可以直接打包提测了。其实组件拆分算得上是体力活,但这是二进制的基础,这个结构搭不好二进制将无从谈起。下面进入主题,聊聊二进制的探索。

题外话:在 podfile 和 podspec 内是否要指定依赖的组件版本问题上产生过困扰,目前有两个方案可供参考:

  1. podfile 和 podspec 都做版本记录,组件发布新版本需要周知依赖方进行更新。
  2. podfile 做版本记录,podspec 不做版本记录,将依赖组件包括所有子节点组件版本全部定义在 podfile 内,有版本升级周知。

第二种好处在于只修改 podfile 即可,无须修改相关的 podspec。

单私有源方案

二进制目前市场上有单私有源、双私有源两种可行方案,下面对这两种方案进行下简单的说明:

单私有源指的是只有一个装 podspec 的私有仓库,也就是上面图中的 PrivateRepo 仓库。那么一个仓库怎么实现源码与二进制的切换呢?其实也很简单,通过在 podspec 配置环境变量即可,总结有如下几步:

  1. 在 Class 同级目录下创建 Lib 文件夹,将二进制 framework 拷贝其中,并推送至远程仓库 ,组件目录结构 tree 一下:
wangkai@192 HelloMoto % tree
.
├── Assets
│   └── Media.xcassets
│       ├── Contents.json
│       └── cb_fx.imageset
│           ├── Contents.json
│           ├── cb_fx@2x.png
│           └── cb_fx@3x.png
├── Classes
│   ├── PHHelloMoto.h
│   └── PHHelloMoto.m
└── Lib
    └── HelloMoto.framework
        ├── Headers -> Versions/Current/Headers
        ├── HelloMoto -> Versions/Current/HelloMoto
        ├── Resources -> Versions/Current/Resources
        └── Versions
            ├── A
            │   ├── Headers
            │   │   ├── PHHelloMoto.h
            │   │   ├── PHHelloMotoYellowView.h
            │   │   ├── PHTestView.h
            │   │   └── PHtest.h
            │   ├── HelloMoto
            │   └── Resources
            │       └── HelloMoto.bundle
            │           ├── Assets.car
            │           └── Info.plist
            └── Current -> A
  1. 通过环境变量,修改 podspec 的 sourcefile 指向:
if ENV['IS_SOURCE'] || ENV["#{s.name}_SOURCE"]
  s.source_files = "#{s.name}/Classes/**/*"
else
  s.ios.vendored_frameworks = "#{s.name}/Lib/#{s.name}.framework"
end
  1. 设置 preserve_paths
s.preserve_paths = "#{s.name}/Lib/**/*.framework","#{s.name}/Classes/**/*"

podspec 中配置 preserve_paths,确保缓存中同时存在源码和二进制的资源及文件,因为 pod 的缓存机制,如果不设置的话在源码和二进制切换时会产生文件的丢失,导致切换时会产生不可预知的问题。

  1. 回顾一下上面的步骤,将 podspec 发布到 PrivateRepo 即可。

完成上面的配置,通过在终端输入:IS_SOURCE pod installpod install 来安装源码和二进制 。如果想要某个库是源码,其他的库为二进制形式,以上图 HelloMoto 为例,通过输入 HelloMoto_SOURCE pod install 即可让 HelloMoto 为源码形式,其他的组件库为二进制形式。

虽然单私有源单版本的方案可以实现源码与二进制的转换,但是我们觉得这个方案存在以下不妥:

  1. 如果想要对多个组件进行二进制源码的切换将会非常繁琐,pod 命令因为要在终端输入 SOURCE 的缘故也会变得非常长。

  2. 破坏了 pod 的缓存机制,pod 的缓存流程可以简单理解如下:

通过上面读取缓存的流程可以看出,如果组件本地只有源码的形式存在,会无法安装二进制,因为本地已经存在了就不会再去 git 上拉取二进制了。这个问题也可以解决,按照上图的思路,将一级和二级缓存删除掉,这样 pod 会直接去下载 git 上的组件进行安装。

参考:《iOS CocoaPods组件平滑二进制化解决方案》

考虑到团队内不可能每个人都对这些流程很熟悉的缘故,我们觉得这会对大家日常工作影响较大,毕竟它对 Cocoapods 的缓存机制有所入侵,另外随着二进制版本增多,git 仓库也会越来越庞大,最终进一步调研了双私有源方案。

双私有源方案

双私有源的方案是本篇的重点,cocoapods-bin 正是采用的这种方案,它指的是有两个装 podspec 的仓库,一个装源码的 podspec,例如前面说到的 PrivateRepo 仓库,另一个装二进制版本的 podspec,暂时将它起名叫 PrivateRepo_Bin,另外还需要一个静态服务器,用来存储二进制的 zip 包,供别人安装。

双私有源的方案相对单私有源来说稍复杂些,额外需要将二进制包上传到 zip 服务器中,再生成一个二进制版本的 podspec,将其发布到二进制私有源 。让团队的所有同学都来维护二进制版本的 podspec 和二进制 zip 包无疑会严重拖累大家的工作效率,cocoapods-bin 这类插件正是为了解决这些问题,后面会通过 cocoapods-core 源码和 cocoapods-bin 源码来分析二进制插件的背后原理。

源码 podspec 和二进制 podspec 的大致区别如下:

{
  ...省略
  "source": {
    "git": "https://github.com/GitWangKai/HelloMoto.git",
    "tag": "0.1.0",
  },
  "resource_bundles": {
    "HelloMoto": [
      "HelloMoto/Assets/**/*.xcassets"
    ]
  },
  "source_files": "HelloMoto/Classes/**/*",
}
{
  ...省略
  "source": {
    "http": "http://localhost:8080/frameworks/HelloMoto/0.1.0/zip",
    "type": "zip"
  },
  "resources": [
    "HelloMoto.framework/Versions/A/Resources/*.bundle"
  ],
  "ios": {
    "vendored_frameworks": "HelloMoto.framework"
  },
}

以 framework 形式为例。

它们主要区别在 source_files 和 vendored_frameworks,将源码的 podspec 修改一下,通过 pod repo push PrivateRepo_Bin HelloMoto.podspec 命令将其发布到 PrivateRepo_Bin 仓库。双私有源的架构图如下:

先忽略 .binary.podspec 的奇怪命名。

前言提到的插件都是基于双私有源的思路实现,下面围绕 cocoapods-bin 这个插件,看一下双私有源方案的具体实现。因为 cocoapods-bin 是一个 Cocoapods 插件,在这之前有必要先了解一下 Cocoapods Plugin 相关概念。

Cocoapods plugin 体系梳理

RVM

Cocoapods 是基于 ruby 编写的,RVM 是 ruby 的版本管理器,支持在多个 ruby 版本中快速切换。使用 Cocoapods 工具的前提是安装了 RVM,终端输入 rvm list known 可以查看所有可以安装的 ruby 版本,通过 rvm install 可以安装指定版本 ruby 环境。

RubyGems

RubyGems 是 ruby 的一个包管理器,它提供一个分发 ruby 程序和库的标准格式,还提供一个管理 gem 安装的工具。

终端执行 gem list,可以看到所有被管理的 ruby 包,如果安装了 cocoapods,它也会在其中。开发好的插件可以通过 gem install 命令进行安装,gem uninstall 命令进行卸载。Cocoapods 插件也是一个 gem,可以通过 gem push 命令发布到 RubyGems.org 供其他人安装,更多细节参阅 rubygems.org

Bundler

管理项目依赖的工具,可以隔离不同项目中 Gem 的版本和依赖环境的差异。它也是用 ruby 写的 Gem 插件。在 pod 插件 create 完成后,会生成 Gemfile,类似 podfile 一样,Gemfile 内部会配置各种依赖:

source 'https://rubygems.org'
gem 'cocoapods-testplugin' , :path => "./cocoapods-testplugin"
group :debug do
    gem 'ruby-debug-ide'
    gem 'debase','0.2.5.beta1'
    gem 'rake','13.0.0'
    gem "cocoapods", '1.9.3'
    gem "cocoapods-generate",'2.0.0'
end

执行 bundle install 安装依赖的 Gem 后会生成相应的 Gemfile.lock。Bundler 依据项目中的 Gemfile 文件来管理 Gem,而 CocoaPods 通过 Podfile 来管理 Pod,如出一辙。

Plugin

在终端输入 pod plugins installed 命令即可查看安装的所有 cocoapods 插件,例如如下是我的机器安装的所有 cocoapods 插件:

wangkai@192 ~ % pod plugins installed

Installed CocoaPods Plugins:
    - cocoapods-deintegrate                 : 1.0.4
    - cocoapods-disable-podfile-validations : 0.1.1
    - cocoapods-generate                    : 2.0.0
    - cocoapods-packager                    : 1.5.0
    - cocoapods-testplugin                  : 0.2.1 (pre_install and
    source_provider hooks)
    - cocoapods-plugins                     : 1.0.0
    - cocoapods-search                      : 1.0.0
    - cocoapods-stats                       : 1.1.0 (post_install hook)
    - cocoapods-trunk                       : 1.5.0
    - cocoapods-try                         : 1.2.0
wangkai@192 ~ % 

其中 cocoapods-testplugin 是自定义的二进制插件 demo,提示 (pre_install and source_provider hooks) 是因为插件内部 hook 了 pre_install 和 source_provider 方法,这块后面会详细说。

还有一个很重要的插件 cocoapods-plugins,它专门用来负责插件的管理,比如查看、搜索、创建等。

Cocoapods 支持自定义插件,基于 cocoapods-plugins, 在终端执行 pod plugins create testplugin 命令即可创建出插件模版工程,tree 一下工程目录:

wangkai@192 cocoapods-testplugin % tree
.
├── Gemfile
├── LICENSE.txt
├── README.md
├── Rakefile
├── cocoapods-testplugin.gemspec
├── lib
│   ├── cocoapods-testplugin
│   │   ├── command
│   │   │   └── testplugin.rb
│   │   ├── command.rb
│   │   └── gem_version.rb
│   ├── cocoapods-testplugin.rb
│   └── cocoapods_plugin.rb
└── spec
    ├── command
    │   └── testplugin_spec.rb
    └── spec_helper.rb

GemFile 有点类似于 Podfile,这里面包含了对其他插件的依赖,pod 用来管理 pod 依赖,gem 用来管理 gem 之间的依赖,这个很好理解。

cocoapods-testplugin.gemspec 在功能上等同于 podspec 文件:

Gem::Specification.new do |spec|
  spec.name          = 'cocoapods-testplugin'
  spec.version       = CocoapodsTestbin::VERSION
  spec.authors       = ['GitWangKai']
  spec.email         = ['xxx@163.com']
  spec.description   = %q{A short description of cocoapods-testbin.}
  spec.summary       = %q{A longer description of cocoapods-testbin.}
  spec.homepage      = 'https://github.com/EXAMPLE/cocoapods-testbin'
  spec.license       = 'MIT'

  spec.files         = `git ls-files`.split($/)
  spec.executables   = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
  spec.test_files    = spec.files.grep(%r{^(test|spec|features)/})
  spec.require_paths = ['lib']

  spec.add_development_dependency 'bundler', '~> 1.3'
  spec.add_development_dependency  'rake'
end

管理发布版本等描述信息,包括对其他插件的依赖等等,描述与 podspec 基本一致。

lib 下是我们开发插件代码编写的位置,编写的命令代码全部放在 command 下,我们最常用的 pod install 命令执行代码也是在 cocoapods-core 的 command 下。

gemspec 中的 dependency 记得加版本指定,否则使用插件时终端会抛警告。

CLAide

CLAide 是一个命令行解释器,用来负责解析我们在终端输入的常用 pod 命令。

module CLAide
  # @return [String]
  #
  #   CLAide’s version, following [semver](http://semver.org).
  #
  VERSION = '1.0.3'.freeze

  require 'claide/ansi'
  require 'claide/argument'
  require 'claide/argv'
  require 'claide/command'
  require 'claide/help'
  require 'claide/informative_error'
end

pod 的命令都是继承自 CLAide 的 Command 类:

 class Command < CLAide::Command
    ...
    require 'cocoapods/command/init'
    require 'cocoapods/command/install'
    require 'cocoapods/command/update'
    ...
end

CLAide 在接收到命令后会遍历所有子类找到输入的 commond,执行对应 ruby 脚本。

制作 Pod 插件环境配置可以参考 rubymine调试cocoapods插件

了解了 pod plugin 相关概念后,就可以开始编写二进制插件了,plugin 因为是被 RubyGems 管理,所以要用 ruby 编写,不熟悉 Ruby 的话需要 菜鸟教程 一下。工具使用很简单,背后的黑魔法才是值得我们理解和思考的。

二进制

iOS 有两种类型的静态库,一种是 .a 后缀,另一种是 .framework 后缀结尾,其实它们本质没有什么区别,都是被多个 .o 打包而成,只不过 .a 是一个纯二进制文件,需要配合 .h 和资源文件一起使用,.framework 内包含头文件和资源文件可以直接使用。但是引用 .framework 需要使用 <> 方式,.a 库可直接使用 "" ,具体使用那种格式可以酌情而定。

二进制打包

关于二进制打包已知两种方案:

cocoapods-packager

cocoapods-packager 是一个开源的构建二进制插件,执行 pod package xxx.podspec ,解析 xxx.podspec, 根据 podspec 内指定的版本去 git 找到对应 tag 下载下来,执行 xcodebuild 命令构建 framework。但它存在一些弊端:

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

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

cocoapods-generate

cocoapods-generate 是 cocoapods-packager 作者的另一个插件,它提供了构建工程的能力,和 cocoapods-packager 相比缺失了构建 framework 功能。但它有个好处,不依赖 git,可以直接根据提供的 podspec 文件在本地生成对应的工程。生成工程后,可以自定义打包脚本,使用 xcodebuild 相关命令构建对应二进制。开发 Cocoapods Plugin 的时候,配置上 Gemfile 依赖即可使用 cocoapods-generate:

group :debug do
    gem 'ruby-debug-ide'
    gem 'debase','0.2.5.beta1'
    gem 'rake','13.0.0'
    gem "cocoapods", '1.9.3'
    gem "cocoapods-generate",'2.0.0'
end

执行 bundle install 后,就可以直接在插件脚本内使用了:

require 'cocoapods/generate'
argvs = [
]

gen = Pod::Command::Gen.new(CLAide::ARGV.new(argvs))
gen.validate!
gen.run

argvs 中的参数根据需要添加即可。参数说明:README.md

在 ruby 中,Pod 是一个 Module,它提供了单独的命名空间,Command 是 Pod 中的类 Class,:: 意为返回某个 Class 或 Class Method,Pod::Command::Gen.new 表示实例化一个 Gen 类型的实例。

工程构建完成后可通过 xcodebuild 命令,自定义脚本代替 cocoapods-packager 的打包功能,核心思路如下:

构建模拟器静态库文件:

xcodebuild ARCHS='i386 x86_64' OTHER_CFLAGS='-fembed-bitcode -Qunused-arguments' CONFIGURATION_BUILD_DIR='输出路径' clean build -configuration ‘环境’ -target '目标target'

构建真机静态库文件:

xcodebuild ARCHS='arm64 armv7' OTHER_CFLAGS='-fembed-bitcode -Qunused-arguments' CONFIGURATION_BUILD_DIR='输出路径' clean build -configuration ‘环境’ -target '目标target'

合并.a/.framework:

lipo -create -output '输出目录' '各种.a路径'

合并完后拷入对应的头文件、证书、资源文件等即可组成完整的 framework 库。这块可以结合着 cocoapods-bin 的源码来看。

二进制上传

二进制上传主要是配置环境:

  1. 二进制文件上传前需要先搭建 mongodb 数据库,用来存储二进制相关信息,例如包名、版本等。可以直接通过 Homebrew 执行 brew install mongodb-community@4.2 安装,推荐一个 mongodb 的可视化工具:Robo 3T

  2. 下载 binary-server 代码,在 mongodb 跑起来之后,cd 到 binary-server 下,执行 npm install 和 npm start。

如上所示就表示 node 服务已经跑起来了。

  1. 终端执行上传命令(详细参考 binary-server/README.md):
curl '上传url' -F "name=#{@spec.name}" -F "version=#{@spec.version}" -F "annotate=#{@spec.name}_#{@spec.version}_log" -F "file=@#{zip_file}

文件上传使用 curl -F 命令,curl 是常用的命令行工具,用来请求 Web 服务器,-F 意为 form-data,默认会为 HTTP 请求加上标头 Content-Type: multipart/form-data

当然 curl 命令也可以写在 Cocoapods 插件内,在二进制打包完成后自动将其上传至二进制服务器。

发布二进制 podspec

创建二进制 podspec

了解二进制 podspec 生成之前,需要先了解 Cocoapods 是如何读取 podspec 文件的。在执行 pod install 后,Cocoapods 在解析依赖的过程中,根据 podfile.lock 指定的版本,构建 Specification(定义在 cocoapod-core 中用来描述 podspec 的对象) 对象。

# cocoapods-core/specification.rb

def self.from_file(path, subspec_name = nil)
  #目标 .podspec 的本地路径
  path = Pathname.new(path)
  #校验 podspec 是否存在
  unless path.exist?
    raise Informative, "No podspec exists at path `#{path}`."
  end
  #文件转为 utf-8 格式字符串
  string = File.open(path, 'r:utf-8', &:read)
  # Work around for Rubinius incomplete encoding in 1.9 mode
  if string.respond_to?(:encoding) && string.encoding.name != 'UTF-8'
    string.encode!('UTF-8')
  end
  #执行或解析string
  from_string(string, path, subspec_name)
end

将文件转换为 utf-8 字符串传递给 from_string 进行处理:

# cocoapods-core/specification.rb

def self.from_string(spec_contents, path, subspec_name = nil)
  path = Pathname.new(path).expand_path
  spec = nil
  case path.extname
  #解析 .podspec
  when '.podspec'
    Dir.chdir(path.parent.directory? ? path.parent : Dir.pwd) do
      #通过 eval 执行 Pod::Specification::DSL 内定义的方法
      spec = ::Pod._eval_podspec(spec_contents, path)
      unless spec.is_a?(Specification)
        raise Informative, "Invalid podspec file at path `#{path}`."
      end
    end
   #解析 .json
  when '.json'
    #string 转为 hash 存储到 Specification 中
    spec = Specification.from_json(spec_contents)
  else
    raise Informative, "Unsupported specification format `#{path.extname}` for spec at `#{path}`."
  end

  spec.defined_in_file = path
  spec.subspec_by_name(subspec_name, true)
end

由此可以看出,podspec 文件支持两种扩展名,podspecjson,分别以不同的方式处理。

  • podspec :通过 eval() 函数将字符串转为代码执行,podspec 文件内的 Pod::Spec.new 字符串会初始化为 Specification 对象(Specification 类定义在 cocoapods-core 中),解析 podfile 思路也是如此,后面在二进制源码切换小节会细说。

eval 函数属于 Ruby 的黑魔法特性之一,类似于 OC 的消息转发动态执行方法。Ruby 可以直接将字符串作为方法和参数直接执行,而 OC 想要实现这样的功能就相对复杂了,感兴趣的同学可以参考下热更框架 OCEval

  • json: 直接执行 from_json,内部会将 json 转为 hash 存储到 Specification 对象内:
#cocoapods-core/specification/json.rb

def self.from_hash(hash, parent = nil, test_specification: false, app_specification: false)
  attributes_hash = hash.dup
  #初始化 Specification 对象
  spec = Spec.new(parent, nil, test_specification, :app_specification => app_specification)
  subspecs = attributes_hash.delete('subspecs')
  testspecs = attributes_hash.delete('testspecs')
  appspecs = attributes_hash.delete('appspecs')

  ## backwards compatibility with 1.3.0
  spec.test_specification = !attributes_hash['test_type'].nil?

  spec.attributes_hash = attributes_hash
  spec.subspecs.concat(subspecs_from_hash(spec, subspecs, false, false))
  spec.subspecs.concat(subspecs_from_hash(spec, testspecs, true, false))
  spec.subspecs.concat(subspecs_from_hash(spec, appspecs, false, true))

  spec
end

了解了 podspec 解析过程,就能很好理解二进制 podspec 创建思路了:

  1. 通过 from_file 方法将本地源码的 podspec 文件构建为 Specification 对象。
  2. 删除 Specification 中的 source_files 属性,增加 vendored_frameworks 属性。

前提是采用了 .framework 形式,如果是 .a 形式需要添加 "resources", "public_header_files", "vendored_libraries",替换 "source", "source_files"

  1. 将 Specification 对象转为 json 文件保存到本地(就是上面看到的 binary.podspec.json 文件)。

详细代码参考:cocoapods-bin/helpers/spec_creator.rb

需要注意的是,cocoapods-bin 内将二进制 podspec 文件名定义如下:

cocoapods-bin/helpers/spec_files_helper.rb

def filename
  @filename ||= "#{spec.name}.binary.podspec.json"
end

binary.podspec.json 后缀结尾,Cocoapods 在解析 podspec 文件过程中会通过调用 specification_path 方法,返回源内的 podspec 路径:

#cocoapods-core/source.rb

def specification_path(name, version)
  raise ArgumentError, 'No name' unless name
  raise ArgumentError, 'No version' unless version
  #拼接文件路径
  path = pod_path(name) + version.to_s
  #拼接 podspec.json 后缀
  specification_path = path + "#{name}.podspec.json"
  unless specification_path.exist?
    #拼接 .podspec 后缀
    specification_path = path + "#{name}.podspec"
  end
  #都不存在报错
  unless specification_path.exist?
    raise StandardError, "Unable to find the specification #{name} " \
          "(#{version}) in the #{self.name} source."
  end
  #返回完整文件路径
  specification_path
end

可以看到 Cocoapods 内部的原始匹配规则为 podspec.json 后缀和 podspec 后缀,如果是其他扩展名会进入 raise 报错找不到。对于二进制版本的 binary.podspec.json后缀,需要重写 Source 类的 specification_path 方法,修改 specification_path,扩充 VALID_EXTNAME 来返回二进制版本 podspec 路径,否则会报错:podspec 在二进制源内找不到:

cocoapods-bin/native/source.rb

module Pod
  class Source
    def specification_path(name, version)
      raise ArgumentError, 'No name' unless name
      raise ArgumentError, 'No version' unless version
      path = pod_path(name) + version.to_s
      #遍历 VALID_EXTNAME 判断文件是否存在
      specification_path = Specification::VALID_EXTNAME
                           .map { |extname| "#{name}#{extname}" }
                           .map { |file| path + file }
                           .find(&:exist?)
      unless specification_path
        raise StandardError, "Unable to find the specification #{name} " \
          "(#{version}) in the #{self.name} source."
      end
      specification_path
    end
  end
end

Open Class。ruby 的另一个特性,这个特性可以让它对任一模块内的方法进行扩充和替换。基于 Ruby 的 Open Class 特性重写 specification_path 方法,关于这个特性,后面 podfile 解析自定义 DSL 还会用到。

在源代码的基础上扩充了校验规则:

VALID_EXTNAME = %w[.binary.podspec.json .binary.podspec .podspec.json .podspec].freeze

在执行 pod install 后,关于 podspec 的匹配规则可以简单描述如下:

  1. 取出 podfile 配置的 pod 库。
  2. 取出该 pod 在源内的所有版本。
  3. 找到 podfile.lock 对应库的版本。
  4. 根据 podfile.lock 中对应的版本号,取出对应的 podspec。

详细过程感兴趣的同学可以深入 Cocoapods 源码钻研一下。

发布二进制 podspec

发布二进制基于 Cocoapods 的 cocoapods/command/repo/push.rb ,这也是 pod repo push 命令的执行文件。

cocoapods-bin/command/bin/repo/push.rb

argvs = [
  # 二进制源
  repo,
  # 依赖源
  "--sources=#{sources_option(@code_dependencies, @sources)}",
  # 其他的参数
  *@additional_args
]
# 前面创建的二进制spec文件
argvs << spec_file if spec_file
if @loose_options
  argvs += ['--allow-warnings', '--use-json']
  if code_spec&.all_dependencies&.any?
    argvs << '--use-libraries'
  end
end
# 执行 pod repo push 命令
push = Pod::Command::Repo::Push.new(CLAide::ARGV.new(argvs))
push.validate!
push.run

需要注意的是,podspec push 到二进制 repo 时会默认执行 lint 验证过程。lint 的过程会执行 Linter 对象的 lint 方法进行校验:

#cocoapods-core/specification/linter.rb

def lint
  @results = Results.new
  if spec
    #检查podspec文件内定义的 s.name 是否与文件名匹配。
    validate_root_name
    #检查定义的属性是否都有值。
    check_required_attributes
    #检查requires_arc
    check_requires_arc_attribute
    #执行hook方法
    run_root_validation_hooks
    #支持的多个平台进行验证
    perform_all_specs_analysis
  else
    results.add_error('spec', "The specification defined in `#{file}` "\
      "could not be loaded.\n\n#{@raise_message}")
  end
  results.empty?
end

在 validate_root_name 中会检查 podspec 文件内定义的 s.name 是否与文件名匹配:

#cocoapods-core/specification/linter.rb

def validate_root_name
  if spec.root.name && file
    acceptable_names = [
      spec.root.name + '.podspec',
      spec.root.name + '.podspec.json',
    ]
    names_match = acceptable_names.include?(file.basename.to_s)
    unless names_match
      results.add_error('name', 'The name of the spec should match the ' \
                        'name of the file.')
    end
  end
end

由于在制作二进制 podspec 的时候,扩展名定义为了:binary.podspec.json,所以这里校验不能通过。基于 Open Class 特性,重写 Linter 的 validate_root_name 方法,修改校验规则验证即可通过:

cocoapods-bin/native/linter.rb

module Pod
  class Specification
    class Linter
      def validate_root_name
        if spec.root.name && file
          acceptable_names = Specification::VALID_EXTNAME.map { |extname| "#{spec.root.name}#{extname}" }
          names_match = acceptable_names.include?(file.basename.to_s)
          unless names_match
            results.add_error('name', 'The name of the spec should match the ' \
                              'name of the file.')
          end
        end
      end
    end
  end
end

二进制源码切换

podfile 解析

二进制源码切换需要先理解 podfile 是如何被 cocoapods 加载的,理解了上面创建二进制 podspec 的过程就很容易理解 podfile 是如何被加载的了,因为它们如出一辙。

在执行 pod install 后,内部具体的实现如下,进入 install.rb 的 run 方法:

#cocoapods/command/install.rb

def run
  # 检查 podfile
  verify_podfile_exists!
  # 初始化 installer 对象
  installer = installer_for_config
  # 是否更新 repo 默认是 false,这也是 pod install 和 pod update 的本质区别
  installer.repo_update = repo_update?(:default => false)
  installer.update = false
  installer.deployment = @deployment
  installer.clean_install = @clean_install
  # 执行install!
  installer.install!
end

在执行 pod install 或 pod update 的时候, cocoapods 内部会先执行 verify_podfile_exists!:

#cocoapods/command.rb

def verify_podfile_exists!
  unless config.podfile
    raise Informative, "No `Podfile' found in the project directory."
  end
end

config.podfile 正是 podfile 读取的入口:

#cocoapods/config.rb

def podfile
  @podfile ||= Podfile.from_file(podfile_path) if podfile_path
end

||= 是 ruby 中的懒加载。

最后会进入 podfile 对象中的 self.from_file 方法:

def self.from_file(path)
  path = Pathname.new(path)
  unless path.exist?
    raise Informative, "No Podfile exists at path `#{path}`."
  end
  # podfile 文件额外支持扩展名为 .podfile .rb .yaml 的文件
  case path.extname
  when '', '.podfile', '.rb'
    Podfile.from_ruby(path)
  when '.yaml'
    Podfile.from_yaml(path)
  else
    raise Informative, "Unsupported Podfile format `#{path}`."
  end
end

from_ruby 为例探究一下 podfile 的解析过程:

# podfile.rb

def self.from_ruby(path, contents = nil)
  ...删除了一些检查格式的代码
  # 返回 podfile 并保存到全局的 config 中
  podfile = Podfile.new(path) do
    # rubocop:disable Lint/RescueException
    begin
      # rubocop:disable Eval
      eval(contents, nil, path.to_s)
      # rubocop:enable Eval
    rescue Exception => e
      message = "Invalid `#{path.basename}` file: #{e.message}"
      raise DSLError.new(message, path, e, contents)
    end
    # rubocop:enable Lint/RescueException
  end
  podfile
end

Ruby 的 begin rescue 语法等同于 try catch,从 begin 到 rescue 中的一切是受保护的。如果代码块执行期间发生了异常,控制会传到 rescue 和 end 之间的块,然后执行 rescue 内的逻辑。

do end 语句是 ruby 的一个特有语法,可以理解为 OC 的 block。

Podfile.new 会调用到 podfile 的 initialize 方法,其中 podfile 文件路径和 block 为初始化方法的参数:

# podfile.rb

def initialize(defined_in_file = nil, internal_hash = {}, &block)
  self.defined_in_file = defined_in_file
  @internal_hash = internal_hash
  if block
    default_target_def = TargetDefinition.new('Pods', self)
    default_target_def.abstract = true
    @root_target_definitions = [default_target_def]
    @current_target_definition = default_target_def
    instance_eval(&block)
  else
    @root_target_definitions = []
  end
end

instance_eval 是 Ruby 执行代码块 block 的一种方式,代码块中定义的方法会成为 podfile 的类方法。除了 instance_eval,在 Ruby 的特性中还有 class_eval 方法,它会让代码块中定义的方法成为 podfile 对象的实例方法,跟它们的语义正好相反,后面自定义配置文件小节还会用到。

如果 block 存在,会通过 instance_eval 继续执行 block 代码块,此时回到上面的 do end 代码块。contents 是将 podfile 文件内的内容转换成 utf-8 格式的字符串,通过 eval() 函数将其执行。

podfile 内的所有方法都定义在 dsl.rb 中。以 platform :ios, '8.0' 为例,eval 函数执行后,会执行 Pod::Podfile::DSL 内定义的 platform 函数:

# podfile/dsl.rb

def platform(name, target = nil)
  # Support for deprecated options parameter
  target = target[:deployment_target] if target.is_a?(Hash)
  current_target_definition.set_platform!(name, target)
end

其中 name 为 :ios,target 为 “8.0”,最后会将它们存储到 podfile 对象内定义的 internal_hash 字典中,后面解析依赖的时候,会到 hash 表中获取这个版本号,校验 pod 库版本是否支持 ios8.0:

def set_hash_value(key, value)
  unless HASH_KEYS.include?(key)
    raise StandardError, "Unsupported hash key `#{key}`"
  end
  internal_hash[key] = value
end

其中 key 为 platform,value 为 {ios:8.0}。podfile 内定义的 DSL 都会被以这样的方式执行。

自定义 DSL

既然 podfile 的方法都定义在了 dsl.rb 中,那么是否可以通过定义 dsl.rb 的扩展来自定义 podfile 内的 DSL 呢,答案当然是可以的。还是基于 Open Class 特性,可以让它对任一模块内的方法进行扩充和替换,包括 DSL module。

module Pod
  class Podfile
   module DSL
    # 对 podfile/dsl.rb 扩充的自定义DSL
    def set_use_source_pods(pods)
      hash_pods_use_source = get_internal_hash_value(USE_SOURCE_PODS) || []
      hash_pods_use_source += Array(pods)
      set_internal_hash_value(USE_SOURCE_PODS, hash_pods_use_source)
    end
   end
 end
end
def set_internal_hash_value(key, value)
  # key 为 USE_SOURCE_PODS,value 为传入的 pods 数组。
  internal_hash[key] = value
end

如上所示,例如在 podfile 内添加 set_use_source_pods ['testPod'] 代码,在 pod install 或 pod update 的时候 set_use_source_pods 就可以被执行了。

理解了上面的过程,就能很好的理解二进制与源码的切换了。podfile 是被 git 管理的,将自定义 DSL 直接配置在 podfile 内显然是不合理的。基于 eval 和 Open Class 的特性,可以很好的解决这个问题。eval 不但可以执行 podfile,它可以执行任意文件,包括自定义的配置文件。通过添加切换源码与二进制的 DSL,并将其添加到自定义的配置文件即可。

自定义配置文件

在 pod install 或 pod update 阶段,在插件内通过 hook pre_install 方法(前提是 podfile 内引入了此插件),在 pod install 执行之前,加载自定义的 podfile 文件即可。核心代码如下(这部分细节可以参考source_provider_hook.rb):

Pod::HooksManager.register('cocoapods-testplugin', :pre_install) do |_context, _|
  project_root = Pod::Config.instance.project_root
  path = File.join(project_root.to_s, 'BinPodfile')
  next unless File.exist?(path)
  contents = File.open(path, 'r:utf-8', &:read)
  podfile = Pod::Config.instance.podfile
  podfile.instance_eval do
    begin
      eval(contents, nil, path)
    rescue Exception => e
      message = "Invalid `#{path}` file: #{e.message}"
      raise Pod::DSLError.new(message, path, e, contents)
    end
  end
end
# BinPodfile

use_binaries!
set_use_source_pods ['testPod']

BinPodfile 为自定义配置文件,将其加入 .gitignore 中,use_binaries! 和 set_use_source_pods 为自定义 DSL。

这和上面 cocoapods 加载 podfile 代码如出一辙。BinPodfile 是本地自定义文件。执行 eval 后,配置信息 testPod 会被存储到internal_hash hash表中。

在 pod install 的后续过程中,可以从 internal_hash hash 表中通过自定义 key 取出 BinPodfile 的配置,完成源码与二进制 podspec 切换工作。

获取 cocoapods 依赖解析结果

在 pod install 命令执行过程中,会有一个解析依赖阶段,当控制台打印 Analyzing dependencies 的时候就是在解析内部依赖关系的过程。在解析依赖的过程中有个很重要的方法 resolver_specs_by_target,所有 pod 库的最终信息都会在这个方法中返回,可以通过 hook 这个方法,拿到所有 pod 库的 podspec 信息,然后将其替换为想要的即可完成切换。关于这个过程,需要先了解一下 pod install 命令背后的事情。

cocoapods/installer.rb

def install!
  # 准备环境
  prepare
  # 解析依赖
  resolve_dependencies
  # 下载依赖
  download_dependencies
  # 验证target
  validate_targets
  # 生成工程文件
  if installation_options.skip_pods_project_generation?
    show_skip_pods_project_generation_message
  else
    integrate
  end
  # 写入依赖
  write_lockfiles
  # 结束回调
  perform_post_install_actions
end

基于 1.9.3 版本,简要分析一下 install 的 prepareresolve_dependencies 阶段,便于了解源码与二进制切换的具体细节。

在 prepare 阶段,内部会执行所有 Plugin 的 pre_install hook 方法:

def prepare
  # Raise if pwd is inside Pods
  if Dir.pwd.start_with?(sandbox.root.to_path)
    message = 'Command should be run from a directory outside Pods directory.'
    message << "\n\n\tCurrent directory is #{UI.path(Pathname.pwd)}\n"
    raise Informative, message
  end
  UI.message 'Preparing' do
    deintegrate_if_different_major_version
    sandbox.prepare
    # 确保所有在podfile中指定的插件都已加载。
    ensure_plugins_are_installed!
    # 执行插件的 pre_install hook 方法。
    run_plugins_pre_install_hooks
  end
end

前面的自定义配置文件能够被加载正是基于此。 resolve_dependencies 的过程相对比较复杂,这是依赖解析过程的核心:

install.rb

def resolve_dependencies
  # 和 pre_install 一样,这里会执行所有插件的 source_provider 的 hook 方法。
  # 可以在这里动态的添加 pod 库的源。
  plugin_sources = run_source_provider_hooks
  analyzer = create_analyzer(plugin_sources)
  # 如果执行了 pod update 的话会先去更新 repo
  UI.section 'Updating local specs repositories' do
    analyzer.update_repositories
  end if repo_update?

  # 分析各种依赖关系阶段
  UI.section 'Analyzing dependencies' do
    analyze(analyzer)
    validate_build_configurations
  end

  UI.section 'Verifying no changes' do
    verify_no_podfile_changes!
    verify_no_lockfile_changes!
  end if deployment?

  analyzer
end

run_source_provider_hooks 方法内部会执行插件的 source_provider hook 方法,用来加载插件内配置的 repo 源,后面分析源加载小节会细说。

然后会进入 Analyzing dependencies 过程,Analyzer 内部会使用 Molinillo (一种图算法)解析得到一张依赖关系表,主要流程如下:

analyzer.rb 

def analyze(allow_fetches = true)
  return @result if @result
  # 校验podfile
  validate_podfile!
  # 校验podfile.lock中的版本,如果大于当前pod版本报错
  validate_lockfile_version!
  # 获取target信息
  if installation_options.integrate_targets?
    target_inspections = inspect_targets_to_integrate
  else
    verify_platforms_specified!
    target_inspections = {}
  end
  # 通过和podfile.lock的比对,返回podfile中不同状态下的pod库。
  # cocoapods将其划分为了四种状态:added/changed/deleted/unchanged。
  podfile_state = generate_podfile_state

  store_existing_checkout_options
  if allow_fetches == :outdated
    # special-cased -- we're only really resolving for outdated, rather than doing a full analysis
  elsif allow_fetches == true
  # 分析通过:podspec,:path,:git等外部源引入的pod库
  # 内部通过工厂构建不同的source类继承自AbstractExternalSource,分别处理上面三种情况
  # 处理使用 :path 方式引入的本地库会被一个叫 PathSource 的类处理,返回 podspec 地址,可以通过重写这个类来重新指定本地 podspec 路径。
  # 通过 :git 方式引入处理会稍微特殊些,内部会 fetch 一个 pre_download 方法进入 Pre-downloading 阶段,预先下载指向的 pod 库,再取出 podspec 进行分析。
    fetch_external_sources(podfile_state)
  elsif !dependencies_to_fetch(podfile_state).all?(&:local?)
    raise Informative, 'Cannot analyze without fetching dependencies since the sandbox is not up-to-date. Run `pod install` to ensure all dependencies have been fetched.' \
      "\n    The missing dependencies are:\n    \t#{dependencies_to_fetch(podfile_state).reject(&:local?).join("\n    \t")}"
  end
  # 返回 lockfile 中的 Pods 库相关信息。
  # 在执行 pod install 后会先读取 podfile.lock 中的版本和依赖进行安装。
  # 这也是执行 pod install 并不会更新本地库版本的原因。
  locked_dependencies = generate_version_locking_dependencies(podfile_state)
  # 返回podfile中配置的pod库依赖关系
  # 同一个pod库依赖不同版本这里会校验不通过报错
  resolver_specs_by_target = resolve_dependencies(locked_dependencies)
  # 校验target的版本,是否大于等于pod库支持的最低版本
  validate_platforms(resolver_specs_by_target)
  # hashmap转array
  specifications = generate_specifications(resolver_specs_by_target)
  # 返回target数组,包括pod库所依赖的,不仅仅podfile中的
  aggregate_targets, pod_targets = generate_targets(resolver_specs_by_target, target_inspections)
  # manifest的state
  sandbox_state   = generate_sandbox_state(specifications)
  # 按target为key的spec分组hash表
  specs_by_target = resolver_specs_by_target.each_with_object({}) do |rspecs_by_target, hash|
    hash[rspecs_by_target[0]] = rspecs_by_target[1].map(&:spec)
  end
  # 按source源为key的spec分组hash表
  specs_by_source = Hash[resolver_specs_by_target.values.flatten(1).group_by(&:source).map do |source, specs|
    [source, specs.map(&:spec).uniq]
  end]
  sources.each { |s| specs_by_source[s] ||= [] }
  # 所有pod库的分析结果存储到analyzer的result属性中
  @result = AnalysisResult.new(podfile_state, specs_by_target, specs_by_source, specifications, sandbox_state,
                               aggregate_targets, pod_targets, @podfile_dependency_cache)
end

在分析依赖的过程中,会生成一个叫 resolver_specs_by_targethash,包含 podfile 中配置的所有 target 以及对应 target 下所有的 pod 库信息。整个过程比较复杂,这个流程结束后整个依赖关系就确定下来了。内部会根据前面生成的 Molinillo 图遍历,将所有 target 依赖的 pod 库构建为 ResolverSpecification 对象,以 [Hash{Podfile::TargetDefinition => Array<ResolverSpecification>}] 的形式存储。

podspec 切换

通过 hook resolver_specs_by_target,遍历所有的 podspec,找到做了二进制标记的 pod 库,对其进行重组,来进行对源码和二进制的切换。这也是 cocoapods-bin 的核心思路:

cocoapods-bin/native/resolver.rb

old_resolver_specs_by_target = instance_method(:resolver_specs_by_target)
define_method(:resolver_specs_by_target) do
  specs_by_target = old_resolver_specs_by_target.bind(self).call
  ...
end

这是一种类似于 OC 的 Method Swizzling,instance_method 临时存储 resolver_specs_by_target 方法,define_method 类似于关键字 def,def 是定一个方法,define_method 是产生一个新的方法,类似 class_addMethod 可以根据参数动态生成方法。其实在 Ruby 中还有其他的交换方式 alias,alias_method 等。

获取到 specs_by_target 后,取出需要切换的 Specification 对象 rspec 和二进制源 source,通过 source 的 specification 方法构建二进制 Specification 对象:

cocoapods-bin/native/resolver.rb

begin
  specification = source.specification(rspec.root.name, spec_version)
  #...省略一些代码
  # 组装新的 rspec ,替换原 rspec
  rspec = ResolverSpecification.new(specification, used_by_only, source)
  rspec
rescue Pod::StandardError => e
  # 没有从新的 source 找到对应版本组件,直接返回原 rspec
  #...省略一些代码
  rspec
end
# cocoapods-core/source.rb
def specification(name, version)
  Specification.from_file(specification_path(name, version))
end

Ruby 的 Class 内定义带有 self 的方法为类方法,例如 Specification 内定义的 self.from_file。

如果未获取到 specification 会进入到 rescue 代码块,直接将 rspec 返回。

整个过程可以总结如下:

  1. 获取自定义文件内 set_use_source_pods 的组件。
  2. 过滤出需要二进制的组件。
  3. 获取二进制 repo 的 source 源。
  4. 通过 source 的 specification(name, version) 方法返回 Specification。
  5. 二进制源中未找到对应的 podspec 直接返回源码 podspec。
  6. 如果存在则通过 ResolverSpecification.new 方法构造新的 ResolverSpecification 返回。

通过自定义 DSL 配合 hook resolver_specs_by_target 即可在不入侵原 podfile 文件的基础上,完成源码与二进制间的配置和切换。

关于 Cocoapods 源的处理

通过函数调用栈可以发现,Cocoapods 在寻找 podspec 的时候,会经过一个叫 find_cached_set 的方法:

#cocoapods/resolver.rb

def find_cached_set(dependency)
  name = dependency.root_name
  cached_sets[name] ||= begin
    if dependency.external_source
      spec = sandbox.specification(name)
      unless spec
        raise StandardError, '[Bug] Unable to find the specification ' \
          "for `#{dependency}`."
      end
      set = Specification::Set::External.new(spec)
    else
      set = create_set_from_sources(dependency)
    end
    unless set
      raise Molinillo::NoSuchDependencyError.new(dependency) # rubocop:disable Style/RaiseArgs
    end
    set
  end
end

set 为 pod 库在 repo 中的所有版本,然后 Cocoapods 会在其中过滤出 podfile.lock 指定版本进行安装。create_set_from_sources 方法内部会调用一个叫 search 的方法:

#cocoapods-core/source/aggregate.rb

def search(dependency)
  found_sources = sources.select { |s| s.search(dependency) }
  unless found_sources.empty?
    Specification::Set.new(dependency.root_name, found_sources)
  end
end

sources 即当前运行环境内的所有 repo,该方法会遍历所有的 repo,找到对应的 pod 的所有版本,找到就返回,没有找到则会抛出异常 raise,也就是控制台常见如下的错误:

[!] Unable to find a specification for `HelloMoto`

You have either:
 * out-of-date source repos which you can update with `pod repo update` or with `pod install --repo-update`.
 * mistyped the name or version.
 * not added the source repo that hosts the Podspec to your Podfile.

那么这个存放了所有 repo 的 sources 来源于哪里呢?这部分代码可以在 cocoapods/installer/analyzer.rb 中找到:

# cocoapods/installer/analyzer.rb

def sources
  # 懒加载。
  @sources ||= begin
    # podfile读取。
    sources = podfile.sources
    # 插件定义的 source 源。
    plugin_sources = @plugin_sources || []
    # podspec 内使用 :source 引入的 source。
    dependency_sources = podfile_dependencies.map(&:podspec_repo).compact
    ...省略一些逻辑
    result
  end
end

从上面的代码可以看出 source 的来源有三处:

  1. podfile 内定义的 source 'xxx'。
  2. 插件 source_provider hook 返回的 source。
  3. pod 库通过 :source 方式引入的。

在前面分析依赖解析的时候,提到了 run_source_provider_hooks 方法,它的内部会执行插件的 source_provider hook 方法返回插件内定义的 repo 给 @plugin_sources 变量:

cocoapods-bin/source_provider_hook.rb

Pod::HooksManager.register('cocoapods-bin', :source_provider) do |context, _|
  sources_manager = Pod::Config.instance.sources_manager
  podfile = Pod::Config.instance.podfile
  if podfile
    # 添加二进制私有源 && 源码私有源
    added_sources = [sources_manager.code_source, sources_manager.binary_source, sources_manager.trunk_source]
    if podfile.use_binaries? || podfile.use_binaries_selector
      added_sources.reverse!
    end
    added_sources.each { |source| context.add_source(source) }
  end
end

如果私有 repo 不想定义在 podfile 内,可以在插件内通过这种方式返回。

结语

本篇篇幅比较长,开篇介绍了组件化架构以及组件私有化的流程,关于组件通信这里没有说,这块可以参考《关于 iOS 组件通信的思考》。然后对两种实践过的二进制方案进行了分析,着重分析了双 repo 的方案。介绍了 Ruby 工具链体系,旨在理解 Cocoapods Plugin 制作原理。后面大篇幅的对 Cocoapods 和 cocoapods-bin 的源码进行了分析,几乎囊括了所有涉及到的关键节点,希望读者能够深入理解二进制插件是如何围绕 Cocoapods 源码进行开发的。最后还介绍了 podspecpodfile 文件的加载过程,pod install 的依赖分析过程以及一些 Ruby 的特性,比如如何利用 Open Class 特性为 podfile 扩充 DSL 和替换方法,如何利用 eval 方法执行脚本文件等等,另外推荐一个美柚开源的,基于 cocoapods-bin 的定制版 cocoapods-imy-bin 供大家参考。

ref:

  1. CocoaPods
  2. cocoapods-bin
  3. iOS CocoaPods组件平滑二进制化解决方案
  4. 有赞iOS-基于二进制的编译提效策略
  5. 1. 版本管理工具及 Ruby 工具链环境