1. 为什么我最终选择了自定义包:被复制粘贴折磨过的人才懂
1.1 一次跨项目“复制代码”事故,让我下定决心
先说一个我踩过的真实场景。几年前我在A项目里写了一个批量修改Prefab的编辑器工具,A项目跑得挺稳,团队里的人也都用顺手了。后来B项目组的人找过来,说“这个工具给我们也整一份呗”。我当时图省事,直接打了个.unitypackage发过去,结果对方导入以后脚本报错报了一大片——原因很简单:那个工具依赖了Editor程序集,还引用了A项目里的一个公共类库,而B项目里根本没有这些依赖。更麻烦的是,B项目组拿到的版本还是老一版的,我后来修了几个Bug他们也不知道,两边的工具行为完全不一致。
这种“复制粘贴式”协作,在Unity项目里太常见了。代码从Assets目录拖来拖去、靠微信传压缩包、在多个项目里各维护一份副本,问题一大堆:版本漂移、依赖缺失、改了一个地方忘了同步、新同事根本不知道这个工具从哪来的。我当时就在想,Unity既然有Package Manager,为什么不能把自己的工具代码也做成一个“包”?后来我花了大半天把工具库重构成了Custom Package(自定义包),从那一刻起,这些痛全部消失了。
1.2 自定义包到底解决了什么问题、适合谁用
Unity的Custom Package,本质上是把你的代码、资源、文档按一套约定好的结构整理在一起,交给Package Manager(UPM)统一管理。它能解决的核心问题有三个:
- 版本可控:UPM会记录包的具体版本号,你可以锁定版本,也可以按需升级,不会出现“B项目拿到的是旧副本”这种问题。
- 依赖可声明:包可以在自身的package.json里声明“我需要依赖哪些其他包”,安装时UPM会自动拉取,不需要使用者手动逐个去装。
- 程序集边界清晰:包内部可以定义自己的程序集(asmdef),代码之间的依赖关系一目了然,编译错误也不会蔓延到整个项目。
这个方案适合谁用?我觉得主要有几类:
- 团队里有通用的编辑器工具(批量处理资源、自定义窗口、构建脚本),需要在多个项目间共享。
- 你自己积累了一套常用代码库(网络请求封装、对象池、事件系统),不想每次都Ctrl+C再Ctrl+V。
- 公司内部有封装的SDK(账号登录、数据统计、热更新框架),给多个游戏项目共用。
- Unity开发插件想去Asset Store或其他渠道分发的独立开发者。
其实不适合的情况也很清楚:那种和单个项目业务深度绑定的脚本、强依赖某个场景管理的逻辑,强行做成包反而增加复杂度。我更建议把“通用能力”抽出来,项目特有逻辑留在Assets里。
2. 自定义包的工作机制:它其实就是一个“带合同”的文件夹
2.1 package.json:包与UPM之间的“合同”
第一次看到自定义包这个说法时,我的第一反应是:这不就是个文件夹吗?和Assets下新建目录有什么区别?区别恰恰就藏在package.json里。这个文件可以理解为包和UPM签的一份“合同”,里面记录了这个包的名字、版本、依赖关系和展示信息。UPM依靠这份合同来决定如何加载、显示和解析这个包。
一个最基础的package.json是这样的:
{ "name": "com.mycompany.devtools", "version": "1.2.0", "displayName": "My Dev Tools", "description": "A set of shared editor tools and runtime utilities.", "unity": "2021.3", "author": { "name": "My Company", "email": "dev@mycompany.com", "url": "https://www.mycompany.com" }, "keywords": [ "editor", "tool", "utility" ], "dependencies": { "com.unity.textmeshpro": "3.0.6" } }这里面有几个字段值得专门说一下:
- name:包的唯一标识,格式建议是“com.公司名.包名”。这玩意儿不只是个名字,它直接决定包的缓存目录,也影响在Registry上的唯一性。如果包要发布,name一旦定下来基本别改,改了等于换了个新包。
- version:必须遵循SemVer语义化版本规范,也就是“主版本号.次版本号.修订号”的形式,例如1.2.0。
- unity:指定这个包依赖的最低Unity版本,UPM在安装时会做校验,版本太低会提示不兼容。
- dependencies:声明当前包依赖的其他包,格式是包名加版本号。这里可以用范围写法,比如
"com.unity.textmeshpro": "3.0.6"表示精确版本,也可以写成"3.0.0"以上版本范围的表达方式,但为了稳定,团队内我一般建议写精确版本。
Unity在读取包信息时,还会读取同目录下的CHANGELOG.md、LICENSE、README等文档,并在Package Manager窗口的详情面板里展示出来。把这个“合同”写清楚,使用这个包的人不用看代码,就能知道它的用途、版本、作者和更新内容。
2.2 目录结构与~后缀:哪些内容会被编译、哪些会被忽略
包内的目录结构有约定,但不强制。Unity官方推荐的做法是:
包名/ ├── package.json ├── Runtime/ # 运行时脚本 ├── Editor/ # 编辑器脚本 ├── Samples~ # 示例代码(带~后缀) ├── Documentation~ # 文档(带~后缀) ├── Tests~ # 测试代码(带~后缀) └── CHANGELOG.md这里比较反直觉的是那个~后缀。在Unity里,文件夹名称以~结尾,会被Unity忽略,不参与资源导入、也不会被编译。这就带来一个好处:你可以把文档、示例、测试代码放到包里,但它们在最终打包发布时不会混进玩家的游戏包里。比如Documentation~里的README和图片,只在本地开发时可以看到,Build后不会被打进去。
与之对应,Runtime目录下的代码会被编译进运行时程序集,在玩家设备上运行;Editor目录下的代码只在编辑器环境下编译。如果你把UPM当成一个普通的Assets文件夹,把所有脚本都丢进去,很容易把程序集关系搅浑,后面踩坑会很难受。
UPM的工作方式和其他资源导入也不太一样:包内的资源会被Unity当做一个整体的引用单元,包与包之间、包与Assets之间靠程序集引用(asmdef)和package.json的依赖关系来组织,而不是靠简单的“文件夹包含”关系。理解这一点,后面很多问题都能想通。
3. 从零搭一个本地自定义包:完整的实操记录
3.1 动手前先定好目录骨架与包名规范
我的做法是,在工作目录旁边放一个LocalPackages文件夹,专门存内部工具包,然后通过UPM的本地引用挂到项目里。不放在项目内Packages目录的原因后面会提到,主要是为了多项目共享和维护方便。
先按官方规范把骨架建好:
LocalPackages/ com.mycompany.devtools/ package.json Runtime/ DevToolLogger.cs DevToolLogger.asmdef Editor/ ClearPlayerPrefsMenu.cs DevToolEditor.asmdef CHANGELOG.md LICENSE Documentation~ README.md包名我建议遵循“反域名”规则:com.公司名.模块名。比如公司域名是mycompany.com,工具模块叫devtools,那包名就是com.mycompany.devtools。这样命名的好处不只是规范,也是为了避免和其他包冲突——在Registry上,包名必须是全局唯一的。
3.2 写一个真实的包:编辑器清档工具+运行时组件
为了演示Runtime和Editor两类代码怎么组织,我做了一个非常小的包:运行时提供一个简单的日志组件,编辑器里提供一个“一键清除PlayerPrefs”的菜单工具。内容很小,但足够说明两种程序集的差别。
先写package.json:
{ "name": "com.mycompany.devtools", "version": "0.1.0", "displayName": "Dev Tools", "description": "Internal shared editor tools and runtime utilities.", "unity": "2021.3", "author": { "name": "My Company" } }然后是Runtime下的运行时脚本DevToolLogger.cs,这个类不强依赖Editor,打真机也能用:
using UnityEngine; namespace MyCompany.DevTools { public static class DevToolLogger { public static void LogInfo(string message) { Debug.Log($"[DevTools] {message}"); } public static void LogWarning(string message) { Debug.LogWarning($"[DevTools] {message}"); } } }接着是Editor下的ClearPlayerPrefsMenu.cs,注意它引用了UnityEditor命名空间,只能在编辑器环境下运行:
using UnityEditor; using UnityEngine; namespace MyCompany.DevTools.Editor { public static class ClearPlayerPrefsMenu { [MenuItem("Tools/Dev Tools/Clear PlayerPrefs")] public static void ClearAll() { PlayerPrefs.DeleteAll(); PlayerPrefs.Save(); Debug.Log("[DevTools] PlayerPrefs has been cleared."); } } }3.3 在Package Manager里挂载并验证结果
代码写完后,需要给两个目录分别创建asmdef程序集定义。在Unity编辑器里,右键点击文件夹,选择Create > Assembly Definition即可生成。这里我给出两个文件的关键配置。
DevToolLogger.asmdef(Runtime,不限制平台):
{ "name": "MyCompany.DevTools.Runtime", "rootNamespace": "MyCompany.DevTools", "references": [], "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], "noEngineReferences": false }DevToolEditor.asmdef(Editor,仅编辑器平台编译):
{ "name": "MyCompany.DevTools.Editor", "rootNamespace": "MyCompany.DevTools.Editor", "references": [ "MyCompany.DevTools.Runtime" ], "includePlatforms": [ "Editor" ], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], "noEngineReferences": false }接下来在Unity编辑器顶部菜单打开 Window > Package Manager,点击左上角“+”按钮,选择“Add package from disk...”,然后选中包目录下的package.json文件。Unity会把它加入当前项目的Packages/manifest.json,Package Manager窗口里就能看到这个包了。
验证方式很简单:菜单栏应该出现“Tools/Dev Tools/Clear PlayerPrefs”,点击后控制台打印清除信息;Project窗口里展开Packages节点,能看到com.mycompany.devtools这个包,点开能看到里面的脚本和文档目录。
注意:Add package from disk和Add package from tarball是有区别的。前者是直接引用本地文件夹,适合开发阶段频繁改代码;后者是引用.tgz压缩包,适合分发固定版本,但不能直接修改源码。
4. 别急着塞代码:asmdef、依赖与版本号这些“包规”要搞清
4.1 Editor与Runtime为什么要分家:程序集视角
很多初学者最容易犯的错误,是把所有脚本丢进一个文件夹,连asmdef都不建。没有asmdef的脚本会全部编译进项目默认的Assembly-CSharp程序集。这个程序集在项目里算是一个“大杂烩”,谁都能引用它,它也能引用所有勾选了Auto Referenced的程序集。
一旦把代码变成包,我强烈建议给Runtime和Editor分别建立独立的asmdef,原因有两点:
第一,编译边界。includePlatforms设为Editor的程序集不会被打进Player包,也不会在真机编译阶段暴露。如果你的程序集里引用了UnityEditor命名空间,却没有限制为Editor-only,打包到Android/iOS时会直接在IL2CPP阶段爆一堆“type or namespace not found”的错误。分家以后,这类错误被Unity提前拦截,不会拖到打包时才发现。
第二,依赖方向。Runtime程序集应该保持干净,不依赖Editor程序集;Editor程序集可以反过来引用Runtime程序集。方向一旦反了,你在打包时同样会遇到引用错误。我把这种关系理解为:运行时是基础库,编辑器是附加工具,附加工具可以调用基础库,但基础库不能反过来依赖工具。这个单向依赖规则,不仅对程序集成立,对整个包与Assets的关系也成立——Assets里的代码可以引用包,包内代码最好不要反过去引用Assets里的业务代码。
还有一个容易被忽略的点:autoReferenced字段。它表示这个程序集是否会被Assembly-CSharp自动引用。默认是true,意味着项目里其它无asmdef脚本可以不写任何引用直接调用这个包里的类。但如果你有多个包互相依赖,或者想做严格的层级隔离,可以把一部分程序集关掉autoReferenced,只允许显式引用。这能让依赖关系更清晰,代价是使用方需要手动在被引用程序集asmdef里加上reference。
4.2 依赖声明、SemVer版本号与包内元数据
包和包之间如果要互相使用,有两种方式。一种是在asmdef的Assembly Definition References里手动引用对方程序集,另一种是在package.json的dependencies里声明依赖关系。我个人的习惯是:如果你是“代码层面引用”,两者都做——dependencies保证UPM会自动把依赖包拉下来,asmdef引用保证编译器知道这个类型去哪找。
SemVer版本号值得单独拿出来讲。版本号格式是主版本.次版本.修订,比如1.2.3。其中:
- 主版本:有不兼容的API变更,升主版本。
- 次版本:新增功能,但保持向后兼容,升次版本。
- 修订:修Bug,不影响API,升修订号。
预览版可以加后缀,比如1.2.0-preview.1。在使用Git分发时,我习惯把版本号直接和一个git tag绑定,例如发v1.2.0,这样使用者可以在Package Manager里切换版本,一目了然。版本号乱写、不更新,是团队协作中一个非常隐蔽的坑——你改了包代码但不升版本号,UPM可能认为包没变化,缓存不刷新,使用者拿到旧逻辑还以为是自己写错了。
包内元数据也建议规范化:CHANGELOG.md记录每次版本变更;LICENSE声明许可证;README放在Documentation~里做使用说明。这些看似不起眼,但在多人协作时能省下大量沟通成本。尤其是CHANGELOG,我发现每次更新包后在群里发一句“更新了啥”,远不如让使用者自己打开Package Manager看更新记录来得高效。
5. 分发和引用的几种姿势:本地、Git与Registry
5.1 file:引用:团队的“本地仓库”方案
打包成自定义包以后,最简单的引用方式就是file:本地路径引用。在项目根目录下打开Packages/manifest.json,可以看到类似这样的内容:
{ "dependencies": { "com.mycompany.devtools": "file:../../LocalPackages/com.mycompany.devtools" } }这里的路径是相对项目根目录的。我建议使用正斜杠,Windows下反斜杠容易出问题。file:引用适合在本地开发、或者团队小伙伴通过共享文件夹直接访问同一个包目录的场景。因为每次改完代码,不需要任何发布流程,重进Unity或者等它刷新就能看到效果,开发效率最高。
但它有个明显缺陷:路径信息写进了manifest.json。如果你的包目录路径和同事的不一致,他拉下代码后会因为找不到路径而编译失败。比如有的人把LocalPackages放在D盘,有的人放在C盘,一旦用绝对路径,协作直接炸。所以用file:引用时,要么约定所有人的目录结构一致,要么只在单机开发阶段临时使用,提交代码前改用Git引用。
5.2 Git URL引用:跨项目分发的主流方案
Git引用是我在团队里最推荐的方式。把包代码放到一个独立的Git仓库里,然后这样写:
{ "dependencies": { "com.mycompany.devtools": "https://github.com/mycompany/com.mycompany.devtools.git#v1.2.0" } }支持通过#tag、#branch或者commit hash来锁定版本。我强烈建议用tag锁定,而不是直接指向master分支。否则某个人推了一版有问题的代码,所有在建项目全都会被影响到,而且你很难快速定位是谁触发的问题。
Git引用的一个细节是:UPM实际上是先把仓库clone到本地缓存,再读取package.json来安装。所以如果仓库是私有的,需要配置好访问权限。公网Git平台没有问题,内网GitLab只要配好SSH Key也OK。另外,仓库根目录必须是包根目录,也就是package.json所在位置,不能把包放在仓库的子目录里,否则UPM找不到包入口。
5.3 发布到私有Registry:面向大规模团队的最后一步
Unity的Package Manager底层协议和npm是兼容的。也就是说,你可以把自研包发布到npm registry上,Unity能直接识别。对于大型团队,这种方式的优势是:版本管理清晰、支持依赖解析、可以同时维护几十个包。
发布流程本身其实就是npm的流程。先在包目录下执行登录和发布命令:
npm login --registry http://your-registry.com npm publish --registry http://your-registry.com然后使用方在manifest.json里添加registry地址和自己的包名就够了。也可以在Unity的Package Manager窗口设置Scoped Registry。这个过程不复杂,但需要运维一个registry服务,常见方案是自建Verdaccio或者使用付费服务。对于团队还没有多到“包个数超过20”的阶段,我不建议急着上registry,Git引用已经能覆盖绝大多数需求。
5.4 四种引用方案怎么选:一张表说清楚
下面是我个人总结的对照表,可以直接参考选择:
| 引用方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| file:路径 | 单机开发、临时验证、包与项目在同一个共享盘 | 修改即时生效,无需发布 | 路径写进manifest,协作环境容易失效 |
| Git URL | 团队跨项目共享正式工具库 | 版本可控、跨平台、协作无缝 | 改动需要提交push才能生效 |
| 嵌入式包(直接放Packages目录) | 某个项目需要强制锁死包内容 | 不依赖外部网络,修改直接生效 | 包内容会进版本库,多人同时改容易冲突 |
| Registry发布 | 大型团队、多包长期维护 | 版本解析、依赖统一管理 | 需要额外运维成本,学习曲线较陡 |
嵌入式包是我没细说的一个方式:把包目录直接放在项目的Packages文件夹下,Unity会自动识别它为包,并且在Package Manager的“Embedded”分类里显示。它非常适合那种“这个项目必须用这版,谁都不许乱动”的场景。缺点是如果多个项目都要用,你需要在每个项目里维护一份拷贝,回到复制粘贴的老路上,所以它更适合单项目内部的强制捆绑。
6. 特性坑与真实排查过程:这些坑我替你踩过了
6.1 改了本地包却不生效:缓存问题排查
第一个让我抓狂的坑,是本地包明明改了代码,Unity却始终跑旧逻辑。我一度以为是自己年龄大了眼花,仔细一看,项目里引用的代码路径完全没问题,最新代码就在那里。
原因其实是UPM的缓存机制。UPM会把Git引用或Registry引用的包缓存到全局缓存目录和项目下的Library/PackageCache。当你使用file:引用时,UPM会尝试按包名+版本号去查找缓存,如果缓存里已经存在同版本号的旧内容,就可能出现加载旧代码的问题。
排查思路可以按这个链路走:
- 先确认package.json里的版本号有没有变。没变的话,Unity有理由不刷新。
- 打开
Library/PackageCache目录,看有没有com.mycompany.devtools相关缓存文件夹,有的话退到编辑器外删除它。 - 删完缓存重新打开Unity,UPM会重新解析manifest.json,拉取或复制最新代码。
所以在本地包开发中,我养成了一个习惯:每次改完代码,至少把版本号的修订位加一,哪怕不提交Git,也能避免很多“改了没反应”的困惑。这个习惯帮我省掉大量无谓的排查时间。
6.2 asmdef引用方向错乱:一个真实报错的分析流程
另一次踩坑,是我在包里的Editor程序集里写了一个辅助类,想给Runtime程序集用,于是反手在Runtime的asmdef里添加了对Editor程序集的引用。编辑器在编译阶段直接报了一个很拗口的错误:
Assembly 'MyCompany.DevTools.Runtime' will not be loaded because it is referenced by another assembly which is not referenced by it.翻译过来是:程序集A引用了程序集B,但B反过来也引用了A,形成了循环引用,Unity拒绝加载。这类循环引用在包多了以后特别容易出现,而且很难一眼看出是哪两个程序集在打架。
我的排查流程是:
- 打开Window > General > Console,把编译错误切换到详细信息模式,找到具体涉及的程序集名字。
- 依次检查两个asmdef文件,看Assembly Definition References里是否互相引用了对方。
- 画一下引用关系:如果发现方向反了,就调整引用方。比如要让Runtime用Editor里的工具,就应该把Editor抽成公共部分,或者放弃这种设计,而不是硬把引用塞进去。
- 如果确实需要Runtime依赖某个编辑器工具,那说明这个工具本质上应该放在Runtime里,别把它定义在Editor程序集中。
这个案例其实说明一个道理:asmdef不只是为了让代码编译更快,它还是你代码架构的“安全边界”。你把代码拆分到位,依赖方向自然清晰,出问题也好定位。反之,等你把几十个脚本一股脑堆在同一个程序集里,再想拆分就非常痛苦。
6.3 manifest.json被合并工具覆盖:团队协作中的版本僵局
最后一个实战坑,发生在多分支并行开发时。团队几个成员同时往Packages/manifest.json里加不同依赖,Git合并时产生了冲突。有同事图省事,直接选了一方的版本覆盖掉,结果另一个人刚加的包引用直接被删除。更隐蔽的是,Packages/packages-lock.json瞬间变成不一致状态,UPM常常会在下一次自动解析时重写整个lock文件,造成更复杂的合并冲突。
那次排查后我建立起一个约定:涉及manifest.json的变更,尽量独立提交,不要和其他功能代码混在同一个commit里。遇到合并冲突时,不要简单覆盖,应该手动把两边的dependencies条目合并到一起,然后让Unity重新生成packages-lock.json,最后再检查一遍Package Manager里的包列表是否完整。
如果你的团队对UPM依赖管理特别频繁,还有一个更省心的方案:把公共依赖集中维护在一个固定的“基础项目模板”里,新项目从模板创建,团队内部不做大规模依赖变更,每次变更走评审流程。看起来好像小题大做,但经历过一次“某个包莫名其妙没了”的排查之后,你会发现这个习惯非常值钱。
我个人现在维护内部工具包的流程是:工具包单独建仓库,版本号严格按SemVer走,每次发布在CHANGELOG里记一笔、打一个git tag,然后在需要更新的项目manifest里把tag号改一下。新的项目加入团队,只需要在manifest里加一行依赖,UPM自动把整个工具链拉齐。这份体验对比当年复制.unitypackage到处传的“原始社会”,可以说是天壤之别。如果你手里已经积攒了好几套跨项目复用的代码,真心建议花半天时间把它们整理成Custom Package,后面省下来的时间和避免的坑,远远超出你最初的投入。