2.4 委托与事件:值班室的对讲机 本节摘要:委托(Delegate)是"能装方法进变量"的类型,事件(Event)是在委托上加了一层发布订阅纪律的广播机制。本节讲清两者关系,给出游戏开发里最常用的事件广播模式,并顺手交代 Linq 在处理数据集合时的几个高频用法。 直接调用的代价 玩家死亡后要做一连串反应:UI 显示结算、音效组播死亡音、成就系统记录、存档系统写入。新手写法是死亡脚本里挨个调用: 能跑,但每加一个反应就要改一次 Die,死亡脚本被迫认识全场的每一个模块——值班员拿着全公司的通讯录,谁换工号都得更新他。三个月后这段代码没人敢动。对讲机模式(事件)把方向反过来:死亡方只对频道喊一声"我死了",谁关心谁订阅,发送方与接收方互不认识。
本节摘要:委托(Delegate)是"能装方法进变量"的类型,事件(Event)是在委托上加了一层发布订阅纪律的广播机制。本节讲清两者关系,给出游戏开发里最常用的事件广播模式,并顺手交代 Linq 在处理数据集合时的几个高频用法。
玩家死亡后要做一连串反应:UI 显示结算、音效组播死亡音、成就系统记录、存档系统写入。新手写法是死亡脚本里挨个调用:
void Die() { uiManager.ShowGameOver(); // 直接喊 UI audioManager.PlayDeath(); // 直接喊音效 achievement.Unlock("first_death"); saveSystem.MarkDeath(); Destroy(gameObject); }
能跑,但每加一个反应就要改一次 Die,死亡脚本被迫认识全场的每一个模块——值班员拿着全公司的通讯录,谁换工号都得更新他。三个月后这段代码没人敢动。对讲机模式(事件)把方向反过来:死亡方只对频道喊一声"我死了",谁关心谁订阅,发送方与接收方互不认识。
委托是类型安全的"方法指针"。用 delegate 关键字声明一种委托类型,它规定了可装入方法的签名;随后就能把方法像变量一样赋值、传递、多播(+= 挂多个)。C# 还内置了 Action(无返回值)与 Func(有返回值)两个泛型委托族,多数场合不必自定义。
事件是委托的封装:声明方用 event 关键字,外部只能 += 订阅与 -= 退订,不能直接赋值或触发——这就保证了广播权只属于声明方,属于"谁能按对讲机的发送键"的纪律。
背景:同上文的死亡连锁反应,目标是让玩家脚本不认识任何接收方。
操作:先建一个全局频道类,再改造发送方与接收方:
using System; // 全局事件频道:只放事件定义,不放逻辑 public static class GameEvents { // 事件参数:谁死的、剩余生命数 public static event Action<string, int> OnPlayerDied; public static void PlayerDied(string name, int livesLeft) { // 只有频道自己能触发广播,外部无权按键 OnPlayerDied?.Invoke(name, livesLeft); } } // 发送方:玩家只管广播 public class PlayerBroadcaster : MonoBehaviour { public int lives = 3; public void Die() { lives--; GameEvents.PlayerDied(gameObject.name, lives); Destroy(gameObject); } } // 接收方 A:UI 订阅 public class GameOverUI : MonoBehaviour { void OnEnable() { GameEvents.OnPlayerDied += HandleDied; } void OnDisable() { GameEvents.OnPlayerDied -= HandleDied; } void HandleDied(string who, int left) { Debug.Log("结算界面弹出,死亡者 " + who + " 剩余生命 " + left); } }
结果:调用玩家的 Die,UI 收到广播并打印;音效、成就要参与,只需各自写一段同样的订阅代码,玩家脚本一行不改。
解读:三个细节决定这套模式的质量。其一,订阅与退订成对出现在 OnEnable 与 OnDisable——只订不退,订阅方销毁后广播仍会找它,轻则空引用重则内存泄漏,这是事件模式事故率最高的一条;其二,?.Invoke 的空条件调用防止"没人订阅"时抛空引用,广播前订阅数为零是完全正常的业务状态;其三,静态事件是方便与危险并存的双刃剑,场景切换后忘记退订的静态事件会在整个游戏存活期里挂着死对象的引用,用静态频道就要格外严格执行成对退订。
变式:把 event Action 换成 UnityEvent 字段(using UnityEngine.Events),在 Inspector 里拖目标方法——设计策划不碰代码也能配置触发链。取舍是:代码内高频事件用 C# event,编辑器可配置的低频触发用 UnityEvent,两者共存是常态。

事件系统用久了会遇到一个新问题:频道太多太碎,谁是发布者、谁是订阅者反而看不清。经验是按"业务事实"而不是"界面动作"定粒度——"玩家死亡""金币拾取""关卡通过"是业务事实,接收方可以有很多种;"刷新按钮颜色""播放某个音效"是具体反应,不该做成独立事件。命名上动宾结构最清晰:OnPlayerDied、OnLevelCleared,参数尽量携带事实本身(谁、多少、在哪),让订阅方不需要反向查询。
频道规模再往上走,就该考虑集中与分散的取舍了:全局静态频道简单直接,适合中小项目;大型项目会按模块拆分频道,甚至上消息总线加事件路由。判断标准依然朴素——当"找一个事件在哪定义、被谁订阅"需要超过一分钟时,就该整理频道的组织结构了。工具层面,搜索事件名的所有引用,能列出完整的发布与订阅图谱,代码审查时值得定期跑一遍。
游戏脚本里处理列表数据,Linq 提供三个最常用的查询:Where 过滤(挑出所有活着的敌人)、OrderBy 排序(按血量从低到高选目标)、First 或 FirstOrDefault 取第一个满足项(找最近的可拾取物)。一例如下:
using System.Linq; // 从列表里找出血量最低且还活着的敌人 Enemy PickWeakest(List<Enemy> enemies) { return enemies .Where(e => e.hp > 0) .OrderBy(e => e.hp) .FirstOrDefault(); }
代价要知道:Linq 每次调用都会产生临时对象与遍历开销,放在 Update 里每帧对上百个对象跑查询,GC(垃圾回收)压力立竿见影。原则是初始化、点击响应、低频逻辑放心用,逐帧热路径换成普通 for 循环。
值班员的嘴和腿都齐了。第 3 章开岗:先接玩家的手(输入),再上物理的秤(碰撞与刚体)。