记一次 CocoaPods 插件整合开发工具

这是一篇通过 CocoaPods 插件整合开发工具的记录文章,主要涉及到以下几部分内容:

1、Podfile 内声明公有源和私有源 source
2、Podfile 内提供 dev_pods 自定义方法用于提测过程中实时拉取组件分支最新 commit
3、解决 libwebp 科学上网问题:修改公有源里 podspec git 地址为 github 地址
4、解决 CocoaPods bug:一旦有任一组件在 podspec 里使用 resources 命令管理资源文件,CocoaPods 会把所有组件内的资源文件拷贝到 main bundle 里

只需在 Podfile 里添加这样一行,即可解决以上问题:

1
plugin 'cocoapods-lebbay'

而且整合了其他插件命令到 cocoapods-lebbay 工程内,方便用户安装和命令维护。接下来就来详细介绍下各部分的实现细节。

Podfile 内声明公有源和私有源 source

我们的组件化目前是采用了公有源和私有源双源的方案。为了 pod install 命令能够找到公有组件和私有组件,需要在 Podfile 里指定双源地址,如:

1
2
3
4
# 私有源地址
source 'https://xyz.com/ios/pod-specs.git'
# 公有源地址
source 'https://github.com/CocoaPods/Specs.git'

但这样存在以下问题:
1、公司项目存在多个 APP,每个工程都要配置这样两行,各组件 Example 工程也存在这样的问题
2、公司 git 曾经换过几次地址,每次都要更换各个工程里的地址,且导致历史 tag 工程 pod install 失败
3、新电脑需要手动添加私有源才能工作

这次新建 cocoapods-lebby 插件工程,就通过 CocoaPods 提供的 source_provider hook 时机添加 source 来统一管理源地址,源不存在时可自动添加。如下注册插件:

1
2
3
4
5
Pod::HooksManager.register('cocoapods-lebbay', :source_provider) do |context|
sources_manager = Pod::Config.instance.sources_manager
context.add_source(sources_manager.private_source)
context.add_source(sources_manager.public_source)
end

闭包参数 context 对象提供了 add_source 方法用于添加 source 对象,这里借鉴 CocoaPods 源码做法,给 Pod::Source::Manager 扩展了两个方法分别提供私有源和公有源:

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
module Pod
class Source
class Manager

# 私有源 source
def private_source
url = 'https://xyz.com/ios/pod-specs.git'
source = source_with_url(url)
return source if source
Command::Repo::Add.parse(['lebbay-spec', url, 'master']).run
source_with_url(url)
end

# 公有源 source
def public_source
url = 'https://github.com/CocoaPods/Specs.git'
source = source_with_url(url)
return source if source
Command::Repo::Add.parse(['master', url, 'master']).run
source_with_url(url)
end

end
end
end

插曲:修复 CocoaPods bug #10764

通过 source_provider hook 时机添加源地址后引发了 CocoaPods 的一个小 bug。目前 CocoaPods 已将默认的公有源从 master(git)换成了 trunk(CDN),并在 #9871 这个 PR 里加了个功能,即在 Podfile 里没有明确使用 master 源并且你的 repo list 里还有 master 的话,在执行 pod install 之后 CocoaPods 会打印一段警告文案提醒用户删除 master 源:

[!] Your project does not explicitly specify the CocoaPods master specs repo. Since CDN is now used as the default, you may safely remove it from your repos directory via pod repo remove master. To suppress this warning please add warn_for_unused_master_specs_repo => false to your Podfile.

当然 CocoaPods 也提供了方法来关掉这个警告:

1
install! 'cocoapods', :warn_for_unused_master_specs_repo => false

因为现在在国内 CDN 的地址几乎都被封了,pod install 经常失败,尤其是组件发版时的 lint 过程。所以我们还在用 master 源。如果使用 source_provider hook 来添加源的话也应该是明确了在使用 master 源,并且不需要这段警告文案。查看 CocoaPods 源码(**installer.rb**)发现,只检查了 Podfile 里的 source 写法,漏掉了插件提供 source 的场景。加上对 plugin sources 的检查的逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
def warn_for_removing_git_master_specs_repo
return unless installation_options.warn_for_unused_master_specs_repo?
plugin_sources = run_source_provider_hooks
all_sources = podfile.sources + plugin_sources.map(&:url)
master_source = all_sources.find { |source| source == MASTER_SPECS_REPO_GIT_URL }
master_repo = config.sources_manager.all.find { |s| s.url == MASTER_SPECS_REPO_GIT_URL }
if master_source.nil? && !master_repo.nil?
UI.warn 'Your project does not explicitly specify the CocoaPods master specs repo. Since CDN is now used as the' \
' default, you may safely remove it from your repos directory via `pod repo remove master`. To suppress this warning' \
' please add `warn_for_unused_master_specs_repo => false` to your Podfile.'
end
end

提交了 PR:**Check the podfile sources and plugin sources when printing warnings without explicitly using the master source. #10764** 应该会在 1.11.0 版本上线。

Podfile 内提供 dev_pods 自定义方法用于提测过程中实时拉取组件分支最新 commit

在组件开发过程中经常会修改几个 pod 的代码,需要一个个的将 pod 指向本地开发目录。在项目测试过程中又要将 pod 一个个指向提测分支,比如:

1
2
3
4
5
6
7
# 开发阶段
pod 'PodA', :path => '../PodA'
pod 'PodB', :path => '../PodB'

# 测试阶段
pod 'PodA', :git => 'https://xyz.com/ios/PodA.git', :branch => 'release/1.0.0'
pod 'PodB', :git => 'https://xyz.com/ios/PodB.git', :branch => 'release/1.0.0'

为了简化写法,我们提供了 dev_pods 方法,简化逻辑后思路大致如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def dev_pods(pods, branch = '')        
if branch.length > 0
# 测试阶段
pods.each do |name|
pod name, :git => "https://xyz.com/ios/#{name}.git", :branch => "#{branch}"
end
else
# 开发阶段
development_path = File.read('./bin/.development_path').chomp
pods.each do |name|
pod name, :path => "#{development_path}#{name}"
end
end
end

./bin/.development_path 文件里配置本地开发目录。
dev_pods 方法的用法如下:

1
2
3
4
5
# 开发阶段
dev_pods ['PodA', 'PodB']

# 测试阶段
dev_pods ['PodA', 'PodB'], 'release/1.0.0'

在测试阶段还有一个问题是,我们希望在组件的提测分支上修改了 bug、提交 commit 之后主工程 pod install 即可拉取最新代码。但因为 Podfile.lock 文件的存在,pod install 之后会把 commit 节点记下来,除非我们在提交 pod 改动后,再去更新主工程的 Podfile.lock 文件,否则是不会拉取最新代码的。

为了实现这一需求,我们在 dev_pods 方法里修改了 Podfile.lock 文件,删掉 commit 节点信息,这样在拉取对应 pod 组件的时候就会拉取最新代码了。

在应用过程中发现的另一问题是:tag 冲突。当组件 PodA 指向的 release/1.0.0 分支里 podspec 里的版本号是 0.0.9,那么主工程的 Podfile.lock 文件里记录的就是 0.0.9。当测试完成,组件 podA 发版修改版本号为 1.0.0 并提交到了 release/1.0.0 分支里,这时主工程再执行 pod install 就会报 tag 冲突。

为解决这一问题,避免提测组件版本号变更影响主工程提测打包,我们的做法是如果 PodA 通过 dev_pods 方法指向了分支,那么在 dev_pods 里删掉 PodA 在 Podfile.lock 里的记录(只删除 version、branch、commit 信息即可),这样在 pod install 的时候就会像下载一个新的 pod 一样。

问题、需求及解决思路大致如上。以前的方案是写了个 ruby 文件(lebbay.rb) 放在主工程目录,在 Podfile 里 require './bin/lebbay.rb'。修改 Podfile.lock 文件也是脚本遍历文件内容操作字符串。现在统一整合到了 cocoapods-lebbay 插件里,为 Podfile 扩充了 DSL 方法,修改 Podfile.lock 文件可以直接使用 cocoapods-core 提供的 Lockfile 类及其方法:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
module Pod
class Podfile
module DSL

public

def dev_pods(pods, branch = '')
if branch.length > 0
pods.each do |name|
pod name, :git => "https://xyz.com/ios/#{name}.git", :branch => "#{branch}"
end
pull_latest_code_and_resolve_conflict(pods)
puts "lebbay: using remote pods with branch: #{branch}".green
else
# 自定义开发目录
development_path = Config.instance.dev_pods_path
pods.each do |name|
pod name, :path => "#{development_path}#{name}"
end
puts "lebbay: using local pods with path: #{development_path}xxx".green
end
end

#--------------------------------------#

private

def pull_latest_code_and_resolve_conflict(pods)
# 1、Podfile.lock
rewrite_lock_file(pods, Config.instance.lockfile_path)
# 2、Manifest.lock
rewrite_lock_file(pods, Config.instance.sandbox.manifest_path)
end

def rewrite_lock_file(pods, lock_path)
return unless lock_path.exist?
lock_hash = Lockfile.from_file(lock_path).to_hash

# 1、PODS
lock_pods = lock_hash['PODS']
if lock_pods
target_pods = []
lock_pods.each do |pod|
if pod.is_a? Hash
first_key = pod.keys[0]
first_value = pod.values[0]
if (first_key.is_a? String) && (first_value.is_a? Array)
next if is_include_key_in_pods(first_key, pods)
dep_pods = first_value.reject { |dep_pod| is_include_key_in_pods(dep_pod, pods) }
target_pods << (dep_pods.count > 0 ? {first_key => dep_pods} : first_key)
next
end
elsif pod.is_a? String
next if is_include_key_in_pods(pod, pods)
end
target_pods << pod
end
lock_hash['PODS'] = target_pods
end

# 2、DEPENDENCIES
locak_dependencies = lock_hash['DEPENDENCIES']
if locak_dependencies
target_dependencies = []
locak_dependencies.each do |dependence|
if dependence.is_a? String
next if is_include_key_in_pods(dependence, pods)
end
target_dependencies << dependence
end
lock_hash['DEPENDENCIES'] = target_dependencies
end

Lockfile.new(lock_hash).write_to_disk(lock_path)
end

def is_include_key_in_pods(target_key, pods)
pods.each do |pod|
if target_key.include? pod
return true
end
end
return false
end

#--------------------------------------#
end
end
end

我们同时修改了 Pods/ 文件夹下的 Manifest.lock 文件,是因为 CooaPods 在 pod install 过程中会对比 lock 文件里记录的 version 版本号。若 Manifest.lock 文件里记录的版本没变的话,在执行 pod install 时 Pods/ 文件夹里对应 Pod 的代码很可能是不会更新的。

其中关于开发目录(development_path = Config.instance.dev_pods_path),给 Pod::Config 扩展了两个方法:设置开发目录 & 读取开发目录:

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
module Pod
class Config

# 读取目录
def dev_pods_path
config_path_file = dev_pods_path_config_file
dev_path = File.read(config_path_file).chomp
end

# 设置目录
def config_dev_pods_path(dev_path)
raise Informative, "input can't be nil" unless dev_path.length > 0
dev_path += '/' unless dev_path[dev_path.length - 1] == '/'

config_path_file = dev_pods_path_config_file
File.open(config_path_file, "w") do |file|
file.syswrite(dev_path)
end
end

# 配置文件
def dev_pods_path_config_file
config_path = File.expand_path('~/.cocoapods-lebbay')
FileUtils.makedirs(config_path) unless File.exists?config_path

config_path_file = config_path + '/dev_pods_path_config'
unless File.exist?(config_path_file)
File.open(config_path_file, "w") do |file|
file.syswrite('../../')
end
end
config_path_file
end

end
end

给 pod 扩展了两个方法入口分别执行这俩方法,读取开发目录(pod dev-pods-path cat),设置开发目录(pod dev-pods-path set):

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
require 'cocoapods-lebbay/cache_config'

module Pod
class Command
class DevPodsPath < Command
self.abstract_command = true
self.summary = 'set or cat dev_pods path'

def self.options
[]
end
end

class Set < DevPodsPath
self.summary = 'set dev_pods path'

def run
UI.puts "Please input dev_path for dev_pods command:".green
answer = STDIN.gets.chomp.strip
Config.instance.config_dev_pods_path(answer)
end
end

class Cat < DevPodsPath
self.summary = 'cat dev_pods path'

def run
UI.puts Config.instance.dev_pods_path.green
end
end
end
end

解决 libwebp 科学上网问题:修改公有源里 podspec git 地址为 github 地址

这个问题是因为 libwebp 的 podspec 里 git 地址是:https://chromium.googlesource.com/webm/libwebp,在 pod install 的时候大概率会因为网络原因而下载失败。将其改为:https://github.com/webmproject/libwebp.git 即可。

之前的做法是在 lebbay.rb 脚本里提供了 libwep_spec_fix 方法批量修改公有源里 libwebp.podspec 的 git 地址,然后在 Podfile 文件里调用下这个方法。
现在也整合到了 cocoapods-lebbay 插件里,在 pre_install 的 hook 时机里执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Pod::HooksManager.register('cocoapods-lebbay', :pre_install) do |context|
target_url = "https://chromium.googlesource.com/webm/libwebp"
replace_url = "https://github.com/webmproject/libwebp.git"

repo_path = File.expand_path('~/.cocoapods/repos/master/Specs/1/9/2/libwebp/*/*.json')
Dir.glob(repo_path) do |json_file|
json_text = File.read(json_file)
json_text = json_text.gsub(target_url, replace_url)

File.open(json_file, "w") do |file|
file.syswrite(json_text)
end
end
end

解决 CocoaPods bug:一旦有任一组件在 podspec 里使用 resources 命令管理资源文件,CocoaPods 会把所有组件内的资源文件拷贝到 main bundle 里

关于这个问题的具体信息请查看:If there are one pod using xcassets via resources spec syntax, then all xcassets will be copied and compied twice during [CP] Copy Pods Resources build phase #8431

虽然我们目前没有 pod 在使用 resources 命令,但为了避免三方库会引入这个问题,我们还是做了规避,在 post_install 的 hook 时机里修复 CocoaPods 的脚本。之前是将 post_install 的 hook 写在了 Podfile 里,现在放在了插件里来做:

1
2
3
4
5
6
7
8
9
Pod::HooksManager.register('cocoapods-lebbay', :post_install) do |context|
target_label = context.umbrella_targets.first.cocoapods_target_label
system %Q{sed -i "" "s/\\[\\[ \\$line != \\"\\${PODS_ROOT}\\*\\" \\]\\]/\\[\\[ \\$line != \\${PODS_ROOT}\\* \\]\\]/g" "./Pods/Target Support Files/#{target_label}/#{target_label}-resources.sh"}
end

# before
#if [[ $line != "${PODS_ROOT}*" ]]; then
# changed
#if [[ $line != ${PODS_ROOT}* ]]; then

后记

做完这些,各个工程里的 Podfile 文件就清爽了很多,之后再有需求的话也可以在 cocoapods-lebbay 插件里统一管理。
除了以上问题的整合,还把之前写的两个插件(组件自动化发版 pod deliver 命令和本地化文案词条管理 pod localize 命令)统一整合在了一起方便用户安装和命令维护。