1. 前言
本篇文章是cocoapods-jxedt插件实现方案的详解,主要从以下几个方面阐述了一下插件的实现方案和历程。
- 插件文件目录介绍
- 插件的工作流程介绍
- 插件实现过程中的问题和解决方案记录
如果你对插件的使用还不了解,建议先读一下cocoapods-jxedt使用介绍这篇文章。
如果你想要了解插件的实现方案,或者你想更好的理解插件去使用,那么请继续读下去,相信会有所收获。
2. 框架图
插件的主要框架如下图所示:
- 图中把框架的主体部分分为了三大部分,git缓存、插件执行流程、以及宿主工程
- 按照执行流程和对应关系描述了其中的关系
- 其中实线箭头表示一定会执行的步骤,长虚线为选择执行(需配置)步骤,短虚线为git操作
- 整个流程从右上角主工程执行
pod install
开始,到hook post_install
验证结果结束
框架只是一个简单的概括,在文章第4部分会完整介绍各流程。
3. 插件文件目录
要了解插件的实现方案,还需要了解一下插件几个关键的文件目录和用途。
根据插件的功能特性,我把插件的关键目录做了一点整理。
- binary_dir,二进制的存放路径
- Pods源码工程目录,插件在运行的时候会生成一个全源码的Pods工程
- build目录,xcodebuild命令编译的输出目录
- git cache目录
如果使用插件的话,这些目录的路径和功能是必须要清楚的。下面一一做一下介绍
3.1 binary_dir
:binary_dir => "二进制文件的保存路径,'Pods/Pods.xcodeproj'文件的相对路径。默认为'../_Prebuild'"
如上述代码片段介绍,binary_dir
参数是配置二进制存放路径的。也就是说默认插件会在Pods同级目录下生成_Prebuild
的目录,用来存放编译的二进制文件。
预编译的二进制都会被放置在binary_dir
的目录,插件链接二进制也是从这个目录下按照规则查找的。如下图:
至于checksum(校验和)的校验规则,文章后续会有介绍。
3.2 Pods源码工程目录
插件在运行的时候会生成一个源码工程,这个工程依赖的所有组件都是源码的形式。
这么处理的原因有两个:
- 需要用到这个工程去编译源码对应的二进制
- 保留这个文件目录,可以随时打开查看二进制对应的源码实现
Pods路径
Pods-Source路径
如图所示,可以直接打开Pods-Source.xcodeproj
文件查看源码,文件夹中没有真实的源码路径,是因为源码都在Pods目录下,后续也会介绍到。详细介绍在第5.1小节,Pods-Source工程生成逻辑
3.3 build目录
build目录是Pods工程执行xcodebuild命令的输出路径,是cocoapods默认设置的
插件在xcodebuild命令执行完成会去build目录收集编译后的二进制文件,执行合并架构、生成checksum文件等,最后把结果存放到binary_dir
目录下。
3.4 git cache目录
插件配置了git缓存,会有一个专门的目录存放从远程git仓库拉取的二进制文件,它的路径是~/Users/#{user}/.cocoapods-jxedt
。如下图所示:
即在用户的根目录下创建一个.cocoapods-jxedt
的文件夹,存放对应的git缓存文件。
4. 插件工作流程
4.1 读取Podfile配置
读取Podfile文件中插件的配置,包括需要预编译的组件配置、预编译结果的保存路径、是否支持xcframework、编译选项、git缓存地址配置等。
Podfile配置这一步骤我把它设定为多参数自定义配置的形式,主要还是为了增大插件的灵活性,开发者可以灵活的去选择配置。
4.2 hook cocoapods pre_install和post_install流程
插件hook了cocoapods的pre_install和post_install两个流程,下面是截取的cocoapods源码中这两个流程的执行节点。
module Pod
class Installer
...
# 执行安装
def install!
# 准备工作,sandbox初始化,执行pre_install等
prepare
...
write_lockfiles # 写入锁存文件
perform_post_install_actions
end
def prepare
...
UI.message 'Preparing' do
...
# 执行pre_install
run_plugins_pre_install_hooks
end
end
def perform_post_install_actions
# 执行post_install
run_plugins_post_install_hooks
...
end
end
end
pre_install流程
pre_install是Pod::Installer
类执行install!
方法最开始执行的回调。
插件的实现方案和大部分的二进制化方案差不多,都是通过两次pod install
来实现。hook pre_install步骤再执行一次install!
操作,用来编译二进制。只是细节上可能存在一些差异。
插件hook这个时机生成一个Pod::JxedtPrebuildInstaller
(继承自Pod::Installer
)对象,执行一次install!
方法,这次install会下载所有组件的源码并生成对应的Pods工程,插件会对这个Pods工程做预编译的操作。
post_install流程
post_install是Pod::Installer
类执行install!
方法最最后执行的回调。
此时所有的组件都已经处理完成,lockfile文件也已经写入,插件会在这个回调的时间节点去验证两次install!
的结果是否一致,如果不一致会有警告输出,具体的方案在4.5小节Validation这个步骤中有介绍。
4.3 预编译(Prebuild)
4.3.1 source installer
执行源码安装。
在这个步骤中插件会生成一个Pod::JxedtPrebuildInstaller
对象,JxedtPrebuildInstaller
是继承Pod::Installer
类的。这个类修改了Podfile的installation_options
的默认配置,修改了integrate_targets
参数值为false,把接收的sandbox对象修改为了Pod::JxedtPrebuildSandbox
,prebuild sandbox的路径和原Sandbox的路径不相同,生成的Pods工程的路径也不同。
...
# 获取原始的installer对象,必须先获取对象
original_installer = ObjectSpace.each_object(Pod::Installer).first
prebuild_sandbox = Pod::JxedtPrebuildSandbox.from_standard_sandbox(@installer_context.sandbox)
source_installer = Pod::JxedtPrebuildInstaller.new(prebuild_sandbox, @installer_context.podfile, @installer_context.lockfile)
# 设置原始的installer携带的参数
source_installer.update = original_installer.update
source_installer.repo_update = original_installer.repo_update
# 执行install
source_installer.install!
为了加快install!
过程的速度,插件内也做了一些优化,即复用原Sandbox的source_root目录,两次install!
实际上操作的是同一个source_root目录。如下:
module Pod
...
class JxedtPrebuildSandbox < Sandbox
...
def sources_root
...
# 原Sandbox的源码路径
standard_sandbox.sources_root
end
end
end
4.3.2 fetch and sync git cache
拉取和更新git仓库的组件缓存到配置的binary_dir
路径。
这个步骤会检查git缓存仓库的配置,如果配置了git缓存和自动fetch远程的选项,会和install!
产生的Manifest.lock
文件比较,同步合适版本的二进制文件到binary_dir
路径。
这里的远程操作是git操作,意味着不需要配置静态资源的服务器,只需要提供一个git仓库的地址即可。
那么怎么选择合适的版本同步,版本控制又是怎么做的?
后续章节也会对这个细节做一个详细说明。详细介绍在第5.2小节,pod组件的版本控制。
4.3.3 prebuild job
执行预编译的操作。
插件通过校验binary_dir
路径下已存在的二进制和Manifest.lock
文件中的checksum(校验和)值,判断哪些组件是没有对应的二进制文件或没有合适版本的二进制文件,把这些Target重新编译获得二进制结果。
def targets_to_prebuild
# 明确配置允许binary的pods
explicit_prebuild_pod_names = ...
# 配置为不允许binary的pods
reject_prebuild_pod_names = ...
# 所有的targets
targets = @source_installer.pod_targets.select { |target|
next unless target.should_build? # 排除不需要编译
next unless target.name == target.pod_name # 排除target name和pod name不一致的
true
}
# 排除本地pod(如果不允许编译本地pod)
targets.reject! { |target| sandbox.local?(target.pod_name) } unless Jxedt.config.dev_pods_enabled?
# 配置中排除的pods
targets.reject! { |target| Jxedt.config.excluded_pods.include?(target.pod_name) }
targets = targets.select { |target|
next if reject_prebuild_pod_names.include?(target.pod_name)
explicit_prebuild_pod_names.include?(target.pod_name) || Jxedt.config.all_binary_enabled?
}
targets
end
def build
# 已存在对应二进制的pods
existed_target_names = ...
# 获得需要编译二进制的pod targets
targets = targets_to_prebuild.reject { |target| existed_target_names.include?(target.name.to_s) }
...
# 编译
options = make_options
options[:configuration] = configuration
options[:targets] = targets
options[:output_path] = output_path + configuration
Jxedt::XcodebuildCommand.new(options).run
...
end
编译没有对应二进制的pod targets,将编译结果放到binary_dir
文件夹下
def make_prebuild(targets, binary_output=nil)
...
# 目标binary路径
binary_path = ...
# make prebuild files
configurations = Jxedt.config.support_configurations
Jxedt.config.support_configurations.each do |configuration|
configuration_path = output_path + configuration
next unless configuration_path.exist?
configuration_path.children().each do |child|
if child.directory? and (not child.children.empty?)
name = child.basename.to_s
next unless prebuild_targets.include?(name)
target_path = binary_path + name
target_path += configuration if configurations.size > 1
target_path.mkpath unless target_path.exist?
command = "cp -r #{child}/ #{target_path}"
`#{command}`
# touch checksum
checksum = nil
checksum = checkout_options[name][:commit] unless checkout_options[name].nil? # commitid有值则使用commitid
checksum = checksums[name] if checksum.nil?
`echo #{command} "\n" >> #{binary_path}/#{name}/#{checksum}.checksum` unless checksum.nil?
end
end
end
end
4.3.4 push to git cache
将编译结果同步到git缓存。
如果配置了auto_push到git仓库,那么插件会把新编译产生的二进制同步到二进制git仓库。如下所示:
# cache push
if Jxedt.config.cache_repo_enabled? && Jxedt.config.auto_push? && build_targets && build_targets.size > 0
log_section "🚄 Push git cache"
require 'cocoapods-jxedt/git_helper/cache_pucher'
output_dir = prebuild_sandbox.standard_sandbox_root + Jxedt.config.binary_dir
Jxedt::CachePucher.push(output_dir, build_targets, false)
end
4.4 Intergation
插件执行到这一步已经完成了Prebuild的所有操作,接下来要做的是修改podspec文件的配置完成二进制依赖。
4.4.1 clear podspec source files
首先要对podspec中所有的源码、头文件配置进行清空。
def empty_source_files(spec)
spec.attributes_hash["source_files"] = []
spec.attributes_hash["public_header_files"] = []
spec.attributes_hash["private_header_files"] = []
["ios", "watchos", "tvos", "osx"].each do |plat|
if spec.attributes_hash[plat] != nil
spec.attributes_hash[plat]["source_files"] = []
spec.attributes_hash[plat]["public_header_files"] = []
spec.attributes_hash[plat]["private_header_files"] = []
end
end
end
4.4.2 add prebuild bundle files
其次,对组件编译的bundle文件进行处理,清除原来podspec文件中设置resource_bundles的配置,将bundle文件添加到resources选项中。
if spec.attributes_hash["resource_bundles"]
# bundle_names = spec.attributes_hash["resource_bundles"].keys
spec.attributes_hash["resource_bundles"] = nil
spec.attributes_hash["resources"] ||= []
resources = spec.attributes_hash["resources"] || []
resources = [resources] if resources.kind_of?(String)
spec.attributes_hash["resources"] = resources
# spec.attributes_hash["resources"] += bundle_names.map{|n| n+".bundle"}
prebuild_bundles = check_sandbox.prebuild_bundles(spec.root.name).each.map { |bundle_path| "_Prebuild/" + bundle_path }
prebuild_bundles = [] if checked_specs[spec.root.name].size > 1 # spec.root.name相同的只添加一次bundle文件
spec.attributes_hash["resources"] += prebuild_bundles
end
4.4.3 add prebuild frameworks
然后是处理编译的二进制framework文件依赖
# Use the prebuild framworks as vendered frameworks
# get_corresponding_targets
targets = Pod.fast_get_targets_for_pod_name(spec.root.name, self.pod_targets, cache)
targets.each do |target|
# the framework_file_path rule is decided when `install_for_prebuild`,
# as to compitable with older version and be less wordy.
check_sandbox.prebuild_vendored_frameworks(spec.root.name).each do |frame_file_path|
framework_file_path = "_Prebuild/" + frame_file_path
framework_file_path = nil if checked_specs[spec.root.name].size > 1 # spec.root.name相同的只添加一次framework文件
add_vendered_framework(spec, target.platform.name.to_s, framework_file_path)
end
# clear resource when target is a dynamic framework
spec.attributes_hash["resources"] = [] if target.build_as_dynamic_framework?
end
这里特殊处理了Target的build type是dynamic的情况,此时需要将resources清空,因为动态库的资源文件会被拷贝到framework文件中。
4.4.4 replace script
替换脚本中的一些参数选项。
这个步骤是插件处理多环境配置的情况时用到的。因为我们的插件支持多configuration的配置,所以会处理不同的编译环境使用不同路径下的xcframework文件、bundle文件等。
如果你的工程没有配置支持多环境的选项,这一步骤实际上什么都不会做。
具体的实现如下:
alias_method :old_script, :script
def script
script = old_script
xcconfig_configuration_alias = Jxedt.config.xcconfig_configuration_alias
match_configuration = Jxedt.config.support_configurations.join('|')
script.gsub!(/#{xcconfig_configuration_alias}-(#{match_configuration})/, "#{xcconfig_configuration_alias}-${CONFIGURATION}")
script
end
插件会根据设置的文件别名匹配,然后把别名的后缀替换成的${CONFIGURATION}
,以支持不同的编译环境。
这也是为什么插件要求这个文件的别名一定要唯一的原因,默认插件会设置别名为cocoapods-binary-jxedt
,这样的话才能保证插件能替换到正确的文件名称。
4.4.5 handle missing framework header_search_path
处理framework文件使用不规范的头文件引用的问题。
再一次谈到了这个问题,假设工程中使用#import "..."
或#import <...>
的方式引用的头文件,因为组件已被编译成framework,cocoapods不会配置这种方式的搜索路径,编译的时候会出现头文件查找不到的冲突。
针对这个问题,我们是提供了两个解决方案:
临时方案
插件在提供了配置选项:framework_header_search_enabled => true
,可以设置此选项的值为true,插件会把framework的Headers
目录配置到HEADER_SEARCH_PATH
中,这样在不修改头文件引用的时候也能正常的编译。
这个方案其实我们并不推荐使用,因为它并不符合规范。当然它可以快速的解决这个问题,我们推荐开发者可以把此方案当做一个过渡方案。慢慢的通过修改头文件的方式去真正的解决问题。
此方案的工作机制可以阅读美团技术团队的文章,文章中有一个篇幅介绍的比较详细了。可以直接定位到 查找系统库的头文件
这个小节查看。
真正解决头文件引用
正是由于这种机制,还导致了另外一种有意思的问题。
在 Static Library 的状况下,一旦我们开启了 Use Header Map,结合组件里所有头文件的类型为 Project 的情况,这个 hmap 里只会包含 `#import "A.h"` 的键值引用,也就是说只有 `#import "A.h"` 的方式才会命中 hmap 的策略,否则都将通过 Header Search Path 寻找其相关路径。
而我们也知道,在引用其他组件的时候,通常都会采用 `#import <A/A.h>` 的方式引入。至于为什么会用这种方式,一方面是这种写法会明确头文件的由来,避免问题,另一方面也是这种方式可以让我们在是否开启 Clang Module 中随意切换,当然还有一点就是,Apple 在 WWDC 里曾经不止一次建议开发者使用这种方式来引入头文件。
上面这段文字节选自美团技术团队的文章,你会发现无论是支持clang module
还是遵从苹果的建议,开发者都应该去修改头文件引用的方式。
鉴于此,我们提供了一个命令去快速的解决头文件引用方式的问题,它就是pod jxedt headerfix
命令。
pod jxedt headerfix
命令的使用方式,在前一篇文章我们已经简单介绍了,这里主要介绍一下它的实现原理。
该命令可以分两种情况使用:
- 修改pod组件中依赖其他组件的头文件引用方式
- 修改工程依赖组件的头文件引用方式
命令实现原理
以修改组件内的头文件引用为例。步骤如下
- 读取Podfile.lock文件
- 执行sandbox初始化
- 执行
Pod::Installer
依赖解析方法resolve_dependencies
,这样就可以获取到组件所有依赖的target,进而获取到依赖组件公开的头文件。 - 文件操作就可以读取需要待修改组件的(.h、.m、.mm、.pch)文件,然后使用文件操作逐行读取
- 用正则匹配到
#import "..."
或#import <...>
的引用,取头文件名称去依赖组件的公开头文件中匹配,找到一处就替换一处 - 最后把替换的结果输出,开发者可以验证一下修改的结果
- 大功告成
def run
@installer = installer_for_config
help! '请检查命令执行路径,需要在Podfile文件所在目录执行' if @installer.nil?
@installer.sandbox.prepare # sandbox prepare
@installer.resolve_dependencies # 解析依赖
# @installer.integrate # 不需要执行也可以解析依赖关系
process_target_files = process_target_files! # 这里读取需要操作的路径下的(.h、.m、.mm、.pch)文件
dependent_targets = dependent_targets! # 这里是一个递归的方法查找依赖的targets
...
lines = File.readlines(file_path)
# 获取命中的行
File.foreach(file_path).with_index {|line, num|
matched = line =~ /^\s*#import\s*"#{header_name_regulation}"\s*\n$/ || line =~ /^\s*#import\s*<#{header_name_regulation}>\s*\n$/
next unless matched
header_name = line.match(/(?<=")#{header_name_regulation}(?=")/) || line.match(/(?<=<)#{header_name_regulation}(?=>)/)
next unless public_header_mapping.include?("#{header_name}")
changed = true # 文件需要修改
project_module_name = public_header_mapping["#{header_name}"]
replace_line = "#import " << "<#{project_module_name}/#{header_name}>\n"
lines[num] = replace_line
record << "#{file_path} 第#{num}行,#{line} => #{replace_line}"
}
files << file_path if changed
begin
File.open(file_path, 'w') { |f| f.write(lines.join) } if changed && @force_write
rescue
failed << file_path
ensure
help! "因权限问题文件修改失败:\n#{file_path}" if failed.size > 0
end
...
end
4.4.6 handle static framework's resources file(xib、xcdatamodeld等文件)
处理静态库的resources文件。
如果podspec文件中的resources文件是普通文件,如png、jpg、plist、bundle文件等,是不需要处理的。如果文件是storyboard、xib、xcdatamodel、xcdatamodeld、xcassets等类型的,这类文件都是需要编译的,所以需要特殊处理一下。具体的情况可以查看下面的代码,摘自cocoapods源码Pod::Target
。
module Pod
class Target
...
# 输出文件的后缀
def self.output_extension_for_resource(input_extension)
case input_extension
when '.storyboard' then '.storyboardc'
when '.xib' then '.nib'
when '.xcdatamodel' then '.mom'
when '.xcdatamodeld' then '.momd'
when '.xcmappingmodel' then '.cdm'
when '.xcassets' then '.car'
else input_extension
end
end
# 根据文件后缀判断文件是否需要编译
def self.resource_extension_compilable?(input_extension)
output_extension_for_resource(input_extension) != input_extension && input_extension != '.xcassets'
end
end
end
对于storyboard、xib这类文件
- 如果文件包含在resource_bundles中,编译后的文件则会被放在bundle文件中,这时是不需要处理的。
- 如果文件在resources中设置了,如
s.resources = 'Assets/Home.storyboard'
,cocoapods是在Pods-Target-resources.sh
的脚本中进行处理的。这里存在三种情况
(1)use libarary,pod使用library静态库的方式
脚本中的逻辑如下:
install_resource()
{
......
# 判断文件后缀,是storyboard、xib、xcdatamodel、xcdatamodeld等需要使用ibtool或xcrun编译
case $RESOURCE_PATH in
*.storyboard)
ibtool --reference-external-strings-file --errors --warnings --notices --minimum-deployment-target ${!DEPLOYMENT_TARGET_SETTING_NAME} --output-format human-readable-text --compile "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/`basename \"$RESOURCE_PATH\" .storyboard`.storyboardc" "$RESOURCE_PATH" --sdk "${SDKROOT}" ${TARGET_DEVICE_ARGS}
;;
*.xib)
ibtool --reference-external-strings-file --errors --warnings --notices --minimum-deployment-target ${!DEPLOYMENT_TARGET_SETTING_NAME} --output-format human-readable-text --compile "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/`basename \"$RESOURCE_PATH\" .xib`.nib" "$RESOURCE_PATH" --sdk "${SDKROOT}" ${TARGET_DEVICE_ARGS}
;;
*.xcdatamodel)
xcrun momc "$RESOURCE_PATH" "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/`basename "$RESOURCE_PATH" .xcdatamodel`.mom"
;;
*.xcdatamodeld)
......
*.xcmappingmodel)
......
*.xcassets)
# xcassets文件会合并,最终打成一个.car的文件
ABSOLUTE_XCASSET_FILE="$RESOURCE_PATH"
XCASSET_FILES+=("$ABSOLUTE_XCASSET_FILE")
;;
*)
# 其他文件,就是一个拷贝,这里记录了一下待拷贝的路径,最终会一起拷贝
echo "$RESOURCE_PATH" || true
echo "$RESOURCE_PATH" >> "$RESOURCES_TO_COPY"
;;
esac
}
# Debug环境执行install_reource
if [[ "$CONFIGURATION" == "Debug" ]]; then
install_resource "${PODS_ROOT}/PodA/Assets/Home.storyboard"
fi
# Release环境执行install_reource
if [[ "$CONFIGURATION" == "Release" ]]; then
install_resource "${PODS_ROOT}/PodA/Assets/Home.storyboard"
fi
从上面的脚本中可以看到,在执行install_resource
的时候会判断文件的后缀,然后去编译。因为install_resource的文件是原文件,是真正编译的时候才去执行的,插件不需要处理。
(2)use_frameworks! :linkage => :dynamic
,使用use_frameworks,并以动态库的方式创建Target
所有的资源都会被打包后放到framework文件中,Pods-Target-resources.sh
脚本中不会出现install_resource的文件,所以这种情况下插件也不做处理。
(3)use_frameworks! :linkage => :static
,使用use_frameworks,并以静态库的方式创建Target
Pods-Target-resources.sh
脚本的实现如下:
# Debug环境执行install_resource
if [[ "$CONFIGURATION" == "Debug" ]]; then
# 此时如果pod组件已经编译成framework,就不会再BUILT_PRODUCTS_DIR路径下生成PodA.framework,这个路径下就不存在storyboardc的文件,编译时候拷贝会报编译错误
install_resource "${BUILT_PRODUCTS_DIR}/PodA/PodA.framework/Home.storyboardc"
end
if [[ "$CONFIGURATION" == "Release" ]]; then
install_resource "${BUILT_PRODUCTS_DIR}/PodA/PodA.framework/Home.storyboardc"
end
此时Pod组件已经切换为二进制,Pod组件的build路径下就不会再编译对应的framework,真正编译时拷贝文件时会出现编译错误,所以这里需要处理。
**总结:**当使用了use_frameworks! :linkage => :static
时,如果组件中以resources的方式引入了xib、storyboard等格式的文件,那么插件需要特殊处理。
插件的处理方案是:
- 检查是否开启了use_frameworks,且设置了build_type为
:static
- 检查Pod组件是否使用了二进制
- Pod组件中是否使用resources引入了xib、storyboard、xcdatamodel等需要编译的文件
- 如果resources中引入了这类文件,则去对应的framework中去查找对应的编译后文件,查找到则替换路径为framework中的文件路径
- 如查找不到则插件则忽略,不处理。待运行时报错,开发者在配置文件中排除这个pod组件即可
4.5 Validation
pod install结果校验。
这是插件的最后一步处理,对前面两次install!
的结果(Manifest.lock文件)做一次diff,如果两次install!
的结果存在差异,则会在命令行中输出警告日志,开发者可以根据日志检查对应的组件使用二进制的情况。
def validate_pod_checksum
original_installer = ObjectSpace.each_object(Pod::Installer).reject {|installer| installer.sandbox.is_a?(Pod::JxedtPrebuildSandbox) }.first
return if original_installer.nil?
check_result = original_installer.lockfile == @installer_context.sandbox.source_lockfile
unless check_result
validation_failed = []
lockfile = original_installer.lockfile
source_lockfile = @installer_context.sandbox.source_lockfile
lockfile.internal_data["SPEC CHECKSUMS"].each_key { |name|
value1 = lockfile.spec_checksums_hash_key(name)
value2 = source_lockfile.spec_checksums_hash_key(name)
validation_failed << name if value1.nil? || value2.nil? || value1 != value2
}
Pod::UI.warn "⚠️ ⚠️ ⚠️ Lockfile文件校验失败,请检查Pod组件: #{validation_failed}"
end
end
这种情况一般不会出现,除非一些极端情况。比如在执行第一次install
时更新了tag 0.1
,在执行第二次install
时恰巧发布了tag 0.2
。
针对上面这种极端情况,插件还提供了一个统计(检查)二进制使用情况的命令pod jxedt binary statistics
。使用如下图
5. 插件问题记录
对于上面提出的几个问题,接下来会进行解答,同时也整理了许多插件开发中遇到的问题及解决方案。
5.1 Pods-Source工程生成逻辑
看过前文的同学会发现Pods-source目录下有一个Pods-Source.xcodeproj
工程文件。这个工程文件就是我们执行prebuild操作的工程文件,它是一个完整的Pods
工程,它完全可以独立编译,插件所需要的二进制文件就是使用这个工程编译出来的。
生成Pods-Source.xcodeproj
文件的原因有:
- 可以使用xcodebuild命令编译组件二进制,且它不会影响原Pods工程
- 可以保留源码,方便开发者查看二进制对应的源码
Pods目录实际上是Pod::Sandbox
生成的,cocoapods中对Pods目录下的文件结构描述如下:
# Pods
# |
# +-- Headers
# | +-- Private
# | | +-- [Pod Name]
# | +-- Public
# | +-- [Pod Name]
# |
# +-- Local Podspecs
# | +-- External Sources
# | +-- Normal Sources
# |
# +-- Target Support Files
# | +-- [Target Name]
# | +-- Pods-acknowledgements.markdown
# | +-- Pods-acknowledgements.plist
# | +-- Pods-dummy.m
# | +-- Pods-prefix.pch
# | +-- Pods.xcconfig
# |
# +-- [Pod Name]
# |
# +-- Manifest.lock
# |
# +-- Pods.xcodeproj
# (if installation option 'generate_multiple_pod_projects' is enabled)
# |
# +-- PodTarget1.xcodeproj
# |
# ...
# |
# +-- PodTargetN.xcodeproj
结合Pods的文件结构,插件在生成Pods-Source对应的Sandbox时做了一些优化
- 拷贝了
Local Podspecs
、Manifest.lock
到Pods-Source目录 Headers
和Target Support Files
文件删除,执行install时由cocoapods生成source_root
(pods源码目录)和原sanbox的source_root
目录一致
优化之后可以看到,呼应问题最开始所说的,最后生成的Pods-Source工程是一个完全独立的Pods工程,它可以独立编译,只不过和Pods工程共用了组件源码。
实现逻辑如下:
module Pod
class JxedtPrebuildSandbox < Sandbox
# 根据原sandbox对象生成新的sandbox
def self.from_standard_sandbox(sandbox, sandbox_path: nil, real_path_compiler: false)
# new sandbox的路径,Pods同级新建Pods-Source目录
prebuild_sandbox_path = Pathname.new(sandbox.root).realpath + '../Pods-Source'
prebuild_sandbox = new(prebuild_sandbox_path)
# initialize
prebuild_sandbox.standard_sandbox = sandbox
# prepare
prebuild_sandbox.prepare_dir
prebuild_sandbox
end
def prepare_dir
# 清除new sandbox目录下的文件
root.children.each { |child| child.rmtree if '.xcodeproj' != child.extname }
# 拷贝standard_sandbox目录下的资源
standard_sandbox.root.children.each do |child|
# Headers目录、Target Support Files目录、工程文件不拷贝,Pods-Source工程需要自己生成
should_skip_paths = [standard_sandbox.headers_root, standard_sandbox.target_support_files_root, standard_sandbox.project_path]
next if should_skip_paths.include?(child)
# Local Podspecs目录、Manifest.lock文件需要拷贝到Pods-Source目录下,这两个文件中有pod组件的版本信息
should_copy_paths = [standard_sandbox.specifications_root, standard_sandbox.manifest_path]
if should_copy_paths.include?(child)
FileUtils.cp_r(child, root + child.basename)
...
end
end
end
def sources_root
...
# pods源码的目录和standard_sandbox目录设置成一样的
standard_sandbox.source_root
end
...
end
5.2 pod组件的版本控制
组件的版本控制支持使用tag依赖和git仓库依赖。
插件是通过Manifest.lock
文件中的checksum值做的版本控制。首先了解一下checksum,checksum即校验和,它是对一个文件进行hash运算后得到的结果,我们可以认为这个是一个唯一的值。
我在使用checksum实现组件版本控制功能时遇到了一个阻碍,那就是使用git分支分支或commit依赖时,虽然更新了组件,但是SPEC CHECKSUMS
中组件的checksum值并没有变化。
我去阅读了一下cocoapods生成checksum的代码,发现内部实际上是对podspec.json文件做的一个SHA1的算法。下面这段代码来源cocoapods-core v1.11.3
module Pod
class Specification
......
# @!group File representation
# @return [String] The SHA1 digest of the file in which the specification
# is defined.
#
# @return [Nil] If the specification is not defined in a file.
#
def checksum
@checksum ||= begin
if root?
unless defined_in_file.nil?
require 'digest'
checksum = Digest::SHA1.hexdigest(File.read(defined_in_file))
checksum = checksum.encode('UTF-8') if checksum.respond_to?(:encode)
checksum
end
else
root.checksum
end
end
end
......
end
end
可以看到pod的checksum值就是对defined_in_file路径下的内容做hash后的结果。这也验证了为什么修改了podspec文件checksum会变的想法。
同时也理解了git依赖时checksum值不变的原因,因为使用git分之依赖时只修改了源码文件,并没有修改podspec文件,所以hash之后的结果是不变的。
Tips:这里传入的path并不是podspec文件的路径,而是cocoapods缓存的文件,格式是podname.podspec.json。
对于checksum这个生成规则,插件的处理逻辑是,优先取CHECKOUT OPTIONS
中commit的值,没有取到再取SPEC CHECKSUMS
中的值。代码如下
module Pod
class Lockfile
def spec_checksums_hash_key(name)
# git分支或commit依赖的组件的checksum值是用podspec文件做的hash,hash可能不变,所以有commitid时校验commitid
checkout_options = self.internal_data["CHECKOUT OPTIONS"] || {}
checksum = checkout_options[name][:commit] unless checkout_options[name].nil?
return checksum unless checksum.nil?
checksums_hash = self.internal_data["SPEC CHECKSUMS"]
return checksums_hash[name] if checksums_hash.include?("#{name}")
end
end
end
5.3 源码和二进制切换
组件的源码和二进制无缝切换也是我在实现插件时遇到的一个问题。
5.3.1 源码二进制不能随意切换的问题
使用过cocoapods-binary的同学应该比较熟悉,每次操作二进制和源码之间切换的时候,都会非常的麻烦。
我查看了Pod::Installer
中的实现如果analysis_result.sandbox_state.added
于analysis_result.sandbox_state.changed
中都没有要切换的组件时,是不会触发组件下载的。added
和changed
是解析Podfile和Lockfile获得的结果,并不会去检查pod源码的文件存不存在,所以如果源码已经被删除的话就无法实现灵活的切换。
插件的做法是组件的源码和二进制文件都保留,这样切换的时候文件本身就存在,无论会不会触发下载都能实现源码和二进制的切换。
5.3.2 修改为二进制依赖后源码会被删除
修改了podspec文件,把源码引用改成了二进制引用,因为源码没有使用源码仍然会被删除。cocoapods的处理如下:
module Pod
class Installer
......
# 下载Pod组件
def download_dependencies
UI.section 'Downloading dependencies' do
install_pod_sources
run_podfile_pre_install_hooks
clean_pod_sources # 清除未被引用的文件
end
end
# 清除PodSourceInstaller中没有被引用的文件
def clean_pod_sources
return unless installation_options.clean?
return if installed_specs.empty?
pod_installers.each(&:clean!)
end
class PodSourceInstaller
......
# 清除未被引用的文件
def clean!
clean_installation unless local?
end
end
end
end
可以看到在组件的源码下载完成之后都会触发clean_pod_sources
的方法,未被引用的文件会被清除。
插件的方案是修改podspec的配置,pod的路径都被保护,文件就不会被清理了。
# keep all file in pods
spec.attributes_hash["preserve_paths"] = "**/*"
5.4 支持xcframework
组件二进制支持xcframework格式的文件,实现起来相对比较简单了。
- 首先将编译的结果framework使用xcodebuild命令生成xcframework
- 其次将二进制依赖由framework修改为xcframework就完成了
5.5 支持Static Library
插件目前支持Pods使用library,即你的工程不支持use_frameworks!
也可以使用本插件。
那么插件是怎么做的呢?接下来我详细介绍一下
当你的Pods工程不支持use_frameworks!
时,target的Mach-O Type
是Static Library
,编译结果则是.a
文件。
现在我们回想一下前文,我们的插件已经支持编译结果是framework和xcframework的二进制。如果我们把.a
的编译结果封装成framework或xcframework文件,那么是不是就可以无缝对接到现有插件支持的功能了?
答案是可以的,早在我阅读cocoapods-packager
这个插件的实现的时候,发现它对framework的实现就是将.a
封装成framework。实现代码如下
module StaticFramework
class Tree
attr_reader :headers_path
attr_reader :module_map_path
...
def make
...
make_framework
end
private
def make_framework
# 创建framework目录
@fwk_path = @root_path + Pathname.new(@name + '.framework')
@fwk_path.rmtree if @fwk_path.exist?
@fwk_path.mkdir
# Modules文件路径,如果支持clang module的话文件会放到这个路径
@module_map_path = @fwk_path + Pathname.new('Modules')
# Headers文件目录
@headers_path = @fwk_path + Pathname.new('Headers')
@headers_path.mkpath unless @headers_path.exist?
end
end
end
因为Pods工程编译的.a
全都是静态库,所以只处理静态库的framework就行了。
Static Library
编译后的文件如下图所示:
我把文件分为5种,对结果做一一做下分析,其中后面4个是和swift相关的:
- 编译后的静态库文件
- Swift公开给OC使用的Header文件,
Swift Compatibility Header
文件夹下有PodName-Swift.h
文件(Swift) - 伞头文件,开启
moduler_header
或包含Swift
文件的组件会有 - modulemap文件,开启
moduler_header
或包含Swift
文件的组件会有 - swiftmodule文件,Swift文件编译产生的结果
插件把Static Library
的编译结果合并的具体逻辑是:
- 将生成的
.a
合并输出到framework的根目录下 - 将
Pods/Headers/Public/PodName
目录下的头文件拷贝到framework的Headers
目录 - 存在
PodName-Swift.h
文件,把文件拷贝到framework的Headers
目录 - 存在
PodName-umbrella.h
文件,把文件拷贝到framework的Headers
目录 - 存在modulemap文件,自动生成一个新的modulemap文件,放到framework的
Modules
目录 - 存在swiftmodule文件,把整个文件拷贝到framework的
Modules
目录
这么操作完之后,把.a
合并成了.framework
,也支持了clang module
,如果最终的结果是xcframework,可以把framework继续生成xcframework。
至此,Static Library
支持二进制化就完成了。
5.6 支持Swift和混编
支持OC和Swift混编,一定支持Swift。混编存在着两种情况
- 使用
use_frameworks!
,这种情况是一定会支持clang module
的,天然支持混编 - 不使用
use_frameworks!
,这种情况如果需要支持混编,需要设置组件:modular_header => true
插件主要处理的是第二种情况,详情参考问题5.5:支持Static Library
5.7 Framework Headers
支持配置HEADER_SEARCH_PATH
如何让依赖的framework,仍然可以使用"..."
和<...>
的方式引用头文件。其实我们在4.4.5小节中已经做过描述,这里再对实现逻辑做一下介绍。
实现的代码逻辑比较简单,但是寻找方案的过程非常麻烦,这里简单做一下流程分析,就不放代码上来了。
修改是的Pod::Target::BuildSettings
这个类,这个类产生的结果会被写入到PodName.debug.xcconfig
以及PodName.release.xcconfig
文件中,了解xcconfig文件的同学应该就差不多明白了。xcconfig文件中会配置诸如 FRAMEWORK_SEARCH_PATHS
、 CONFIGURATION_BUILD_DIR
、 PODS_ROOT
、 PODS_BUILD_DIR
等参数,也会配置前文提到的 HEADER_SEARCH_PATHS
。
通过阅读美团技术团队的这篇文章,我们可以想象,如果把PodName.framework/Headers
这个地址写入到HEADER_SEARCH_PATHS
中,是不是就能实现想要的效果呢?我们试验了一下,发现确实可以。
方案已经确定了,接下来就是找到Pod::Target::BuildSettings
这个类,hook其处理HEADER_SEARCH_PATHS
的方法,写入framework的Headers路径。
其中对于xcframework和不同的cocoapods版本也要做不一样的处理逻辑,这里就不一一列举了。
5.8 二进制支持多Configuration配置
设计多Configuration的支持的初衷是因为,我们有一个项目引用了好几个业务组件,业务还比较复杂,其中有大量的#ifdef DEBUG
这样对于宏的判断,改动起来比较的困难。那么到底能不能支持多Configuration场景下的二进制呢,所以开始了我的探索之旅。
这时又不得不提前一节我们讲过的xcconfig文件了,干脆我们在这里对xcconfig做个简短的介绍吧。
5.8.1 xcconfig
xcconfig是苹果提供的方式,可以在配置文件中修改build setting内的变量。
可以这样去创建xcconfig文件,如下图
xcconfig文件的配置规则如下:
-
如果配置的是build settings中有的key值,则需要添加
$(inherited),因为添加了 $(inherited)才会继承手动在build settings中的配置,否则xcconfig文件中的配置会覆盖手动添加的值
-
如果添加的值不是build settings中已有的key,则会被添加在User-Define中
5.8.2 多Configuration支持
细心的话,可以在cocoapods生成的xcconfig文件中看到一些在build settings中常见的参数,如FRAMEWORK_SEARCH_PATHS
、HEADER_SEARCH_PATHS
等。
对FRAMEWORK_SEARCH_PATHS
的值分析过后可以发现,cocoapods的逻辑实际上是把当前组件依赖的framework的路径配置在了这里。我们知道编译的时候就是通过FRAMEWORK_SEARCH_PATHS
的配置来找到framework文件进行链接的,那么我是不是在PodName.debug.xcconfig
和PodName.release.xcconfig
配置不同的framework路径,就可以根据编译环境链接不同的二进制framework了呢?
想到这里,赶紧去尝试了一下,发现正是如此。到这里方案好像已经通了,具体怎么实现呢?
其实可以通过修改Pod::Target::PodTargetBuildSettings
在生成xcconfig文件的时候,把它的FRAMEWORK_SEARCH_PATHS
和HEADER_SEARCH_PATHS
结果做下修改,根据configuration的值,修改成不同的framework路径。
到这里其实也只完成了一步,即仅完成了链接时查找的framework的替换。还应该有另外连个步骤需要修改
-
Pod::Generator::CopyResourcesScript
,拷贝资源文件,如bundle文件最好也替换一下路径 -
Pod::Generator::CopyXCFrameworksScript
,如果是xcframework的话,编译时需要拷贝相应架构的framework文件到编译目录,这里一定要替换
我们仍建议把这个方案当做过渡方案,最终还是要解决组件内对DEBUG
、RELEASE
宏的判断问题。
5.9 Xcode 14 bundle Code Sign的问题
这个问题其实在我刚开始升级到Xcode 14的时候就遇到了,当时看网上有许多的解决方案。比如说下面加到Podfile中的这段代码
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = ""
config.build_settings['CODE_SIGNING_REQUIRED'] = "NO"
config.build_settings['CODE_SIGNING_ALLOWED'] = "NO"
end
end
end
当时就特别想知道为什么这样就可以了?cocoapods执行这个post_install
是什么时候执行的呢?
我带着问题去查看了cocoapods
的源码,找到了cocoapods的调用顺序。
实际上cocoapods是在Pod::Installer.install!
的时候调用了下面这个方法
def run_plugins_post_install_hooks
# This short-circuits because unlocking pod sources is expensive
if any_plugin_post_install_hooks?
unlock_pod_sources
context = PostInstallHooksContext.generate(sandbox, pods_project, aggregate_targets)
# 执行post_install方法
HooksManager.run(:post_install, context, plugins)
end
lock_pod_sources
end
你可以看到它在执行方法之前先做了unlock的操作,执行之后又进行了lock操作,需要注意这里执行的post_install
并不是plugin注册的方法。这也是为什么使用hook cocoapods的post_install
时机不能修改工程配置,因为真正调用plugin的方法时已经进行了lock操作,工程不允许修改了。
所以,我们的插件为了减少开发者在Podfile中配置的操作,我们解决了这个问题。修改的方法其实是Pod::Podfile
module Pod
class Podfile
......
def post_install!(installer)
if @post_install_callback
@post_install_callback.call(installer)
true
else
false
end
end
end
end
可以看到原方法是查看了开发者有没有在Podfile中配置回调,如果有则执行,没有则不执行。
我们的处理是根据这个返回结果,如果返回结果为true,插件就不再处理,如果为false,插件主动帮助添加代码。
就这样,无感知的解决了这个配置新工程时都要去设置的问题,而且只需要修改一处代码就可以。
5.10 源码和二进制链接调试功能
前面5.1小节介绍了,插件执行的时候都会创建Pods-Source工程,二进制就是使用Pods-Source工程编译出来的。
二进制和源码链接调试的原理其实就是,电脑上有着编译二进制时的源码路径和源码文件,这样在调试符号断点的时候就可以跳转到对应的源码中。如果对这个解释还不是很明白的同学,可以看一下美团zsource命令背后的那些事儿这篇文章更深入的了解一下。
所以对cocoapods-jxedt插件来说,想要实现二进制调试的功能,只需要保证Pods-Source工程存在就行了。
那么插件是怎样支持的呢?
5.10.1 keep_source_project
插件最简单的支持就是提供了:keep_source_project
这个插件配置参数,设置为true的话,插件在执行完成时会保留Pods-Source工程。
如果之前已经执行过install了,且没有设置keep_source_project
参数,那么怎么生成Pods-Source工程呢?
- 修改
:keep_source_project => true
后,再次执行pod install命令 - 执行插件提供的命令,
pod jxedt binary sourceProject
,此命令也会生成源码工程
如果你以为这样就结束了,那还是有点保守了。因为大多数场景下,项目都是多人开发,而这个二进制调试的功能严格的要求源码的路径,也就是说换台电脑之后仍然需要的是编译时源码路径下存在文件。想想一下编译时的路径是这样/Users/A/Desktop/Project/Pods-Source
,其中这个A
是电脑的用户,总不能要求所有同学的电脑用户都叫做A
吧。
所以对于这种多人开发场景下的二进制调试功能,我们的下一个方案就来了。
5.10.2 prebuild_sandbox_path
它也是插件提供的一个配置参数,意思是预编译的工程路径,这个参数和keep_source_project
不存在冲突,都可以设置。
可以理解成开发者可以指定一个工程路径,这个路径在多台电脑上都存在。
这个参数只接收/Users/cocoapods-jxedt/
开头的参数,所以要使用prebuild_sandbox_path
参数的前提是电脑上必须存在/Users/cocoapods-jxedt/
这个目录。怎么创建这个目录,我们提供了命令pod jxedt user --add
,它的执行逻辑就是创建目录、为目录递归的赋权限。
`sudo mkdir #{dirname} && sudo chmod -R 777 #{dirname}`
可以看到这个命令执行的时候是sudo执行的,可能过程需要输入用户密码。大胆输入就行了,插件只是创建一个特殊的目录,并赋读写权限而已。
我们建议参数这样配置:prebuild_sandbox_path => '/Users/cocoapods-jxedt/ProjectName-Pods'
,你可以使用ProjectName-Pods的格式来命名,这样比较容易区分多个Project。
5.11 插件二进制缓存
5.11.1 缓存方案的选择
关于支持插件二进制缓存的问题,其实是有两种方案可供选择。
- 建立一个静态资源服务器,将二进制结果压缩成zip缓存到服务器
- 使用git仓库缓存,将二进制结果推送到远程仓库保存
我对比了一下这两种方案,大致总结了一下优缺点
使用静态资源服务器
优点:二进制资源可以直接定位到下载,省时间省流量
缺点:需要搭建静态资源服务器,对于iOS开发人员来说有学习成本。但是如果你的企业能很好的支持的话,也不失为一个好的选择
使用git仓库缓存
优点:几乎没有学习成本,创建一个git仓库即可
缺点:下载二进制资源的时候需要拉取整个git仓库,二进制文件比较多也比较大的话,下载成本会比较高
插件使用的缓存方案,是笔者结合自己的情况和使用场景,选择了git仓库作为二进制缓存的方式。
5.11.2 缓存方案的实现
要想实现git缓存,首先要在代码层面上支持操作git仓库。
我参考了cocoapods-binary-cache插件方案操作git命令的实现,重新整理了一下,支持了git clone
、git chekout
、git push
的操作。
def git_clone(cmd, options = {})
git("clone #{cmd}", :git_dir => false)
end
def git_fetch(repo, branch)
Pod::UI.puts "Fetching cache from #{repo} (branch: #{branch})".green
dest_dir = @cache_path
if Dir.exist?(dest_dir + "/.git")
git("fetch origin #{branch}")
git("checkout -f FETCH_HEAD", ignore_output: true)
git("branch -D #{branch}", ignore_output: true, can_fail: true)
git("checkout -b #{branch}")
else
FileUtils.rm_rf(dest_dir)
git_clone("--depth=1 --branch=#{branch} #{repo} #{dest_dir}")
end
end
def git_commit_and_push(branch)
commit_message = "Update prebuilt cache"
git("add .")
git("commit -m '#{commit_message}'")
git("push origin #{branch}")
end
其次,提供zip解压缩功能,实现把二进制文件压缩上传和解压恢复二进制文件
zip解压缩的功能也是参考cocoapods-binary-cache的代码,这个插件写的确实是不错。
module ZipUtils
def self.zip(path, zip_name: nil, to_dir: nil)
basename = File.basename(path)
zip_name = basename if zip_name.nil?
out_path = to_dir.nil? ? "#{zip_name}.zip" : "#{to_dir}/#{zip_name}.zip"
cmd = []
cmd << "cd" << File.dirname(path)
cmd << "&& zip -r --symlinks" << out_path << basename
cmd << "&& cd -"
`#{cmd.join(" ")}`
end
def self.unzip(path, to_dir: nil)
cmd = []
cmd << "unzip -nq" << path
cmd << "-d" << to_dir unless to_dir.nil?
`#{cmd.join(" ")}`
end
end
最终实现的缓存效果是:
- 工程执行pod install,检查配置的git缓存仓库,把仓库clone到本地
~/.cocoapods-jxedt/
的目录下 - 执行prebuild的操作,产生二进制文件
- 把编译的二进制结果,压缩后放到clone的仓库
- 本地仓库push到远程git仓库
6. 总结
本文笔者通过对插件的文件目录解释、运行流程、实现过程中遇到的问题及解决方案来总结了一下整个插件的实现原理。
在此期间,笔者收获了很多,在这里要感谢那些提供了开源插件和这么多优秀文章的开发者或团队,这里有个小子在各位前辈的肩膀上又造了一个轮子。
同时也要感谢我的领导和团队,在我提出要实现这样一个插件时给出的支持与鼓励。
最后要感谢一下我的家人,感谢他们给了我时间、空间和支持。没有家人的理解和支持,可能我最终也不能完成这样一个插件的编写。
感恩!