Unity3D 进阶教程 - 02 依赖注入与可编写脚本对象

概述

入门系列中,我们习惯了直接在脚本中用 GetComponent<T>()FindObjectOfType<T>() 获取依赖。这种方式的问题在于 硬编码的依赖查找——被依赖对象的位置、创建方式发生变化时,所有引用处都要改。

依赖注入(Dependency Injection, DI) 将依赖的创建和查找从使用者中剥离,由外部统一注入。而 可编写脚本对象(ScriptableObject) 是 Unity 原生提供的一种轻量级 DI 和数据驱动工具。

1. 为什么需要依赖注入

1.1 紧耦合的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
// 传统写法:紧耦合
public class Player : MonoBehaviour
{
private AudioManager _audio;
private ScoreManager _score;

private void Awake()
{
// 隐式依赖:必须场景中有 AudioManager 和 ScoreManager
_audio = FindObjectOfType<AudioManager>();
_score = FindObjectOfType<ScoreManager>();
}
}

这些隐式依赖导致:

  • 测试时需要在场景中手动搭建完整环境
  • 重构时修改查找逻辑影响所有使用者
  • 依赖关系不明确,新人难以理解

1.2 DI 的核心原则

依赖倒置原则——高层模块不应依赖低层模块,两者都应依赖抽象。

  • 不要 new:让外部传入依赖
  • 不要找:让外部传入依赖
  • 不要问:让外部传入依赖

2. 构造器注入(Constructor Injection)

纯 C# 类可以通过构造器注入依赖,最适合非 MonoBehaviour 类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 定义抽象
public interface IInputService
{
Vector2 GetMovement();
bool IsJumpPressed();
}

public interface ISaveService
{
void Save(string key, object data);
T Load<T>(string key);
}

// 使用者只依赖抽象
public class PlayerController
{
private readonly IInputService _input;
private readonly ISaveService _save;

public PlayerController(IInputService input, ISaveService save)
{
_input = input;
_save = save;
}

public void Update()
{
var movement = _input.GetMovement();
// 处理移动...
}
}

2.1 在 Unity 中手动装配

1
2
3
4
5
6
7
8
9
public class GameBootstrap : MonoBehaviour
{
private void Awake()
{
var input = new UnityInputService();
var save = new JsonSaveService();
var controller = new PlayerController(input, save);
}
}

Bootstrapper 类作为组合根,负责创建和组装所有依赖。这是最简单的 DI 形式,无需任何框架。


3. 使用 VContainer 框架

当项目复杂后,手动装配变得繁琐。VContainer 是目前 Unity 生态中最主流的 DI 框架(轻量、高性能、支持多种注入方式)。

3.1 安装

通过 Package Manager 添加 VContainer:

1
https://github.com/hadashiA/VContainer.git?path=VContainer/Assets/VContainer

3.2 注册依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using VContainer;
using VContainer.Unity;

public class GameLifetimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
// 注册接口到实现
builder.Register<IInputService, UnityInputService>(Lifetime.Singleton);
builder.Register<ISaveService, JsonSaveService>(Lifetime.Singleton);

// 注册 MonoBehaviour(通过组件)
builder.RegisterComponentInHierarchy<Player>();

// 注册纯 C# 类
builder.Register<PlayerController>(Lifetime.Transient);
}
}

3.3 注入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 构造器注入
public class PlayerController
{
public PlayerController(IInputService input, ISaveService save) { }
}

// MonoBehaviour 属性注入
public class Player : MonoBehaviour
{
[Inject] private IInputService _input;
[Inject] private ISaveService _save;
}

// MonoBehaviour 方法注入
public class Player : MonoBehaviour
{
[Inject]
public void Construct(IInputService input, ISaveService save)
{
// 显式注入,比属性注入更清晰
}
}

3.4 生命周期管理

1
2
3
builder.Register<IAudioService, AudioService>(Lifetime.Singleton); // 单例,整个生命周期一个实例
builder.Register<IBullet, Bullet>(Lifetime.Transient); // 每次获取新建
builder.Register<IPooledObject, Bullet>(Lifetime.Scoped); // 作用域内单例

VContainer 在场景切换时会自动释放 MonoBehaviour 的注入,无需手动清理。


4. 可编写脚本对象(ScriptableObject)作为 DI 工具

ScriptableObject 是 Unity 提供的数据容器,但它的能力远不止存数据。利用 SO 可以实现 数据驱动的架构,这是 Unity 中最实用的轻量 DI 方案。

4.1 作为事件通道

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 定义 SO 事件通道
[CreateAssetMenu(menuName = "Events/IntEventChannel")]
public class IntEventChannelSO : ScriptableObject
{
public event System.Action<int> OnEventRaised;

public void Raise(int value)
{
OnEventRaised?.Invoke(value);
}
}

// 发布者
public class ScoreManager : MonoBehaviour
{
[SerializeField] private IntEventChannelSO _scoreChangedChannel;

public void AddScore(int amount)
{
// 更新分数...
_scoreChangedChannel.Raise(currentScore);
}
}

// 订阅者(完全不知道发布者是谁)
public class ScoreUI : MonoBehaviour
{
[SerializeField] private IntEventChannelSO _scoreChangedChannel;

private void OnEnable() => _scoreChangedChannel.OnEventRaised += UpdateUI;
private void OnDisable() => _scoreChangedChannel.OnEventRaised -= UpdateUI;

private void UpdateUI(int score)
{
_scoreText.text = score.ToString();
}
}

发布者和订阅者在 Inspector 中通过拖拽绑定,无需写代码关联。这是 Unity 的事件总线简化版,特别适合中小项目。

4.2 作为游戏配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[CreateAssetMenu(menuName = "Game/EnemyConfig")]
public class EnemyConfigSO : ScriptableObject
{
public string EnemyName;
public float MoveSpeed = 3f;
public int MaxHealth = 100;
public int Damage = 10;
public GameObject Prefab;
public AudioClip SpawnSound;
}

// 使用
public class EnemySpawner : MonoBehaviour
{
[SerializeField] private EnemyConfigSO _goblinConfig;
[SerializeField] private EnemyConfigSO _orcConfig;

public void SpawnGoblin()
{
var enemy = Instantiate(_goblinConfig.Prefab);
enemy.GetComponent<Enemy>().Init(_goblinConfig);
}
}

优势:

  • 策划可直接在 Inspector 中调整数值,无需修改代码
  • 支持变体(继承 SO),方便做不同类型的配置
  • 运行时热更新数值(编辑器模式下)

4.3 作为工厂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[CreateAssetMenu(menuName = "Game/EffectFactory")]
public class EffectFactorySO : ScriptableObject
{
[SerializeField] private GameObject _explosionPrefab;
[SerializeField] private GameObject _sparkPrefab;
[SerializeField] private GameObject _smokePrefab;

public GameObject Create(EffectType type, Vector3 position)
{
var prefab = type switch
{
EffectType.Explosion => _explosionPrefab,
EffectType.Spark => _sparkPrefab,
EffectType.Smoke => _smokePrefab,
_ => null
};
return prefab ? Instantiate(prefab, position, Quaternion.identity) : null;
}
}

4.4 作为运行时数据仓库

1
2
3
4
5
6
7
8
9
10
11
[CreateAssetMenu(menuName = "Game/PlayerInventory")]
public class PlayerInventorySO : ScriptableObject
{
public int Gold { get; private set; }
public List<Item> Items { get; private set; } = new();

public void AddGold(int amount)
{
Gold += amount;
}
}

SO 作为数据仓库的好处是:在编辑器中运行游戏时可以实时查看和修改数据,方便调试。


5. 三种 DI 方式对比

方式 适用场景 优势 劣势
手动构造器注入 小项目、纯 C# 库 无额外依赖,简单直接 项目大后管理复杂
VContainer 中大型项目 自动管理生命周期、高性能、支持多种注入 需要学习框架 API
ScriptableObject 数据驱动、配置管理 Unity 原生、图形化配置、可继承 运行时修改需序列化

混合使用建议

1
2
3
4
5
VContainer 负责:系统级依赖(服务、管理器、工厂)
ScriptableObject 负责:配置数据、事件通道、资源引用

// VContainer 注入 SO 配置
builder.RegisterInstance(_enemyConfig);

总结

依赖注入的核心思想是 控制反转——让使用者不再负责依赖的创建和查找。

  • 手动注入:适合小项目,理解 DI 本质
  • VContainer:中大型项目的标配,自动管理生命周期
  • ScriptableObject:Unity 原生的数据驱动方案,适合配置与事件通道

无论用哪种方式,目标是让代码变成这样:

1
2
3
4
5
6
7
8
// 依赖清晰可见,职责单一,易于测试
public class PlayerController
{
public PlayerController(IInputService input, IMovementService movement, IAnimationService animation)
{
// 所有依赖都在构造器中显式声明
}
}

下一篇将探讨 分层状态机与行为树,深入了解游戏 AI 架构。

ByteFisher
分享编程技术 · 记录钓鱼乐趣
扫码关注
▸ 扫码关注 ◂
分享: