首页 / 资讯中心 / 文章详情

Unity asmdef程序集拆分实战:编译提速、依赖治理与工程化落地

Unity asmdef程序集拆分实战:编译提速、依赖治理与工程化落地 ★ FEATURED ARTICLE
改一行 UI 文案,按 CtrlS,然后盯着编辑器右下角那个转圈的小图标等了半分多钟——这个场景做 Unity 的人应该都不陌生。项目越大,这段等待越离谱,最后大家养成了一个习惯:改完一堆东西再切回来,中间去泡杯咖啡。问题的根源往往不在机器性能,也不在 Unity 本身慢,而在于整个 Assets 目录被塞进了一个叫Assembly-CSharp的巨大程序集里,编译器每次都要把它从头到尾嚼一遍。这篇内容聊的就是 Unity 程序集定义文件(.asmdef)这件事:它是什么、能解决什么问题、怎么拆、拆完到底能快多少、以及拆完之后会踩哪些坑。只要你手上有一个脚本数量过千的 Unity 项目,或者正准备从一个原型往工程化方向走,这篇东西基本都能直接用上。我按实际动手的顺序来写,从原理到方案到落地,尽量给到能抄作业的程度。1. 先搞明白 asmdef 到底在改什么1.1 默认状态:所有脚本挤在一个程序集里新建一个 Unity 工程,Assets下放进去的 C# 脚本,Unity 会自动把它们全部编进一个叫Assembly-CSharp的程序集。放在Editor目录下的脚本进Assembly-CSharp-Editor,放在Plugins或Standard Assets下的脚本会先一步编成Assembly-CSharp-firstpass。这套预定义规则的设计目标是零配置就能跑,你新建一个脚本,立刻就能访问项目里任何其他脚本的 public 类型,不用写一行引用声明。代价藏在编译器的行为里。Roslyn 编译的最小单位是程序集,不是文件。当你改动了Assembly-CSharp里的任意一个.cs文件,Unity 会把属于这个程序集的所有源文件重新喂给编译器一次,跑一遍语法分析、语义绑定、IL 生成。一个三千脚本的项目,你只改了一个按钮的点击回调,编译器依然要把三千个文件全部过一遍。这就是那个转圈图标的由来。1.2 编译粒度等于重编半径理解 asmdef 的价值,关键要抓住一句话:编译的粒度是程序集,重编的范围是这个程序集加上它的全部下游。假设项目拆成四个程序集:A 是底层工具,B 依赖 A,C 依赖 B,D 依赖 C。你改了一个 A 里的文件,那么 A、B、C、D 四个全部要重编;你改了一个 D 里的文件,只有 D 重编,A、B、C 都不用动。这就是为什么拆分方案里,底层必须尽量稳定、上层必须尽量易变——易变的代码放在叶子节点,重编爆炸半径才最小。反过来说,如果你的拆分方案里有一个什么都往里塞的 Common 程序集,被所有人引用,那这个 Common 就成了新的单点:改它一下,全项目重编。拆分没做对,效果可能还不如不拆。1.3 什么样的项目值得动手不是所有项目都值得折腾。我见过一个只有两百个脚本的小工具,硬拆成十二个程序集,结果是每加一个功能都要改三处 asmdef,维护成本远超收益。判断标准我整理成了一张表:项目特征建议脚本数量 300,单人开发,迭代周期以天计不拆,保持默认脚本 300~800,两三人协作粗拆 4~6 个,重点隔离 Editor 和第三方脚本 800~3000,团队 4 人以上系统性拆分 10~20 个脚本 3000,有明确模块边界按模块拆,配自动化校验需要把部分逻辑复用到服务端或另一个客户端底层用No Engine References独立成集除了编译速度,还有第二个收益经常被忽略:架构清晰度。asmdef 的引用关系是一张强制执行的依赖图。代码里你写using只是声明,真正的约束在于没有引用就编译不过。这张图往那一放,谁依赖谁、哪一层越界了,一目了然。很多项目做到后期变成一坨,就是因为没有任何机制阻止表现层直接调用数据层内部实现,而 asmdef 天然就是这道墙。2. 拆分方案:按什么维度切2.1 三种常见切法及其代价按功能模块切是最直观的:战斗、背包、任务、UI 各一个程序集。好处是业务边界清晰,坏处是模块之间交叉引用极多——战斗要弹 UI,UI 要读背包,背包要响应任务事件,画出来的依赖图是一张网,不是一棵树,很容易撞上循环依赖。按技术层次切更稳:底层基础库、数据与配置、领域逻辑、表现层、编辑器工具。依赖严格单向,下层不知道上层的存在。这是我更推荐的主干结构,功能模块的切分可以作为第三层挂在领域逻辑下面。按复用性切是混在里面的第三种思路:凡是可能被别的工程拿走的代码(数学库、协议结构、状态机、事件总线),一律抽成No Engine References的纯 C# 程序集。这种程序集连UnityEngine都不能引用,强迫你把逻辑和引擎 API 剥离开。刚开始会觉得别扭,写习惯了会发现这类代码的测试和移植成本极低。实际项目里通常是层次为主干、功能为分支、复用性做筛选三者混用。2.2 一份可以直接抄的目录与程序集对照表我给一套自己用得比较顺的结构,你可以照着改名字:程序集名目录依赖说明Game.FoundationAssets/Game/Foundation无数学、工具、事件总线,No Engine ReferencesGame.DataAssets/Game/DataFoundation配置表结构、存档模型Game.DomainAssets/Game/DomainFoundation, Data纯逻辑,不碰 MonoBehaviourGame.ServicesAssets/Game/ServicesDomain, Data资源加载、音频、输入、网络Game.GameplayAssets/Game/GameplayServices, Domain战斗、AI、交互Game.UIAssets/Game/UIServices, Domain界面与表现Game.BootstrapAssets/Game/Bootstrap全部启动入口、场景装配Game.EditorAssets/Game/Editor只依赖需要的includePlatforms: [Editor]Game.Tests.EditModeAssets/Tests/EditMode被测程序集勾选 Test Assemblies要注意Game.Bootstrap这种什么都依赖的顶层程序集,它的存在是合理的,因为它就是装配根。但必须严格限制:任何业务代码都不许反向往 Bootstrap 里塞东西,否则装配根会慢慢变成第二个Assembly-CSharp。2.3 单向依赖怎么保证,以及那几个绕不开的例外单向依赖的原则好说,难在执行。最常见的破防场景是:UI 层想直接调用战斗层的内部方法,一加引用就顺了,顺手就加了。三个月后回头一看,战斗层反过来又在引用 UI 层去弹个伤害飘字,循环依赖就这么形成了。我的处理方式是三条:第一,跨层通信一律走事件或接口,接口定义放在下层或独立的中立程序集里。比如战斗层对外发DamageDealtEvent,UI 层订阅,战斗层不认识 UI。第二,Game.Foundation这种底层绝对不允许引用任何上层程序集,而且开启了No Engine References这个硬约束。一旦有人想在 Foundation 里写个Debug.Log,编译直接报错,自然就断了念想。第三,定期跑一个依赖审计脚本,把异常引用揪出来。这个脚本我放到第 6 节里给。具体到 Unity 还有一个绕不开的限制:asmdef 不能引用预定义程序集。也就是说,你的Game.UI无法引用Assembly-CSharp。这带来的直接影响是:拆分过程是渐进的,但你拆出去的那一块,就不能再用还留在Assembly-CSharp里的代码了。这个约束在迁移期特别烦,解决办法放在 6.1 节讲。2.4 第三方插件与 Plugins 目录的处理插件永远是最容易被忽略的一块。Plugins 目录下的 DLL,Inspector 里有一个Auto Reference勾选项。默认勾着,意味着所有程序集都能直接用这些 DLL 的类型。问题在于它会污染所有程序集的依赖,而且有些 DLL(比如某些平台 SDK)会把自己的依赖也带进来,导致打不同平台时出现各种奇怪的符号缺失。我的处理是:能关的Auto Reference都关掉,改成由具体的程序集显式引用。做法是把程序集的Override References勾上,然后在下面的Assembly References列表里明确列出需要的 DLL。这条路会让 asmdef 的配置变长,但换来的是谁用了哪个插件这件事变得可查。哪天要换掉一个 SDK,搜一下谁引用了它的 DLL,受影响范围立刻就出来了。那些没有 asmdef 的插件源码目录,如果不想搬文件,用.asmref文件把整个目录并进指定程序集就行,3.6 节会写。3. 创建 asmdef 与关键参数逐项说明3.1 创建方式与文件结构在 Project 窗口里右键 → Create → Assembly Definition,或者右键任意脚本目录 → Create → Assembly Definition,Unity 会在该目录生成一个.asmdef文件,同时生成对应的.meta。这个文件的本质是一个 JSON,用文本编辑器打开就能直接改。最小的 asmdef 长这样:{ name: Game.UI, rootNamespace: Game.UI, references: [ Game.Services, Game.Domain ], includePlatforms: [], excludePlatforms: [], allowUnsafeCode: false, overrideReferences: false, precompiledReferences: [], autoReferenced: true, defineConstraints: [], versionDefines: [], noEngineReferences: false }name是程序集的真实名字,也是其他程序集引用它时用的标识,改了名字等于换了身份,所有引用它的地方都要跟着改。rootNamespace只影响新脚本创建时自动生成的 namespace,对编译没有任何作用,但团队统一设置能省掉很多这个类在哪的搜索时间。有一点必须强调:移动或重命名 asmdef 文件时,一定连.meta一起动,并且尽量在 Unity 编辑器里操作。在系统文件管理器里直接剪切粘贴,如果.meta丢了,Unity 会重新生成一个 GUID,所有以 GUID 形式引用它的程序集会全部断链,报一堆找不到程序集的错误。3.2 每个字段的实际作用很多人第一次看到这堆字段会懵,我按什么情况下需要改它的顺序讲一遍。字段默认值什么时候需要改includePlatforms空(全平台)只在特定平台编译时填,如[Android]excludePlatforms空想排除某平台时填,不能和includePlatforms同时非空allowUnsafeCodefalse用了unsafe、指针、fixed关键字overrideReferencesfalse需要手动控制引用哪些预编译 DLLprecompiledReferences空配合上一个字段,列出 DLL 文件名autoReferencedtrue想让Assembly-CSharp不要自动引用它defineConstraints空有条件编译,比如只在某宏存在时参与编译versionDefines空根据包版本自动生成宏noEngineReferencesfalse纯 C# 逻辑层,强制不引用 UnityEngineautoReferenced这个字段值得单独说。它设为 true 时,预定义的Assembly-CSharp会自动引用这个程序集,反过来不成立——这个程序集不会自动引用Assembly-CSharp。所以你把一个模块拆出去,它在Assembly-CSharp里的老邻居依然能看到它,但它看不到老邻居。这个不对称是迁移期最容易被绊倒的地方。noEngineReferences是我最喜欢的一个开关。勾上之后,这个程序集连UnityEngine.dll都不引用,写代码时Vector3、Debug、MonoBehaviour全部不可用,只能用System命名空间下的东西。刚开始用会觉得束手束脚,但正是这种束才逼着你把纯逻辑和引擎耦合分开。战斗数值、状态机、寻路算法、协议解析这类东西,理想状态本来就不该知道 Unity 的存在——这样将来做服务端校验或者换引擎,这部分代码能直接拿走。3.3 References 用 GUID 还是名字asmdef 的引用列表里,每一项要么是程序集名字符串,要么是GUID:xxxxxxxx形式。选哪种由项目设置里的一个开关控制,在Project Settings Editor页里能找到以 GUID 形式引用程序集的选项。两种方式的取舍很实际:方式优点缺点名字引用可读性好,diff 里一眼看出引用了谁程序集改名后全部断链GUID 引用改名不影响引用JSON 里全是哈希,评审时看不出所以然我的选择是开启 GUID 引用,但在团队规范里写死一条:程序集名字一旦确定不再轻易改,要改就当成一次专门的迁移任务处理。这样既拿到了稳定性,又避免了 diff 不可读的问题——反正名字不改,看名字的机会本来也不多。顺带说一个提交习惯:asmdef 是 JSON,冲突时手动合并不难,但.meta文件冲突就很麻烦。建议把*.asmdef和*.asmdef.meta都作为文本处理,.gitattributes里显式声明,避免被当成二进制。3.4 Editor 程序集:把编辑器代码彻底隔离编辑器代码隔离做不好,是最常见的编辑器里好好的,一打包就炸的元凶。标准的做法是给所有编辑器脚本单独建一个程序集,includePlatforms填[Editor]。{ name: Game.Editor, rootNamespace: Game.Editor, references: [ Game.Services, Game.Domain, Game.Gameplay ], includePlatforms: [Editor], autoReferenced: true }includePlatforms填[Editor]之后,打 Player 时这个程序集整个不参与编译,里面的代码不会被包进去,也不会因为using UnityEditor而报错。这比在每个文件外面套#if UNITY_EDITOR干净得多,后者一旦漏一个文件就是打包失败,而且代码里到处是预处理指令,读起来很痛苦。反过来,运行时代码绝对不能引用 Editor 程序集。这条 Unity 会帮你拦,但如果项目里同时存在大量#if UNITY_EDITOR包裹的、位于 runtime 程序集里的编辑器辅助代码,情况就变得微妙了:编译能过,打包也能过,只是那些代码被裁掉了,运行到某个路径时行为不对。我的建议是尽量不写这种代码,实在要写,把它单独抽进一个 Editor 程序集里,通过接口回注。另外提醒一点:Game.Editor的autoReferenced保持 true 通常没问题,因为它不会被包进去。但如果你发现某些编辑器脚本莫名其妙地在Assembly-CSharp-Editor里也有一份,检查一下是不是有脚本放在了 asmdef 覆盖范围之外的目录。3.5 versionDefines 与 defineConstraints:处理可选依赖项目里总会有一些装了就用、没装也能跑的功能,比如某个后处理包、某个分析工具。传统做法是在代码里硬写#if HAS_XXX,然后指望有人在 Player Settings 里手动加宏。这种手工步骤迟早会被忘掉。versionDefines解决的就是这个问题:它根据某个包的版本自动生成宏。{ name: Game.Services, versionDefines: [ { name: com.unity.addressables, expression: 1.19.0, define: HAS_ADDRESSABLES }, { name: com.unity.cinemachine, expression: [2.8.0,3.0.0), define: HAS_CINEMACHINE_2_8 } ] }expression支持单个版本号(表示大于等于)和区间语法。配合代码里的#if HAS_ADDRESSABLES,整个可选功能的开关就变成声明式的了,包升级到区间外时会自动关闭。defineConstraints是另一个方向的工具:它决定这个程序集要不要参与编译。填[HAS_ADDRESSABLES]意味着只有该宏存在时,这个程序集才被编译。把一个可选功能的实现整个做成独立程序集,再用defineConstraints卡住,依赖关系就非常干净——没装包,程序集不存在,其他代码里的引用也会被#if处理掉,不会出现悬空引用。有一点要注意:defineConstraints在编辑器里的效果是程序集不编译,但引用它的程序集如果没做条件处理,会直接报找不到类型。所以这套机制必须两头都配:引用方用#if包住,被引用方用defineConstraints卡住。3.6 asmref:不搬文件也能并进程序集有些时候你不想移动文件——比如第三方插件的目录结构是固定的,升级时整个目录覆盖,你自己加 asmdef 会被冲掉。这时候用.asmref文件。在目标目录下右键 → Create → Assembly Definition Reference,生成一个.asmref文件,内容是:{ reference: Game.Foundation }这个目录下的所有脚本,在编译时就当它是Game.Foundation的一部分。文件还在原地,目录结构不变,但编译归属变了。这个机制在迁移期特别好用。你可以先在合适的地方建好程序集骨架,然后用 asmref 把散落各处的脚本吸进来,不用大规模移动文件,风险极低。等稳定之后再慢慢整理目录。asmref 也常用来处理某个子目录需要不同的平台设置的情况。比如主程序集全平台编译,但其中一个子目录只在 Android 上需要,那就在那个子目录里放一个 asmref,指向上层程序集,同时用includePlatforms做区分。不过这属于比较高级的用法,用得不好会让依赖关系变得难以追踪,我一般建议优先考虑拆成独立程序集。4. 编译速度到底能提升多少4.1 编译耗时到底花在哪Unity 的编译流程大致是这样:检测到脚本变化 → 收集变更的文件 → 遍历受影响的程序集 → 对每个程序集调用一次编译器 → 加载生成的 DLL → 触发域重载(如果进了 Play 模式)。时间主要花在三个地方:编译本身(语义分析 IL 生成)、引用加载(每个程序集编译时要加载它引用的所有元数据)、域重载(重新加载所有程序集并运行静态构造函数)。程序集拆分对第一项的影响最大,因为它是按程序集粒度重编的。对第二项的影响是双向的:拆开之后,每个程序集编译时需要加载的引用变少了,单个程序集编译更快;但程序集总数变多,总加载次数增加。对第三项基本是略增,因为域重载要遍历的程序集多了。所以拆分不是越多越好,存在一个最优点。我的经验是:对于 2000~3000 脚本的项目,10~20 个程序集是甜点区。超过 40 个之后,增量编译的收益开始被固定开销吃掉,而且管理成本陡增。4.2 一组对照量级我手上没有能公开的项目数据,给一组按常见规模测算的量级参考,你拿去和自己项目对一下,数量级上基本能对上:场景程序集数改表现层脚本到可进 Play改底层共享脚本到可进 Play未拆分1(有效)25~45 秒25~45 秒粗拆5~88~15 秒20~40 秒系统拆分12~203~7 秒15~30 秒过度拆分403~8 秒15~30 秒,且配置维护成本上升这组数字最值得看的是第二列和第三列的反差。拆分之后,改 UI 的等待从半分钟掉到几秒,但改底层的等待几乎没变,甚至可能因为程序集多了加载开销略增。这告诉你一件事:拆分的主要收益来自大部分日常改动都落在叶子节点上。如果你的日常改动本来就集中在某个被所有人引用的公共模块里,拆分的收益会大打折扣,该考虑的是先把这个公共模块瘦身。想拿到自己项目的真实数据,最简单的方式是在编辑器里打点。写个静态构造的类挂在编译事件上:using UnityEditor; using UnityEditor.Compilation; using UnityEngine; [InitializeOnLoad] public static class CompileTimer { static double _start; static CompileTimer() { CompilationPipeline.compilationStarted _ _start EditorApplication.timeSinceStartup; CompilationPipeline.compilationFinished _ { double cost EditorApplication.timeSinceStartup - _start; Debug.Log($[Compile] 本次编译耗时 {cost:F2}s); }; } }配合Console里按时间排序,跑一天下来就能拿到真实的分布。别凭感觉判断,感觉经常是错的。4.3 几个看起来有用其实没用的优化第一个坑是只拆 Editor。很多人第一天就把所有编辑器脚本挪进单独的 asmdef,然后发现自己改 UI 脚本时还是要等三十秒。因为 Editor 程序集本来就不参与增量编译的主路径,拆它主要收益是打包安全,不是速度。第二个坑是把一个模块拆成一堆小 asmdef。每个类一个 asmdef,听起来很激进很清爽,实际效果是:改一个类,它的下游依然要重编,而总编译次数变多,固定开销累积。这是典型的感觉在做优化,实际在做加法。第三个坑是关掉 Domain Reload 当作编译优化。这两个是不同的东西:Domain Reload 影响的是进 Play 的时间,不是编译时间。关掉之后进 Play 确实快了,但静态变量不会重置,很容易出现第二次进 Play 时状态残留的诡异 bug。要关就得配套写好[RuntimeInitializeOnLoadMethod]的状态重置逻辑,否则省下的时间会以调 bug 的形式还回去。第四个坑是相信增量编译会自动跳过没变的程序集从而随意拆分。这个前提是对的,但它只在改动确实落在叶子节点时才生效。底层程序集哪怕只改一行,全部下游照旧重编。所以拆分的核心功夫在怎么让易变的代码待在叶子节点,而不是拆多少个。5. 上生产之后最常撞的坑5.1 循环依赖:怎么形成的,怎么断循环依赖的表现是编译报错,大意是 A 和 B 互相引用导致无法确定编译顺序。Unity 不会自动帮你解决,只能改代码。形成路径通常是三步:某天 UI 层需要读战斗层的状态,加了引用;某天战斗层需要弹个提示框,顺手引了 UI;某天有人把两个都挪进 Common,循环没了,但 Common 变成了新的上帝对象。断循环的手法有几种,按推荐顺序:第一种是抽接口。在更下层或者中立程序集里定义IDamageNotifier,战斗层依赖这个接口,UI 层实现它,启动时注入。这是最干净的做法,代价是多几个文件。第二种是事件总线。把跨层通信改成发布订阅,底层完全不认识订阅方。缺点是调用链变隐式,调试时找不到是谁触发的,需要配一套事件日志才方便排查。第三种是合并程序集。实在解不开,就把互相纠缠的两个模块合成一个程序集。这是承认失败,但比留着一个假的分层结构要好。分层是为了降低耦合,如果合起来反而更清晰,那就合。第四种是下沉共用部分。两个模块互相需要的那一小块代码,通常是很小的一块(比如一个枚举、一个数据结构),把它下沉到 Foundation 层,两边的引用就都指向下了,循环自然消失。5.2 反射、类型查找与序列化的连带损伤拆分之后,一类很隐蔽的 bug 会浮上来:代码里用字符串写死了程序集名去查类型。// 拆分前能跑,拆分后返回 null var t Type.GetType(Game.Battle.PlayerController, Assembly-CSharp);这类代码在未拆分时随处可见,而且往往藏在一些老的工具类里,平时跑不到。判断方法很简单:全局搜Assembly-CSharp、Assembly-CSharp-Editor这两个字符串,有几处就是几个雷。正确的替代写法有三种。一是Type.GetType时只写全名不写程序集,让 Unity 在当前程序集和 mscorlib 里找;二是遍历AppDomain.CurrentDomain.GetAssemblies()逐个找;三是用UnityEditor.TypeCache.GetTypesDerivedFromT(),这个是 Unity 提供的高性能扫描接口,编辑器下用它最稳。运行时没有 TypeCache,得自己缓存一份结果。序列化方面,把脚本挪到另一个程序集本身不会破坏引用,因为 Unity 是按 GUID 记录脚本引用的。真正会出问题的是:移动脚本文件时在系统文件管理器里操作而没带上.meta,或者重命名了类名、改了 namespace。这两件事在拆 asmdef 的过程中很容易顺手做出来,结果就是一堆预制体和场景上的脚本引用变成Missing (Mono Script)。我的做法是:整个拆分过程中,文件移动一律在 Unity 的 Project 窗口里做,并且每次移动完立刻提交一次,确保.meta跟着走,出问题能回滚。批量整理目录这种操作,我宁可写个编辑器脚本调用AssetDatabase.MoveAsset,也不用外部工具。5.3 预编译 DLL 与插件引用断裂Override References这个勾选项是双刃剑。勾上之后,precompiledReferences列表之外的 DLL 全部不再被引用。听起来很合理,实际用起来的坑在于:如果你只列了主 DLL,漏了它依赖的辅助 DLL,编译时报的错可能完全不着边际。比如某个 SDK 的主库Sdk.Core.dll依赖Sdk.Protocol.dll,你只列了前者,报错可能是找不到 SdkProtocolMessage 类型,而这个名字你在主库里根本搜不到。排查方法是拿 ILSpy 之类的工具打开主 DLL,看它的引用列表,把依赖链上的 DLL 一个个补齐。另一个常见问题是 Plugins 目录下同一份 DLL 存在多个平台版本,比如Plugins/Android/xxx.dll和Plugins/iOS/xxx.dll。这时不要用Override References手动列,而是保持Auto Reference打开,靠 Inspector 里的平台设置区分。手动列的话,你没法在同一个 asmdef 里表达Android 用这个,iOS 用那个。我的经验是:能用平台设置区分的,就不要用 Override References。后者的适用场景是我确实只想让这一个程序集看到这个 DLL,属于收紧权限,不是做平台适配。5.4 常见问题速查表现象大概率原因处理方式编译报循环依赖A 引用 B,B 又引用 A抽接口或下沉共用部分打包时报找不到类型runtime 程序集引用了 Editor 程序集检查includePlatforms,把编辑器代码移走编辑器里正常,运行时Missing Script脚本移动时.meta丢失用AssetDatabase.MoveAsset移动,检查Missing Script某种类型在运行时为 nullType.GetType写死了程序集名改成不带程序集名或全程序集扫描引用的 DLL 类型找不到Override References漏了依赖 DLL补齐precompiledReferences改了程序集名后一堆报错引用方还在用旧名字全局搜索旧名字,或开启 GUID 引用某个宏的条件编译不生效versionDefines的包版本不在区间内检查ProjectSettings里实际安装的包版本新人拉代码后编译不过.meta没提交检查.gitignore是不是把.meta排除了6. 团队落地:怎么拆,怎么守6.1 渐进式迁移的顺序不要试图在一个下午把所有程序集拆完。我踩过的最惨的一次是周五下午动手,拆到一半发现循环依赖解不开,周末两天没法提交,周一只能整体回滚。后来总结的顺序是这样的:第一步,先把Editor目录的所有脚本收进编辑器程序集。这一步风险最低,收益是打包安全。第二步,把纯工具类、数学库、事件系统这类谁都不依赖的代码抽成 Foundation 层,并打开No Engine References。这一步可能会暴露出很多隐藏的引擎依赖,不要硬扛,遇到Debug.Log就换成自己封的日志接口,遇到Vector3就用自定义结构体或者先放弃这个文件的迁移。第三步,把数据模型和配置表结构抽成 Data 层,依赖 Foundation。第四步开始拆业务层。这一步要按引用关系最少的顺序来:先挑出那些只被少数地方引用的模块,拆出去之后修一下引用方就完事。等外围拆完了,再动核心模块。最后一步是收尾,把Assembly-CSharp里剩下的东西整理归位。这时候会剩下一堆说不清属于哪的散装脚本,我的处理是给它们一个Game.Legacy程序集,允许它引用任意地方,但禁止任何新代码引用它。这个技术债隔离区能防止旧代码继续污染新结构。整个过程建议用分支做,每拆一层合一次主干,不要憋大招。6.2 用脚本守住边界拆完之后,防线是靠人盯的,人一定会松懈。写个审计脚本挂在 CI 或者启动时跑,把违规引用揪出来。using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; public static class AsmdefAudit { class AsmdefData { public string name; public string[] references; public string[] includePlatforms; } [MenuItem(Tools/程序集/审计依赖关系)] public static void Run() { var root Application.dataPath; var map new Dictionarystring, AsmdefData(); foreach (var path in Directory.GetFiles(root, *.asmdef, SearchOption.AllDirectories)) { var data JsonUtility.FromJsonAsmdefData(File.ReadAllText(path)); map[data.name] data; } foreach (var kv in map) { var self kv.Key; var isEditor kv.Value.includePlatforms ! null kv.Value.includePlatforms.Contains(Editor); foreach (var raw in kv.Value.references ?? new string[0]) { var target ResolveRef(raw); if (string.IsNullOrEmpty(target)) continue; // 规则一:运行时代码不许引用编辑器程序集 if (!isEditor target.EndsWith(.Editor)) Debug.LogError($[依赖违规] {self} 引用了编辑器程序集 {target}); // 规则二:底层不许引用业务层 if (self Game.Foundation !target.StartsWith(Game.Foundation)) Debug.LogError($[依赖违规] Foundation 引用了 {target}); } } Debug.Log($审计完成,共 {map.Count} 个程序集); } static string ResolveRef(string raw) { if (!raw.StartsWith(GUID:)) return raw; var path AssetDatabase.GUIDToAssetPath(raw.Substring(5)); if (string.IsNullOrEmpty(path)) return null; return JsonUtility.FromJsonAsmdefData(File.ReadAllText(path)).name; } }这个脚本很粗糙,但覆盖了最常破防的两条规则。你可以按自己项目的分层约定往里加规则,比如UI 层不许引用 Gameplay 层的内部实现类这种,靠命名约定检查。要在 CI 里跑,把它包成静态方法,用-batchmode -quit -executeMethod AsmdefAudit.Run调用,在日志里搜依赖违规。同时要注意-executeMethod场景下的Debug.LogError不会让进程返回非零码,得自己读日志或者调用EditorApplication.Exit(1)。6.3 命名和目录约定程序集名建议用公司前缀.层次.模块的三段式,比如Game.Domain.Battle、Game.UI.Settings。这种命名有两个好处:排序之后天然按层次聚集;审计脚本里可以用StartsWith快速判断层次。目录结构跟程序集一一对应,不要出现程序集在这里,脚本却散在三个地方的情况。asmref 可以用,但要克制——每多一个 asmref,新人理解代码归属时就要多跳一次。另外一条容易被忽略的约定:根命名空间和程序集名保持一致。rootNamespace设成Game.UI.Settings,新建脚本自动带 namespace,写代码时不需要额外思考。这一条看着琐碎,但在几十个程序集的项目里,是降低心智负担最有效的一招。还有一点是关于测试程序集的:测试 asmdef 一定要勾上Test Assemblies,并且引用nunit.framework.dll。如果你用的是新版本的测试框架包,它会把 nunit 作为包依赖带进来,有时候需要在precompiledReferences里显式加一行nunit.framework.dll,不然会出现找不到 Assert的报错。这个坑我在两个项目里都遇到过,每次都要翻一下旧笔记才想起来。最后分享一个实际体会:asmdef 最好在项目还小的时候就开始拆。等到项目三五千个脚本、循环依赖遍地的时候再动手,那就不叫优化了,叫开胸手术。我见过一个团队在这个阶段硬拆,断断续续做了两个月,期间主干一直不太能出包,新功能只能往后压。同样的工作量,如果在脚本八百个的时候用两周慢慢铺,几乎感觉不到阵痛。这件事和写单元测试、抽接口一样,成本是随着项目规模非线性上涨的,早做便宜,晚做很贵。
阅读完成 · 觉得有帮助?
咨询建站