1 背景
在國内手機廠商應用市場和第三方手機應用市場如此泛濫的環境下,針對不同的應用市場區分個别特殊功能、跟蹤活躍留存這些資料來源,等。這時建構區分App管道是很有必要的。Android Gradle中提供了ProductFlavors{}閉包配置來幫助我們很好的處理多管道建構的問題和實作批量自動化,關于ProductFlavors{}我們在之前的博文《Android Gradle使用詳解(三) 之 Android Gradle插件配置詳解》中有簡單提過,每個ProductFlavor可以有自己的SourceSet,還可以有自己的Dependencies依賴,這意味着我們可以為每個管道定義它們自己的資源、代碼以及依賴的第三方庫。今天我們來就詳細看看多管道建構的基本原理和選擇一種适配你自己工程的建構方式。
2 基本原理
在Android Gradle中,定義了一個叫Build Variant的概念,翻譯過來叫建構變體或構件。一個Build Variant = Build Type + Product Flavor,也就是建構類型(如比release、debug) + 建構管道(比如華為、小米),它們組合起來就是:HuaweiRelease、HuaweiDebug、MiRelease、MiDebug。ProductFlavors{}的示例配置如:
android {
……
defaultConfig {
……
}
buildTypes {
release {
……
}
debug {
……
}
}
productFlavors {
huawei {
……
}
mi {
……
}
}
}
3 多管道建構定制
多管道的定制,其實就是對Android Gradle插件中ProductFlavor{}的配置,通過配置不同的字段來靈活控制每一個管道的獨特性。幾乎所有在defaultCofnig{}和buildTypes{}中可配置使用的方法或屬性,都能在productFlavors{}中使用。關于defaultCofnig{}和buildTypes{}中常用的配置可以看之前的博文《Android Gradle使用詳解(三) 之 Android Gradle插件配置詳解》。下面我們就來看看除此外在 productFlavors{} 中比較常使用的屬性和方法。
3.1 buildConfigField(自定義BuildConfig類)
BuildConfig類相信大家并不陌生,它是由Android Gradle建構腳本在編譯後自動生成的不能修改的,一般大概是這樣:
package com.zyx.myapplication;
public final class BuildConfig {
public static final boolean DEBUG = Boolean.parseBoolean("true");
public static final String APPLICATION_ID = "com.zyx.myapplication";
public static final String BUILD_TYPE = "debug";
public static final String FLAVOR = "";
public static final int VERSION_CODE = 1;
public static final String VERSION_NAME = "1.0";
}
可以看到裡頭的常量都是我們在Gradle中配置的字段,比如 BuildConfig.DEBUG 用于判斷是否是debug編譯版本,BuildConfig.VERSION_CODE 用于獲得目前APP的版本号,等。這些都是Gradle預設自動生成的,其實我們還可以通過productFlavors{}中的buildConfigField屬性來自己定義新增一些常用的常量,例如管道号,然後就可以從代碼中來獲得該管道号進行上報或其他操作。請看示例:
android {
……
productFlavors {
huawei {
buildConfigField 'String', 'CHANNEL', '"華為管道号"'
}
mi {
buildConfigField 'String', 'CHANNEL', '"小米管道号"'
}
}
}
通過修改Gradle後,重新建構會發現提示了:
Error:All flavors must now belong to a named flavor dimension.Learn more at https://d.android.com/r/tools/flavorDimensions-missing-error-message.html
的錯誤。意思是所有的flavors必須要在同一個次元中。原來在Gradle4後有一種自動比對消耗庫的機制,便于debug variant 自動消耗一個庫,然後就是必須要所有的flavor 都屬于同一個次元。為了解決這個錯誤,我們就來為上面兩個管道加入一個品牌的次元配置,上述示例修改為:
android {
……
flavorDimensions "brand"
productFlavors {
huawei {
dimension 'brand'
buildConfigField 'String', 'CHANNEL', '"華為管道号"'
}
mi {
dimension 'brand'
buildConfigField 'String', 'CHANNEL', '"小米管道号"'
}
}
}
關于flavorDimensions和dimension次元的解釋,我們會在下面再來說說,現在先執行重新編譯後看看BuildConfig類:
package com.zyx.myapplication;
public final class BuildConfig {
public static final boolean DEBUG = Boolean.parseBoolean("true");
public static final String APPLICATION_ID = "com.zyx.myapplication";
public static final String BUILD_TYPE = "debug";
public static final String FLAVOR = "huawei";
public static final int VERSION_CODE = 1;
public static final String VERSION_NAME = "1.0";
// Fields from product flavor: huawei
public static final String CHANNEL = "華為管道号";
}
可以看到,此時就會多出了一個CHANNE的常量。這裡要注意的是,value這個參數,我們在單引号裡頭怎樣寫,生成出來的就是怎麼樣,這裡寫義的是一個String類型,是以單引号裡頭一定要存在一對雙引号,否則就會編譯錯誤。
3.2 resValue(自定義資源)
除了通過自定義BuildConfig類來定義管道号外,其實還可以通過resValue來自定義資源的方式來區分管道。resValue是一個方法,它在defaultCofnig{}、buildTypes{}和ProductFlavor中都可以使用,它的使用示例如:
android {
……
flavorDimensions "brand"
productFlavors {
huawei {
dimension 'brand'
resValue 'string', 'channel', '華為管道号'
}
mi {
dimension 'brand'
resValue 'string', 'channel', '小米管道号'
}
}
}
配置完後,再次執行編譯,以huawei為例,此時會生成檔案:build/generated/res/resValues/huawei/debug/values/ generated.xml,檔案内容如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Automatically generated file. DO NOT MODIFY -->
<!-- Values from product flavor: huawei -->
<string name="channel" translatable="false">華為管道号</string>
</resources>
我們在Java代碼中,像引用正常資源一樣使用便可:
String channel = getResources().getString(R.string.channel);
3.3 manifestPlaceholdes(動态配置AndroidManifest)
除了通過自定義BuildConfig和自定義資源來在代碼中判斷和獲得管道号外,還可以通過動态配置AndroidManifest檔案來進行管道的區分,例如像友盟這類第三方分析統計,就會要求我們在AndroidManifest檔案中指定管道号名稱:<meta-data android:name=”UMENG_CHANNEL” android:value=”XX管道号” />
manifestPlaceholdes是productFlavors{}的一個屬性,是一個Map類型,通過對它的配置就可以友善地動态來設定AndroidManifest中的預設的占位符變量,使用示例如:
android {
……
productFlavors {
huawei {
manifestPlaceholders.put('UMENG_CHANNEL', '華為管道号')
}
mi {
manifestPlaceholders.put('UMENG_CHANNEL', '小米管道号')
}
}
}
然後修改AndroidManifest檔案:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.zyx.myapplication">
<application
……>
<meta-data android:name="UMENG_CHANNEL" android:value="${UMENG_CHANNEL}" />
……
</application>
</manifest>
通過上述的修改後,在建構時${UMENG_CHANNEL}将會被替換成Gradle中配置的華為管道号或小米管道号。我們可以通過apktool反編譯生成後的APK包便可看到AndroidManifest檔案中的${UMENG_CHANNEL}被替換了。
3.4 dimension(次元)
在前面自定義BuildConfig類中已經提到了dimension,它就好像一個分組一樣。有時候,我們想基于不同的标準來建構App,比如上面的例子是品牌,若目前需求中又還有收費和免費呢?。在不考慮BuildType情況下就已經有四種組合了:華為市場的免費版、小米市場的免費版、華為市場的收費版、小米市場的收費版。對于這種情況,我們有兩種方式來建構,第一種就是配置4個ProductFlavor,然後針對這兩4個ProductFlavor進行配置,這種方法比較通俗易懂,但是存在腳本備援,若以後出現更多的市場管道就更糟了,要拷貝的腳本代碼更多。第二種方法就是通過dimension多元度的方式來解決。
dimension是ProductFlavor{}的一個屬性,接收一個字元串,像上面提到的情況,就可以定義兩個次元,比如free和paid可以認為它們都是屬于版本version,而華為市場和小米市場是屬于品牌brand。
定義次元要使用flavorDimensions方法來聲明,它是android{}中的方法,它和productFlavors{}是平級的。記住一定要先聲明然後再在ProductFlavor中使用。示例如下:
android {
……
flavorDimensions "brand", "version"
productFlavors {
huawei {
dimension 'brand'
……
}
mi {
dimension 'brand'
……
}
free {
dimension 'version'
……
}
paid {
dimension 'version'
……
}
}
}
通過dimension指定ProductFlavor所屬的次元非常友善,Android Gradle會自動地幫我們生成相應的Task、SourceSet、Dependencies等。值得注意的是,次元是有優先級的,第一個參數的優先級最大,其次是第二個,依此類推,是以在聲明之前一定要根據自己的需求來指寫好順序。
3.5 resConfigs(多語言資源打包)
resConfigs屬于PraductFlavor{}的一個方法,它可以讓我們配置哪些類型的資源才被打到包中去。比如隻打包zh的資源或隻打包xhdpi格式的圖檔等。如果你正在開發一款國際化的App,它是支援多種語言的就可以通過配置resConfigs方法來打包出多語言包,每種語言包隻需要有自己的語言資源,而不需要将其它國家語言也一同打包在一起進而增加apk的大小。resConfigs接收的參數就是我們在Android開發時的資源限定符,使用示例如下:
android {
……
flavorDimensions "language"
productFlavors {
cn {
dimension 'language'
resConfigs 'cn'
}
zh {
dimension 'language'
resConfigs 'zh' // 多個用逗号分隔,如:resConfigs 'zh', 'en'
}
}
}
如果我們支援的語言非常多,而定義的proudctFlavors的名字跟語言資源限定符保持一緻的話,那麼上面的代碼還可以使用疊代的方式批量進行配置,示例修改成:
android {
……
flavorDimensions "language"
productFlavors {
cn {
dimension 'language'
}
zh {
dimension 'language'
}
}
productFlavors.all { flavor ->
resConfigs name
}
}
3.6 sourceSets (區分代碼實作)
在工程建立後,AndroidStudio預設幫我們建立了應用于所有産品的代碼集main,它的對應的目錄是src/main,我們也可以根據管道情況建立相應的代碼集,這樣便能實作不同的管道有着不同的實作方法,使用示例如下:
android {
……
sourceSets{
cn.java.srcDirs = ['src/cn/java']
zh.java.srcDirs = ['src/zh/java']
}
flavorDimensions "language"
productFlavors {
cn {
dimension 'language'
resConfigs 'cn'
}
zh {
dimension 'language'
resConfigs 'zh' // 多個用逗号分隔,如:resConfigs 'zh', 'en'
}
}
}
這樣配置後,便可在各自相應的檔案夾下存在需要差別的代碼,比如同樣的類,不同的方法實作。
4 批量修改生成的apk檔案名
前面提到Build Variant的概念。一個Build Variant = Build Type + Product Flavor,預設情況下,建構成功後輸出apk檔案名稱就是以“app_”開頭,後面緊接着是Product Flavor 和 Build Type,例如:app_huawei_debug.apk。當我們為多管道訂制包時,可能需要更加一目了然和增加更多資訊的名字,這時就需要修改生成的apk檔案名。
要修改生成的apk檔案名,那麼就要修改Android Gradle打包的輸出。Android對象提供了3個屬性:applicationVariants、libraryVariants 和 testVariants。它們傳回的就是Build Variant集合,是以隻需要疊代這些集合,然後在其中執行修改生成apk的輸出檔案名就可以達到自動批量修改apk檔案名。
例如現在需要輸出的檔案名以“xyx_”開頭,後面除了緊接Product Flavor 和 Build Type外,還要帶上版本名。請看示例:
android {
……
defaultConfig {
……
}
buildTypes {
release {
……
}
debug {
……
}
}
productFlavors {
huawei {
……
}
mi {
……
}
}
applicationVariants.all { variant ->
variant.outputs.each { output ->
if (output.outputFile != null && output.outputFile.name.endsWith('.apk')) {
def fileName = "zyx_${variant.flavorName}_${variant.buildType.name}_${variant.versionName}.apk"
output.outputFile = new File(output.outputFile.parent, fileName)
}
}
}
}
重新建構後,若的Gradle版本是4之前的,倒是沒有什麼問題,但是若是Gradle的版本是4或以上,就會報錯:
Cannot set the value of read-only property 'outputFile' for ApkVariantOutputImpl_Decorated{apkData=Main{type=MAIN, fullName=debug, filters=[]}} of type com.android.build.gradle.internal.api.ApkVariantOutputImpl.
原來是在新版本的Gradle後,将'outputFile'設為了隻讀,是以在此已情況下,可以将腳本代碼修改為:
applicationVariants.all { variant ->
variant.outputs.all { output ->
if (output.outputFile != null && output.outputFile.name.endsWith('.apk')) {
def fileName = "app_${variant.flavorName}_${variant.buildType.name}_${variant.versionName}.apk"
outputFileName = fileName
}
}
}
現在,再次執行重新建構後,以華為的debug為例,在目錄:buile\outputs\apk\huawei\debug目錄下就會生成檔案:zyx_mi_debug_1.0.apk
5 更高效的多管道建構
一般地,我們生成多個管道包,主要目的是為了跟蹤每個管道的資料情況,是以除了管道号來區分外,大部分情況下,并沒有什麼不同。對于目前國内應用市場如此廣多的情況下,在productFlavors{}中去配置不同的市場區分管道明顯是很影響效率和代碼備援的。針對這樣的情況,目前比較流行的一個方法就是:
1.利用Android Gradle打出一個母包apk檔案;
2.接着基于該包複制出命名區分産品、管道等資訊的apk包;
3.然後再對複制出來的apk檔案進行修改,就是在其META_INF目錄下添加一個以管道命名的空檔案,例如:”zyx_huawei”;
4.重複步驟2和步驟3來生成多個管道包apk
為什麼是一個空檔案,然後以管道來命名,不直接将管道号寫進檔案裡?這裡有段曆史,在Android4.4及之前是可以這樣做的,而且你不止可以塞一個檔案進去,就算你塞一部小電影進去都沒有問題。像Windows木馬就是這個思路,可在執行檔案exe尾巴上挂一個木馬病毒,執行exe同時也會執行這個木馬。到了後來,谷歌發現了這個漏洞,因為apk是一個zip壓縮包,會在檔案頭記錄壓縮包的大小,就在Android4.4之後,Android會在apk安裝的時候,檢查apk的實際大小,然後跟apk的頭部記錄壓縮包大小是否相等來校驗包是否合法。是以現在我們隻能通過一件0B的空檔案以檔案名來區分管道了。
根據上述步驟,我們可以用python腳本來實作,請看代碼:
build.py
# coding=utf-8
import zipfile
import shutil
import os
# 空檔案 便于寫入此空檔案到apk包中作為channel檔案
src_empty_file = 'zyx.txt'
f = open(src_empty_file, 'w')
f.close()
# 擷取管道清單
channel_file = 'channel.txt'
f = open(channel_file)
lines = f.readlines()
f.close()
# 擷取目前目錄中所有的apk源包
src_apks = []
for file in os.listdir('.'):
if os.path.isfile(file):
extension = os.path.splitext(file)[1][1:]
if extension in 'apk':
src_apks.append(file)
# 周遊apk檔案
for src_apk in src_apks:
# 擷取檔案名加擴充名
src_apk_file_name = os.path.basename(src_apk)
# 分割檔案名與擴充名
temp_list = os.path.splitext(src_apk_file_name)
# 擷取檔案名
src_apk_name = temp_list[0]
# 擷取擴充名
src_apk_extension = temp_list[1]
# 建立生成目錄
output_dir = src_apk_name + '_channel_apk/'
# 目錄不存在則建立
if not os.path.exists(output_dir):
os.mkdir(output_dir)
# 周遊管道号并建立對應管道号的apk檔案
for line in lines:
# 擷取目前管道号,因為從管道檔案中獲得帶有\n,所有strip一下
target_channel = line.strip()
# 拼接對應管道号的apk
target_apk = output_dir + src_apk_name + "-" + target_channel + src_apk_extension
# 拷貝建立新apk
shutil.copy(src_apk, target_apk)
# zip擷取建立立的apk檔案
zipped = zipfile.ZipFile(target_apk, 'a', zipfile.ZIP_DEFLATED)
# 初始化管道資訊
empty_channel_file = "META-INF/zyx_{channel}".format(channel = target_channel)
# 寫入管道資訊
zipped.write(src_empty_file, empty_channel_file)
# 關閉zip流
zipped.close()
build.py檔案放置于跟apk母包同一目錄下,并在該目錄下建立兩個txt檔案:zyx.txt和channel.txt。其中,zyx.txt是一個空檔案,用于代碼将其重命名後放置于apk包内,而channel.txt是配置各個市場管道号的檔案,用回車區分,如:
channel.txt
huwwei
mi
oppo
vivo
準備好目錄下的檔案後,就可以在指令行中執行:python build.py
這時便可見在apk目錄下生成了一個檔案夾,檔案夾内就會生成根據channel.txt中的管道号生成對應用管道包apk,如圖:
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsICM38FdsYkRGZkRG9lcvx2bjxiNx8VZ6l2cs0zZYVmN5wWZ1gnMMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2X0hXZ0xCMx81dvRWYoNHLrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49zZuBnLwYzNzEzMzETMxETMxgTMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
我們在Java代碼中要讀取META-INF目錄下以”zyx_”開頭的管道檔案名也很簡單,使用下面的方法代碼即可實作。一般地為了性能考慮,都會在Application啟動後,将其管道号讀出,然後将其儲存于SharedPreferences中,友善後面開發中使用。解析META-INF目錄下的管道号方法代碼如下:
public static String getChannelId(Context context) {
ApplicationInfo appinfo = context.getApplicationInfo();
String sourceDir = appinfo.sourceDir;
String ret = "";
ZipFile zipfile = null;
try {
zipfile = new ZipFile(sourceDir);
Enumeration<?> entries = zipfile.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = ((ZipEntry) entries.nextElement());
String entryName = entry.getName();
if (entryName.startsWith("META-INF/zyx_")) {
ret = entryName;
break;
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (zipfile != null) {
try {
zipfile.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
String[] split = ret.split("_");
String channel = "";
if (split != null && split.length >= 2) {
channel = ret.substring(split[0].length() + 1);
}
return channel;
}
6 總結
上面所講述的關于多管道建構就講完了,多管道、多語言的建構其實就是利用對ProductFlavor{}的配置,也可以通過周遊applicationVariants集合來自定義各個管道包輸出的名稱。還有另外的一種多管道高效批量打包方式就是在包内的META-INF檔案夾内建立一個包含管道号名的空檔案來區分。大家在日常開發中,可以根據自身項目實際需求情況來選擇一種适配你項目的建構方式來實作多管道。