前言
本文需要用到RubyMine集成开发环境,Fastlane的蒲公英插件,需要用户自行安装配置,引用链接如下:
RubyMine官方网站https://www.jetbrains.com/ruby/
蒲公英Fastlane插件安装教程:https://www.pgyer.com/doc/view/fastlane
我在之前的一篇文章(https://www.jianshu.com/p/50cff529358f)已经讲到了如何从零开始使用fastlane实现iOS内测安装包的构建,显然,实现这样的功能并不困难,同时会有朋友问:这样构建的意思是什么?为什么不用XCode给我提供的打包工具实现项目的构建呢?
其实,上一篇讲述的只是基础,现在我们要围绕这个基础来搭建一款真正符合我们使用场景的构建项。当然,如果正在阅读的你懂点Ruby,可能会理解起来更轻松。
功能概述
这次构建可以让我们解决平常构建打包中的一些痛点,通通变得自动化,如下:
- 一键根据日期时间生成安装包,同时生成Markdown格式的更新日志到本地文件夹
- 自动上传安装包到蒲公英,实现App分发
- 自动发送应用更新消息到钉钉群,并且在钉钉群中展示本次构建的信息和更新描述,实现参与安装测试的朋友可以很清晰的了解到这一次构建的详细信息
- 自动上传dSYM符号表到bugly,这样,我们开发人员在App的崩溃日志管理上会显得更加轻松
当然,还可以配置各种各样的功能,不过,这对于我们程序员来说都不是难题,要做什么,取决于我们的需求。
开始使用Ruby编写构建脚本
因为fastfile是基于Ruby编写,所以我们为了能配套该文件开设一个基于Ruby语言的工程,需要用到RubyMine(VSCode也支持Ruby调试,但是没有RubyMine方便,这里统一使用RubyMine),RubyMine是大名鼎鼎的捷克公司JetBrains开发的一款针对Ruby语言的集成开发环境,比较出名的PhpStorm, WebStorm, idea都是JetBrains旗下的产品。具体安装RubyMine的教程可以自己到网上查询资料。
使用Ruby打开Fastfile所在文件夹(即fastlane文件夹,可以查看我的上一篇文章找到文件夹位置),以该文件夹作为Ruby工程目录,如下:
Fastfile是不能通过RubyMine来运行的,怎么办呢,我们可以借助创建一个类Controller
来帮他实现一些操作。同时我们还要创建几个类,把Controller包含在内则有这么几个类:
类名 | 用途 |
---|---|
Controller | 控制器类,用于处理业务逻辑 |
ProjectInfo | 数据模型类,只读,用于读取项目信息 |
BuildInfo | 数据模型类,可读写,用户可以编辑其属性,实现构建的可定制化 |
CodeConf | 数据模型类,只读,用于读取项目代码中配置项目信息(通常使用Shell脚本或Ruby脚本获取,比如当前代码里配置的网络环境是开发环境还是线上环境) |
UserSetting | 该类在专用于用户在构建前设定构建数据,实现差异化构建(比如每次打包时本次的更新内容) |
DataSource | 数据源类,可读写,这里主要配置蒲公英 ,应用签名信息 ,钉钉群消息 等,供UserSetting 类选择调用,实现构建时用户可手动选择预先配置的一种实现可定制化构建 |
拆分业务后,整个构建的业务更加清晰。更重要的是,我们可以非常愉快地对构建的业务逻辑进行调试,这些类和Fastfile都是分离的,而且是由Fastfile单向调用他们,调试这些类对Fastfile没有任何影响,这样我们就可以对整个构建功能的业务逻辑进行随意的扩展而不会显得混乱。
ProjectInfo类
# 采集工程信息,包含XCode工程和打包导出配置
class ProjectConf
@project_name = "<XCode工程名>"
@app_name = "<App的名字,比如:微信>"
@@CurTime = Time.now # 类变量:当前时间
def self.app_build_time # 类方法:app开始构建时间
return @@CurTime.strftime('%Y-%m-%d %H:%M:%S')
end
def self.output_year_month # 类方法:app开始构建的年月,用于导出IPA和更新日志时使用
return @@CurTime.strftime('%Y_%m')
end
def self.output_file_build_time # 类方法:存入文件格式的app开始构建的时间,用于存入文件名
return @@CurTime.strftime('%Y%m%d_%H%M%S')
end
def self.project_name # 工程名get方法
return @project_name
end
def self.app_name # 应用名get方法
return @app_name
end
def self.output_dir # 输出根目录
return "/Users/#{`whoami`.chomp}/FastlaneOutput"
end
def self.ipa_dir # 输入ipa文件夹
return "#{self.output_dir}/Apps/#{ProjectConf.output_year_month}"
end
def self.log_dir # 输出更新日志文件夹
return "#{self.output_dir}/Logs"
end
def self.ipa_name # 输入的ipa文件的文件名
return "#{self.project_name}_#{ProjectConf.output_file_build_time}.ipa"
end
def self.cur_branch # 项目构建时所在的分支(没有配置Git的情况不会有值)
return `git rev-parse --abbrev-ref HEAD`.to_s.chomp
end
def self.cur_commit_id # 项目构建时Git的Commit ID(没有配置Git的情况不会有值)
return `git log --pretty=format:"%h" | head -1 | awk '{print $1}'`.to_s.chomp
end
def self.pbxproj_path # project.pbxproj文件路径,读取工程信息需要读取该文件
return "../#{self.project_name}.xcodeproj/project.pbxproj"
end
def self.app_version # 应用版本信息(如果工程中没有配置则预设为1.0)
ver = "1.0"
app_version_line = `sed -n '/MARKETING_VERSION/'p #{self.pbxproj_path}`
if app_version_line != nil then
ver = app_version_line[/\= .*;/].delete('"').delete('=').delete(' ').delete(';')
end
return ver
end
def self.build_version # 应用构建版本信息(如果工程中没有配置则预设为1)
ver = "1"
build_version_line = `sed -n '/CURRENT_PROJECT_VERSION/'p #{self.pbxproj_path}`
if build_version_line != nil then
ver = build_version_line[/\= .*;/].delete('"').delete('=').delete(' ').delete(';')
end
return ver
end
end
BuildInfo类
该类的属性都可以配置。用户构建时根据自己的需要来配置。
class BuildConf
def self.update_desc # 更新描述信息,当前每次都不一样啦
return @update_desc
end
def self.update_desc=(val) # 这是set方法
@update_desc = val
end
def self.build_configuration # 构建配置 Release或Debug
return @build_configuration
end
def self.build_configuration=(val)
@build_configuration = val
end
def self.app_export_method # 导出方式,我们用的ad-hoc
return @app_export_method
end
def self.app_export_method=(val)
@app_export_method = val
end
def self.is_upload_to_pgyer # 构建完成后是否自动上传到蒲公英
return @is_upload_to_pgyer
end
def self.is_upload_to_pgyer=(val)
@is_upload_to_pgyer = val
end
def self.is_send_to_dingtalk # 构建完成后是否自动发送钉钉群消息
return @is_send_to_dingtalk
end
def self.is_send_to_dingtalk=(val)
@is_send_to_dingtalk = val
end
def self.is_upload_dSYM_to_bugly # 构建完成后是否自动上传符号表到bugly
return @is_upload_dSYM_to_bugly
end
def self.is_upload_dSYM_to_bugly=(val)
@is_upload_dSYM_to_bugly = val
end
def self.pgyer_selected_index # 蒲公英数据源的选定索引值
return @pgyer_selected_index
end
def self.pgyer_selected_index=(val)
@pgyer_selected_index= val
end
def self.app_sign_seleted_index # 应用签名的选定索引值
return @app_sign_seleted_index
end
def self.app_sign_seleted_index=(val)
@app_sign_seleted_index= val
end
def self.ding_webhook_index # 钉钉群消息Webhook的选定索引值
return @ding_webhook_index
end
def self.ding_webhook_index=(val)
@ding_webhook_index = val
end
end
CodeConf类
该类可定制性非常强,这里不做讲解,大家了解Shell脚本或Ruby文件内容读取的可以自己做相关的处理,这里默认我们如下处理。如有疑问,欢迎留言。
# require './project_conf' # 如果有依赖项,则要引入其他的类(默认没有引入)
# require './build_conf'
# 从项目代码中读取配置的类,日常构建项目时不需要更改该文件
class CodeConf
# 从项目代码中读取当前工程的网络环境
def self.enviroment
return 'dev'
end
# 通过代码读取当前工程是否支持用户自切网络环境
def self.is_mutable_enviroment
return false
end
end
# puts "网络环境 => #{CodeConf.enviroment}"
# puts "是否可自切换网络环境 => #{CodeConf.is_mutable_enviroment}"
DataSource类
数据源类,是用于存储各种配置数组的地方,可以供用户选择,他为BuildInfo类服务。
# 用户配置的数据源
class DataSource
# 蒲公英配置数组, 可以配置多组数据,构建时选其一
@pgyer_configs = Array[
{
"api_key" => "xxxxxxxxxxx",
"user_key" => "xxxxxxxxxxx",
"app_url" => "https://www.pgyer.com/xxxx",
"app_icon" => "https://www.pgyer.com/app/qrcode/xxxx"
},
{
"api_key" => "xxxxxxxxxxx",
"user_key" => "xxxxxxxxxxx",
"app_url" => "https://www.pgyer.com/xxxx",
"app_icon" => "https://www.pgyer.com/app/qrcode/xxxx"
},
{
"api_key" => "xxxxxxxxxxx",
"user_key" => "xxxxxxxxxxx",
"app_url" => "https://www.pgyer.com/xxxx",
"app_icon" => "https://www.pgyer.com/app/qrcode/xxxx"
}
]
# 应用配置签名数组(构建时选其一)
@app_signs = Array[
{"bundle_id" => "<bundle id1>", "provisioning_profile" => "<描述文件名1>"},
{"bundle_id" => "<bundle id2>", "provisioning_profile" => "<描述文件名2>"},
]
# 钉钉webhook链接数组
@ding_webhooks = Array[
{"name" => "<钉钉群1>", "url" => "https://oapi.dingtalk.com/robot/send?access_token=xxxxx"},
{"name" => "<钉钉群2>", "url" => "https://oapi.dingtalk.com/robot/send?access_token=xxxxx"},
{"name" => "<钉钉群3>", "url" => "https://oapi.dingtalk.com/robot/send?access_token=xxxxx"},
{"name" => "<钉钉群4>", "url" => "https://oapi.dingtalk.com/robot/send?access_token=xxxxx"}
]
def self.pgyer_configs
return @pgyer_configs
end
def self.app_signs
return @app_signs
end
def self.ding_webhooks
return @ding_webhooks
end
end
UserSetting类
require './build_conf'
class UserSetting
# 初始化数据,
def self.initData
# 更新描述信息,每一项用`;`隔开
BuildConf.update_desc = "更新了A功能;优化了B功能;修复了C问题;"
BuildConf.build_configuration = "Release"
BuildConf.app_export_method = "ad-hoc"
BuildConf.is_upload_to_pgyer = true
BuildConf.is_send_to_dingtalk = true
BuildConf.is_upload_dSYM_to_bugly = true
# pgyer_selected_index, app_sign_seleted_index, ding_webhook_index的索引值选择请参照DataSource类
BuildConf.pgyer_selected_index = 2
BuildConf.app_sign_seleted_index = 0
BuildConf.ding_webhook_index = 3
end
end
Controller类
Controller则是上述各个类的管理类,同时Controller还负责供Fastfile的调度,以此来实现对Fastfile解耦,同时也解决了Fastfile不方便调试的问题。该类代码相对较多,代码类我会贴出相关注释。
# Fastlane的数据配置和方法调用文件
# 注: 该文件所在文件夹可以用RubyMine以工程形式打开调试
require 'fileutils'
require 'net/http'
require 'json'
require './data_source'
require './project_conf'
require './build_conf'
require './code_conf'
require './user_setting'
# 控制器类
class Controller
def self.initConf
# 从UserSetting类中初始化数组配置
UserSetting.initData # 首先通过UserSetting类去预设构建配置
# 是否可以自切环境的中文描述
@is_mutable_enviroment_desc = CodeConf.is_mutable_enviroment == true ? "可切换" : "不可切换"
# 以下几项是更具BuildConf中配置的索引值去取DataSource类中数据源配置,也是要在UserSetting中预设
@pgyer_conf = DataSource.pgyer_configs.at(BuildConf.pgyer_selected_index)
@app_sign_conf = DataSource.app_signs.at(BuildConf.app_sign_seleted_index)
@ding_webhook_conf = DataSource.ding_webhooks.at(BuildConf.ding_webhook_index)
end
def self.pgyer_conf
return @pgyer_conf
end
def self.app_sign_conf
return @app_sign_conf
end
def self.ding_webhook_conf
return @ding_webhook_conf
end
# 打印构建配置
def self.print_configs
puts("\n︎ 蒲公英配置 ︎\napi_key: #{@pgyer_conf["api_key"]}\nuser_key: #{@pgyer_conf["user_key"]}\napp_url: #{@pgyer_conf["app_url"]}\napp_icon: #{@pgyer_conf["app_icon"]}")
puts("\n︎ 签名配置 ︎\nbundle_id: #{@app_sign_conf["bundle_id"]}\nprovisioning_profile: #{@app_sign_conf["provisioning_profile"]}")
puts("\n︎ 钉消息配置 ︎\n钉消息目标群: #{@ding_webhook_conf["name"]}\nwebhook URL: #{@ding_webhook_conf["url"]}")
puts "\n"
puts "\n︎ markdown消息配置 ︎\n#{self.ding_release_note}"
end
# 格式化更新信息(规则:中英文分号或分号带空格通通转换成中文分号间隔)
def self.formated_update_desc
# 更新内容规范化
formated_desc = BuildConf.update_desc.gsub(/; |; |;/, ";").chomp
if formated_desc[-1, 1] == ";" then
formated_desc.chop!
end
if formated_desc.length == 0 then
formated_desc = "<暂无记录>"
end
return formated_desc
end
# 通过格式化后的更新信息 => 更新信息数组(规则:利用分号分割)
def self.updates
updates = self.formated_update_desc.split(";")
return updates
end
# 生成本次构建信息项目通用哈希数组
def self.build_items
items = Array[
{"item" => "应用版本", "value" => ProjectConf.app_version + "(build #{ProjectConf.app_version})"},
{"item" => "构建时间", "value" => ProjectConf.app_build_time},
{"item" => "构建环境", "value" => "#{CodeConf.enviroment}(#{@is_mutable_enviroment_desc})"},
{"item" => "构建途径", "value" => "#{BuildConf.build_configuration} => #{BuildConf.app_export_method}"},
{"item" => "构建分支", "value" => "#{ProjectConf.cur_branch}(#{ProjectConf.cur_commit_id})"},
{"item" => "应用签名", "value" => @app_sign_conf["bundle_id"]}
]
return items
end
# 生成更新描述的markdown格式的无需列表字符串(用途:发送钉消息;存入本地更新日志;蒲公英更新项也采用此方案,换行 + `*`)
def self.md_update_content
content = String.new
(0..updates.count - 1).each { |i|
content += "* " + updates[i] + "\n"
}
return content
end
# 生成构建信息项信息:用于存入markdown无序列表
def self.md_build_content
content = String.new
(0..self.build_items.length - 1).each { |i|
content += "* #{self.build_items[i]["item"]}: #{self.build_items[i]["value"]}\n"
}
return content
end
# 生成构建项信息:用于写入到上传蒲公英的描述中
def self.pyger_build_content
content = String.new
(0..self.build_items.length - 1).each { |i|
content += "#{self.build_items[i]["item"]}: #{self.build_items[i]["value"]}\n"
}
return content
end
# 生成蒲公英工程上传时的描述信息
def self.pgyer_build_article
return self.pyger_build_content + "更新描述:\n" + self.md_update_content
end
# 用于写入本地日志文件的更新内容
def self.logs_release_note
release_note = "\n## v#{ProjectConf.app_version} (#{ProjectConf.ipa_name})\n\n" + "> 构建信息\n\n" + "#{self.md_build_content}\n" + "> 更新描述\n\n" + "#{self.md_update_content}\n" + "------------------------------\n"
return release_note
end
# 用于发送钉钉webhook消息的更新内容
def self.ding_release_note
msg_title = "发现#{ProjectConf.app_name}(iOS)新版本!\n"
release_note = "### #{msg_title}\n" +
"![#{@pgyer_conf["app_icon"]}](#{@pgyer_conf["app_icon"]})\n\n" +
"###### *链接*: [#{@pgyer_conf["app_url"]}](#{@pgyer_conf["app_url"]})\n\n" +
"------------------------------\n\n" + "> 构建信息\n\n" + "#{self.md_build_content}\n" + "> 更新描述\n\n" + "#{self.md_update_content}\n"
return release_note
end
# 写入更新日志到本地
def self.write_to_local_logs
# puts "\nself.md_build_content\n"
# puts "\nself.md_update_content\n"
release_note_path = "#{ProjectConf.log_dir}/#{ProjectConf.app_name}_iOS_#{ProjectConf.output_year_month}更新记录.md"
r_n_path_temp = "#{ProjectConf.log_dir}/.latest_log.tmp"
# 如果本次构建有更新,则写入更新日志(先要检测Log文件夹是否存在,不存在则需要创建)
FileUtils.mkpath "#{ProjectConf.log_dir}"
if File::exists?(release_note_path) == false then
# 如果文件不存在,创建文件并写入内容
`touch #{release_note_path}`
`echo "# #{ProjectConf.app_name}(iOS) #{ProjectConf.output_year_month}更新记录\n\n" >> #{release_note_path}`
end
# 写入本次更新内容
`echo "#{self.logs_release_note}" > "#{r_n_path_temp}"` # 先将本次更新内容存入到缓存文件
`sed -i '' '/更新记录/r #{r_n_path_temp}' #{release_note_path}` # 将缓存文件的内容插入到`release_note_path`文件的`更新记录`所在行的下一行
`rm -rf #{r_n_path_temp}` # 删除缓存文件
puts "\n------------------------------\n"
puts "✅ 已经成功写入更新日志到:#{release_note_path} ✅"
end
# 发送更新信息到钉钉群
def self.send_ding_msg
msg_title = "发现#{ProjectConf.app_name}(iOS)新版本!\n"
markdown = {
"msgtype": "markdown",
"markdown": {title: msg_title, text: ding_release_note}
}
# 发起发送请求
uri = URI.parse(@ding_webhook_conf["url"])
https = Net::HTTP.new(uri.host, uri.port)
https.use_ssl = true
request = Net::HTTP::Post.new(uri.request_uri)
request.add_field('Content-Type', 'application/json')
request.body = markdown.to_json
response = https.request(request)
puts "------------------------------"
puts "Response #{response.code} #{response.message}: #{response.body}"
if response.code == "200" then
puts "✅ 已发送钉消息 ==> #{@ding_webhook_conf["name"]}(url: #{@ding_webhook_conf["url"]}) ✅"
else
puts "❎ 钉消息发送失败 ❎"
end
end
# 上传符号表到bugly
def self.upload_dSYM_to_bugly
bugly_app_id = "<bugly的app_id,这里其实也可以抽取到数据源配置类中>"
bugly_app_key = "<bugly的app_key,可以抽取到数据源配置类中>"
dSYM_name = "#{ProjectConf.project_name}_#{ProjectConf.output_file_build_time}.app.dSYM.zip"
dSYM_path = "#{ProjectConf.ipa_dir}/#{dSYM_name}"
channel = "default"
bugly_desc = "︎ Bugly符号表配置 ︎\n" +
"bugly_app_id: #{bugly_app_id}\n" +
"bugly_app_key: #{bugly_app_key}\n" +
"dSYM_path: #{dSYM_path}\n" +
"channel: #{channel}\n"
puts bugly_desc
`curl -k "https://api.bugly.qq.com/openapi/file/upload/symbol?app_key=#{bugly_app_key}&app_id=#{bugly_app_id}" --form "api_version=1" --form "app_id=#{bugly_app_id}" --form "app_key=#{bugly_app_key}" --form "symbolType=2" --form "bundleId=#{@app_sign_conf["bundle_id"]}" --form "productVersion=#{ProjectConf.app_version}" --form "channel=#{channel}" --form "fileName=#{dSYM_name}" --form "file=@#{dSYM_path}" --verbose`
end
end
# 这里是需要调用控制器自己的initConf方法,作为Fastfile引入Controller类时调用栈中最先执行的方法
Controller.initConf
Controller.print_configs
Fastlane中Fastfile的配置
从上一篇博文中可以了解到,我们Fastfile文件中配置好项目构建信息就可以给App打包了。而本文中,我们封装了好几个抽象类,全都为Fastfile服务。
直接在Fastfile中配置有简单、快速的优点,但是缺点很明显,如下:
- 不方便调试。Fastfile不是
.rb
后缀的文件,必须要通过bundle exec fastlane ...
相关的方式去执行才能暴露问题,不仅干扰项目的正常构建,还会在构建的App项目很大的时候查错会变得难上加难 - Fastfile容易变得异常臃肿。由于处理了一些不应该由它去处理的业务,违背了类的
单一职责
的设计理念 - 代码可读性差,可维护性差。Fastfile设计的初心就是通过简单配置实现强大的构建过程,我们在后期还要对Fastfile配置进行扩展会变得麻烦且易错
而我们使用多个Ruby类把这些数据和业务处理抽象出来后,Fastfile的负担会小很多。如下是我们处理后的Fastfile:
require './controller' # 引入控制器,由控制器去调用其他类并处理业务逻辑
default_platform(:ios)
platform :ios do
lane :adhoc do
cocoapods
build_app(scheme: ProjectConf.project_name,
workspace: ProjectConf.project_name + ".xcworkspace",
include_bitcode: true,
configuration: BuildConf.build_configuration,
export_method: BuildConf.app_export_method,
output_directory: ProjectConf.ipa_dir,
output_name: ProjectConf.ipa_name,
silent: false,
include_symbols: true,
export_xcargs: "-allowProvisioningUpdates",
export_options: {
provisioningProfiles: {
Controller.app_sign_conf["bundle_id"] => Controller.app_sign_conf["provisioning_profile"]
}
})
# 打包成功后写入更新日志到本地
Controller.write_to_local_logs
# 分发应用到蒲公英
if BuildConf.is_upload_to_pgyer == true then
pgyer(
api_key: Controller.pgyer_conf["api_key"],
user_key: Controller.pgyer_conf["user_key"],
update_description: Controller.pgyer_build_article
)
end
# 钉钉群消息
if BuildConf.is_send_to_dingtalk == true then
Controller.send_ding_msg
end
# 上传符号表到bugly
if BuildConf.is_upload_dSYM_to_bugly == true then
Controller.upload_dSYM_to_bugly
end
end
end
这里要说明的是pgyer上传方法没有在外部实现,因为Fastlane的蒲公英插件只能在Fastfile中调用,不多是否上传我们通过引入外部的变量BuildConf.is_upload_to_pgyer
来控制。
总结
按照上述的各项配置,我们就可以实现我们的定制化构建功能了。
终端进入XCode工程的根目录,执行bundle exec fastlane adhoc
, 会看到屏幕上会先打印我们预先配置好的参数,这都是Controller
类中print_configs
方法的功劳。
当我们的项目构建完成时。就可以实现文章最前面功能概述
描述的那些功能了。
本项目源码地址:https://github.com/cba023/FastlaneAdhoc
转载请注明出处。