1. 项目概述:为什么要在UE5.4里造一个消息队列?
如果你在UE5里做过稍微复杂点的逻辑,尤其是涉及到不同系统(比如UI、Gameplay、网络、AI)之间通信,肯定遇到过这样的头疼事:A系统想告诉B系统“玩家血量变了”,结果B系统可能还没初始化好,或者正在处理别的逻辑,直接调用接口就崩了。又或者,你想在下一帧再执行某个操作,或者让一个操作延迟几秒执行,难道就靠一个
Delay
节点或者
Timer
硬等?代码耦合度高,调试起来像一团乱麻。
这就是消息队列(Message Queue, MQ)要解决的问题。它本质上是一个“中间人”,发送方(Producer)把消息丢到队列里就可以不管了,接收方(Consumer)按照自己的节奏从队列里取消息处理。两者完全解耦,发送方不知道、也不关心是谁来处理,处理方也不知道消息具体从哪来。在大型游戏架构里,这种异步、解耦的通信方式几乎是必需品。
你可能会问,UE不是有
Delegate
(委托)和
Event
(事件)吗?没错,它们是UE里常用的通信机制,但它们是
同步
的。当
Broadcast
一个多播委托时,所有绑定的函数会立刻、依次被执行。如果某个监听者处理时间过长,或者崩溃了,会直接阻塞整个调用链。而消息队列是
异步
的,消息被放入队列后,发送方就返回了,处理是延后的、可控的。
所以,在UE5.4里实现一个消息队列,不是为了替代现有机制,而是 补全 UE在异步、可靠、跨线程消息传递方面的能力。它能帮你:
- 彻底解耦系统 :Gameplay模块发个“任务完成”消息,UI模块和成就系统各自监听处理,互不干扰。
- 实现延迟与定时逻辑 :将消息投递到“延迟队列”或“定时队列”,实现精准的延迟回调,比单纯用Timer更清晰、更易管理。
- 缓冲与削峰填谷 :瞬间产生大量事件(如击中特效、音效请求),可以先存入队列,由消费端逐帧消化,避免同一帧卡顿。
- 简化跨线程通信 :将需要从工作线程(如资源加载、网络接收)传递到游戏线程的数据包装成消息,安全地投递到主线程队列处理。
接下来,我们就从零开始,在UE5.4里打造一个轻量、高效、易用的消息队列系统。
2. 核心设计:打造一个适合UE的轻量级MQ框架
直接照搬RocketMQ、Kafka那种企业级消息中间件到游戏里是杀鸡用牛刀,太重了。我们需要的是一个贴合UE引擎习惯、开销小、功能聚焦的轻量级实现。核心设计围绕以下几个点展开:
2.1 消息体设计:如何承载任意数据?
消息队列里的“消息”必须是一个通用的载体。在C++里,我们很自然地想到使用
USTRUCT
,因为它可以被UE的反射系统部分支持,也方便蓝图使用。但为了极致的灵活性和性能,我们选择使用
TSharedPtr
来包装一个消息基类。
首先,定义一个所有消息的基类:
// MQMessage.h
#pragma once
#include "CoreMinimal.h"
#include "MQMessage.generated.h"
USTRUCT(BlueprintType)
struct MYMQ_API FMQMessageBodyBase
{
GENERATED_BODY()
public:
virtual ~FMQMessageBodyBase() = default;
// 可以定义一些公共接口,比如获取消息类型名,用于调试或路由
virtual FString GetMessageType() const { return TEXT("Base"); }
};
// 使用一个简单的结构体包装消息体和元数据
USTRUCT(BlueprintType)
struct MYMQ_API FMQMessage
{
GENERATED_BODY()
public:
// 消息主题(或频道),用于区分消息种类,如 “Player.Health”, “System.Achievement”
UPROPERTY(BlueprintReadWrite, Category = "MQ")
FName Topic;
// 发送时间戳(引擎时间)
UPROPERTY(BlueprintReadWrite, Category = "MQ")
float SendTime;
// 延迟处理的时间(秒),0表示立即处理
UPROPERTY(BlueprintReadWrite, Category = "MQ")
float Delay = 0.0f;
// 实际的消息数据,使用共享指针以便安全传递和存储
TSharedPtr<FMQMessageBodyBase> Body;
};
这里的关键是
Body
成员,它是一个指向基类的智能指针。我们可以为不同的数据类型创建派生类:
// 示例:一个携带整型数据的消息体
USTRUCT(BlueprintType)
struct FMQMessageBody_Int : public FMQMessageBodyBase
{
GENERATED_BODY()
UPROPERTY(BlueprintReadWrite, Category = "MQ")
int32 Value = 0;
virtual FString GetMessageType() const override { return TEXT("Int"); }
};
// 示例:一个携带字符串和向量的消息体
USTRUCT(BlueprintType)
struct FMQMessageBody_TransformEvent : public FMQMessageBodyBase
{
GENERATED_BODY()
UPROPERTY(BlueprintReadWrite, Category = "MQ")
FString EventName;
UPROPERTY(BlueprintReadWrite, Category = "MQ")
FVector Location;
virtual FString GetMessageType() const override { return TEXT("TransformEvent"); }
};
这样设计的好处是 类型安全 和 扩展性 极强。任何USTRUCT都可以作为消息体,通过模板和继承,我们可以在编译期或运行时安全地转换类型。
注意 :
Body没有使用UPROPERTY,因为TSharedPtr<FMQMessageBodyBase>不是UE反射直接支持的UObject类型。这没关系,因为我们的消息主要在C++侧创建和解析,蓝图可以通过定义好的、带有UPROPERTY的派生消息体结构来交互。或者,我们可以提供一个辅助函数将常见数据类型打包成消息。
2.2 队列核心:线程安全与存储选择
消息队列的核心数据结构就是一个“队列”。UE提供了
TQueue
这个线程安全的模板容器,它天生就是为了生产者-消费者场景设计的,支持
Enqueue
(入队)和
Dequeue
(出队)操作,并且这些操作是线程安全的。
我们的主消息队列类将包含一个
TQueue
:
// MQCore.h
#pragma once
#include "CoreMinimal.h"
#include "MQMessage.h"
#include "Async/AsyncWork.h"
#include "Containers/Queue.h"
class MYMQ_API FMQCore : public FRunnable
{
public:
static FMQCore& Get();
// 初始化与销毁
void Initialize();
void Shutdown();
// 发布消息
void Publish(const FName& InTopic, const TSharedPtr<FMQMessageBodyBase>& InBody, float InDelay = 0.0f);
// 订阅消息
FDelegateHandle Subscribe(const FName& InTopic, const TFunction<void(const TSharedPtr<FMQMessageBodyBase>&)>& InCallback);
void Unsubscribe(const FName& InTopic, const FDelegateHandle& InHandle);
private:
FMQCore();
~FMQCore();
// FRunnable 接口,用于驱动消息处理的专用线程(可选)
virtual uint32 Run() override;
virtual void Stop() override;
virtual void Exit() override;
// 内部处理函数
void ProcessPendingMessages();
// 主消息队列(线程安全)
TQueue<TSharedPtr<FMQMessage>, EQueueMode::Mpsc> MainMessageQueue;
// 延迟消息的优先队列(按处理时间排序)
struct FDelayedMessage
{
TSharedPtr<FMQMessage> Message;
float ExecuteTime;
bool operator<(const FDelayedMessage& Other) const { return ExecuteTime > Other.ExecuteTime; } // 小顶堆
};
TArray<FDelayedMessage> DelayedMessages;
// 订阅者列表 Key: Topic, Value: 回调函数数组
TMap<FName, TArray<TFunction<void(const TSharedPtr<FMQMessageBodyBase>&)>>> Subscribers;
// 线程控制
FRunnableThread* WorkerThread = nullptr;
FThreadSafeBool bIsRunning = false;
// 用于线程同步的事件
FEvent* Semaphore = nullptr;
FCriticalSection SubscribersCriticalSection;
FCriticalSection DelayedMessagesCriticalSection;
};
这里有几个关键设计决策:
-
使用
TQueue:它内部自带了锁,Mpsc(Multi-producer, Single-consumer)模式非常适合我们这里可能有多个系统发送消息,但由一个统一消费者(我们的消息处理循环)来处理的场景。 -
延迟消息处理
:我使用了一个
TArray配合堆算法(operator<定义)来实现优先队列。ProcessPendingMessages函数每一帧或每次循环时,会检查DelayedMessages堆顶的消息是否到了该执行的时间。 -
订阅模式
:使用
TMap来存储主题和回调函数的映射。这里回调使用了TFunction,它比UE的委托更轻量,也更适合C++ lambda。注意对Subscribers的读写需要用FCriticalSection(临界区)保护,因为订阅/取消订阅可能发生在任何线程。 -
专用线程 vs 主线程Tick
:我提供了两种驱动方式。一种是继承
FRunnable,创建一个专用工作线程来不断处理消息(Run函数),适合需要后台持续处理的逻辑。另一种更简单、更安全(尤其对于需要操作游戏对象的逻辑)的是在游戏主线程的Tick中调用ProcessPendingMessages。示例中展示了线程方案,但我会强烈建议新手先从主线程Tick方案开始。
实操心得 :对于游戏开发, 我强烈建议将消息的最终消费放在游戏线程(GameThread) 。因为大部分UE的API(如生成Actor、修改UI、播放音效)都必须在游戏线程调用。我们的消息队列可以负责接收来自任何线程的消息(
Publish是线程安全的),但在ProcessPendingMessages中,当真正执行订阅者的回调时,应该确保回调函数是在游戏线程中执行的。这可以通过将回调函数包装成AsyncTask投递到游戏线程来实现,或者直接约定订阅者回调只来自主线程Tick驱动。
2.3 订阅与分发:如何高效通知订阅者?
当一条消息到达处理时间,我们需要找到所有订阅了该消息主题(Topic)的监听者,并调用他们的回调函数。这个过程必须高效,因为可能每帧都有大量消息。
我们在
FMQCore
中实现
ProcessPendingMessages
的核心逻辑:
void FMQCore::ProcessPendingMessages()
{
// 1. 处理延迟消息
float CurrentTime = GetWorld() ? GetWorld()->GetTimeSeconds() : FPlatformTime::Seconds();
{
FScopeLock Lock(&DelayedMessagesCriticalSection);
while (DelayedMessages.Num() > 0 && DelayedMessages[0].ExecuteTime <= CurrentTime)
{
TSharedPtr<FMQMessage> ReadyMessage = DelayedMessages[0].Message;
DelayedMessages.RemoveAt(0, 1, false); // 保持堆结构
if (ReadyMessage.IsValid())
{
MainMessageQueue.Enqueue(ReadyMessage);
}
// 重新调整堆
if (DelayedMessages.Num() > 0)
{
DelayedMessages.Heapify();
}
}
}
// 2. 处理主队列中的消息
TSharedPtr<FMQMessage> NextMessage;
while (MainMessageQueue.Dequeue(NextMessage))
{
if (!NextMessage.IsValid()) continue;
FName Topic = NextMessage->Topic;
TSharedPtr<FMQMessageBodyBase> Body = NextMessage->Body;
// 查找订阅者
TArray<TFunction<void(const TSharedPtr<FMQMessageBodyBase>&)>> CallbacksToExecute;
{
FScopeLock Lock(&SubscribersCriticalSection);
if (TArray<TFunction<void(const TSharedPtr<FMQMessageBodyBase>&)>>* FoundCallbacks = Subscribers.Find(Topic))
{
CallbacksToExecute = *FoundCallbacks; // 复制一份,避免在回调中修改原数组导致问题
}
}
// 执行回调
for (auto& Callback : CallbacksToExecute)
{
if (Callback)
{
// 重要:这里假设回调是线程安全的,或者我们确保它在GameThread执行。
// 更安全的做法是:AsyncTask(ENamedThreads::GameThread, [Callback, Body](){ Callback(Body); });
Callback(Body);
}
}
}
}
关键点与避坑指南 :
-
回调执行环境
:如代码注释所述,直接执行
Callback(Body)存在风险。如果Publish来自工作线程,而回调函数里调用了UE游戏线程的API,程序会崩溃。 安全的做法 是在Publish时或ProcessPendingMessages中,判断当前线程,如果不是游戏线程,则使用AsyncTask或FFunctionGraphTask将回调的执行派发到游戏线程。这是实现中 最容易出错的地方 。 -
订阅者列表拷贝
:在执行回调前,我们复制了一份回调列表(
CallbacksToExecute)。这是因为回调函数本身可能会执行Unsubscribe操作,直接遍历和修改Subscribers映射会导致迭代器失效,程序崩溃。这是一个经典的并发编程问题。 -
性能考量
:对于高频消息(如每帧的
Tick),频繁地查找TMap和拷贝数组可能成为瓶颈。如果性能敏感,可以考虑使用TArray存储订阅者,或者对Topic使用哈希值进行比较。但对于绝大多数游戏逻辑,这个开销是可以接受的。
3. 分步实现:从零搭建消息队列模块
理论说完了,我们动手创建一个UE5.4插件或模块。这里我以创建名为
MyMessageQueue
的游戏模块为例。
3.1 创建模块与基础类
-
在你的项目源码目录(
Source/YourProjectName/)下创建新文件夹MyMessageQueue。 -
创建
MyMessageQueue.Build.cs文件,定义模块依赖:// MyMessageQueue.Build.cs using UnrealBuildTool; public class MyMessageQueue : ModuleRules { public MyMessageQueue(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core" }); PrivateDependencyModuleNames.AddRange(new string[] { "CoreUObject", "Engine", "Slate", "SlateCore", }); } } -
创建
MyMessageQueue.h和MyMessageQueue.cpp,实现模块启动和关闭,并在其中初始化我们的FMQCore单例。// MyMessageQueue.h #pragma once #include "Modules/ModuleManager.h" class FMyMessageQueueModule : public IModuleInterface { public: virtual void StartupModule() override; virtual void ShutdownModule() override; };// MyMessageQueue.cpp #include "MyMessageQueue.h" #include "MQCore.h" void FMyMessageQueueModule::StartupModule() { FMQCore::Get().Initialize(); } void FMyMessageQueueModule::ShutdownModule() { FMQCore::Get().Shutdown(); } IMPLEMENT_MODULE(FMyMessageQueueModule, MyMessageQueue) -
将前面设计的
MQMessage.h、MQCore.h及其对应的.cpp文件放入模块目录。 -
在
MQCore.cpp中实现单例模式和初始化逻辑:// MQCore.cpp 部分实现 FMQCore& FMQCore::Get() { static FMQCore Instance; return Instance; } void FMQCore::Initialize() { if (bIsRunning) return; bIsRunning = true; // 方案一:创建工作者线程(处理消息) // WorkerThread = FRunnableThread::Create(this, TEXT("MQWorkerThread"), 0, TPri_BelowNormal); // 方案二(推荐):注册到游戏线程的Tick // 需要在某个游戏线程对象(如GameInstance或PlayerController)的BeginPlay中调用BindToTick } void FMQCore::Shutdown() { bIsRunning = false; if (Semaphore) { Semaphore->Trigger(); } if (WorkerThread) { WorkerThread->WaitForCompletion(); delete WorkerThread; WorkerThread = nullptr; } // 清空队列和订阅者 ... }
3.2 实现线程安全的消息发布与订阅
现在实现最关键的
Publish
和
Subscribe
函数。
// MQCore.cpp - Publish 实现
void FMQCore::Publish(const FName& InTopic, const TSharedPtr<FMQMessageBodyBase>& InBody, float InDelay)
{
if (!bIsRunning || !InBody.IsValid()) return;
TSharedPtr<FMQMessage> NewMessage = MakeShared<FMQMessage>();
NewMessage->Topic = InTopic;
NewMessage->SendTime = FPlatformTime::Seconds();
NewMessage->Body = InBody;
if (InDelay > 0.0f)
{
FDelayedMessage DelayedMsg;
DelayedMsg.Message = NewMessage;
DelayedMsg.ExecuteTime = NewMessage->SendTime + InDelay;
FScopeLock Lock(&DelayedMessagesCriticalSection);
DelayedMessages.HeapPush(DelayedMsg);
}
else
{
// 立即消息,放入主队列
MainMessageQueue.Enqueue(NewMessage);
// 如果有工作者线程,通知它
if (Semaphore)
{
Semaphore->Trigger();
}
}
}
// MQCore.cpp - Subscribe 实现
FDelegateHandle FMQCore::Subscribe(const FName& InTopic, const TFunction<void(const TSharedPtr<FMQMessageBodyBase>&)>& InCallback)
{
FScopeLock Lock(&SubscribersCriticalSection);
TArray<TFunction<void(const TSharedPtr<FMQMessageBodyBase>&)>>& CallbackArray = Subscribers.FindOrAdd(InTopic);
CallbackArray.Add(InCallback);
// 返回一个可用于取消订阅的句柄。这里简单使用回调在数组中的索引作为标识。
// 更健壮的做法是生成一个唯一的句柄。
return FDelegateHandle(CallbackArray.Num() - 1);
}
void FMQCore::Unsubscribe(const FName& InTopic, const FDelegateHandle& InHandle)
{
FScopeLock Lock(&SubscribersCriticalSection);
if (TArray<TFunction<void(const TSharedPtr<FMQMessageBodyBase>&)>>* FoundCallbacks = Subscribers.Find(InTopic))
{
int32 Index = InHandle.GetIndex(); // 假设我们实现了GetIndex
if (FoundCallbacks->IsValidIndex(Index))
{
// 注意:这里只是置空,而不是移除,避免破坏其他句柄的索引。
// 可以考虑在ProcessPendingMessages中跳过空回调,或定期清理。
(*FoundCallbacks)[Index] = nullptr;
}
}
}
3.3 绑定到游戏主循环(Tick)
对于游戏开发,让消息队列在主线程
Tick
是最稳妥的。我们可以创建一个
UObject
来负责每帧驱动。
// MQActorComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "MQActorComponent.generated.h"
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class MYMQ_API UMQActorComponent : public UActorComponent
{
GENERATED_BODY()
public:
UMQActorComponent();
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
protected:
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
};
// MQActorComponent.cpp
#include "MQActorComponent.h"
#include "MQCore.h"
UMQActorComponent::UMQActorComponent()
{
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.TickGroup = TG_PrePhysics; // 在物理模拟前处理消息
}
void UMQActorComponent::BeginPlay()
{
Super::BeginPlay();
FMQCore::Get().Initialize();
}
void UMQActorComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// 每帧驱动消息处理
FMQCore::Get().ProcessPendingMessages();
}
void UMQActorComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
FMQCore::Get().Shutdown();
Super::EndPlay(EndPlayReason);
}
将这个组件添加到你的
GameMode
、
PlayerController
或某个长期存在的
Actor
上,消息队列就会自动每帧处理了。
4. 实战应用:在游戏逻辑中收发消息
框架搭好了,怎么用呢?我们来看几个游戏内的典型场景。
4.1 场景一:UI与Gameplay解耦
假设玩家血量发生变化,需要更新UI血条并可能触发一个屏幕特效。
传统紧耦合方式
:
PlayerHealthComponent
直接调用
UIPlayerHUD->UpdateHealthBar(CurrentHealth)
和
CameraShakeManager->PlayLowHealthEffect()
。
PlayerHealthComponent
需要知道UI和特效管理器的具体引用。
使用消息队列解耦 :
-
定义消息
:
USTRUCT(BlueprintType) struct FMQMessageBody_HealthChanged : public FMQMessageBodyBase { GENERATED_BODY() UPROPERTY(BlueprintReadWrite, Category = "MQ") float CurrentHealth; UPROPERTY(BlueprintReadWrite, Category = "MQ") float MaxHealth; UPROPERTY(BlueprintReadWrite, Category = "MQ") AActor* DamageInstigator; // 可选 }; -
发送消息(在PlayerHealthComponent中)
:
void UPlayerHealthComponent::TakeDamage(float DamageAmount) { CurrentHealth = FMath::Clamp(CurrentHealth - DamageAmount, 0.0f, MaxHealth); auto HealthMsg = MakeShared<FMQMessageBody_HealthChanged>(); HealthMsg->CurrentHealth = CurrentHealth; HealthMsg->MaxHealth = MaxHealth; FMQCore::Get().Publish(FName(TEXT("Player.Health.Changed")), HealthMsg); if (CurrentHealth <= 0.0f) { auto DeathMsg = MakeShared<FMQMessageBody_Base>(); // 甚至可以用空消息体 FMQCore::Get().Publish(FName(TEXT("Player.Death")), DeathMsg); } } -
接收消息(在UI和特效系统中)
:
// 在UIPlayerHUD的初始化中订阅 void AUIPlayerHUD::BeginPlay() { Super::BeginPlay(); HealthChangedHandle = FMQCore::Get().Subscribe( FName(TEXT("Player.Health.Changed")), [this](const TSharedPtr<FMQMessageBodyBase>& Body) { if (auto HealthBody = StaticCastSharedPtr<FMQMessageBody_HealthChanged>(Body)) { // 注意:这个Lambda可能在非游戏线程被调用! // 安全的做法:用AsyncTask切换到GameThread再更新UI AsyncTask(ENamedThreads::GameThread, [this, HealthBody](){ UpdateHealthBar(HealthBody->CurrentHealth, HealthBody->MaxHealth); }); } } ); } void AUIPlayerHUD::EndPlay(const EEndPlayReason::Type EndPlayReason) { FMQCore::Get().Unsubscribe(FName(TEXT("Player.Health.Changed")), HealthChangedHandle); Super::EndPlay(EndPlayReason); }// 在CameraShakeManager中订阅 void ACameraShakeManager::BeginPlay() { Super::BeginPlay(); FMQCore::Get().Subscribe( FName(TEXT("Player.Health.Changed")), [this](const TSharedPtr<FMQMessageBodyBase>& Body) { if (auto HealthBody = StaticCastSharedPtr<FMQMessageBody_HealthChanged>(Body)) { if (HealthBody->CurrentHealth / HealthBody->MaxHealth < 0.3f) { AsyncTask(ENamedThreads::GameThread, [this](){ PlayLowHealthEffect(); }); } } } ); }
这样做的好处 :
-
PlayerHealthComponent不再依赖任何UI或特效类,只负责发消息。 - UI系统和特效系统独立监听自己关心的消息,可以随时热插拔。
-
未来要增加一个新系统(比如语音提示“血量低”),只需要订阅同一个消息,无需修改
PlayerHealthComponent。
4.2 场景二:实现延迟与定时任务
你想让一个爆炸效果在3秒后触发,或者让一个NPC在对话结束后5秒走到某个位置。
// 3秒后触发爆炸
auto ExplosionMsg = MakeShared<FMQMessageBody_TransformEvent>();
ExplosionMsg->EventName = TEXT("BigExplosion");
ExplosionMsg->Location = TargetLocation;
FMQCore::Get().Publish(FName(TEXT("VFX.Trigger")), ExplosionMsg, 3.0f); // 关键:第三个参数是延迟
// NPC对话结束,5秒后移动
auto MoveMsg = MakeShared<FMQMessageBody_String>();
MoveMsg->Value = TEXT("MoveToPoint_B");
FMQCore::Get().Publish(FName(TEXT("AI.NPC.Move")), MoveMsg, 5.0f);
在VFX系统或AI系统中,正常订阅
VFX.Trigger
和
AI.NPC.Move
主题即可。消息队列会自动在3秒和5秒后将消息投递到处理队列,完全不需要你手动管理
Timer
句柄或担心
Timer
回调的上下文安全问题。
4.3 场景三:跨线程数据传递
你在工作线程中加载了一个纹理,需要在主线程中应用它。
// 在工作线程(AsyncLoad线程)中
void UMyAsyncLoader::LoadTextureCompleted(UTexture2D* LoadedTexture)
{
if (IsInGameThread())
{
ApplyTexture(LoadedTexture);
}
else
{
// 创建消息体,携带纹理指针。注意:需要确保LoadedTexture的生命周期。
auto TextureMsg = MakeShared<FMQMessageBody_TextureLoaded>();
TextureMsg->Texture = LoadedTexture; // 假设FMQMessageBody_TextureLoaded有UTexture2D*成员
// 发布消息,延迟为0,表示尽快处理
FMQCore::Get().Publish(FName(TEXT("Render.Texture.Ready")), TextureMsg);
// 注意:这里LoadedTexture的引用计数需要管理好,或者消息体持有TSoftObjectPtr。
}
}
// 在主线程的某个Renderer组件中
void UMyRendererComponent::BeginPlay()
{
Super::BeginPlay();
FMQCore::Get().Subscribe(
FName(TEXT("Render.Texture.Ready")),
[this](const TSharedPtr<FMQMessageBodyBase>& Body) {
// 因为ProcessPendingMessages是在主线程Tick中调用的,所以这里一定在主线程!
if (auto TextureBody = StaticCastSharedPtr<FMQMessageBody_TextureLoaded>(Body))
{
ApplyTexture(TextureBody->Texture); // 安全地应用纹理
}
}
);
}
消息队列充当了一个 线程安全的邮箱 ,工作线程把“信”(消息)投进去,主线程定期取“信”处理,完美解决了跨线程访问UE对象的问题。
5. 高级话题与性能优化
一个基础的消息队列已经能解决80%的问题。当你的游戏规模变大,消息量激增时,可以考虑以下优化和扩展。
5.1 消息优先级
有些消息需要被优先处理(如输入消息、网络同步消息),有些则可以延后(如日志消息、非关键特效)。我们可以为
FMQMessage
增加一个
Priority
字段,并使用优先级队列(如
TBinaryHeap
)来代替简单的
TQueue
。
UENUM(BlueprintType)
enum class EMQPriority : uint8
{
RealTime, // 实时,如输入
High, // 高,如游戏状态更新
Normal, // 普通,大多数游戏事件
Low, // 低,如日志、分析事件
};
// 在FMQMessage中添加
UPROPERTY(BlueprintReadWrite, Category = "MQ")
EMQPriority Priority = EMQPriority::Normal;
// 在FMQCore中使用TArray<TSharedPtr<FMQMessage>>并自定义排序规则的堆,或者维护多个不同优先级的TQueue。
5.2 消息过滤与路由
有时订阅者只关心特定条件下的消息。例如,一个区域触发器只关心发生在它边界内的事件。可以在订阅时传递一个过滤函数(Predicate)。
// 扩展Subscribe函数,增加一个过滤条件
FDelegateHandle SubscribeWithFilter(const FName& InTopic,
TFunction<bool(const TSharedPtr<FMQMessageBodyBase>&)> InFilter,
TFunction<void(const TSharedPtr<FMQMessageBodyBase>&)> InCallback);
// 在ProcessPendingMessages中,执行回调前先检查过滤条件
if (InFilter && !InFilter(Body)) continue;
Callback(Body);
5.3 对象生命周期与内存管理
这是使用消息队列(以及任何基于回调的系统)必须警惕的 内存泄漏 问题,与Handler内存泄漏原理类似。
问题根源 :
-
你订阅消息时,回调函数(常常是Lambda)捕获了
this指针(即你的UObject)。 -
你的UObject被销毁了(
EndPlay或ConditionalBeginDestroy),但 没有取消订阅 。 - 消息队列的订阅者列表里,还保存着一个指向已销毁对象的回调函数(“悬空指针”)。
- 当未来有消息发布时,系统会尝试调用这个回调,导致访问违例(崩溃)或执行无意义的逻辑。
解决方案 :
-
严格配对
:在
BeginPlay中订阅,在EndPlay中 必须 取消订阅。这是铁律。 -
使用弱引用
:在Lambda中捕获对象的弱引用。
TWeakObjectPtr<AUIPlayerHUD> WeakThis(this); SubscribeHandle = FMQCore::Get().Subscribe(Topic, [WeakThis](auto& Body){ if (AUIPlayerHUD* StrongThis = WeakThis.Get()) { StrongThis->HandleMessage(Body); } // 如果对象已销毁,WeakThis.Get()返回nullptr,什么也不做。 }); -
自动清理
:在
FMQCore的ProcessPendingMessages中,执行回调前检查弱引用是否有效,无效则从订阅者列表中移除。或者定期清理订阅者列表中的空回调。 -
使用UE的委托系统
:可以考虑用
FDelegate和FDelegateHandle来管理订阅,它内部有更完善的清理机制,但会比TFunction重一些。
5.4 性能分析与监控
当消息量很大时,需要监控队列深度、处理延迟等。
-
添加统计
:在
FMQCore中添加计数器,统计每秒发布/处理的消息数、队列平均长度、最大延迟等。 - 条件编译 :在开发版本中,可以添加详细的日志输出,记录每条消息的流向和处理时间。
- 蓝图调试支持 :可以暴露一些函数到蓝图,让设计师也能查看当前活跃的主题和订阅者数量,便于调试复杂的事件流。
6. 常见问题排查与调试技巧
在实际使用中,你肯定会遇到各种问题。这里记录一些典型坑位和解决方法。
问题1:消息发了,但没收到。
-
检查主题名
:确保发布和订阅的主题
FName完全一致,包括大小写。FName是大小写不敏感的,但最好保持一致。使用FName(TEXT(“My.Topic”))创建。 -
检查订阅时机
:是否在接收者初始化(如
BeginPlay)之前就发布了消息?确保订阅发生在可能的发布之前。对于游戏开始时就存在的对象,在BeginPlay中订阅是安全的。 -
检查生命周期
:接收者对象是否已经被销毁(
EndPlay)?如果销毁了,其订阅的回调应该被移除。 - 检查线程 :消息是在哪个线程发布的?处理回调的线程是哪个?如果回调里调用了游戏线程API,但回调在工作线程执行,就会崩溃或静默失败。 确保回调执行在正确的线程 。
问题2:程序崩溃,报错访问违例。
-
悬空指针
:这是最常见的原因。回顾
5.3 对象生命周期
部分。使用
TWeakObjectPtr并在回调中检查有效性。 -
在回调中取消订阅
:如果你在回调函数内部执行了
Unsubscribe,并且遍历订阅者列表的代码没有做保护(如我们之前做的拷贝列表),会导致迭代器失效崩溃。确保我们的ProcessPendingMessages中先拷贝回调列表再执行。 -
消息体类型转换错误
:使用
StaticCastSharedPtr向下转型时,确保消息体类型匹配。可以在消息基类中加入MessageType字符串,在转换前先进行判断。
问题3:性能下降,感觉卡顿。
- 单帧消息爆炸 :如果某一帧发布了成百上千条消息(比如粒子碰撞事件),而处理这些消息的回调函数本身很耗时(如播放音效、生成Actor),就会导致卡顿。 解决方案 :对于高频事件,考虑在发布端进行合并或节流。例如,收集一秒内所有的“得分”消息,合并成一条“总得分更新”消息再发布。
-
订阅者过多
:如果一个热门主题(如
”Tick”)有上百个订阅者,每帧遍历执行所有回调开销很大。 解决方案 :审视设计,是否真的需要这么多订阅者?能否合并逻辑?对于高频主题,回调函数应尽量轻量。 -
频繁的内存分配
:每发布一条消息都
MakeShared一个消息体,会产生大量的小内存分配。 解决方案 :对于极其高频的消息,可以考虑使用对象池(TObjectPool)来复用消息体对象。
调试技巧 :
-
可视化日志
:在
Publish和回调执行处添加UE_LOG,并附带消息主题和简要内容。使用UE_LOG(LogTemp, Verbose, TEXT(“Publishing topic %s”), *InTopic.ToString());。 -
蓝图调试节点
:创建一些简单的蓝图函数库节点,如
Print Active MQ Topics、Get MQ Queue Size,暴露给设计师,在运行时查看消息队列状态。 -
断点与探查
:在
FMQCore::ProcessPendingMessages函数开始处打条件断点,当特定主题的消息出现时中断,可以一步步跟踪消息的分发流程。
实现一个消息队列,就像是给你的游戏世界铺设了一套神经系统。各个器官(系统)不再需要直接纠缠在一起,而是通过神经信号(消息)来协同工作。一开始可能会觉得多了一层抽象有点麻烦,但当你需要修改、调试、扩展系统时,你会感谢当初引入了这套解耦的机制。尤其是在UE5.4这样功能庞杂的引擎里,良好的架构是项目能健康迭代下去的基石。从今天这个小轮子开始,尝试用消息的思维去设计你的下一个功能模块吧。
1326




被折叠的 条评论
为什么被折叠?



