浅析 cocoapods-binary
source link: https://looseyi.github.io/post/sourcecode-ios/cocoapods-binary-1/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
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,其拦截方式如下:
1
2
3
Pod::HooksManager.register('cocoapods-binary', :pre_install) do |installer_context|
...
end
利用 CocoaPods 提供的 HooksManager 注册 pre_install
hook 来下载 binary pods。过程分两步:
环境检查,首先是通过标记全局的 is_prebuild_stage
来防止 pre_install
重复的进入。
1
2
3
if Pod.is_prebuild_stage
next
end
接着检查 podfile 是否设置了 use_framework!
。
1
2
3
4
5
6
7
8
podfile = installer_context.podfile
podfile.target_definition_list.each do |target_definition|
next if target_definition.prebuild_framework_pod_names.empty?
if not target_definition.uses_frameworks?
STDERR.puts "[!] Cocoapods-binary requires `use_frameworks!`".red
exit
end
end
即 cocoapods-binary 需要打出的包为 framework 的形式。了解更多 CocoaPods 使用 framework 的原因,请猛戳 这里。
Binary Pod 的下载安装
这里说的 binary pod 都是在 podfile 中被标记为
:binary => true
的。
安装前需要 hook 相关方法进行预编译状态检查,并在安装结束后将它们重置。
1
2
3
4
5
6
7
8
Pod.is_prebuild_stage = true
Pod::Podfile::DSL.enable_prebuild_patch true
Pod::Installer.force_disable_integration true
Pod::Config.force_disable_write_lockfile true
Pod::Installer.disable_install_complete_message true
...
# install 成功后将 👆 5 个变量重置为 false
Pod::UserInterface.warnings = [] # clean the warning in the prebuild step, it's duplicated.
接着初始化 binary_installer:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
update = nil
repo_update = nil
include ObjectSpace
ObjectSpace.each_object(Pod::Installer) { |installer|
update = installer.update
repo_update = installer.repo_update
}
standard_sandbox = installer_context.sandbox
prebuild_sandbox = Pod::PrebuildSandbox.from_standard_sandbox(standard_sandbox)
prebuild_podfile = Pod::Podfile.from_Ruby(podfile.defined_in_file)
lockfile = installer_context.lockfile
binary_installer = Pod::Installer.new(prebuild_sandbox, prebuild_podfile, lockfile)
# install ...
installer 的初始化需要:prebuild_sandbox
、prebuild_podfile
、lockfile
。
prebuild_sandbox
管理的目录在 Pods/_Prebuild
,它是 Sandbox 的子类。Sandbox 管理着 CocoaPods 的 /Pods
目录。
lockfile 和 prebuild_podfile 是分别从工程目录的 podfile.lock
和 Podfile
中读取的。
install
1
2
3
4
5
6
7
if binary_installer.have_exact_prebuild_cache? && !update
binary_installer.install_when_cache_hit!
else
binary_installer.update = update
binary_installer.repo_update = repo_update
binary_installer.install!
end
当缓存命中且没有 pod 需要更新时,会执行 install_when_cache_hit!
(就是打印一下 cache pods),否则开始 binary pod 的下载,下载目录就在 Pods/_Prebuild
下。
预编译环境控制
聊一下上面的 5 个环境控制开关,定义在 feature_switches.rb 。
is_prebuild_stag
用于标记当前是否在进行 binary install。
1
2
3
4
5
class_attr_accessor :is_prebuild_stage
def class_attr_accessor(symbol)
self.class.send(:attr_accessor, symbol)
end
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
都会被忽略掉。实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Podfile
module DSL
@@enable_prebuild_patch = false
def self.enable_prebuild_patch(value)
@@enable_prebuild_patch = value
end
old_method = instance_method(:pod)
define_method(:pod) do |name, *args|
if !@@enable_prebuild_patch
old_method.bind(self).(name, *args)
return
end
# --- patch content ---
...
end
end
end
由于可选参数 :binary => true
是添加在每个 pod 上,因此我们需要先 hook pod 实现来获取 options。
- 通过
instance_method
获取旧的pod
方法 - 用
define_method
来完成重载 - 以
old_method.bind(self).(name, *args)
完成原有逻辑的调用。
Ruby Method Swizzling 三部曲 😂 ,后续还有许多使用该方式的 hook 操作。
接着看 patch content 逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
should_prebuild = Pod::Podfile::DSL.prebuild_all
local = false
options = args.last
if options.is_a?(Hash) and options[Pod::Prebuild.keyword] != nil
should_prebuild = options[Pod::Prebuild.keyword]
local = (options[:path] != nil)
end
if should_prebuild and (not local)
old_method.bind(self).(name, *args)
end
-
检查总开关
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
。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Config
@@force_disable_write_lockfile = false
def self.force_disable_write_lockfile(value)
@@force_disable_write_lockfile = value
end
old_method = instance_method(:lockfile_path)
define_method(:lockfile_path) do
if @@force_disable_write_lockfile
return PrebuildSandbox.from_standard_sanbox_path(sandbox_root).root + 'Manifest.lock.tmp'
else
return old_method.bind(self).()
end
end
end
Binary Pod 的预编译
cocoapods-binary 在下载 binary pod 源码前会先检查是否已经有预编译好的二进制包,如果没有缓存才会开始binary pod 的下载和预编译。
预编译的缓存查询
缓存查询方法为 have_exact_prebuild_cache? ,我们在前面提到过,来看其实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def have_exact_prebuild_cache?
return false if local_manifest == nil # step 1
# step 2
changes = prebuild_pods_changes
added = changes.added
changed = changes.changed
unchanged = changes.unchanged
deleted = changes.deleted
exsited_framework_pod_names = sandbox.exsited_framework_pod_names
missing = unchanged.select do |pod_name|
not exsited_framework_pod_names.include?(pod_name)
end
needed = (added + changed + deleted + missing)
return needed.empty?
end
- 检查
PrebuildSandbox
下是否存在Manifest.lock
文件,没有则说明没有成功执行过 binary pod 安装过也就不会有缓存,可以直接 return false - 执行
prebuild_pods_changes
获取 pod 变更 - 执行
exsited_framework_pod_names
查看是否有预编译过的 framework - 结合前两步的结果判断是否存在缓存
预编译的 mainfest 检查
1
2
3
4
5
6
7
8
def local_manifest
if not @local_manifest_inited
@local_manifest_inited = true
raise "This method should be call before generate project" unless self.analysis_result == nil
@local_manifest = self.sandbox.manifest
end
@local_manifest
end
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:
1
2
3
4
5
6
7
8
9
10
def prebuild_pods_changes
return nil if local_manifest.nil?
if @prebuild_pods_changes.nil?
changes = local_manifest.detect_changes_with_podfile(podfile)
@prebuild_pods_changes = Analyzer::SpecsState.new(changes)
# save the chagnes info for later stage
Pod::Prebuild::Passer.prebuild_pods_changes = @prebuild_pods_changes
end
@prebuild_pods_changes
end
方法第一行也检查了 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 过来的。
1
2
3
def exsited_framework_pod_names
exsited_framework_name_pairs.map {|pair| pair[1]}.uniq
end
exsited_framework_name_pairs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def pod_name_for_target_folder(target_folder_path)
name = Pathname.new(target_folder_path).children.find do |child|
child.to_s.end_with? ".pod_name"
end
name = name.basename(".pod_name").to_s unless name.nil?
name ||= Pathname.new(target_folder_path).basename.to_s # for compatibility with older version
end
# Array<[target_name, pod_name]>
def exsited_framework_name_pairs
return [] unless generate_framework_path.exist?
generate_framework_path.children().map do |framework_path|
if framework_path.directory? && (not framework_path.children.empty?)
[framework_path.basename.to_s, pod_name_for_target_folder(framework_path)]
else
nil
end
end.reject(&:nil?).uniq
end
该方法虽然逻辑简单,却是比较核心的逻辑之一。
它最终调用 pod_name_for_target_folder
来检查 Pods/_Prebuild/GeneratedFrameworks
目录下对应的 framework 中是否存在以 .pod_name 为结尾的文件,来标示该 framework 是否完成了预编译。它就是一个空文件而已。
Target 编译
通过 pre_install
下载完 pods 后就要开始编译啦,入口如下:
1
2
3
4
5
6
7
old_method2 = instance_method(:run_plugins_post_install_hooks)
define_method(:run_plugins_post_install_hooks) do
old_method2.bind(self).()
if Pod::is_prebuild_stage
self.prebuild_frameworks!
end
end
为了保证 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 回来。因此,需要先将对应路径保存。
1
2
3
4
5
6
7
8
path_objects = resources.map do |path|
object = Prebuild::Passer::ResourcePath.new
object.real_file_path = framework_path + File.basename(path)
object.target_file_path = path.gsub('${PODS_ROOT}', standard_sandbox_path.to_s) if path.start_with? '${PODS_ROOT}'
object.target_file_path = path.gsub("${PODS_CONFIGURATION_BUILD_DIR}", standard_sandbox_path.to_s) if path.start_with? "${PODS_CONFIGURATION_BUILD_DIR}"
object
end
Prebuild::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;
简化后逻辑如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
old_method2 = instance_method(:resolve_dependencies)
define_method(:resolve_dependencies) do
self.remove_target_files_if_needed # 1
old_method2.bind(self).()
self.validate_every_pod_only_have_one_form
cache = []
specs = self.analysis_result.specifications
prebuilt_specs = (specs.select do |spec|
self.prebuild_pod_names.include? spec.root.name
end)
prebuilt_specs.each do |spec|
# 2
targets = Pod.fast_get_targets_for_pod_name(spec.root.name, self.pod_targets, cache)
targets.each do |target|
framework_file_path = target.framework_name
framework_file_path = target.name + "/" + framework_file_path if targets.count > 1
add_vendered_framework(spec, target.platform.name.to_s, framework_file_path)
end
empty_source_files(spec)
# 3
if spec.attributes_hash["resource_bundles"]
bundle_names = spec.attributes_hash["resource_bundles"].keys
spec.attributes_hash["resource_bundles"] = nil
spec.attributes_hash["resources"] ||= []
spec.attributes_hash["resources"] += bundle_names.map{|n| n+".bundle"}
end
# to avoid the warning of missing license
spec.attributes_hash["license"] = {}
end
end
关于这三步操作,分别做一些解答。
第一步的清理工作是在执行原有逻辑前执行,是为了避免生成的旧的 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
1
2
3
4
5
6
7
8
9
10
11
old_method = instance_method(:install_source_of_pod)
define_method(:install_source_of_pod) do |pod_name|
# original logic ...
if self.prebuild_pod_names.include? pod_name
pod_installer.install_for_prebuild!(self.sandbox)
else
pod_installer.install!
end
# original logic ...
end
这里的目的是为跳过 binary pod 的下载,以及完成 symbol link 的操作。简化逻辑如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def install_for_prebuild!(standard_sanbox)
return if standard_sanbox.local? self.name
prebuild_sandbox = Pod::PrebuildSandbox.from_standard_sandbox(standard_sanbox)
target_names = prebuild_sandbox.existed_target_names_for_pod_name(self.name)
target_names.each do |name|
real_file_folder = prebuild_sandbox.framework_folder_path_for_target_name(name)
target_folder = standard_sanbox.pod_dir(self.name)
if target_names.count > 1
target_folder += real_file_folder.basename
end
target_folder.rmtree if target_folder.exist?
target_folder.mkpath
walk(real_file_folder) do |child|
source = child
# only make symlink to file and `.framework` folder
if child.directory? and [".framework", ".dSYM"].include? child.extname
mirror_with_symlink(source, real_file_folder, target_folder)
next false # return false means don't go deeper
elsif child.file?
mirror_with_symlink(source, real_file_folder, target_folder)
next true
else
next true
end
end
# symbol link copy resource for static framework
hash = Prebuild::Passer.resources_to_copy_for_static_framework || {}
path_objects = hash[name]
if path_objects != nil
path_objects.each do |object|
make_link(object.real_file_path, object.target_file_path)
end
end
end # of for each
end # of method
核心是 walk 方法,它遍历每个 static framework 目录下的文件和 .framework
文件夹,将其作为 Pods
目录下对应文件的引用。
EmbedFrameworksScript
这一步是为了解决 symbol link 后,对于 embeded framework 中产生的问题。embedded framework
是苹果在 iOS 8 后提出的代码共享方案,为了解决宿主 App 和 Extensions 的代码共用而产生的。详细 medium 文章;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
old_method = instance_method(:script)
define_method(:script) do
script = old_method.bind(self).()
patch = <<-SH.strip_heredoc
#!/bin/sh
old_read_link=`which readlink`
readlink () {
path=`$old_read_link $1`;
if [ $(echo "$path" | cut -c 1-1) = '/' ]; then
echo $path;
else
echo "`dirname $1`/$path";
fi
}
SH
script = script.gsub "rsync --delete", "rsync --copy-links --delete"
patch + script
end
上一步中把 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 逻辑。先上一张脑图补一补:
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK