凌晨两点,项目组微信群里炸了锅。Android包打出来,测试安装后界面资源全是旧的;iOS包倒是新的,但登录模块必现闪退。两边一核对,发现一个人用的是本地构建,另一个人跑了CI机器上的旧脚本,打包参数、资源版本、热更配置全是各打各的。那一周我干的最多的事不是写业务代码,而是给所有人的电脑装同一个打包脚本、统一入口。也就是从那时候起,我意识到一个问题:Editor里的打包系统,不是"能出包就行",它是个需要认真设计架构的子系统。
这篇文章定位是"架构篇",对应的是我整理内部工具链时的一个切片。适合正在搭建或者重构项目构建管线、被多平台出包折腾过、想把手动出包流程变成"一键可复现"的读者。内容围绕Editor打包系统的核心架构展开:整体分层、关键模块、数据流设计,再给一个可直接落地的骨架实现,最后是我实践中踩过的一些坑。
1. 先理清楚:打包系统到底在解决什么问题
很多团队对打包系统的认知停留在"写个脚本调用一下Build接口"上。这个阶段的项目通常还能跑,因为出包的人就是开发本人,出错了随时能debug。可一旦项目进入联调期、提测期,出包就变成了团队级行为:策划要包测玩法、客户端要看资源、测试要验证更新、渠道要母包。这时候"能出包"和"可复现的出包"就是两码事了。
1.1 打包系统的职责边界
我理解的Editor打包系统,核心职责是四件事:
- 可复现:同一份代码、同一份资源、同一份配置,在任何机器、任何时间打出来的包,行为必须一致。
- 可配置:不同平台、不同渠道、不同版本,通过配置而不是改代码来区分。
- 可扩展:项目自定义逻辑(替换图标、改配置、写版本号)能挂载进来,而不需要改打包系统底层。
- 可观测:打包过程有日志、有报告,失败时能快速定位到具体环节。
如果这四件事都做到了,这个打包系统就算立住了。如果只做到了第一件,那它充其量是个"能跑的脚本"。
1.2 常见误区:把打包逻辑全部写进Editor脚本里
我最开始写打包工具的时候,就是典型的"什么都在Editor脚本里干":判断平台、执行Build、拷贝产物、上传服务器,全塞在一个几百行的静态方法里。缺点很快就暴露了——改一处牵一发动全身,而且所有定制逻辑全混在一起,后来的人根本不敢动。
这个经历给我的教训是:打包系统的架构重点不在"怎么写构建代码",而在"怎么把流程拆成可组合的模块"。这跟写业务系统是一个道理,只不过它的用户不是玩家,而是团队里的开发、策划、测试。
2. 整体架构分层:一条流水线,四个层次
我设计的打包系统架构,参考的是经典的分层思想,但做了一定裁剪。整体分四层:入口层、编排层、任务层、资源层。下面逐一说明。
2.1 入口层:把出包变成"命令"而不是"流程"
入口层解决的是"人怎么触发打包"。我见过最原始的做法是:让开发打开工程,在菜单里点某个自定义MenuItem,然后在弹出的对话框里填一堆参数。这种方式在小团队里能用,但放到CI上就寸步难行——CI没法弹对话框。
所以入口层我推荐做两件事:
- 提供一个命令行入口,支持从外部传入所有参数(平台、渠道、版本号、输出目录等)。
- 提供一个可视化面板,本质是对命令行参数的封装,方便开发本地手动操作时不用记参数名。
命令行入口是打包系统的生命线。不管以后接Jenkins还是GitLab CI,它都直接决定你能不能平滑接入。可视化面板则纯粹是开发者体验问题,做得再好,也只是在调命令行。
2.2 编排层:用清单文件描述"怎么打"
编排层是打包系统的核心所在。它解决的痛点是:打包过程不是一个Build调用,而是很多步骤的组合。
一个典型的多渠道包流程是这样的:
- 拉取/切换代码分支。
- 更新打包机上的资源库(或者烘焙资源)。
- 根据渠道配置修改工程设置(包名、图标、启动图、权限)。
- 执行引擎构建,生成安装包。
- 做后处理:重命名、注入渠道SDK、修改配置、签名。
- 生成Build Report,上传产物到内部服务器。
这些步骤各自独立,又前后依赖。如果把这些步骤顺序写死在代码里,那么新加渠道、调整步骤顺序都得改代码。我的做法是:用一份清单文件(Manifest)来描述整个流程,代码只负责"读清单、执行步骤"。
2.3 任务层:最小可复用单元
清单文件里的每一步,在代码里对应一个任务(Task)。任务层是打包系统的"积木块",每个任务只干一件事,输入输出清晰。
举几个例子:
| 任务名 | 职责 | 典型参数 |
|---|---|---|
| SwitchPlatformTask | 切换当前平台 | Platform, Architecture |
| ApplyChannelConfigTask | 应用渠道配置 | Channel, VersionCode |
| BuildPlayerTask | 执行引擎构建 | OutputPath, Development Build |
| PostProcessTask | 通用后处理入口 | ScriptName, Enabled |
| CollectArtifactTask | 收集/重命名产物 | SourceMask, TargetName |
任务与任务之间不直接通信,只通过一个共享的上下文对象传递数据。这样每个任务可以单独测试,也可以自由组合。
2.4 资源层:把引擎差异关进笼子里
资源层是很多打包系统设计时忽略的。所谓资源层,就是对引擎提供的底层接口做一层薄封装,隔离不同引擎版本和不同模块的API差异。
举例子:Unity里老版本用BuildPipeline.BuildAssetBundles,新版本用BuildPipeline.BuildAssetBundles重载,参数签名变了;PlayerSettings里API也有变动。如果没有资源层,这些差异会被迫散落在各个Task里。有了资源层,只有这层需要关心引擎版本差异,上层的编排和任务都不用改。
3. 核心模块拆解:配置、依赖、报告
架构分完层,还要解决几个横向的核心问题。这几个模块不挂在某一层,而是贯穿整个打包过程。如果它们没设计好,架构再分层也白搭。
3.1 配置系统:数据驱动才是灵魂
打包过程中有大量配置项:打包哪个平台、哪个渠道、版本号多少、是否需要Development Build、SDK路径在哪、签名文件在哪。这些信息如果散落在代码里,就是灾难。
我建议所有配置都外置,核心分两类:
全局配置,描述"打包机环境"和"通用参数",比如:
{ "enginePath": "D:/Engine/2021.3.10f1", "outputRoot": "D:/Builds", "androidSdkPath": "D:/AndroidSDK", "keystore": { "path": "D:/Keys/release.keystore", "alias": "game", "password": "******" }, "serverList": { "qa": "http://192.168.1.10:8080", "prod": "https://api.game.com" } }渠道配置,描述"某个渠道的专属设置":
{ "channel": "huawei", "packageName": "com.game.huawei", "appName": "游戏名-华为版", "versionCode": 10021, "icon": "Assets/Icons/huawei.png", "sdkPlugins": ["HuaweiSDK"], "postScripts": ["InjectChannelInfo", "ObfuscateDll"] }配置驱动带来的最大好处是:新加一个渠道,只需要新增一份配置文件,而不需要新增一份代码路径。渠道之间的差异被数据化,产品、运营、测试都能看懂,而不必等开发改脚本。
注意:配置里千万不要放密码明文,至少用环境变量或者密文托管,尤其是签名密码和服务器密钥这些敏感信息。我在公司内部见过把keystore密码直接写在JSON里提交到Git仓库的,这个习惯必须改。
3.2 依赖收集:最容易翻车的地方
打包系统最隐蔽的坑,往往不在Build本身,而在"你以为你打进去了,实际没有"。
依赖收集的典型问题:
- 运行时动态加载的资源,不在场景引用链上,打包器收集不到。
- AssetBundle之间的隐性依赖,比如A Bundle引用了B Bundle里的材质,如果B没被显式打进包里,运行时材质就丢了。
- Shader变体,非常经典:某些变体只有在特定渲染条件下才会被收集,漏了变体,线上就有材质变紫。
- 代码反射引用的类型,可能被打包器裁剪,导致运行时TypeNotFound。
所以在打包系统架构里,依赖收集不能依赖引擎默认行为,必须显式声明、显式校验。我的做法:
- 建一份"资源打包清单",列出哪些资源必须进包,哪些Bundle必须单独打。
- 打包完成后跑一遍"静态依赖校验",扫描场景和Prefab的引用,跟打包报告做比对,找出"被引用但未打进包"的资源。
- Shader变体收集,用ShaderVariantCollection显式声明常用变体集合,不指望自动收集。
3.3 产物管理与版本追溯:Build Report不能省
打包过程的另一个核心模块是"产物管理"。它不是简单地把APK丢到一个目录里,而是要回答三个问题:这个包是什么时候打的?谁打的?包含了哪些提交?
我推荐每次打包都生成一份Build Report,至少包含:
| 字段 | 说明 |
|---|---|
| BuildTime | 打包开始/结束时间 |
| BuiltBy | 执行打包的用户(或机器名) |
| Branch | 代码分支 |
| Commit | 具体commit hash |
| Config | 使用的那份Manifest/配置文件名 |
| Output | 产物路径和Hash值 |
| EngineVersion | 引擎版本 |
| Errors/Warnings | 打包期间的异常和警告摘要 |
不要小看这份报告。线上出了问题,第一件事是查"包是哪来的"——有Build Report,这是10分钟的事;没有,可能就是一天的排查。
4. 实操:一个可落地的打包系统骨架
前面讲了架构思路,这部分给一个能跑的骨架,用类C#的伪代码描述,按我上面说的四层来写。
4.1 入口层:命令行与菜单
入口层最核心的是一个参数解析器,把命令行参数转成配置对象:
// CommandLineEntry.cs public static class CommandLineEntry { public static void Execute(string[] args) { var options = ArgsParser.Parse(args); // 命令行下不允许弹窗,直接跑 BuildPipelineRunner.Run(options); } }菜单入口本质是套壳:
[MenuItem("Build/Open Build Window")] public static void OpenWindow() { // 可视化面板,内部生成参数对象,调 BuildPipelineRunner.Run BuildWindow.Show(); }唯一要注意的是:菜单入口和命令行入口共用同一个Runner,避免两条路径代码分叉。我见过有些项目菜单里一套逻辑、命令行又一套逻辑,最终结果不一致,排查起来非常痛苦。
4.2 编排层:读取Manifest并驱动任务
编排层核心逻辑是:读取Manifest、按顺序执行Task、收集结果。
public class BuildPipelineRunner { public BuildResult Run(BuildOptions options) { var manifest = ManifestLoader.Load(options.ManifestPath); var context = new BuildContext(options); foreach (var step in manifest.Steps) { var task = TaskRegistry.Create(step.TaskName); if (task == null) { throw new BuildException($"Unknown task: {step.TaskName}"); } task.Initialize(context, step.Param); task.Execute(); context.RecordResult(step.TaskName, task.Result); } return new BuildResult { Success = context.StepResults.All(r => r.Success), ArtifactPath = context.GetOutput(), Report = BuildReportGenerator.Generate(context) }; } }Manifest文件示例(YAML或JSON均可,我常用YAML,可读性好一些):
name: android-huawei-rel platform: android channel: huawei version: 1.4.2 versionCode: 10042 steps: - task: SwitchPlatform param: { platform: android, arch: arm64 } - task: ApplyChannelConfig param: { channel: huawei } - task: BuildAssetBundles param: { output: Bundles/huawei, variant: release } - task: BuildPlayer param: output: Builds/android/huawei/game_1.4.2_huawei.apk development: false - task: PostProcess param: { scripts: ["InjectChannelInfo"] } - task: CopyArtifact param: { keepDays: 14 }为什么用配置驱动而不是代码硬编码?六个字:改配置不用发版。测试要一个"关闭热更"的包、策划要一个"全资源直调"的包,都是改配置的事,不用动代码。
4.3 任务层:任务接口设计
任务层的接口我一般这样定义:
public interface IBuildTask { string Name { get; } void Initialize(BuildContext context, TaskParam param); void Execute(); }每个任务不直接依赖其他任务,只通过BuildContext读写共享数据。比如BuildPlayerTask需要知道"输出路径",它不关心这个路径是谁设置的,只管从context里取。
context.Get<string>("OutputPath");这样的好处是:任务可以任意组合、任意排序,逻辑上完全解耦。坏处是:context是弱类型字典,如果key拼错,运行时才发现。折中方案是context内部做类型校验和key白名单,或者生成强类型DTO。
4.4 资源层:封装引擎差异
资源层用一个例子说明,AssetBundle构建接口:
public static class AssetBundleBuilder { public static BuildResult Build(string outputPath, bool devMode) { // 根据当前引擎版本选择不同的API if (EditorUserBuildSettings.activeBuildTarget == BuildTarget.Android) { // 具体实现... } // 屏蔽引擎版本的API差异 } }资源层虽然薄,但极其重要。它的存在意义是:当引擎升级时,只有这层需要改。任务层、编排层完全无感知。
4.5 参数与目录规范
最后给出一个我实际在用的目录规范,方便参考:
Builds/ ├── android/ │ ├── huawei/ │ │ ├── game_1.4.2_huawei.apk │ │ └── report.json │ ├── xiaomi/ │ └── google/ ├── ios/ │ ├── huawei/ # iOS也按渠道分,不一定只有Android才多渠道 │ └── appstore/ └── logs/ ├── build_20250601_1030.log └── build_20250601_1030_error.log目录命名里带版本号和渠道名,看着简单,但在"按需回溯旧包"的场景下省太多事了。我见过有些人把包全丢在一个目录里,文件名就一个日期,后面想找某个特定渠道的包只能一个个打开看,效率极低。
5. 常见问题与排查技巧实录
架构设计得再完美,落地时一定会碰到实际问题。挑几个我踩过的坑,分享排查思路。
5.1 不同机器打出来的包体大小不一致
现象:开发在自己电脑上打个包可能才180MB,CI机器上打出来190MB,差异还挺稳定。
排查思路:
- 先看是否同一份配置、同一个代码commit。
- 看资源打包是否受机器上已有的AssetBundle缓存影响。
- 看Shader变体收集是否一致(不同机器安装的插件版本可能不同)。
我遇到最典型的原因是:开发机器上之前手动Build过一次,生成了增量缓存,而CI是干净的,导致引用收集不一致。解决办法:在流水线里面显式清理旧的打包缓存,保证每次打包都是干净状态,或者反过来,保证增量缓存是可复现的。
5.2 增量构建不生效,甚至打出旧资源
增量构建不生效,多半是缓存key的设计问题。打包缓存key必须包含:代码版本、资源版本、配置版本、引擎版本、平台、渠道,任何一个维度变了,缓存key都要变。这个key可以是一个拼接的字符串,也可以是一个Hash。
还有一个常见坑:有些任务会把产物写到固定路径,比如Bundles/temp,如果两次构建的key不同但路径相同,就可能读到上一次的旧Bundles。我的建议是缓存目录按key分目录,不要用固定路径。
bundleCacheRoot: Cache/{platform}/{channel}/{assetsVersion}5.3 并行构建时资源冲突
多人同时触发打包,如果输出目录都是同一个,会产生竞争:文件被覆盖、日志互相穿插、签名并发冲突。
简单解法是输出目录加时间戳或者任务ID:
outputRoot: Builds/{platform}/{channel}/{timestamp}更进一步是引入构建任务ID,整个Pipeline以任务ID为根目录,所有中间产物都在这个目录下,任务结束后再拷贝到共享产物区。
5.4 Editor版本与打包机版本不一致
这个问题在接CI时特别明显:本地用的是2021.3.10f1,CI机器上装的是2021.3.20f1,两个版本行为可能有细微差别,导致本地和CI打出来的包不一致。
强制锁定版本:CI机上安装和开发一致的Editor版本,在配置里显式声明引擎版本,校验不通过直接拒绝构建。别怕麻烦,这一步是"可复现"的基石。
6. 关于后续扩展的几点个人经验
这套架构如果跑顺了,后面有几个方向可以自然扩展,不需要推翻重来。
一个是接入CI/CD。因为入口层是命令行参数,CI那边只需要调一条命令,把Manifest路径传进去,就能接入流水线。接的时候建议让CI把打包日志、Build Report挂到构建页面,这样开发看CI结果的时候,不止看到一个"成功/失败",还能直接看到产物路径和失败日志。
另一个是分布式构建。当项目体量变大,本地Bundle构建、代码编译、签名都比较耗时,可以把不同任务放到不同的机器上执行。但前提是任务层接口足够独立,不然分布式了也没法编排。
还有一个我最近在尝试的方向:把打包测试从"人肉验证"推进到"自动冒烟"。打出来的包自动跑一遍关键流程(启动、登录、进关卡),把截图和日志回传。这不算打包系统本身,但它是打包系统收口的一环,能发现很多"包能出但根本不能玩"的问题。
我个人的体会是:Editor打包系统看起来是个"内部工具",不值得重视,但其实它的架构质量直接决定了团队效率的上限。混乱的打包脚本会让每个成员都变成"出包运维",而清晰的分层和配置驱动,则能让打包变成一件随时可以交给机器的事。