PAYATHON 2026

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 的范围。

备注:内容仅供参考。