阅读 930

不一样的Gradle多渠道配置总结

*本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

好久没有写博客了,突然想把这段时间项目中使用到的技术和多渠道相关的认识总结分享一下~

一、新增渠道

使用AndroidStudio配合gradle,可以很方便的输出多个渠道包,只需要在app Module下的build.gradle中,对productFlavors领域进行配置即可,假设我当前开发的项目,需要上线不同的地区,一个是国内版,一个美国版,还有一个免费版,那么gradle可以这么配:

android {
    productFlavors {
        china { // 中国版
        }
        america { // 美国版
        }
        free { // 免费版
        }
    }
}
复制代码

以上多渠道配置完成后,在Android Studio的Build Variants标签中,就会有不同渠道变体供我们选择了。当我们想使用AS直接运行某个渠道的app时,就需要先在Build Variants标签中选择好变体,再点击"运行"按钮运行项目。

在productFlavors中还可以配置包名(applicationId)、版本号(versionCode)、版本名(versionName)、icon、应用名 等等,举个例子:

free {
	applicationId 'com.lqr.demo.free'
	versionCode 32
	versionName '1.3.2'
	manifestPlaceholders = [
			app_icon: "@drawable/ic_launcher",
			app_name: "菜鸡【免费版】",
	]
}
复制代码

注意:
这里配置的包名是applicationId,而不是清单文件里的packageName,applicationId与packageName是不一样的。
我们常说,一部Android设备上不能同时安装2个相同包名的app,指的是applicationId不能一样。
applicationId与packageName的区别可查阅:《ApplicationId versus PackageName》

如果工程要求不同渠道共存,或者对版本号、icon、应用名等有定制需求的话,那么这个多渠道配置就显得非常有用了。其中,app_icon、app_name是放在manifestPlaceholders的,这个其实是在对AndroidManifest.xml中的占位符进行变量修改,也就是说,要定制icon或者应用名的话,还需要对清单文件做些小修改才行(增加一些占位符),如:

<application
    xmlns:tools="http://schemas.android.com/tools"
    android:icon="${app_icon}"
    android:label="${app_name}"
    android:theme="@style/AppTheme"
    android:largeHeap="true"
    tools:replace="android:label">
	...
</application>
复制代码

二、生成渠道变量

在新增渠道之后,我们可以对这些渠道进行一起更多的配置,假设项目代码需要根据不同的渠道,赋予不同的数据,当然你可以选择在java代码中通过判断当前渠道名,配合switch来设置静态常量,但其实不用那么烦琐,而且有些静态数据通过类似config.gradle或config.properties这类配置文件来配置有比较好,那么gradle中的applicationVariants完全可以帮助到我们,以下面的配置Demo为例进行说明:

// 多渠道相关设置
applicationVariants.all { variant ->
    buildConfigField("String", "PROUDCT", "\"newapp\"")
    buildConfigField("String[]", "DNSS", "{\"http://119.29.29.29\",\"http://8.8.8.8\",\"http://114.114.114.114\"}")
    if (variant.flavorName == 'china') {
        buildConfigField("String", "DNS", "\"http://119.29.29.29\"")
    } else if (variant.flavorName == 'america') {
        buildConfigField("String", "DNS", "\"http://8.8.8.8\"")
    } else if (variant.flavorName == 'free') {
        buildConfigField("String", "DNS", "\"http://114.114.114.114\"")
    }
}
复制代码

通过gradle中提供的buildConfigField(),AndroidStudio会在执行脚本初始化时,根据当前所选变体将对于的配置转变为BuildConfig.java中的一个个静态常量:

当我切换其他变体时,BuildConfig中的DNS也会跟着一起改变,这样,我们在工程代码中,就不需要去判断当前渠道名来为某些静态常量赋值了。这里只是举例了使用buildConfigField()来生成String和String[]常量,当然也可以用来生成其它类型的常量数据,有兴趣的话,可以百度了解下。

三、变体的使用

上面提到了变体,那么变体是什么?可以这样理解,变体是由【Build Type】和【Product Flavor】组合而成的,组合情况有【Build Type】*【Product Flavor】种,举个例子,有如下2种构建类型,并配置了2种渠道:

Build Type:release debug
Product Flavor:china free
复制代码

那么最终会有四种 Build Variant 组成:

chinaRelease chinaDebug freeRelease freeDebug
复制代码

变体在复杂多渠道工程中是相当有用的,可以做到资源文件合并以及代码整合,这里的合并与整合怎么理解?我们使用Android Studio进行项目开发时,会把代码文件与资源文件都存放在app/src目录下,通常是main下会有java、res、assets来区分存放代码文件和资源文件,你可以把main看作是默认渠道工程文件目录,也就是说main下存放在代码文件和资源文件对所有渠道来说都是共同持有的。

那么,一旦出来了某些代码文件或者资源文件是个别渠道专属时,应该怎么办呢?因为main是共有的,所以理想状态下,我们并不会把这类"不通用"的文件放在main下(这样做不会出错,但是做法很low,会增大apk包体积),Android Studio为变体做了很好的支持,我们可以在app/src下,创建一个以渠道名命名的目录,用于存放这类个别渠道专属的代码文件和资源文件,如:

可以看到,当我选择freeDebug变体时,app/src/free下的目录高亮了,说明它们被Android Studio识别,在运行工程时,Android Studio会将free和main下的所有资源文件进行合并,将代码文件进行整合。同理,如果我选择的是chinaDebug变体,那么app/src/china下目录就会高亮。知道如何创建变体目录后,下面就开始进行资源合并与代码整合了。

1、资源合并

资源文件有哪些?我们可以这样认为:

资源文件 = res下的所有文件 + AndroidManifest.xml 
复制代码

变体的资源合并功能简直是"神器"一般的存在,可以解决很多业务需求,如不同渠道显示的icon不同,应用名不同等等。Android Studio在对变体目录和main目录进行资源合并时,会遵守这样的规则,假设当前选中的变体是freeDebug:

  • 某资源在free下有,在main中没有,那么在打包时,会将该资源直接合并到main资源中。
  • 某资源在free下有,在main中也有,那么在打包时,会以free为主,将free中资源替换掉main中资源。

针对上述2个规则,这里以string.xml为例进行说明,main下的string.xml是:

<resources>
    <string name="app_name">Demo</string>
    <string name="app_author">Lin</string>
</resources>
复制代码

free下的string.xml是:

<resources>
    <string name="error_append">发生错误</string>
    <string name="app_author">Lqr</string>
</resources>
复制代码

那么最终打出的apk包里的string.xml是:

<resources>
    <string name="app_name">Demo</string>
    <string name="error_append">发生错误</string>
    <string name="app_author">Lqr</string>
</resources>
复制代码

除了字符串合并外,还有图片(drawable、mipmap)、布局(layout)、清单文件(AndroidManifest.xml)的合并,具体可以自己尝试一下。其中,清单文件的合并需要提醒一点,如果渠道目录下的AndroidManifest.xml与main下的AndroidManifest.xml拥有相同的节点属性,但属性值不同时,那么就需要对main下的AndroidManifest.xml进行修改了,具体修改要根据编译时报错来处理,所以,报错时不要慌,根据错误提示修改就是了。

注意:布局(layout)文件的合并是对整个文件进行替换的~。

2、代码整合

代码文件,顾名思义就是指java目录下的.java文件了,为什么代码叫整合,而资源却是合并呢?因为代码文件是没办法合并的,只能是整合,整合是什么意思?假设当前选中的变体是freeDebug,有一个java文件是Test.java,这个Test.java要么只存在free/java下,要么只存在于main/java下,如:

可以看到,一切正常,Test.java被AndroidStudio识别,但如果此时在main/java下也存在Test.java,那么Android Studio就会报错了:

代码整合是一个比较头痛的事,因为如果你是在渠道目录free下去引用main下的类,那么是完全没有问题的,但如果反过来,在main下去引用free下的专属类时,情况就会变得很糟糕,当你切换其他变体时(如,切换成chinaDebug),这时工程就会报错了,因为变体切换,Test.java是free专属的,在chinaDebug变体下,free不会被识别,于是main就找不到对应的类了。

选择freeDebug变体时,正常引用Test.java:

选择chinaDebug变体时,找不到Test.java(只找到junit下的Test.java):

所以,对于代码整合,需要我们在开发过程中慎重考虑,多想想如何将渠道目录与main目录进行解耦。比如可以使用Arouter来解耦main与渠道目录下所有的Activity、Fragment,将类引用转换为字符串引用,全部将由Arouter来管理,又或者通过反射来处理,等等,这里顺带记录一下,我项目中使用ARouter来判断Activity、Fragment是否存在,和获取的相关方法:

/**
 * 获取到目标Delegate(仅仅支持Fragment)
 */
public <T extends CommonDelegate> T getTargetDelegate(String path) {
    return (T) ARouter.getInstance().build(path).navigation();
}

/**
 * 获取到目标类class(支持Activity、Fragment)
 */
public Class<?> getTargetClass(String path) {
    Postcard postcard = ARouter.getInstance().build(path);
    LogisticsCenter.completion(postcard);
    return postcard.getDestination();
}
复制代码

3、其他

前面只说到了res和java这2个目录,那么assets呢,它是属于哪种?很可惜,assets虽然是资源,但它不是合并,而是整合,也就是说,assets文件的处理方式跟java文件的处理方式是一样的,不能在渠道目录和main目录下同时存在相同的assets文件,这将对某些需求实现造成阻碍,举个例子,假设china与free使用的assets资源是一样的,而america单独使用自己的assets资源,并且这些assets资源文件名都是一样的,那这时要怎么办呢?给每个渠道都放一份各自的assets资源吗?这种做法可行,但很low,原因如下:

  1. 复用性差:都说了china与free使用的资源是一样的,从整个工程的角度来看,一个工程里放了2份一模一样的assets资源文件,如果我有10个渠道,其中9个渠道使用的assets资源是一样的要怎么办,copy9次?
  2. 维护成本高:在开发行业里,需求变动是很常见的事,产品经理会时不时改下需求,所以,叫你改assets资源文件也是很有可能的,如果你采用每个渠道都放一份,那么当assets资源需要修改时,你就需要将每个渠道的assets目录资源替换一遍。记得,是每次修改都要替换一遍。

正确的解决方案是使用sourceSets,对于sourceSets的使用,放到下一节去说明。

四、sourceSets

强大的gradle,通过sourceSets可以让开发者能够自定义项目结构,如自定义assets目录、java目录、res目录,而且还可以是多个,但要知道的是,sourceSets并不会破坏变体的合并规则,它们是分开的,sourceSets只是起到了“扩充”的作用。这里先摆一下sourceSets的常规使用:

sourceSets {
	main {
		manifest.srcFile 'AndroidManifest.xml'
		java.srcDirs = ['src']
		aidl.srcDirs = ['src']
		renderscript.srcDirs = ['src']
		res.srcDirs = ['res']
		assets.srcDirs = ['assets']
	}
}
复制代码

1、复用assets资源

对于多渠道共用同一套assets资源文件这个问题,结合sourceSets,我们可以这么处理,步骤如下:

  1. 把共用的assets资源存放到一个渠道目录下,如free/assets。
  2. 修改sourceSets规则,强制指定china渠道的assets目录为free/assets。
sourceSets {
	china {
		sourceSet.assets.srcDirs = ['src/free/assets']
	}
}
复制代码

这样配置以后,如果下次需要统一修改china与free的assets资源文件时,你就只需要把free/assets目录下的资源文件替换掉就好了。虽然这种写法已经满足前面说的需求了,但是还不够,还可以再优化一下,假设你有20个渠道,都使用同一套assets资源的话,按前面的写法你就要写19遍sourceSets配置了。

sourceSets {
	china {
		sourceSet.assets.srcDirs = ['src/free/assets']
	}
	a{
		sourceSet.assets.srcDirs = ['src/free/assets']
	}
	b{
		sourceSet.assets.srcDirs = ['src/free/assets']
	}
	...
}
复制代码

可以想像,在这个gradle文件中,光sourceSets配置就会有多长,你可能会说,一个项目怎么会有这么多渠道,不好意思,本人所处公司的业务需求就有20+个渠道的情况,话不多说,下面就来看看怎么优化好这段配置,如果你有学习过gradle,就应该知道,gradle是一种脚本,脚本是可以像写代码一样写逻辑的,那么上面的配置就可以转化为一个if-else代码片段:

sourceSets {
	sourceSets.all { sourceSet ->
	// println("sourceSet.name = ${sourceSet.name}")
	if (sourceSet.name.contains('Debug') || sourceSet.name.contains('Release')) {
		if (sourceSet.name.contains("china") 
				|| sourceSet.name.contains("a")
				|| sourceSet.name.contains("b")
				|| ...) {
				sourceSet.assets.srcDirs = ['src/free/assets']
			}
		}
	}
}
复制代码

现在你可能会觉得这样写好像精简不了多少,不过一旦你的业务复杂起来,像这样用代码的逻辑思维来处理配置,相信这会是一种不错的选择。

有兴趣的可以打印下sourceSet.name;if的写法不一定要用contains(),也可以用其他的判断方式,具体看开发者自己决定。

2、修改程序主入口

对于sourceSets的使用,除了针对修改assets以外,java文件、res资源文件、清单文件等等都是可以用同样的方式进行“扩充”的,比如不同渠道共用一套java代码逻辑,那么我们可以把这套代码单独抽取出来存放在一个其他目录下,然后使用sourceSets对其进行添加。这里就以我亲身经历来说明,我是如何通过sourceSets对于java和清单文件进行指定,并且完美解决此类"变态"需求的。

1)背景

新的app项目开发完成,现在需要将项目定制化后上线,项目整体采用 1个Activity + n个Fragment架构,这个Activity便是程序主入口,因为我们产品是做机顶盒app开发,产品开发完成后,需要上线到盒子运营商(局方)的应用商店,然后通过盒子推荐位(EPG)启动我们开发的app,因此上线后,需要提供app的包名和类名给到局方,假设新app的包名和类名分别如下:

包名:com.lqr.newapp
类名:com.lqr.newapp.MainActivity
复制代码

2)需求

把新app的包名和类名改成跟旧app的一样,因为局方那边不想换~~假设旧app的包名和类名如下:

包名:com.lqr.oldapp
类名:com.lqr.oldapp.MainActivity
复制代码

3)问题

修改包名很简单,但是修改入口类名就很麻烦了,如果我在该渠道目录下新增一个com.lqr.oldapp.MainActivity,并在其清单文件中进行注册,那么,在打包时,渠道目录下的AndroidManifest.xml会与main目录下的AndroidManifest.xml进行合并。

而main目录下的AndroidManifest.xml中已经注册了com.lqr.newapp.MainActivity,这样就会导致,最终输出apk包中的清单文件会有2个入口类。

是的,这样的产品交付出去,确实也可以应付掉局方的需求,但是,一旦盒子安装了这个app,那么盒子Launcher上可能会同时出现2个入口icon,到时又是一顿折腾,毕竟app上线流程比较麻烦,我们最好是保证产品就一个入口。

4)分析

因为变体的资源合并规则,只要渠道目录和main目录下都存在AndroidManifest.xml,那么最终apk包里的清单文件合并出来的就会是2个文件的融合,所以,不能在这2个清单文件中分别注册入口。可以抽出2个不同入口的AndroidManifest.xml存放到其他目录,main下的AndroidManifest.xml只注册通用组件即可。

5)操作:

a. 抽离MainActivity(oldapp)

在app目录下,创建一个support/entry目录(名字随意),用于存放入口相关功能的代码及资源文件,将com.lqr.oldapp.MainActivity放到support/entry/java目录下。

b. 抽离AndroidManifest.xml

在support目录下,创建manifest(名字随意),用于存放各渠道对应的AndroidManifest.xml,如:

其中newapp目录下的AndroidManifest.xml:

<application>
    <activity android:name="com.lqr.newapp.MainActivity">
        <intent-filter>
            <action android:name="android.intent.action.MAIN"/>

            <category android:name="android.intent.category.LAUNCHER"/>
        </intent-filter>
    </activity>
</application>
复制代码

oldapp目录下的AndroidManifest.xml:

<application>
    <activity android:name="com.lqr.oldapp.MainActivity">
        <intent-filter>
            <action android:name="android.intent.action.MAIN"/>
            <category android:name="android.intent.category.LAUNCHER"/>
        </intent-filter>
    </activity>
</application>
复制代码

c. 配置sourceSets

经过上面2步后,oldapp的MainActivity与各自的主入口注册清单文件就被抽离出去了,接下来就是使用sourceSets,根据不同的渠道名,指定java与清单文件即可:

sourceSets {
    sourceSets.all { sourceSet ->
        // project.logger.log(LogLevel.ERROR, "sourceSet.name = " + sourceSet.name)
        if (sourceSet.name.contains('Debug') || sourceSet.name.contains('Release')) {
            if (sourceSet.name.contains("china")) {
                sourceSet.java.srcDirs = ['support/entry/java']
                sourceSet.manifest.srcFile 'support/manifest/oldapp/AndroidManifest.xml'
            } else {
                sourceSet.manifest.srcFile 'support/manifest/newapp/AndroidManifest.xml'
            }
        }
    }
}
复制代码

至此,最终打出的apk包中的AndroidManifest.xml中就只会保留一个主入口了,完美解决了局方要求。

3、解疑

Q:为什么要把oldapp的MainActivity也抽出去?

A:因为oldapp的MainActivity不单只是china这个渠道需要用到,后续还会被其它渠道使用,为了后续复用考虑,于是就把MainActivity抽离出来。

Q:为什么sourceSets中要判断sourceSet.name是否包含Debug或Release?

A:如果你有打印过sourceSet.name的话,你一定会发现输出的结果不单单只是那几个变体名,还有androidTest、test、main等等这些,但我们仅仅只是想对工程变体(chinaDebug、chinaRelease、freeDebug、freeRelease)指定java目录和清单文件而已,如果对test、main这类“东西”也指定的话,结果并不是我们想要的,所以,一定要确保source配置的是我们想要指定的变体,而非其他。

Q:sourceSets与变体合并的关系究竟如何?

A:以java源码目录为例,默认AS工程的java源码目录是【src/main/java】,在gradle中通过sourceSets指定了另一个目录,比如【support/entry/java】,那么打包时,AS会认为这2个目录均是有效的java目录,所以,sourceSets指定的java目录仅仅只是对原来的扩充,而非替换。还是以java源码目录为例,如果你的项目配置了多渠道,在不考虑sourceSets的情况下,项目在打包时,因为变体合并的特性,有效的java目录也是有2个,分别是【src/main/java】和【src/渠道名/java】,变体的合并规则不会因为sourceSets的配置而改变,如果将上述2种情况一起考虑上的话,那么最终打包时,有效的java目录则是3个,分别是【src/main/java】、【src/渠道名/java】、 【support/entry/java】。

五、渠道依赖

我们知道,要在gradle中添加第三方库依赖的话,需要在dependencies领域进行配置,常见的configuration有provided(compileOnly)、compile(api)、implementation等等,它们的区别请自行百度查阅了解,针对【Build Type】、【Product Flavor】、【Build Variant】,这些configuration也会出现一些组合,如:

a. 构建类型组合

debugCompile	// 所有的debug变体都依赖
releaseCompile	// 所有的release变体都依赖
复制代码

b. 多渠道组合

chinaCompile	// china渠道依赖
americaCompile	// america渠道依赖
freeCompile		// free渠道依赖
复制代码

c. 变体组合

chinaDebugCompile		// chinaDebug变体依赖
chinaReleaseCompile		// chinaRelease变体依赖
americaDebugCompile		// americaDebug变体依赖
americaReleaseCompile	// americaRelease变体依赖
freeDebugCompile		// freeDebug变体依赖
freeReleaseCompile		// freeRelease变体依赖
复制代码

1、常规方式配置渠道依赖

通过上述组合就可以轻松配置好各种情况下的依赖了,如:

// autofittextview
compile 'me.grantland:autofittextview:0.2.+'
// leakcanary
debugCompile "com.squareup.leakcanary:leakcanary-android:1.6.1"
debugCompile "com.squareup.leakcanary:leakcanary-support-fragment:1.6.1"
releaseCompile "com.squareup.leakcanary:leakcanary-android-no-op:1.6.1"
// gson
chinaCompile 'com.google.code.gson:gson:2.6.2'
americaCompile 'com.google.code.gson:gson:2.6.2'
freeCompile 'com.google.code.gson:gson:2.5.2'
复制代码

2、代码方式配置渠道依赖

虽然官方给出的多种组合依赖可以解决几乎所有的依赖问题,但实际上,当渠道有很多很多时,整个gradle文件将变得冗长臃肿,你能想像20多个渠道中只有1个渠道依赖的gson版本不同的情况吗?所以,这时候就需要考虑一下,充分利用好gradle作为脚本的特性,使用代码方式来进行渠道依赖:

dependencies {
    gradle.startParameter.getTaskNames().each { task ->
        // project.logger.log(LogLevel.ERROR, "lqr print task : " + task)
        if (task.contains('free')) {
            compile 'com.google.code.gson:gson:2.5.2'
        } else {
            compile 'com.google.code.gson:gson:2.6.2'
        }
    }
}
复制代码

另外,还有一种方式是我之前项目中使用过的,但这种方式不支持依赖远程仓库组件,这里也记录一下:

dependencies {
	// 配置 插件化库 依赖
    applicationVariants.all { variant ->
        if (variant.flavorName == 'china')
                ||variant.flavorName == 'america') {
            dependencies.add("${variant.flavorName}Compile", project(':DroidPluginFix'))
        } else {
            dependencies.add("${variant.flavorName}Compile", project(':DroidPlugin'))
        }
    }
}
复制代码

要知道,以下写法是正确的,但就是不生效:

dependencies.add("${variant.flavorName}Compile", 'com.google.code.gson:gson:2.6.2')
复制代码

DroidPluginFix是最新官方适配了Android7、8的DroidPlugin,而DroidPlugin则没有适配,因为历史原因,需要对不同的渠道依赖不同的DroidPlugin版本。

六、结语

在公司待了有1年半了,这段时间里对自己的要求很严格,学习了很多新知识,并大胆的用于实践,收获颇多,因为公司业务的特殊性,对gradle以及多渠道的掌握要求比较高,所以,这也是我这段时间来重点学习的一部分,但是毕竟是单独学习这些知识,可能也会存在一些掌握不到位的情况,所以上面提到的知识如果有误或有更好的处理方式,欢迎各位指出和分享,thx~

关注下面的标签,发现更多相似文章
评论