在 Unity 项目中,随着功能模块增多,业务逻辑往往依赖于大量低层实现——比如 UI 直接引用数据访问层、游戏对象硬编码查找具体组件、系统模块相互 new 实例化等。这种“上层模块依赖下层模块”的紧耦合,会导致:
- 修改痛苦:更改低层实现会连带修改所有依赖它的高层逻辑。
- 难以测试:高层逻辑无法替换低层实现为 Mock,单元测试难以编写。
- 扩展困难:想要引入新实现(如替换存储方式)必须修改现有业务代码。
依赖倒置原则(Dependency Inversion Principle, DIP)正是为了解决以上问题:
“High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.”
“高层模块不应该依赖低层模块,两者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。”
Robert C. Martin
下面,我们将从 “为什么重要”——“如何识别违例”——“重构方案”——“度量与工具” 四个角度,结合 Unity 实战,深入剖析 DIP 的落地策略。
一、为什么需要依赖倒置?
- 解耦高层与低层
通过引入接口或抽象类,高层逻辑只与抽象交互,任何底层实现变化都无需触碰业务代码。 - 可测试性
高层模块接收抽象依赖,我们可以注入 Mock 实现,轻松编写单元测试。 - 支持并行开发
团队成员可以各自实现不同的低层模块,然后在业务模块中按需注入,互不干扰。 - 插件化扩展
新增功能时,只要提供新的具体实现并注册到依赖注入容器,无需修改已有模块。
二、Unity 中的 DIP 违例示例
public class ScoreManager {
private SQLiteConnection _db; // 直接依赖具体存储
private PlayerUI _ui; // 直接依赖具体 UI
public ScoreManager() {
_db = new SQLiteConnection("game.db");
_ui = GameObject.FindObjectOfType<PlayerUI>();
}
public void SaveScore(int playerId, int score) {
_db.Execute($"INSERT INTO Scores VALUES({playerId}, {score})");
_ui.UpdateScoreDisplay(score);
}
}
问题
ScoreManager同时依赖SQLiteConnection与PlayerUI,高层业务逻辑与细节实现耦合;- 若要改用 JSON 存储,或用不同 UI 展示,都要修改
ScoreManager; - 单元测试时无法 Mock 数据层或 UI 层。
三、DIP 重构方案
3.1 引入抽象接口
将低层细节抽象为接口,让高层模块只依赖接口而非具体实现。
// 抽象数据存储
public interface IScoreRepository {
void Save(int playerId, int score);
}
// 抽象 UI 更新
public interface IScoreView {
void UpdateDisplay(int score);
}
// 高层业务仅依赖抽象
public class ScoreManager {
private readonly IScoreRepository _repo;
private readonly IScoreView _view;
public ScoreManager(IScoreRepository repo, IScoreView view) {
_repo = repo;
_view = view;
}
public void SaveScore(int playerId, int score) {
_repo.Save(playerId, score);
_view.UpdateDisplay(score);
}
}
3.2 具体实现示例
// 低层存储:SQLite
public class SQLiteScoreRepository : IScoreRepository {
SQLiteConnection _db;
public SQLiteScoreRepository(string connStr) {
_db = new SQLiteConnection(connStr);
}
public void Save(int playerId, int score) {
_db.Execute($"INSERT INTO Scores VALUES({playerId}, {score})");
}
}
// 低层视图:Unity UI
public class UnityScoreView : MonoBehaviour, IScoreView {
public Text scoreText;
public void UpdateDisplay(int score) {
scoreText.text = $"Score: {score}";
}
}
3.3 依赖注入与容器
在 Unity 中,可通过以下几种方式完成实例注入:
| 方式 | 优缺点 |
|---|---|
| 构造函数注入 | 最安全,可确保所有依赖就绪,但 MonoBehaviour 无法直接 new; |
| 属性/字段注入 | 方便在 Inspector 中拖拽赋值,但不易检测未赋值错误; |
| 服务定位器 | 全局获取,使用简单,但隐藏了依赖,不利于测试; |
| DI 框架(如 Zenject) | 自动解析依赖,支持生命周期管理,但引入外部库; |
3.3.1 使用 Zenject(示例)
// 安装依赖
public class GameInstaller : MonoInstaller {
public override void InstallBindings() {
Container.Bind<IScoreRepository>()
.To<SQLiteScoreRepository>()
.AsSingle()
.WithArguments("game.db");
Container.Bind<IScoreView>()
.To<UnityScoreView>()
.FromComponentInHierarchy()
.AsSingle();
Container.Bind<ScoreManager>().AsSingle();
}
}
// 使用依赖注入
public class GameController : MonoBehaviour {
[Inject] ScoreManager _scoreManager;
void Start() {
_scoreManager.SaveScore(1, 100);
}
}
四、度量与工具支持
- 架构可视化
- NDepend:创建依赖图(Dependency Graph),确保依赖关系单向流动,不形成循环;
- ReSharper:检测直接依赖具体类,可提示“抽象化”建议。
- 耦合度量
- Afferent/Efferent Coupling:模块的入度/出度,可在 NDepend 中查看;
- Stability:通过 $\mathrm{Stability} = \frac{\text{Efferent}}{\text{Afferent} + \text{Efferent}}$ 判断模块可变性。
- 单元测试
- 为
ScoreManager编写测试,注入 Mock 实现,不触及 SQLite 或 Unity UI:
[Test]
public void SaveScore_UpdatesRepoAndView() {
var mockRepo = new Mock<IScoreRepository>();
var mockView = new Mock<IScoreView>();
var mgr = new ScoreManager(mockRepo.Object, mockView.Object);
mgr.SaveScore(1, 200);
mockRepo.Verify(r => r.Save(1, 200), Times.Once);
mockView.Verify(v => v.UpdateDisplay(200), Times.Once);
}
五、常见误区与注意事项
- 避免 Service Locator 滥用:虽然方便,但会隐藏依赖,降低可测试性;
- MonoBehaviour 构造注入限制:需借助 DI 框架或工厂模式,在
Awake()/Start()中初始化构造依赖; - 生命周期管理:注意单例与瞬时对象的生命周期差异,避免内存泄漏;
- 接口粒度:接口不宜过大(参见 ISP),也不要为每个方法都创建一个接口。
六、小结与最佳实践
- 高层模块只依赖抽象:永远不要在业务逻辑里
new出具体实现; - 低层实现依赖抽象:细节层通过接口与高层“反向依赖”;
- 使用 DI 容器或手动注入:合理选择注入方式,确保依赖可见、可管理;
- 编写 Mock 测试:验证业务逻辑与底层实现解耦;
- 持续监测依赖图谱:避免形成不必要的循环依赖,保持架构清晰。