Android Jenkins的定制化打包平台构建
Jenkins安装、插件安装 、项目构建
本篇文章的前提是已经完成Jenkins的自动化构建平台搭建,能够完成项目的自动化构建,如果对此还有疑问,请参考下面的链接,搭建Jenkins自动化构建平台。
Android Jenkins自动化构建之路 for Linux
Android Jenkins自动化构建之路 for Windows
Android Jenkins自动化构建之路 for MacOS
参数化构建流程
- Jenkins 定制参数,通过Shell脚本写入构建配置文件buildconfig.txt
- 新建 buildSrc 构建工程
- 解析buildconfig.txt 配置文件
- 解析的内容分为以下几种
- BuildConfig 中相关开关,如是否开启某一个模块功能代码
- 普通字符串,如包名,内部版本号,外部版本号
- 资源文件替换,如启动页,桌面icon
- xml文件修改,主要指AndroidManifest.xml
- 业务代码应用配置文件处理之后的内容。
其中 构建工程对buildconfig.txt的解析 是重点和难点。
如果项目构建已经完成,我们的构建过程是这个样子
参数化构建配置完成.png
左边的Build会变成 Build with Parameters,我们在构建项目的时候,会多出一些选项。
这些选项的定义和解析,和项目的业务有关,这里列举了常用的几个。
- 包名
- 内部版本号
- 外部版本号
- 替换桌面图标
- 是否显示闹钟的开关
Jenkins 参数化配置
- 选中 Config 选项,进入配置界面
勾选 This project is parameterized ,下面会有一个 Add Parameter 的下拉箭头选项。
点击 Add Parameter ,如下
Add Parameter.png这里列举了Jenkins支持的参数类型,基本满足我们所有的需求,最常用的有
- String Parameter
- Boolean Parameter
- Choice Parameter
- File Parameter
添加一个 String Parameter ,如下
String Parameter.pngName 就是 key 值,获取的时候就用这个对应的名称,VERSION_NAME
Default Value 是默认值,如果构建时不填,就使用默认值。
添加一个 File Parameter
File Parameter.pngSPLASH_RES 用来获取上传文件的文件名使用。
- 读取参数
我们在上面配置了很多参数,怎么读取这些参数,并把这些参数保存成配置文件呢?
Shell 脚本文件内容
Execute shell.png
这段脚本文件,会在项目根目录下生成一个 buildconfig.txt 文件,每次构建,都会写入下面的内容,通过 $ 获取参数内容
生成的配置文件内容如下
PKG_NAME=com.abc2345
APP_NAME=天气
VERSION_NAME=5
VERSION_CODE=5
IS_USE_ALARM=true
IS_CREATE_ALARM_SHORTCUT=true
IS_USE_WIDGET=true
SPLASH_RES=SPLASH.zip
ICON_RES=ICON.zip
完成了参数化构建的配置文件生成,下面就是怎么通过配置工程,解析并使用这些配置文件了。
新建配置文件解析工程
创建配置文件解析工程时,有很多坑需要注意,这里使用的是取巧的一种方法。
- 创建 一个Android Library 工程,命名为 buildSrc ,必须是这个名称,不然会有各种各样的问题。
- 修改工程目录结构,如下。
这个目录结构和普通的Android 结构有些区别,src/main 下存放代码文件和资源文件,groovy用来存放 groovy代码。resources下存放入口配置文件。
- 修改 buildSrc 中 build.gradle 文件。
将 build.gradle 中内容修改为如下
apply plugin: 'groovy'
apply plugin: 'maven'
dependencies {
compile gradleApi()
compile localGroovy()
}
repositories {
mavenCentral()
}
- 配置 gradle.plugin
在 groovy 下新建一个类,如 AssembleBuildConfigPlugin,实现 Plugin<Project> 接口
重写 apply(Project project) 方法,
@override
void apply(Project project){
// 输出一行日志
println "buildsrc apply"
}
- 创建 .properties 文件
- 在 resources 目录下,新建 META-INF 文件,然后在文件夹中再创建 gradle-plugins 文件,一定要分两次创建两层文件夹。
- 在 gradle-plugins 中,创建 com.abc123.properties 文件,其中文件名为构建工程的包名,后缀为.properties。
- properties 中输入入口类文件名。
implementation-class=com.abc123.AssembleBuildConfigPlugin
等号的右边填写完整路径名称。
- 应用 buildSrc 工程。
在 App Model 的 build.gradle 中,应用 buildSrc 工程
apply plugin: 'com.abc123'
com.abc123 就是我们构建工程的包名。
-
编译工程
编译 buildSrc.png
选中 右侧 buildSrc 中的 build 任务,在gradle console 中可以看到执行情况。
下面是构建过程截图
解析 buildconfig.txt
buildSrc 的入口类就是我们定义的AssembleBuildConfigPlugin
我们定义一个 resolveProfile 的方法,groovy相关的语法大家自己了解,groovy可以兼容java。
/**
* 解析配置信息
* @param project 项目目录
*/
def resolveProfile(Project rootProject) {
def ext = rootProject.extensions.findByName('ext')
def config = ext.getAt("config")
def android = ext.getAt("android")
println("show default \tt ext==" + ext + "\t config:" + config + "\t android:" + android)
def profileMap = readJenkinsProfile()
println "return map:" + profileMap
// 包名
String PKG_NAME = profileMap.getProperty("PKG_NAME")
println "PKGNAME===" + PKG_NAME
if (!TextUtils.isEmpty(PKG_NAME)) {
android.applicaitonId = PKG_NAME
println("applicationId == " + PKG_NAME)
}
// 内部版本号
//版本号
String VERSION_CODE = profileMap.getProperty("VERSION_CODE")
if (VERSION_CODE != null && VERSION_CODE.length() > 0) {
android.versionCode = Integer.parseInt(VERSION_CODE)
println "resolve from config VERSION_CODE: " + VERSION_CODE
}
// 外部版本号
String VERSION_NAME = profileMap.getProperty("VERSION_NAME")
if (!TextUtils.isEmpty(VERSION_NAME)) {
android.versionName = VERSION_NAME
println("VERSION_NAME ==" + VERSION_NAME)
}
//应用名称
String APP_NAME = profileMap.getProperty("APP_NAME")
if (!TextUtils.isEmpty(APP_NAME)) {
config.APP_NAME = APP_NAME
println("APP_NAME ==" + APP_NAME)
}
// 是否开启闹钟模块
String IS_USE_ALARM = profileMap.getProperty("IS_USE_ALARM")
if (!TextUtils.isEmpty(IS_USE_ALARM)) {
config.IS_USE_ALARM = IS_USE_ALARM
println "IS_USE_ALARM == " + IS_USE_ALARM
}
// 是否开启创建桌面闹钟功能
String IS_CREATE_ALARM_SHORTCUT = profileMap.getProperty("IS_CREATE_ALARM_SHORTCUT")
if (!TextUtils.isEmpty(IS_CREATE_ALARM_SHORTCUT)) {
config.IS_CREATE_ALARM_SHORTCUT = IS_CREATE_ALARM_SHORTCUT
println "IS_CREATE_ALARM_SHORTCUT ==" + IS_CREATE_ALARM_SHORTCUT
}
// 是否开启小组件功能
String IS_USE_WIDGET = profileMap.getProperty("IS_USE_WIDGET")
if (!TextUtils.isEmpty(IS_USE_WIDGET)) {
config.IS_USE_WIDGET = IS_USE_WIDGET
println "IS_USE_WIDGET==" +IS_USE_WIDGET
}
// 替换启动页资源
String SPLASH_RES = profileMap.getProperty("SPLASH_RES")
println "SPLASH_RES ==="+SPLASH_RES
if (!TextUtils.isEmpty(SPLASH_RES)){
String resZipPath = "SPLASH_RES.zip"
def splashFile = new File(resZipPath);
println "iconFile ==="+splashFile.absolutePath
if (splashFile.exists()) {
println "res file exist and start replace res"
config.SPLASH_RES=SPLASH_RES
replaceRes(rootProject, resZipPath, "main")
} else {
println "use default splash res"
}
}
// 替换icon 资源
String ICON_RES = profileMap.getProperty("ICON_RES")
println "ICON_RES ==="+ICON_RES
if (!TextUtils.isEmpty(ICON_RES)){
String iconPath = "ICON_RES.zip"
def iconFile = new File(iconPath)
if (iconFile.exists()) {
println "res file exist and start replace res"
config.ICON_RES = ICON_RES
replaceRes(rootProject,iconPath,"main")
} else {
println "use default icon res"
}
}
println("show end \tt ext==" + ext + "\t config:" + config + "\t android:" + android)
}
读取jenkins 配置文件方法
/**
* 读取jenkins 配置
*/
def readJenkinsProfile() {
def props = new Properties()
def profile = new File(BUILD_PROFILE)
if (profile.exists()) {
profile.withInputStream {
stream -> props.load(new InputStreamReader(stream, "UTF-8"))
}
}
println "readProfile : " + props
return props
}
替换资源文件的相关方法
//替换资源文件
def replaceRes(Project rootProject, String resZipPath, String product) {
println "#################### replace res start ####################"
def appProject = rootProject.findProject(":app");
File srcFile = appProject.file("src")
File productFile = new File(srcFile, product)
println "productFile==="+productFile.absolutePath
if (!productFile.exists()){
productFile.mkdir()
}
ZipFileUtil.replaceResFromZip(resZipPath, productFile.absolutePath)
}
zip 文件解压操作
def static replaceResFromZip(String path, String toPath) throws IOException {
def count = -1;
def index = -1;
def file = null;
def is = null;
def fos = null;
def bos = null;
println "path===="+path+"\t toPaht===="+toPath
ZipFile zipFile = new ZipFile(path);
Enumeration<?> entries = zipFile.entries();
while (entries.hasMoreElements()) {
def buf = new byte[2048];
ZipEntry entry = (ZipEntry) entries.nextElement();
def filename = entry.getName();
filename = toPath + "/" + filename;
println "fileName=="+filename
File file2 = new File(filename.substring(0, filename.lastIndexOf("/")));
println "file2====="+file2.absolutePath
if (!file2.exists()) {
file2.mkdirs();
}
// 过滤掉文件夹 和 macOS 上的特殊文件 __MASOSX 和隐藏文件
if (!filename.endsWith("/") && !filename.startsWith("_") && !filename.startsWith(".")) {
file = new File(filename);
file.createNewFile();
is = zipFile.getInputStream(entry);
fos = new FileOutputStream(file);
bos = new BufferedOutputStream(fos, 2048);
while ((count = is.read(buf)) > -1) {
bos.write(buf, 0, count);
}
bos.flush();
fos.close();
is.close();
}
}
zipFile.close();
println "####################replaceResFromZip end ########################"
}
以上这些基本就是构建定制化打包平台的主要工作,具体的细节,需要通过代码不断调试。