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的技術記事本
您的支持是對博主最大的鼓勵,感謝您的認真閱讀。
本文版權歸作者所有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接,否則保留追究法律責任的權利。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容