这样使用Gradle可以神奇地打各种渠道包
/ 今日科技快讯 /
6月12日,在特斯拉2019年年度股东大会召开之际,正值这家电动汽车制造商处于关键性的历史时刻。对于特斯拉的投资者来说,过去的一年就像坐过山车一样,特斯拉股价最高升至每股387.46美元,最低跌至每股176.99美元。特斯拉首款大众型电动汽车Model 3就处于这些剧烈波动的中心。
/ 作者简介 /
本篇文章来自GitLqr的投稿,分享了如何利用gradle来实现多渠道配置,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。
GitLqr的博客地址:
https://juejin.im/user/58a53faf5c497d005fa78737
/ 新增渠道 /
使用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》:
https://link.juejin.im/?target=http%3A%2F%2Ftools.android.com%2Ftech-docs%2Fnew-build-system%2Fapplicationid-vs-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下目录就会高亮。知道如何创建变体目录后,下面就开始进行资源合并与代码整合了。
资源合并
资源文件有哪些?我们可以这样认为:
资源文件 = 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)文件的合并是对整个文件进行替换的~。
代码整合
代码文件,顾名思义就是指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();
}
其他
前面只说到了res和java这2个目录,那么assets呢,它是属于哪种?很可惜,assets虽然是资源,但它不是合并,而是整合,也就是说,assets文件的处理方式跟java文件的处理方式是一样的,不能在渠道目录和main目录下同时存在相同的assets文件,这将对某些需求实现造成阻碍,举个例子,假设china与free使用的assets资源是一样的,而america单独使用自己的assets资源,并且这些assets资源文件名都是一样的,那这时要怎么办呢?给每个渠道都放一份各自的assets资源吗?这种做法可行,但很low,原因如下:
复用性差:都说了china与free使用的资源是一样的,从整个工程的角度来看,一个工程里放了2份一模一样的assets资源文件,如果我有10个渠道,其中9个渠道使用的assets资源是一样的要怎么办,copy9次?
维护成本高:在开发行业里,需求变动是很常见的事,产品经理会时不时改下需求,所以,叫你改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']
}
}
复用assets资源
对于多渠道共用同一套assets资源文件这个问题,结合sourceSets,我们可以这么处理,步骤如下:
把共用的assets资源存放到一个渠道目录下,如free/assets。
修改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(),也可以用其他的判断方式,具体看开发者自己决定。
修改程序主入口
对于sourceSets的使用,除了针对修改assets以外,java文件、res资源文件、清单文件等等都是可以用同样的方式进行“扩充”的,比如不同渠道共用一套java代码逻辑,那么我们可以把这套代码单独抽取出来存放在一个其他目录下,然后使用sourceSets对其进行添加。这里就以我亲身经历来说明,我是如何通过sourceSets对于java和清单文件进行指定,并且完美解决此类"变态"需求的。
背景
新的app项目开发完成,现在需要将项目定制化后上线,项目整体采用 1个Activity + n个Fragment架构,这个Activity便是程序主入口,因为我们产品是做机顶盒app开发,产品开发完成后,需要上线到盒子运营商(局方)的应用商店,然后通过盒子推荐位(EPG)启动我们开发的app,因此上线后,需要提供app的包名和类名给到局方,假设新app的包名和类名分别如下:
包名:com.lqr.newapp
类名:com.lqr.newapp.MainActivity
需求
把新app的包名和类名改成跟旧app的一样,因为局方那边不想换~~假设旧app的包名和类名如下:
包名:com.lqr.oldapp
类名:com.lqr.oldapp.MainActivity
问题
修改包名很简单,但是修改入口类名就很麻烦了,如果我在该渠道目录下新增一个com.lqr.oldapp.MainActivity,并在其清单文件中进行注册,那么,在打包时,渠道目录下的AndroidManifest.xml会与main目录下的AndroidManifest.xml进行合并。
而main目录下的AndroidManifest.xml中已经注册了com.lqr.newapp.MainActivity,这样就会导致,最终输出apk包中的清单文件会有2个入口类。
是的,这样的产品交付出去,确实也可以应付掉局方的需求,但是,一旦盒子安装了这个app,那么盒子Launcher上可能会同时出现2个入口icon,到时又是一顿折腾,毕竟app上线流程比较麻烦,我们最好是保证产品就一个入口。
分析
因为变体的资源合并规则,只要渠道目录和main目录下都存在AndroidManifest.xml,那么最终apk包里的清单文件合并出来的就会是2个文件的融合,所以,不能在这2个清单文件中分别注册入口。可以抽出2个不同入口的AndroidManifest.xml存放到其他目录,main下的AndroidManifest.xml只注册通用组件即可。
操作
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中就只会保留一个主入口了,完美解决了局方要求。
解疑
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变体依赖
常规方式配置渠道依赖
通过上述组合就可以轻松配置好各种情况下的依赖了,如:
// 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'
代码方式配置渠道依赖
虽然官方给出的多种组合依赖可以解决几乎所有的依赖问题,但实际上,当渠道有很多很多时,整个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版本。
/ 总结 /
因为公司业务的特殊性,对gradle以及多渠道的掌握要求比较高,所以,这也是我这段时间来重点学习的一部分,但是毕竟是单独学习这些知识,可能也会存在一些掌握不到位的情况,所以上面提到的知识如果有误或有更好的处理方式,欢迎各位指出和分享,thx~
推荐阅读:
欢迎关注我的公众号
学习技术或投稿
长按上图,识别图中二维码即可关注