关于 dependency-injection
面向 Godot 4.3+ 项目的依赖注入技能,帮助开发者降低节点耦合并改善测试能力。内容对比 Autoload、@export 注入、Service Locator 和场景注入的适用场景。
主要功能
- 01比较四种依赖注入模式
- 02通过 @export 显式声明依赖
- 03用 Service Locator 管理可替换服务
- 04从父场景向子节点注入依赖
- 05使用替身和模拟对象测试节点
适用场景
- 01隔离节点以便单独测试
- 02按关卡配置不同的游戏依赖
- 03在运行时切换服务实现
- 04减少 Autoload 造成的系统耦合
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ jame581/ godotprompter --skill dependency-injection安装后在 Claude Code 中输入/dependency-injection确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/autoloads.mdexport-injection.mdscene-injection.mdservice-locator.mdtesting-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 存储特定场景或生命周期短暂的节点