前言
在大多数iOS项目中,都会使用CocoaPods作为三方库的包管理工具,某些项目还会使用Bundler来约束CocoaPods的版本、管理CocoaPods的插件等,而CocoaPods和Bundler作为Gem包,通常会使用RubyGems来安装和管理。
RubyGems、Bundler、CocoaPods都是Ruby语言开发的工具,我们在使用这套工具链的过程中,或许对中间的运行过程知之甚少,遇到问题也会有诸多疑惑。
本文将从Bundler和CocoaPods命令执行流程的角度,来理解整个工具链的运行原理。让大家在后续使用过程中,知道在终端敲下命令后,背后发生了什么,以及遇到问题怎么定位,甚至可以借鉴前辈们的思路,创造自己的工具。
bundle exec pod xxx 执行流程
直接执行pod
命令,流程中只会涉及到RubyGems和Cocoapods,为了理解包括Bundler在内的整体工具链的运行原理,本文将对bundle exec pod xxx
的运行过程进行剖析(xxx
代表pod
的任意子命令),理解了bundle exec pod xxx
运行执行过程,对于pod
命令运行过程的理解便是水到渠成的事。
先简单梳理下bundle exec pod xxx
的执行流程,如果有不理解的地方可以先跳过,后面会展开描述各个环节的细节。
当在终端敲下bundle exec pod xxx
时:
1、Shell命令行解释器解析出bundle命令,根据环境变量$PATH查找到bundle可执行文件
2、读取bundle可执行文件第一行shebang(!#),找到ruby解释器路径,开启新进程,加载执行ruby解释器程序,后续由ruby解释器解释执行bundle可执行文件的其他代码
3、RubyGems从已安装的Gem包中查找指定版本的bundler,加载执行bundler中的bundle脚本文件,进入bundler程序
4、bundler的CLI解析命令,解析出pod命令和参数install,分发给Bundler::Exec
5、Bundler::Exec查找pod的可执行文件,加载并执行pod可执行文件的代码
6、pod可执行文件和前面的bundle可执行文件类似,查找指定版本的Cocoapods,并找到Cocoapods里的pod可执行文件加载执行,进入Cocoapods程序
以上就是整体流程,接下来分析流程中各环节的细节
Shell命令行解释器处理bundle
命令
每开一个终端,操作系统就会启动一个Shell命令行解释器程序,Shell命令行解释器会进入一个循环,等待并解析用户输入命令。
终端上可以通过watch ps
查看当前正在运行的进程。如果没有watch
命令,可通过brew install watch
安装。
从 macOS Catalina 开始,Zsh 成为默认的Shell解释器。可以通过echo $SHELL
查看当前的Shell解释器。更多关于mac上终端和Shell相关知识可以参考 终端使用手册。关于Zsh 的源码可以通过zsh.sourceforge.io/下载。
当用户输入命令并按回车键后,Shell解释器解析出命令,例如bundle
,然后通过环境变量$PATH
查找名为bundle
的可执行文件的路径。
shell
$ echo $PATH
/Users/用户名/.rvm/gems/ruby-3.0.0/bin:/Users/用户名/.rvm/gems/ruby-3.0.0@global/bin:/Users/用户名/.rvm/rubies/ruby-3.0.0/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/Users/用户名/.rvm/bin
以:
将$PATH分隔成多个路径,然后从前往后,查找某路径下是否有命令的可执行文件。例如打开/Users/用户名/.rvm/gems/ruby-3.0.0/bin
,可以看到bundle
可执行文件等。

也可以在终端通过which bundle
查看可执行文件的路径:
shell
$ which bundle
/Users/用户名/.rvm/gems/ruby-3.0.0/bin/bundle
Ruby解释器
通过cat
查看上述bundle
可执行文件的内容:
ruby
$ cat /Users/用户名/.rvm/gems/ruby-3.0.0/bin/bundle
#!/Users/用户名/.rvm/rubies/ruby-3.0.0/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'bundler' is installed as part of a gem, and
# this file is here to facilitate running it.
#
# 加载rubygems (lib目录下)
require 'rubygems'
version = ">= 0.a"
# 检查参数中是否存在以下划线包围的版本号,如果是,则取有效的版本号
str = ARGV.first
if str
str = str.b[/\A_(.*)_\z/, 1]
if str and Gem::Version.correct?(str)
version = str
ARGV.shift
end
end
# rubygems新版本中执行activate_bin_path方法
if Gem.respond_to?(:activate_bin_path)
# 查找bundler中名为bundle的可执行文件,加载并执行 (bundler是Gem包的名称,bundle是可执行文件名称)
load Gem.activate_bin_path('bundler', 'bundle', version)
else
gem "bundler", version
load Gem.bin_path("bundler", "bundle", version)
end
其内容的第一行shebang(#!)指明了执行该程序的解释器为#!/Users/用户名/.rvm/rubies/ruby-3.0.0/bin/ruby
。Shell解释器读到这一行之后,便会开启一个新进程,加载ruby解释器,后续的工作交给ruby解释器程序。
这里的ruby是使用了RVM进行了版本控制,如果是homebrew安装的,路径是/usr/local/opt/ruby/bin/ruby
,系统自带的ruby的路径是/usr/bin/ruby
。
可以通过路径/Users/用户名/.rvm/src/ruby-3.0.0
看到ruby的源码。简单看一下ruby的main
函数:
c
int
main(int argc, char **argv)
{
#ifdef RUBY_DEBUG_ENV
ruby_set_debug_option(getenv("RUBY_DEBUG"));
#endif
#ifdef HAVE_LOCALE_H
setlocale(LC_CTYPE, "");
#endif
ruby_sysinit(&argc, &argv);
{
RUBY_INIT_STACK;
ruby_init();
return ruby_run_node(ruby_options(argc, argv));
}
}
ruby程序正式运行前,会通过ruby_options
函数读取环境变量RUBYOPT
(ruby解释器选项),可以通过设置环境变量RUBYOPT
来自定义ruby解释器的行为。
例如在用户目录下创建一个ruby文件.auto_bundler.rb
,然后在Zsh 的环境变量配置文件.zshrc
中添加:export RUBYOPT="-r/Users/用户名/.auto_bundler.rb"
,执行一下source .zshrc
或者新开一个终端,ruby程序运行前便会加载.auto_bundler.rb
。我们可以利用该机制,在.auto_bundler.rb
添加逻辑,在iOS项目下执行pod xxx
时,检查如果存在Gemfile
文件,自动将pod xxx
替换成bundle exec pod xxx
,从而达到省去bundle exec
的目的。
RubyGems中查找Gem包
通过上述bundle
可执行文件的内容,我们还可以知道该文件是由RubyGems
在安装bundler时生成,也就是在gem install bundler
过程中生成的。
RubyGems
是ruby库(Gem)的包管理工具,github源码地址 github.com/rubygems/ru..., 安装到电脑上的源码地址 ~/.rvm/rubies/ruby-x.x.x/lib/ruby/x.x.x
。其命令行工具的一级命令是gem
。
当执行gem install xxx
安装Gem完成后,会结合Gem的gemspec
文件里executables
指定的名称,生成对应的可执行文件,并写入~/.rvm/rubies/ruby-x.x.x/bin
目录下。源码细节可以查看RubyGems
的installer.rb
文件中的install
和generate_bin
方法。
RubyGems
生成的bundle
以及其他Gem的可执行文件里的核心逻辑,是去查找指定版本的Gem包里的可执行文件,加载并执行。以下是RubyGems
3.0.0版本的查找逻辑:
rubygems.rb:
ruby
def self.activate_bin_path(name, exec_name = nil, *requirements) # :nodoc:
# 查找gemspec文件,返回Gem::Specification对象
spec = find_spec_for_exe name, exec_name, requirements
Gem::LOADED_SPECS_MUTEX.synchronize do
# 这两行核心逻辑是将Gem的依赖项以及自己的gemspec文件里的require_paths(lib目录)添加到$LOAD_PATH中
spec.activate
finish_resolve
end
# 拼接完整的可执行文件路径并返回
spec.bin_file exec_name
end
def self.find_spec_for_exe(name, exec_name, requirements)
raise ArgumentError, "you must supply exec_name" unless exec_name
# 通过Gem名和参数创建一个Gem::Dependency对象
dep = Gem::Dependency.new name, requirements
# 根据Gem名获取已加载的Gem的spec
loaded = Gem.loaded_specs[name]
# 如果获取到已加载的Gem的spec并且是符合条件的,则直接返回
return loaded if loaded && dep.matches_spec?(loaded)
#查找所有满足条件的spec
specs = dep.matching_specs(true)
# 过滤出executables包含传进来的可执行文件名的spec
#(bundler的spec文件的executables:%w[bundle bundler])
specs = specs.find_all do |spec|
spec.executables.include? exec_name
end if exec_name
# 如果有多个版本,返回找到的第一个,一般是最大版本,bunder除外
unless spec = specs.first
msg = "can't find gem #{dep} with executable #{exec_name}"
raise Gem::GemNotFoundException, msg
end
spec
end
dependency.rb:
ruby
def matching_specs(platform_only = false)
env_req = Gem.env_requirement(name)
# 对于多个版本的Gem,这里得到的是按版本降序的数组
matches = Gem::Specification.stubs_for(name).find_all do |spec|
requirement.satisfied_by?(spec.version) && env_req.satisfied_by?(spec.version)
end.map(&:to_spec)
# 这里会针对bundler特殊处理
# 例如读取当前目录下Gemfile.lock文件里的"BUNDLED WITH xxx"的版本号xxx,将xxx版本号的spec放到matches的首位
# 关于bundler特殊处理的逻辑详见RubyGems里的bundler_version_finder
Gem::BundlerVersionFinder.filter!(matches) if name == "bundler".freeze && !requirement.specific?
if platform_only
matches.reject! do |spec|
spec.nil? || !Gem::Platform.match_spec?(spec)
end
end
matches
end
def self.stubs_for(name)
if @@stubs_by_name[name]
@@stubs_by_name[name]
else
pattern = "#{name}-*.gemspec"
stubs = installed_stubs(dirs, pattern).select {|s| Gem::Platform.match_spec? s } + default_stubs(pattern)
stubs = stubs.uniq {|stub| stub.full_name }.group_by(&:name)
stubs.each_value {|v| _resort!(v) }
@@stubs_by_name.merge! stubs
@@stubs_by_name[name] ||= EMPTY
end
end
# Gem名升序,版本号降序
def self._resort!(specs) # :nodoc:
specs.sort! do |a, b|
names = a.name <=> b.name
next names if names.nonzero?
versions = b.version <=> a.version
next versions if versions.nonzero?
Gem::Platform.sort_priority(b.platform)
end
end
Gem::Dependency
的matches_specs
方法是在specifications
目录下查找符合条件的gemspec
文件,存在多个版本时返回最大版本。但是对bundler做了特殊处理,可以通过设置环境变量或者在项目的Gemfile中
指定bundler版本等方式,返回需要的bundler版本。

Bundler查找指定版本的Cocoapods
Bundler是管理Gem依赖和版本的工具,其命令行工具的一级命令是bundle
和bundler
,两者是等效的。
Bundler的gemspec文件里的executables为%w[bundle bundler]
bundler.gemspec部分内容
ruby
Gem::Specification.new do |s|
s.name = "bundler"
s.version = Bundler::VERSION
# ...
s.files = Dir.glob("lib/bundler{.rb,/**/*}", File::FNM_DOTMATCH).reject {|f| File.directory?(f) }
# include the gemspec itself because warbler breaks w/o it
s.files += %w[bundler.gemspec]
s.files += %w[CHANGELOG.md LICENSE.md README.md]
s.bindir = "exe"
s.executables = %w[bundle bundler]
s.require_paths = ["lib"]
end
安装之后RubyGems会生成bundle和bundler两个可执行文件,而Bundler包里既有bundle,也有bundler可执行文件,bundler的逻辑实际上是去加载bundle可执行文件,核心逻辑在bundle可执行文件中。
Bundler中的bundle可执行文件的核心代码:
ruby
#!/usr/bin/env ruby
base_path = File.expand_path("../lib", __dir__)
if File.exist?(base_path)
$LOAD_PATH.unshift(base_path)
end
Bundler::CLI.start(args, :debug => true)
这个函数通过命令解析和分发,到达CLI::Exec的run函数:
ruby
def run
validate_cmd!
# 设置bundle环境
SharedHelpers.set_bundle_environment
# 查找pod的可执行文件
if bin_path = Bundler.which(cmd)
if !Bundler.settings[:disable_exec_load] && ruby_shebang?(bin_path)
# 加载pod可执行文件
return kernel_load(bin_path, *args)
end
kernel_exec(bin_path, *args)
else
# exec using the given command
kernel_exec(cmd, *args)
end
end
def kernel_load(file, *args)
args.pop if args.last.is_a?(Hash)
ARGV.replace(args)
$0 = file
Process.setproctitle(process_title(file, args)) if Process.respond_to?(:setproctitle)
# 加载执行setup.rb文件
require_relative "../setup"
TRAPPED_SIGNALS.each {|s| trap(s, "DEFAULT") }
# 加载执行pod可执行文件
Kernel.load(file)
rescue SystemExit, SignalException
raise
rescue Exception # rubocop:disable Lint/RescueException
Bundler.ui.error "bundler: failed to load command: #{cmd} (#{file})"
Bundler::FriendlyErrors.disable!
raise
end
终端上通过which pod查看pod可执行文件的路径,再通过cat查看其内容,可以看到内容和bundler一致
ruby
$ which pod
/Users/用户名/.rvm/gems/ruby-3.0.0/bin/pod
$ cat /Users/用户名/.rvm/gems/ruby-3.0.0/bin/pod
#!/Users/用户名/.rvm/rubies/ruby-3.0.0/bin/ruby
#
# This file was generated by RubyGems.
#
# The application 'cocoapods' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
version = ">= 0.a"
str = ARGV.first
if str
str = str.b[/\A_(.*)_\z/, 1]
if str and Gem::Version.correct?(str)
version = str
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
按照前面rubygems查找bundler的方式,会找到最高版本的Cocoapods。那么,bundler将命令转发给pod前,是怎么查找到Gemfile.lock文件中指定版本的cocoapods或者其他Gem呢?
实际上Bundler替换了RubyGems的activate_bin_path和find_spec_for_exe等方法的实现。
上述的setup.rb中的核心代码是Bundler.setup
,最终会执行到runtime.rb
文件的setup
方法
runtime.rb
ruby
def setup(*groups)
# @definition是有Gemfile和Gemfile.lock文件生成的
@definition.ensure_equivalent_gemfile_and_lockfile if Bundler.frozen_bundle?
# Has to happen first
clean_load_path
# 根据definition获取所有Gem的spec信息
specs = @definition.specs_for(groups)
# 设置bundle的环境变量等
SharedHelpers.set_bundle_environment
# 替换RubyGems的一些方法,比如activate_bin_path和find_spec_for_exe等
# 使Gem包从specs中获取(获取Gemfile中指定版本的Gem)
Bundler.rubygems.replace_entrypoints(specs)
# 将Gem包lib目录添加到$Load_PATH
# Activate the specs
load_paths = specs.map do |spec|
check_for_activated_spec!(spec)
Bundler.rubygems.mark_loaded(spec)
spec.load_paths.reject {|path| $LOAD_PATH.include?(path) }
end.reverse.flatten
Bundler.rubygems.add_to_load_path(load_paths)
setup_manpath
lock(:preserve_unknown_sections => true)
self
end
可以通过终端上在工程目录下执行 bundle info cocoapods
找到Gemfile中指定版本的cocoapods的安装路径, 再通过cat查看其bin目录下的pod文件内容,其核心逻辑如下:
ruby
#!/usr/bin/env ruby
# ... 忽略一些对于编码处理的代码
require 'cocoapods'
# 如果环境变量配置文件文件中设置了COCOAPODS_PROFILE,会讲Cocoapod的方法耗时写入COCOAPODS_PROFILE对应的文件中
if profile_filename = ENV['COCOAPODS_PROFILE']
require 'ruby-prof'
# ...
File.open(profile_filename, 'w') do |io|
reporter.new(RubyProf.profile { Pod::Command.run(ARGV) }).print(io)
end
else
# 命令解析和转发等
Pod::Command.run(ARGV)
end
Cocoapods依赖claide
解析命令,Pod::Command继承自CLAide::Command,CLAide::Command的run方法如下:
ruby
def self.run(argv = [])
# 加载插件
plugin_prefixes.each do |plugin_prefix|
PluginManager.load_plugins(plugin_prefix)
end
argv = ARGV.coerce(argv)
# 解析出子命令
command = parse(argv)
ANSI.disabled = !command.ansi_output?
unless command.handle_root_options(argv)
command.validate!
#命令的子类执行,例如Pod::Command::Install
command.run
end
rescue Object => exception
handle_exception(command, exception)
end
插件加载:
每个pod命令的执行都会通过claide
的PluginManager
去加载插件。Pod::Command
重写了CLAide::Command
的plugin_prefixes
,值为%w(claide cocoapods)
。PluginManager
会去加载当前环境下所有包含claide_plugin.rb
或 cocoapods_plugin.rb
文件的 Gem。cocoapods插件中都会有一个cocoapods_plugin.rb
文件。
关于cocoapods的其他详细解析,可以参考Cocoapods历险记系列文章。
VSCode断点调试任意Ruby项目
1、VSCode安装扩展:rdbg
2、工程目录中创建Gemfile,添加以下Gem包,然后终端执行bundle install。
arduino
gem 'ruby-debug-ide'
gem 'debase', '0.2.5.beta2'
0.2.5.beta2是debase的最高版本,0.2.5.beta1和0.2.4.1都会报错,issue: 0.2.4.1 and 0.2.5.beta Fail to build on macOS Catalina 10.15.7
3、用VSCode打开要调试的ruby项目,例如Cocoapods。
-
如果调试当前使用版本的Cocopods,找的Cocopods所在目录,VSCode打开即可。
-
如果调试从github克隆的Cocopods,Gemfile里需要用path执行该Cocopods, 例如:
rubygem "cocoapods", :path => '~/dev/CocoaPods/'
4、创建lanch.json

launch.json:
json
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "rdbg",
"name": "pod install", //配置名称,用于在调试器中标识该配置
"request": "launch",
"script": "/Users/用户名/.rvm/gems/ruby-3.0.0/bin/pod", //指定要执行的脚本或可执行文件的路径
"cwd": "/Users/用户名/Project/NC", //指定在哪个目录下执行
"args": ["install"], //传递给脚本或可执行文件的命令行参数
"askParameters": true, //在启动调试会话之前提示用户输入其他参数
"useBundler": true, //使用Bundler
}
]
}
5、运行

断点调试RubyGems -> Bundler -> Cocoapods的流程
1、执行which ruby
找到ruby目录,在ruby-x.x.x目录下找到lib/ruby/x.x.x/
,获得rubygems源码位置
shell
$ which ruby
/Users/用户名/.rvm/rubies/ruby-3.0.0/bin/ruby
其源码位置:/Users/用户名/.rvm/gems/ruby-3.0.0/lib/ruby/3.0.0

2、用VSCode打开/Users/用户名/.rvm/gems/ruby-3.0.0/bin/ruby/3.0.0
,找到rubygems.rb
文件,在load Gem.activate_bin_path
这一行加上断点
3、在包含Gemfile的iOS项目目录下,执行which bundle
,获取到bundle的可执行文件路径:
shell
$ which bundle
/Users/用户名/.rvm/gems/ruby-3.0.0/bin/bundle
4、创建launch.json,在launch.json中添加如下配置:
launch.json:
json
{
"version": "0.2.0",
"configurations": [
{
"type": "rdbg",
"name": "exec pod install", // 名字任意
"request": "launch",
"script": "/Users/用户名/.rvm/gems/ruby-3.0.0/bin/bundle", // `which bundle`找到的路径
"args": ["exec pod install"],
"askParameters": false,
"cwd": "/Users/用户名/iOSProject" //替换为自己的iOS项目路径
}
]
}
至此便可以点击VSCode的run按钮断点调试ruby工具链的主体流程了。
运行断点如果跳不到bundler或者cocoapods项目中,可以将bundler或者cocoapods源码中的文件拖到VSCode工程中。 获取项目中正在使用的bundler或者cocoapods源码的源码位置,可以在项目目录下执行bundle info bundler
和bundle info cocoapods
,从输出的结果中可以找到路径。
如果提示某些Gem包未安装,执行gem install xxx
安装即可。