程序员

优雅地使用fastlane构建iOS内测安装包(自动分发到蒲公英

2020-04-19  本文已影响0人  编程文学家

前言

本文需要用到RubyMine集成开发环境,Fastlane的蒲公英插件,需要用户自行安装配置,引用链接如下:

本项目源码地址:https://github.com/cba023/FastlaneAdhoc

RubyMine官方网站https://www.jetbrains.com/ruby/

蒲公英Fastlane插件安装教程:https://www.pgyer.com/doc/view/fastlane

我在之前的一篇文章(https://www.jianshu.com/p/50cff529358f)已经讲到了如何从零开始使用fastlane实现iOS内测安装包的构建,显然,实现这样的功能并不困难,同时会有朋友问:这样构建的意思是什么?为什么不用XCode给我提供的打包工具实现项目的构建呢?
其实,上一篇讲述的只是基础,现在我们要围绕这个基础来搭建一款真正符合我们使用场景的构建项。当然,如果正在阅读的你懂点Ruby,可能会理解起来更轻松。

功能概述

这次构建可以让我们解决平常构建打包中的一些痛点,通通变得自动化,如下:

钉钉群消息的效果 蒲公英下载页展示本次构建信息 fastlane输出文件夹生成的内容 本地Markdown格式的版本更新日志

当然,还可以配置各种各样的功能,不过,这对于我们程序员来说都不是难题,要做什么,取决于我们的需求。

开始使用Ruby编写构建脚本

因为fastfile是基于Ruby编写,所以我们为了能配套该文件开设一个基于Ruby语言的工程,需要用到RubyMine(VSCode也支持Ruby调试,但是没有RubyMine方便,这里统一使用RubyMine),RubyMine是大名鼎鼎的捷克公司JetBrains开发的一款针对Ruby语言的集成开发环境,比较出名的PhpStorm, WebStorm, idea都是JetBrains旗下的产品。具体安装RubyMine的教程可以自己到网上查询资料。

RubyMine官网:https://www.jetbrains.com/ruby/

使用Ruby打开Fastfile所在文件夹(即fastlane文件夹,可以查看我的上一篇文章找到文件夹位置),以该文件夹作为Ruby工程目录,如下:

以fastlane文件夹为工程目录打开后RubyMine显示的内容

Fastfile是不能通过RubyMine来运行的,怎么办呢,我们可以借助创建一个类Controller来帮他实现一些操作。同时我们还要创建几个类,把Controller包含在内则有这么几个类:

类名 用途
Controller 控制器类,用于处理业务逻辑
ProjectInfo 数据模型类,只读,用于读取项目信息
BuildInfo 数据模型类,可读写,用户可以编辑其属性,实现构建的可定制化
CodeConf 数据模型类,只读,用于读取项目代码中配置项目信息(通常使用Shell脚本或Ruby脚本获取,比如当前代码里配置的网络环境是开发环境还是线上环境)
UserSetting 该类在专用于用户在构建前设定构建数据,实现差异化构建(比如每次打包时本次的更新内容)
DataSource 数据源类,可读写,这里主要配置蒲公英应用签名信息钉钉群消息等,供UserSetting类选择调用,实现构建时用户可手动选择预先配置的一种实现可定制化构建

拆分业务后,整个构建的业务更加清晰。更重要的是,我们可以非常愉快地对构建的业务逻辑进行调试,这些类和Fastfile都是分离的,而且是由Fastfile单向调用他们,调试这些类对Fastfile没有任何影响,这样我们就可以对整个构建功能的业务逻辑进行随意的扩展而不会显得混乱。

右键 => Debug就能调试,很方便

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中配置有简单、快速的优点,但是缺点很明显,如下:

而我们使用多个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
转载请注明出处。

上一篇下一篇

猜你喜欢

热点阅读