
改一个按钮的点击回调Unity 编辑器转了两圈才开始播放任务管理器里 CPU 也没跑满就是干等。很多人第一反应是机器不行、换 SSD、加内存其实八成是程序集划分的问题——所有脚本躺在Assembly-CSharp里改一个文件等于让整个工程重新编一遍。这篇聊的就是.asmdefAssembly Definition程序集定义怎么用、按什么顺序拆、拆完为什么还是慢以及那些官方文档里不会写的坑。不管你是刚接手一个几百脚本的小项目还是在维护一个编译一次要一分多钟的中大型工程下面这些内容都能直接拿去对照操作。我不会只给你一份字段说明而是把为什么这么拆拆完怎么验证报错了怎么顺藤摸瓜这几件事讲透。1. 默认程序集那笔隐形账单一次改动到底触发了多少重编译1.1 Assembly-CSharp 的编译模型先把它看清楚Unity 有一套预定义程序集predefined assemblies机制凡是没被.asmdef覆盖到的脚本都会被自动归到几个固定的程序集里。Assets根目录下和任意普通文件夹里的运行时脚本进入Assembly-CSharpEditor文件夹下的脚本进入Assembly-CSharp-Editor而Plugins、Standard Assets这类特殊目录下的脚本又是另一套。这套机制的好处是零配置、开箱即用坏处是它是一个不可分割的整体。关键在于一个程序集在编译产物层面就是一个 DLL。Assembly-CSharp里放 1 个脚本和放 800 个脚本对 Unity 来说都是一个编译单元。你动了其中任何一个文件这个编译单元就被标记为脏整个单元重新编译。这跟文件数量、跟改动大小都没关系改一个空格和重写一个类成本是一样的。更麻烦的是传播效应。Unity 在编译前会先构建一张程序集依赖图按拓扑序依次编译。Assembly-CSharp-Editor默认引用Assembly-CSharp测试程序集引用业务程序集你的自定义程序集又引用别的自定义程序集。一旦上游的Assembly-CSharp变脏所有传递依赖它的下游程序集全部要重编。这就是为什么有些工程改一行代码能等二三十秒——真正被重编的可能不止一个程序集。1.2 一条改动引发的连锁反应用模拟项目算一遍拿一个虚拟的中型项目举例就叫它模拟项目X。它有 620 个运行时脚本、90 个 Editor 脚本、30 个测试脚本全部使用默认程序集。它的编译链路是这样的阶段涉及程序集脚本数量示意耗时编译运行时Assembly-CSharp62011.5 s编译编辑器扩展Assembly-CSharp-Editor902.8 s编译测试Assembly-CSharp-Test300.9 s域重载与资源刷新--3.5 s合计3 个程序集740约 18.7 s表里的数字是本地机器上的示意量级不同机器、不同 Unity 版本差异很大重点看比例关系别拿绝对值当基准。注意最后一行前面那个域重载。这一步很关键后面会专门讲因为它和程序集是两码事。此刻你只需要记住改任意一个运行时脚本11.5 秒的运行时编译和 2.8 秒的编辑器编译都跑不掉因为后者依赖前者。那如果拆成 6 个程序集呢假设把业务拆成Core、Data、Gameplay、UI四层加上Editor和Tests。改一个 UI 层的脚本只有UI变脏Editor和Tests因为引用了UI也会跟着重编但Core、Data、Gameplay完全不受影响。如果UI里只有 80 个脚本重编的脚本量从 740 降到 170 左右编译时间大致能压到原来的四分之一到三分之一。收益不是来自编译变快了而是来自被牵连的东西变少了。这个认知很重要它直接决定了你该怎么切分——切得对不对全看改动会不会扩散。1.3 哪些工程最该先动手哪些其实没必要不是所有项目都值得拆。我见过一个只有 40 个脚本的原型硬拆了 5 个程序集结果每次新建脚本都要想这个该放哪个文件夹、要不要加引用维护成本远超那 1 秒的编译收益。判断标准很简单看三个信号单次编译超过 8 到 10 秒且脚本总数超过 300 个拆分的收益开始明显。团队人数超过 2 人多人同时改不同模块程序集能把冲突面收窄。有明显可分离的边界比如第三方 SDK 封装、纯逻辑层不依赖UnityEngine、编辑器工具链。反过来如果脚本总量小、模块之间高度耦合、或者项目已经进入收尾阶段只改数值那真没必要折腾。拆程序集本质上是在给代码画边界边界画不清楚拆出来的程序集只会变成互相引用的蜘蛛网编译时间不降反升——因为程序集数量多了依赖图的构建和序列化本身也要花时间。2..asmdef文件里每个字段都在管什么2.1 一个最小可用文件的字段全貌.asmdef就是个 JSON 文本文件扩展名特殊用编辑器打开就是普通文本可以直接手写也可以在 Inspector 里点。一个典型的运行时程序集长这样{ name: Game.Core, rootNamespace: Game.Core, references: [ GUID:8f3c1c0e5a6d4b1e9c2f7a3d0b5e6c1a ], includePlatforms: [], excludePlatforms: [], allowUnsafeCode: false, overrideReferences: false, precompiledReferences: [], autoReferenced: false, defineConstraints: [], versionDefines: [], noEngineReferences: false }几个字段的职责我拆开说。name是程序集名全工程唯一不能和已有的 DLL 重名改名等于换程序集所有引用它的地方都会断。rootNamespace只是新建脚本时的默认命名空间提示不影响编译别指望它能强制约束。references是引用列表可以写程序集名也可以写GUID:前缀的字符串。includePlatforms和excludePlatforms是平台白名单和黑名单二者互斥使用编辑器专用程序集就靠includePlatforms: [Editor]实现。autoReferenced决定预定义程序集也就是Assembly-CSharp能不能自动看到它这是拆分期最容易踩的坑。defineConstraints是符号条件versionDefines是包版本条件。noEngineReferences打开后这个程序集不再引用UnityEngine纯 C# 项目的标志。allowUnsafeCode和overrideReferences用得少但要认识。前者是unsafe指针开关后者配合precompiledReferences使用——默认所有程序集都能看到Plugins下所有预编译 DLL打开overrideReferences后你必须在precompiledReferences里手动勾选需要的那些。这个字段在插件多的项目里价值很大能砍掉大量无关 DLL 的引用顺带减小构建产物。2.2 引用用名称还是 GUID这是个团队协作问题Inspector 里有个 Use GUIDs 勾选框。勾上之后引用写成GUID:xxxx取消勾选就写成程序集名字符串。两者的取舍很实际维度名称引用GUID 引用可读性高一眼知道引用谁低得去查程序集改名全部断裂需要手改自动跟随不受影响版本控制冲突文本冲突少GUID 冲突难排查文件迁移/合并相对安全容易产生无效引用小团队、程序集数量在 10 个以内用名称更舒服出问题好定位。程序集数量上去了、又经常有人重命名模块GUID 更稳。有个中间做法GUID 引用但同时在rootNamespace里写清楚靠命名空间提示来弥补可读性。不管选哪种.asmdef文件和它的.meta文件都必须进版本控制.meta丢了会导致 GUID 重新生成所有 GUID 引用瞬间全部失效这是最容易造成我本地好好的同事拉下来全是红的原因之一。2.3asmref不想大动干戈时的补丁方案有一种情况很常见某个第三方插件目录下有一堆脚本你想给它们加个引用但不能把整个目录变成程序集——因为插件自己的代码可能分散在多个目录。或者你正在做渐进式迁移只想把某个子文件夹里的几个脚本并入已有程序集。这时用 Assembly Definition Reference也就是.asmref文件。它的内容极简{ reference: Game.Core }把它丢进目标文件夹该文件夹下的所有脚本就被并进Game.Core而不是新建一个程序集。这个机制的价值在于它不改变编译单元的数量只改变归属。适合给插件打补丁、适合把散落在各处的 Editor 脚本归拢到编辑器程序集也适合在拆分的过渡期做局部试验。但别滥用。.asmref会让代码的实际归属和目录表象不一致新人接手时容易懵。我一直坚持的原则是.asmref只用于两种场景——第三方资源包和迁移中间态长期结构一律用.asmdef老老实实建目录。3. 拆分粒度怎么定先画依赖图再动手3.1 依赖方向必须是单向的有环就拆不动拆程序集前的第一件事不是建文件夹是在纸上画依赖方向。Unity 的程序集不允许循环引用A 引用 B、B 又引用 A编译直接报错没有警告但能跑这种中间态。所以你必须先把模块间的依赖关系理成一张有向无环图。我一般用三层来起步基础层工具类、扩展方法、事件总线、数据结构、领域层业务模型、状态机、配置解析、表现层MonoBehaviour、UI、特效、输入。依赖方向只能是表现层 → 领域层 → 基础层反过来的任何一条线都是设计问题要么用接口加事件反转要么把公共部分下沉。这里有个反直觉但极其重要的点程序集拆分的收益取决于被依赖的次数而不是代码的量。如果一个Core程序集被 20 个程序集引用那你改Core一行代码20 个下游照样全量重编拆分等于白做。所以真正的原则是变化频率越高的模块越应该放在依赖图的叶子位置被越少程序集引用越稳定的模块越往下沉。换句话说那些天天改的业务逻辑就要单独成程序集而且尽量别被别人引用。反过来那些一年不动一次的工具库就算被所有人引用也无所谓。这个原则想明白了拆分方案基本就成型了。3.2 一次可以照着做的拆分顺序从零开始拆我推荐的顺序是由外向内先把边界最清晰的切出去风险最低第一步切第三方和插件。找到Plugins、ThirdParty、SDK这类目录给每个独立的第三方库建一个.asmdefautoReferenced设为false然后在references里只填它们自己内部需要的。目的是让业务代码显式声明我用了哪个 SDK而不是默认全都能看到。顺手打开overrideReferences把无关的预编译 DLL 摘掉。第二步切 Editor。所有Editor目录统一成一个或多个编辑器程序集includePlatforms: [Editor]。这一步最容易做也最安全而且能立刻防住一类严重问题运行时脚本误用了UnityEditor命名空间打包时才炸。有了独立程序集运行时程序集根本引用不到编辑器程序集写错当场编译失败。第三步切测试。测试程序集引用UnityEngine.TestRunner和UnityEditor.TestRunner并勾选 Test Assemblies 选项新版本在 Inspector 里有专门的复选框会自动帮你补上nunit.framework.dll的预编译引用。或者用defineConstraints: [UNITY_INCLUDE_TESTS]保证非测试构建里这个程序集不会被编进去。第四步切纯逻辑层。挑出那些不依赖UnityEngine的类——数值计算、状态机、序列化、配置解析——集中放一个目录建程序集并打开noEngineReferences。这一步收益很大编译更快不用处理引擎引用能在纯 .NET 环境跑单测还从物理上阻止了逻辑层偷偷调用Debug.Log或MonoBehaviour。第五步切业务层。按功能域切比如战斗、背包、任务、UI 各一个。这一步最需要判断力切太细会导致程序集数量爆炸切太粗又回到原点。第六步处理autoReferenced。前面几步都在新建程序集默认autoReferenced: trueAssembly-CSharp还能看到它们。等你把所有脚本都迁进程序集、Assembly-CSharp里什么都不剩了再统一改成false。顺序反了会立刻引发大面积找不到类型的报错。3.3 拆完怎么验证别靠感觉拆完程序集别急着说快了。我一般看三个东西第一个是编译耗时。Unity 2019 之后用了 Bee 增量构建后端编译时间可以在编辑器日志里找到也可以打开 Preferences 里的诊断选项让编辑器在控制台打印每步耗时。简单点的办法是掐秒表改一个叶子程序集里的脚本看从保存到转圈结束几秒。第二个是依赖图。Unity 没有内置的可视化但你可以写个几十行的编辑器脚本扫描AssetDatabase.FindAssets(t:AssemblyDefinitionAsset)读每个文件的references字段输出成文本形式的邻接表。跑一次就能看出有没有意外的跨层引用——比如表现层直接引用了数据层或者两个业务模块互相引用。第三个是构建产物。打个包看Library/ScriptAssemblies或构建输出里的 DLL 列表确认程序集数量和你的设计一致没有多出来的、也没有漏掉的。同时注意构建日志里的代码剥离信息程序集是剥离器的工作单位之一切分合理的话剥离效果会更好包体会小一点。4. 报错排查链路那些让人怀疑人生的坑4.1 找不到命名空间的四种真相The type or namespace name XXX could not be found是拆程序集后出现频率最高的报错但它至少有四种完全不同的成因得按顺序排查第一种真的忘了加引用。打开源程序集的 Inspector看references里有没有目标程序集。注意引用是单向的A 引用 B 不代表 B 能看到 A。第二种目标程序集autoReferenced是false而你的脚本还在Assembly-CSharp里。这是最隐蔽的一种因为程序集明明存在、引用关系也看似正常但Assembly-CSharp作为一个预定义程序集只能自动看到autoReferenced: true的程序集。解决办法要么给Assembly-CSharp建一个.asmdef把它也纳入体系要么把脚本迁进有明确引用的程序集里。第三种引用了一个编辑器程序集。运行时程序集去引用includePlatforms: [Editor]的程序集编辑器下可能勉强能编打包必挂。反过来编辑器程序集引用运行时程序集是正当的。第四种程序集编译本身失败了。上游程序集有语法错误或引用缺失Unity 会跳过它的产物生成下游自然找不到类型。这时候控制台的第一条红色报错才是真正的元凶后面几十条都是连锁反应。养成习惯出问题永远只修第一条。4.2 循环引用和预编译 DLL 冲突两种硬伤循环引用没有优雅解法只能重构。常见手法是把双方都需要的类型抽到一个第三方的契约程序集里双方各自引用它。如果实在抽不出来比如 A 的类需要 B 的类型做参数B 的方法需要 A 的类型做参数说明模块边界本身画错了这时候应该考虑合并这两个程序集——合并不是失败硬拆才是。预编译 DLL 的冲突更阴间报错长这样Multiple precompiled assemblies with the same name XXX.dll present。原因通常是同一个 DLL 被放进了两个不同目录或者你给某个插件建了.asmdef又在precompiledReferences里手动引了一份而它本来就已经被自动引用了。排查办法是打开overrideReferences后对照勾选列表把重复的摘掉。还有一个变体两个不同的插件都打包了同一个开源库的不同版本这种情况只能删掉一份或者统一版本没别的办法。4.3internal的可见性边界和序列化丢数据internal修饰符的作用域是程序集不是命名空间。拆程序集之前同一个命名空间下所有类互相internal可见拆完之后跨程序集直接不可见一堆代码报红。这时候有两个选择改成public或者在目标程序集的AssemblyInfo.cs里加[assembly: InternalsVisibleTo(另一个程序集名)]。后者的好处是保持封装代价是引入了隐式耦合只建议给测试程序集开这个口子。还有个更疼的坑类型迁移导致序列化数据丢失。Unity 的序列化是按脚本 GUID 类型全名来定位的。你把一个MonoBehaviour从一个程序集挪到另一个程序集如果你的rootNamespace或者命名空间跟着变了类型全名就变了场景和 Prefab 里挂着的组件会变成Missing (Mono Script)。程序集拆分本身不会丢数据改命名空间才会。所以拆分时的铁律是迁移脚本只改文件夹和.asmdef归属绝对不动命名空间。要动命名空间就单独做一次迁移做完立刻全量检查场景和 Prefab别和程序集拆分混在一个提交里。这个教训我是真踩过一个下午的工作量全花在重新挂组件上。4.4 域重载拆完之后为什么还是慢这是最需要讲清楚的一件事。很多人拆完程序集编译时间从 18 秒降到 5 秒然后发现进播放模式还是要等十几秒——因为瓶颈已经从编译转移到域重载Domain Reload了。域重载是 Unity 在脚本编译完成后重新创建整个 C# 域的机制。所有的静态变量、单例、事件订阅都会被清空重建耗时跟程序集划分几乎无关跟工程的资源数量和脚本数量有关。程序集只能优化编译管不了重载。想进一步提速要用 Enter Play Mode Options在 Project Settings 的 Editor 页里打开取消勾选 Reload Domain 和 Reload Scene。这样进播放模式几乎是瞬间的。但代价很明确——静态字段不会被重置上一轮运行留下的状态会带到下一轮各种诡异的第一次运行正常、第二次运行出错都从这来。配套的解决方案是给所有需要初始化的静态状态加[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]在里面手动清零。我的经验是这两件事要分阶段做先拆程序集拿到确定性的编译收益团队适应了再考虑关域重载否则两个变量一起动出了玄学问题根本没法定位。5. 程序集作为架构契约比编译速度更值钱的部分5.1 用引用关系把分层焊死编译速度是短期收益架构清晰度才是长期价值。程序集最狠的地方在于它把约定变成了编译器强制。你可以写一百页文档说表现层不许直接访问数据库层但只要有人图省事using一下就能绕过而程序集层面没有引用就是编不过没得商量。我通常会给项目定一条依赖规则表贴在 README 里程序集允许引用禁止引用目的Game.Core无noEngineReferences一切业务程序集保证纯逻辑可测Game.DataCoreUI、Gameplay数据层不感知表现Game.GameplayCore、DataUI、Editor玩法不依赖界面Game.UICore、Data、GameplayEditor界面单向依赖Game.Editor全部运行时程序集-工具链可读全工程Game.Tests全部运行时程序集Game.Editor测试独立于工具这张表看着朴素实际执行起来能挡掉大量临时引用一下的腐化。配合代码评审只要.asmdef的引用列表发生变更就必须说明理由整个架构的熵增速度会明显下降。顺带说一句noEngineReferences。给核心逻辑层打开这个开关收益是双份的一是从物理上阻止了有人在逻辑层里偷偷new GameObject()二是这部分代码可以脱离 Unity 跑单元测试CI 上几秒钟跑完不用起编辑器。代价是你得自己造轮子——日志、时间、随机数都得抽象成接口由外层注入这确实是额外工作量但换来的是可测试性值。5.2 和 UPM 包、命名空间、目录结构的关系程序集、命名空间、目录结构这三者是三套独立的系统Unity 不会帮你对齐得自己定规矩。我建议的做法是三者同名同构目录Assets/Scripts/Core/对应程序集Game.Core对应根命名空间Game.Core。这样任何人看到一个类的using就知道它来自哪个目录、哪个程序集心智负担最低。UPM 包Packages目录下或本地file:引用的包本质也是程序集包里的.asmdef由包作者定义你不能改。这时候如果包的划分不合你的意可以用.asmref把包里的脚本并入你的程序集或者干脆包一层适配器程序集把它隔离起来。永远不要在业务代码里直接引用第三方包的程序集名包一层适配器将来换库的时候只改一个地方。编辑器工具链也值得单独说。所有编辑器扩展放独立的Game.Editor程序集好处是打包时它完全不参与编译——includePlatforms: [Editor]的程序集在构建产物里根本不存在。这个隔离做得越早越好晚了你会发现业务代码里到处都是对编辑器 API 的隐式依赖。5.3 长期维护的几条经验条目最后分享几条我实际维护下来觉得最值钱的经验都是踩过之后总结的程序集数量控制在 10 到 25 个之间。低于 10 个收益不明显高于 25 个依赖图维护成本陡增而且编译调度本身也有开销。这个区间是大多数中型项目的甜点。每次只拆一层拆完立刻提交。一次性重构整个工程然后统一验证是灾难的开始。哪怕多提交几次也要保证每一步都能回滚。.asmdef的每次改动都当作架构变更对待。它不是配置文件是模块契约。加一条引用就是在引入一个依赖要有意识。给程序集起名加统一前缀。比如全部Game.开头Inspector 的引用列表里排序整洁一眼能看出哪些是自家的、哪些是第三方的。定期跑一次依赖扫描脚本。三个月不看引用列表一定会长出几条不该有的线。写个几十行的小工具每周跑一次比事后重构便宜得多。我个人在实际操作中的体会是程序集拆分这件事最难的从来不是技术是下决心给代码画边界。技术上无非是建文件、填引用、改报错一两天就能摸熟真正花时间的是想清楚这个类到底属于哪一层这两个模块凭什么要互相引用。而一旦边界画清楚了编译速度提升只是顺带的红利代码本身会变得更难写坏——因为你想写坏都没有那个权限了。