介绍
cocoapods-binary 最早是在 CocoaPods 的 Blog 中发现的:pre-compiling dependencies。虽非官方出品,但却是国内程序员的力作,medium 原版介绍:Pod 预编译的傻瓜式解决方案:
A CocoaPods plugin to integrate pods in form of prebuilt frameworks, not source code, by adding just one flag in podfile. Speed up compiling dramatically.
简单来说,cocoapods-binary 通过开关,在 pod insatll 的过程中进行 library 的预编译,生成 framework,并自动集成到项目中。
整个预编译工作分成了三个阶段来完成:
- binary pod 的安装
- binary pod 的预编译
- binary pod 的集成
Binary Pod 的安装
Binary Pod 的安装作是以 pre_install
hook 作为入口,开始插件的运作。
当我们在命令行中执行 pod install
,CocoaPods 会依次执行 👆 图的几个方法。cocoapods-binary 的 pre_install
就是在 prepare 阶段插入的逻辑。
这里的 pre_install
不同于 Podfile
中的 pre_install,其拦截方式如下:
1Pod::HooksManager.register('cocoapods-binary', :pre_install) do |installer_context|
2 ...
3end
利用 CocoaPods 提供的 HooksManager 注册 pre_install
hook 来下载 binary pods。过程分两步:
环境检查
环境检查,首先是通过标记全局的 is_prebuild_stage
来防止 pre_install
重复的进入。
1if Pod.is_prebuild_stage
2 next
3end
接着检查 podfile 是否设置了 use_framework!
。
1podfile = installer_context.podfile
2podfile.target_definition_list.each do |target_definition|
3 next if target_definition.prebuild_framework_pod_names.empty?
4 if not target_definition.uses_frameworks?
5 STDERR.puts "[!] Cocoapods-binary requires `use_frameworks!`".red
6 exit
7 end
8end
即 cocoapods-binary 需要打出的包为 framework 的形式。了解更多 CocoaPods 使用 framework 的原因,请猛戳 这里。
Binary Pod 的下载安装
这里说的 binary pod 都是在 podfile 中被标记为
:binary => true
的。
安装前需要 hook 相关方法进行预编译状态检查,并在安装结束后将它们重置。
1Pod.is_prebuild_stage = true
2Pod::Podfile::DSL.enable_prebuild_patch true
3Pod::Installer.force_disable_integration true
4Pod::Config.force_disable_write_lockfile true
5Pod::Installer.disable_install_complete_message true
6...
7# install 成功后将 👆 5 个变量重置为 false
8Pod::UserInterface.warnings = [] # clean the warning in the prebuild step, it's duplicated.
接着初始化 binary_installer:
1update = nil
2repo_update = nil
3include ObjectSpace
4ObjectSpace.each_object(Pod::Installer) { |installer|
5 update = installer.update
6 repo_update = installer.repo_update
7}
8
9standard_sandbox = installer_context.sandbox
10prebuild_sandbox = Pod::PrebuildSandbox.from_standard_sandbox(standard_sandbox)
11prebuild_podfile = Pod::Podfile.from_Ruby(podfile.defined_in_file)
12lockfile = installer_context.lockfile
13binary_installer = Pod::Installer.new(prebuild_sandbox, prebuild_podfile, lockfile)
14
15# install ...
installer 的初始化需要:prebuild_sandbox
、prebuild_podfile
、lockfile
。
prebuild_sandbox
管理的目录在 Pods/_Prebuild
,它是 Sandbox 的子类。Sandbox 管理着 CocoaPods 的 /Pods
目录。
lockfile 和 prebuild_podfile 是分别从工程目录的 podfile.lock
和 Podfile
中读取的。
install
1if binary_installer.have_exact_prebuild_cache? && !update
2 binary_installer.install_when_cache_hit!
3else
4 binary_installer.update = update
5 binary_installer.repo_update = repo_update
6 binary_installer.install!
7end
当缓存命中且没有 pod 需要更新时,会执行 install_when_cache_hit!
(就是打印一下 cache pods),否则开始 binary pod 的下载,下载目录就在 Pods/_Prebuild
下。
预编译环境控制
聊一下上面的 5 个环境控制开关,定义在 feature_switches.rb 。
is_prebuild_stag
用于标记当前是否在进行 binary install。
1class_attr_accessor :is_prebuild_stage
2
3def class_attr_accessor(symbol)
4 self.class.send(:attr_accessor, symbol)
5end
attr_accessor
是 Ruby 为 instance 提供的 Access 方法,类似 Objc 的 @perperty
可以自动生成 getter & setter。作者利用 Ruby 的动态调用,将 symbol 发送给 attr_accessor
来添加 class_attr_accessor
的扩展。
enable_prebuild_patch
用于过滤出需要预编译的 pod,默认为 false。
Cocoapods-Binary 在使用说明中提到过两种设置预编译的方式:
- 针对单个具体的 pod 的可选参数:
:binary => true
- 在所有 targets 之前的全局参数:
all_binary!
enable_prebuild_patch 就是用于实现这两个变量的判断逻辑,只有在 binary install 时会将 enable_prebuild_patch 设为 true,开启之后不需要预编译的 pod
都会被忽略掉。实现如下:
1class Podfile
2 module DSL
3 @@enable_prebuild_patch = false
4 def self.enable_prebuild_patch(value)
5 @@enable_prebuild_patch = value
6 end
7
8 old_method = instance_method(:pod)
9 define_method(:pod) do |name, *args|
10 if !@@enable_prebuild_patch
11 old_method.bind(self).(name, *args)
12 return
13 end
14 # --- patch content ---
15 ...
16 end
17 end
18end
由于可选参数 :binary => true
是添加在每个 pod 上,因此我们需要先 hook pod 实现来获取 options。
- 通过
instance_method
获取旧的pod
方法 - 用
define_method
来完成重载 - 以
old_method.bind(self).(name, *args)
完成原有逻辑的调用。
Ruby Method Swizzling 三部曲 😂 ,后续还有许多使用该方式的 hook 操作。
接着看 patch content 逻辑:
1should_prebuild = Pod::Podfile::DSL.prebuild_all
2local = false
3
4options = args.last
5if options.is_a?(Hash) and options[Pod::Prebuild.keyword] != nil
6 should_prebuild = options[Pod::Prebuild.keyword]
7 local = (options[:path] != nil)
8end
9
10if should_prebuild and (not local)
11 old_method.bind(self).(name, *args)
12end
-
检查总开关
prebuild_all
对应的是all_binary!
的声明。 -
检查
pod
方法中是否存在可选参数:binary
,并更新should_prebuild
-
只有
should_prebuild
为 true 的 pod 方法才会走原有逻辑,false 的会被忽略
作者通过这样的配合完成了一键 all_binary!
和单个 :binary
的个性化设置。
force_disable_integration
a force disable option for integral
正常 CocoaPods 安装后会执行 integrate_user_project 整合用户的 project:
- 创建 xcode 的 workspace, 并整合所有的 target 到新的 workspace 中
- 抛出 Podfile 空项目依赖和 xcconfig 是否被原有的 xcconfig 所覆盖依赖相关的警告。
这里 通过 force_disable_integration 拦截后强制跳过合成这一步。
disable_install_complete_message
a option to disable install complete message
install 后会执行 print_post_install_message 来输出各种收集的警告,这里同样以 hook 强制跳过。
force_disable_write_lockfile
option to disable write lockfiles
正常的 pod install 会生成 podfile.lock
文件以保存上次的 Pods 依赖配置。在预编译中我们通过替换 lockfile_path
将锁文件保存到了 Pods/_Prebuild/Manifest.lock.tmp
。
1class Config
2 @@force_disable_write_lockfile = false
3 def self.force_disable_write_lockfile(value)
4 @@force_disable_write_lockfile = value
5 end
6
7 old_method = instance_method(:lockfile_path)
8 define_method(:lockfile_path) do
9 if @@force_disable_write_lockfile
10 return PrebuildSandbox.from_standard_sanbox_path(sandbox_root).root + 'Manifest.lock.tmp'
11 else
12 return old_method.bind(self).()
13 end
14 end
15end
Binary Pod 的预编译
cocoapods-binary 在下载 binary pod 源码前会先检查是否已经有预编译好的二进制包,如果没有缓存才会开始binary pod 的下载和预编译。
预编译的缓存查询
缓存查询方法为 have_exact_prebuild_cache? ,我们在前面提到过,来看其实现:
1def have_exact_prebuild_cache?
2 return false if local_manifest == nil # step 1
3 # step 2
4 changes = prebuild_pods_changes
5 added = changes.added
6 changed = changes.changed
7 unchanged = changes.unchanged
8 deleted = changes.deleted
9
10 exsited_framework_pod_names = sandbox.exsited_framework_pod_names
11 missing = unchanged.select do |pod_name|
12 not exsited_framework_pod_names.include?(pod_name)
13 end
14
15 needed = (added + changed + deleted + missing)
16 return needed.empty?
17end
- 检查
PrebuildSandbox
下是否存在Manifest.lock
文件,没有则说明没有成功执行过 binary pod 安装过也就不会有缓存,可以直接 return false - 执行
prebuild_pods_changes
获取 pod 变更 - 执行
exsited_framework_pod_names
查看是否有预编译过的 framework - 结合前两步的结果判断是否存在缓存
预编译的 mainfest 检查
1def local_manifest
2 if not @local_manifest_inited
3 @local_manifest_inited = true
4 raise "This method should be call before generate project" unless self.analysis_result == nil
5 @local_manifest = self.sandbox.manifest
6 end
7 @local_manifest
8end
local_manifest 其实是 ruby 的一个方法,作用上面介绍过,那 Manifest.lock 文件又是啥?详细:objc.io
This is a copy of the
Podfile.lock
that gets created every time you runpod install
. If you’ve ever seen the errorThe sandbox is not in sync with the Podfile.lock
, it’s because this file is no longer the same as thePodfile.lock
.
由于 Pods
目录并不一定会添加到项目的 version control 中,所以利用 Mainfest.lock
来确保工程师在运行项目前能够准确更新对应的 pod。否则会导致 build 失败等各种问题。
预编译的 pods 变更检查
接着通过 prebuild_pods_changes
检查是否有需要更新的 pod:
1def prebuild_pods_changes
2 return nil if local_manifest.nil?
3 if @prebuild_pods_changes.nil?
4 changes = local_manifest.detect_changes_with_podfile(podfile)
5 @prebuild_pods_changes = Analyzer::SpecsState.new(changes)
6 # save the chagnes info for later stage
7 Pod::Prebuild::Passer.prebuild_pods_changes = @prebuild_pods_changes
8 end
9 @prebuild_pods_changes
10end
方法第一行也检查了 local_manifest
是因为会被多处调用,所有这里也添加了判断。核心是依赖 cocoapods-core 的 detect_changes_with_podfile 来获取需要更新的 pods,其描述如下:
Analyzes the Pod::Lockfile and detects any changes applied to the Podfile since the last installation.
它对于每个 pod 会检查如下几个状态:
- added: Pods that weren’t present in the Podfile.
- changed: Pods that were present in the Podfile but changed:
- Pods whose version is not compatible anymore with Podfile,
- Pods that changed their external options.
- removed: Pods that were removed form the Podfile.
- unchanged: Pods that are still compatible with Podfile.
最后从 unchanged
中查找 exsited_framework_pod_names
来判断是否有已预编译过的 framework。
预编译的 framework 缓存检查
预编译的 framework 检查即 exsited_framework_pod_names
从 exsited_framework_name_pairs
中 map 过来的。
1def exsited_framework_pod_names
2 exsited_framework_name_pairs.map {|pair| pair[1]}.uniq
3end
exsited_framework_name_pairs
1def pod_name_for_target_folder(target_folder_path)
2 name = Pathname.new(target_folder_path).children.find do |child|
3 child.to_s.end_with? ".pod_name"
4 end
5 name = name.basename(".pod_name").to_s unless name.nil?
6 name ||= Pathname.new(target_folder_path).basename.to_s # for compatibility with older version
7end
8
9# Array<[target_name, pod_name]>
10def exsited_framework_name_pairs
11 return [] unless generate_framework_path.exist?
12 generate_framework_path.children().map do |framework_path|
13 if framework_path.directory? && (not framework_path.children.empty?)
14 [framework_path.basename.to_s, pod_name_for_target_folder(framework_path)]
15 else
16 nil
17 end
18 end.reject(&:nil?).uniq
19end
该方法虽然逻辑简单,却是比较核心的逻辑之一。
它最终调用 pod_name_for_target_folder
来检查 Pods/_Prebuild/GeneratedFrameworks
目录下对应的 framework 中是否存在以 .pod_name 为结尾的文件,来标示该 framework 是否完成了预编译。它就是一个空文件而已。
Target 编译
通过 pre_install
下载完 pods 后就要开始编译啦,入口如下:
1old_method2 = instance_method(:run_plugins_post_install_hooks)
2define_method(:run_plugins_post_install_hooks) do
3 old_method2.bind(self).()
4 if Pod::is_prebuild_stage
5 self.prebuild_frameworks!
6 end
7end
为了保证 prebuild_frameworks!
是在最后一步执行,作者并未通过 HooksManager
来添加 plugins 的 post_install
而是直接 Override 了它的调用方法。
关于 install hooks,CocoaPods 提供了两种类型:Podfile hook 和 plugin hook。
插件的 hooks 操作都是通过 HooksManager
来完成调用的,podfile 中提供的 hooks 则是单独的方法执行时机也是各有不同。
prebuild_frameworks!
方法比较长就不贴完整的代码了,概括如下:
第一步:获取待更新的 targets
targets 的获取逻辑同预编译缓存检查中的 needed 的获取有些类似。
- 通过
prebuild_pods_changes
和exsited_framework_pod_names
得到root_names_to_update
- 接着用
fast_get_targets_for_pod_name
找出对应的 taregets - 调用
recursive_dependent_targets
将 targets 的依赖 map 出来,合并到 targets 中并去重复 - 过滤掉已预编译过的 targets 以及被标记为
:binary => false
的 target。
注意,如果 pod target 原本就是以 .a + .h
形式存在的二进制包,则会被直接过滤掉。
第二步:完成 pod target 的编译、保存资源文件、写入以 .pod_name
为结尾的标记文件。
核心逻辑是通过 xcodebuild
命令来完成编译打包,最后将生成的二进制包和 dSYM 输出到 GeneratedFrameworks
目录。需要注意的是对于 iOS 平台生成的二进制包同时包含了模拟器和真机的二进制文件,分别打包后再通过 libo
合并成一份 Fat Binary 的。
完整的构建代码在 build_framework.rb 中这里也不作展开,就补充两点:
- 对于 static framework 需要在编译后,手动将相关资源 copy 回来。因此,需要先将对应路径保存。
1path_objects = resources.map do |path|
2 object = Prebuild::Passer::ResourcePath.new
3 object.real_file_path = framework_path + File.basename(path)
4 object.target_file_path = path.gsub('${PODS_ROOT}', standard_sandbox_path.to_s) if path.start_with? '${PODS_ROOT}'
5 object.target_file_path = path.gsub("${PODS_CONFIGURATION_BUILD_DIR}", standard_sandbox_path.to_s) if path.start_with? "${PODS_CONFIGURATION_BUILD_DIR}"
6 object
7end
8Prebuild::Passer.resources_to_copy_for_static_framework[target.name] = path_objects
- 如果 pod 中包含了 vendored library 和 vendered framework 也会在 build 后 copy 回来。
第三步:清理无用文件和 pods
注意!install 完成后仅保存 PrebuildSandbox
下的 Manifest.lock
以及 GeneratedFrameworks
目录下的 framework,而下载至 _Prebuild
目录下的源码都会被清理。
Binary Pod 的集成
最后集成工作是在普通的 pod install 模式下,同样通过 Ruby Method Swizzling 三部曲 来拦截的。
resolve dependencies
resolve_dependencies 在 cocoapods 中是通过创建 Analyzer
来分析 podfile 的 dependencies。
这里 hook 后主要用于清理并修改 prebuild specs 中对应的数据。
- 删除 prebuild framework 时下载的 source code 和生成的 target support files;
- 将 prebuild spec 中的 source_files 的配置全部置空,然后用 prebuild 后的 framework 作为 vender framework 替代;
- 清除 prebuild spec 中的 resource_bundles;
简化后逻辑如下:
1old_method2 = instance_method(:resolve_dependencies)
2define_method(:resolve_dependencies) do
3
4 self.remove_target_files_if_needed # 1
5
6 old_method2.bind(self).()
7 self.validate_every_pod_only_have_one_form
8
9 cache = []
10 specs = self.analysis_result.specifications
11 prebuilt_specs = (specs.select do |spec|
12 self.prebuild_pod_names.include? spec.root.name
13 end)
14
15 prebuilt_specs.each do |spec|
16 # 2
17 targets = Pod.fast_get_targets_for_pod_name(spec.root.name, self.pod_targets, cache)
18 targets.each do |target|
19 framework_file_path = target.framework_name
20 framework_file_path = target.name + "/" + framework_file_path if targets.count > 1
21 add_vendered_framework(spec, target.platform.name.to_s, framework_file_path)
22 end
23
24 empty_source_files(spec)
25
26 # 3
27 if spec.attributes_hash["resource_bundles"]
28 bundle_names = spec.attributes_hash["resource_bundles"].keys
29 spec.attributes_hash["resource_bundles"] = nil
30 spec.attributes_hash["resources"] ||= []
31 spec.attributes_hash["resources"] += bundle_names.map{|n| n+".bundle"}
32 end
33
34 # to avoid the warning of missing license
35 spec.attributes_hash["license"] = {}
36 end
37end
关于这三步操作,分别做一些解答。
第一步的清理工作是在执行原有逻辑前执行,是为了避免生成的旧的 targets 文件触发文件修改的警告。
call original 后又执行了一次 validate_every_pod_only_have_one_form
,虽然没有副作用,但目的是为了避免一些异常情况下,某些 pod 以源码的形式又出现在其他 target 中。
target_checker 中作者提到过 cocoapods-binary 有一个限制:
一个 pod 只能允许对应一个 target。
理由就是我们在第二步将预编译后的 static framework 作为 vender framework 的方式嵌入到项目中,同时清空了全部 platform 上的 source_files 的配置。
第三步对于 resource bundle target 的清理,是为了避免 resource bundle 的重复 copy。
因为 pod install 中 ,如果 podspec 指定了 resource_bundles
,Xcode 是会为我们生成 bundle target 的。而我们在 static framework 中已经生成并 copy 了,所以避免重复需要清除一下。
download dependencies
这一步是 hook 了 download_dependencies
执行中会触发的一个方法:install_source_of_pod
1old_method = instance_method(:install_source_of_pod)
2define_method(:install_source_of_pod) do |pod_name|
3
4 # original logic ...
5 if self.prebuild_pod_names.include? pod_name
6 pod_installer.install_for_prebuild!(self.sandbox)
7 else
8 pod_installer.install!
9 end
10 # original logic ...
11end
这里的目的是为跳过 binary pod 的下载,以及完成 symbol link 的操作。简化逻辑如下:
1def install_for_prebuild!(standard_sanbox)
2 return if standard_sanbox.local? self.name
3
4 prebuild_sandbox = Pod::PrebuildSandbox.from_standard_sandbox(standard_sanbox)
5 target_names = prebuild_sandbox.existed_target_names_for_pod_name(self.name)
6
7 target_names.each do |name|
8
9 real_file_folder = prebuild_sandbox.framework_folder_path_for_target_name(name)
10
11 target_folder = standard_sanbox.pod_dir(self.name)
12 if target_names.count > 1
13 target_folder += real_file_folder.basename
14 end
15 target_folder.rmtree if target_folder.exist?
16 target_folder.mkpath
17
18
19 walk(real_file_folder) do |child|
20 source = child
21 # only make symlink to file and `.framework` folder
22 if child.directory? and [".framework", ".dSYM"].include? child.extname
23 mirror_with_symlink(source, real_file_folder, target_folder)
24 next false # return false means don't go deeper
25 elsif child.file?
26 mirror_with_symlink(source, real_file_folder, target_folder)
27 next true
28 else
29 next true
30 end
31 end
32
33 # symbol link copy resource for static framework
34 hash = Prebuild::Passer.resources_to_copy_for_static_framework || {}
35
36 path_objects = hash[name]
37 if path_objects != nil
38 path_objects.each do |object|
39 make_link(object.real_file_path, object.target_file_path)
40 end
41 end
42 end # of for each
43end # of method
核心是 walk 方法,它遍历每个 static framework 目录下的文件和 .framework
文件夹,将其作为 Pods
目录下对应文件的引用。
EmbedFrameworksScript
这一步是为了解决 symbol link 后,对于 embeded framework 中产生的问题。embedded framework
是苹果在 iOS 8 后提出的代码共享方案,为了解决宿主 App 和 Extensions 的代码共用而产生的。详细 medium 文章;
1old_method = instance_method(:script)
2define_method(:script) do
3
4 script = old_method.bind(self).()
5 patch = <<-SH.strip_heredoc
6 #!/bin/sh
7 old_read_link=`which readlink`
8 readlink () {
9 path=`$old_read_link $1`;
10 if [ $(echo "$path" | cut -c 1-1) = '/' ]; then
11 echo $path;
12 else
13 echo "`dirname $1`/$path";
14 fi
15 }
16 SH
17 script = script.gsub "rsync --delete", "rsync --copy-links --delete"
18 patch + script
19end
上一步中把 pod target 文件夹中的 framework 文件改成了相对路径的 symblink,而 EmbedFrameworksScript 是通过 readlink
的方式来读取路径,它对相对路径的处理不太好,这里需要重写。
重写其实就是在把相对路径改为绝对路径。
流程图
最后贴一下,梳理的流程图:
结果对比
基本的模块介绍完,我们来看看,引入 cocoapods-binary 插件后 Pods 的文件构成:
_Prebuild 目录下则完整保存了一份 Pods 源代码,同时多出来的 GeneratedFrameworks 则缓存了预编译后的 binary 文件以及 dSYM 符号表。在最后的 integration 阶段 symbol link 替换完后源码则会被删除同时指向binary。
总结
使用过程中这个方案还是有很多限制的:
- 由于 CocoaPods 在 1.7 以上版本修改了 framework 生成逻辑,不会把 bundle copy 至 framework,因此需要将 Pod 环境固定到 1.6.2;
- pod 要支持 binary,header ref 需要变更为
#import <>
或者@import
以符合 moduler 标准; - 需要统一开发环境。如果项目支持 Swift,不同 compiler 编译产物有 Swift 版本兼容问题;
- 最终的 binary 体积比使用源码的时候大一点,不建议最终上传 Store;
- 建议 ignore Pods 文件夹,否则在 source code 与 binary 切换过程会有大量 change,增加 git 负担;
- 如果需要 debug 就需要切换回源码,或者通过 dSYM 映射来完成方法对定位。
整体感觉是很不错的思路,适用于人数不多的中小型项目。一旦项目依赖库较多,可能就不太适用了,限制太多,同时对开发的要求和环境的一致性要求比较高。
前期准备基本介绍完了,下一节就是核心的 prebuild 逻辑。先上一张脑图补一补: