相信每个人都有这样一个愿景,就是希望自己维护的每一个组件都能跟知名的开源项目一样优秀,有高的代码质量,完整的测试和文档。为了达到这一目的,就需要有 CI 作为项目开发过程中的一个环节介入。在每次 push 或者提交 Merge Request 的时候,CI 可以代替人来做一些事情,编译项目,跑跑测试,做一些静态检测,帮助提交者和 reviewer 及时发现一些简单的问题,提高工作效率。CI 的

CI 都能做些什么?

对于 iOS 组件来说,以下几件事在 CI 阶段做是比较有帮助的,

  • build 项目并运行所有测试,给出 Code Coverage
  • 使用 lint 工具对代码进行静态分析
  • 使用 danger 找出 MR 以及 MR 改动中可能存在的问题

以上的几步都会通过 danger 在 MR 下面给出评论,效果如图,

GitLab runner

选择 GitLab runner 作为 CI 平台是很自然事情,它 UI 好看(是的 Jenkins 太复古了),集成简单,而且还可以作为 MR 的合并流程的其中一环,比如 pipeline 没过,就无法合并,

.gitlab-ci.yml

想要 runner 执行上边提到的几个步骤,需要在仓库的根目录添加 .gitlab-ci.yml ,这个文件告诉了 runner,每次 push 或者 MR 的提交后,它应该做什么事情。关于这个文件怎么写,官网写得比较清楚,这里就不再赘述了,就谈谈我觉得需要注意的一些问题吧,

  • 一个 stage 过后产出的文件是不能被下一个 stage 获取到的,如果想这样,需要在 stage 中添加 artifacts 字段,指定文件的路径
1
2
3
4
5
xxx_stage:
stage: xxx
artifacts:
paths:
- xxx.abc
  • 对于多个仓库想要使用同一个 runner 的情况,需要在每个 stage 中添加 tags 字段,标示 runner 的名字,而且需要在仓库的配置里面设置一下
  • 如果遇到字符集的问题,可以在 before_script 中添加下面行
1
2
3
4
before_script:
- export LANG=en_US.UTF-8
- export LANGUAGE=en_US:en
- export LC_ALL=en_US.UTF-8

lint & Code Coverage

因为项目是 Objective-C 和 Swift 混编的,所以两种语言的 lint 都要有。因为还处于搭建的初期,而且 lint 的规则还处于变化的过程中,所以并没有把 lint 的结果作为一种很强的约束(比如 lint 不过,pipeline 就会 fail),目前只会把它的结果作为一种提示,评论在 MR 的下面,后面在这套流程走入正轨,在 lint 工具支持的情况下(swiftlint),会补上这一功能

OCLint

Objective-C 相关的 lint 工具找了一圈,没有又简单又美观又易用的,只有 OCLint 能将就用一下。Lint 结果需要在 build 工程之后才能给出,所以稍微麻烦一些,前期配置花了一些时间,

1
2
3
4
5
6
7
8
9
10
11
12
# .gitlab-ci.yml
build_project:
script:
- cd Example
- pod install
- xcodebuild clean -workspace ${POD_NAME}.xcworkspace -scheme ${POD_NAME}-Example | xcpretty
# 指定 derived data 目录是不想让每次编译都被缓存,OCLint 对没有更新的 build 不会产生结果
- xcodebuild test -workspace ${POD_NAME}.xcworkspace -scheme ${POD_NAME}-Example -destination 'platform=iOS Simulator,name=iPhone X,OS=11.2' -derivedDataPath build_outputs | xcpretty -r json-compilation-database -o compile_commands.json
# 在远端脚本更新以后,不希望每一个仓库都作出同样的更改,所以把这个脚本放到另外一个仓库
- git archive --remote=xxx.git HEAD ci-lint.rb | tar xvf -
- ruby ci-lint.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# ci-lint.rb
def get_oclint_comment
# 下载 oclint 的配置文件
download_file(".oclint")
# 让 oc-lint parse 刚才 build 生成的 `compile_commands.json` 文件
oclint_output = `oclint-json-compilation-database -v -e Pods`
puts "----- origin OCLint output -----"
puts oclint_output
# 对 oclint 结果做一些处理,比如找到 `TotalFiles=0` 就认为 lint success
oclint_comment = parse_oclint_output(oclint_output)
puts "----- parsed OCLint output -----"
puts oclint_comment
oclint_comment
end

swiftlint

比 OCLint 好很多,简单美观且易用,社区活跃,不需要编译工程;唯一不能处理的就是一些编译过后才能发现的问题,比如嵌套过深等等(其实这些检测结果多数情况没卵用)。.swiftlint 同样放在其它仓库,方便规则变更,记得把 reporter 改成 emoji ,结果会更好看。

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
# ci-lint.rb
# 获取 swiftlint 的结果
def get_swiftlint_result
swiftlint_comment_content = `swiftlint`
puts "----- origin swift lint content -----"
if not swiftlint_comment_content.include? "Line"
# 不包含 `Line`,我们认为 lint 通过
swiftlint_comment_content = "\n## swiftlint report\n✅ success\n"
else
# 否则不通过
swiftlint_comment_content = "\n## swiftlint report\n```\n" + swiftlint_comment_content + "\n```"
end
swiftlint_comment_content
end
def get_swiftlint_comment
# 需要在根目录执行
Dir.chdir("../")
# 下载 swiftlint 配置文件
download_file(".swiftlint.yml")
swiftlint_comment = get_swiftlint_result
puts "----- parsed swiftlint content -----"
puts swiftlint_comment
swiftlint_comment
end

Code Coverage

使用的工具是 slather,配合 GitLab 原生的 Code Coverage 显示,用正则过滤一下 output 就能获取结果,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# .slather.yml
# 在 CI 阶段,用来跑 Code Coverage 使用的配置文件
# ${PROJECT_NAME} 需要被替换掉
configuration: Debug
input_format: profdata
xcodeproj: ${PROJECT_NAME}.xcodeproj
workspace: ${PROJECT_NAME}.xcworkspace
scheme: ${PROJECT_NAME}-Example
# 这个目录是上面 derived data 指定的目录
build_directory: build_outputs
binary_basename: ${PROJECT_NAME}
ignore:
- "Pods/*"
1
2
3
4
5
6
7
8
9
10
11
# ci-lint.rb
# 获取每个文件的 coverage 比例结果
def get_coverage_comment
slather_yml_file_name = ".slather.yml"
# 下载 slather 的配置文件
download_file(slather_yml_file_name)
# 把 `${PROJECT_NAME}` 替换成 $CI_PROJECT_DIR
modify_slatheryml(slather_yml_file_name)
"\n## Code Coverage Report\n```\n" + `slather coverage` + "\n```"
end
1
2
3
# 配置 .gitlab-ci.yml,即可在 MR 中显示 Code Coverage
build_project:
coverage: '/\d+(?:\.\d*)?\%$/'

danger

danger 能够获取信息都是与 MR 有关的,比如修改了哪些文件,改动有多少行,有没有填写描述等等,具体的内容可以看它的文档。在 GitLab 上集成 danger 有些问题,如果是是 fork 仓库向主库提交 MR,danger 就会获取不到正确的 project_idmr_iid,它就不能找到对应的 MR 进行评论,这就需要我们帮它找到对应的值并告知,

1
2
3
4
5
6
7
# .gitlab-ci.yml
danger:
stage: danger
script:
- git archive --remote=xxx.git HEAD ci-danger.sh | tar xvf -
- sh ci-danger.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# ci-danger.sh
# 下载 Dangerfile
git archive --remote=xxxx.git HEAD Dangerfile | tar xvf -
# 下载获取 mr_iid 的脚本
git archive --remote=xxx.git HEAD ci-mr_iid.rb | tar xvf -
# 获取 project_id 和 mr_iid
output=$(ruby "ci-mr_iid.rb")
project_id=$(cut -d',' -f1 <<< $output)
mr_iid=$(cut -d',' -f2 <<< $output)
# 运行 danger 命令,把获取到的字段作为 ENV 传给 danger
DANGER_GITLAB_HOST=git.xxx.com \
DANGER_GITLAB_API_BASE_URL=https://git.xxx.com/api/v4 \
DANGER_GITLAB_API_TOKEN=xxx \
CI_PROJECT_ID=$project_id \
CI_MERGE_REQUEST_ID=$mr_iid \
danger
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
# ci-mr_iid.rb
# 作用是找到当前仓库的 project_id 和 mr_iid
require 'gitlab'
project_name = ENV['CI_PROJECT_NAME']
# GitLab private token
private_token = "xxx"
# 所有仓库所在的 group id,需要用 API 获取一下
platform_groud_id = "123"
commit_sha = ENV['CI_COMMIT_SHA']
# 在这个 group 中寻找名字一样的 project,获取它的 project_id
g = Gitlab.client(endpoint: 'https://git.xxx.com/api/v4', private_token: private_token)
platform_group = g.group(platform_groud_id)
project_id = platform_group.projects.find{ |p| p["name"] == project_name }["id"].to_s
# 这一次 commit 的 SHA,用来获取 mr_iid
# 获取 mr_iid
mr_cmd = "curl -s \"https://git.xxx.com/api/v4/projects/#{project_id}/merge_requests?private_token=#{private_token}&state=opened\" | jq -r \".[]|select(.sha == \\\"#{commit_sha}\\\")|.iid\""
mr_iid = `#{mr_cmd}`
ENV['CI_PROJECT_ID'] = project_id
ENV['CI_MERGE_REQUEST_ID'] = mr_iid
puts project_id + "," + mr_iid

以上的各个步骤,除了 .gitlab-ci.yml 以外的脚本及配置文件,都是从其它仓库下载下来的,这样做的好处有很多,让配置的规则有动态更新的能力,让每个仓库保持干净,没有代码没有多余的无关文件可以隐藏细节(这也算是一种程度的封装吧😆)等等。如果有更好的方式,以及上面的代码有什么问题,欢迎提出意见

pod template

如果要让每一个独立组件都适应类似的 .gitlab-ci.yml ,就需要做一番大改动了。每个组件都有自己的 git 仓库,不管对于已经存在的组件,还是未来将要出现的组件,都需要提供一套快速创建出一个可以直接支持当前 CI workflow 的工程模版。

可能很多人都不知道,CocoaPods 自带一条命令,pod lib create,它可以通过一个模板的 git 链接创建一个组件。我们可以通过 --template-url 来指向自己需要的模板,从而达到快速创建工程的目的。

pod-template

pod template 文件组织

假如有这样一个 pod 叫 BestFramework,我是这样组织它的结构的,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
~/D/BestFramework tree . -L 2 -a
.
├── .gitignore
├── .gitlab-ci.yml
├── BestFramework
│   ├── Classes
│   └── Resources
├── BestFramework.podspec
├── Example
│   ├── BestFramework
│   ├── BestFramework.xcodeproj
│   ├── BestFramework.xcworkspace
│   ├── Podfile
│   ├── Podfile.lock
│   ├── Pods
│   └── Tests
├── README.md
└── _Pods.xcodeproj -> Example/Pods/Pods.xcodeproj

把 BestFramework 当作一个 Development Pods 作为 Example 的依赖,测试放到 Example/Tests 中,gitlab-ci.yml 中的内容是根据创建时候给出的名字动态创建的。其实文件夹结构这种东西怎么组织都可以,只要外面能正确引用就可以了

最后以这样一幅图作为总结,

所以我们主要覆盖的是创建项目以及提交 MR 两个阶段,提供一些效率工具及使用 CI 帮助我们更好更快地书写/review代码