引言
在实际项目中,经常会遇到这样的需求:
同一套核心代码,需要维护多个渠道包,或者多个略有差异的定制 App。
比如:
-
小米渠道包首页显示
mi -
华为渠道包首页显示
hw -
苏州版使用苏州启动图
-
全国版使用全国启动图
-
不同渠道接入不同统计参数
-
不同版本使用不同 App 名称、Logo、隐私协议地址
这些需求如果靠复制项目来做,后期维护成本会非常高。更合理的方式是使用 Android Gradle 的 productFlavors和 buildTypes,通过 Gradle 在构建阶段生成不同的包。
这篇文章重新梳理一下 Android 多渠道打包的核心概念,以及在组件化项目中常见的 No matching variant of project问题。
一、先分清三个概念
以前我对这个概念理解得不够准确,容易把"构建类型"说成 productFlavors + buildTypes。
现在更准确的理解应该是:
Build Variant = Product Flavor × Build Type
也就是说,最终真正参与构建的是 Build Variant ,它是 ProductFlavor 和 BuildType 组合出来的结果。
1. BuildType
buildTypes 表示构建方式。
常见的是:
debug
release
staging
例如:
buildTypes {
debug {
isDebuggable = true
}
release {
isMinifyEnabled = true
isShrinkResources = true
}
}
debug 和 release 关注的是调试、混淆、签名、日志、压缩等构建行为。
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"
所以 xiaomi、huawei、oppo 都属于渠道维度。
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 的变体体系。
只有把 BuildType、ProductFlavor、Build Variant、applicationId、namespace、sourceSet 资源覆盖和组件化变体匹配串起来,才能真正把多渠道打包这件事讲清楚。