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("../keystore/keystore.jks") keyAlias "xxx" keyPassword "xxx" storePassword "xxx" } } buildTypes { release { debuggable true minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' signingConfig signingConfigs.release applicationVariants.all { variant -> if (variant.buildType.name.equals('release')) { variant.outputs.each { output -> def outputFile = output.outputFile if (outputFile != null && outputFile.name.endsWith('.apk')) { def fileName = "${defaultConfig.applicationId}_${defaultConfig.versionName}_${releaseTime()}.apk" output.outputFile = new File(outputFile.parent, fileName) } } } } } } lintOptions { abortOnError false } } 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; } FileInfo info = new FileInfo(); info.setFileName(file.getMetaData().get("fileName").toString()); ByteArrayOutputStream bos = new ByteArrayOutputStream(); try { file.writeTo(bos); info.setContent(bos.toByteArray()); bos.close(); } catch (IOException e) { e.printStackTrace(); } return 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"); } if (!appinfo.getAppKey().equals(appKey)) { return error("appKey检验失败!"); } if (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); return successData(appItem); } return error("上传失败"); } /** * 存储文件 * * @param file 文件对象 * @param appItem appitem对象 * @return 上传成功与否 */ private boolean saveUploadFile(@RequestParam("file") MultipartFile file, AppItem appItem) { String fileName = file.getOriginalFilename(); logger.info("上传的文件名为:" + fileName); String fileId = null; try { fileId = gridFSService.saveFile(fileName, file.getBytes()); appItem.setFileId(fileId); appItem.setUrl("/api/app/download/" + fileId); appItem.setFileSize((int) file.getSize()); appItem.setCreated(System.currentTimeMillis()); appItem.setDownloadCount(0); if (fileName.endsWith(".apk")) { readVersionFromApk(file, appItem); } return true; } catch (IOException e) { logger.error(e.getMessage(),e); } return false; }

因为我们是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()); // 读取icon byte[] iconData = apkFile.getFileData(apkMeta.getIcon()); BASE64Encoder encoder = new BASE64Encoder(); String icon = "data:image/png;base64,"+encoder.encode(iconData); appItem.setIcon(icon); apkFile.close(); new File(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; private String envVarsPath; Build build; @DataBoundConstructor public UploadPublisher(String appid, String appKey, String scanDir, String wildcard, String updateDescription, String envVarsPath) { this.appid = appid; this.appKey = appKey; this.scanDir = scanDir; this.wildcard = wildcard; this.updateDescription = updateDescription; this.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<AppItem> appItems = appItemRepository.findByAppIdOrderByCreatedDesc(appId,new PageableQueryArgs()); AppItem current = appItems.getContent().get(0); model.put("items",appItems.getContent()); model.put("currentItem",current); return "app"; }

延伸阅读#

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


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

关注作者

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

posted @   JadePeng  阅读(4235)  评论(0编辑  收藏  举报
编辑推荐:
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
点击右上角即可分享
微信分享提示
CONTENTS