Gradle使用详解(七) 之 多渠道构建你的APP

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/lyz_zyx/article/details/83957008

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权威指南》

 

 

猜你喜欢

转载自blog.csdn.net/lyz_zyx/article/details/83957008