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

项目组除了常规的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构建,选择对应的版本即可。

enter description here
enter description here

构建也比较简单,输入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
enter description here
enter description here

apphosting 服务设计

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

enter description here
enter description here

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

enter description here
enter description here

然后apphosting简单划分下模块:

enter description here
enter description here

我们需要开发一个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项目里构建后操作里,选择我们开发好的插件:

enter description here
enter description here

apphosting web

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

enter description here
enter description here

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

enter description here
enter description here
    @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的技术记事本
您的支持是对博主最大的鼓励,感谢您的认真阅读。
本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容