Unity 中如何使用 Action 事件降低类之间的耦合?
可以直接依赖,是否抽象取决于使用需求
让 HealthController 订阅 ClickController 的事件是合理的。事件机制使 ClickController 不需要知道哪些类在监听,也不必直接调用 HealthController。
订阅端仍然依赖事件发布者,原代码有两处这样的依赖:
- 通过
ClickController.OnClickControllerEvent找到事件。 - 通过
TakeDamage(ClickController clickController)接收具体类型的参数。
如果这两个组件只在一个简单场景中配合使用,可以保留直接订阅,先去掉未使用的事件参数。如果后续需要替换点击来源,或让同一个生命值组件接入不同事件源,再引入接口会更有价值。
先移除没有使用的事件参数
原来的 TakeDamage 没有使用 clickController 参数,可以将事件改为无参数的 Action:
public static event Action OnClickControllerEvent;
对应的发布代码改为:
OnClickControllerEvent?.Invoke();
处理方法也改成无参数形式:
private void TakeDamage()
{
health -= 10;
if (health <= 0)
Debug.Log("I'm dead now! :(");
}
使用 Action 的脚本需要引入 using System;。订阅和取消订阅的写法不变。
这样可以去掉事件参数中的具体类型依赖,但 HealthController 仍然通过 ClickController 访问静态事件。
需要替换事件源时,使用接口和实例事件
接口可以描述一个对象能够提供点击通知。这里使用实例事件,让 HealthController 依赖 IClickSource。
1. 定义事件源接口
创建 IClickSource.cs:
using System;
public interface IClickSource
{
event Action Clicked;
}
接口只声明事件契约。事件由实现类保存,并在实现类内部触发。
不需要把静态事件搬进接口。原来的调用方式 ClickController.OnClickControllerEvent 仍然会引用具体类;改用实例接口后,需要给订阅者提供一个具体的事件源对象。
2. 让 ClickController 实现接口
创建或修改 ClickController.cs:
using System;
using UnityEngine;
public class ClickController : MonoBehaviour, IClickSource
{
public event Action Clicked;
private void Update()
{
if (Input.GetMouseButtonDown(0))
OnLeftClick();
}
private void OnLeftClick()
{
Camera mainCamera = Camera.main;
if (mainCamera == null)
return;
Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit))
{
Debug.Log("The cube was clicked! Notifying subscribers!");
Clicked?.Invoke();
}
}
}
每个 ClickController 实例都有自己的事件。HealthController 可以订阅指定实例,而不必监听所有发布者共用的静态事件。
3. 让 HealthController 订阅接口
下面通过 Inspector 指定事件源组件,再将它转换为 IClickSource:
using UnityEngine;
public class HealthController : MonoBehaviour
{
[SerializeField] private MonoBehaviour clickSourceComponent;
private IClickSource clickSource;
private int health = 100;
private void OnEnable()
{
clickSource = clickSourceComponent as IClickSource;
if (clickSource == null)
{
Debug.LogError(
"Assign a component that implements IClickSource.",
this);
return;
}
clickSource.Clicked += TakeDamage;
}
private void OnDisable()
{
if (clickSource != null)
{
clickSource.Clicked -= TakeDamage;
clickSource = null;
}
}
private void TakeDamage()
{
health -= 10;
if (health <= 0)
Debug.Log("I'm dead now! :(");
}
}
在 Inspector 中,将实现了 IClickSource 的 ClickController 组件拖入 clickSourceComponent 字段。
这里使用 MonoBehaviour 字段,是为了通过 Unity 的常规 Inspector 引用组件。普通接口字段不能仅靠添加 [SerializeField] 就按这种方式直接配置组件引用,因此示例会在运行时检查该组件是否实现接口。
完成连接后,HealthController 的代码不再引用 ClickController。其他组件只要实现 IClickSource,也可以作为事件源。具体组件由场景配置连接。
事件范围和生命周期需要单独处理
在 OnEnable 中订阅、OnDisable 中取消订阅,适合组件启用时监听、禁用时停止监听的需求。原代码在这点上已经处理正确,使用静态事件时尤其要保留取消订阅逻辑。
还需要注意原代码的实际行为:
Physics.Raycast(ray, out hit)没有检查命中的对象是否为指定 cube。日志中的文字不会限制命中对象;只要射线命中符合查询条件的 Collider,就会发布事件。- 静态事件会通知所有已订阅的
HealthController。改为实例事件后,同一事件源的所有订阅者仍然都会收到通知。如果只应扣除被点击对象的生命值,还需要根据hit识别目标并设计通知范围。 - 示例通过 Inspector 预先指定事件源。如果运行时更换事件源,需要先取消订阅旧对象,再订阅新对象。
- 原来的
minHealth和maxHealth只是字段,没有参与生命值计算,因此不会自动限制health的范围。
备注:内容仅供参考。
