☰
Unity编译慢怎么优化?.asmdef程序集划分与依赖治理
2026/10/23 1:22:15 网站建设 项目流程

改一个按钮的点击回调,Unity 编辑器转了两圈才开始播放,任务管理器里 CPU 也没跑满,就是干等。很多人第一反应是机器不行、换 SSD、加内存,其实八成是程序集划分的问题——所有脚本躺在Assembly-CSharp里,改一个文件等于让整个工程重新编一遍。这篇聊的就是.asmdef(Assembly Definition,程序集定义)怎么用、按什么顺序拆、拆完为什么还是慢,以及那些官方文档里不会写的坑。不管你是刚接手一个几百脚本的小项目,还是在维护一个编译一次要一分多钟的中大型工程,下面这些内容都能直接拿去对照操作。我不会只给你一份字段说明,而是把"为什么这么拆""拆完怎么验证""报错了怎么顺藤摸瓜"这几件事讲透。

1. 默认程序集那笔隐形账单:一次改动到底触发了多少重编译

1.1 Assembly-CSharp 的编译模型,先把它看清楚

Unity 有一套"预定义程序集"(predefined assemblies)机制,凡是没被.asmdef覆盖到的脚本,都会被自动归到几个固定的程序集里。Assets根目录下和任意普通文件夹里的运行时脚本进入Assembly-CSharp,Editor文件夹下的脚本进入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这类目录,给每个独立的第三方库建一个.asmdef,autoReferenced设为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: true,Assembly-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 的引用列表里排序整洁,一眼能看出哪些是自家的、哪些是第三方的。

定期跑一次依赖扫描脚本。三个月不看,引用列表一定会长出几条不该有的线。写个几十行的小工具,每周跑一次,比事后重构便宜得多。

我个人在实际操作中的体会是,程序集拆分这件事最难的从来不是技术,是下决心给代码画边界。技术上无非是建文件、填引用、改报错,一两天就能摸熟;真正花时间的是想清楚"这个类到底属于哪一层""这两个模块凭什么要互相引用"。而一旦边界画清楚了,编译速度提升只是顺带的红利,代码本身会变得更难写坏——因为你想写坏都没有那个权限了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询