mcpField
D
dependency-injection

dependency-injection

github.com/jame581开发929177 次安装2 周前更新
代码仓库

关于 dependency-injection

面向 Godot 4.3+ 项目的依赖注入技能,帮助开发者降低节点耦合并改善测试能力。内容对比 Autoload、@export 注入、Service Locator 和场景注入的适用场景。

主要功能

  1. 01比较四种依赖注入模式
  2. 02通过 @export 显式声明依赖
  3. 03用 Service Locator 管理可替换服务
  4. 04从父场景向子节点注入依赖
  5. 05使用替身和模拟对象测试节点

适用场景

  1. 01隔离节点以便单独测试
  2. 02按关卡配置不同的游戏依赖
  3. 03在运行时切换服务实现
  4. 04减少 Autoload 造成的系统耦合

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/jame581/godotprompter --skill dependency-injection

安装后在 Claude Code 中输入/dependency-injection确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
autoloads.md
export-injection.md
scene-injection.md
service-locator.md
testing-with-di.md

详细介绍

Godot 4.3+ 中的依赖注入

用于在系统之间连接依赖项的模式,让节点保持低耦合、可替换且可测试。所有示例均面向 Godot 4.3+,不使用任何已弃用的 API。

相关技能: godot-testing 用于构建便于测试的架构,event-bus 用于基于信号的解耦,godot-project-setup 用于注册自动加载项。


1. 问题

紧耦合会让代码难以测试、扩展和替换。在 Godot 中,最常见的紧耦合形式就是在代码库中随处直接访问全局自动加载项。

# BAD — tight coupling via direct autoload access scattered everywhere

# player.gd
func take_damage(amount: int) -> void:
    health -= amount
    AudioManager.play_sfx("hurt")          # hard dependency on AudioManager
    UIManager.update_health_bar(health)    # hard dependency on UIManager
    if health <= 0:
        GameState.record_death()           # hard dependency on GameState

# enemy.gd
func attack() -> void:
    AudioManager.play_sfx("attack")        # same AudioManager dependency again
// BAD — tight coupling via direct autoload / global access scattered everywhere

// Player.cs
public partial class Player : CharacterBody3D
{
    private int _health = 100;

    public void TakeDamage(int amount)
    {
        _health -= amount;
        GetNode<AudioManager>("/root/AudioManager").PlaySfx("hurt");        // hard dependency
        GetNode<UIManager>("/root/UIManager").UpdateHealthBar(_health);     // hard dependency
        if (_health <= 0)
            GetNode<GameState>("/root/GameState").RecordDeath();            // hard dependency
    }
}

// Enemy.cs
public partial class Enemy : CharacterBody3D
{
    public void Attack()
    {
        GetNode<AudioManager>("/root/AudioManager").PlaySfx("attack");      // same dependency again
    }
}

这种做法的问题:

  • 每个直接调用 AudioManager 的节点都与其具体实现耦合。
  • 将 AudioManager 换成其他实现,需要修改每个调用方。
  • 无法单独对 Player 进行单元测试——AudioManager、UIManager 和 GameState 都必须存在且有效。
  • 场景加载时,自动加载项的初始化顺序错误可能会悄无声息地破坏行为。
  • 隐藏的依赖项会让人难以看出类实际需要什么才能正常运行。

2. 方案对比

模式复杂度可测试性最适用场景
自动加载项低低真正的全局单例:音频、设置、平台服务
@export 注入低高大多数节点——在编辑器中连接依赖项,无需运行时查找
服务定位器中中插件、可选系统、运行时可替换的实现
场景注入低高父级到子级的连接:Level 设置 Enemy,HUD 设置子面板

3. 将自动加载项用作单例

在 项目设置 → 全局 → 自动加载 中注册脚本,即可全局访问(AudioManager.play_sfx(...)、GameState.score = 100)。最适用于横切关注点:音频、存档状态、事件总线、设置。不要轻易将特定领域的系统设为自动加载项(这类系统应通过场景注入)。

完整的 AudioManager 示例(音效 + 音乐交叉淡入淡出),包含 GDScript + C# 版本,请参阅 references/autoloads.md。


4. @export 节点注入

将协作节点公开为 @export var health_component: HealthComponent,然后在检查器中或通过父场景连接。生命周期:@export 属性会在 _ready() 之前赋值。

完整的 @export 模式(GDScript + C#)和生命周期说明,请参阅 references/export-injection.md。


5. 服务定位器模式

一个集中式注册表自动加载项,将 String 键映射到服务实例。服务在 _ready() 中注册自身,在 _exit_tree() 中注销;使用方调用 ServiceLocator.get_service(name)。适用于需要在运行时灵活替换实现的场景(测试、模组、A/B 变体)。

完整的服务定位器(GDScript + C#)和带类型的辅助方法,请参阅 references/service-locator.md。


6. 场景注入

父场景加载其子节点后,在 _ready() 中遍历场景树并赋值依赖项(enemy.player = $Player)。子节点声明 @export 属性,但由父级而非检查器设置。最适用于每个关卡中各不相同的游戏专属依赖项。

父级注入子级模式(GDScript + C#),请参阅 references/scene-injection.md。


7. 使用依赖注入进行测试

注入伪对象/测试替身,才能让节点可测试。对于自动加载项:在测试场景加载前,用模拟对象替换。对于 @export 注入:将导出属性替换为测试替身。对于服务定位器:在相同键下注册伪对象。

基于 GUT 的测试模式展示了各种注入技术,请参阅 references/testing-with-di.md。


8. 何时使用哪种方式

情况推荐模式
几乎每个场景中的每个节点都会使用的服务Autoload 单例
节点需要 1–3 个依赖,且场景由编辑器编写@export 注入
系统是可选的,或可在运行时替换Service Locator
父场景构建子节点并了解它们的需求场景注入
为具有外部依赖的节点编写测试@export 或属性注入 + stub
必须在任何项目中都能运行的插件Service Locator(自行注册,不作任何假设)
两个同级节点需要相同的依赖让它们的父节点持有该依赖并向下注入

快速决策指南:

Does every scene in the project need it?
  YES → Autoload singleton
  NO  ↓

Is the dependency known at edit-time and wired in the Inspector?
  YES → @export injection
  NO  ↓

Does the dependency need to be swapped at runtime (plugins, A/B testing)?
  YES → Service Locator
  NO  ↓

Does a parent scene own both the consumer and the dependency?
  YES → Scene injection
  NO  → Reconsider — either promote to autoload or restructure ownership

9. 反模式

所有情况都使用 Autoload
# BAD — GameManager, EnemySpawner, InventorySystem, DialogueSystem all as autoloads.
# Every node in the game is coupled to every other system at module level.
# Test one component → must initialise all autoloads.

# GOOD — Only AudioManager, Settings, and SceneTransition are autoloads.
# EnemySpawner is a node in the Level scene, injected into enemies that need it.
// BAD — GameManager, EnemySpawner, InventorySystem, DialogueSystem all as autoloads.
// Every node in the game is coupled to every other system at module level.
// Test one component → must initialise all autoloads.

// GOOD — Only AudioManager, Settings, and SceneTransition are autoloads.
// EnemySpawner is a node in the Level scene, injected into enemies that need it.
过深的依赖链
# BAD — Player needs HealthComponent, which needs AudioManager,
# which needs SoundBank, which needs FileSystem...
# A change deep in the chain breaks everything above it.

# GOOD — flatten: HealthComponent takes only AudioManager (or a narrow interface).
# Each node declares only immediate dependencies.
// BAD — Player needs HealthComponent, which needs AudioManager,
// which needs SoundBank, which needs FileSystem...
// A change deep in the chain breaks everything above it.

// GOOD — flatten: HealthComponent takes only AudioManager (or a narrow interface).
// Each node declares only immediate dependencies.
循环依赖
# BAD
# PlayerController._ready() calls ServiceLocator.get_service("inventory")
# InventorySystem._ready() calls ServiceLocator.get_service("player")
# Neither can fully initialise because the other isn't ready yet.

# GOOD — break the cycle with a signal.
# InventorySystem emits item_used; PlayerController connects to it.
# PlayerController never holds a reference to InventorySystem at all.
// BAD
// PlayerController._Ready() calls ServiceLocator.GetService("inventory")
// InventorySystem._Ready() calls ServiceLocator.GetService("player")
// Neither can fully initialise because the other isn't ready yet.

// GOOD — break the cycle with a signal.
// InventorySystem emits ItemUsed; PlayerController connects to it.
// PlayerController never holds a reference to InventorySystem at all.
将 Service Locator 当作万能对象
# BAD — everything is registered: enemies, UI panels, individual nodes.
# ServiceLocator becomes a second, untyped scene tree.

# GOOD — only register stable, long-lived services (audio, analytics, save system).
# Short-lived nodes are wired by their parent via scene injection.
// BAD — everything is registered: enemies, UI panels, individual nodes.
// ServiceLocator becomes a second, untyped scene tree.

// GOOD — only register stable, long-lived services (audio, analytics, save system).
// Short-lived nodes are wired by their parent via scene injection.
注入后忘记进行 null 检查
# BAD — crashes if the @export was never set in the editor
func take_damage(amount: int) -> void:
    audio.play_sfx("hurt")   # "Nonexistent function 'play_sfx' in base 'Nil'" if audio was not wired

# GOOD — guard or assert clearly
func take_damage(amount: int) -> void:
    assert(audio != null, "HealthComponent: audio dependency was not injected")
    audio.play_sfx("hurt")

# OR — treat it as optional
func take_damage(amount: int) -> void:
    if audio != null:
        audio.play_sfx("hurt")
// BAD — crashes if the [Export] was never set in the editor
public void TakeDamage(int amount)
{
    _audio.PlaySfx("hurt");   // NullReferenceException if _audio was not wired
}

// GOOD — guard or assert clearly
public void TakeDamage(int amount)
{
    if (_audio == null)
    {
        GD.PushError("HealthComponent: audio dependency was not injected");
        return;
    }
    _audio.PlaySfx("hurt");
}

// OR — treat it as optional
public void TakeDamage(int amount)
{
    _audio?.PlaySfx("hurt");
}

10. 检查清单

  • Autoload 仅用于真正的全局服务(音频、设置、平台)
  • 节点显式声明其依赖(@export 或公共属性),而不是对远处的节点调用 get_node
  • 使用前验证 @export 字段(通过 assert 或 null 检查)
  • Service Locator 服务在 _exit_tree() / _ExitTree() 中调用 unregister
  • 场景注入在父节点的 _ready() 中进行,确保子节点已完全初始化
  • 服务或 Autoload 之间不存在循环依赖
  • 每个节点仅依赖其直接协作者,不存在过深的依赖链
  • 测试 stub/mock 是实现了与真实服务相同接口的普通节点
  • 如果 C# @export([Export])依赖持有事件订阅,则在 _ExitTree() 中断开连接 / 清除
  • 不使用 Service Locator 存储特定场景或生命周期短暂的节点

评论

支持 Markdown