
做Unity安卓发行的人大概率都有过这么一段记忆发版前一天晚上运营甩来一张表华为、小米、OPPO、vivo、应用宝每个渠道都要单独的APK包名还得不一样。我第一次处理这种多渠道APK打包需求时直接在Unity里来回改bundleIdentifier打一个改一个结果华为包能装小米包覆盖安装时报“应用未安装”老用户更新全部扑街后台一串INSTALL_FAILED_UPDATE_INCOMPATIBLE。后来才真正明白同一个Unity工程稳定输出不冲突的安装包靠的不是打包时的手速而是把“身份隔离”这件事交给工程结构去管。这篇文章就围绕这个核心把我验证过的一套完整方案写出来从原理到可抄作业的配置都会覆盖。1. 先聊清楚你到手的APK为什么总在互相“打架”多渠道打包的痛点表面看是“同一份代码要出很多包”实际问题是“出出来的包能不能各安其位”。我见过最多的情况有三类每一类都在“打架”覆盖安装冲突同一个包名先后安装的APK签名不一致系统直接拒绝更新。表现就是用户手机上提示“应用未安装”或者adb安装时报INSTALL_FAILED_UPDATE_INCOMPATIBLE。共存安装冲突两个不同包名的渠道包装第一个正常装第二个时系统报INSTALL_FAILED_CONFLICTING_PROVIDER因为两个包声明了相同的ContentProvider authority。数据层面的“打架”渠道号没写对或者写死了渠道逻辑所有渠道包在统计后台全部归到一个渠道运营那边数据完全没法看。这三个问题有一个共同根源同一个Unity工程可以提供完全相同的代码和资源但Android系统区分一个应用靠的不是“你从哪里来”而是包名、签名、ContentProvider这些硬身份标识。只要你用粗暴的方式改其中一个其他身份标识没有跟着变冲突就来了。理解这一点之后多渠道打包的思路就清晰了不是在打包时东改一处西改一处而是提前定义好每个渠道的“身份模板”构建时按模板自动生成对应的包身份各不相干自然不会打架。2. 拆解三个“身份维度”包名、渠道号、签名各管什么既然要按模板出包就得先搞懂一个APK里到底哪些东西决定了它“是谁”。在我的实践里这三个维度是最核心的。2.1 applicationId系统眼中的“你是谁”Unity工程里我们通常只设置一个bundleIdentifier导出到Android工程后对应的是Gradle里的applicationId。它是Android系统识别应用的最终身份两个APK只有applicationId完全相同系统才会认为它们是同一个应用。多渠道打包最常见的需求就是不同渠道使用不同包名因为各家应用商店对包名有要求也方便同时装多个渠道包做测试。需要注意applicationId一变牵连的东西很多应用内部代码里写死的包名判断要跟着变推送SDK的注册ID会基于包名生成切换包名后别名/标签需要重新初始化第三方平台微信登录、支付宝支付的后台回调配置会校验包名换包名就换一套配置后端服务器如果按包名做了渠道白名单也需要同步维护。所以我的建议是不要把包名当成“随意改的字符串”而是作为真实验收标准的第一个维度来规划。2.2 渠道号运营统计里的“你从哪来”渠道号本身不影响安装冲突但影响业务判断。它的作用包括统计不同渠道的下载量、激活量、付费转化以及客户端上报时附带渠道信息让后端知道当前用户来自哪个市场。渠道号的注入方式常见有三种注入方式原理优点缺点AndroidManifest meta-data在Manifest里写入一个meta-data节点运行时读取简单直观Unity C#可以直接读到需要理解Manifest合并规则BuildConfig字段Gradle在编译期生成BuildConfig.CHANNEL常量Java侧读取方便类型安全C#读取需要绕一层反射运行时资源文件每个渠道放不同assets文件运行时加载灵活可以放非字符串配置文件容易漏同步维护成本高我自己最常用的是第一种meta-data方式。原因很简单它不依赖Java/Kotlin代码Unity侧通过AndroidJavaObject就能读而且Gradle的manifestPlaceholders可以非常自然地把渠道值注入到Manifest里一条配置同时解决“渠道号注入”和“Manifest差异”。2.3 签名能不能覆盖、能不能上架的硬门槛签名是另一个被很多人忽略的硬约束。Android系统要求APK必须签名签名的作用包括校验APK完整性、保证发布者身份、决定两个APK是否能在同一手机上共存或覆盖。正式签名与调试签名Unity默认打出来的包用的是Debug签名debug.keystore不能用于上架。多渠道打包必须切换到正式签名否则用户以后永远没法覆盖更新。V1/V2/V3签名方案低版本Android只认V1Android 7.0以后有V2Android 9.0以后引入V3。新版本Unity默认会同时带上V1和V2如果某些渠道要求更高版本兼容需要在gradle里配置。“同包名不同签名”是死路一旦某个包名已经被正式包签过名以后所有更新包都必须用同一把正式签名。如果中间用过测试签名那批用户的升级路径就彻底断了只能卸载重装。所以我在工程里会把签名文件放到统一目录所有渠道共用同一把正式签名除非有特殊需求比如某些海外渠道要求分证书。签名这一维度的原则是尽量统一不要每个渠道一把新签名否则维护成本和风险都会放大。3. 两条主流打包路线我为什么偏要选Gradle变体搞清楚身份维度之后剩下的问题是打包路线怎么选。我经历过两条主流路线各有适用场景。3.1 路线AUnity内循环BuildPlayer改包名这个方法是在Unity的Editor脚本里循环调用PlayerSettings.SetApplicationIdentifier改完一个渠道就BuildPipeline.BuildPlayer一次。它在“纯Unity团队”里非常流行因为不需要接触Android工程只需要写C##if UNITY_EDITOR using UnityEditor; using UnityEditor.Build.Reporting; using UnityEngine; public static class MultiChannelBuilder { public static void BuildAll() { var channels new System.Collections.Generic.Dictionarystring, string { { huawei, com.example.game.huawei }, { xiaomi, com.example.game.xiaomi }, { oppo, com.example.game.oppo } }; foreach (var kv in channels) { PlayerSettings.SetApplicationIdentifier(BuildTargetGroup.Android, kv.Value); PlayerSettings.SetScriptingDefineSymbols(BuildTargetGroup.Android, $CHANNEL_{kv.Key.ToUpper()}); var options new BuildPlayerOptions { scenes new[] { Assets/Scenes/Main.unity }, locationPathName $Build/{kv.Key}_v{Application.version}.apk, target BuildTarget.Android }; BuildReport report BuildPipeline.BuildPlayer(options); if (report.summary.result ! BuildResult.Succeeded) { Debug.LogError($[Build] {kv.Key} 打包失败); } } } } #endif这条路线的问题在于每一次BuildPlayer都是Unity完整走一遍构建流程资源导入、代码编译、IL2CPP转换、APK封装全部重来。三个渠道就可能拖掉半个多小时渠道再多一点时间成本完全不可控。而且Unity在构建过程中会对整个工程做状态更新如果你的工程有任何构建期生成的临时文件渠道之间很容易互相污染。这条路我只建议在两种情况下用渠道数量很少2到3个且没有原生Gradle定制需求。否则后面的路会更省心。3.2 路线B导出Gradle工程 productFlavors这也是我现在的主力方案。Unity只负责导出一次标准Gradle工程之后所有渠道差异全部交给Gradle的productFlavors去处理。productFlavors是Android Gradle Plugin提供的“变体”机制你在build.gradle里声明多个flavor每个flavor可以有自己的applicationId、版本号、Manifest占位符、依赖、签名配置。构建时可以单独构建某个flavor也可以一条命令批量构建所有flavor。方案B的核心优势有两个Unity侧只构建一次后续切包名、切渠道号、切换签名都不需要重新跑Unity构建流程资源打包和代码编译的增量部分都在Gradle层完成速度快得多。所有身份维度都能精确控制而且配置是声明式的写清楚就能反复使用不会每次打包靠手工改。代价是团队需要懂一点Gradle语法并且要接受“Unity导出的工程会在重导时被覆盖”这个事实。后一个问题我们后面会单独讲怎么规避。3.3 我最终采用的混合方案实际项目中我用的不是纯Gradle而是“Unity负责资源与代码Gradle负责身份与打包壳”的混合方案Unity侧只保留通用的场景、代码、Shader、AB包构建逻辑不写死渠道相关分支如果有渠道专用的资源图标、闪屏、配置通过Unity的宏定义如CHANNEL_HUAWEI在导出Gradle工程前做一次资源预筛或者干脆放到Gradle的flavor目录里做覆盖渠道号、包名、签名、Manifest差异全部在Gradle侧声明最后用批处理命令串联Unity导出 Gradle多flavor构建一条命令产出全部渠道包。这套方案把“容易出错的人工步骤”压缩到了最少机器的重复劳动也尽量避免了。4. 完整实操一个Unity工程的五渠道APK稳定输出下面是我在真实项目中完整跑通的配置按步骤走就能复现。4.1 导出前的Unity工程收尾工作在动手导Gradle工程之前先把Unity侧的几个关键项固定下来bundleIdentifierUnity侧写一个“默认值”即可真正生效的包名由Gradle的flavor决定。但是要注意Unity的Player Settings里不要留空否则某些SDK会在构建期读取空包名直接报错。版本号建议统一在Unity的Player Settings里设置versionName和versionCode会导出到Gradle的defaultConfig里。如果不同渠道版本号需要不同再在flavor里覆盖。Scripting Backend建议用IL2CPP ARMv7/ARM64这样在Gradle层做增量构建时Unity导出产物是稳定的so库不会被Gradle重新编译。Custom Main Manifest在Player Settings - Publishing Settings里勾选Custom Main ManifestUnity会生成一个Assets/Plugins/Android/AndroidManifest.xml。虽然我们主要改的是Gradle工程里的Manifest但让Unity导出结构稳定可以减少很多意外。这些收尾工作的共同目的只有一个让Unity导出过程变得可重复、可预期。每次导出结果一致Gradle侧的配置才有意义。4.2 在launcher模块落地flavor变体Unity导出的Gradle工程里真正的应用模块是launcher在Unity 2019.3之后所以我们先改launcher/build.gradle。但这里有一个关键经验不要直接改Unity导出的build.gradle而是把自定义配置放到外部gradle文件里再在build.gradle末尾apply进去。原因在于Unity每次重新导出都会覆盖launcher/build.gradle。如果你把flavor、签名配置直接写在这个文件里下次导出就全没了。正确做法是创建一个launcher/multiChannel.gradle然后在build.gradle末尾加一行apply from: multiChannel.gradlemultiChannel.gradle里的内容如下android { // 渠道维度 flavorDimensions channel productFlavors { huawei { dimension channel applicationId com.example.game.huawei manifestPlaceholders [ CHANNEL_VALUE: huawei, FILE_PROVIDER_AUTHORITY: com.example.game.huawei.fileprovider ] } xiaomi { dimension channel applicationId com.example.game.xiaomi manifestPlaceholders [ CHANNEL_VALUE: xiaomi, FILE_PROVIDER_AUTHORITY: com.example.game.xiaomi.fileprovider ] } oppo { dimension channel applicationId com.example.game.oppo manifestPlaceholders [ CHANNEL_VALUE: oppo, FILE_PROVIDER_AUTHORITY: com.example.game.oppo.fileprovider ] } vivo { dimension channel applicationId com.example.game.vivo manifestPlaceholders [ CHANNEL_VALUE: vivo, FILE_PROVIDER_AUTHORITY: com.example.game.vivo.fileprovider ] } yyb { dimension channel applicationId com.example.game.yyb manifestPlaceholders [ CHANNEL_VALUE: yyb, FILE_PROVIDER_AUTHORITY: com.example.game.yyb.fileprovider ] } } // 统一签名配置 signingConfigs { release { storeFile file(System.getenv(KEYSTORE_FILE) ?: release.keystore) storePassword System.getenv(KEYSTORE_STORE_PASSWORD) keyAlias System.getenv(KEYSTORE_KEY_ALIAS) keyPassword System.getenv(KEYSTORE_KEY_PASSWORD) } } buildTypes { release { signingConfig signingConfigs.release minifyEnabled false proguardFiles getDefaultProguardFile(proguard-android.txt) } } }用System.getenv读取签名密码是为了保证密码不写进仓库也方便CI环境注入。如果你的密钥文件路径或密码固定也可以直接写死但注意别把生产签名文件提交进版本库。这里还会遇到一个常见问题launcher模块加了flavor但unityLibrary模块没有flavorGradle能自动匹配合法变体吗答案是可以。当app模块有flavor而library模块没有时AGP会自动选择library的default variant。我实测在Unity导出的工程结构下这样配置可以正常工作。4.3 AndroidManifest占位符与Provider去重接下来要在Manifest里使用这些占位符否则渠道号不会真正写进APK。launcher/src/main/AndroidManifest.xml里在application节点内加上meta-dataapplication !-- 渠道号 -- meta-data android:namechannel android:value${CHANNEL_VALUE} / !-- FileProviderauthorities必须随包名变化否则多渠道包会冲突 -- provider android:nameandroidx.core.content.FileProvider android:authorities${FILE_PROVIDER_AUTHORITY} android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider /application注意Manifest里使用${CHANNEL_VALUE}这样的占位符编译时Gradle会把它替换成对应flavor里的manifestPlaceholders值。如果你用的是applicationId作为Provider的一部分也可以写成${applicationId}.fileproviderGradle会按当前flavor的applicationId自动填充这比手工维护FILE_PROVIDER_AUTHORITY更省事。另一个坑是Manifest合并。Unity的unityLibrary模块自带了一份AndroidManifest.xml里面也声明了UnityPlayerProviderauthorities通常写成${applicationId}.fileprovider。当多个模块的Manifest合并时如果出现相同节点冲突会在launcher的合并结果里报错编译日志会提示Attribute authority ... was also specified at ...。解决办法是在Manifest根节点加上xmlns:tools和tools:replacemanifest xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:toolshttp://schemas.android.com/tools application tools:replaceandroid:authorities !-- ... -- /application /manifest不建议无脑加tools:replace因为它会单向使用当前声明的值覆盖其他模块的值。但对于authorities这种本来就设计成每个模块唯一、且我们又明确通过占位符区分开的场景加上它是安全的。4.4 C#运行时读取渠道号并缓存Manifest写好了渠道号还得能被Unity侧读到。我封了一个ChannelHelper类只在Android真机运行时生效编辑器下返回固定值方便调试using UnityEngine; #if UNITY_ANDROID !UNITY_EDITOR public static class ChannelHelper { private static string _channel; public static string GetChannel() { if (_channel ! null) return _channel; try { using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (var activity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) using (var packageManager activity.CallAndroidJavaObject(getPackageManager)) { // 获取当前应用 ApplicationInfo var packageName activity.Callstring(getPackageName); // 128 对应 PackageManager.GET_META_DATA var applicationInfo packageManager.CallAndroidJavaObject( getApplicationInfo, packageName, 128); var metaData applicationInfo.GetAndroidJavaObject(metaData); _channel metaData.Callstring(getString, channel); if (string.IsNullOrEmpty(_channel)) _channel unknown; } } catch (System.Exception e) { Debug.LogError($[Channel] 读取渠道号失败: {e.Message}); _channel unknown; } return _channel; } } #else public static class ChannelHelper { public static string GetChannel() { return editor; } } #endif这个类有两个细节值得注意。一是缓存渠道号在应用生命周期内不会变不用每次调用都走一遍JNI跨桥读一次存静态字段就行性能开销小很多。二是128这个Magic Number它在Android源码里是PackageManager.GET_META_DATA也可以写成PackageManager.GET_META_DATA对应的常量值但C#侧没法直接引用Java常量用数字最省事。在需要上报渠道号的地方直接调用ChannelHelper.GetChannel()即可。4.5 一把梭Gradle task批量输出并自动重命名配置完flavor之后构建单渠道包很简单./gradlew :launcher:assembleHuaweiRelease如果想一条命令构建全部渠道的正式包./gradlew :launcher:assembleRelease这条命令会依次构建所有productFlavors的release变体。但生成的APK默认都叫launcher-release.apk放在不同的flavor目录下容易弄混。我习惯在multiChannel.gradle里加一段输出重命名逻辑android { applicationVariants.all { variant - variant.outputs.all { def channel variant.productFlavors[0].name def buildType variant.buildType.name def version variant.versionName def date new Date().format(yyyyMMdd_HHmm, TimeZone.getTimeZone(GMT8)) if (buildType release) { outputFileName MyGame_${channel}_v${version}_${date}.apk } else { outputFileName MyGame_${channel}_${buildType}_${date}.apk } } } }这样每个渠道包的名字会自动带上渠道、版本号、构建时间比如MyGame_huawei_v1.2.3_20240505_1530.apk。发给测试或运营的时候光看文件名就能对应到渠道和版本不容易拿错包。5. 最容易翻车的三个坑从报错日志逆推根因配置本身不复杂真正让人血压飙升的是下面三个坑。我把排查过程写出来大家遇到类似日志可以直接对号入座。5.1 INSTALL_FAILED_UPDATE_INCOMPATIBLE换包名不换签名现象用户手机上有旧包安装新渠道包时提示“应用未安装”。用adb安装会在末尾看到INSTALL_FAILED_UPDATE_INCOMPATIBLE。根因新旧APK的包名一致但签名不一致。Android系统要求覆盖安装时新签名必须与旧签名相同否则拒绝安装。常见诱因有两个手动在Unity的Player Settings里切换了签名文件没有在Gradle的signingConfigs里统一之前用debug签名装了测试包后来用正式签名打同一个包名测试包没卸载就直接覆盖。排查方式用apksigner看两个APK的签名指纹apksigner verify --print-certs old.apk apksigner verify --print-certs new.apk对比Signer #1 certificate DN或SHA-256指纹。如果不一样就是签名不一致。解决统一所有渠道包的正式签名文件测试机上的旧包先卸载再装新包。从工程层面讲把签名配置收敛到同一个signingConfigs里避免有人绕过去单独改。5.2 INSTALL_FAILED_CONFLICTING_PROVIDER合并后的Authority撞车现象同一台手机上渠道A的包能正常安装再装渠道B的包时报INSTALL_FAILED_CONFLICTING_PROVIDER。根因两个APK里声明了完全相同的ContentProvider authority。Android系统规定同一authority在系统内全局唯一第二个申请者直接被拒绝。最常见的来源是第三方SDK打进了自己的FileProvider且authorities是写死的比如com.umeng.fileprovider、com.bugly.fileprovider。排查方式先看合并后的Manifest里到底有哪些provideraapt dump badging huawei.apk | grep -i provider aapt dump badging xiaomi.apk | grep -i provider或者在Gradle构建产物目录里看launcher/build/intermediates/merged_manifests/下对应flavor的AndroidManifest.xml搜索provider节点重点看android:authorities字段。解决给有冲突的provider的authorities改成占位符模式让它跟随applicationId变化。上文我们在Manifest里手动声明FileProvider就是这么做的。如果是第三方SDK内置的provider优先看SDK文档是否支持占位符如果不支持就需要在不同flavor下隐藏冲突SDK或者用tools:noderemove在特定flavor下移除该节点这是一个比较硬核但不常被提起的做法。5.3 重新导出Gradle工程把多渠道配置冲掉现象跑通了全流程第二天改了场景重新导出Unity工程再执行Gradle构建发现渠道包全变成了默认包名渠道号也丢了。根因Unity的Export Project会覆盖整个Gradle工程目录包括launcher/build.gradle。如果你把flavor配置直接写在build.gradle里所有自定义配置都白写了。解决我前文强调的apply外部gradle文件方案就是专门防这个的。只要multiChannel.gradle和AndroidManifest.xml的修改发生在导出目录之外比如放到Unity工程的Assets/Plugins/Android/下让Unity每次导出时自动拷贝进去重新导出就不会丢。我把完整的自定义文件放在Assets/Plugins/Android/目录下Unity导出时会自动合并一劳永逸。这个坑之所以排进前三是因为它不出错则已一出错就是“整批包都错”而且不仔细看构建日志很难发现原因。6. 让稳定可持续自动化流水线与版本治理方案本身跑通只是第一步真正让“稳定”两个字成立的是自动化。我最后把流水线串起来。6.1 batchmode导出Gradle工程Unity的导出过程不要靠人肉点菜单用批处理模式可以完全无人值守。我写了一个编辑器入口public static class BuildTools { public static void ExportAndroidProject() { var options new BuildPlayerOptions { scenes new[] { Assets/Scenes/Main.unity }, locationPathName ../build/android_project, target BuildTarget.Android, options BuildOptions.AcceptExternalModificationsToPlayer }; var report BuildPipeline.BuildPlayer(options); if (report.summary.result ! BuildResult.Succeeded) { throw new System.Exception($[Export] 失败: {report.summary}); } } }命令行调用/Applications/Unity/Hub/Editor/2021.3.10f1/Unity.app/Contents/MacOS/Unity \ -batchmode \ -quit \ -projectPath /path/to/your/project \ -executeMethod BuildTools.ExportAndroidProject \ -logFile build_export.logWindows下把Unity路径换成C:\Program Files\Unity\Hub\Editor\...\Unity.exe即可。6.2 CI/CD或本机脚本的一次性编排导出完成之后进入Gradle工程执行构建cd ../build/android_project # 如果gradlew没有执行权限 chmod x gradlew # 批量构建全部渠道release包 ./gradlew :launcher:assembleRelease \ -PkeyStoreFile$KEYSTORE_FILE \ --no-daemon把两段串进同一个shell脚本就实现了“一条命令出全部渠道包”的效果。这里要提醒一个性能相关的点Gradle daemon默认会保留内存如果你用CI构建完就销毁容器建议加--no-daemon避免僵尸进程占内存。但本地开发时不要加保留daemon能大幅加快连续构建速度。6.3 版本号、构建号、渠道映射表统一管理我维护了一张渠道映射表放在项目的CHANNELS.csv里既是给脚本读的也是给运营确认的渠道applicationId渠道号特殊配置华为com.example.game.huaweihuawei无小米com.example.game.xiaomixiaomi推送SDK单独初始化OPPOcom.example.game.oppooppo无vivocom.example.game.vivovivo无应用宝com.example.game.yybyyb腾讯SDK单独初始化版本号每次发版前在Unity的Player Settings里统一改一次。Gradle侧默认会读取Unity导出的versionCode和versionName渠道之间保持一致这样后端排查问题时不需要额外对表。6.4 发版前10分钟的产物自检清单最后分享一个我每次发版前都会手过一遍的清单很简单但很救命用aapt dump badging确认每个APK的package与预期渠道一致用apksigner verify --print-certs确认所有渠道签名一致安装两个不同渠道包到同一台手机确认能共存安装新包覆盖旧包确认不报错进游戏后查看日志或后台数据确认ChannelHelper.GetChannel()返回值正确。这一套下来基本能拦住99%的渠道包事故。我在实际项目里用这套方案维护八个渠道的日常发版已经跑了大半年没有再出现过安装冲突和渠道号混乱的问题。有一次运营临时要求加一个新渠道包我只在multiChannel.gradle里加了一段flavor配置重新跑一次脚本十几分钟所有包就齐了。真正稳定之后多渠道APK打包这件事就不再需要“每次发版都提心吊胆”了。