查看原文
其他

这样使用Gradle可以神奇地打各种渠道包

GitLqr 郭霖 2020-10-29


/   今日科技快讯   /


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,原因如下:


  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']
    }
}


复用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(),也可以用其他的判断方式,具体看开发者自己决定。


修改程序主入口


对于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~


推荐阅读:

干货分享,组件化开发到底存在哪些优势?

Android和H5那些不可描述的事情...

Canvas可以画出任何你想要的效果


欢迎关注我的公众号

学习技术或投稿



长按上图,识别图中二维码即可关注


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存