阅读 561
浅析 cocoapods-binary

浅析 cocoapods-binary

介绍

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,其拦截方式如下:

Pod::HooksManager.register('cocoapods-binary', :pre_install) do |installer_context|
  ...
end
复制代码

利用 CocoaPods 提供的 HooksManager 注册 pre_install hook 来下载 binary pods。过程分两步:

Main.png

环境检查

环境检查,首先是通过标记全局的 is_prebuild_stage 来防止 pre_install 重复的进入。

if Pod.is_prebuild_stage
  next
end
复制代码

接着检查 podfile 是否设置了 use_framework!

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 相关方法进行预编译状态检查,并在安装结束后将它们重置。

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:

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_sandboxprebuild_podfilelockfile

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

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

install

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。

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 都会被忽略掉。实现如下:

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。

  1. 通过 instance_method 获取旧的 pod 方法
  2. define_method 来完成重载
  3. old_method.bind(self).(name, *args) 完成原有逻辑的调用。

Ruby Method Swizzling 三部曲 😂 ,后续还有许多使用该方式的 hook 操作。

接着看 patch content 逻辑:

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
复制代码
  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

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
复制代码

feature_switch.png

Binary Pod 的预编译

cocoapods-binary 在下载 binary pod 源码前会先检查是否已经有预编译好的二进制包,如果没有缓存才会开始binary pod 的下载和预编译。

预编译的缓存查询

缓存查询方法为 have_exact_prebuild_cache? ,我们在前面提到过,来看其实现:

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

预编译的 mainfest 检查

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 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:

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_namesexsited_framework_name_pairs 中 map 过来的。

def exsited_framework_pod_names
    exsited_framework_name_pairs.map {|pair| pair[1]}.uniq
end
复制代码

exsited_framework_name_pairs

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 是否完成了预编译。它就是一个空文件而已。

exact_prebuild_cache.png

Target 编译

通过 pre_install 下载完 pods 后就要开始编译啦,入口如下:

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.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 回来。因此,需要先将对应路径保存。
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
复制代码
  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;

简化后逻辑如下:

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

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 的操作。简化逻辑如下:

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 文章;

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 逻辑。先上一张脑图补一补:

structure

文章分类
iOS
文章标签