Docker+Jenkins持续集成环境(5): android构建与apk发布

项目组除了常规的 java 项目,还有不少 android 项目,如何使用 jenkins 来实现自动构建呢?本文会介绍安卓项目通过 jenkins 构建的方法,并设计开发一个类似蒲公英的 app 托管平台。

android 构建#

安装 android sdk:#

  • 先下载 sdk tools
  • 然后使用 sdkmanager 安装:
Copy
./sdkmanager "platforms;android-21" "platforms;android-22" "platforms;android-23" "platforms;android-24" "platforms;android-25" "build-tools;27.0.3" "build-tools;27.0.2" "build-tools;27.0.1" "build-tools;27.0.0" "build-tools;26.0.3" "build-tools;26.0.2" "build-tools;26.0.1" "build-tools;25.0.3" "platforms;android-26"

然后把把 sdk 拷贝到 volume 所在的目录。

jenkins 配置#

jenkins 需要安装 gradle 插件,构建的时候选择 gradle 构建,选择对应的版本即可。

enter description here

构建也比较简单,输入 clean build 即可。

android 签名#

修改 build 文件

Copy
android {
signingConfigs {
    release {
        storeFile file(<span class="hljs-string">"../keystore/keystore.jks"</span>)
        keyAlias <span class="hljs-string">"xxx"</span>
        keyPassword <span class="hljs-string">"xxx"</span>
        storePassword <span class="hljs-string">"xxx"</span>
    }
}

buildTypes {
    release {
        debuggable <span class="hljs-literal">true</span>
        minifyEnabled <span class="hljs-literal">false</span>
        proguardFiles getDefaultProguardFile(<span class="hljs-string">'proguard-android.txt'</span>), <span class="hljs-string">'proguard-rules.pro'</span>
        signingConfig signingConfigs.release
        applicationVariants.all { variant -&gt;
            <span class="hljs-keyword">if</span> (variant.buildType.name.equals(<span class="hljs-string">'release'</span>)) {
                variant.outputs.each {
                    output -&gt;
                        <span class="hljs-keyword">def</span> outputFile = output.outputFile
                        <span class="hljs-keyword">if</span> (outputFile != <span class="hljs-literal">null</span> &amp;&amp; outputFile.name.endsWith(<span class="hljs-string">'.apk'</span>)) {
                            <span class="hljs-keyword">def</span> fileName = <span class="hljs-string">"${defaultConfig.applicationId}_${defaultConfig.versionName}_${releaseTime()}.apk"</span>
                            output.outputFile = <span class="hljs-keyword">new</span> File(outputFile.parent, fileName)
                        }
                }
            }
        }
    }
}
lintOptions {
    abortOnError <span class="hljs-literal">false</span>
}

}

def releaseTime() {
new Date().format("yyyyMMdd_HH_mm_ss", TimeZone.getTimeZone("Asia/Chongqing"))
}

构建时自动生成版本号#

android 的版本号分为 version Nubmer 和 version Name,我们可以把版本定义为
versionMajor.versionMinor.versionBuildNumber,其中 versionMajor 和 versionMinor 自己定义,versionBuildNumber 可以从环境变量获取。

Copy
ext.versionMajor = 1 ext.versionMinor = 0

android {
defaultConfig {
compileSdkVersion rootProject.ext.compileSdkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
applicationId "com.xxxx.xxxx"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionName computeVersionName()
versionCode computeVersionCode()
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
}

// Will return "1.0.42"
def computeVersionName() {
// Basic <major>.<minor> version name
return String.format('%d.%d.%d', versionMajor, versionMinor,Integer.valueOf(System.env.BUILD_NUMBER ?: 0))
}

// Will return 100042 for Jenkins build #42
def computeVersionCode() {
// Major + minor + Jenkins build number (where available)
return (versionMajor * 100000)
+ (versionMinor * 10000)
+ Integer.valueOf(System.env.BUILD_NUMBER ?: 0)
}

apk 发布#

解决方案分析#

jenkins 构建的 apk 能自动发布吗?
国内已经有了 fir.im,pgyer 蒲公英等第三方的内测应用发布管理平台,对于小团队,注册使用即可。但是使用这类平台:

  • 需要实名认证,非常麻烦
  • 内部有些应用放上面不合适

如果只是简单的 apk 托管,功能并不复杂,无非是提供一个 http 接口提供上传,我们可以自己快速搭建一个,称之为 apphosting。

大体的流程应该是这样的:

  • 开发人员 commit 代码到 SVN
  • jenkins 从 svn polling,如果有更新,jenkins 启动自动构建
  • jenkins 先 gradle build,然后 apk 签名
  • jenkins 将 apk 上传到 apphosting
  • jenkins 发送成功邮件,通知开发人员
  • 开发人员从 apphosting 获取最新的 apk

enter description here

apphosting 服务设计#

首先,分析领域模型,两个核心对象,APP 和 app 版本,其中 app 存储 appid、appKey 用来唯一标识一个 app,app 版本存储该 app 的每次 build 的结果。

enter description here

再来分析下,apphosting 系统的上下文

enter description here

然后 apphosting 简单划分下模块:

enter description here

我们需要开发一个 apphosting,包含 web 和 api,数据库采用 mongdb,文件存储采用 mongdb 的 grid fs。除此外,需要开发一个 jenkins 插件,上传 apk 到 apphosting。

文件存储#

文件可以存储到 mongodb 或者分布式文件系统里,这里内部测试使用 mongdb gridfs 即可,在 spring boot 里,可以使用 GridFsTemplate 来存储文件:

Copy
/** * 存储文件到 GridFs * @param fileName * @param mediaContent * @return fileid 文件 id */ public String saveFile(String fileName,byte[] mediaContent){ DBObject metaData = new BasicDBObject(); metaData.put("fileName", fileName); InputStream inputStream = new ByteArrayInputStream(mediaContent); GridFSFile file = gridFsTemplate.store(inputStream, metaData); try { inputStream.close(); } catch (IOException e) { e.printStackTrace(); } return file.getId().toString(); }

存储文件成功的话会发挥一个 fileid,通过这个 id 可以从 gridfs 获取文件。

Copy
/** * 读取文件 * @param fileid * @return */ public FileInfo getFile(String fileid){ GridFSDBFile file = gridFsTemplate.findOne(new Query(Criteria.where("_id").is(fileid))); if(file==null){ return null; }
    <span class="hljs-type">FileInfo</span> <span class="hljs-variable">info</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">FileInfo</span>();
    info.setFileName(file.getMetaData().get(<span class="hljs-string">"fileName"</span>).toString());
    <span class="hljs-type">ByteArrayOutputStream</span> <span class="hljs-variable">bos</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">ByteArrayOutputStream</span>();
    <span class="hljs-keyword">try</span> {
        file.writeTo(bos);
        info.setContent(bos.toByteArray());
        bos.close();
    } <span class="hljs-keyword">catch</span> (IOException e) {
        e.printStackTrace();
    }

    <span class="hljs-keyword">return</span> info;
}

APK 上传接口#

处理上传使用 MultipartFile,双穿接口需要检验下 appid 和 appKey,上传成功会直接返回 AppItem apk 版本信息。

Copy
@RequestMapping(value = {"/api/app/upload/{appId}"}, produces = MediaType.APPLICATION_JSON_UTF8_VALUE, method = {RequestMethod.POST}) @ResponseBody public String upload(@PathVariable("appId") String appId, String appKey, AppItem appItem, @RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return error("文件为空"); } appItem.setAppId(appId); AppInfo appinfo = appRepository.findByAppId(appItem.getAppId()); if (appinfo == null) { return error("无效 appid"); }
    <span class="hljs-keyword">if</span> (!appinfo.getAppKey().equals(appKey)) {
        <span class="hljs-keyword">return</span> error(<span class="hljs-string">"appKey检验失败!"</span>);
    }

    <span class="hljs-keyword">if</span> (saveUploadFile(file, appItem)) {
        appItem.setCreated(System.currentTimeMillis());
        appItemRepository.save(appItem);

        appinfo.setAppIcon(appItem.getIcon());
        appinfo.setAppUpdated(System.currentTimeMillis());
        appinfo.setAppDevVersion(appItem.getVesion());
        appRepository.save(appinfo);

        <span class="hljs-keyword">return</span> successData(appItem);
    }

    <span class="hljs-keyword">return</span> error(<span class="hljs-string">"上传失败"</span>);
}

/**
* 存储文件
*
* @param file 文件对象
* @param appItem appitem 对象
* @return 上传成功与否
*/

private boolean saveUploadFile(@RequestParam("file") MultipartFile file, AppItem appItem) {
String fileName = file.getOriginalFilename();
logger.info("上传的文件名为:" + fileName);

    <span class="hljs-type">String</span> <span class="hljs-variable">fileId</span> <span class="hljs-operator">=</span> <span class="hljs-literal">null</span>;
    <span class="hljs-keyword">try</span> {
        fileId = gridFSService.saveFile(fileName, file.getBytes());

        appItem.setFileId(fileId);
        appItem.setUrl(<span class="hljs-string">"/api/app/download/"</span> + fileId);
        appItem.setFileSize((<span class="hljs-type">int</span>) file.getSize());
        appItem.setCreated(System.currentTimeMillis());
        appItem.setDownloadCount(<span class="hljs-number">0</span>);

        <span class="hljs-keyword">if</span> (fileName.endsWith(<span class="hljs-string">".apk"</span>)) {
            readVersionFromApk(file, appItem);
        }

        <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
    } <span class="hljs-keyword">catch</span> (IOException e) {
        logger.error(e.getMessage(),e);
    }

    <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;
}

因为我们是 apk,apphosting 需要知道 apk 的版本、图标等数据,这里可以借助 apk.parser 库。先把文件保存到临时目录,然后使用 apkFile 类解析。注意这里把 icon 读取出来后,直接转换为 base64 的图片。

Copy
/** * 读取 APK 版本号、icon 等数据 * * @param file * @param appItem * @throws IOException */ private void readVersionFromApk(@RequestParam("file") MultipartFile file, AppItem appItem) throws IOException { // apk 读取 String tempFile = System.getProperty("java.io.tmpdir") +File.separator + System.currentTimeMillis() + ".apk"; file.transferTo(new File(tempFile)); ApkFile apkFile = new ApkFile(tempFile); ApkMeta apkMeta = apkFile.getApkMeta(); appItem.setVesion(apkMeta.getVersionName());
    <span class="hljs-comment">// 读取icon</span>
    <span class="hljs-type">byte</span>[] iconData =  apkFile.getFileData(apkMeta.getIcon());
    <span class="hljs-type">BASE64Encoder</span> <span class="hljs-variable">encoder</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">BASE64Encoder</span>();
    <span class="hljs-type">String</span> <span class="hljs-variable">icon</span> <span class="hljs-operator">=</span> <span class="hljs-string">"data:image/png;base64,"</span>+encoder.encode(iconData);
    appItem.setIcon(icon);
    apkFile.close();
    <span class="hljs-keyword">new</span> <span class="hljs-title class_">File</span>(tempFile).delete();
}

jenkins 上传插件#

jenkins 插件开发又是另外一个话题,这里不赘述,大概讲下:

  • 继承 Recorder 并实现 SimpleBuildStep,实现发布插件
  • 定义 jelly 模板,让用户输入 appid 和 appkey 等参数
Copy
<?jelly escape-by-default='true'?> <j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form">

<f:entry title="appid" field="appid">
<f:textbox />
</f:entry>

<f:entry title="appKey" field="appKey">
<f:password />
</f:entry>

<f:entry title="扫描目录" field="scanDir">
<f:textbox default="$${WORKSPACE}"/>
</f:entry>

<f:entry title="文件通配符" field="wildcard">
<f:textbox />
</f:entry>

<f:advanced>
<f:entry title="updateDescription(optional)" field="updateDescription">
<f:textarea default="自动构建"/>
</f:entry>
</f:advanced>

</j:jelly>

  • 在 UploadPublisher 定义 jelly 里定义的参数,实现绑定
Copy
private String appid; private String appKey; private String scanDir; private String wildcard; private String updateDescription;
<span class="hljs-keyword">private</span> String envVarsPath;

Build build;

<span class="hljs-meta">@DataBoundConstructor</span>
<span class="hljs-keyword">public</span> <span class="hljs-title function_">UploadPublisher</span><span class="hljs-params">(String appid, String appKey, String scanDir, String wildcard, String updateDescription,  String envVarsPath)</span> {
    <span class="hljs-built_in">this</span>.appid = appid;
    <span class="hljs-built_in">this</span>.appKey = appKey;
    <span class="hljs-built_in">this</span>.scanDir = scanDir;
    <span class="hljs-built_in">this</span>.wildcard = wildcard;
    <span class="hljs-built_in">this</span>.updateDescription = updateDescription;
    <span class="hljs-built_in">this</span>.envVarsPath = envVarsPath;
}

  • 然后在 perfom 里执行上传,先扫描到 apk,再上传
Copy
Document document = Jsoup.connect(UPLOAD_URL +"/" + uploadBean.getAppId()) .ignoreContentType(true) .data("appId", uploadBean.getAppId()) .data("appKey", uploadBean.getAppKey()) .data("env", uploadBean.getEnv()) .data("buildDescription", uploadBean.getUpdateDescription()) .data("buildNo","build #"+ uploadBean.getBuildNumber()) .data("file", uploadFile.getName(), fis) .post();

插件开发好后,编译打包,然后上传到 jenkins,最后在 jenkins 项目里构建后操作里,选择我们开发好的插件:

enter description here

apphosting web#

仿造蒲公英,编写一个 app 展示页面即可,参见下图:

enter description here
还可以将历史版本返回,可以看到我们的版本号每次构建会自动变化:

enter description here

Copy
@GetMapping("/app/{appId}") public String appInfo(@PathVariable("appId") String appId, Map<String, Object> model) { model.put("app", appRepository.findByAppId(appId));
    Page&lt;AppItem&gt; appItems = appItemRepository.findByAppIdOrderByCreatedDesc(appId,<span class="hljs-keyword">new</span> <span class="hljs-title class_">PageableQueryArgs</span>());
    <span class="hljs-type">AppItem</span> <span class="hljs-variable">current</span>  <span class="hljs-operator">=</span> appItems.getContent().get(<span class="hljs-number">0</span>);
    model.put(<span class="hljs-string">"items"</span>,appItems.getContent());
    model.put(<span class="hljs-string">"currentItem"</span>,current);

    <span class="hljs-keyword">return</span> <span class="hljs-string">"app"</span>;
}

延伸阅读#

Jenkins+Docker 搭建持续集成环境:


作者:Jadepeng
出处:jqpeng 的技术记事本 --http://www.cnblogs.com/xiaoqi
您的支持是对博主最大的鼓励,感谢您的认真阅读。
本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

关注作者

欢迎关注作者微信公众号, 一起交流软件开发:欢迎关注作者微信公众号