Skip to content

委托底层

委托底层保存了什么?

一句话讲清楚

委托底层可以理解为一个对象,它主要保存:目标对象引用 target方法入口 method。如果是多播委托,还会保存一个调用列表 invocation list

delegate-internals

实例方法委托保存什么

c
using System; // 引入 Action 委托
public sealed class Player // 定义玩家类
{ // 类开始
    public void Attack() // 定义攻击方法
    { // 方法开始
        Console.WriteLine("Attack"); // 输出攻击文本
    } // 方法结束
} // 类结束
public static class DelegateExample // 定义委托示例类
{ // 类开始
    public static void Run() // 定义运行方法
    { // 方法开始
        Player player = new Player(); // 创建玩家对象
        Action action = player.Attack; // 创建委托,保存 player 对象引用和 Attack 方法信息
        action(); // 调用委托,相当于调用 player.Attack()
    } // 方法结束
} // 类结束

这里 action 不是只保存 Attack 这个方法名。 它还要知道这个方法属于哪个对象。

所以它大概保存:

target = playermethod = Player.Attack

调用时就能找到:对 player 调用 Attack

静态方法委托保存什么

c
using System; // 引入 Action 委托
public static class BattleLog // 定义战斗日志类
{ // 类开始
    public static void Print() // 定义静态打印方法
    { // 方法开始
        Console.WriteLine("Battle Log"); // 输出战斗日志
    } // 方法结束
} // 类结束
public static class StaticDelegateExample // 定义静态委托示例类
{ // 类开始
    public static void Run() // 定义运行方法
    { // 方法开始
        Action action = BattleLog.Print; // 创建静态方法委托,不需要目标对象
        action(); // 调用委托,相当于调用 BattleLog.Print()
    } // 方法结束
} // 类结束

静态方法不属于某个实例对象,所以可以理解为:

target = nullmethod = BattleLog.Print

多播委托保存什么

多播委托就是一个委托里保存多个调用目标。

c
using System; // 引入 Action 委托
public static class MulticastDelegateExample // 定义多播委托示例类
{ // 类开始
    public static void Run() // 定义运行方法
    { // 方法开始
        Action action = null; // 定义一个空委托变量
        action += StepA; // 把 StepA 加入调用列表
        action += StepB; // 把 StepB 加入调用列表
        action(); // 按加入顺序依次调用 StepA 和 StepB
    } // 方法结束
    private static void StepA() // 定义第一个方法
    { // 方法开始
        Console.WriteLine("A"); // 输出 A
    } // 方法结束
    private static void StepB() // 定义第二个方法
    { // 方法开始
        Console.WriteLine("B"); // 输出 B
    } // 方法结束
} // 类结束

这时候委托内部大概保存:

c
invocation list = [StepA, StepB]

调用 action() 时,会按顺序调用列表里的方法。

委托还有几个重点

委托有类型签名。 比如 Action<int> 只能指向参数是 int、返回值是 void 的方法。

委托是引用类型。 它本身是对象,不是普通函数指针。

委托通常是不可变的。 +=-= 看起来像修改原委托,实际常常是生成一个新的委托对象,再把变量指向新对象。

委托可能持有对象引用。 如果事件订阅了某个对象的方法,而没有取消订阅,委托的调用列表可能一直引用这个对象,导致对象不能被 GC 回收。

面试高分回答

NOTE

委托底层本质是一个对象。对于实例方法委托,它会保存目标对象引用和方法入口信息,所以调用委托时能知道要对哪个对象调用哪个方法。对于静态方法委托,因为不需要实例对象,可以理解为 target 为空,只保存方法入口。多播委托还会维护一个 invocation list,里面保存多个委托项,调用时按顺序依次执行。委托是引用类型,并且通常是不可变的,+=-= 会生成新的委托对象。事件内存泄漏也和这个有关,因为委托调用列表会持有订阅对象的方法引用。

委托调用和普通函数调用有什么区别?

一句话讲清楚

普通函数调用是直接调用某个明确的方法;委托调用是先通过委托对象找到它保存的 targetmethod,再间接调用。普通调用更直接,委托调用更灵活。

delegate-vs-direct-call

普通函数调用

c
using System; // 引入 Console 类型
public sealed class Player // 定义玩家类
{ // 类开始
    public void Attack() // 定义攻击方法
    { // 方法开始
        Console.WriteLine("Attack"); // 输出攻击文本
    } // 方法结束
} // 类结束
public static class DirectCallExample // 定义普通调用示例类
{ // 类开始
    public static void Run() // 定义运行方法
    { // 方法开始
        Player player = new Player(); // 创建玩家对象
        player.Attack(); // 直接调用 player 对象的 Attack 方法
    } // 方法结束
} // 类结束

这种调用很明确:就是 player.Attack()。 编译器和运行时都比较容易知道目标方法是谁,调用路径短,也更容易优化。

委托调用

c
using System; // 引入 Action 委托
public static class DelegateCallExample // 定义委托调用示例类
{ // 类开始
    public static void Run() // 定义运行方法
    { // 方法开始
        Player player = new Player(); // 创建玩家对象
        Action action = player.Attack; // 创建委托,保存 player 对象和 Attack 方法
        action(); // 调用委托,间接调用 player.Attack()
    } // 方法结束
} // 类结束

这里的 action 底层保存了:

target = playermethod = Attack

调用 action() 时,会先通过委托找到目标对象和方法,再执行。

核心区别

对比点普通函数调用委托调用
调用目标代码里直接写死委托对象里保存
调用路径更直接多一层间接调用
灵活性较低高,可以把方法当参数传
性能通常更快一点有轻微额外开销
多播不支持支持 += 多个方法
常用场景固定逻辑调用回调、事件、策略、通知

委托的优势

委托可以把方法当成参数传递。

c
using System; // 引入 Action 委托
public static class SkillSystem // 定义技能系统
{ // 类开始
    public static void PlaySkill(Action onFinished) // 播放技能,并接收技能结束回调
    { // 方法开始
        Console.WriteLine("Play Skill"); // 输出技能播放文本
        onFinished?.Invoke(); // 如果回调不为空,就调用回调
    } // 方法结束
} // 类结束

调用方可以决定技能结束后做什么:

c
using System; // 引入 Console 类型
public static class SkillUseExample // 定义技能使用示例类
{ // 类开始
    public static void Run() // 定义运行方法
    { // 方法开始
        SkillSystem.PlaySkill(OnSkillFinished); // 把 OnSkillFinished 方法作为回调传进去
    } // 方法结束
    private static void OnSkillFinished() // 定义技能结束回调
    { // 方法开始
        Console.WriteLine("Skill Finished"); // 输出技能结束文本
    } // 方法结束
} // 类结束

这样 SkillSystem 不需要知道外部具体要做什么,只负责在结束时调用委托。 这就是解耦。

面试高分回答

TIP

普通函数调用是直接调用明确的方法,调用路径短,性能通常更好,也更容易被 JIT 优化。委托调用本质是通过一个委托对象间接调用,委托内部保存目标对象和方法信息,调用时先找到 target 和 method 再执行,因此会多一点间接调用开销。但委托的优势是灵活,可以把方法作为参数传递,支持回调、事件、多播和策略替换。实际项目里,固定逻辑和热路径可以优先普通调用;事件通知、按钮点击、异步完成、技能结束回调这类解耦场景适合用委托。

多播委托调用顺序是什么?

一句话讲清楚

多播委托按调用列表 invocation list 的顺序执行。一般来说,谁先 += 进去,谁就先执行;后 += 的后执行。

multicast-delegate-order

基础例子

c
using System; // 引入 Action 和 Console
public static class MulticastOrderExample // 定义多播委托顺序示例类
{ // 类开始
    public static void Run() // 定义运行方法
    { // 方法开始
        Action action = null; // 定义一个空委托变量
        action += A; // 把 A 方法追加到调用列表第一个位置
        action += B; // 把 B 方法追加到调用列表第二个位置
        action += C; // 把 C 方法追加到调用列表第三个位置
        action(); // 按调用列表顺序执行,输出 A、B、C
    } // 方法结束
    private static void A() // 定义 A 方法
    { // 方法开始
        Console.WriteLine("A"); // 输出 A
    } // 方法结束
    private static void B() // 定义 B 方法
    { // 方法开始
        Console.WriteLine("B"); // 输出 B
    } // 方法结束
    private static void C() // 定义 C 方法
    { // 方法开始
        Console.WriteLine("C"); // 输出 C
    } // 方法结束
} // 类结束

输出顺序是:

c
A
B
C

如果有返回值怎么办

如果多播委托有返回值,前面方法的返回值会被后面覆盖,最终你拿到的是最后一个方法的返回值

c
using System; // 引入 Func 和 Console
public static class MulticastReturnExample // 定义多播委托返回值示例类
{ // 类开始
    public static void Run() // 定义运行方法
    { // 方法开始
        Func<int> func = null; // 定义一个返回 int 的委托
        func += ReturnOne; // 添加第一个返回方法
        func += ReturnTwo; // 添加第二个返回方法
        int result = func(); // 两个方法都会执行,但 result 接收最后一个方法的返回值
        Console.WriteLine(result); // 输出 2
    } // 方法结束
    private static int ReturnOne() // 定义返回 1 的方法
    { // 方法开始
        return 1; // 返回 1
    } // 方法结束
    private static int ReturnTwo() // 定义返回 2 的方法
    { // 方法开始
        return 2; // 返回 2
    } // 方法结束
} // 类结束

所以多播委托更适合 void 返回值,比如事件通知。

如果中间抛异常怎么办

默认情况下,如果调用列表中某个方法抛异常,后面的委托就不会继续执行。

c
using System; // 引入 Action 和 Console
public static class MulticastExceptionExample // 定义异常中断示例类
{ // 类开始
    public static void Run() // 定义运行方法
    { // 方法开始
        Action action = null; // 定义空委托
        action += A; // 添加 A
        action += ThrowError; // 添加会抛异常的方法
        action += C; // 添加 C
        action(); // 执行到 ThrowError 时抛异常,C 默认不会执行
    } // 方法结束
    private static void A() // 定义 A 方法
    { // 方法开始
        Console.WriteLine("A"); // 输出 A
    } // 方法结束
    private static void ThrowError() // 定义抛异常方法
    { // 方法开始
        throw new Exception("Error"); // 抛出异常
    } // 方法结束
    private static void C() // 定义 C 方法
    { // 方法开始
        Console.WriteLine("C"); // 如果前面异常没处理,这里不会执行
    } // 方法结束
} // 类结束

如果你希望一个监听者失败不影响其他监听者,可以手动遍历调用列表。

c
using System; // 引入 Action、Delegate 和 Exception
public static class SafeMulticastExample // 定义安全多播调用示例类
{ // 类开始
    public static void SafeInvoke(Action action) // 安全调用多播委托
    { // 方法开始
        if (action == null) return; // 如果委托为空就直接返回
        Delegate[] delegates = action.GetInvocationList(); // 获取多播委托的调用列表
        for (int i = 0; i < delegates.Length; i++) // 遍历调用列表
        { // for 开始
            Action item = (Action)delegates[i]; // 把当前委托项转成 Action
            try // 尝试调用当前委托项
            { // try 开始
                item(); // 调用当前方法
            } // try 结束
            catch (Exception exception) // 捕获当前方法抛出的异常
            { // catch 开始
                Console.WriteLine(exception.Message); // 输出异常信息,避免影响后续方法
            } // catch 结束
        } // for 结束
    } // 方法结束
} // 类结束

关于 -=

-= 会从调用列表里移除匹配的方法。 如果同一个方法加了多次,通常移除最后一个匹配项。

c
using System; // 引入 Action 和 Console
public static class MulticastRemoveExample // 定义移除委托示例类
{ // 类开始
    public static void Run() // 定义运行方法
    { // 方法开始
        Action action = null; // 定义空委托
        action += A; // 添加 A
        action += B; // 添加 B
        action += B; // 再添加一次 B
        action -= B; // 移除一个匹配的 B,通常移除最后加入的那个 B
        action(); // 执行 A 和剩下的一个 B
    } // 方法结束
    private static void A() // 定义 A 方法
    { // 方法开始
        Console.WriteLine("A"); // 输出 A
    } // 方法结束
    private static void B() // 定义 B 方法
    { // 方法开始
        Console.WriteLine("B"); // 输出 B
    } // 方法结束
} // 类结束

WARNING

面试高分回答

多播委托内部维护一个调用列表,+= 会把方法追加到列表末尾,调用时按列表顺序从前往后执行。-= 会从调用列表中移除匹配的方法,多个相同方法时通常移除最后一个匹配项。如果多播委托有返回值,最终拿到的是最后一个方法的返回值;如果中间某个方法抛异常,后续方法默认不会继续执行。需要保证每个监听者互不影响时,可以用 GetInvocationList() 手动遍历并单独 try catch

多播委托有返回值时返回谁的结果?

一句话答案: 多播委托有返回值时,所有绑定的方法都会按顺序执行,但最终返回值是最后一个方法的返回值,前面方法的返回值会被覆盖掉。

multicast-delegate-return-value

代码示例:

c
using System; // 引入 Console 和 Func

public static class MulticastReturnValueExample // 定义多播委托返回值示例类
{ // 类开始
    public static void Run() // 定义运行方法
    { // 方法开始
        Func<int> func = null; // 定义一个返回 int 的委托变量
        func += ReturnOne; // 把 ReturnOne 加入委托调用列表
        func += ReturnTwo; // 把 ReturnTwo 加入委托调用列表
        func += ReturnThree; // 把 ReturnThree 加入委托调用列表

        int result = func(); // 三个方法都会执行,但 result 只拿到最后一个方法的返回值

        Console.WriteLine(result); // 输出 3
    } // 方法结束

    private static int ReturnOne() // 定义第一个方法
    { // 方法开始
        Console.WriteLine("ReturnOne"); // 输出第一个方法被调用
        return 1; // 返回 1,但这个值会被后面的返回值覆盖
    } // 方法结束

    private static int ReturnTwo() // 定义第二个方法
    { // 方法开始
        Console.WriteLine("ReturnTwo"); // 输出第二个方法被调用
        return 2; // 返回 2,但这个值会被后面的返回值覆盖
    } // 方法结束

    private static int ReturnThree() // 定义第三个方法
    { // 方法开始
        Console.WriteLine("ReturnThree"); // 输出第三个方法被调用
        return 3; // 返回 3,最终 func() 拿到的就是它
    } // 方法结束
} // 类结束

输出结果:

c
ReturnOne
ReturnTwo
ReturnThree
3

IMPORTANT

面试高分回答: 多播委托内部维护的是一个调用列表,调用时会按订阅顺序依次执行。对于 void 委托,通常只关心所有方法是否执行;但如果委托有返回值,普通调用只能拿到最后一个委托项的返回值。如果我想拿到每一个方法的返回值,就不能直接 func(),而应该用 GetInvocationList() 手动遍历调用列表。另外,如果中间某个方法抛异常,后面的委托项默认不会继续执行,除非我自己逐个调用并做 try-catch

委托为什么可能导致对象无法释放?

一句话答案: 委托可能导致对象无法释放,是因为委托会保存实例方法所属对象的强引用。如果一个长生命周期对象,比如单例、静态事件、全局事件中心,持有了某个短生命周期对象的方法委托,而短生命周期对象没有取消订阅,那么 GC 会认为它还“有人引用”,就不会回收它。

delegate-keeps-object-alive

底层怎么理解: C# 的委托不是简单的“函数地址”。对于实例方法委托,它内部至少会保存两类信息:

Target:方法属于哪个对象。 Method:具体要调用哪个方法。

比如:

c
button.onClick += panel.OnClick;

这不是只保存 OnClick 这个方法,还会保存 panel 这个对象引用。只要 button.onClick 还持有这个委托,panel 就可能无法被 GC 回收。

Unity 里最常见的坑:

c
using System; // 引入 Action 委托类型
using UnityEngine; // 引入 Unity 的 MonoBehaviour 类型

public static class GameEvents // 定义一个全局事件类
{ // 类开始
    public static event Action OnGoldChanged; // 定义一个静态事件,它的生命周期通常很长
    public static void RaiseGoldChanged() // 定义触发事件的方法
    { // 方法开始
        OnGoldChanged?.Invoke(); // 如果事件不为空,就调用所有订阅者
    } // 方法结束
} // 类结束

public class BagPanel : MonoBehaviour // 定义背包面板脚本
{ // 类开始
    private void OnEnable() // 当面板启用时调用
    { // 方法开始
        GameEvents.OnGoldChanged += RefreshUI; // 订阅全局事件,此时事件会持有当前 BagPanel 对象
    } // 方法结束

    private void OnDisable() // 当面板禁用时调用
    { // 方法开始
        GameEvents.OnGoldChanged -= RefreshUI; // 取消订阅,断开全局事件对当前对象的引用
    } // 方法结束

    private void RefreshUI() // 定义刷新 UI 的实例方法
    { // 方法开始
        Debug.Log("刷新背包金币显示"); // 输出刷新日志
    } // 方法结束
} // 类结束

为什么 OnDisable 里要取消订阅? 因为 GameEvents.OnGoldChanged 是静态事件,它属于全局级别,通常会一直活着。BagPanel 关闭后,如果没有 -= RefreshUI,静态事件还持有 BagPanel.RefreshUI,也就间接持有了 BagPanel 对象,导致面板对象无法释放。

IMPORTANT

面试高分回答: 委托内部会保存调用目标和方法信息,尤其是订阅实例方法时,委托会持有目标对象的强引用。如果一个生命周期更长的对象,比如静态事件、单例、事件总线,持有短生命周期对象的委托,而短生命周期对象没有取消订阅,那么 GC 会认为这个对象仍然可达,所以不会回收,最终可能造成内存泄漏。在 Unity 里我一般遵守“谁订阅,谁取消”的原则,常见做法是在 OnEnable 订阅,在 OnDisableOnDestroy 取消订阅。尤其要小心静态事件、全局事件中心和匿名 Lambda,因为它们很容易忘记解绑。

事件如何限制外部直接赋值?

一句话答案: C# 用 event 关键字限制外部直接赋值。外部只能对事件使用 +=-=,不能使用 = 覆盖,也不能直接 Invoke() 触发事件。

event-restrict-direct-assignment

为什么普通委托字段危险?

如果你这样写:

c
using System; // 引入 Action 委托类型

public class Enemy // 定义敌人类
{ // 类开始
    public Action OnDead; // 定义 public 委托字段,外部可以直接访问和修改
} // 类结束

外部就可以这样做:

c
enemy.OnDead += ShowReward; // 可以订阅死亡回调
enemy.OnDead = null; // 也可以直接清空所有订阅者,非常危险
enemy.OnDead(); // 甚至可以从外部直接触发死亡事件,也很危险

问题是:OnDead 是一个 public 字段,别人拿到的是字段本身,所以可以随便改。

event 后怎么限制?

c
using System; // 引入 Action 委托类型

public class Enemy // 定义敌人类
{ // 类开始
    public event Action OnDead; // 定义事件,外部只能 += 和 -=

    public void Die() // 定义敌人死亡方法
    { // 方法开始
        OnDead?.Invoke(); // 只有 Enemy 类内部可以触发事件
    } // 方法结束
} // 类结束

外部只能这样:

c
enemy.OnDead += ShowReward; // 允许:订阅事件
enemy.OnDead -= ShowReward; // 允许:取消订阅事件

外部不能这样:

c
enemy.OnDead = null; // 编译错误:外部不能直接覆盖事件
enemy.OnDead(); // 编译错误:外部不能直接触发事件

底层理解:event 本质上是给委托加了一层访问控制。它不是让委托变成另一种东西,而是限制外部访问权限。

可以粗略理解成:

c
using System; // 引入 Action 委托类型

public class Enemy // 定义敌人类
{ // 类开始
    private Action onDead; // 私有委托字段,外部不能直接访问

    public event Action OnDead // 对外暴露事件入口
    { // 事件开始
        add // 外部使用 += 时,会进入 add
        { // add 开始
            onDead += value; // 把外部传入的方法加入调用列表
        } // add 结束
        remove // 外部使用 -= 时,会进入 remove
        { // remove 开始
            onDead -= value; // 把外部传入的方法移出调用列表
        } // remove 结束
    } // 事件结束

    public void Die() // 定义死亡方法
    { // 方法开始
        onDead?.Invoke(); // 类内部通过私有委托字段触发事件
    } // 方法结束
} // 类结束

IMPORTANT

面试高分回答: 普通 public delegate 字段会把委托本身暴露出去,外部既可以 +=,也可以 = 覆盖,甚至可以直接调用,这会破坏封装。而 event 是 C# 对委托的封装,它限制外部只能订阅和取消订阅,也就是只能使用 +=-=。事件的触发权保留在声明事件的类内部,这样可以避免外部随意清空订阅者或越权触发事件。

event ActionAction 字段区别是什么?

一句话答案:Action 字段是把委托本身暴露出去;event Action 是把委托包装成事件,外部只能 += 订阅和 -= 取消订阅,不能 = 覆盖,也不能直接触发。

event-action-vs-action-field

先看 Action 字段:

c
using System; // 引入 Action 委托类型

public class Enemy // 定义敌人类
{ // 类开始
    public Action OnDead; // 定义 public 委托字段,外部可以完全控制它
} // 类结束

外部可以这样操作:

c
enemy.OnDead += ShowReward; // 可以添加一个回调方法
enemy.OnDead -= ShowReward; // 可以移除一个回调方法
enemy.OnDead = null; // 可以直接清空所有回调,这很危险
enemy.OnDead?.Invoke(); // 可以从外部直接触发回调,这也很危险

问题在于:public Action OnDead 是一个公开字段,别人拿到的是委托变量本身,所以别人可以随便赋值、清空、调用。

再看 event Action

c
using System; // 引入 Action 委托类型

public class Enemy // 定义敌人类
{ // 类开始
    public event Action OnDead; // 定义事件,外部只能订阅和取消订阅

    public void Die() // 定义敌人死亡方法
    { // 方法开始
        OnDead?.Invoke(); // 只有 Enemy 类内部可以触发事件
    } // 方法结束
} // 类结束

外部只能这样:

c
enemy.OnDead += ShowReward; // 允许:订阅事件
enemy.OnDead -= ShowReward; // 允许:取消订阅事件

外部不能这样:

c
enemy.OnDead = null; // 编译错误:外部不能直接覆盖事件
enemy.OnDead?.Invoke(); // 编译错误:外部不能直接触发事件

底层理解:event Action 底层依然是委托,只是 C# 编译器给它加了一层访问限制。可以粗略理解成:

c
using System; // 引入 Action 委托类型

public class Enemy // 定义敌人类
{ // 类开始
    private Action onDead; // 真正保存回调列表的私有委托字段

    public event Action OnDead // 对外暴露事件
    { // 事件开始
        add // 外部使用 += 时进入这里
        { // add 开始
            onDead += value; // 把外部传进来的方法加入调用列表
        } // add 结束
        remove // 外部使用 -= 时进入这里
        { // remove 开始
            onDead -= value; // 把外部传进来的方法移出调用列表
        } // remove 结束
    } // 事件结束

    public void Die() // 定义死亡方法
    { // 方法开始
        onDead?.Invoke(); // 类内部统一触发事件
    } // 方法结束
} // 类结束

CAUTION

面试高分回答:Action 是委托类型,如果把它作为 public 字段暴露出去,外部代码可以直接赋值、清空和调用,这会破坏封装,甚至可能把其他订阅者都清掉。而 event Action 是事件,它底层仍然依赖委托,但 C# 限制了外部访问权限:外部只能 +=-=,不能直接 =,也不能直接 Invoke。所以事件更适合表达“我只允许别人订阅通知,不允许别人控制通知本身”。

匿名函数取消订阅为什么容易失败?

一句话答案: 匿名函数取消订阅容易失败,是因为你 += 时创建了一个委托对象,-= 时重新写一遍 Lambda 又创建了另一个委托对象。两个看起来一样,但通常不是同一个,所以移除不到原来的订阅。

anonymous-unsubscribe-fails

错误写法:

c
using System; // 引入 Action 委托类型

public class ButtonExample // 定义按钮示例类
{ // 类开始
    public event Action OnClick; // 定义点击事件

    public void Bind() // 定义绑定方法
    { // 方法开始
        OnClick += () => Console.WriteLine("点击按钮"); // 订阅时创建了一个匿名函数委托对象 A
    } // 方法结束

    public void Unbind() // 定义解绑方法
    { // 方法开始
        OnClick -= () => Console.WriteLine("点击按钮"); // 解绑时又创建了另一个匿名函数委托对象 B
    } // 方法结束
} // 类结束

这段代码看起来像是“订阅什么就取消什么”,但实际上不是。

+= () => Console.WriteLine("点击按钮") 创建的是委托 A。 -= () => Console.WriteLine("点击按钮") 创建的是委托 B。

A 和 B 不是同一个委托项,所以取消订阅失败,原来的匿名函数还留在事件列表里。

正确写法一:用命名方法

c
using System; // 引入 Action 委托类型

public class ButtonExample // 定义按钮示例类
{ // 类开始
    public event Action OnClick; // 定义点击事件

    public void Bind() // 定义绑定方法
    { // 方法开始
        OnClick += HandleClick; // 使用命名方法订阅事件
    } // 方法结束

    public void Unbind() // 定义解绑方法
    { // 方法开始
        OnClick -= HandleClick; // 使用同一个命名方法取消订阅
    } // 方法结束

    private void HandleClick() // 定义点击处理方法
    { // 方法开始
        Console.WriteLine("点击按钮"); // 输出点击信息
    } // 方法结束
} // 类结束

正确写法二:保存匿名函数引用

c
using System; // 引入 Action 委托类型

public class ButtonExample // 定义按钮示例类
{ // 类开始
    public event Action OnClick; // 定义点击事件
    private Action clickHandler; // 保存匿名函数对应的委托引用

    public void Bind() // 定义绑定方法
    { // 方法开始
        clickHandler = () => Console.WriteLine("点击按钮"); // 创建匿名函数并保存到字段
        OnClick += clickHandler; // 使用保存好的委托引用订阅事件
    } // 方法结束

    public void Unbind() // 定义解绑方法
    { // 方法开始
        OnClick -= clickHandler; // 使用同一个委托引用取消订阅
        clickHandler = null; // 解绑后清空字段引用
    } // 方法结束
} // 类结束

底层理解: 委托移除不是按“代码长得像不像”来判断,而是按调用列表里的委托项是否匹配来判断。一个委托项大概可以理解成:

c
目标对象 Target + 方法 Method

匿名函数每写一次,编译器都可能生成新的方法或新的闭包对象。尤其是 Lambda 捕获外部变量时,还会生成闭包对象,所以更容易导致取消订阅失败。

TIP

面试高分回答: 匿名函数取消订阅容易失败,是因为订阅和取消订阅必须使用同一个委托实例,或者至少能匹配到同一个目标对象和方法。+= () => ...-= () => ... 虽然代码看起来一样,但通常是两个不同的匿名函数委托,事件的调用列表里找不到后者对应的项,所以原来的订阅不会被移除。在 Unity 里这会导致对象关闭后仍然收到事件,甚至因为事件持有对象引用而无法被 GC 回收。实际开发中我会优先用命名方法订阅,或者把 Lambda 保存到字段里,再用同一个字段取消订阅。

UnityEvent 和 C# event 区别是什么?

一句话答案:C# event 是语言层的事件封装,适合代码内部通信;UnityEvent 是 Unity 提供的可序列化事件,能在 Inspector 里拖对象、绑定方法,适合低频、可配置的交互。

unityevent-vs-csharp-event

核心区别:

对比点C# eventUnityEvent
本质C# 语言特性,基于委托UnityEngine.Events 里的可序列化事件类
Inspector不能直接显示可以显示并配置
外部权限外部只能 +=-=如果公开字段,外部可以 Invoke()
性能更轻量更重一些
适合场景高频逻辑、系统通信、战斗事件UI 按钮、关卡机关、策划配置
返回值可以基于有返回值的委托,但事件通常不用返回值通常是 void,不适合返回结果
风险忘记取消订阅会泄漏Inspector 绑定丢失、方法改名失效、运行时监听未移除

C# event 示例:

c
using System; // 引入 Action 委托类型
using UnityEngine; // 引入 Unity 的 MonoBehaviour 类型

public class Enemy : MonoBehaviour // 定义敌人类
{ // 类开始
    public event Action OnDead; // 定义 C# event,外部只能订阅和取消订阅

    public void Die() // 定义死亡方法
    { // 方法开始
        OnDead?.Invoke(); // 只有 Enemy 类内部可以触发事件
    } // 方法结束
} // 类结束

外部可以:

c
enemy.OnDead += HandleEnemyDead; // 可以订阅事件
enemy.OnDead -= HandleEnemyDead; // 可以取消订阅事件

外部不可以:

c
enemy.OnDead = null; // 编译错误,外部不能清空事件
enemy.OnDead?.Invoke(); // 编译错误,外部不能触发事件

UnityEvent 示例:

c
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.Events; // 引入 UnityEvent 类型

public class Door : MonoBehaviour // 定义门脚本
{ // 类开始
    [SerializeField] private UnityEvent onOpened; // 序列化 UnityEvent,让它显示在 Inspector 里

    public void Open() // 定义开门方法
    { // 方法开始
        onOpened.Invoke(); // 触发 Inspector 中绑定的所有回调
    } // 方法结束
} // 类结束

这个 onOpened 可以在 Inspector 里拖入对象,然后选择方法,比如播放音效、打开特效、触发机关。

IMPORTANT

面试高分回答:C# event 更像是代码层的事件封装,它底层是委托,但限制外部只能 +=-=,不能直接赋值或触发,所以封装性更好,性能也更轻量,适合战斗、状态变化、系统间通信这类高频逻辑。UnityEvent 是 Unity 的可序列化事件,优势是能显示在 Inspector 里,让策划或美术不用写代码也能绑定回调,适合 UI 按钮、关卡机关、动画事件这类低频、可配置场景。但 UnityEvent 调用成本更高,方法改名可能导致 Inspector 绑定丢失,公开暴露时封装性也弱。所以我的选择是:核心逻辑用 C# event,编辑器配置和低频表现层回调用 UnityEvent。

UnityEvent 为什么更方便但更慢?

一句话答案:UnityEvent 更方便,是因为它能被 Unity 序列化,能在 Inspector 里拖对象、选方法、保存到场景或 Prefab;但它更慢,是因为它不是直接调用委托,而是经过 UnityEvent 的一套包装、监听列表管理、持久化调用解析和参数处理。

unityevent-convenient-but-slower

方便在哪里:

UnityEvent 可以显示在 Inspector 里。你可以直接把对象拖进去,然后选择要调用的方法,比如按钮点击后播放音效、打开面板、触发机关。

它还能保存到场景和 Prefab 里,所以策划或美术不写代码也能配置事件绑定。

慢在哪里:

C# event 基本是委托调用,路径很短:

c
event -> delegate invocation list -> method

UnityEvent 路径更长:

c
UnityEvent.Invoke -> PrepareInvoke -> PersistentCall / RuntimeCall -> InvokableCall -> target method

也就是说,UnityEvent 为了支持 Inspector、序列化、持久化绑定、动态参数等功能,多了一层 Unity 自己的事件系统包装。

代码对比:

c
using System; // 引入 Action 委托类型
using UnityEngine; // 引入 Unity 的 MonoBehaviour
using UnityEngine.Events; // 引入 UnityEvent 类型

public class EventCompareExample : MonoBehaviour // 定义事件对比示例类
{ // 类开始
    public event Action OnHpChangedByCode; // 定义 C# event,适合代码内部高频通知
    [SerializeField] private UnityEvent onButtonClickedByInspector; // 定义 UnityEvent,适合 Inspector 里配置回调

    public void ChangeHp() // 定义血量变化方法
    { // 方法开始
        OnHpChangedByCode?.Invoke(); // 触发 C# event,调用路径更短
    } // 方法结束

    public void ClickButton() // 定义按钮点击方法
    { // 方法开始
        onButtonClickedByInspector.Invoke(); // 触发 UnityEvent,方便 Inspector 配置但调用链更长
    } // 方法结束
} // 类结束

实际怎么选:

高频逻辑用 C# event,比如战斗伤害、属性变化、AI 状态切换、每帧可能触发的系统事件。

低频配置用 UnityEvent,比如 UI 按钮点击、开门机关、动画结束后播放音效、关卡触发器。

TIP

面试高分回答:UnityEvent 的优势是和 Unity 编辑器深度结合,它可以序列化到场景和 Prefab 中,并且能在 Inspector 里直接配置监听对象和方法,所以非常适合表现层和低频交互。但它比 C# event 慢,因为它底层不是简单的委托直接调用,而是经过 UnityEventBase、持久化监听列表、运行时监听列表、InvokableCall 等包装,还可能涉及方法解析和参数处理。我的选择是:核心逻辑、高频事件用 C# event,需要可视化配置、策划可调、低频触发的地方用 UnityEvent

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0