ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity程序集与.asmdef拆分实战:增量编译优化与模块化架构指南

Unity程序集与.asmdef拆分实战:增量编译优化与模块化架构指南

做了几年 Unity,项目体量一上来,改一行 UI 文案要等几十秒编译、点一次 Play 要转半分钟圈,这种滋味估计不少人都尝过。很多人第一反应是换机器、清 Library、关杀毒软件,折腾一圈发现改善有限,真正的问题往往出在编译单元的划分上。Unity 程序集和配套的 .asmdef 文件,就是用来解决这件事的:它把原本一锅粥的脚本切成若干独立编译单元,让增量编译只重编受影响的那一小块,同时用引用关系把模块边界钉死,架构清晰度顺带就上来了。这篇内容适合两类人看——一类是被编译速度折磨、想动手改但怕把项目搞崩的开发者,另一类是团队规模上来之后发现谁都敢引用谁、代码越写越乱的负责人。我会从机制讲到字段、从目录规划讲到改造流程和踩坑实录,尽量把每一步的“为什么”讲透,让新手能照着做,老手能挑着看。

1. 为什么中型以上项目躲不开程序集这道坎

1.1 Unity 默认的编译单元是怎么划分的

在没有 .asmdef 的情况下,Unity 对脚本的划分非常粗暴,基本只认几个固定位置。Assets/Plugins、Assets/Standard Assets这类目录下的脚本进Assembly-CSharp-firstpass;项目里其余所有运行时脚本,不管属于战斗、UI、存档还是网络,统统塞进Assembly-CSharp;放在任意Editor文件夹下的脚本进Assembly-CSharp-Editor,Plugins 下的编辑器脚本则进Assembly-CSharp-Editor-firstpass。

编译顺序也是固定的:先 firstpass,再普通程序集,最后编辑器程序集,因为编辑器脚本通常要引用运行时类型。这套设计对小型项目极其友好,零配置、开箱即用。问题在于它是“全有或全无”的:Assembly-CSharp里的任何一个 .cs 文件改动,整个程序集连同依赖它的编辑器程序集都要重新编译一遍。一个两三百个脚本的 Demo 感觉不出来,等到脚本数量上了四位数,单次编译时间就会线性膨胀到几十秒甚至更久,而增量编译的收益几乎为零——因为你改的任何一个文件都落在同一个编译单元里。

这里有个概念必须先厘清:命名空间不等于程序集。很多人以为把代码放进不同的 namespace 就算模块隔离了,其实 namespace 只是 C# 语言层面的名字容器,编译时还是同一个 dll,改哪个文件都要整包重编,也完全拦不住 A 模块直接 new 出 B 模块的内部类。程序集才是编译和引用的真实边界,是 Unity 里唯一能强制“你不引用我,就用不了我”的机制。

1.2 单一大程序集带来的三个隐性成本

第一个成本是时间。全量编译慢是显性的,更烦的是它把开发节奏切碎了。改一行逻辑、切回编辑器、等编译、等域重载、点 Play、跑两秒发现写错了、再来一遍。单次浪费三十秒,一天重复五十次就是二十五分钟,而且这二十五分钟是打断状态的,实际损失远大于账面数字。

第二个成本是耦合。Assembly-CSharp里所有类型默认互相可见,写代码时 IDE 自动补全一拉,什么内部类都能摸到。短期看是方便,长期看是灾难:UI 脚本直接读战斗模块的私有状态,工具类反向引用业务单例,几个月后你想抽一个模块出来做复用,发现它像蜘蛛网一样牵着半个项目。这种耦合不是某个人写坏的,是编译单元没有边界导致的必然结果——没有物理隔离,纪律就只能靠人盯,而人盯是不可靠的。

第三个成本是团队协作冲突。所有人往同一个程序集里加文件,谁改了什么在编译层面完全不可见,代码评审时也看不出依赖是否合理。等到项目后期想做“按功能裁剪包体”或者“把某模块做成独立包”,才发现根本拆不动,因为没人知道真实的依赖图长什么样。

1.3 拆程序集到底解决了什么,又没解决什么

先把预期摆正。拆 .asmdef不会让首次全量编译变快,反而可能略慢一点点,因为每个程序集都有固定的元数据处理、序列化信息生成和引用解析开销,十个程序集的总开销一定大于一个。它真正带来的收益在增量编译:Unity 的编译管线会根据程序集依赖图计算影响范围,你改了 A 程序集里的一个脚本,只需要重编 A,以及所有直接或间接引用 A 的下游程序集;跟 A 无关的 B、C、D 一行都不用动。

由此推出整个拆分策略的核心原则:越稳定、越底层的代码越往下放,越易变、越上层的代码越往上放。因为改动底层程序集的代价是整条依赖链重编,改动末端程序集的代价只有它自己。把每天都改的 UI 逻辑放在依赖链最末端,把一年不动几次的数学库、事件总线、存档格式放在最底层,编译时间的分布才会向你希望的方向倾斜。

它没解决的问题也得说清楚:拆程序集对运行时性能没有直接影响,程序集是编译期概念,不会让代码跑得更快;它也不能自动帮你解耦,如果两个模块本来就互相调用,硬拆只会得到一个循环依赖报错,该重构的还得重构。它更像一副骨架,把边界撑起来,肉还得自己长。

2. .asmdef 文件的字段逐个拆解

2.1 必填与高频字段

.asmdef 本质就是个 JSON 文件,Unity 在 Inspector 里提供的面板只是它的可视化外壳。想真正掌控它,最好直接看文本。一个最小可用的文件长这样:

{ "name": "Game.Core", "rootNamespace": "Game.Core", "references": [], "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], "noEngineReferences": false }

name是程序集名,也是最终生成的 dll 名字,改它等于改程序集身份,所有引用它的地方都要跟着更新,所以起名要一次到位。我的习惯是统一前缀加模块名,比如Game.Core、Game.Battle、Game.UI,前缀让同一项目的程序集在列表里能挨在一起。rootNamespace只影响新建脚本时自动填充的命名空间,对编译没作用,但填上能省掉大量手写 using 的功夫,建议和 name 保持一致。

references是引用列表,也是整个文件里最核心的字段,程序集之间能不能互相看见全靠它。autoReferenced决定这个程序集是否被预定义的Assembly-CSharp自动引用,默认是 true;如果你希望某个程序集只能被显式引用、防止老代码偷偷用,就把它关掉。allowUnsafeCode打开后可以用指针和非托管内存,只有确实需要底层操作的程序集才勾,范围越小越安全。

2.2 引用机制:GUID 还是名字

references 里的条目有两种存储形式。默认是 GUID 形式,类似"GUID:8f3a2c1e9b7d4a5c8e6f0a1b2c3d4e5f",指向的是被引用 asmdef 文件自身的 GUID;另一种是纯名字字符串"Game.Core"。Unity 在 Inspector 里有个 “Use GUIDs” 勾选项来控制。

选哪种要看项目阶段。GUID 的好处是程序集改名时引用不会断,因为指向的是文件身份而不是名字,适合已经稳定、重命名概率低的中大型项目;坏处是纯文本阅读时完全看不懂引用了谁,代码评审、diff 对比、手写合并都很难受。名字引用的可读性极佳,一眼能看出依赖方向,但一旦被引用方改名,引用会静默失效变成编译错误。

我的做法是:日常开发用名字引用,方便 review 和排查依赖;等到项目进入收敛期、确定不会再大改结构时,再统一切成 GUID。切换本身只是勾一下选项,Unity 会自动转换,成本很低,但最好在团队里统一约定,别一半人用 GUID 一半人用名字,合并时会一直冲突。

2.3 平台过滤与条件编译

includePlatforms和excludePlatforms是互斥的,只能用一个。前者是白名单,只列出的平台才编译;后者是黑名单,列出的平台跳过。典型场景是平台专属的 SDK 封装:Android 的支付、iOS 的登录,各自建一个程序集,用 includePlatforms 限定,其他平台打包时这些代码根本不会进编译流程,省时间也避免平台 API 在编辑器里报错。

defineConstraints更灵活,它是一个宏列表,只有这些宏全部被定义时该程序集才参与编译。团队里常用它做功能开关,比如给某个实验性玩法单独建程序集,约束FEATURE_EXPERIMENT_A,默认不定义,宏关着的时候这部分代码完全不编译,连语法错误都不会拦你。这个特性很适合做灰度功能,但也要注意:宏没开的时候,引用它的程序集会直接报“类型找不到”,所以这类程序集必须被隔离在依赖链末端,不能让别人硬依赖。

versionDefines是给包版本适配用的,可以按某个包是否安装、版本号落在哪个区间来定义宏。比如某 SDK 的 2.x 和 3.x API 不兼容,就可以用 versionDefines 在不同版本下定义不同宏,代码里用#if分流,一份代码同时兼容两个大版本。这个字段用的人不多,但在维护对外发布的工具包时非常值钱。

2.4 容易被忽略的几个开关

overrideReferences配合precompiledReferences使用,控制的是对预编译 dll(也就是 .dll 文件而非 .cs 源码)的访问。默认情况下,项目里所有被标记为 Auto Reference 的 dll 对所有程序集可见;一旦勾上 overrideReferences,这个程序集就只能看见 precompiledReferences 里明确列出的 dll。这个开关是收敛依赖的好工具,能防止某个底层模块偷偷调用了某个 UI 插件库的 dll。

noEngineReferences勾上之后,该程序集不再引用 UnityEngine,只能写纯 C# 代码。听起来限制很大,实际价值不小:把纯逻辑层(数值计算、状态机、存档序列化、寻路算法)做成不依赖引擎的程序集,一是编译更快,二是可以脱离 Unity 单独跑单元测试,三是将来想复用到其他运行时也不用重写。我一般会强制要求核心逻辑层开启这个开关,等哪天需要做纯逻辑的自动化测试,会庆幸当初的这个决定。

还有一个隐性开关藏在细节里:修改 asmdef 文件本身会触发该程序集的完整重编译加一次域重载,跟改脚本的代价不同。所以不要频繁在 Inspector 里点来点去调引用,配置前先想清楚,批量改完再让 Unity 编一次。

3. 一套可落地的分层架构与目录规划

3.1 依赖方向的确定原则

拆程序集之前必须先画依赖方向,否则拆到一半必然撞上循环引用。最稳妥的做法是照搬经典分层思想:依赖只能单向,从上层指向下层,下层永远不知道上层的存在。判断谁该在下层,有个很实用的标准——看谁更“无知”。一个模块如果对业务一无所知,只提供通用能力(日志、事件、对象池、数学工具),它就该在最底层;一个模块如果必须知道“玩家血量”“关卡配置”这些概念,它就在上层。

具体到游戏项目,我通常按这个顺序排:引擎无关的基础库 → 引擎相关的通用能力(资源加载、音频封装、UI 框架)→ 领域基础(数据定义、配置表结构、存档格式)→ 各业务模块(战斗、任务、商店)→ 表现层与界面 → 编辑器工具 → 测试。上层可以引用下层,下层引用上层一律视为设计错误,必须通过接口下沉或事件解耦来消除。

接口下沉是解决循环依赖最常用的手法:A 需要调 B,B 也需要通知 A,那就把 A 需要的那部分能力抽成接口放到更底层的一个Game.Abstractions程序集里,A 和 B 都引用它,互相之间不引用。这样依赖方向依然是单向的,运行时用注册表或依赖注入把实现塞进去。

3.2 推荐的分层与目录结构

程序集的物理位置建议和目录结构严格对应,一个 asmdef 管一个目录及其所有子目录(子目录里如果再放 asmdef 会切断继承,这点后面会讲)。推荐的骨架大致是这样:

Assets/_Project/ Scripts/ Core/ Game.Core.asmdef (noEngineReferences = true) Abstractions/ Game.Abstractions.asmdef Infrastructure/ Game.Infrastructure.asmdef Domain/ Game.Domain.asmdef Features/ Battle/ Game.Features.Battle.asmdef Quest/ Game.Features.Quest.asmdef Shop/ Game.Features.Shop.asmdef Presentation/ Game.Presentation.asmdef Boot/ Game.Boot.asmdef Editor/ Tools/ Game.Editor.Tools.asmdef Tests/ EditMode/ Game.Tests.EditMode.asmdef PlayMode/ Game.Tests.PlayMode.asmdef ThirdParty/ (第三方源码,各自保留原有 asmdef)

对应到引用关系上:Core 不引用任何人;Abstractions 引用 Core;Infrastructure 引用 Core 和 Abstractions;Domain 引用 Core、Abstractions;Features 下的各模块引用 Domain、Infrastructure、Abstractions;Presentation 引用 Features 和各模块暴露的接口;Boot 作为组装层,引用所有业务模块,负责在启动时把它们串起来。Editor 和 Tests 只被工具链使用,不进入运行时依赖图。

这个结构的关键在于 Boot 层:它允许存在“引用所有人”的组装代码,但只允许这一个程序集这么干。业务模块之间如果要通信,走事件总线或者 Abstractions 里的接口,绝不直接引用对方。这样即便将来某个功能模块要整个摘出去,改动面也被限制在 Boot 的注册代码里。

3.3 程序集粒度怎么定

这是最容易走极端的地方。切得太粗,拆了跟没拆一样;切得太细,引用表维护成本飙升,每次加功能都要改三四个 asmdef,团队很快就会烦。我给的经验区间是:单个程序集控制在几十到一两百个脚本之间,业务模块按功能切,通用层按职责切,别再往下细分。

判断依据是编译收益的边际递减。假设一个程序集有 N 个脚本,改动其中一个需要重编 N 个;如果拆成两个各 N/2 的程序集且互不依赖,改一个只需要重编 N/2 个,收益接近一半。但如果继续拆成四个,收益提升有限,而引用配置、命名管理、域重载次数的成本开始占上风。更重要的是,Unity 每次程序集内容变化都会触发一次域重载,程序集数量多的时候,重载本身的固定开销会吃掉一部分编译时间的节省。

还有个实操细节:一个目录只能有一个 asmdef 直接管辖,子目录要么被它覆盖,要么自己新建 asmdef 切断继承。所以目录规划要在动手前想清楚,别出现“这个文件夹一半归 A 程序集一半归 B”的情况,那种结构后期没人理得顺。

3.4 编辑器、测试、第三方库的特殊处理

编辑器脚本必须单独成程序集,且必须勾选 includePlatforms 为 Editor。这不是优化,是硬性要求:编辑器 API 在打包后的运行时不存在,混在运行时程序集里会直接导致打包失败。编辑器程序集可以引用任意运行时程序集,方向是单向的,运行时绝对不能反过来引用编辑器程序集。

测试程序集有两个必须注意的点。一是要在 references 里加上UnityEngine.TestRunner和UnityEditor.TestRunner,二是要在 defineConstraints 里加上UNITY_INCLUDE_TESTS,否则打包时会因为找不到测试框架类型而报错。测试程序集建议分 EditMode 和 PlayMode 两套,EditMode 只依赖纯逻辑层,跑得快;PlayMode 依赖完整业务链,跑得慢但覆盖真实场景。

第三方库的处理原则是不动它的 asmdef。很多插件自带 asmdef,直接保留,你的代码在 references 里加它就行。没有 asmdef 的第三方源码,如果放在 Assets 下会被吸进Assembly-CSharp,那就把它整个挪到Assets/Plugins或单独的ThirdParty目录,给它补一个 asmdef,并把 autoReferenced 设为 false,逼着用它的模块显式声明依赖。这一步能立刻暴露出“谁在偷偷用某个插件”,对后期清理非常有用。

4. 从零改造:把现有项目安全拆开的操作流程

4.1 改造前的准备与基线测量

千万别在没有基线的情况下动手,否则改完你连“到底快了多少”都说不清。改造前先做三件事。第一,完整提交一次代码,确保工作区干净,改造失败能一键回滚。第二,测出当前的编译耗时基线:清理 Library 后测一次全量编译时间,然后随便改一个业务脚本测增量编译时间,各记三个样本取中位数。测试时关掉无关的 IDE 索引、浏览器标签,减少干扰。

第三,也是最容易被跳过的一步:用脚本扫一遍项目里所有 .cs 的目录分布和类型依赖,粗略画出模块之间的调用关系。不用很精确,重点是找出那些“谁都在引用”的公共类,以及明显的双向依赖。这两类东西是拆分的最大障碍,提前识别出来,规划时就能把它们下沉到公共层或者抽成接口。

注意:测量基线时一定要用同一台机器、同一个 Unity 版本、同一个目标平台。不同平台切换会触发脚本重新编译,数据会失真。

4.2 自底向上的拆分顺序

拆分顺序强烈建议从依赖链最底端往上做,也就是先建 Core,再建 Abstractions、Infrastructure,最后才是业务模块。原因很实际:自底向上每一步都能立即编译通过,随时可以停下来提交;自顶向下则会出现“上层程序集引用了还不存在的下层程序集”,全项目一片红,改到一半根本没法验证。

第一步,找出项目里那些不依赖任何业务概念的工具代码——字符串扩展、数学工具、对象池、事件总线、时间管理。把它们挪进 Core 目录,加 asmdef,勾上 noEngineReferences(如果确实用了 UnityEngine 就暂时不勾),编译。这一步通常会报出一堆“找不到类型”,因为原来这些工具引用了业务类,正好借机把反向依赖砍掉。

第二步,建 Abstractions,把跨模块的接口和数据结构放进去。这一步的产出直接决定了后面业务模块能不能拆干净。

第三步,按功能模块逐个拆。每拆一个,编译通过、跑一遍相关功能、提交一次。别贪多,一次拆三四个模块出问题很难定位。

4.3 引用关系的配置与验证

配置引用时有个高效技巧:先全部不配,让编译器告诉你缺什么。新建的 asmdef 默认 references 为空,此时该程序集下的脚本会大量报“找不到类型或命名空间”,逐条看报错,把对应的程序集加进 references,直到编译通过。这比预先猜依赖要准确得多,也能顺便发现意料之外的耦合。

验证依赖方向是否正确,除了看代码,还可以用 Unity 的 Assembly Definition 面板反查——选中一个程序集,Inspector 里会列出它引用了谁。更彻底的办法是写个简单的编辑器脚本扫一遍所有 asmdef,检查是否存在“下层引用了上层”的违规情况,把规则固化成自动检查,每次提交前跑一遍。

移动脚本文件时有条铁律:必须在 Unity 编辑器内拖拽,或者连同 .meta 文件一起移动。Unity 靠 .meta 里的 GUID 识别资源,只挪 .cs 不挪 .meta,GUID 就变了,所有引用这个脚本的预制体、场景、ScriptableObject 全部变成 Missing。这个坑踩一次能让人通宵,务必记住。

4.4 灰度与回滚

改造不要一次性铺满全项目。我的做法是先挑一个相对独立、依赖少的模块试点,跑一两周,确认编译速度确实有改善、没人反馈功能异常,再推广到其他模块。试点期间保留原来的Assembly-CSharp,让没拆的代码继续待在里面,新旧并存完全没问题。

回滚方案很简单也很可靠:拆程序集本质上就是加文件、改引用,代码逻辑一行没动,所以 git 回滚的粒度很清晰。但有个隐患要注意——如果你在拆分过程中顺手改了代码结构(比如把某个类挪了命名空间),回滚就会牵扯到业务代码。所以尽量把“拆程序集”和“重构代码”分成两次提交,别混在一起,出问题时才好判断是谁的锅。

5. 踩坑实录:拆分后常见的六类问题与排查路径

5.1 编译期类型找不到

最高频的报错,没有之一。症状是The type or namespace name 'XXX' could not be found,原因就两种:要么引用没配,要么类型所在的程序集根本没参与编译。排查顺序建议这样走:先确认报错脚本属于哪个程序集,确认目标类型属于哪个程序集,然后检查前者 references 里有没有后者。

如果引用配了还是找不到,看目标程序集的 includePlatforms 和 defineConstraints。常见的坑是某个程序集被限制成只在 Android 平台编译,你在编辑器里当然看不到它;或者它被 defineConstraints 里的宏挡住了,宏没开,整个程序集不参与编译,引用方自然找不到类型。这时候别急着改代码,先去 Player Settings 的 Scripting Define Symbols 里检查宏。

还有一种更隐蔽的情况:程序集只引用了运行时程序集,但用到的类型在编辑器程序集里。编辑器程序集可以引用运行时,反过来不行,方向错了报错信息也一样是“找不到”。养成习惯,看到类型找不到先问一句“它属于哪个程序集”。

5.2 循环依赖

循环依赖的报错很直接,Assembly with cyclic references之类,但这东西一旦出现往往是结构性的,改起来疼。成因通常是 A 模块的某个工具类被 B 用了,B 的某个管理器又被 A 反过来调用。

解决思路有三条,按推荐顺序排。第一,抽接口下沉:把双方互相需要的那部分能力抽象成接口,放进更底层的程序集,实现留在各自模块里,通过注册表在启动时绑定。第二,改事件通信:把“直接调用”改成“发事件”,发送方只需要知道事件类型,接收方自己订阅,事件类型定义在公共层。第三,合并程序集:如果两个模块的耦合已经深到抽不干净,承认现实把它们合成一个程序集,等以后有重构机会再拆。硬拆出一个循环依赖再靠反射绕过去,属于给自己埋雷,不建议。

5.3 编辑器脚本与运行时脚本互相污染

典型症状是编辑器里一切正常,打包就报The type or namespace name 'UnityEditor' could not be found。原因是某个运行时程序集里混进了using UnityEditor,或者某个类上挂了[CustomEditor]之类的编辑器特性,但没有用#if UNITY_EDITOR包起来。

规矩很简单:编辑器专属代码一律放 Editor 目录下的编辑器程序集,运行时程序集里如果要写编辑器专用逻辑,必须整段包在#if UNITY_EDITOR里。但更推荐的做法是彻底分离——运行时程序集只暴露接口,编辑器程序集去实现工具界面,两边通过接口通信,这样连条件编译都省了。

5.4 反射、序列化与资源引用

拆程序集之后,所有写死程序集名的反射代码都可能挂。比如Type.GetType("SomeClass, Assembly-CSharp"),一旦 SomeClass 搬到Game.Features.Battle,这行代码就静默返回 null。排查这类问题没有捷径,只能全局搜索Assembly-CSharp、GetType(、Assembly.Load这类关键字,逐个确认。

序列化这边也有坑。Unity 的序列化对跨程序集的类型没有额外限制,但前提是字段类型所在的程序集必须被正确引用,否则 Inspector 上那个字段显示为 None。另外,ScriptableObject的创建菜单如果依赖类型扫描,拆分后要确认类型仍然可见,否则右键菜单里会凭空少几个选项。

5.5 第三方插件与包管理

插件自带 asmdef 的情况越来越普遍。你要用它,就在 references 里加上它的程序集名,这没什么难度。麻烦的是那些没有 asmdef、直接摊在 Assets 下的插件源码,它们会继续吸进Assembly-CSharp,而你的新程序集想用它们,就会遇到“新程序集无法引用 Assembly-CSharp”的尴尬——因为预定义程序集的引用方向是单向的,Assembly-CSharp能引用 autoReferenced 的程序集,反过来不行。

解决办法是给插件补 asmdef。补的时候注意保留它原有的目录结构,references 里加上它依赖的引擎模块,autoReferenced 设为 false,逼着业务代码显式声明。补完之后插件代码就变成了独立程序集,跟你的分层架构平起平坐,引用关系一目了然。

通过 Package Manager 安装的包(比如某些输入、渲染相关的官方包)内部自带 asmdef,引用时填包内的程序集名即可。填之前建议去 Packages 目录下找一下那个 asmdef,确认名字拼写,别看文档猜。

5.6 排查速查表

把上面这些整理成一张表,出问题时可以对着扫一遍:

现象高概率原因排查动作
类型或命名空间找不到引用未配置 / 平台过滤 / 宏未定义检查引用列表、includePlatforms、defineConstraints
打包报 UnityEditor 找不到运行时程序集混入编辑器代码全局搜 using UnityEditor 和编辑器特性
循环引用报错模块间双向调用抽接口下沉或改事件通信
场景/预制体引用丢失移动脚本未带 .meta从版本库恢复 .meta 后用编辑器移动
反射返回 null写死了程序集名搜 Assembly-CSharp 和 GetType
插件类型在新程序集里不可见插件无 asmdef 被吸进默认程序集给插件补 asmdef 并显式引用
改了 asmdef 后编译很久修改 asmdef 触发全量重编加域重载批量改完再让 Unity 编译,别边改边看

6. 编译速度实测与持续维护

6.1 增量编译的真实收益在哪

前面说过,拆程序集不改善全量编译,甚至略慢。实测下来的规律是:全量编译时间可能增加百分之几到十几个百分点,具体取决于程序集数量和硬件;而增量编译的收益非常明显,改一个末端业务脚本的编译时间通常能降到原来的三分之一到五分之一。对于日常开发,增量编译占了绝大多数场景,所以整体体验是显著变好的。

真正决定收益大小的,是你把“改动热点”放在了依赖链的哪个位置。如果 UI 和玩法逻辑还在底层程序集里,那每次改都要重编半条链,拆了也白拆。反过来,把高频改动的模块放在最末端、依赖最少的地方,收益才能最大化。这也是为什么我一直强调分层顺序——它不只是为了架构好看,直接决定了编译时间的账怎么算。

还有一点值得提醒:Unity 的编译是并行的,多个互不依赖的程序集可以同时编译。所以拆成多个平行的小程序集,比拆成一条长长的依赖链,在多核机器上的表现更好。这也是为什么业务模块之间要尽量互不引用,只通过接口和事件通信——除了架构上的好处,还能让它们在编译时并行起来。

6.2 几个把收益放大的技巧

第一个技巧是把域重载的成本也算进去。脚本编译完之后 Unity 要重载整个域,重建所有托管对象,这个开销和项目里的资源数量、序列化对象数量相关,跟程序集数量关系不大。如果你的项目域重载要五六秒,那编译节省的两秒意义就有限。这种情况可以考虑开启进入播放模式时的域重载选项,让 Play 时复用已有域,只重载必要部分,两者叠加效果更好。不过这个选项对静态状态的清理要求很高,开启前要确认项目里没有依赖静态字段在 Play 之间保持干净的逻辑。

第二个技巧是减少程序集之间的传递依赖。A 引用 B,B 引用 C,那 A 其实能看见 C 里的类型,但编译时 A 依赖 B,B 变了 A 也要重编。如果 A 实际上只用 C 的类型,却通过 B 间接获得,那 B 的任何改动都会连累 A。所以能直接引用就直连,别图省事走传递。这条在依赖层级深的时候尤其明显。

第三个技巧是把自动生成的代码单独成程序集。协议代码、配置表生成的类、序列化代码,这些东西数量大、生成频率高,单独放一个程序集,每次重新生成只重编它自己,业务代码完全不用动。这个改动成本很低,收益却很直接,属于性价比最高的优化之一。

6.3 团队协作下的架构守护

架构这件事,靠自觉是守不住的。拆完程序集之后,最怕的是新人为了省事直接把引用一加,突破分层。所以要把规则变成机器能检查的东西:写一个编辑器脚本,在编译前或提交前扫描所有 asmdef,比对一份“允许的依赖白名单”,发现违规就报错。白名单用文本文件维护,每次新增引用都要走代码评审,让依赖关系变成需要理由的显式决策。

另一件事是给程序集写文档。不用很长,每个 asmdef 旁边放个 README,写清楚这个程序集负责什么、允许依赖谁、被谁依赖、有哪些约束(比如禁止引用引擎、禁止使用某类 API)。新人接手时先看这个,比翻代码快得多。文档跟着 asmdef 一起提交,改结构时同步更新,成本很低但价值很高。

最后一句实在话:程序集拆分不是一次性任务,是持续维护的过程。新功能上线、旧模块下线、第三方库替换,都会牵动依赖图。我在实际项目里的体会是,与其追求一步到位的完美分层,不如先把核心边界立起来,剩下的随着开发节奏慢慢收。每次拆一个小模块、跑通、提交,比憋一个大重构要可靠得多。真正踩过几次循环依赖和 meta 丢失的坑之后,你会对“依赖方向”这四个字有完全不同的理解。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进