使用 Gradle 實現一套程式碼開發多個應用

劉俊發表於2017-07-11

在文章 使用 Gradle 對應用進行個性化定製 中,我們能夠針對一個應用的正式服、測試服、超管服等其他版本,進行個性化定製。
這一篇文章我們來點大動作,讓你用一套程式碼構建多個應用。

場景介紹

需求:“將某個應用換一套皮膚、第三方賬號、後臺伺服器,改個名字上線,並且以後的新功能同步進行更新”。

當你遇到這樣的需求會怎麼做呢?

是將專案複製一份,然後修改其中的內容,有新功能的時候再手動複製過來稍微修改一下 UI?

或者可以切換一個分支,在這個分支上修改相關的資訊,每次開發完新功能,將程式碼合併過來,再稍微修改新功能的 UI?

現在我來介紹使用 GradleflavorDimensions,實現一份程式碼構建多個應用。

具體實現

老規矩,先上完整的 Gradle 配置:

android {
    compileSdkVersion 25
    buildToolsVersion "25.0.3"
    defaultConfig {
        minSdkVersion 16
        targetSdkVersion 25
        versionCode gitVersionCode()
    }

    // 配置兩個應用的簽名檔案
    signingConfigs {
        app1 {
            storeFile file("app1.jks")
            storePassword "111111"
            keyAlias "app1"
            keyPassword "111111"
        }

        app2 {
            storeFile file("app2.jks")
            storePassword "111111"
            keyAlias "app2"
            keyPassword "111111"
        }
    }

    buildTypes {
        release {
            // 不顯示Log
            buildConfigField "boolean", "LOG_DEBUG", "false"
        }

        debug {
            // 顯示Log
            buildConfigField "boolean", "LOG_DEBUG", "true"
            versionNameSuffix "-debug"
            signingConfig null
            manifestPlaceholders.UMENG_CHANNEL_VALUE = "test"
        }
    }

    //建立兩個維度的 flavor
    flavorDimensions "APP", "SERVER"

    productFlavors {

        app1 {
            dimension "APP"
            applicationId 'com.imliujun.app1'

            versionName rootProject.ext.APP1_versionName

            //應用名
            resValue "string", "app_name", "APP1"

            buildConfigField("String", "versionNumber", "\"${rootProject.ext.APP1_versionName}\"")

            //第三方SDK的一些配置
            buildConfigField "int", "IM_APPID", "app1的騰訊IM APPID"
            buildConfigField "String", "IM_ACCOUNTTYPE", "\"app1的騰訊IM accountype\""
            manifestPlaceholders = [UMENG_APP_KEY      : "app1的友盟 APP KEY",
                                    UMENG_CHANNEL_VALUE: "app1預設的渠道名",
                                    XG_ACCESS_ID       : "app1信鴿推送ACCESS_ID",
                                    XG_ACCESS_KEY      : "app1信鴿推送ACCESS_KEY",
                                    QQ_APP_ID          : "app1的QQ_APP_ID",
                                    AMAP_KEY           : "app1的高德地圖key",
                                    APPLICATIONID      : applicationId]
            //簽名檔案
            signingConfig signingConfigs.app1
        }

        app2 {
            dimension "APP"
            applicationId 'com.imliujun.app2'

            versionName rootProject.ext.APP2_versionName

            //應用名
            resValue "string", "app_name", "APP2"

            buildConfigField "String", "versionNumber", "\"${rootProject.ext.APP2_versionName}\""

            //第三方SDK的一些配置
            buildConfigField "int", "IM_APPID", "app2的騰訊IM APPID"
            buildConfigField "String", "IM_ACCOUNTTYPE", "\"app2的騰訊IM accountype\""
            manifestPlaceholders = [UMENG_APP_KEY      : "app2的友盟 APP KEY",
                                    UMENG_CHANNEL_VALUE: "app2預設的渠道名",
                                    XG_ACCESS_ID       : "app2信鴿推送ACCESS_ID",
                                    XG_ACCESS_KEY      : "app2信鴿推送ACCESS_KEY",
                                    QQ_APP_ID          : "app2的QQ_APP_ID",
                                    AMAP_KEY           : "app2的高德地圖key",
                                    APPLICATIONID      : applicationId]
            //簽名檔案
            signingConfig signingConfigs.app2
        }

        offline {
            dimension "SERVER"

            versionName getTestVersionName()
        }

        online {
            dimension "SERVER"
        }

        admin {
            dimension "SERVER"

            versionName rootProject.ext.versionName + "-管理員"
            manifestPlaceholders.UMENG_CHANNEL_VALUE = "admin"
        }
    }
}

android.applicationVariants.all { variant ->
    switch (variant.flavorName) {
        case "app1Admin":
            variant.buildConfigField "String", "DOMAIN_NAME",
                    "\"https://admin.app1domain.com/\""
            if ("debug" == variant.buildType.getName()) {
                variant.mergedFlavor.setVersionName(getTestVersionName() + "-管理員")
            } else {
                variant.mergedFlavor.setVersionName(rootProject.ext.APP1_VERSION_NAME + "-管理員")
            }
            break
        case "app1Offline":
            variant.buildConfigField "String", "DOMAIN_NAME",
                    "\"https://offline.app1domain.com/\""
            variant.mergedFlavor.setVersionName(getTestVersionName())
            break
        case "app1Online":
            variant.buildConfigField "String", "DOMAIN_NAME",
                    "\"https://online.app1domain.com/\""
            if ("debug" == variant.buildType.getName()) {
                variant.mergedFlavor.setVersionName(getTestVersionName())
            }
            break
        case "app2Admin":
            variant.buildConfigField "String", "DOMAIN_NAME",
                    "\"https://admin.app2domain.com/\""
            if ("debug" == variant.buildType.getName()) {
                variant.mergedFlavor.setVersionName(getApp2TestVersionName() + "-管理員")
            } else {
                variant.mergedFlavor.setVersionName(rootProject.ext.APP2_VERSION_NAME + "-管理員")
            }
            break
        case "app2Offline":
            variant.buildConfigField "String", "DOMAIN_NAME",
                    "\"https://offline.app2domain.com/\""
            variant.mergedFlavor.setVersionName(getApp2TestVersionName())
            break
        case "app2Online":
            variant.buildConfigField "String", "DOMAIN_NAME",
                    "\"https://online.app2domain.com/\""
            if ("debug" == variant.buildType.getName()) {
                variant.mergedFlavor.setVersionName(getApp2TestVersionName())
            }
            break
    }
}複製程式碼
ext {
    APP1_VERSION_NAME = "2.0.2"
    APP1_TEST_NUM = "0001"
    APP2_VERSION_NAME = "1.0.5"
    APP2_TEST_NUM = "0005"
}

def getTestVersionName() {
    return String.format("%s.%s", rootProject.ext.APP1_VERSION_NAME,
            rootProject.ext.APP1_TEST_NUM)
}

def getApp2TestVersionName() {
    return String.format("%s.%s", rootProject.ext.APP2_VERSION_NAME,
            rootProject.ext.APP2_TEST_NUM)
}

static int gitVersionCode() {
    def count = "git rev-list HEAD --count".execute().text.trim()
    return count.isInteger() ? count.toInteger() : 0
}複製程式碼

在上一篇文章的配置上進行了一些修改,同時保留上一篇文章裡所有的功能。

配置多應用

首先來看最重要的一個概念:

flavorDimensions "APP", "SERVER"複製程式碼

這一行程式碼配置了兩個維度的 flavorAPP 代表多應用,SERVER 代表伺服器版本。

根據上面的配置資訊可以看到,app1app2 設定了 dimension "APP" 所以屬於 APP 這個維度,offlineonlineadmin 設定了 dimension "SERVER" 屬於 SERVER 這個維度。

根據 Product Flavors 的兩個維度 APP [app1, app2] 和 SERVER [offline, online, admin] 以及 Build Type [debug, release],最後會生成以下 Build Variant:

  • app1AdminDebug
  • app1AdminRelease
  • app1OfflineDebug
  • app1OfflineRelease
  • app1OnlineDebug
  • app1OnlineRelease
  • app2AdminDebug
  • app2AdminRelease
  • app2OfflineDebug
  • app2OfflineRelease
  • app2OnlineDebug
  • app2OnlineRelease

是不是每個應用都有 3 個伺服器版本,每個版本都有 debugrelease 包。

配置不同的包名

我們要實現多應用,必須能安裝在同一臺手機上。所以不同應用之間的包名得不一樣。

APP 維度的 flavor 中設定不同的 applicationId,就可以實現修改應用包名。

app1{
    applicationId 'com.imliujun.app1'
}

app2{
    applicationId 'com.imliujun.app2'
}複製程式碼

這樣配置後,app1app2 就能夠安裝在同一臺手機上,也能同時上傳應用商店。

有一點大家切記,AndroidManifest.xml 中的 package 不需要去修改,R 檔案的路徑是根據這個 package 來生成的。如果對 package 進行修改,R 檔案的路徑也會改變,所有引用到 R 檔案的類都需要進行修改。

動態配置 URL 和版本號

既然每個 Build Variant 都是由不同維度的 Product Flavors 和 Build Type 組合而來,我們肯定不能像上一篇文章一樣將伺服器的 URL 配置在 offlineonlineadmin 中了,因為 app1Offlineapp2Offline 同樣是測試服,但不是同一個應用 URL 也不一樣。

這個時候就需要通過 task 操作來根據不同的組合設定不同的資料了。

android.applicationVariants.all { variant ->
    //判斷當前的 flavorName 是什麼版本
    switch (variant.flavorName) {
        case "app1Admin":
            //這是 app1 的超管版本,設定超管伺服器 URL
            variant.buildConfigField "String", "DOMAIN_NAME",
                    "\"https://admin.app1domain.com/\""
            //判斷當前是 `debug` 包還是 `release` 包,設定版本號
            if ("debug" == variant.buildType.getName()) {
                variant.mergedFlavor.setVersionName(getTestVersionName() + "-管理員")
            } else {
                variant.mergedFlavor.setVersionName(rootProject.ext.APP1_VERSION_NAME + "-管理員")
            }
            break
        case "app1Offline":
            variant.buildConfigField "String", "DOMAIN_NAME",
                    "\"https://offline.app1domain.com/\""
            variant.mergedFlavor.setVersionName(getTestVersionName())
            break
        case "app1Online":
            variant.buildConfigField "String", "DOMAIN_NAME",
                    "\"https://online.app1domain.com/\""
            if ("debug" == variant.buildType.getName()) {
                variant.mergedFlavor.setVersionName(getTestVersionName())
            }
            break
        case "app2Admin":
            variant.buildConfigField "String", "DOMAIN_NAME",
                    "\"https://admin.app2domain.com/\""
            if ("debug" == variant.buildType.getName()) {
                variant.mergedFlavor.setVersionName(getApp2TestVersionName() + "-管理員")
            } else {
                variant.mergedFlavor.setVersionName(rootProject.ext.APP2_VERSION_NAME + "-管理員")
            }
            break
        case "app2Offline":
            variant.buildConfigField "String", "DOMAIN_NAME",
                    "\"https://offline.app2domain.com/\""
            variant.mergedFlavor.setVersionName(getApp2TestVersionName())
            break
        case "app2Online":
            variant.buildConfigField "String", "DOMAIN_NAME",
                    "\"https://online.app2domain.com/\""
            if ("debug" == variant.buildType.getName()) {
                variant.mergedFlavor.setVersionName(getApp2TestVersionName())
            }
            break
    }
}複製程式碼

兩個 APP 的伺服器 URL 和版本號不一致,所以通過 task 來動態設定。

配置應用名

不同的應用配置自己的應用名:

resValue "string", "app_name", "APP1"複製程式碼

這行程式碼的意思和在 strings.xml 中定義一個 String 值是一樣的。不過這裡通過 Gradle 配置了 app_name 就不能在 strings.xml 中再定義了,會報錯提示有衝突。

配置應用簽名

如果多個應用使用同一個簽名檔案,按照上一篇文章寫的在 buildTypesreleasedebug 中配置就可以。但是每個應用的簽名檔案不一樣呢?

signingConfigs {

    app1 {
        storeFile file("app1.jks")
        storePassword "111111"
        keyAlias "app1"
        keyPassword "111111"
    }

    app2 {
        storeFile file("app2.jks")
        storePassword "111111"
        keyAlias "app2"
        keyPassword "111111"
    }
}複製程式碼

配置多個簽名檔案,在 APP 這個維度的 flavor 中配置簽名資訊:

app1{
    signingConfig signingConfigs.app1
}

app2{
    signingConfig signingConfigs.app2
}複製程式碼

這樣就可以針對不同的應用設定不同的簽名檔案了。但是,還有一個要注意的地方,這個坑我以前沒填上,而是繞遠路繞過去了,現在我來填上它!

debug {
    signingConfig null
}複製程式碼

一定要在 debug 中將簽名檔案的配置置空,不然 Build Type 的許可權比 Product Flavors 要高,而 debug Build Type(構建型別) 會自動使用 debug SigningConfig (簽名配置),這樣一來就將 flavor 中配置的簽名資訊給覆蓋掉了。導致的問題就是編譯 release 包沒有問題,編譯 debug 包就不能使用某些需要校驗簽名的第三方SDK了。

配置不同應用的程式碼和資源

終於來到重頭戲了,現在只需要更換 UI、文案或者某些介面佈局和邏輯程式碼就大功告成啦。

首先,建立每個應用對應的 sourceSets 目錄,比如:

  • app1 的 sourceSets 位置是 src/app1/
  • app2 的 sourceSets 位置是 src/app2/

app1 是已經開發完成的應用,只需要換 UI、文案就成了 app2,在 src/app2/ 目錄下再新建 res 目錄,將需要替換的切圖命名和 app1 中的命名保持一致放入 res 對應的目錄下就完美換膚了。

文案同理,將需要替換的字串在 src/app2/res/values/strings.xml 中再寫一份,保持 name 相同,其中的內容隨便替換。

佈局檔案、style、color 替換的規則同上。

微信登入、分享、支付的回撥是返回到 {應用包名.wxapi.WXEntryActivity}{應用包名.wxapi.WXPayEntryActivity} 這兩個 Activity。

我們在 app1app2 中都放入這兩個回撥 Activity:

sourceSets 檔案目錄
sourceSets 檔案目錄

然後在 AndroidManifest.xml 檔案中動態配置 Activity 的包名:

<!-- 微信分享回撥 -->
<activity android:name="${APPLICATIONID}.wxapi.WXEntryActivity"/>
<!-- 微信支付的回撥 -->
<activity android:name="${APPLICATIONID}.wxapi.WXPayEntryActivity"/>複製程式碼

APPLICATIONID 佔位符在 Gradle 中設定:

manifestPlaceholders = [APPLICATIONID : applicationId]複製程式碼

如果使用了 ShareSDK 做第三方分享和登入,需要配置 ShareSDK.xml 放到 assets 資料夾下,將 main/assets/ShareSDK.xml 複製一份到 app2/assets/ShareSDK.xml,將裡面的第三方 APP ID 和 APP KEY 替換一下就可以了。

專案如果使用了 ContentProvider 要注意替換 authorities,如果 authorities 裡面的值是一樣的,手機上只能裝一個應用哦,可以和上面動態配置 Activity 包名一樣操作,用信鴿 SDK 演示一下:

 <!-- 【必須】 【注意】authorities修改為 包名.AUTH_XGPUSH, 如demo的包名為:com.qq.xgdemo -->
<provider
    android:name="com.tencent.android.tpush.XGPushProvider"
    android:authorities="${APPLICATIONID}.AUTH_XGPUSH"
    android:exported="true"/>複製程式碼

總結

上面的內容基本涉及到所有的方面,其他的細節也好,特殊的需求定製也好,使用上面的方式去處理都能夠解決。希望大家不要光學會複製貼上,要掌握其原理,遇到類似的需求就能舉一反三。

demo地址:github.com/imliujun/Gr…

總結一下技術點:

  • manifestPlaceholders -> AndroidManifest.xml 佔位符
  • buildConfigField -> BuildConfig 動態配置常量值
  • resValue -> String.xml 動態配置字串
  • signingConfigs -> 配置簽名檔案
  • productFlavors -> 產品定製多版本
  • flavorDimensions -> 為產品定製設定多個維度
  • android.applicationVariants -> 操作 task

相關閱讀

歡迎關注微信公眾號:大腦好餓,更多幹貨等你來嘗

公眾號:大腦好餓
公眾號:大腦好餓

相關文章