美文网首页持续集成Docker容器Android开发
Docker+Jenkins持续集成环境(5): android

Docker+Jenkins持续集成环境(5): android

作者: JadePeng | 来源:发表于2018-02-08 10:53 被阅读75次

    项目组除了常规的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 hereenter 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 hereenter description here

    apphosting 服务设计

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

    enter description hereenter description here

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

    enter description hereenter description here

    然后apphosting简单划分下模块:

    enter description hereenter 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 hereenter description here

    apphosting web

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

    enter description hereenter description here

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

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

    相关文章

      网友评论

        本文标题:Docker+Jenkins持续集成环境(5): android

        本文链接:https://www.haomeiwen.com/subject/hbazzxtx.html