项目组除了常规的 java 项目, 还有不少 android 项目, 如何使用 jenkins 来实现自动构建呢? 本文会介绍安卓项目通过 jenkins 构建的方法, 并设计开发一个类似蒲公英的 app 托管平台
android 构建
安装 android sdk:
先下载 sdk tools
然后使用 sdkmanager 安装:
. / 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 构建, 选择对应的版本即可
构建也比较简单, 输入 clean build 即可
android 签名
修改 build 文件
- 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 可以从环境变量获取
- 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
apphosting 服务设计
首先, 分析领域模型, 两个核心对象, APP 和 app 版本, 其中 app 存储 appidappKey 用来唯一标识一个 app,app 版本存储该 app 的每次 build 的结果
再来分析下, apphosting 系统的上下文
然后 apphosting 简单划分下模块:
我们需要开发一个 apphosting, 包含 web 和 api, 数据库采用 mongdb, 文件存储采用 mongdb 的 grid fs 除此外, 需要开发一个 jenkins 插件, 上传 apk 到 apphosting
文件存储
文件可以存储到 mongodb 或者分布式文件系统里, 这里内部测试使用 mongdb gridfs 即可, 在 spring boot 里, 可以使用 GridFsTemplate 来存储文件:
- /**
- * 存储文件到 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 获取文件
- /**
- * 读取文件
- * @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 版本信息
- @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 的图片
- /**
- * 读取 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 等参数
- <?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 里定义的参数, 实现绑定
- 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, 再上传
- 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 项目里构建后操作里, 选择我们开发好的插件:
apphosting web
仿造蒲公英, 编写一个 app 展示页面即可, 参见下图:
还可以将历史版本返回, 可以看到我们的版本号每次构建会自动变化:
- @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";
- }
来源: http://www.bubuko.com/infodetail-2490694.html