Android自动化打包记录--Jenkins+Docker+WSL2

前言

在面向海外的项目组辛勤耕耘了两年,今年被调到了国内的开发组,很多东西突然感觉有些陌生了起来。首先接到的第一个任务就是打包自动化的工作,因为国内的项目组目前有多个app在同时开发,提测的时候人工打完测试包上传到三方平台,然后发送钉钉通知告知测试人员。到生产环境的时还需要打包、加固、重签名,再处理多渠道问题,最后还需要手动上传mapping文件到Bugly等平台,整个一套流程够复杂,并且也相当浪费时间,多个app处理起来更是繁琐。

所以,把这件事交给机器去做就是我们的终极目的。其实这件事情整体做下来更像是运维的工作,但是呢,作为一个开发工程师我学(卷)一点运维的内容不过分吧。整体内容围绕Jenkins + Docker来进行阐述,如有纰漏或错误,还请各位帮忙斧正。

注: 由于编写该文档时,360加固免费版还是支持命令行的方式使用的,但是现在免费版已经不支持命令行的操作了,如果使用则需要购买加固专业版,或者成为企业版用户。所以现在情况下,我又写了一个桌面端的工具来完成后续步骤,文章参考《使用ComposeDesktop开发一款桌面端多功能APK工具》。

自动化流程

先把我们前述的需求分类整理下,大致分为测试环境和生产环境,具体的流程步骤如下:

测试环境

在测试环境下,主要流程如下:

  1. 打出测试包
  2. 上传到公司内网服务器
  3. 生成apk的下载二维码(qrencode)
  4. 获取Git日志的提交记录
  5. 最后使用钉钉机器人通知到群即可

测试环境的整个自动化过程还是相对简单的。

注:在之前我们是上传到fir.im或者蒲公英这样的应用内测托管平台上的,然而由于最近一段时间貌似审核比较严重,动不动新的app就会被提示违规然后不给下载,所以正好借此机会舍弃了三方平台,转而使用内网服务器。

生产环境

生产环境的流程就复杂多了,主要流程如下:

  1. 打出生产包
  2. 加固(加固方案这里示例的是360加固,其他比如腾讯乐固等,大家可以自行选择)
  3. 重签名(可以使用360命令行重签名,也可以自行重签名)
  4. 生成渠道包(这一步采用的是VasDolly方案,其他如Walle等,大家可以自行选择)
  5. 渠道包生产完毕后就可以将渠道包存储到服务器上或上传到其他三方平台上提供下载链接了
  6. 将生成的mapping文件上传到崩溃分析的平台即可,这里是腾讯的bugly
  7. 发送钉钉通知告知相关人员

自动化原理

整体的流程已经分析完了,那么如何实现呢?

Jenkins!在Jenkins中我们可以编写pipeline脚本来处理上述步骤,免去了人工操作的烦恼。Jenkins的格言:

使开发者从繁杂的集成中解脱出来,专注于更为重要的业务逻辑实现上

接下来我们先着重看下打包这个步骤,光是打包我们就需要配置java环境、gradle环境、android sdk/ndk等等,如果这套自动化的工具单部署到一台服务器上还则罢了,要是再多部署几台,那光是配置这一套环境就能把人逼疯了,怎么处理呢?。

Docker!Docker允许我们把这些配置的内容统统封装起来,做成镜像文件。哪里有需要就下载这个镜像,然后在容器中运行该镜像,这样就能提供出来一套跟开发一模一样的环境,然后在其中使用正常的gradle打包命令就可以了。

接下来我们就在Linux的环境下,安装Jenkins和Docker来一步步实现我们的自动化流程。

Windows下安装Ubuntu

因为我的电脑系统是Windows 11,为了方便我直接采用了WSL2的方案。

安装步骤

首先在搜索中输入“启用或关闭Windows功能”,然后再弹框中勾选如下两项,然后最好重启电脑:
Snipaste_2022-05-30_23-48-00.png
打开Microsoft Store,搜索ubuntu,这里我选择Ubuntu 20.04.4 LTS版本进行了安装。

安装完毕后打开Ubuntu过程中可能会遇到各种奇奇怪怪的问题,如果有,请参考下文相关方案。

相关error处理

error: 0x8007019e

Installing, this may take a few minutes…
WslRegisterDistribution failed with error: 0x8007019e
The Windows Subsystem for Linux optional component is not enabled. Please enable it and try again.
See https://aka.ms/wslinstall for details.
Press any key to continue…

以管理员权限打开Window PowerShell,输入以下代码,然后按 Y 确定,重启系统:

Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux
error: 0x800701bc、0x80370102

Installing, this may take a few minutes…
WslRegisterDistribution failed with error: 0x800701bc
Error: 0x800701bc WSL 2 ??? https://aka.ms/wsl2kernel

Press any key to continue…

前往微软WSL官网下载安装适用于 x64 计算机的最新 WSL2 Linux 内核更新包安装即可。
https://docs.microsoft.com/zh-cn/windows/wsl/install-manual#step-4—download-the-linux-kernel-update-package

WSL访问Windows

主要是mnt,表示挂载:

//进入Windows下E盘

cd /mnt/e

WSL访问内网

需要设置端口转发:

//设置端口转发
netsh interface portproxy add v4tov4 listenport=【宿主机windows平台监听端口】 listenaddress=0.0.0.0 connectport=【wsl2平台监听端口】 connectaddress=【wsl2平台ip】

//删除端口转发    
netsh interface portproxy delete v4tov4 listenport=【宿主机windows平台监听端口】 listenaddress=0.0.0.0

//查看端口转发状态
netsh interface portproxy show all


Ubuntu下Docker内容

官方网址:https://docs.docker.com/desktop/linux/install/

安装

如果按照官方步骤执行失败的话,可以参考如下步骤:
首先更新软件包索引,然后添加新的HTTPS软件源:

sudo apt update
sudo apt install apt-transport-https ca-certificates curl gnupg-agent software-properties-common

然后导入源仓库的GPG key:

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

将Docker APT软件源添加到系统:

sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"

现在可以查看Docker软件源中的所有可用版本了:

apt list -a docker-ce

安装:

//安装最新版本
sudo apt install docker-ce docker-ce-cli containerd.io

//安装指定版本
sudo apt install docker-ce=<VERSION> docker-ce-cli=<VERSION> containerd.io

验证安装,如果成功输出docker的版本号,表示安装成功:

docker -v

运行HelloWorld

在运行hello-world之前需要先启动docker服务,否则报错如下:

docker: Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Post “http://%2Fvar%2Frun%2Fdocker.sock/v1.24/containers/create”: dial unix /var/run/docker.sock: connect: permission denied.
See ‘docker run --help’.

启动docker命令如下:

sudo service docker start

然后运行docker的hello-world,验证是否安装成功:

sudo docker run hello-world

拉取镜像

以拉取jdk8镜像为例:

sudo docker pull openjdk:8-jdk-oracle

镜像和容器命令

//显示出所有的镜像
sudo docker images

//-it表示以交互式运行该镜像
sudo docker run -it 镜像ID

//列出所有的容器
sudo docker ps -a

//启动停止容器
sudo docker start/stop 容器ID

//列出正在运行的容器
sudo docker ps

//以交互式进入正在运行的容器
sudo docker exec -it 容器ID /bin/bash

创建镜像

了解了如何使用镜像后,我们现在可以尝试创建自己所需要的镜像了,根据上文的流程我们先从简单的镜像创建说起,然后再一步步创建Android打包所需的复杂的镜像。创建镜像需要我们编写Dockerfile脚本,一些常用的脚本指令可以在官网中找到,请参考《编写Dockerfile的最佳实践》。

为了方便创建镜像,我在Windows上也安装并启动了Docker然后使用IntelliJ IDEA组织相关代码和资源,同时IDEA还需要安装一下Docker插件。一切准备就绪后我们这就开始制作镜像了。
image.png

创建VasDolly镜像

VasDolly需要在JDK8的环境下使用,那么有两种方式:

  • 使用ubuntu作为基础镜像,自行安装jdk并配置环境
  • 直接使用jdk8的基础镜像

我们使用第一种方式做为演示,首先工程结构如下所示:
image.png
在VasDolly文件夹下,我们有jdk-8u333-linux-x64.tar.gz以及VasDolly.jar、Dockerfile文件。

Dockerfile脚本的内容如下:

#指定基础镜像
FROM ubuntu:20.04

#添加文件到容器中
ADD jdk-8u333-linux-x64.tar.gz /home/jdk/
ADD VasDolly.jar /home/vasdolly/

# JDK会自动解压,直接配置环境变量
ENV JAVA_HOME /home/jdk/jdk1.8.0_333
ENV JRE_HOME $JAVA_HOME/jre
ENV CLASSPATH $JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar:$JRE_HOME/lib:$CLASSPATH
ENV PATH $JAVA_HOME/bin:$PATH

# 发送钉钉机器人消息所需
RUN apt update && apt install -y curl

#运行指令
CMD ["java", "-jar", "/home/vasdolly/VasDolly.jar", "help"]

我们以ubuntu20.04版本作为基础镜像,然后添加JDK和VasDolly文件到镜像中,并配置JDK的相关环境变量,最后又安装了发送钉钉消息所需的curl组件。

注意运行指令的区别:

  • CMD 在Docker Run 时运行。
  • RUN 在 Docker Build时运行。

Dockerfile脚本编写完毕后我们就可以,运行脚本来创建镜像了,这里也有两种方式可以创建镜像:

  • 直接点击Dockerfile中的运行按钮
  • 在VasDolly文件夹下执行docker的创建镜像指令

第一种方式很简单了,点击按钮等待创建镜像就好了。如果想练习Docker指令,那么切换到VasDolly目录下执行创建镜像的指令即可。注意:注意最后一个参数是上下文路径,由于我们有拷贝文件的操作,所以用点则表示当前文件夹路径。

sudo docker build -t [镜像名]:[镜像TAG] [上下文路径]

#例如
sudo docker build -t vasdolly:0.1 .

然后镜像打包成功后,我们可以用交互式命令运行该镜像,当容器启动后就可以看到控制台输出的VasDolly的帮助信息了。

创建Bugly镜像

该镜像主要用于将mapping符号表上传到bugly后台,其同样要求是基于JDK8版本,官方文档见《Bugly Android 符号表配置》 。那么这次呢我们就使用DockerHub上的openjdk:8-jdk-oracle基础镜像,免去了自行配置JDK环境的烦恼。Dockerfile文件如下,编写完毕后执行创建镜像指令即可,对比上面的真是非常的简单粗暴且好使:

#指定基础镜像
FROM openjdk:8-jdk-oracle

#添加文件到容器中
ADD buglyqq-upload-symbol.jar /home/Bugly/

#运行指令(Bugly没有该指令,运行会出现报错信息,仅仅为验证镜像的正确性)
CMD ["java", "-jar", "/home/Bugly/buglyqq-upload-symbol.jar", "help"]
创建Android打包镜像

Android打包需要JDK、Gradle、Android SDK、NDK(非必须)等工具,所以我们需要将这些东西统统打包进镜像中。

本来使用的基础镜像是ubuntu:20.04,然后自己手动配置上述环境,但是后面发现这种方式比较麻烦,而且镜像体积也比较大,所以后续采用了官方的grale-jdk作为了基础镜像,然后我们只需要配置Android SDK就好了。从官网下载cmdlinetools文件:https://developer.android.google.cn/studio/ 。然后使用sdkmanager安装build-tools以及platforms等文件。注意,需要使用–sdk_root来指定SDK存储的路径。

还需要注意的一个问题就是,Gradle下载依赖后的缓存问题,参考文章:https://zwbetz.com/reuse-the-gradle-dependency-cache-with-docker/ 。官方文章:https://docs.docker.com/develop/develop-images/multistage-build/ 。Docker镜像是一个很纯净的环境,所以每次执行如果不缓存依赖文件,那么每次执行都会重新下载,非常耗费时间。在制作镜像时,我们创建gradle等的缓存目录,然后在Docker中挂载到本地目录。

#指定基础镜像
FROM gradle:6.5.0-jdk11

# 安装需要的组件,解压
RUN apt update && apt install -y zip \
    && apt install -y curl \
    && apt install -y qrencode \
    && apt install -y lftp \
    && mkdir -p /usr/mylib/cmdlinetools \
    && mkdir -p /usr/mylib/androidsdkhome \
    && chmod 777 /usr/mylib/androidsdkhome \
    && mkdir -p /usr/mylib/androidsdkroot \
    && chmod 777 /usr/mylib/androidsdkroot \
    && mkdir -p /usr/mylib/gradlecache \
    && chmod 777 /usr/mylib/gradlecache

# 添加文件到容器中
ADD cmdline-tools.zip /usr/mylib/cmdlinetools

# 配置SDK环境变量
ENV ANDROID_SDK_HOME /usr/mylib/androidsdkhome
ENV PATH $ANDROID_SDK_HOME:$PATH
ENV ANDROID_SDK_ROOT /usr/mylib/androidsdkroot
ENV PATH $ANDROID_SDK_ROOT:$PATH

# 配置Gradle的环境变量,配置缓存路径(如果不进行配置,会在项目的根目录下创建?文件夹,可能导致编译异常)
ENV GRADLE_USER_HOME /usr/mylib/gradlecache
ENV PATH $GRADLE_USER_HOME:$PATH

# Android命令行工具解压,配置环境,否则无法使用sdkmanager命令
WORKDIR /usr/mylib/cmdlinetools
RUN unzip cmdline-tools.zip \
    && chmod 777 cmdline-tools/bin/sdkmanager \
    && rm cmdline-tools.zip
ENV PATH /usr/mylib/cmdlinetools/cmdline-tools/bin:$PATH


# 下载平台工具 (目前platform28,buildtool29)
RUN yes | sdkmanager --sdk_root=/usr/mylib/androidsdkroot "build-tools;29.0.2" \
    && yes | sdkmanager --sdk_root=/usr/mylib/androidsdkroot "platforms;android-28"

#运行指令
CMD ["gradle", "-v"]

上传镜像

如果你想上传到官方的Docker Hub交友网站也是可以的,这里为了减少网络环境的影响,还是直接白嫖了阿里云。

首先我们需要注册一个阿里云账号,记录账号密码,然后开通镜像容器服务(免费的),创建镜像命名空间,准备好后就可以上传我们制作好的镜像了(这里一笔带过了,相信对大家都不是问题,如果具体流程不清楚的可以百度):

#登录阿里云账号,回车后需要输入密码
sudo docker login --username=账号名 registry.cn-hangzhou.aliyuncs.com

#创建TAG
sudo docker tag 镜像ID registry.cn-hangzhou.aliyuncs.com/阿里云镜像命名空间/镜像名:版本号
sudo docker tag 52f503ef1474 registry.cn-hangzhou.aliyuncs.com/vsdragon/vasdolly:0.1

#上传镜像
sudo docker push registry.cn-hangzhou.aliyuncs.com/阿里云镜像命名空间/镜像名:版本号
sudo docker push registry.cn-hangzhou.aliyuncs.com/vsdragon/vasdolly:0.1

相关问题

WSL2下Ubunt无法启动Docker

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?

解决方案:在Windows中以管理员身份运行ubuntu。

Windows下 absolute path问题

Failed to run image ‘xxx’. Error: docker: Error response from daemon: the working directory’C:\Users\xxx.jenkins\workspace\sample’ is invalid, it needs to be an absolute path.
See ‘docker run --help’.

在Windows下运行Jenkins带docker的脚本,报错如上。

参考方案:参考https://github.com/jenkinsci/docker-workflow-plugin/pull/184 ,但是对我来说还没有办法解决,所以采用的是Windows下WSL2的方案。

Gradle缓存位置

image.png
制作Android打包镜像时,如果不指定Gradle的缓存目录,那么在运行Pipeline脚本的时候,Gradle下载的依赖缓存位置则为Jenkins Job下根目录的【?】文件夹中!大部分的依赖可能没有问题,但是有些情况下,读取这个问号就出现了转义的情况:

net.lingala.zip4j.exception.ZipException: java.io.FileNotFoundException: /var/lib/jenkins/workspace/Sample/%3F/.gradle/caches/modules-2/files-2.1/…

问号被转换为了%3F,这时候读取某些依赖就失败了,进而导致项目编译失败。

解决方案:制作镜像的时候务必手动指定下gradle的缓存目录,即配置GRADLE_USER_HOME环境变量,注意不要带特殊符号等,不要给自己找麻烦!!!

引起的其他问题:当进行上述处理后,在后续进行gradle的编译时,因为使用的是Docker,每次都会重新下载缓存,所以我们还需要在pipeline的脚本中指定本机的目录挂载到上述的缓存目录。示例脚本如下:

agent {
  docker {
    image 'registry.cn-hangzhou.aliyuncs.com/vsdragon/android-builder:0.7'
    //挂载本地目录
    args '-v /usr/mylib/gradlecache:/usr/mylib/gradlecache'
  }
}

然后本机目录也要赋予读写权限,否则报错如下:

  • What went wrong: Gradle could not start your build.
    Could not initialize native services.
    Failed to load native library ‘libnative-platform.so’ for Linux amd64.

Ubuntu下Jenkins内容

官方网址 :https://www.jenkins.io/
linux下安装方案: https://www.jenkins.io/doc/book/installing/linux/

安装Java环境

Jenkins需要java的环境,所以需要先安装java:

//安装JDK
sudo apt update
sudo apt install openjdk-8-jre
java -version

//卸载JDK
sudo dpkg --list | grep -i jdk
sudo apt-get purge jdk*
sudo apt-get purge icedtea-* jdk-*

安装Jenkins

官方脚本如果有问题,请使用如下脚本安装:

//导入Jenkins软件源相关
wget -q -O - https://pkg.jenkins.io/debian/jenkins.io.key | sudo apt-key add -

//添加软件源到系统中
sudo sh -c 'echo deb http://pkg.jenkins.io/debian-stable binary/ > /etc/apt/sources.list.d/jenkins.list'

//升级apt软件包列表,并安装最新版本Jenkins
sudo apt update
sudo apt install jenkins

安装完毕后,浏览器打开 localhost:8080,此时会让你输入管理员密码,查看密码:

sudo cat /var/lib/jenkins/secrets/initialAdminPassword

显示的结果就是密码,输入下方即可:

image.png

安装默认的社区插件

第一步执行完毕后安装Jenkins插件步骤,建议直接安装社区插件即可:

image.png

创建Jenkins用户

image.png

实例配置

默认8080端口即可
image.png

配置镜像源

配置国内镜像源,这样下载速度会有一定的提升,先到镜像源站点查看可用的镜像源:http://mirrors.jenkins-ci.org/status.html
在插件管理中 -> 高级 选项页面下,替换升级站点的URL,如下所示:
image.png
换用清华的镜像源:
image.png

安装Docker相关插件

要使用Docker功能,首先Linux上需要安装Docker,然后Jenkins中需要安装相关Docker插件。Docker的安装请上一章节,现在我们需要安装如下Docker插件:
image.png

配置访问Docker的权限

查看本机上的用户,等安装完毕Docker后执行

grep bash /etc/passwd

//例如我机器上的用户如下
//root:x:0:0:root:/root:/bin/bash
//drag:x:1000:1000:,,,:/home/drag:/bin/bash
//jenkins:x:112:119:Jenkins,,,:/var/lib/jenkins:/bin/bash

//查看当前机器上的用户
cat /etc/group

启动服务,如果想要以非root用户执行Docker命令,那么需要将当前用户添加到docker用户组,给其执行docker的权限,该用户组在安装Docker过程中被创建:

#添加docker用户组(安装docker后就会存在,这一步当作验证即可)
sudo groupadd docker

#将当前用户加入到docker用户组中(如果是在jenkins中运行,还要把jenkins用户加入进去)
sudo gpasswd -a $USER docker

#更新用户组
newgrp - docker

#测试当前用户是否可以直接执行docker命令
docker ps    

WebHook处理

这里主要说明下Jenkins项目的“构建触发器”,我们想要达到当提交代码到相关分支上后,能够自动触发项目的构建。所以需要配合GitLab或者Github做一些关联。

如果使用Jenkins自带的构建触发器,如下配置token:
image.png

在GitLab中,找到 “设置”-> “导入所有仓库”,然后配置Jenkins项目地址,后面拼上 /build?token=Jenkins项目中设置的TOKEN,然就点击确认按钮即可。
image.png

这时候我们可以点击刚刚创建的 webhook,点击测试:
image.png
如果没有遇到错误,页面显示成功,然后Jenkins任务也触发并执行了,那么恭喜你没有踩坑。

但是不那么幸运的小伙伴可能就会跟我一样遇到错误如下:
image.png
此时,参照可以参照官方提供的解决方案,地址https://plugins.jenkins.io/build-token-root/#documentation
首先需要在Jenkins中搜索然后安装 **【Build Authorization Token Root Plugin】**插件:
image.png
插件安装完毕后在Jenkins的“系统管理”->“安全”->“全局安全配置”中进行设置如下即可:
image.png
此时按照官方的解决方案,WebHook配置的URL地址也需要进行一丝变动:

//原来是
http://JENKINS_URL/JOB_NAME/build?token=TOKEN

//现在则变为了
http://JENKINS_URL/generic-webhook-trigger/invoke?job=JOB_NAME&&token=TOKEN

配置完新的WebHook地址后,此时测试的话,应该是没有问题了,如果有请Google,注意一定是Google。

相关问题

未安装Docker相关插件导致的错误

/var/jenkins_home/workspace/image-run@tmp/durable-19c2e384/script.sh: 1: docker: not found

解决方案:安装上文所述的相关Docker插件。

Docker 权限的错误

docker inspect -f . registry.cn-hangzhou.aliyuncs.com/vsdragon/vasdolly:0.1
Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get “http://%2Fvar%2Frun%2Fdocker.sock/v1.24/containers/registry.cn-hangzhou.aliyuncs.com/vsdragon/vasdolly:0.1/json”: dial unix /var/run/docker.sock: connect: permission denied

jenkins是由jenkins用户启动执行的,docker是需要以docke用户启动执行的,而当前用户没有执行docker的权限。

解决方案:查看上文,然后将jenkins用户加入docker组。

加固命令相关

加固采用的是360加固的方案,见三六零天御官网。其他方案有apk大小或者其他限制,相比来说360的方案限制稍微小一些(然而现在免费版的已经无法使用该命令行的方式了):

常用命令

//登录
./java/bin/java -jar jiagu.jar -login [账号] [密码]

//设置签名
./java/bin/java -jar jiagu.jar -importsign [keystore文件路径] [keystore密码] [alias] [alias密码]

//加固、重签名
./java/bin/java -jar jiagu.jar -jiagu [源apk的路径] [保存到文件夹的路径] [-autosign(可选)] [-automulpkg(可选)]

--------------------------------------------------

//查看是否签名
./java/bin/keytool -list -printcert -jarfile [apk路径]

360多渠道示例

UMENG_CHANNEL google 1
UMENG_CHANNEL wandoujia 2

一共三列,依次为统计平台、市场名称、渠道编号,中间用空格隔开 ,以下为相关名词说明:

  • 统计平台

统计平台:即Android Name,应用中集成的数据分析sdk的公司名称,例:UMENG_CHANNEL。

  • 市场名称

各大安卓应用分发市场(下拉列表里提供了Top20的市场供选择),以帮助开发者区分不同渠道包特征上传相对应市场。

  • 渠道编号

即Android Value,一般填写相关Channel id。用户可自行定义区分各大市场的关键字,可以是英文、数字、汉字等。

注意事项

  • 必须进入到jiagu文件夹中执行相关命令
  • 必须使用360提供的java命令:./java/bin/java -jar jiagu.jar xxx
  • 使用多渠道文件后会在输出文件夹中得到所有的渠道包,以及一个加固包

但是,目前多渠道打包用的是VasDolly的方案,请查看下文!!!

VasDolly命令相关

目前的多渠道方案为腾讯的VasDolly方案,GitHub地址** **https://github.com/Tencent/VasDolly

常用命令

//通过help查看具体命令
java -jar VasDolly.jar help

//获取指定APK的签名方式
java -jar VasDolly.jar get -s [apkPath]

//获取指定APK的渠道信息
java -jar VasDolly.jar get -c [apkPath]

//删除指定APK的渠道信息
java -jar VasDolly.jar remove -c [apkPath]

//通过指定渠道字符串添加渠道信息
java -jar VasDolly.jar put -c "channel1,channel2" [apkPath] [outputDir]

//通过指定某个渠道字符串添加渠道信息到目标APK
java -jar VasDolly.jar put -c "channel1" [apkPath] [dstApkPath]

//通过指定渠道文件添加渠道信息
java -jar VasDolly.jar put -c [channelTextPath] [apkPath] [outputDir]

--------------------------------------------------

//为基于V1的多渠道打包添加了多线程支持,满足渠道较多的使用场景
java -jar VasDolly.jar put -mtc channel.txt [apkPath] [outputDir]

//提供了FastMode,生成渠道包时不进行强校验,速度可提升10倍以上
java -jar VasDolly.jar put -c channel.txt -f [apkPath] [outputDir]

Bugly命令相关

Bugly也提供了上传mapping文件的工具,官方文档地址《Bugly Android 符号表配置》。

常用命令

java -jar buglyqq-upload-symbol.jar -appid <APP ID>
                                    -appkey<APP KEY>
                                    -bundleid <App BundleID>
                                    -version <App Version>
                                    -platform <App Platform>
                                    -inputSymbol <Original Symbol File Path>
                                    -inputMapping <mapping file>

参数说明

  • **appid **

在Bugly平台产品对应的appid

  • **appkey **

在Bugly平台产品对应的appkey

  • bundleid

Android平台是包名、iOS平台叫bundle id

  • version

App版本号 (PS:注意版本号里不要有特殊字符串,比如( ),不然运行可能会报错)
如果上报包含mapping文件,那么此处的版本号必须和要还原的堆栈所属的app的实际版本号一致,因为一个版本下的App是对应唯一的mapping.txt,不对齐则无法还原对应的堆栈。具体的版本号可以参考bugly.qq.com上堆栈信息。
如果只是上传so或者dsym,那么不要求版本号必须和要还原的堆栈所属的app版本号一样,因为so和dsym还原堆栈的时候是通过模块UUID来匹配的,但是仍然推荐填写一个app的真实版本号。

  • platform

平台类型,当前支持的选项分别是 Android、IOS,注意大小写要正确

  • inputSymbol

原始符号表[dsym、so]所在文件夹目录地址,如果是Android平台同时包含mapping和so,此处输入两个原始符号表存储的共同父目录

  • **inputMapping **

mapping所在文件夹目录地址[Android平台特有,ios忽略]

测试环境pipeline脚本

上述工作全部准备完毕后我们终于可以编写Jenkins的pipeline脚本了:

/**
 * 打包脚本
 */

/**
 * GitLab仓库地址
 */
def GIT_URL = "YOUR_GIT_REPOSTORY_URL"

/**
 * GitLab下载代码的秘钥
 */
def GIT_CREDENTIALS_ID = "YOUR_CRENENTALS_ID"

/**
 * 全局变量内容
 */
class PkgInfo {

    /**
     * App的类型
     */
    static APP_TYPE_MAP = [
            "APP名称1"  : "appFlavor1",
            "APP名称2"  : "appFlavor2",
    ]

    /**
     * 获取支持的App类型数据
     */
    static def getSupportAppList() {
        String str = ""
        for (element in APP_TYPE_MAP) {
            str += "${element.key}\n"
        }
        return str
    }

    /**
     * app的环境
     */
    static APP_ENV_MAP = [
            "测试": "test",
            "生产": "prod",
            "市场": "market",
    ]

    /**
     * 获取支持的环境类型数据
     */
    static def getSupportEnvList() {
        String str = ""
        for (element in APP_ENV_MAP) {
            str += "${element.key}\n"
        }
        return str
    }

    /**
     * 打包成功情况下通知到的群组
     */
    static DING_SUCCESS_MAP = [
            "钉钉群组名称": "钉钉群组中机器人token",
    ]

    /**
     * 获取支持的打包成功通知到的群组数据
     */
    static def getSupportDingSuccessList() {
        String str = ""
        for (element in DING_SUCCESS_MAP) {
            str += "${element.key}\n"
        }
        return str
    }

    /**
     * 打包失败情况下通知到的群组
     */
    static DING_FAILURE_MAP = [
            "钉钉群组名称"  : "钉钉群组中机器人token",
    ]

    /**
     * 获取支持的打包失败通知到的群组数据
     */
    static def getSupportDingFailureList() {
        String str = ""
        for (element in DING_FAILURE_MAP) {
            str += "${element.key}\n"
        }
        return str
    }

    /**
     * Apk文件的输出目录
     */
    static APK_OUTPUT_DIR = "app/build/myApks/"

    /**
     * 获取当前App的Flavor
     */
    static def getFlavorName(String appKey) {
        return APP_TYPE_MAP.get(appKey)
    }

    /**
     * 获取当前App的Flavor
     */
    static def getEnvName(String envKey) {
        return APP_ENV_MAP.get(envKey)
    }

    /**
     * 获取运行成功通知到的群组
     */
    static def getDingSuccessToken(String key) {
        return DING_SUCCESS_MAP.get(key)
    }

    /**
     * 获取运行失败通知到的群组
     */
    static def getDingFailureToken(String key) {
        return DING_FAILURE_MAP.get(key)
    }

    /**
     * 获取打包的gradle脚本
     */
    static def getAssembleCmd(String appKey, String envKey) {
        def flavor = getFlavorName(appKey)
        def env = getEnvName(envKey)
        return "gradle --no-daemon clean app:assemble${firstCharToUpperCase(flavor)}${firstCharToUpperCase(env)}Release"
    }

    /**
     * 将字符串的首字母大写
     */
    static def firstCharToUpperCase(String str) {
        def firstStr = str.charAt(0).toString().toUpperCase()
        def otherStr = str.substring(1, str.length())
        return "${firstStr}${otherStr}"
    }

    /**
     * 是否需要上传mapping文件到服务器
     */
    static def needUploadMappingToServer(String envName) {
        return envName == "market" || envName == "prod"
    }

    /**
     * 是否需要上传mapping文件到bugly
     */
    static def needUploadMappingToBugly(String appKey, String envKey) {
        def flavor = getFlavorName(appKey)
        def env = getEnvName(envKey)

        return flavor == "psd" && (env == "prod" || env == "market")
    }

    /**
     * 获取mapping文件的路径
     */
    static def getMappingDir(String flavorName, String envName) {
        return "app/build/outputs/mapping/${flavorName}${firstCharToUpperCase(envName)}Release"
    }
}

/**
 * 返回App的基本信息
 * info[0] app名(同Flavor)
 * info[1] app版本名
 * info[2] app版本号
 */
static def getAppInfo(def script, def flavorName) {
    return script.readFile("app/build/myApksInfo/${flavorName}.txt").readLines()
}

/**
 * 获取当前的格式化时间
 */
static def getCurrentTime(def script) {
    return script.sh(script: "echo `date '+%Y_%m%d_%H%M'`", returnStdout: true).trim()
}

/**
 * 上传文件
 */
static def upload(def script, String flavorName, String envName) {
    def appInfo = getAppInfo(script, "${flavorName}")
    def sourceApkDir = PkgInfo.APK_OUTPUT_DIR
    script.echo "当前app的信息:${appInfo}"

    def versionCode = appInfo[2]

    //要上传到的服务器文件夹的地址 (根目录在psd-android文件夹下)
    def time = getCurrentTime(script)
    def uploadApkDir = "${envName}/${versionCode}/${flavorName}/${time}"
    script.println("要上传到的文件夹目录:${uploadApkDir}")

    //存储到的文件夹网址
    def dirUrl = "http://内网地址:内网端口/${uploadApkDir}"

    //获取apk名称
    def apkName = script.sh(returnStdout: true, script: "ls -1 ${sourceApkDir}").split()[0]
    def qrName = "qr.png"
    script.println("当前apk的名字:${apkName}")

    //制作apk文件的二维码,存储到输出的apk目录中
    def apkUrl = "${dirUrl}/${apkName}"
    def qrUrl = "${dirUrl}/${qrName}"
    script.sh "qrencode -o ${sourceApkDir}${qrName} '${apkUrl}'"

    uploadApksToServer(script,
            "${sourceApkDir}",
            "${uploadApkDir}"
    )

    //正式环境和市场环境都上传mapping文件到服务器
    if (PkgInfo.needUploadMappingToServer(envName)) {
        def uploadMappingDir = "${uploadApkDir}/mapping"
        def sourceMappingDir = PkgInfo.getMappingDir(flavorName, envName)

        uploadMappingToServer(
                script,
                "$sourceMappingDir",
                "$uploadMappingDir"
        )
    }

    return [apkUrl, qrUrl]
}

/**
 * 上传apk文件以及二维码图片到服务器
 */
static def uploadApksToServer(def script,
                              def sourceApkDir,
                              def uploadApkDir) {
    script.sh "cd $sourceApkDir && lftp -u 账户名,账户密码 内网地址 -e \"cd androidApks; mkdir -p $uploadApkDir; cd $uploadApkDir; mput *; exit\""
}
/**
 * 上传mapping文件夹到服务器
 */
static def uploadMappingToServer(def script,
                                 def sourceMappingDir,
                                 def uploadMappingDir) {
    script.sh "cd $sourceMappingDir && lftp -u 账户名,账户密码 内网地址 -e \"cd androidApks; mkdir -p $uploadMappingDir; cd $uploadMappingDir; mput *.txt; exit\""
}

/**
 * 上传mapping文件到bugly
 */
static def uploadMappingToBugly(def script, def versionName, def sourceMappingDir) {
    //去除字符串中的v字,只保留类似 1.2.3 字样
    def realVersionName = versionName.replace("v", "")

    script.sh "java -jar /home/bugly/buglyqq-upload-symbol-334.jar" +
            " -appid 你的APPID" +
            " -appkey 你的APPKEY" +
            " -bundleid 包名" +
            " -version ${realVersionName}" +
            " -platform Android" +
            " -inputMapping ${sourceMappingDir}"
}

/**
 * 获取git提交日志信息
 */
static def getGitLogs(def script) {

    def gitLogCount = 5

    /**
     * |sed 's/\"//g'
     * 该命令表示去除字符串中的双引号,如果不去除引号的话会导致发送钉钉脚本语法错乱
     */
    script.sh "git log --no-merges --pretty=format:\"%cn: %s\" -${gitLogCount} | sed 's/\\\"//g' > log.txt"

    def gitLogs = ""
    def lines = script.readFile("./log.txt").readLines()
    for (line in lines) {
        gitLogs = gitLogs + "\n- " + line.trim()
    }
    return gitLogs
}

/**
 * 发送钉钉成功消息
 * @param script
 * @return
 */
static def sendDingSuccessMessage(def script, String flavorKey, String envKey, String dingSuccessKey, def urls, def showGitLog) {

    def flavorName = PkgInfo.getFlavorName(flavorKey)

    def appInfo = getAppInfo(script, flavorName)
    script.echo "当前app的信息:${appInfo}"

    def versionName = appInfo[1]
    def versionCode = appInfo[2]


    if (showGitLog) {

        def logs = getGitLogs(script)

        script.sh "curl 'https://oapi.dingtalk.com/robot/send?access_token=${PkgInfo.getDingSuccessToken(dingSuccessKey)}'" +
                " -H 'Content-Type: application/json'" +
                " -d '{" +
                "\"msgtype\": \"markdown\"," +
                "\"markdown\": {" +
                "\"title\":\"打包成功的通知\"," +
                "\"text\":" +
                "\"" +
                "## ${envKey}包:${flavorName}_${versionCode}_${versionName}" +
                "\n-----" +
                "\n**注意**:仅支持内网环境" +
                "\n- [历史APK目录](http://内网地址:内网端口)" +
                "\n- [点击下载APK](${urls[0]})" +
                "\n- [点击显示二维码](${urls[1]})" +
                "\n-----" +
                "\n**更新日志**" +
                "\n${logs}" +
                "\"" +
                "}}'"
    } else {
        script.sh "curl 'https://oapi.dingtalk.com/robot/send?access_token=${PkgInfo.getDingSuccessToken(dingSuccessKey)}'" +
                " -H 'Content-Type: application/json'" +
                " -d '{" +
                "\"msgtype\": \"markdown\"," +
                "\"markdown\": {" +
                "\"title\":\"打包成功的通知\"," +
                "\"text\":" +
                "\"" +
                "## ${envKey}包:${flavorName}_${versionCode}_${versionName}" +
                "\n-----" +
                "\n注意:仅支持内网环境" +
                "\n- [历史APK目录](http://内网地址:内网端口)" +
                "\n- [点击下载APK](${urls[0]})" +
                "\n- [点击显示二维码](${urls[1]})" +
                "\"" +
                "}}'"
    }
}

/**
 * 发送钉钉失败消息
 */
static def sendDingFailureMessage(def script, String dingFailureKey) {
    script.sh "curl 'https://oapi.dingtalk.com/robot/send?access_token=${PkgInfo.getDingFailureToken(dingFailureKey)}'" +
            " -H 'Content-Type: application/json'" +
            " -d '{\"at\":{\"atMobiles\":[\"15757126424\"]},\"markdown\":{\"title\":\"打包失败通知\",\"text\":\"### 打包失败辣,快来人处理! \\n@被艾特人手机号\"},\"msgtype\":\"markdown\"}'"
}

pipeline {
    agent none

    parameters {
        string name: 'PARAM_GIT_BRANCH', defaultValue: 'auto_pkg_test', description: '输入Git分支,默认如上', trim: true
        choice name: 'PARAM_APP_TYPE', choices: "${PkgInfo.getSupportAppList()}", description: '选择App的类型,默认如上'
        choice name: 'PARAM_APP_ENV', choices: "${PkgInfo.getSupportEnvList()}", description: '选择App的环境,默认如上'
        choice name: 'PARAM_DING_SUCCESS', choices: "${PkgInfo.getSupportDingSuccessList()}", description: '选择运行成功通知到的群,默认如上'
        choice name: 'PARAM_DING_FAILURE', choices: "${PkgInfo.getSupportDingFailureList()}", description: '选择运行失败通知到的群,默认如上'
        booleanParam name: 'PARAM_SHOW_GIT_LOG', defaultValue: false, description: '是否打印Git提交日志,默认false'
    }

    stages {

        stage('Package') {

            agent {
                docker {
                    image 'registry.cn-hangzhou.aliyuncs.com/vsdragon/android-builder:1.1'
                    //做一下Gradle缓存目录的挂载
                    args '-v /usr/mylib/gradlecache:/usr/mylib/gradlecache'
                }
            }

            steps {
                echo "==================================================>>Stage_1"

                echo "==================================================>>下载源码"
                git branch: "$PARAM_GIT_BRANCH", credentialsId: "${GIT_CREDENTIALS_ID}", url: "${GIT_URL}"

                script {
                    echo "==================================================>>开始打包"
                    sh PkgInfo.getAssembleCmd("$PARAM_APP_TYPE", "$PARAM_APP_ENV")


                    echo "==================================================>>上传APK"
                    def urls = upload(this,
                            PkgInfo.getFlavorName("$PARAM_APP_TYPE"),
                            PkgInfo.getEnvName("$PARAM_APP_ENV")
                    )

                    echo "==================================================>>发送群通知"
                    sendDingSuccessMessage(this,
                            "$PARAM_APP_TYPE",
                            "$PARAM_APP_ENV",
                            "$PARAM_DING_SUCCESS",
                            urls,
                            Boolean.valueOf("$PARAM_SHOW_GIT_LOG"))
                }
            }

            post {
                failure {
                    script {
                        sendDingFailureMessage(this, "$PARAM_DING_FAILURE")
                    }
                }
            }
        }

        /**
         * 上传APK以及Mapping文件
         * 注意:bugly:0.3版本带lftp命令
         */
        stage("Upload To Bugly") {
            agent {
                docker {
                    image 'registry.cn-hangzhou.aliyuncs.com/vsdragon/bugly:0.4'
                }
            }

            steps {
                script {
                    echo "==================================================>>Stage2"
                    if (PkgInfo.needUploadMappingToBugly("$PARAM_APP_TYPE", "$PARAM_APP_ENV")) {
                        echo "==================================================>>上传mapping文件到bugly"
                        def flavorName = PkgInfo.getFlavorName("$PARAM_APP_TYPE")
                        def envName = PkgInfo.getEnvName("$PARAM_APP_ENV")

                        def appInfo = getAppInfo(this, flavorName)
                        def versionName = appInfo[1]

                        def sourceMappingDir = PkgInfo.getMappingDir(flavorName, envName)

                        uploadMappingToBugly(this, versionName, sourceMappingDir)
                    }
                }
            }

            post {
                failure {
                    script {
                        sendDingFailureMessage(this, "$PARAM_DING_FAILURE")
                    }
                }
            }
        }
    }
}

以上代码是后来更改过的脚本了,采用了参数化构建的方式,允许选择App的类型,环境等进行打包。

总结

目前来说带加固那一套的脚本已经失效了,现在能做到的就是打包、存储、上传apk及mapping文件的功能了,多渠道包的功能也从中剥离了。但整体的思路都在上文基本表述出来了,如果有需要可以按照文章进行尝试。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值