LOADING

加载过慢请开启缓存 浏览器默认开启

面向对象设计原则系列(6):依赖倒置原则(DIP)


在 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 的落地策略。


一、为什么需要依赖倒置?

  1. 解耦高层与低层
    通过引入接口或抽象类,高层逻辑只与抽象交互,任何底层实现变化都无需触碰业务代码。
  2. 可测试性
    高层模块接收抽象依赖,我们可以注入 Mock 实现,轻松编写单元测试。
  3. 支持并行开发
    团队成员可以各自实现不同的低层模块,然后在业务模块中按需注入,互不干扰。
  4. 插件化扩展
    新增功能时,只要提供新的具体实现并注册到依赖注入容器,无需修改已有模块。

二、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 同时依赖 SQLiteConnectionPlayerUI,高层业务逻辑与细节实现耦合;
  • 若要改用 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);
    }
}

四、度量与工具支持

  1. 架构可视化
    • NDepend:创建依赖图(Dependency Graph),确保依赖关系单向流动,不形成循环;
    • ReSharper:检测直接依赖具体类,可提示“抽象化”建议。
  2. 耦合度量
    • Afferent/Efferent Coupling:模块的入度/出度,可在 NDepend 中查看;
    • Stability:通过 $\mathrm{Stability} = \frac{\text{Efferent}}{\text{Afferent} + \text{Efferent}}$ 判断模块可变性。
  3. 单元测试
  • 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),也不要为每个方法都创建一个接口。

六、小结与最佳实践

  1. 高层模块只依赖抽象:永远不要在业务逻辑里 new 出具体实现;
  2. 低层实现依赖抽象:细节层通过接口与高层“反向依赖”;
  3. 使用 DI 容器或手动注入:合理选择注入方式,确保依赖可见、可管理;
  4. 编写 Mock 测试:验证业务逻辑与底层实现解耦;
  5. 持续监测依赖图谱:避免形成不必要的循环依赖,保持架构清晰。