介绍

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 作为入口,开始插件的运作。

pre_install.png

当我们在命令行中执行 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。过程分两步:

Main.png

环境检查

环境检查,首先是通过标记全局的 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_sandboxprebuild_podfilelockfile

prebuild_sandbox 管理的目录在 Pods/_Prebuild,它是 Sandbox 的子类。Sandbox 管理着 CocoaPods 的 /Pods 目录。

lockfile 和 prebuild_podfile 是分别从工程目录的 podfile.lockPodfile 中读取的。

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。

  1. 通过 instance_method 获取旧的 pod 方法
  2. define_method 来完成重载
  3. 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
  1. 检查总开关 prebuild_all 对应的是 all_binary! 的声明。

  2. 检查 pod 方法中是否存在可选参数 :binary,并更新 should_prebuild

  3. 只有 should_prebuild 为 true 的 pod 方法才会走原有逻辑,false 的会被忽略

作者通过这样的配合完成了一键 all_binary! 和单个 :binary 的个性化设置。

enable_prebuild_patch.png

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

feature_switch.png

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
  1. 检查 PrebuildSandbox 下是否存在 Manifest.lock 文件,没有则说明没有成功执行过 binary pod 安装过也就不会有缓存,可以直接 return false
  2. 执行 prebuild_pods_changes 获取 pod 变更
  3. 执行 exsited_framework_pod_names 查看是否有预编译过的 framework
  4. 结合前两步的结果判断是否存在缓存

预编译的 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 run pod install. If you’ve ever seen the error The sandbox is not in sync with the Podfile.lock, it’s because this file is no longer the same as the Podfile.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_namesexsited_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 是否完成了预编译。它就是一个空文件而已。

exact_prebuild_cache.png

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.png

插件的 hooks 操作都是通过 HooksManager 来完成调用的,podfile 中提供的 hooks 则是单独的方法执行时机也是各有不同。

prebuild_frameworks!

方法比较长就不贴完整的代码了,概括如下:

第一步:获取待更新的 targets

targets 的获取逻辑同预编译缓存检查中的 needed 的获取有些类似。

  1. 通过 prebuild_pods_changesexsited_framework_pod_names 得到 root_names_to_update
  2. 接着用 fast_get_targets_for_pod_name 找出对应的 taregets
  3. 调用 recursive_dependent_targets 将 targets 的依赖 map 出来,合并到 targets 中并去重复
  4. 过滤掉已预编译过的 targets 以及被标记为 :binary => false 的 target。

注意,如果 pod target 原本就是以 .a + .h 形式存在的二进制包,则会被直接过滤掉。

第二步:完成 pod target 的编译、保存资源文件、写入以 .pod_name 为结尾的标记文件。

核心逻辑是通过 xcodebuild 命令来完成编译打包,最后将生成的二进制包和 dSYM 输出到 GeneratedFrameworks 目录。需要注意的是对于 iOS 平台生成的二进制包同时包含了模拟器和真机的二进制文件,分别打包后再通过 libo 合并成一份 Fat Binary 的。

完整的构建代码在 build_framework.rb 中这里也不作展开,就补充两点:

  1. 对于 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
  1. 如果 pod 中包含了 vendored libraryvendered 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 中对应的数据。

  1. 删除 prebuild framework 时下载的 source code 和生成的 target support files;
  2. 将 prebuild spec 中的 source_files 的配置全部置空,然后用 prebuild 后的 framework 作为 vender framework 替代;
  3. 清除 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 逻辑。先上一张脑图补一补:

structure