Assembly Definition 与依赖边界¶
前言¶
刚开始写项目时,所有脚本都放在 Assembly-CSharp 里。改一个 UI 类,Unity 会把整个工程重新编译一遍。后来拆了 .asmdef,编译确实快了一点,但 Common 程序集开始变大,战斗、UI、网络和编辑器工具都往里塞,最后又绕回去了。
所以拆程序集本身不难,难的是拆完以后,依赖方向能不能一直保持清楚。1
先定依赖方向¶
一个比较稳妥的分层如下:
Game.Domain 规则和数据结构,不引用 UnityEngine
Game.Application 用例编排,依赖 Domain
Game.Infrastructure 存档、网络、资源加载,依赖 Application
Game.Presentation MonoBehaviour、UI、动画表现,依赖 Application
Game.Bootstrap 启动入口,依赖具体实现
依赖只能往下走。Domain 不应该知道 Presentation 里有没有按钮,Application 也不应该直接 FindObjectOfType。Bootstrap 是唯一允许同时看到接口和实现的地方。
在 Game.Domain.asmdef 里,引用列表保持为空:
{
"name": "Game.Domain",
"rootNamespace": "Game.Domain",
"references": [],
"autoReferenced": false
}
autoReferenced 设为 false 后,默认的 Assembly-CSharp 就看不见这个程序集,所有代码都必须显式放进对应的 asmdef。这样做一开始会多一些配置,但能避免项目里同时存在两套编译边界。
用组合根创建对象¶
业务类不负责查找依赖,创建过程集中在入口:
public sealed class GameBootstrap : MonoBehaviour
{
private CombatService _combat;
private void Awake()
{
var clock = new UnityClock();
var rules = new DefaultCombatRules();
_combat = new CombatService(clock, rules);
}
}
CombatService 是普通的 C# 类,构造函数直接接收接口:
public sealed class CombatService
{
private readonly IClock _clock;
private readonly ICombatRules _rules;
public CombatService(IClock clock, ICombatRules rules)
{
_clock = clock;
_rules = rules;
}
public DamageResult Apply(Attack attack, Combatant target)
{
return _rules.Resolve(
attack with { Timestamp = _clock.NowSeconds },
target);
}
}
这样测试时不用启动场景,换一个假的 IClock 就能验证时间相关逻辑。使用 VContainer、Zenject 这类容器也可以,但容器应该只在启动入口组装对象,不要让业务代码到处 Resolve。
编辑器代码单独放¶
编辑器工具很容易把 UnityEditor 引用带进运行时程序集。比较省事的做法是单独建 Game.Editor.asmdef:
{
"name": "Game.Editor",
"references": [
"Game.Domain",
"Game.Application"
],
"includePlatforms": [
"Editor"
]
}
这样打包时不会把编辑器脚本带进去,运行时程序集也无法反向引用编辑器代码。
几个容易踩的点¶
- 不要因为两个程序集都需要一个类型,就马上新建
Common。先看这个类型属于谁的契约,能不能放到更下层。 - 循环依赖不能靠“再拆一个程序集”解决。肯定是有一边的职责放错了,先找反向引用的原因。
- 先拆稳定边界,不要把每个文件夹都做成 asmdef。程序集太多会拖慢编译,也会让引用配置变得难维护。
- 修改 asmdef 后重新看一次依赖图。编译通过只说明类型能对上,不代表方向是对的。
拆分的时机不用太早。原型阶段可以先不拆;当项目出现多人并行开发、平台差异实现或独立发布需求中的两项,再开始整理边界比较划算。