一、系统为什么会互相“知道太多”
一个回合结束时,可能需要:
- UI 显示结算面板
- 音频播放胜利音效
- 统计系统记录本回合数据
- 成就系统检查是否完成条件
- 观战系统保存回放标记
- 任务系统推进任务进度
最直接的写法是让 QuestSubsystem 逐个调用它们:
QuestSubsystem->ShowResultWidget(Result);QuestSubsystem->PlayVictorySound(Result);QuestSubsystem->RecordStatistics(Result);QuestSubsystem->CheckAchievements(Result);QuestSubsystem->SaveReplayMarker(Result);这会让回合系统知道所有下游模块的类名、生命周期和函数签名。加一个“录像系统”,就要修改回合系统;删一个音效模块,也可能影响编译。
消息通信的思路是:
发布者:QuestSubsystem → 发布“Message.Mission.Finished”消息 ↓订阅者:UI / Audio / Stats / Achievement / Replay → 各自决定要不要处理发布者只知道发生了什么,不知道谁会处理。
二、直接调用和消息通信怎么选
消息不是所有场景的答案。可以用这张表判断:
| 场景 | 更适合 |
|---|---|
| 必须拿到返回值才能继续 | 直接调用 |
| 明确的一对一依赖 | 接口 / 直接调用 |
| 一个事件有多个独立订阅者 | 消息 |
| 发送者不应该依赖接收者 | 消息 |
| 需要跨网络到达另一台机器 | RPC / Replicated 状态 |
| 高频逐帧数据 | 专门的数据通道 |
// 一对一、需要结果:直接调用const bool bCanUseAbility = RuleService->CanUseAbility(PlayerId, AbilityTag);
// 一对多、无需知道接收者:发消息MessageBus->BroadcastMessage( GameplayChannels::MissionFinished, Result);消息的价值是减少依赖,不是隐藏依赖。 如果系统有十几个订阅者,仍然需要能追踪“谁订阅了这个频道、谁会修改什么”。
三、GameplayTag 是很好的消息频道名
前面讲过 GameplayTag 是系统之间的“类型安全字符串”。消息系统可以直接使用层级 Tag 作为频道:
Message.Mission.StartedMessage.Mission.FinishedMessage.Ability.ActivatedMessage.Ability.RejectedMessage.Item.PickedUp调用者不需要依赖 UMissionResultWidget,它只发布:
FGameplayTag Channel = FGameplayTag::RequestGameplayTag( TEXT("Message.Mission.Finished"));
FObjectMessage Payload;Payload.Object = ResultObject;
UGameplayMessageSubsystem::Get(this).BroadcastMessage( Channel, Payload);在使用 Lyra 风格的 GameplayMessageRuntime 时,可以用 UGameplayMessageSubsystem 和强类型 Payload;如果项目没有启用该插件,也可以实现一个自己的 UGameInstanceSubsystem 消息总线,保留相同的思路。
1 频道命名要稳定
频道名称会被代码、蓝图和调试工具共同引用。建议:
- 用
Message.作为根节点,和状态 Tag 区分 - 名词描述事件,不要用某个实现类的名字
- 频道表达业务语义,不表达 UI 表现
- 需要层级监听时,提前定义父子关系
Message.Ability.Activated // 业务事件Message.Ability.PlaySound // 表现细节,不建议作为公共频道“技能释放成功”可以同时驱动音效、UI 和统计;“播放某个音效”只应该是音频系统内部的事情。
四、Payload:消息要带多少信息
消息必须有足够信息让订阅者工作,但不能把整个系统的内部对象都塞进去:
USTRUCT(BlueprintType)struct FAbilityActivatedMessage { GENERATED_BODY()
UPROPERTY(BlueprintReadOnly) TObjectPtr<const APlayerState> Player = nullptr;
UPROPERTY(BlueprintReadOnly) FGameplayTag AbilityTag;
UPROPERTY(BlueprintReadOnly) int32 ActivationSequence = 0;};推荐传递:
- 稳定的 ID
- 事件发生时的快照值
- 必要的上下文(回合号、玩家 ID)
- 不可变的结果数据
尽量不要传递:
- 订阅者可以自己修改的可变全局对象
- 只因为某个 UI 恰好需要才加入的字段
- 让接收者必须知道内部实现的“万能上下文”
如果所有消息都只有一个 void* Context,那不是解耦,而是把类型错误推迟到了运行时。
五、监听生命周期比广播更容易出问题
FGameplayMessageListenerHandle ListenerHandle;
void UMissionHUDWidget::NativeConstruct() { Super::NativeConstruct();
ListenerHandle = UGameplayMessageSubsystem::Get(this) .RegisterListener<FAbilityActivatedMessage>( GameplayChannels::AbilityActivated, this, &UMissionHUDWidget::OnAbilityActivated);}
void UMissionHUDWidget::NativeDestruct() { UGameplayMessageSubsystem::Get(this) .UnregisterListener(ListenerHandle);
Super::NativeDestruct();}必须处理这些情况:
- Widget 反复打开时不会重复注册
- 监听者销毁后,消息总线不会留下悬空对象
- Subsystem 重建时,旧 Handle 不会被错误复用
- 测试结束时,所有监听都能清理
RAII、Delegate Handle 或 UObject 生命周期绑定都可以实现自动解绑。谁注册,谁负责取消注册,是最容易记住的规则。
六、消息不是网络复制
这是消息系统最容易被误用的地方:
服务器 BroadcastMessage(AbilityActivated) → 只在服务器进程内通知订阅者 ✕ 不会自动到达客户端如果客户端也需要知道技能释放成功,需要先走网络边界:
客户端请求 ServerUseAbility → 服务器验证并修改 Replicated MissionState → 客户端收到属性变化 / Client RPC → 客户端本地 BroadcastMessage → UI、音频、统计各自响应服务器上的消息适合连接服务器内部的规则、日志和结算系统;客户端上的消息适合连接客户端表现。不要把本地消息总线当作跨机器传输协议。
1 可恢复状态和瞬时事件
任务进度 = 3 / 5 → Replicated 属性,晚加入客户端也能得到
“技能图标闪一下” → 本地消息,错过了也不影响事实消息可以由状态变化触发,但不能取代可恢复的权威状态。
七、消息处理顺序和线程边界
多数 Gameplay 消息在 Game Thread 上同步分发:
BroadcastMessage() → 依次调用当前订阅者 → 订阅者可能继续发布新消息 → 当前调用栈内完成这带来两个注意点:
- 不要在消息回调里做长时间阻塞的文件或网络 I/O
- 不要依赖“某两个订阅者的注册顺序”表达业务顺序
如果业务真的需要顺序:
MissionFinished → 任务系统先生成 FinalResult → 再发布 ResultReady → UI / Audio / Stats 只消费最终结果把顺序建模成状态或阶段,比依赖 Delegate 的偶然调用顺序更稳。
八、和 GAS、Subsystem 的组合
这几篇文章里的组件可以这样分工:
Gameplay Ability → 触发技能、应用 GE、改变 Tags
World / GameInstance Subsystem → 管理跨对象的业务服务
Gameplay Message → 传播“发生了什么”
Replicated State → 让其他机器知道“现在是什么”例如“完成任务目标”:
Ability.Activate → 服务器检查权限与消耗 → QuestSubsystem.CompleteObjective() → 修改 ObjectiveState = Completed → Broadcast Objective.Completed(服务器本地) → Replicated State 到客户端 → 客户端 Broadcast Objective.Completed → UI、音效、相机各自处理每一层只做自己的事:GAS 管技能规则,Subsystem 管业务服务,复制管网络事实,消息管本地解耦。
九、合作游戏消息频道示例
Message.Mission.Started Payload: MissionId、MapId、EndTime
Message.Ability.Activated Payload: PlayerId、AbilityTag、Sequence
Message.Ability.Rejected Payload: PlayerId、AbilityTag、RejectReason
Message.Item.PickedUp Payload: ItemId、PlayerId、WorldLocation
Message.Mission.Finished Payload: TeamId、RewardId、CompletionId其中 AbilityRejected 很适合本地 UI 显示提示,但真正的拒绝原因仍然必须来自服务器;客户端不能自己推断“服务器应该允许”。
十、总结
| 概念 | 一句话 |
|---|---|
| 直接调用 | 有明确一对一依赖、需要返回值时使用 |
| 消息 | 一对多、发布者不需要知道接收者时使用 |
| Channel | 用稳定的 GameplayTag 表达业务事件名称 |
| Payload | 带最小但完整的不可变事件快照 |
| Listener Handle | 管理订阅生命周期,避免重复回调和悬空引用 |
| 网络边界 | 本地消息不会自动跨机器,仍需 RPC 或复制 |
消息系统不是为了让代码“看起来没有依赖”,而是把依赖从类名依赖变成了业务事件依赖。 事件命名、Payload 类型和生命周期都认真设计,系统才是真的松耦合。