Android AGP 8.x 多渠道打包:ProductFlavor、Build Variant 与资源覆盖

引言

在实际项目中,经常会遇到这样的需求:

同一套核心代码,需要维护多个渠道包,或者多个略有差异的定制 App。

比如:

  • 小米渠道包首页显示 mi

  • 华为渠道包首页显示 hw

  • 苏州版使用苏州启动图

  • 全国版使用全国启动图

  • 不同渠道接入不同统计参数

  • 不同版本使用不同 App 名称、Logo、隐私协议地址

这些需求如果靠复制项目来做,后期维护成本会非常高。更合理的方式是使用 Android Gradle 的 productFlavorsbuildTypes,通过 Gradle 在构建阶段生成不同的包。

这篇文章重新梳理一下 Android 多渠道打包的核心概念,以及在组件化项目中常见的 No matching variant of project问题。


一、先分清三个概念

以前我对这个概念理解得不够准确,容易把"构建类型"说成 productFlavors + buildTypes

现在更准确的理解应该是:

复制代码
Build Variant = Product Flavor × Build Type

也就是说,最终真正参与构建的是 Build Variant ,它是 ProductFlavorBuildType 组合出来的结果。

1. BuildType

buildTypes 表示构建方式。

常见的是:

复制代码
debug
release
staging

例如:

复制代码
buildTypes {
    debug {
        isDebuggable = true
    }

    release {
        isMinifyEnabled = true
        isShrinkResources = true
    }
}

debugrelease 关注的是调试、混淆、签名、日志、压缩等构建行为。

2. ProductFlavor

productFlavors 表示产品差异、渠道差异、地区差异、客户差异。

例如:

复制代码
xiaomi
huawei
oppo
vivo
suzhou
china
free
pro

如果是同一个 App 发布到不同应用市场,可以用:

复制代码
xiaomi / huawei / oppo / vivo

如果是不同地区或客户版本,可以用:

复制代码
suzhou / china
clientA / clientB

3. Build Variant

当我们配置了:

复制代码
buildTypes:
  debug
  release

productFlavors:
  suzhou
  china

最终 Gradle 会组合出:

复制代码
suzhouDebug
suzhouRelease
chinaDebug
chinaRelease

这些组合结果才叫 Build Variant。


二、多渠道包和多定制 App 要分开

多渠道打包最容易混淆的是包名问题。

一句话总结:

复制代码
多渠道包:applicationId 不变
多定制 App:applicationId 可以不同

1. 同一个 App 的多渠道包

比如:

复制代码
小米市场包
华为市场包
OPPO 市场包
vivo 市场包

这些本质上还是同一个 App。

这种情况下,正式包的 applicationId 不应该变:

复制代码
defaultConfig {
    applicationId = "com.example.app"
}

不同渠道只改:

复制代码
渠道号
统计参数
资源
Manifest 占位符
部分渠道 SDK

不要给每个渠道设置不同包名。

否则应用市场会认为它们是不同 App,用户也不能作为同一个 App 正常升级。

2. 多个定制 App

如果是:

复制代码
客户 A App
客户 B App
苏州版 App
全国版 App
免费版 App
专业版 App

并且希望它们可以在同一台设备上同时安装,那么就需要不同的 applicationId

例如:

复制代码
productFlavors {
    create("clientA") {
        dimension = "brand"
        applicationId = "com.example.clienta"
    }

    create("clientB") {
        dimension = "brand"
        applicationId = "com.example.clientb"
    }
}

因为 Android 系统真正识别安装包身份的是 applicationId


三、AGP 8.x 下的基础配置

现在新项目大多已经是 AGP 8.x 及以上版本,模块里一般需要显式配置 namespace

一个基础的多渠道配置如下:

复制代码
android {
    namespace = "com.example.app"
    compileSdk = 35

    defaultConfig {
        applicationId = "com.example.app"
        minSdk = 24
        targetSdk = 35
        versionCode = 1
        versionName = "1.0.0"

        manifestPlaceholders["CHANNEL_VALUE"] = "default"
    }

    buildFeatures {
        buildConfig = true
    }

    buildTypes {
        debug {
            isDebuggable = true
        }

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

    flavorDimensions += "channel"

    productFlavors {
        create("xiaomi") {
            dimension = "channel"
            buildConfigField("String", "CHANNEL", "\"xiaomi\"")
            resValue("string", "app_channel", "xiaomi")
            manifestPlaceholders["CHANNEL_VALUE"] = "xiaomi"
        }

        create("huawei") {
            dimension = "channel"
            buildConfigField("String", "CHANNEL", "\"huawei\"")
            resValue("string", "app_channel", "huawei")
            manifestPlaceholders["CHANNEL_VALUE"] = "huawei"
        }

        create("oppo") {
            dimension = "channel"
            buildConfigField("String", "CHANNEL", "\"oppo\"")
            resValue("string", "app_channel", "oppo")
            manifestPlaceholders["CHANNEL_VALUE"] = "oppo"
        }
    }
}

这里需要注意:

复制代码
namespace:R、BuildConfig 等生成代码的命名空间
applicationId:最终安装到手机和应用市场识别的包名
源码 package:Kotlin / Java 文件自己的包路径

这三者有关联,但不是完全一回事。


四、dimension、BuildConfig、resValue、manifestPlaceholders 分别干什么

以小米渠道为例:

复制代码
create("xiaomi") {
    dimension = "channel"
    buildConfigField("String", "CHANNEL", "\"xiaomi\"")
    resValue("string", "app_channel", "xiaomi")
    manifestPlaceholders["CHANNEL_VALUE"] = "xiaomi"
}

这几行分别对应不同用途。

1. dimension

复制代码
dimension = "channel"

表示当前 flavor 属于 channel 这个维度。

前面声明了:

复制代码
flavorDimensions += "channel"

所以 xiaomihuaweioppo 都属于渠道维度。

2. buildConfigField

复制代码
buildConfigField("String", "CHANNEL", "\"xiaomi\"")

这会生成:

复制代码
BuildConfig.CHANNEL

代码里可以这样用:

复制代码
when (BuildConfig.CHANNEL) {
    "xiaomi" -> {
        // 小米渠道逻辑
    }

    "huawei" -> {
        // 华为渠道逻辑
    }
}

如果需要在代码中判断当前渠道,就用 BuildConfig.CHANNEL

3. resValue

复制代码
resValue("string", "app_channel", "xiaomi")

这会生成一个资源:

复制代码
<string name="app_channel">xiaomi</string>

可以像普通资源一样使用:

复制代码
val channel = getString(R.string.app_channel)

或者 Compose 中:

复制代码
Text(text = stringResource(R.string.app_channel))

如果只是资源层面的文案差异,也可以通过 resValue 解决。

4. manifestPlaceholders

复制代码
manifestPlaceholders["CHANNEL_VALUE"] = "xiaomi"

这个是给 AndroidManifest.xml 里的占位符用的。

比如 Manifest 中写:

复制代码
<meta-data
    android:name="APP_CHANNEL"
    android:value="${CHANNEL_VALUE}" />

打小米包时会变成:

复制代码
<meta-data
    android:name="APP_CHANNEL"
    android:value="xiaomi" />

打华为包时会变成:

复制代码
<meta-data
    android:name="APP_CHANNEL"
    android:value="huawei" />

这类配置常用于友盟、推送、统计 SDK 的渠道标识。


五、sourceSet 资源覆盖机制

多渠道打包中,最常用、也最干净的玩法是资源覆盖。

比如现在有这样的结构:

复制代码
app/src/main/res/drawable-nodpi/ic_splash_logo1.png
app/src/suzhou/res/drawable-nodpi/ic_splash_logo1.png
app/src/china/res/drawable-nodpi/ic_splash_logo1.png

代码或 XML 里仍然只写:

复制代码
@drawable/ic_splash_logo1

不需要写:

复制代码
@drawable/suzhou_ic_splash_logo1
@drawable/china_ic_splash_logo1

当构建苏州包时:

复制代码
./gradlew :app:assembleSuzhouRelease

Gradle 会识别当前 variant 是:

复制代码
suzhou + release

它会合并这些资源源集:

复制代码
app/src/main/res
app/src/suzhou/res
app/src/release/res
app/src/suzhouRelease/res

如果这些源集里有同名资源,例如都叫:

复制代码
@drawable/ic_splash_logo1

那么 flavor 里的资源会覆盖 main 里的默认资源。

所以苏州包最终拿到的是:

复制代码
app/src/suzhou/res/drawable-nodpi/ic_splash_logo1.png

全国包最终拿到的是:

复制代码
app/src/china/res/drawable-nodpi/ic_splash_logo1.png

核心点就一句话:

复制代码
代码和 XML 不需要写 suzhou 路径,只要资源名一样,构建哪个 flavor,Gradle 就会把对应 flavor 文件夹下的同名资源合进去。

这也是多渠道包最常用的玩法:

复制代码
同一个 @drawable/ic_splash_logo1
不同渠道放不同图片
构建哪个渠道,就自动使用哪个渠道的图片

六、哪些内容适合用资源覆盖

资源覆盖非常适合处理:

复制代码
启动页 logo
App 图标
App 名称
首页 banner
主题色
隐私协议地址
渠道文案
欢迎页图片

比如 App 名称:

公共资源:

复制代码
app/src/main/res/values/strings.xml

<string name="app_name">默认应用</string>

苏州渠道:

复制代码
app/src/suzhou/res/values/strings.xml

<string name="app_name">苏州维保</string>

全国渠道:

复制代码
app/src/china/res/values/strings.xml

<string name="app_name">全国维保</string>

Manifest 中仍然写:

复制代码
android:label="@string/app_name"

Gradle 会根据当前构建的 flavor 自动使用对应资源。

所以,多渠道差异能用资源覆盖解决的,不要写 if else


七、组件化项目中 module 要不要都加 flavor

这是组件化项目里很容易纠结的地方。

假设项目结构是:

复制代码
app
libs
  lib_basic
  lib_net
  lib_logger
modules
  mod_home
  mod_mine
  mod_task

第一版多渠道打包,通常只需要在 app 模块配置 productFlavors

普通 library module 不一定要跟着加:

复制代码
xiaomi
huawei
oppo
suzhou
china

如果只是 App 壳层有渠道差异,比如 App 名称、启动图、渠道号、统计参数,这些放在 app 模块即可。

只有下面几种情况,才考虑让 module 也配置 flavor:

复制代码
某个 module 自己需要根据渠道编译不同代码
某个 module 只在某个渠道接入特殊 SDK
某个 module 自己也声明了 flavor dimension
出现 No matching variant 报错

不要一上来就给所有模块都加渠道维度,这会让项目复杂度快速上升。


八、No matching variant of project 是怎么来的

组件化项目中,经常会遇到:

复制代码
No matching variant of project

这个问题的本质是:

复制代码
app 当前正在构建某个 variant,
但是它依赖的 module 没有一个合适的 variant 可以匹配。

比如 app 选择了:

复制代码
stagingDebug

但是某个 library 只有:

复制代码
debug
release

Gradle 不知道应该用 library 的哪个 variant,于是就报错。


九、matchingFallbacks 怎么用

场景一:App 有 staging,library 没有 staging

比如 app 中有:

复制代码
buildTypes {
    create("staging") {
        initWith(getByName("release"))
        matchingFallbacks += listOf("release", "debug")
    }
}

意思是:

复制代码
如果依赖库没有 staging 这个 buildType,
就优先尝试使用 release,
如果 release 也没有,再尝试 debug。

这适合 buildType 不匹配的情况。


场景二:App 和 library 有同一个维度,但 flavor 名称对不上

比如 app 有:

复制代码
channel: suzhou / china

library 有:

复制代码
channel: common

这时 app 构建 suzhouRelease,但是 library 没有 suzhou,就可能需要:

复制代码
productFlavors {
    create("suzhou") {
        dimension = "channel"
        matchingFallbacks += listOf("common")
    }
}

意思是:

复制代码
如果 library 找不到 suzhou,就用 common 来兜底。

十、missingDimensionStrategy 怎么用

还有一种情况是:

复制代码
library 有一个额外的 flavor dimension,
但是 app 没有这个 dimension。

比如 library 有:

复制代码
abi: arm64 / x86

但是 app 没有 abi 维度。

这时可以在 app 的 defaultConfig 中指定:

复制代码
defaultConfig {
    missingDimensionStrategy("abi", "arm64")
}

意思是:

复制代码
当依赖库需要 abi 维度,但 app 没有声明时,默认选择 arm64。

简单记:

复制代码
matchingFallbacks:当前维度有,但是当前值匹配不上,用兜底值
missingDimensionStrategy:依赖库多了一个维度,app 没有这个维度,指定默认选择

十一、实际项目建议

如果只是做多渠道包,我建议按这个顺序来:

复制代码
1. 只在 app 模块增加 productFlavors
2. applicationId 保持不变
3. 用 BuildConfig.CHANNEL 处理代码里的渠道判断
4. 用 manifestPlaceholders 处理 Manifest 里的渠道参数
5. 用 sourceSet 资源覆盖处理启动图、App 名称、Logo、主题色
6. 普通 libs 和 modules 先不要配置 flavor
7. 出现 No matching variant 后,再根据具体报错处理 matchingFallbacks 或 missingDimensionStrategy

也就是说,不要一开始就把所有模块都复杂化。

多渠道打包第一版应该先做到:

复制代码
同一个 applicationId
不同渠道资源
不同渠道参数
不同渠道打包任务

十二、打包命令

假设配置了:

复制代码
suzhou
china
xiaomi
huawei

那么可以执行:

复制代码
./gradlew :app:assembleSuzhouRelease
./gradlew :app:assembleChinaRelease
./gradlew :app:assembleXiaomiRelease
./gradlew :app:assembleHuaweiRelease

打完后 APK 一般在:

复制代码
app/build/outputs/apk/suzhou/release/
app/build/outputs/apk/china/release/
app/build/outputs/apk/xiaomi/release/
app/build/outputs/apk/huawei/release/

Android Studio 里也可以通过 Build Variants 面板切换:

复制代码
suzhouDebug
chinaDebug
xiaomiDebug
huaweiDebug

顶部运行配置仍然可能显示 app,那只是运行 app 模块,不代表当前渠道。真正当前构建的渠道,要看 Build Variants 面板。


总结

Android 多渠道打包的核心不是复制项目,而是利用 Gradle 的变体构建能力。

几个关键点:

复制代码
Build Variant = ProductFlavor × BuildType

BuildType 适合表示 debug、release、staging
ProductFlavor 适合表示渠道、地区、客户、产品版本

多渠道包 applicationId 不变
多定制 App 才考虑不同 applicationId

代码判断渠道用 BuildConfig.CHANNEL
Manifest 渠道参数用 manifestPlaceholders
资源差异优先用 sourceSet 同名资源覆盖

组件化项目中,普通 module 不一定要跟着 app 配置 flavor
出现 No matching variant 时,再看 matchingFallbacks 和 missingDimensionStrategy

以前我更关注的是 No matching variant of project 这个报错怎么解决。现在回头看,真正应该理解的是 Android Gradle 的变体体系。

只有把 BuildTypeProductFlavorBuild VariantapplicationIdnamespace、sourceSet 资源覆盖和组件化变体匹配串起来,才能真正把多渠道打包这件事讲清楚。