1. 程式人生 > >Gradle使用詳解(七) 之 多渠道構建你的APP

Gradle使用詳解(七) 之 多渠道構建你的APP

1 背景

在國內手機廠商應用市場和第三方手機應用市場如此氾濫的環境下,針對不同的應用市場區分個別特殊功能、跟蹤活躍留存這些資料來源,等。這時構建區分App渠道是很有必要的。Android Gradle中提供了ProductFlavors{}閉包配置來幫助我們很好的處理多渠道構建的問題和實現批量自動化,關於ProductFlavors{}我們在之前的博文《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{}中常用的配置可以看之前的博文《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', '"小米渠道號"'
        }
    }
}

關於flavorDimensionsdimension維度的解釋,我們會在下面再來說說,現在先執行重新編譯後看看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渠道號” />

manifestPlaceholdesproductFlavors{}的一個屬性,是一個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反編譯便可看到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
    }
}

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

 

根據上述步驟,我們可以用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.txtchannel.txt。其中,zyx.txt是一個空檔案,用於程式碼將其重新命名後放置於apk包內,而channel.txt是配置各個市場渠道號的檔案,用回車區分,如:

channel.txt

huwwei
mi
oppo
vivo

準備好目錄下的檔案後,就可以在命令列中執行:python build.py

這時便可見在apk目錄下生成了一個資料夾,資料夾內就會生成根據channel.txt中的渠道號生成對應用渠道包apk,如圖:

我們在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資料夾內建立一個包含渠道號名的空資料夾來區分。大家在日常開發中,可以根據自身專案實際需求情況來選擇一種適配你專案的構建方式來實現多渠道。

 

——本文部分內容參考自《Android Gradle權威指南》