
1. 项目概述为什么我们需要自定义Unity包如果你在Unity团队里待过一段时间大概率遇到过这样的场景美术同学需要一个批量重命名工具程序同学写了一个脚本扔在Assets/Editor下后来另一个项目也需要类似功能大家又开始满世界找那个脚本或者干脆重写一遍。又或者你们团队有一套内部使用的UI框架、资源管理模块每次开新项目都得手动复制一堆文件夹还得小心翼翼地处理依赖和版本生怕漏掉哪个dll或者asmdef文件。这种“散装”的代码和资源管理方式在项目初期或小团队中尚可忍受。但随着团队规模扩大、项目数量增多其弊端会像滚雪球一样凸显代码重复、版本混乱、更新困难、新人上手成本高。而Unity官方提供的Package Manager和自定义包Custom Package功能正是为了解决这些问题而生的。它允许你将可复用的代码、资源、编辑器工具打包成一个独立的单元像安装TextMeshPro或Cinemachine一样通过一行git地址或一个包名就能在任何项目中一键安装、更新和管理。今天要聊的就是如何从零开始把一个散落在项目角落里的编辑器工具或运行时模块打包成一个专业的、可通过Git仓库在团队内部分享和协作的自定义Unity包。这不仅仅是把文件打个压缩包那么简单它涉及到Unity包的标准结构、程序集定义、依赖管理、版本控制以及团队工作流的建立。掌握这套流程意味着你的团队代码将从“手工作坊”升级到“标准化生产”是迈向高效、规范开发的关键一步。2. 核心概念与准备工作在动手之前我们必须先理清几个核心概念这能帮你理解每一步操作背后的意图而不是机械地照搬命令。2.1 Unity包Package到底是什么你可以把一个Unity包理解为一个高度自包含的功能模块。它不仅仅是一堆脚本还可以包含程序集Assembly通过.asmdef文件定义的、编译后的DLL这是包的核心。编辑器工具Editor Tools只在Unity编辑器中运行的脚本和界面。运行时资源Runtime Assets预制体、材质、Shader、动画控制器等。示例Samples展示如何使用的示例场景和脚本。文档Documentationmd格式的说明文件。与直接复制Assets文件夹相比包的优势在于明确的边界通过程序集定义文件.asmdef强制隔离避免命名空间污染和意外的依赖。依赖管理在package.json中声明依赖的其他包如Unity UINewtonsoft.JsonUnity的包管理器会自动处理。版本控制可以指定安装特定版本、某个分支甚至某个提交便于升级和回滚。可移植性一个包可以被多个项目使用更新只需改一处。2.2 程序集定义文件.asmdef——包的基石这是Unity包开发中最关键的一环。.asmdef文件决定了你的代码如何被编译。作用它将一组脚本定义为一个独立的程序集DLL而不是默认编译到Assembly-CSharp中。这带来了更好的编译速度增量编译和清晰的模块边界。重要配置name程序集的名称也是其他程序集引用它时使用的名字。references此程序集依赖的其他程序集包括其他.asmdef定义的或外部DLL。includePlatforms/excludePlatforms指定在哪些平台下该程序集被包含或排除。常用于分离编辑器Editor和运行时Runtime代码。allowUnsafeCode是否允许不安全代码。overrideReferencesprecompiledReferences用于引用外部的、预编译的DLL。注意一个常见的良好实践是为运行时代码和编辑器代码分别创建独立的.asmdef文件。例如MyCompany.MyPackage.Runtime.asmdef和MyCompany.MyPackage.Editor.asmdef并且让Editor程序集引用Runtime程序集。这样可以确保发布游戏时编辑器工具代码不会被包含在构建中。2.3 Git与Unity包的无缝集成Unity的Package Manager原生支持直接从Git仓库安装包格式为com.yourcompany.yourpackage: https://github.com/yourcompany/yourpackage.git或者指定分支、标签、提交com.yourcompany.yourpackage: https://github.com/yourcompany/yourpackage.git#v1.0.0com.yourcompany.yourpackage: https://github.com/yourcompany/yourpackage.git#develop这意味着你的包源码本身就是一个Git仓库。团队共享时无需手动分发文件只需在项目的Packages/manifest.json中添加一行Git地址。当其他成员拉取项目时Unity会自动克隆并导入这个包。这为团队协作带来了极大的便利但也对仓库的结构和提交规范提出了要求。准备工作清单安装Git确保本地和团队成员的机器上都安装了Git。Windows用户推荐使用Git for Windows它包含了Bash和必要的工具。准备一个空的Git仓库在GitLab、GitHub或自建的Git服务器上创建一个空的远程仓库例如命名为com.yourcompany.utility-tools。规划包结构在动手前用纸笔或思维导图简单规划一下你的包要包含哪些功能哪些是运行时需要的哪些是编辑器专用的。3. 从零构建自定义包一步步拆解让我们以一个实际的例子来贯穿整个流程假设我们要创建一个“项目初始化工具包”com.yourcompany.project-initializer它包含一个运行时程序集定义一些通用的枚举和接口和一个编辑器程序集提供创建标准项目文件夹结构的菜单项。3.1 第一步创建标准的包目录结构不要在现有的Unity项目Assets目录里直接捣鼓。正确的做法是在Unity项目之外创建一个独立的文件夹作为包的根目录。在你的硬盘上比如D:\Dev\UnityPackages新建文件夹com.yourcompany.project-initializer。这个命名遵循了Unity包的命名规范反向域名能有效避免冲突。在这个根目录下创建以下核心文件夹com.yourcompany.project-initializer/ ├── package.json # 包的“身份证”最重要文件 ├── README.md # 说明文档 ├── CHANGELOG.md # 版本更新日志 ├── LICENSE.md # 许可证文件 ├── Runtime/ # 运行时代码和资源 │ ├── Scripts/ │ │ └── (你的运行时C#脚本) │ └── com.yourcompany.project-initializer.Runtime.asmdef ├── Editor/ # 编辑器专用代码和资源 │ ├── Scripts/ │ │ └── (你的编辑器C#脚本) │ └── com.yourcompany.project-initializer.Editor.asmdef ├── Samples~/ # 示例文件夹注意末尾的~ │ └── ExampleFolder/ │ ├── SampleScene.unity │ └── SampleScript.cs └── Tests/ # 测试代码可选 ├── EditMode/ └── PlayMode/**关键点解析** * package.json必须放在根目录。它定义了包名、版本、依赖、Unity版本兼容性等元数据。 * Samples~文件夹名末尾的波浪号(~)是Unity的特殊约定。它告诉Unity这个文件夹是“示例”在通过Package Manager安装包时这些示例**不会**自动导入到用户的Assets目录而是出现在Package Manager窗口的包详情页供用户有选择地“Import to Project”。这避免了污染用户的Assets目录。导入后示例会出现在Assets/Samples/Package Name/Sample Name下。 * Tests如果你编写了单元测试使用Unity Test Framework可以放在这里。测试代码通常也需要自己的.asmdef文件。 ### 3.2 第二步编写包的“身份证”——package.json 这是包的核心配置文件。在根目录下创建package.json内容如下 json { name: com.yourcompany.project-initializer, version: 1.0.0, displayName: Project Initializer Tools, description: A set of tools to standardize new Unity project folder structure and initial setup., unity: 2022.3, unityRelease: 20f1, documentationUrl: https://yourcompany.github.io/com.yourcompany.project-initializer/, changelogUrl: https://github.com/yourcompany/com.yourcompany.project-initializer/blob/main/CHANGELOG.md, licensesUrl: https://github.com/yourcompany/com.yourcompany.project-initializer/blob/main/LICENSE.md, dependencies: { com.unity.nuget.newtonsoft-json: 3.2.1 }, keywords: [ tool, editor, utility, productivity ], author: { name: Your Name, email: your.emailyourcompany.com, url: https://yourcompany.com }, samples: [ { displayName: Basic Usage Example, description: Demonstrates how to use the menu item and runtime API., path: Samples~/ExampleFolder } ] }字段详解与避坑指南name必须使用小写以com.开头。这是包的唯一标识符也是你在manifest.json中引用的名字。一旦发布强烈建议永不修改。version遵循 语义化版本规范 Major.Minor.Patch。比如1.0.0。每次向Git仓库推送可用于共享的更新时都应该更新这个版本号。unity和unityRelease指定兼容的Unity编辑器最低版本。unityRelease是完整版本号如2022.3.20f1中的20f1。你可以通过Help - About Unity查看。填错会导致包在低版本Unity中无法识别。dependencies声明此包所依赖的其他包。例如如果你的工具用了Newtonsoft.Json来处理配置就依赖这个包。Unity包管理器会自动解析并安装它们。注意不要在这里声明对Unity模块如Unity.UI的依赖它们通常由项目设置决定。samples数组用于定义多个示例集。path指向的是Samples~下的子文件夹路径。这个配置能让你的示例在Package Manager窗口中优雅地显示和导入。实操心得package.json的格式非常严格一个多余的逗号都可能导致Unity包管理器解析失败。推荐使用VS Code等编辑器并安装JSON语法检查插件。在将包集成到项目测试前可以先用在线JSON校验工具检查一遍。3.3 第三步创建并配置程序集定义文件.asmdef分别在Runtime和Editor文件夹下创建.asmdef文件。Runtime程序集定义 (Runtime/com.yourcompany.project-initializer.Runtime.asmdef):{ name: com.yourcompany.project-initializer.Runtime, rootNamespace: YourCompany.ProjectInitializer, references: [], includePlatforms: [], excludePlatforms: [], allowUnsafeCode: false, overrideReferences: false, precompiledReferences: [], autoReferenced: true, defineConstraints: [], versionDefines: [], noEngineReferences: false }name建议与文件夹名保持一致清晰明了。rootNamespace设置一个根命名空间你在此程序集下的所有脚本的默认命名空间都会基于此。这能有效组织代码避免冲突。Editor程序集定义 (Editor/com.yourcompany.project-initializer.Editor.asmdef):{ name: com.yourcompany.project-initializer.Editor, rootNamespace: YourCompany.ProjectInitializer.Editor, references: [ com.yourcompany.project-initializer.Runtime ], includePlatforms: [ Editor ], excludePlatforms: [], allowUnsafeCode: false, overrideReferences: false, precompiledReferences: [], autoReferenced: true, defineConstraints: [], versionDefines: [], noEngineReferences: false }references这里显式引用了我们的Runtime程序集。因为编辑器工具很可能需要调用运行时定义的接口或枚举。includePlatforms: [Editor]这是最关键的一步它确保这个程序集只在Unity编辑器环境下被编译和引用不会被打进游戏运行时Player Build中。这能有效减少最终包体大小并避免因缺少UnityEditor命名空间而导致的构建错误。3.4 第四步编写实际的包代码现在在对应的Scripts文件夹下添加你的C#脚本。Runtime脚本示例 (Runtime/Scripts/ProjectConfig.cs):using UnityEngine; namespace YourCompany.ProjectInitializer { /// summary /// 定义项目标准的文件夹结构 /// /summary public static class ProjectFolders { public const string Art Art; public const string Art_Textures Art/Textures; public const string Art_Models Art/Models; public const string Art_Materials Art/Materials; public const string Audio Audio; public const string Scripts Scripts; public const string Prefabs Prefabs; public const string Scenes Scenes; // ... 更多定义 } /// summary /// 项目初始化配置数据可序列化用于存储 /// /summary [System.Serializable] public class ProjectInitializerConfig { public bool createArtFolder true; public bool createScriptsFolder true; // ... 其他配置项 } }Editor脚本示例 (Editor/Scripts/ProjectInitializerMenu.cs):using UnityEditor; using UnityEngine; using YourCompany.ProjectInitializer; // 引用Runtime命名空间 namespace YourCompany.ProjectInitializer.Editor { public static class ProjectInitializerMenu { [MenuItem(Tools/YourCompany/Initialize Project Folders)] public static void CreateStandardFolders() { // 使用Runtime中定义的常量 CreateFolderIfNotExists(ProjectFolders.Art); CreateFolderIfNotExists(ProjectFolders.Art_Textures); CreateFolderIfNotExists(ProjectFolders.Scripts); CreateFolderIfNotExists(ProjectFolders.Prefabs); // ... 创建其他文件夹 AssetDatabase.Refresh(); // 刷新资源数据库让新文件夹立即显示 Debug.Log([ProjectInitializer] Standard project folders created successfully.); } private static void CreateFolderIfNotExists(string path) { if (!AssetDatabase.IsValidFolder(Assets/ path)) { string parentFolder Assets; string[] folders path.Split(/); foreach (var folder in folders) { string newFolderPath parentFolder / folder; if (!AssetDatabase.IsValidFolder(newFolderPath)) { AssetDatabase.CreateFolder(parentFolder, folder); } parentFolder newFolderPath; } } } [MenuItem(Tools/YourCompany/Project Initializer Settings)] public static void OpenSettingsWindow() { // 可以在这里打开一个自定义的EditorWindow用于配置ProjectInitializerConfig // EditorWindow.GetWindowProjectInitializerSettingsWindow(Init Settings); } } }3.5 第五步本地测试你的包包代码写好了怎么在Unity项目里测试呢有两种主流方法方法一本地路径引用推荐用于开发阶段在你的目标Unity项目的Packages文件夹内找到manifest.json文件。在dependencies区块内添加一行本地路径引用{ dependencies: { com.unity.collab-proxy: 2.0.5, // ... 其他包 com.yourcompany.project-initializer: file:D:/Dev/UnityPackages/com.yourcompany.project-initializer } }注意格式file:[你的包的绝对或相对路径]。使用绝对路径更可靠。保存manifest.jsonUnity会自动识别并加载这个本地包。你可以在Package Manager窗口的“Packages: My Registries”或“Packages: In Project”中找到它。方法二符号链接Symlink更高级的做法是在Unity项目的Packages文件夹内为你开发的包创建一个符号链接Windows叫“目录联接”。这样你在包目录的任何修改都会实时反映到项目中无需修改manifest.json。Windows (CMD管理员模式):mklink /J C:\YourUnityProject\Packages\com.yourcompany.project-initializer D:\Dev\UnityPackages\com.yourcompany.project-initializermacOS/Linux (终端):ln -s /path/to/your/package/root /path/to/unity/project/Packages/com.yourcompany.project-initializer测试要点检查菜单项Tools/YourCompany/Initialize Project Folders是否出现点击后是否能正确创建文件夹。尝试构建项目Build确认编辑器代码在Editor程序集中没有导致构建错误。检查Console确保没有关于程序集引用或编译的错误。4. 利用Git仓库实现团队共享与版本管理本地包测试无误后下一步就是把它推送到Git仓库供团队使用。4.1 初始化Git仓库并设置.gitignore在你的包根目录com.yourcompany.project-initializer打开终端或Git Bash初始化仓库git init git add . git commit -m Initial commit: v1.0.0 - Basic project folder initialization tool关联远程仓库git remote add origin https://github.com/yourcompany/com.yourcompany.project-initializer.git创建.gitignore文件在包根目录创建.gitignore排除不需要版本控制的文件。一个针对Unity包的典型.gitignore如下# 忽略Unity生成的文件 [Ll]ibrary/ [Tt]emp/ [Oo]bj/ [Bb]uild/ [Bb]uilds/ [Ll]ogs/ [Uu]ser[Ss]ettings/ # 忽略操作系统文件 .DS_Store Thumbs.db # 忽略IDE文件 (按需) .vs/ .vscode/ *.swp *.swo # 忽略包开发中可能产生的中间文件 *.csproj *.sln *.pidb *.suo *.userprefs重要确保你的.gitignore没有忽略package.json、.asmdef、Runtime/、Editor/、Samples~/等核心目录和文件。4.2 版本控制策略与分支管理对于团队共享的包一个清晰的版本控制策略至关重要。主分支main/master存放稳定、可发布的版本。每个提交对应一个明确的包版本在package.json中定义。开发分支develop日常开发集成分支。功能完成并测试后合并到main并打标签。功能分支feature/*开发新功能时从develop拉取的分支。Git标签Tags对应包的正式发布版本。每次在main分支上发布新版本时务必打上标签。git tag -a v1.0.0 -m Release version 1.0.0 git push origin v1.0.0版本号更新流程在develop分支开发新功能。功能完成准备发布v1.1.0。将develop分支合并到main分支。在main分支上更新package.json中的version字段为1.1.0。提交这次版本更新git commit -am Bump version to 1.1.0。打上标签git tag -a v1.1.0 -m Release version 1.1.0。推送main分支和标签git push origin main --tags。4.3 在团队项目中引用Git包现在团队其他成员可以在他们的Unity项目中引用这个包了。修改项目的Packages/manifest.json{ dependencies: { com.unity.collab-proxy: 2.0.5, // ... 其他官方包 com.yourcompany.project-initializer: https://github.com/yourcompany/com.yourcompany.project-initializer.git#v1.0.0 } }引用特定版本推荐使用#v1.0.0标签。这能确保所有团队成员使用完全相同的代码版本避免因使用不同分支如main的HEAD提交而导致的差异。引用分支#develop。仅用于测试尚未发布的最新特性不适合正式项目。引用提交哈希#a1b2c3d。最精确但可读性差一般用于锁定某个临时修复。当团队成员拉取包含此manifest.json的项目后Unity的Package Manager会自动从Git仓库拉取指定版本/分支的包并解析其依赖。整个过程对使用者来说是透明的。5. 高级主题与最佳实践5.1 处理第三方DLL依赖如果你的包需要引用第三方编译好的DLL例如Newtonsoft.Json.dll你需要将DLL文件放在包内一个合适的文件夹例如Runtime/Plugins/。在放置DLL的文件夹下可能需要创建Plugins元文件如果DLL是平台特定的或者通过.asmdef的precompiledReferences来引用。更规范的做法是如果该第三方库有对应的Unity官方包如com.unity.nuget.newtonsoft-json应在package.json的dependencies中声明让包管理器去处理。5.2 编写测试与持续集成为你的包编写单元测试是保证质量的好习惯。使用Unity Test Framework将测试代码放在Tests/EditMode或Tests/PlayMode下并为其创建专门的.asmdef文件如com.yourcompany.project-initializer.Tests.asmdef并引用你的Runtime和Editor程序集。你可以在Git仓库中配置CI/CD如GitHub Actions, GitLab CI在每次推送代码时自动运行测试确保新提交不会破坏现有功能。5.3 包的发布与私有Registry对于更大型的团队或公司使用Git直连可能会遇到权限管理、访问速度等问题。此时可以考虑搭建私有的Unity包Registry注册表。工具可以使用Verdaccio或Unity官方推荐的upm工具配合Git服务器来搭建。流程将打包好的.tgz文件可以通过npm pack命令在包根目录生成发布到私有Registry。优势更快的下载速度、更好的权限控制、支持语义化版本查询^1.0.0。5.4 常见问题与排查技巧问题1Unity找不到我的本地包通过file:引用。检查manifest.json中的路径是否正确。使用绝对路径并注意Windows的路径分隔符是\但在JSON中需要转义为\\或者使用/。检查包根目录是否有正确的package.json文件。问题2编辑器菜单项不显示。检查包含菜单项代码的脚本是否放在Editor文件夹下其所在的程序集.asmdef的includePlatforms是否包含Editor检查脚本编译是否有错误查看Console。问题3构建Build时出错提示找不到UnityEditor命名空间。原因编辑器代码被打包进了运行时构建。解决确保所有编辑器脚本都在Editor文件夹内并且该文件夹下的.asmdef文件正确设置了includePlatforms: [Editor]。问题4更新Git包版本后项目中的包没有更新。解决在Unity编辑器中打开Package Manager窗口找到你的包点击右侧的“Update”按钮或下拉菜单选择特定版本。有时需要手动修改manifest.json中的版本号或提交哈希。问题5包依赖了另一个Git包导致解析失败。情况你的com.yourcompany.package-a在package.json中依赖了com.yourcompany.package-b。解决确保package-b也以同样的方式Git URL在项目的manifest.json中被声明或者被发布到了同一个私有Registry。Unity的包管理器需要能解析整个依赖树。