Unity多渠道APK打包全攻略:包名隔离与自动化构建实战 📅 发布时间:2026/9/16 8:41:37 👁 浏览次数: 1. 项目背景与需求拆解1.1 为什么同一工程要打多个APK做Unity开发的人应该都遇到过这样的场景你手里只有一个Unity工程但是市场那边要求你同时提供华为、小米、OPPO、vivo、应用宝、官网等多个渠道的包。更麻烦的是测试人员或者客户往往需要在同一台手机上同时安装好几个渠道版本去验证功能差异结果装完A渠道的包再装B渠道的包直接提示“应用未安装”或者“覆盖安装失败”手机上的旧版本数据还被清掉了。这个问题的本质在于所有渠道包共用了同一个包名。Android系统识别一个应用是不是同一个App看的就是包名和签名。包名相同、签名相同系统就认为是同一个应用后装的会覆盖先装的包名相同、签名不同系统就会拒绝安装或者要求卸载旧版本。所以渠道包要想“和平共处”在同一个设备上第一步就是让每个渠道拥有不同的包名。但麻烦点在于修改包名不是光改一个配置那么简单。Unity工程里的包名牵扯到Player Settings牵扯到AndroidManifest牵扯到代码里可能有硬编码的包名判读逻辑还可能牵扯到第三方SDK初始化时用的appkey和包名绑定关系。如果每打一个渠道包就手动改一遍改完再手动改回去早晚会出事故——漏改了某一个SDK配置或者Bundle Identifier改错导致某个渠道包上不了架排查起来非常痛苦。所以理想方案是通过一套可靠的打包流程从同一个Unity工程里一键输出多个渠道的APK每个APK的包名、渠道标识、版本号、图标、SDK配置都与渠道一一对应且互不干扰。这篇文章分享的就是我在实际项目中验证过的这套方案不是纯理论都是踩过坑之后沉淀下来的实操流程。1.2 多渠道包要解决的核心问题先梳理一下多渠道APK打包到底要解决哪些具体问题想清楚这些问题再做方案才不会做到一半发现遗漏。第一是包名隔离问题。不同渠道需要不同包名这样同一个设备才能同时装多个渠道包才能做对比测试、灰度验证。这里有一种特殊情况是同一应用的上架包和内部体验包也要区别开来比如正式包叫com.company.app测试包叫com.company.app.beta也是同样的道理。第二是渠道标识问题。不管包名叫什么渠道包的内部要能识别出自己是哪个渠道。这个被识别出来的结果会用于统计SDK上报、运营活动区分、分享链接参数拼装、或者客户端请求后端接口时携带渠道参数。如果一个包发出去之后连自己是什么渠道都不知道那这个渠道包就是不合格的。第三是配置自动切换问题。每个渠道的第三方SDK appkey、服务器地址、友盟或者数盟的统计key、微信开放平台的appid这些配置在不同渠道之间往往是不同的。打包时这些配置要能自动切换到当前渠道对应的值绝对不能手工去改代码。第四是版本管理问题。多渠道包发送出去之后不同渠道的版本迭代节奏可能不同比如某个渠道还在旧版本上做特殊活动另一个渠道已经要发新版本那就要求打包系统能灵活控制每个渠道的版本号而不是所有渠道强绑定同一个版本。Android的版本号分为versionCode整数用于升级判断和versionName字符串用于展示给用户实际操作中两者都可以在打包时动态指定。第五是流程可重复问题。打包脚本要能重复执行执行结果要稳定最好能接入命令行构建方便在CI服务器上跑自动化出包。手动在某台电脑上点Unity导出APK换个人换个环境可能结果就不一样了这就没法叫“稳定输出”。把这五个问题都解决了才能算是一套完整的多渠道打包方案。2. 核心方案选型改包名、改渠道、改配置2.1 最笨的办法手动改Bundle Identifier先说说很多新手会做的笨办法就是每次打包前打开Project Settings把Other Settings里的Bundle Identifier改一改然后再Build。单次打包看起来也能做选择多渠道包的时候就连点几次Build每改一次包名就打一个包。这个办法有一个非常明显的坑Unity编译Android工程时包名会被写入到AndroidManifest.xml的package字段里同时还会作为R类的包名、BuildConfig的applicationId、资源文件的命名空间基础。如果你同时在Project Settings里改了Bundle Identifier又在某个第三方SDK的AndroidManifest中写死了另一个包名合并清单时就会报冲突错误Manifest merger failed。更隐蔽的是部分SDK在初始化时会把包名写死在assets目录的配置文件中你改了Bundle Identifier但没同步改这份配置集成测试时SDK就上报不了数据后台看到的数据全是错的。手动方式最大的问题还不是容易错而是不可重复。这次手动改对了下次呢QA那边拿着一个测试包来问“这个包是什么时候打的、什么渠道、什么版本”如果你依赖的是手动流程大概率是答不上来的因为你可能当场就记混了。所以在正式项目中我不建议任何人采用纯手动改包名的方式做多渠道出包。2.2 推荐的方案构建前置脚本 批量出包我最终采用的是“构建前置脚本 批量出包”的方案。这个方案分几层底层的核心是写一个Unity Editor扩展脚本在打包前动态修改Player Settings里的applicationIdentifier这是新版Unity API里的叫法老版本里叫bundleIdentifier并同步修改AndroidManifest中的渠道meta-data以及其他跟渠道相关的配置比如友盟的appkey、微信的appid等。脚本的工作方式是这样的我定义了一个渠道配置文件用JSON或者ScriptableObject保存里面记录每个渠道的渠道名、包名、版本号、versionCode、第三方key列表。打包脚本读取这个配置遍历所有渠道对每个渠道动态设置参数然后调用Unity的BuildPipeline执行一次Build输出APK文件文件名带上渠道名和版本号保证出包结果一目了然。这一层解决的是“打包时自动改配置”的问题但是人对“自动执行的脚本”也要有掌控感。所以写脚本时要考虑日志输出、异常中断、构建目标目录管理这些东西。每次脚本执行完输出一份打包报告文本文件就行记录当前的Unity版本、渠道配置、包名、签名文件MD5、构建时间这个报告本身就是发版记录的原始凭证。2.3 进阶方案Gradle多Flavor构建如果你对Android构建体系足够熟悉还可以走另一条路Unity导出Gradle工程后不直接出APK而是把这个导出的工程当作一个标准的Android工程来管理在build.gradle里配置productFlavors产品风味用Gradle的多维构建能力一次性生成多个渠道包。productFlavors方案的原理是在Gradle里定义多个flavor每个flavor可以指定自己的applicationId、versionCode、versionName还可以在src/flavorName/res/、src/flavorName/assets/目录下放渠道特有的资源和配置文件。在执行./gradlew assembleRelease时Gradle会为每个flavor构建一个独立的APK。这个方案的好处是构建体系根正苗红适合已经有Android原生开发基础、需要做精细化渠道差异管理的团队。缺点也很明显Unity自动导出的Gradle工程结构复杂如果Unity版本升级导致导出结构变化你的Gradle脚本可能要做相应调整另外Unity的Android模块和Gradle之间有一些自动生成的逻辑乱改容易改出问题。我在实际工作中是怎么选的呢如果团队里没有专门的Android原生开发人员只是Unity开发需要出渠道包那就用Editor脚本方案如果团队里有Android原生开发而且对Gradle非常熟悉那可以两个方案混用——Unity负责出通用包体Gradle负责做渠道差异和批量构建。这两种方案我都实践过下面详细拆解每种方案的关键实现细节。3. 方案一实操基于Unity Editor脚本的批量打包3.1 渠道配置表设计这一节是整篇最核心的实操部分。先说第一步把所有渠道参数集中管理起来。我建议不要直接在代码里写死每个渠道的配置而是用一个JSON文件来做配置表例如放在工程根目录的BuildConfig/channels.json。配置表大概长这样{ company: ExampleGame, unityVersion: 2021.3.10f1, channels: [ { name: huawei, applicationId: com.examplegame.demo, versionName: 1.2.0, versionCode: 120, appkey: huawei_appkey_xxxx, wechatAppId: , umengKey: umeng_huawei_key, icon: Assets/Art/icon_huawei.png, outputName: ExampleGame_huawei_v1.2.0.apk }, { name: xiaomi, applicationId: com.examplegame.demo.xiaomi, versionName: 1.2.0, versionCode: 120, appkey: xiaomi_appkey_xxxx, wechatAppId: wx1234567890, umengKey: umeng_xiaomi_key, icon: Assets/Art/icon_xiaomi.png, outputName: ExampleGame_xiaomi_v1.2.0.apk } ] }字段说明name渠道代号会被写入到最终的APK渠道标识里。applicationId当前渠道要用的包名核心字段决定了是否能多包共存。versionNameversionCode版本号和版本码Android系统根据versionCode判断是否覆盖安装。appkey、wechatAppId、umengKey第三方SDK需要的渠道级配置按你实际接入的SDK自行增减字段。icon渠道要求的角标图标有些渠道要求图标上带自己渠道的角标如果不做差异化图标这里可以统一留空。outputName最终APK输出文件名建议强制规范命名格式游戏名_渠道名_版本号.apk这样整个分发链路里看文件名就知道版本了。上面我把versionCode设置为120意思是主版本1.2.0对应的整型版本码。常见的做法是主版本 * 10000 次版本 * 100 修订版本这样每个发布版本都会有一个单调递增的整数Android系统升级判断依赖这个数值务必保证同一渠道下新包的versionCode大于旧包的versionCode否则用户永远检查不到更新。3.2 Editor打包脚本编写接下来写Unity Editor的C#脚本。核心逻辑就是遍历JSON配置设置PlayerSettings然后调用BuildPipeline.BuildPlayer。先用一个辅助类负责读取JSON配置using System; using System.IO; using UnityEngine; namespace GameBuild { [Serializable] public class ChannelConfig { public string name; public string applicationId; public string versionName; public int versionCode; public string appkey; public string wechatAppId; public string umengKey; public string icon; public string outputName; } [Serializable] public class BuildConfig { public string company; public string unityVersion; public ChannelConfig[] channels; } public static class ConfigLoader { public static BuildConfig Load(string jsonPath) { if (!File.Exists(jsonPath)) { throw new Exception(Channels config file not found: jsonPath); } string json File.ReadAllText(jsonPath); return JsonUtility.FromJsonBuildConfig(json); } } }然后写打包主入口。这一步要区分你是要打单个渠道的包还是想一次把配置里的所有渠道包都打出来我两种都支持用菜单或者命令行参数来控制using System; using UnityEditor; using UnityEngine; using System.IO; namespace GameBuild { public static class ChannelBuilder { private const string ConfigPath BuildConfig/channels.json; [MenuItem(Tools/Build APK/Build All Channels)] public static void BuildAllChannels() { BuildConfig config ConfigLoader.Load(ConfigPath); foreach (ChannelConfig channel in config.channels) { BuildSingleChannel(channel); } Debug.Log(All channels build complete.); } [MenuItem(Tools/Build APK/Build Current Channel)] public static void BuildCurrentChannel() { BuildConfig config ConfigLoader.Load(ConfigPath); // 这里获取当前渠道的方式可以自定义也可以临时在面板里输入 ChannelConfig channel config.channels[0]; BuildSingleChannel(channel); } // 供命令行调用 public static void BuildChannelFromCommandLine() { string channelName GetArgument(channel); BuildConfig config ConfigLoader.Load(ConfigPath); foreach (ChannelConfig channel in config.channels) { if (channel.name channelName) { BuildSingleChannel(channel); return; } } throw new Exception(Channel not found: channelName); } private static void BuildSingleChannel(ChannelConfig channel) { // 调整包名 PlayerSettings.SetApplicationIdentifier(BuildTargetGroup.Android, channel.applicationId); // 版本号 PlayerSettings.bundleVersion channel.versionName; PlayerSettings.Android.bundleVersionCode channel.versionCode; // 可选的图标切换 if (!string.IsNullOrEmpty(channel.icon)) { Texture2D icon AssetDatabase.LoadAssetAtPathTexture2D(channel.icon); if (icon ! null) { PlayerSettings.SetIconsForTargetGroup(BuildTargetGroup.Android, new Texture2D[] { icon }); } } // 写入渠道标识后面章节解释如何读取 string manifestPath Assets/Plugins/Android/AndroidManifest.xml; ApplyChannelToManifest(manifestPath, channel.name, channel.appkey, channel.umengKey); // 执行构建 string outputPath BuildOutput/ channel.outputName; Directory.CreateDirectory(BuildOutput); BuildPlayerOptions options new BuildPlayerOptions { scenes EditorBuildSettings.scenes, locationPathName outputPath, target BuildTarget.Android, options BuildOptions.None }; BuildReport report BuildPipeline.BuildPlayer(options); if (report.summary.result ! BuildResult.Succeeded) { throw new Exception(Build failed for channel: channel.name); } // 写构建报告 WriteBuildReport(channel, outputPath); Debug.Log($Build succeeded: {channel.name} - {outputPath}); } private static string GetArgument(string name) { string[] args Environment.GetCommandLineArgs(); for (int i 0; i args.Length; i) { if (args[i] - name i 1 args.Length) { return args[i 1]; } } return null; } } }这段脚本看起来不复杂但有几个细节值得重点关注。第一个是PlayerSettings.SetApplicationIdentifier是只对当前Unity进程的内存生效不会永久写死到ProjectSettings里。如果你中途Build失败下次打开Unity还是原来的包名这一点对于“同一工程稳定输出”非常关键。但是反过来也意味着如果你有临时的、不是通过脚本修改包名的需求记得在手动打包前检查包名是不是被上次脚本改过了。第二个是PlayerSettings.Android.bundleVersionCode在新版本的Unity里可能迁移到了PlayerSettings.Android.bundleVersionCode在Unity 2021中还有一种等价写法是PlayerSettings.Android.bundleVersionCode channel.versionCode;。这里不同的Unity版本API名称可能有细微差异编不过就查一下当前版本的API文档。第三个是构建选项。如果你需要出的是上架市场包一般用BuildOptions.None就好如果是用来本地测试的调试包或者验证包可以检查一下是否需要加上BuildOptions.Development。我强烈建议正式发布流程一律使用Release构建不要在Debug模式下出包Debug包体积大、日志多、性能差如果被人拿去反编译还会暴露更多内部信息。3.3 渠道标识如何写入和读取前面提到了要把渠道标识写入AndroidManifest。具体做法是在application节点里加一个meta-dataapplication ... meta-data android:nameCHANNEL_ID android:valuehuawei / meta-data android:nameAPPKEY android:valueappkey_value / /application在打包脚本的ApplyChannelToManifest里可以用XmlDocument来直接修改这个文件把android:value替换成当前渠道配置里的值。这一步不能省因为很多统计SDK都需要在AndroidManifest里配置AppKey或ChannelID。对应的Unity C#侧读取渠道标识的代码如下using System; using UnityEngine; public static class ChannelHelper { public static string GetChannelId() { #if UNITY_ANDROID !UNITY_EDITOR try { AndroidJavaObject activity GetActivity(); AndroidJavaObject packageManager activity.CallAndroidJavaObject(getPackageManager); string packageName activity.Callstring(getPackageName); AndroidJavaObject applicationInfo packageManager.CallAndroidJavaObject( getApplicationInfo, packageName, 128 // GET_META_DATA ); AndroidJavaObject metaData applicationInfo.GetAndroidJavaObject(metaData); return metaData.Callstring(getString, CHANNEL_ID); } catch (Exception e) { Debug.LogError(Failed to read channel id: e.Message); return unknown; } #else return editor; #endif } private static AndroidJavaObject GetActivity() { return new AndroidJavaClass(com.unity3d.player.UnityPlayer) .GetStaticAndroidJavaObject(currentActivity); } }读取渠道标识还有一种更轻量的办法就是直接把渠道名写到一个放在StreamingAssets目录下的文本文件里。原因在于用AndroidJavaObject访问AndroidManifest走JNI调用对非Android平台的兼容性要额外处理容易踩坑。相比之下读取StreamingAssets里的文件就简单得多public static string GetChannelIdFromStreamingAssets() { string path Path.Combine(Application.streamingAssetsPath, channel_id.txt); #if UNITY_ANDROID !UNITY_EDITOR // Android上StreamingAssets在压缩包内需要用UnityWebRequest读取 UnityWebRequest request UnityWebRequest.Get(path); var op request.SendWebRequest(); while (!op.isDone) { } return request.downloadHandler.text.Trim(); #else return File.ReadAllText(path).Trim(); #endif }两种方式我都用过我的经验是如果只是判断渠道用StreamingAssets文本文件最省心如果还要同时读取appkey、微信appid这些SDK配置统一放AndroidManifest里更方便因为原生SDK本身也会从Manifest里读这些值。用第一种方式时AndroidManifest里的meta-data是原生SDK也能读到的这是StreamingAssets里的文件做不到的大部分原生SDK不会去你的App文件里找渠道信息除非你专门做定制开发。3.4 多包共存验证打完包之后验证有没有真正做到“不冲突安装”我个人建议的三个检查点第一个检查点是在Android设备上先装一个渠道包再装另一个渠道包观察是否出现过“应用未安装”或“已安装了签名不一致的应用”这类提示。正常情况是第二个包直接装上手机桌面出现两个不同名字的图标如果游戏显示名称被渠道包修改过会更容易区分。第二个检查点是分别打开两个包在代码里把ChannelHelper.GetChannelId()和Application.identifier打印出来或者显示在游戏设置面板里确认包名、渠道标识各自正确。我这里习惯在游戏内部做一个隐藏入口连续点击版本号五次弹出调试面板显示当前包名、渠道、版本号非常方便QA同学自查。第三个检查点是用命令行工具做快速验证。不安装直接看包信息也行可以用aapt dump badging xxx.apk来检查包名和版本信息。自己打包机如果配了Android SDKaapt在build-tools目录下一条命令就能看到APK的包名、版本号、原生权限等关键信息aapt dump badging ExampleGame_huawei_v1.2.0.apk | head -n 20输出结果里package: name后面的值就是当前APK的包名拿这个跟渠道配置表一一对应能迅速发现包名配错的问题。4. 方案二实操Gradle多Flavor构建进阶4.1 在Unity导出的Gradle工程上配置productFlavors如果你的项目渠道逻辑复杂到连AndroidManifest里的权限都想区分比如某些渠道不允许申请某个权限有的是大屏渠道需要申请额外的权限那光靠Unity侧的Editor脚本会越写越复杂。这时候可以考虑从Unity导出Gradle工程然后在Gradle工程里处理所有Android原生层面的差异化逻辑。Unity导出Gradle工程有两种方式一是直接使用Unity内置的Export Project功能二是在编辑器中通过命令行参数-buildTarget Android -exportGradleProject来指定。导出完成后你会得到一个包含build.gradle、settings.gradle、gradle.properties、unityLibrary等目录的标准Android工程结构。在项目根目录的build.gradle中的android节点内添加productFlavors配置比如android { compileSdkVersion 33 defaultConfig { minSdkVersion 22 targetSdkVersion 33 versionCode 1 versionName 1.0 } flavorDimensions channel productFlavors { huawei { dimension channel applicationId com.examplegame.demo versionCode 120 versionName 1.2.0 manifestPlaceholders [ CHANNEL_ID: huawei, APPKEY: huawei_appkey_xxxx ] } xiaomi { dimension channel applicationId com.examplegame.demo.xiaomi versionCode 121 versionName 1.2.1 manifestPlaceholders [ CHANNEL_ID: xiaomi, APPKEY: xiaomi_appkey_xxxx ] } } buildTypes { release { minifyEnabled false signingConfig signingConfigs.release } } }在这里要注意Unity生成的unityLibrary模块和根工程的build.gradle是有依赖关系的。你不要把applicationId写在unityLibrary/build.gradle里applicationId只属于app主模块unityLibrary里写的是Android库模块的一些基础配置不能混。manifestPlaceholders这个东西是Gradle的Manifest站位符你可以在AndroidManifest.xml里这样引用它meta-data android:nameCHANNEL_ID android:value${CHANNEL_ID} / meta-data android:nameAPPKEY android:value${APPKEY} /构建时Gradle会自动把占位符替换成对应flavor里配置的真实值这个比在Unity侧用XmlDocument手动改Manifest要干净得多也是Gradle方案在这类场景下更优雅的核心原因。4.2 用命令行批量输出渠道包配置完flavor之后出包就变得规模化了。直接用Android Gradle Plugin的命令行工具在工程根目录执行./gradlew assembleHuaweiRelease assembleXiaomiRelease注意任务名的大小写规则assemble flavor名首字母大写 BuildType名首字母大写。例如你在productFlavors里定义的是huaweibuildTypes里定义的是release那么对应任务名就是assembleHuaweiRelease。如果你执行./gradlew assembleReleaseGradle会把所有flavor的Release类型包全部打出来也就是华为、小米、应用宝等所有渠道一起出。跟Unity Editor脚本相比Gradle方案在出包速度上的优势是增量构建gradle会把编译过的代码缓存下来第二次构建时如果代码没变它不会重新走一遍全量编译往往几十秒就出包了。Unity BuildPipeline出包则每次都是一套全量流程耗时比较长尤其是开着很多场景和资源时。4.3 渠道定向资源和代码productFlavors还有一个非常好用的能力按渠道放专属资源和代码文件。比如你在src/huawei/res/values/channel_config.xml里放resources string namechannel_namehuawei/string string nameumeng_appkeyumeng_huawei_key/string /resources然后Android原生代码里通过getResources().getString(R.string.channel_name)就能拿到当前构建渠道对应的渠道名。如果你需要在同一个工程里对不同渠道跑不同的Java代码逻辑还可以在src/huawei/java/下放渠道专属的Java类Gradle构建时会自动使用该flavor下的源码其他渠道代码不参与编译。Unity侧代码如果想读取这些Gradle注入的资源用AndroidJavaObject来调用原生方法读取R.string资源即可。整体链路会变成Unity侧代码通过AndroidJNI桥接Java方法Java方法读取Gradle按flavor注入的资源值这种方式在原生能力和Unity侧抽象之间做了一个比较清晰的隔离代码维护相对简单。不过这类桥接代码有个天然劣势就是调试的时候多了一层跨语言调用链。一旦出问题Unity一侧的堆栈信息往往拿不到Java侧的详细报错排查起来比较费劲。我的建议是如果只是基础渠道包Unity侧的渠道读取完全不需要走Gradle注入直接用上一章的StreamingAssets方案就够了只有当你必须要做原生SDK渠道差异、或者你的渠道包需要针对不同应用市场申请不同权限的时候再考虑引入Gradle flavor资源注入。5. 签名、版本与配置同步的坑5.1 签名配置是渠道包安全的底线多渠道包还有一个必须重视的点就是签名。Android的应用更新和安装来源校验全部依赖签名渠道包用同一个签名文件是底线。千万不要为了图省事给不同渠道配了不同的keystore也不要随便生成一个新keystore就把所有渠道包都签上那会导致用户在同一个渠道上升级时提示“应用未安装”或“签名不一致”这是最严重的线上事故。签名文件的管理我建议做到三点使用同一个正式的keystore出所有渠道的上架包并且把keystore的密码、别名、签名算法记录下来放到公司的密钥管理系统里统一保存。调试包可以用独立的debug keystore但是绝对不要让调试包签名和上架包签名混用。注意同一台设备上上架包和调试包是不同签名的两个App它们在应用列表里也是分开展示的两个图标这本身就是正常的共存关系不用强求它们互相覆盖。打正式包时务必使用keytool -list -v -keystore xxx.keystore核对MD5/SHA256指纹确保AndroidManifest里配置的签名相关SDK参数和当前签名文件匹配。比如你用某个推送SDK时要在厂商后台配置App的SHA256指纹配错了推送服务就拉不起来。Unity侧的签名设置有两个位置一是Build Settings的Player Settings里的Publishing Settings这里可以勾选Custom Main Keystore并选择keystore文件二是在Gradle方案中通过signingConfigs节点来统一配置。如果你的渠道包涉及不同渠道使用不同签名的极端情况比如某渠道要求使用渠道方提供的签名那建议用Gradle方案每个flavor单独指定一个signingConfig控制力更强。5.2 versionCode与渠道升级策略前面提到过versionCode的一致性这里再展开讲一下实际操作中容易忽略的细节。Android升级检测的原理不是看versionName的字符串大小比较而是纯看versionCode的整数大小比较。假设旧版包的versionCode是120新版包的versionCode如果被配成了110那么即使versionName是2.0.0用户检查更新时系统也认为这是旧版本根本不会提示更新。这个bug在渠道多的时候特别容易被忽略因为不同渠道的版本节奏不同可能某个渠道还在维护旧版另一个渠道已经发布新版配置表稍微改错一个数字问题就悄悄埋下了。我的做法是给每个渠道单独建立一段发布记录至少在配置文件里用注释或者一个releaseHistory字段把历史版本列出来每次提交渠道包时对照着看一遍versionCode是否单调递增。还可以在打包脚本里写一个检查遍历之前输出目录里的旧APK用aapt读取旧包的versionCode如果发现当前要出包的versionCode小于等于旧包脚本直接抛错阻止出包。这个检查逻辑成本很低但价值很高强烈建议加上。另外既然说到了版本更新Android 11开始有一个特性需要所有发布渠道包的人注意如果你的Target SDK版本提升到了30及以上就不能再通过adb install直接覆盖安装一个签名不一致的包系统会拒绝这个操作。不过我们渠道包发行场景下用户走的是应用市场更新流程应用市场会自动处理包覆盖的事影响不大。但如果你的QA同学经常通过APK文件直接覆盖安装来测试就要提醒他们用同签名的包来覆盖或者干脆卸载旧版再装新版。5.3 配置同步与CI联动多渠道打包一旦跑起来配置分散在多个位置Unity的PlayerSettings、AndroidManifest、JSON配置表、Gradle的flavor块、第三方SDK后台最容易出现的是“某个配置改了但打包时没有生效”的问题。所以我在团队里推行一个约定所有渠道配置以JSON配置表为准Unity脚本和Gradle脚本都从这个配置表生成而不是各自维护一套。如果要接入CI流水线也是走同一套脚本只不过是用命令行驱动的在Jenkins/GitLab CI里安装好Unity和Android SDK然后执行Unity的batch mode命令行构建/opt/Unity/Editor/Unity -batchmode -quit -projectPath /path/to/project \ -executeMethod GameBuild.ChannelBuilder.BuildChannelFromCommandLine \ -channel huawei \ -logFile build_huawei.logCI里的稳定不是靠运气是靠脚本在每次构建前把基于配置表的参数重新写入环境。比如-channel huawei这个参数传到脚本之后脚本会重新读取channels.json把包名、版本号、渠道标识重新设置一遍再执行BuildPipeline。这样即使上次某个人手动在Unity里改过PlayerSettings下一次CI构建也会把它重置成配置表里的值不会出现“上次打包正常、这次打包突然包名变了”的灵异事件。6. 常见问题与排查技巧实录6.1 安装失败类问题问题现象原因排查解决方案安装时提示“应用未安装”包名冲突或者签名冲突最常见于手机上已装有同一包名的其他App用adb install安装时注意看完整报错信息确认两个渠道包是否用了不同包名确认旧包和新包签名是否一致覆盖安装提示“签名不一致”旧包签名和新包签名不同检查keystore和签名的别名/密码确认是否不小心用了debug签名出正式包安装后闪退目标SDK版本跟设备不兼容或者IL2CPP的arm64相关的so库没打全检查targetSdkVersion设置确认Build Settings - ARM64勾选抓取logcat日志定位具体异常安装后桌面出现两个图标且功能一样两个渠道包没有做包名区分检查JSON配置表里不同渠道的applicationId是否重复APK体积异常增大资源没有被增量构建压缩或者渠道专属资源被打进了所有渠道包检查AssetBundle资源是否按渠道打包确认StreamingAssets是否混入其他渠道的文件这里有一个经验之谈Android安装失败时手机自带的安装器提示通常非常简略就四个字“应用未安装”。但如果你用adb命令行安装adb install xxx.apk会输出INSTALL_FAILED_UPDATE_INCOMPATIBLE、INSTALL_FAILED_CONFLICTING_PROVIDER、INSTALL_FAILED_SIGNATURE_INVALID等具体失败原因。所以我的建议是凡是遇到安装问题先把手机用USB连上电脑用adb安装一次比在手机上瞎猜快得多。INSTALL_FAILED_CONFLICTING_PROVIDER是一个很多人没注意到但特别容易踩的坑。Unity工程如果勾选了自定义AndroidManifest并且里面注册了ContentProvider很多SDK都会注册自己的ContentProvider而ContentProvider的authority值是写死的包名相关字符串那么两个渠道包如果包名不同但authority相同就会在安装新包时直接失败。这种问题通常表现为包名改了、签名也改了但就是装不上。遇到这种情况优先去AndroidManifest里搜provider相关的配置把authority改成带渠道名的唯一值。6.2 渠道读取异常类问题问题现象原因排查解决方案代码里读取到的渠道始终是默认值AndroidManifest中的meta-data值没有被替换检查Manifest合并日志确认使用的是最终合并后的Manifest而非Unity工程里原始的Manifest友盟/数盟统计后台看不到渠道数据appkey配置错误或者渠道ID没有上报检查AndroidManifest中APPKEY和CHANNEL_ID的meta-data标签是否正确检查SDK初始化时机是否在渠道读取之前微信登录提示应用签名不正确微信开放平台后台的包名和签名未更新为当前渠道包核对当前渠道包的包名和签名指纹需要在新包安装后重新获取签名的MD5值并提交到微信后台部分渠道读取到的字符串末尾带换行符读取StreamingAssets的txt文件时没有Trim在读取渠道文本后调用.Trim()6.3 出包效率与稳定性问题Unity的BuildPipeline是出了名的吃内存和吃时间一个中等体量的项目全量打一遍Android包5分钟到20分钟都有可能。如果要做多渠道批量出包一次性打5个渠道可能就要一个多小时这时候如果有某个渠道在构建中途编译失败前面几个渠道的包就算白打了。所以我在脚本里特意加了两个机制一个是单渠道构建失败不中断整体流程记录错误但继续打下一个另一个是支持增量构建选项如果场景和资源没有变化第二次出包时可以少打一次资源包速度能快不少。还有一个不太起眼但很影响稳定性的点是Unity的缓存问题。同一个工程连续多次执行BuildPipeline如果中间出现过一次Il2Cpp代码裁剪错误或者资源序列化错误很容易出现“Unity进程内存溢出”或者“构建一直卡住不结束”。我遇到这种情况的常规操作是删除工程的Library/Bee目录和Library/PlayerScriptAssemblies目录Unity会在下次构建时重新生成重启Unity以批处理模式重新构建如果还不行优先怀疑是否某个插件SDK跟当前Unity版本不兼容可以看Editor日志中是否有OOM或者Native Crash关键字。7. 针对Unity单机/轻量项目的简化方案7.1 不用SDK、只区分渠道时怎么处理如果你的项目没有接入任何统计SDK或者推送SDK纯做多个渠道的分发那完全可以砍掉配置表里跟SDK相关的所有字段只保留name、applicationId、versionCode、versionName、outputName这几个核心字段。甚至你手动在Player Settings里改包名然后重新Build也够用。但既然要“稳定输出”我还是建议哪怕不接SDK也走脚本流程因为人不可靠脚本可靠。这种简化场景下渠道标识的读取最方便的就是StreamingAssets文本方案。你在打包脚本里加一行代码把当前渠道的name写入到Assets/StreamingAssets/channel.txt然后构建游戏运行时就能随时读取渠道名。这套方案绕开了AndroidManifest的解析不依赖JNIUnity侧也能在编辑器和真机得到一致的逻辑出错概率最低。7.2 用AssetBundle分担渠道资源差异还经常有一种情况不同渠道的资源差异很大比如某渠道要求首包去掉新手引导动画或者某渠道要求开屏图不同。这种情况下如果把所有资源都打进APK就等于为一个渠道的差异背上了所有渠道的资源包非常浪费。处理思路是把资源用AssetBundle打包按渠道名放在不同的Bundle分组里。打包脚本在构建当前渠道包前只打进该渠道对应的AssetBundle列表。具体步骤是在AssetBundle的abName前缀中加入渠道维度例如channel/huawei/ab_ui_tips、channel/xiaomi/ab_ui_tips在资源构建阶段按渠道匹配输出Bundle组运行时通过AssetBundle.LoadFromFileAsync按渠道名动态加载正确渠道的Bundle。这样做的效果是不同渠道的包体体积差异会很明显但同一套代码可以正确处理逻辑这也是“同一工程稳定输出”在高资源差异场景下的扩展方向。7.3 多产物与多场景的复用如果你手上不只一个游戏项目而是多个Unity工程需要复用同一套打包流程那可以把这份Editor脚本和JSON配置表抽取成一个公共工具包放在类似Assets/Plugins/BuildTools的公共目录下然后通过Package Manager的local package方式引用到每个工程里。这样每个工程只需要把自己的渠道配置写成JSON其他的打包逻辑完全复用。我在公司内部就是这么做的。落地之后最大的好处是新人上手成本大幅降低只需要照着wiki改配置表不需要理解打包脚本内部的实现细节同时老项目迁移到新打包流程也很方便因为所有项目的打包入口和产物命名规范都统一了QA做回归验证时看到的包名和文件结构完全一致。8. 个人经验收尾这套方案我从Unity 2018一直用到Unity 2022中间踩过的坑远比文章里写到的多。最值得说的一点是不管脚本写得多么完善永远不要跳过“双人复核”这一步。我见过太多因为修改了配置表里的一个包名导致线上事故的案例这个包名一旦发布出去用户装上了新包之后旧包的数据就迁移不过去了后续想改回来往往要付出很大的代价。所以我现在出包的习惯是打完全部渠道包之后先跑一个自动校验脚本拉取每个APK的包名、版本号、SHA256签名、渠道标识然后跟配置表做一次全量比对任何不匹配都会直接标红。这一步跑完才敢把这个批次的包提交给测试。如果你也经常被“多渠道打包”这件事折磨建议先别急着加更多花哨框架把配置表、脚本、校验这三件事做扎实就已经能覆盖绝大多数项目的需求了。