Build types,Product flavors和Build variants在项目中的实践

2023/09/03

最近在项目开发中,遇到了需要依赖共同代码产出不同库的情况,踩了一些坑,现将一些想法记录下来,希望能给需要的读者提供一点思路

本文中Gradle的配置脚本使用了Kotlin,如你使用的是Groovy,配置方式可能有所不同。如果你不确定Groovy下如何修改对应的配置项,你可以通过搜索配置项名+Groovy来查看对应的配置方法。

Build types,Product flavors和Build variants的关系

Gradle作为一种构建工具,其主要作用是帮助开发者管理资源的。开发者通过资源的不同组合,实现不同的功能,得到不同的产品。在这过程中,有些共同的资源被抽象出来了,所有的产品都需要这些资源。这种情况下,作为构建工具,Gradle理所应当也要提供相应的支持,所以Build typesProduct flavorsBuild variants就出现了。

首先,作为一个产品,绝大部分开发版和发布版是会有一些不同的,日志信息,后门功能,验证逻辑可能都不太一样。针对这种情况,Gradle提供了Build types

随着产品开发,可能推出了会员版本,这时候产品不仅仅在开发和发布中有不同,在实现上也有不同了,所以Gralde为这种情况提供了Product flavor

当然,无论是会员版本还是普通版本,它们都存在开发,测试,发布流程,每个流程和每个产品组合也需要提供配置方式,所以有了Build variants。它是前两项的组合,提供一种更细粒度的控制。

它们的相互配合,就产生了无尽的可能。接下来我将结合我实际使用,向读者阐述一下这些工具的应用场景。

Build types

Build types是开发,测试,发布等过程的一个配置层级,其作用是为这些阶段提供不同的配置信息,所以在资源项基本相同的情况下使用它。如应用开发中,不同阶段通常有不同的接口地址,接口地址属于一种配置项,并且随着阶段不同而不同,所以build type就能很出色地完成这种任务。下面就用实例,简单地阐述一下build types是怎样起作用的。

首先我们需要一个项目,项目的原始目录结构如下

  build.gradle.kts   	//下文用项目build文件代替
  settings.gradle.kts	//下文用项目settings文件代替
├─app
  build.gradle.kts 	//下文用app build文件代替
  └─src
      ├─main
  AndroidManifest.xml

  ├─java
  └─io
      └─hongui
          └─gradlesample
                  MainActivity.kt

默认的Build type

Gradle已经默认为我们添加了debugrelease两种build types,分别代表开发阶段和发布阶段的配置,在app build文件中,我们可以查看Android Studio给我们生成的默认配置

android {
    buildTypes {
        // 开发配置
        debug{

        }
        // 发布配置
        release {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
}

debugrelease有着相同的配置项,只是取值不同,如

  1. isDebuggable=true使得debug下可以使用调试器;
  2. isMinifyEnabled=false不进行代码缩减,这会使得该模式下有安装包体积更大,如果为true则会移除没有使用到的类和方法,在使用反射时应注意;
  3. isShrinkResources=false不进行资源缩减,使得无用的资源也被打包进安装包;
  4. versionNameSuffix+=".debug"可以方便从版本信息中知道使用的是哪种发布模式;
  5. applicationIdSuffix+=".debug"可以配置应用ID后缀,使得在同一手机上安装多种发布包;
  6. addManifestPlaceholders(mapOf("key" to "value"))AndroidManifests.xml添加配置。

诸如此类,更多配置项可查看官方文档

当然,除了开发和发布阶段,我们可能还需要测试阶段的配置,这就需要我们自己创建了。

创建新的Build type

buildTypes构建块中,我们可以通过create方法来创建新的Build type。新的Build type和默认创建的两种是完全一样的,也有上面这些配置项可供使用。

android {
    buildTypes {
        // 开发配置
        debug{
            
        }
        // 新建的测试配置,名字是alpha
        create("alpha"){
            
        }
        // 发布配置
        release {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
}

当你创建完后,Android Studio会在文件标签页下方提示你项目需要同步,点击Sync Now按钮,同步完成后,你可以在侧边工具中中找到Build Variants工具窗口,这时候,Active Build Variant中就会多一个alpha的构建选项

build variant preview

这就是告诉你,你可以发布alpha环境的包了。

可以看到大部分配置项都是为了区分环境而设置的,当然,我觉得目前为止,这些配置项对于应用开发是不够的,开发者需要的可能是更直观的区分,如应用图标,应用名称,而这些就要通过新的规则引入来实现了。

使用源代码集

Gradle除了提供配置项来配置环境,还提供了源代码集功能来区分资源,源代码集提供单独配置某个Build types的能力。通常一个module会在src目录下创建一个main目录,在该目录下存放代码和资源,我们称之为主源代码集。当我们希望Build types之间有所不同,要配置不同资源时,就可以**src**目录下创建和**Build types**同名的变种目录,称之为变种源代码集,它们的结构是完全相同的,可以存放变种特有的代码和资源,在编译时,Gradle会把两个源代码集中的资源和代码放在一起编译,好像就是在同一个目录下一样。如我希望让应用在不同的环境下显示不同的应用名称或者图标,就可以使用源代码集功能实现。

  1. 首先,我们需要确定目标模块,实例中目标模块是app;
  2. 然后,我们进入到目标模块的src目录;
  3. 接着以src为父目录,新建和Build type名字一一对应的文件夹,如debug环境,就新建debug文件夹;
  4. 参考main文件结构,配置想区分的资源;
  5. 同步

下面就是修改alpharelease模式下,应用名称的一个实例

app
└─src
    ├─alpha
  └─res
      ├─values
      strings.xml
    ├─main
  └─res
      ├─values
      strings.xml

    ├─release
  └─res
      ├─values
      strings.xml

strings.xml定义了三个键相同,值不同的字符串资源,下面就是alpha,release环境下的运行效果。

build alpha build release

当然,除了这些普通资源,代码也可以配置,只需要在上面目录结构的基础上,加上java目录,然后在对应包下添加文件,就好像直接写在main目录下的包中一样,只不过这些类只在目标构建类型和目录匹配时才会生效。

Product flavors

大部分时候,Build types已经够用了,但是当我们需要两个或以上不同类型产品,但是大部分资源要共用的情况,就该考虑Product flavors了。如果说Build types完成的是动态的配置的话,Product flavors完成的就是静态的配置,正如它名字一样,它是用来区分产品的。

我们可以先来看看常用的配置项有哪些

android{
    defaultConfig {
        applicationId = "io.hongui.gradlesample"
        minSdk = 24
        targetSdk = 33
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
}

上面代码块中,defaultConfig构建块就相当于一个Product flavor,里面的内容都是它常用的配置项。可以看到,假如将这些都重新设置之后,它完全可以看成一个新应用了。

在以前,我常常在想,Build typesProduct flavors有很多相似的地方,为什么要同时提供这两种配置方式。直到我在实际工作中遇到了问题,才慢慢理解这些东西。

在之前的工作中,我需要基于同一套C++代码和两套接口,提供两个功能类型不完全一样的sdk。最开始我想法很粗暴,直接新建另一个module解决。但是在实际开发过程中,我又同时需要共有一些Kotlin的代码来实现一些功能的复用,这些代码大部分是internal的,所以我为了共用代码,完全破坏了以前的封装,将很多不应该暴露出去的代码暴露出去了。所以我越想越觉得不对,最终通过研究Gradle的功能,开始尝试用Product flavors的思路解决这个问题。

为了更好地描述实际遇到的问题,我们的项目目录结构变成了这样

│  build.gradle.kts   	//下文用项目build文件代替
│  settings.gradle.kts	//下文用项目settings文件代替
├─app
│  │  build.gradle.kts 	//下文用app build文件代替
│  └─src
│      ├─main
│      │  │  AndroidManifest.xml
│      │  │
│      │  ├─java
│      │  │  └─io
│      │  │      └─hongui
│      │  │          └─gradlesample
│      │  │                  MainActivity.kt
├─sdk
│  │  build.gradle.kts 	//下文用sdk build文件代替
│  └─src
│      ├─main
│      │  │  AndroidManifest.xml
│      │  │
│      │  ├─java
│      │  │  └─io
│      │  │      └─hongui
│      │  │          └─sdk
│      │  │              Test.kt

我新建了一个sdk模块,接下来,我们需要在sdk build文件上下文章了。

创建Product flavor

创建Product flavor的第一步是用flavorDimensions.add("sdk")Product flavor 指定一个dimensions,也就是起个名字。这一步很重要,不然会在编译时报错 Error:All flavors must now belong to a named flavor dimension. The flavor 'flavor_name' is not assigned to a flavor dimension. 起好名字后,我们就可以使用productFlavors构建块创建Product flavors了。

productFlavors {
    create("hello"){
        dimension="sdk"
    }
    create("world"){
        dimension="sdk"
    }
}

除了指定好dimension,其他项就可以根据需要覆盖掉defaultConfig的内容就好了,和Build types差不多,问题出在了app模块引用sdk模块上。

依赖配置

在没有配置Product flavors情况下,我们引用本地module就直接implementation(project(":sdk"))就行,但是创建了Product flavors后,你再看,就报错了

Could not determine the dependencies of task ':app:lintVitalReportRelease'.

> Could not resolve all task dependencies for configuration ':app:releaseRuntimeClasspath'.

   > Could not resolve project :sdk.

     Required by:

         project :app

      > The consumer was configured to find a component for use during runtime, preferably optimized for Android, as well as attribute 'com.android.build.api.attributes.BuildTypeAttr' with value 'release', attribute 'com.android.build.api.attributes.AgpVersionAttr' with value '8.1.0', attribute 'org.jetbrains.kotlin.platform.type' with value 'androidJvm'. However we cannot choose between the following variants of project :sdk:

          - helloReleaseRuntimeElements

          - worldReleaseRuntimeElements

        All of them match the consumer attributes:

诸如此类,简单地说,就是它告诉你,app模块中引用的sdk定义了Product flavors,但是app中没有定义,构建app模块的时候,就不知道app该使用sdk的哪个Product flavor。按照这个思路,我们在app中定义同名的product flavor是不是就能告诉使用它要使用哪一个Product flavor了。

同名Product flavors依赖

flavorDimensions+="sdk"
productFlavors{
    create("hello"){
        dimension="sdk"
    }
}

这相当于告诉Gralde,使用同名的Product flavor来依赖的sdk模块,它们的名字和dimensions必须一一对应。

不同名Product flavors依赖

但是,有些情况下,我们可能没法创建同名的Product flavor,这种情况下我们就需要自定义一种匹配关系了,而这个配置项就是matchingFallbacks

flavorDimensions+="sdk"
productFlavors{
    create("helloflavor"){
        dimension="sdk"
        matchingFallbacks+="hello"
    }
}

示例中,我们没有hello这种Product flavor,只有helloflavor。名字不匹配,Gradle无法确定应用用哪种Product flavor来构建helloflavor,所以同样会编译失败。所以我们手动指定了matchingFallbacks来告诉Gradle,如果找不到helloflavor,就找hello。我们知道sdk中是有hello的,所以匹配成功,构建通过。

没有Product flavors依赖

更有,我们可能完全不需要创建Product flavors,我们只希望依赖sdk中的hello,但是不想麻烦地创建Product flavors,Gradle也提供了配置项missingDimensionStrategy

但是虽然我们知道了配置项,但是由于没有创建Product flavors,这个配置项该写在哪里呢?还记得前面我说过defaultConfig相当于一种Product flavors吗,所以我们把配置项写在defaultConfig里。

它需要至少两个参数,第一个是指定dimensionssdk中指定的是sdk,所以第一个参数为了匹配到这个dimensions也必须写成sdk。第二个参数起就是Product flavors的名字,如我们希望是hello,就是传hello。假如还有第二个库,第二个库有新的Product flavors,那么还得接着传递第三个参数,这个参数名同样要和第二个库中Product flavors的名字相对应。

所以,最终的配置就是这样

android {
    defaultConfig {
        applicationId = "io.hongui.gradlesample"
        minSdk = 24
        targetSdk = 33
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

        missingDimensionStrategy("sdk","hello")
    }
}

添加源代码集

每个Product flavors通常都有自己的图标,名字,甚至自己的代码和资源,所以通常它会创建和Product flavors同名的文件夹,用于定制这个产品,也就是和Build types一样的源代码集。

Build variants

Build variants是在前两项中任取一项组合而成的产物,用于表示最终的安装包。通常我们能从名字上就能看出产物是由哪些配置项组成的,因为它的命名规律是<product-flavor><Build-Type>。如前面的sdk库,由于我们创建了两种Product flavors,然后Gradle会为我们默认创建两种Build types,两两相组合,所以最终会产生四种Build variants

  • helloDebug
  • helloRelease
  • worldDebug
  • worldRelease

它们作为最终的组合体,同样可以配置源代码集,里面资源会替换前面某个单独的Build types或者Product flavors

值得一提的是,Android Studio提供了Build variants的Tool window,方便开发者快速在这些产物间切换,切换完后,下一次点击Run就会使用选择的Build variants打包了,你可以在Android Studio的侧边栏找到它,如果没有可以在菜单->View->Tool Windows->Build variants路径下找到它。

总结

虽然,这三个配置功能,有很多共同之处,都可以使用源代码集,都可以设置一些环境区分选项。但是它们并不是重复设计,而是代表着不同的理念和粒度,在实际项目中,应该根据实际情况做不同的选择。

Build types更多针对的是发布模式,方便对开发,测试,发布等阶段进行隔离,通常在功能上不会有很大差异,适合那些环境区分的设置。

Product flavors针对的是产品本身的区别,适合在那些需要发布多个产品,但是共用资源的情况,相当于是一种产品隔离。

Build variants则是发布模式和产品区别的组合,因为多个产品同样需要发布模式的区分。

总结一句话,这三个都是用来实现隔离功能的,**Build types**是环境隔离,**Product flavors**是产品隔离,**Build variants**是产物隔离

好了,今天的分享就到这里,如果需要源码,可在这里找到,希望大家有所收获!