1277ff1bf0aa6df49a990dfaba6c4936
CocoaPods 历险 - 总览

基本上所有的 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 方法来测试它:

[1] > eval "100 * (100 + 1) / 2"
=> 5050
[2] > eval "puts 'Hello Ruby!'"
Hello Ruby!
=> nil

通过 eval 使得 Ruby 获得了更强大的动态能力,在运行时可以使用字符串来改变逻辑,而不是与传统的手动解析输入和输入语法树。

这个特性会在 cocoapods-core 中大量使用,用来解析 PodfilePodfile.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 中的代码,其中定义了一些类方法:

# file: CocoaPods - cocoapods/downloader.rb

module Pod
  module Downloader
    require 'cocoapods/downloader/cache'
    require 'cocoapods/downloader/request'
    require 'cocoapods/downloader/response'

    def self.download(
      request,
      target,
      can_cache: true,
      cache_path: Config.instance.cache_root + 'Pods'
    )
      # ...
      end
    # ...
  end
end

但是在 cocoapods-downloader 模块中,Downloader 这个 module 的方法并不能满足全部需求,于是在 api.rb 中会看到这样的代码扩展:

# file: CocoaPods - cocoapods-downloader/api.rb

module Pod
  module Downloader
    module API
      def execute_command(executable, command, raise_on_failure = false)
        # ...
      end

      def check_exit_code!(executable, command, output)
        # ...
      end
  end
end  

这也就好比在 iOS 开发中对某一个 Class 的 Category 扩展,可以为其增加方法、增加属性,甚至是重写方法。在 GitHub 有近百个关于 CocoaPods 的扩展和优化,这种代码随处可见。

值得一提的,在 Ruby 中使用类似于 ObjC 的 Method Swizzling 也随处可见。Ruby 中可简单的通过 alias_method 来对成员方法、类方法做到方法替换。

[1] pry(main)> class Test
[1] pry(main)*   def hello
[1] pry(main)*     p "hello"
[1] pry(main)*   end
[1] pry(main)* end
=> :hello
[2] pry(main)> class Test
[2] pry(main)*   alias_method :hello_old, :hello
[2] pry(main)*   def hello
[2] pry(main)*     p "hello world"
[2] pry(main)*   end
[2] pry(main)* end
=> :hello
[3] pry(main)> test = Test.new
=> #<Test:0x007fca3eb1d158>
[4] pry(main)> test.hello
"hello world"
=> "hello world"

Bundler 对于 Gem 开发的本地映射

CocoaPods 中,我们可以看到它的 gemfile 的写法是这样:

SKIP_UNRELEASED_VERSIONS = false

# 声明 CocoaPods 对于 git 仓库的依赖关系。
# 兼容本地 git 仓库模式开发
#
def cp_gem(name, repo_name, branch = 'master', path: false)
  return gem name if SKIP_UNRELEASED_VERSIONS
  # 如果 path 参数为 true,那么将在 "../[repo_name]" 这个路径下搜索仓库
  opts = if path
           { :path => "../#{repo_name}" }
         else
           # 否则拼接 git 仓库地址获取
           url = "https://github.com/CocoaPods/#{repo_name}.git"
           { :git => url, :branch => branch }
         end
  # 返回标准的 Gemfile Gem 导入格式
  gem name, opts
end

source 'https://rubygems.org'

gemspec

gem 'json', :git => 'https://github.com/segiddins/json.git', :branch => 'seg-1.7.7-ruby-2.2'

group :development do
  # 开发的 Gem 组件
  cp_gem 'claide',                'CLAide'
  cp_gem 'cocoapods-core',        'Core'
  cp_gem 'cocoapods-deintegrate', 'cocoapods-deintegrate'
  cp_gem 'cocoapods-downloader',  'cocoapods-downloader'
  cp_gem 'cocoapods-plugins',     'cocoapods-plugins'
  cp_gem 'cocoapods-search',      'cocoapods-search'
  cp_gem 'cocoapods-stats',       'cocoapods-stats'
  cp_gem 'cocoapods-trunk',       'cocoapods-trunk'
  cp_gem 'cocoapods-try',         'cocoapods-try'
  cp_gem 'molinillo',             'Molinillo'
  cp_gem 'nanaimo',               'Nanaimo'
  cp_gem 'xcodeproj',             'Xcodeproj'

  gem 'cocoapods-dependencies', '~> 1.0.beta.1'

  gem 'bacon'
  gem 'mocha'
  gem 'mocha-on-bacon'
  gem 'prettybacon'
  gem 'webmock'

  # 集成测试
  gem 'diffy'
  gem 'clintegracon'

  # 代码质量校验
  gem 'inch_by_inch'
  gem 'rubocop'

  gem 'danger'
end

group :debugging do
  gem 'cocoapods_debug'

  gem 'rb-fsevent'
  gem 'kicker'
  gem 'awesome_print'
  gem 'ruby-prof', :platforms => [:ruby]
end

Gemfile 中我们可以看到很多通过 cp_gem 装载的 Gem 库,并且如果发现有与 CocoaPods 项目目录同级的目录,则会使用对应的项目直接通过 Gem 加载。通过简单的目录分割和 Gemfile 管理,就实现了最基本又最直观的热插拔,对组件开发十分友好。所以你只要将多个仓库如下图方式排列,即可实现跨仓库组件开发:

# gua @ Guabook in ~/Desktop/cocoapod-dev [66:66:66]
$ ls -l
lrwxr-xr-x  1 gua  staff    31 Jul 30 21:34 CocoaPods
lrwxr-xr-x  1 gua  staff    26 Jul 31 13:27 Core 
lrwxr-xr-x  1 gua  staff    31 Jul 31 10:14 Molinillo 
lrwxr-xr-x  1 gua  staff    31 Aug 15 11:32 Xcodeproj 
lrwxr-xr-x  1 gua  staff    42 Jul 31 10:14 cocoapods-downloader 

当然选型为 Ruby 一定不仅仅有以下这些好处,有很多东西是我这个非专业 Rubyer 选手可以看出来的。如果有其他的优势可以帮我补充。

组件构成和对应职责

通过上面对于 Gemfile 的简单分析,可以看出 CocoaPods 不仅仅是一个仓库那么简单,它作为一个三方库版本管理工具,对自身组件的管理和组件化也是十分讲究的。我们继续来看这份 Gemfile

# 开发的 Gem 组件

# CLAide 是一个命令的解析器
#通过简单的 API 提供命令的构造、参数解析、生成 help 等功能
cp_gem 'claide',                'CLAide'

# Core 用于 CocoaPods 中模板文件的解析
# 包括 Podfile、podspec 文件,还包括所有的 .lock 文件中特殊的 YAML 文件
cp_gem 'cocoapods-core',        'Core'

# cocoapods-deintegrate 用来从工程中删除所有的 CocoaPods 相关配置
cp_gem 'cocoapods-deintegrate', 'cocoapods-deintegrate'

# cocoapods-downloader CocoaPods 中的下载模块扩展
# 支持从 git, svn, hg, http, scp, bzr 多种协议,也有部分下载时候的优化策略
cp_gem 'cocoapods-downloader',  'cocoapods-downloader'

# cocoapods-plugins 插件管理功能
# 其中有 `pod plugin` 全套命令,支持对于 CocoaPods 插件的列表一览(list)、搜索(search)、创建(create)功能
cp_gem 'cocoapods-plugins',     'cocoapods-plugins'

# Pods 搜索工具
# 用来搜索 Pods 三方库
cp_gem 'cocoapods-search',      'cocoapods-search'

# Pods 组件数据统计插件
# 展示下载量等相关统计方面的信息
cp_gem 'cocoapods-stats',       'cocoapods-stats'

# 与 cocoapods.org 打通的命令行工具
# 用于查看个人用户信息、上传 spec 等操作
cp_gem 'cocoapods-trunk',       'cocoapods-trunk'

# Pods 组件 Demo 工程快速预览
cp_gem 'cocoapods-try',         'cocoapods-try'

# Pods 组件版本依赖仲裁算法
cp_gem 'molinillo',             'Molinillo'

# ascii 编码的 plist 文件 Ruby 原生解析库
cp_gem 'nanaimo',               'Nanaimo'

# 通过原生 Ruby 创建Xcode项目
cp_gem 'xcodeproj',             'Xcodeproj'

通过查看 Gemfile 可以看出 CocoaPods 对于组件的拆分粒度是比较细微的,通过对各种组件的组合达到现在的完整版本。这些组件中,笔者也仅仅零散的看过一点,由于组件群过于庞大。但是都能达到拆开去使用的程度。

例如,如果当对构建 Xcode 工程有校本化处理需求的时候,可以使用 Xcodeproj 来打开、修改、保存一个工程。例如使用 xcodeproj 来查看 Sepicat 项目的一些信息:

[1] pry(main)> require 'xcodeproj'
=> true
[2] pry(main)> XCODEPROJ_PATH = Pathname.new("/Users/gua/Desktop/git/Sepicat-dev/Sepicat/Sepicat/Sepicat.xcodeproj")
=> #<Pathname:/Users/gua/Desktop/git/Sepicat-dev/Sepicat/Sepicat/Sepicat.xcodeproj>
[3] pry(main)> project = Xcodeproj::Project.open(XCODEPROJ_PATH)
=> #<Xcodeproj::Project:0x3fe51f622d2c>
[4] pry(main)> project.targets
=> [#<Xcodeproj::Project::Object::PBXNativeTarget:0x3fe51f498e5c>, #<Xcodeproj::Project::Object::PBXNativeTarget:0x3fe51fc96410>]
[I'm 5] pry(main)> project.to_hash
=> {"objects"=>
  {"D59E24B31FB8861200B00B8C"=>
    {"isa"=>"PBXProject",
     "attributes"=>
      {"LastSwiftUpdateCheck"=>"0910",
       "LastUpgradeCheck"=>"0910",
       "ORGANIZATIONNAME"=>"Desgard_Duan",
       "TargetAttributes"=>
        {"D59E24BA1FB8861200B00B8C"=>
          {"CreatedOnToolsVersion"=>"9.1",
           "LastSwiftMigration"=>"0920",
           "ProvisioningStyle"=>"Automatic"},
         "D59E24CE1FB8861200B00B8C"=>
          {"CreatedOnToolsVersion"=>"9.1",
           "ProvisioningStyle"=>"Automatic",
           "TestTargetID"=>"D59E24BA1FB8861200B00B8C"}}},
# ...

这里我调用了 Project 这个 Class 的 to_hash 方法,它可以讲 xcodeproj 所有的环境变量以 Hash 方式返回并输出。当然,Xcodeproj 功能不止于此,在后续的系列文章中会有更多的使用方法。这里仅仅是来展示,所有的 Gem 组件是可以单独抽离出来使用的。

从一次 Install 命令来初探 CocoaPods 源码

命令入口

pod install 其实是 iOS 开发者在学习 CocoaPods 时学习的第一个命令,虽然网上已经有很多关于 pod install 的原理解析,但是作为系列第一篇,这个分析仍旧重要。我们打开终端到项目中的 Podfile 所在目录下,输入 pod install

$ pod install

每次当输入一个 pod xxx 命令的时候,首先系统会调用这个 pod 命令。所有的命令都是在 /bin 目录下存放的脚本,当然 Ruby 环境的也不例外。我们可以通过 command which pod 来查看命令所在位置:

$ command which pod
/Users/gua/.rvm/gems/ruby-2.4.1/bin/pod

出现 /Users/gua/.rvm/gems/ruby-2.4.1/bin/pod 而不是 /usr/local/bin/pod 的原因是因为我电脑中的 Ruby 版本是使用 RVM 进行版本控制的,所以会装在这么一个冗长的目录下。我们来看一下这个入口脚本执行了什么:

# pod
#!/usr/bin/env ruby_executable_hooks
#
# This file was generated by RubyGems.
#
#'cocoapods' 做为 Gem 组件被安装
# 这个脚本做为入口文件来唤醒 cocoapods 
#

require 'rubygems'

version = ">= 0.a"

if ARGV.first
  str = ARGV.first
  # "BINARY" 是 ASCII-8BIT 别名
  str = str.dup.force_encoding("BINARY") if str.respond_to? :force_encoding
  # \A 代表输入的开始位置,\z 代表输入的结束
  if str =~ /\A_(.*)_\z/ and Gem::Version.correct?($1) then
    version = $1
    # shift 拿出第一个元素并移除
    ARGV.shift
  end
end

if Gem.respond_to?(:activate_bin_path)
  load Gem.activate_bin_path('cocoapods', 'pod', version)
else
  gem "cocoapods", version
  load Gem.bin_path("cocoapods", "pod", version)
end

入口中将命令的执行指向了 Gem 组件的 Path 中,这样就找到了 CocoaPods 的入口脚本,即在 cocoapods/bin 目录下的 pod

#!/usr/bin/env ruby

# ... 忽略一些对于编码处理的代码

require 'cocoapods'

# 这里我手动输出一下调用栈,来关注一下
puts caller

if profile_filename = ENV['PROFILE']
  require 'ruby-prof'
  reporter =
    case (profile_extname = File.extname(profile_filename))
    when '.txt'
      RubyProf::FlatPrinterWithLineNumbers
    when '.html'
      RubyProf::GraphHtmlPrinter
    when '.callgrind'
      RubyProf::CallTreePrinter
    else
      raise "Unknown profiler format indicated by extension: #{profile_extname}"
    end
  File.open(profile_filename, 'w') do |io|
    # 实例化一个 Pods::Command 对象,并进入主流程
    reporter.new(RubyProf.profile { Pod::Command.run(ARGV) }).print(io)
  end
else
  # 实例化一个 Pods::Command 对象,并进入主流程
  Pod::Command.run(ARGV)
end

调用栈输出了两个方法:

/Users/gua/.rvm/gems/ruby-2.4.1/bin/pod:23:in `load'
/Users/gua/.rvm/gems/ruby-2.4.1/bin/pod:23:in `<main>'
/Users/gua/.rvm/gems/ruby-2.4.1/bin/ruby_executable_hooks:24:in `eval'
/Users/gua/.rvm/gems/ruby-2.4.1/bin/ruby_executable_hooks:24:in `<main>'

ruby_executable_hooks 通过 bin 目录下的 pod 入口唤醒,再通过 eval 的手段调起我们需要的 CocoaPods 工程。这是 Bundler 的自身行为。

在入口的最后部分,发现通过调用 Pod::Command.run(ARGV),实例化了一个 CLAide::Command 对象,于是用户输入的命令及层数从此进入CLAide 解析阶段。这里不对 CLAide 这个命令解析工具做过多的分析,这个是后面系列文章的内容,我们仅仅知道,它是一个根据继承来自动生成命令层级的命令解析工具,每次命令的执行,其实是对应到具体 Class 的 run 方法

module Pod
# hello
  class Command
    class Install < Command
      # .. 省略很多参数定义
      def run
        # 判断是否存在 Podfile 文件
        # 不存在直接回抛出 "No `Podfile' found in the project directory." 的警告,终止程序
        verify_podfile_exists!

        # 从 Config 中获取一个 Instraller 实例
        installer = installer_for_config

        # 默认是不执行 update
        installer.repo_update = repo_update?(:default => false)
        installer.update = false
        installer.deployment = @deployment

        # install 的真正过程
        installer.install!
      end
    end
  end
end
top Created with Sketch.