UE插件间调用蓝图函数:ProcessEvent反射机制详解与实践
1. 项目概述:为什么要在UE插件间调用蓝图函数?
在虚幻引擎(Unreal Engine, 简称UE)的日常开发中,我们经常会遇到一个看似简单却颇为棘手的问题:一个用C++编写的核心功能模块(通常封装在一个插件里),需要去调用另一个独立插件中某个蓝图资源(Blueprint)里定义的函数。比如,你的“网络通信插件”在收到服务器消息后,需要通知“UI管理插件”里的某个蓝图Widget去更新界面。这两个插件彼此独立,没有直接的C++类引用关系,蓝图资源更是运行时动态加载的对象,传统的#include和直接函数调用在这里完全行不通。
这时,UObject::ProcessEvent这个底层函数就成为了连接C++世界与蓝图世界的“桥梁”。它允许你在C++端,仅凭一个UObject实例指针和一个函数名(或函数签名),就能触发该对象上定义的任何UFUNCTION,无论这个函数是在C++中声明,还是在蓝图中用节点实现的。跨插件调用的核心难点,其实就变成了如何在C++端安全、准确地获取到目标蓝图对象的实例,并构造正确的参数。
我之所以花时间研究这个方案,是因为在大型项目或插件化架构中,强制让所有插件产生编译期依赖是一种糟糕的设计。它会导致代码耦合度剧增,编译时间变长,并且不利于模块的独立测试与分发。使用ProcessEvent进行动态调用,是一种基于接口而非实现的松耦合通信方式,虽然会引入一些运行时检查和性能开销,但在架构清晰度和灵活性上带来的收益是巨大的。
2. 核心原理:ProcessEvent与虚幻引擎的反射系统
要理解ProcessEvent,必须先了解虚幻引擎强大的反射(Reflection)系统。C++本身不具备运行时类型信息(RTTI)的完整能力,而UE通过一套宏(如UCLASS,UFUNCTION,UPROPERTY)和代码生成工具(Unreal Header Tool, UHT),为C++类注入了丰富的元数据(Metadata)。这使得引擎在运行时能够查询一个UObject派生类拥有哪些属性、函数,以及函数的参数和返回类型。
ProcessEvent是这个反射系统的执行引擎之一。它的函数签名如下:
void UObject::ProcessEvent( UFunction* Function, void* Parms );UFunction* Function: 这是一个指向要执行的函数反射信息的指针。它包含了函数名、参数列表、返回类型、标志位等所有元数据。void* Parms: 这是一个指向一块内存的指针,这块内存包含了要传递给函数的所有参数,以及用于接收返回值的空间。参数的内存布局必须与UFunction描述的结构完全一致。
当你调用一个蓝图函数时,无论是通过蓝图节点还是C++,底层最终都可能走到ProcessEvent。C++直接调用声明为UFUNCTION的函数时,编译器生成的代码会帮你处理好UFunction查找和参数内存的构建。而我们现在要做的,就是手动模拟这个过程,实现“间接调用”。
为什么能跨插件?因为反射是基于运行时类型的。只要你能在运行时获得目标蓝图对象(一个UObject*)和其函数签名(一个UFunction*),无论它们来自哪个模块或插件,ProcessEvent都能正确执行。插件边界在编译期是壁垒,在运行时通过反射系统可以被穿透。
3. 方案设计与关键步骤拆解
整个流程可以分解为四个关键步骤,每一步都有需要注意的细节和潜在的坑。
3.1 步骤一:定位并加载目标蓝图资源
目标蓝图通常是一个资源文件(.uasset)。在插件环境中,你不能使用硬编码的路径,因为插件的安装目录可能变化。正确的方法是使用FSoftObjectPath或FName结合插件的命名规则来构造引用。
1. 确定资源路径:假设目标插件名为PluginB, 蓝图资源位于其内容目录下:/Game/PluginB/UI/Widgets/WBP_StatusMessage。 在C++代码中,你需要构造一个指向该资源的软引用或直接加载。
// 使用软引用,适合异步加载或提前引用 FSoftObjectPath WidgetPath(TEXT("/Game/PluginB/UI/Widgets/WBP_StatusMessage.WBP_StatusMessage_C")); // 注意:蓝图类资源路径需要加上‘_C’后缀,表示其生成的类。 // 或者,如果你知道插件安装后的绝对内容根目录(较不推荐) // FString FullPath = IPluginManager::Get().FindPlugin("PluginB")->GetContentDir() / TEXT("UI/Widgets/WBP_StatusMessage.uasset");2. 异步加载蓝图类:为了避免主线程卡顿,推荐使用异步加载。你需要加载的是该蓝图的类对象(UClass*),而不是一个实例。
FStreamableManager& Streamable = ... // 获取一个资源流管理器实例 TSharedPtr<FStreamableHandle> Handle = Streamable.RequestAsyncLoad(WidgetPath, [](FSoftObjectPath) { // 加载完成回调 UClass* WidgetClass = Cast<UClass>(StaticLoadObject(UClass::StaticClass(), nullptr, *WidgetPath.ToString())); if (WidgetClass) { // 存储WidgetClass供后续使用 } });> 注意:确保你的插件(PluginA)的.uplugin描述文件中,LoadingPhase设置合理(例如PostConfigInit),并且对PluginB有可选的依赖声明,这样引擎才会在合适的时间加载PluginB的内容。直接加载一个未加载插件中的资源会失败。
3.2 步骤二:获取目标对象的UObject实例指针
获得了蓝图类(UClass*)之后,下一步是获取你想要调用的那个特定实例的指针。这是整个流程中最容易出错的一环,因为你需要一种跨插件的对象查找机制。
常见方案:
通过游戏实例(GameInstance)或子系统(Subsystem)注册:这是最稳健的方式。让PluginB中的蓝图对象在创建时(如
BeginPlay),将自己注册到一个双方都能访问的全局管理器或子系统中。这个管理器可以定义在另一个公共插件(PluginCommon)里,或者使用引擎自带的UGameInstanceSubsystem。// 在公共头文件(PluginCommon)中定义接口 class IStatusWidgetProvider { public: virtual UObject* GetStatusWidgetObject() = 0; }; // 在PluginB的蓝图控制器C++类中实现 class UPluginBWidgetController : public UObject, public IStatusWidgetProvider { ... virtual UObject* GetStatusWidgetObject() override { return StatusWidgetInstance; } // 返回UObject* }; // 在PluginA中,通过GameInstance获取接口 IStatusWidgetProvider* Provider = GameInstance->GetSubsystem<UMyGlobalSubsystem>()->GetWidgetProvider(); if (Provider) { TargetObject = Provider->GetStatusWidgetObject(); }通过标签(Tag)或名称查找:如果目标Actor或Component有一个已知且唯一的标签或名称,可以使用
FindObject或遍历世界中的Actor来查找。这种方法耦合度低但效率也低,且不够可靠。// 查找Actor for (TActorIterator<AActor> It(World); It; ++It) { if (It->ActorHasTag(FName(TEXT("TargetStatusWidgetActor")))) { TargetObject = *It; break; } }通过依赖注入传递引用:在游戏初始化阶段,由上层系统将PluginB的对象引用显式地设置到PluginA的某个对象中。这要求项目有统一的初始化流程。
> 实操心得:强烈推荐使用第一种(基于接口的注册/查找)方案。它虽然需要前期设计一些基础设施,但带来了清晰的依赖关系和极高的可靠性。避免使用全局变量或裸指针直接存储跨插件对象引用,因为对象的生命周期难以管理。
3.3 步骤三:查找并准备UFunction
有了TargetObject(UObject*)和函数名,就可以查找UFunction了。
FString FunctionName = TEXT("UpdateStatusMessage"); // 蓝图中的函数名 UFunction* Function = TargetObject->FindFunction(FName(*FunctionName)); if (!Function) { // 函数未找到,可能是函数名错误、函数不是UFUNCTION、或蓝图未编译 UE_LOG(LogTemp, Error, TEXT("Function %s not found on object %s"), *FunctionName, *TargetObject->GetName()); return; }关键点:
- 函数必须在蓝图中被定义为事件(Event)或函数(Function),并且其**“纯”**选项需要根据情况设置。如果C++端需要传递参数,则该函数不能是纯函数。
- 函数需要勾选
Call in Editor或Callable吗?对于运行时ProcessEvent调用,通常不需要。但将其标记为BlueprintCallable是一个好习惯,这不会影响ProcessEvent,但能让其他蓝图调用它,使函数用途更清晰。 - 函数名大小写敏感。蓝图中的函数名是
UpdateStatus,你就不能查找updateStatus。
3.4 步骤四:构建参数内存并执行ProcessEvent
这是最需要小心的一步。你需要手动分配一块内存,其布局必须与UFunction所描述的参数列表完全匹配,包括可能的返回值。
1. 分析函数签名:假设蓝图函数定义如下:
// 蓝图函数签名 UpdateStatusMessage(FText NewMessage, int32 Priority, bool& bWasSuccessful)它有两个输入参数(FText,int32)和一个输出参数(bool&)。
2. 计算参数结构:在内存中,参数是按特定顺序排列的。对于非静态的UFUNCTION,第一个参数永远是this(即执行该函数的对象实例)的隐式参数,但其内存空间由ProcessEvent内部管理,我们不需要在参数块中提供。我们需要提供的是函数声明的参数。 但是,更安全通用的方法是使用FProperty系统来设置和获取参数值。
3. 使用TFieldIterator和FProperty(推荐方法):这种方法避免了手动计算内存偏移,更安全。
// 1. 分配参数内存块。FMemory::Malloc分配的内存需要手动管理生命周期。 uint8* Params = (uint8*)FMemory::Malloc(Function->ParmsSize, Function->ParmsAlignment); // 务必初始化内存为零,避免未初始化的值导致崩溃。 FMemory::Memzero(Params, Function->ParmsSize); // 2. 遍历函数的属性(参数),并设置值。 for (TFieldIterator<FProperty> It(Function); It && (It->PropertyFlags & CPF_Parm); ++It) { FProperty* Prop = *It; // 区分输入参数和输出参数 if (Prop->HasAnyPropertyFlags(CPF_OutParm) && !Prop->HasAnyPropertyFlags(CPF_ConstParm | CPF_ReturnParm)) { // 输出参数(如 bool& bWasSuccessful),通常由函数内部填充,我们只需要提供地址。 // 这里可以先初始化一个默认值。 if (FBoolProperty* BoolProp = CastField<FBoolProperty>(Prop)) { bool* ValuePtr = BoolProp->ContainerPtrToValuePtr<bool>(Params); *ValuePtr = false; // 初始化为false } } else if (!Prop->HasAnyPropertyFlags(CPF_OutParm) || Prop->HasAnyPropertyFlags(CPF_ReturnParm)) { // 输入参数 或 返回值 if (FTextProperty* TextProp = CastField<FTextProperty>(Prop)) { FText* ValuePtr = TextProp->ContainerPtrToValuePtr<FText>(Params); *ValuePtr = FText::FromString(TEXT("Hello from PluginA!")); } else if (FIntProperty* IntProp = CastField<FIntProperty>(Prop)) { int32* ValuePtr = IntProp->ContainerPtrToValuePtr<int32>(Params); *ValuePtr = 5; // 设置Priority为5 } // 注意:返回值属性(CPF_ReturnParm)的处理通常在执行后。 } } // 3. 执行函数 TargetObject->ProcessEvent(Function, Params); // 4. 读取输出参数和返回值 bool bSuccess = false; for (TFieldIterator<FProperty> It(Function); It && (It->PropertyFlags & CPF_Parm); ++It) { FProperty* Prop = *It; if (Prop->HasAnyPropertyFlags(CPF_OutParm) && !Prop->HasAnyPropertyFlags(CPF_ConstParm)) { if (FBoolProperty* BoolProp = CastField<FBoolProperty>(Prop)) { bool* ValuePtr = BoolProp->ContainerPtrToValuePtr<bool>(Params); bSuccess = *ValuePtr; // 获取函数执行后bWasSuccessful的值 UE_LOG(LogTemp, Log, TEXT("UpdateStatusMessage returned: %s"), bSuccess ? TEXT("true") : TEXT("false")); } } if (Prop->HasAnyPropertyFlags(CPF_ReturnParm)) { // 处理返回值,例如函数是 int32 GetSomething() if (FIntProperty* IntProp = CastField<FIntProperty>(Prop)) { int32* ReturnValuePtr = IntProp->ContainerPtrToValuePtr<int32>(Params); UE_LOG(LogTemp, Log, TEXT("Function returned: %d"), *ReturnValuePtr); } } } // 5. 释放参数内存 FMemory::Free(Params);> 注意事项:
- 内存对齐:使用
Function->ParmsAlignment进行分配至关重要,错误的对齐会导致访问违规(Access Violation)崩溃。 - 参数顺序:
TFieldIterator遍历属性的顺序就是参数在内存中的顺序,这通常与函数声明顺序一致,但依赖迭代器是最安全的。 - 复杂类型:对于
UObject*、TArray、FStruct等复杂类型,需要使用对应的FObjectProperty、FArrayProperty、FStructProperty来正确设置和复制数据。直接内存拷贝对于这些类型是危险的。 - 性能:频繁地查找
UFunction和分配参数内存会有开销。对于高频调用的函数,应考虑缓存UFunction*指针和参数内存块(或使用对象池复用内存)。
4. 完整代码示例与封装实践
将上述步骤封装成一个工具函数或类,可以极大提升代码的复用性和安全性。
// PluginAHelper.h #pragma once #include "CoreMinimal.h" #include "UObject/NoExportTypes.h" #include "PluginAHelper.generated.h" UCLASS() class PLUGINA_API UBlueprintFunctionInvoker : public UObject { GENERATED_BODY() public: /** * 跨插件调用蓝图对象上的函数。 * @param TargetObject 目标蓝图对象实例。 * @param FunctionName 要调用的函数名。 * @param InParams 输入参数的映射(参数名 -> 参数值)。支持FText, int32, float, bool, FString, FName。 * @param OutParams 输出参数的映射(参数名 -> 输出值指针)。 * @return 是否成功找到函数并尝试调用。 */ UFUNCTION(BlueprintCallable, Category = "PluginA|Utils") static bool InvokeBlueprintFunction( UObject* TargetObject, const FString& FunctionName, const TMap<FString, FGenericStruct>& InParams, TMap<FString, FGenericStruct>& OutParams ); private: // 内部方法,用于设置特定类型的属性值 static bool SetPropertyValue(FProperty* Prop, void* ParamsPtr, const FGenericStruct& Value); static bool GetPropertyValue(FProperty* Prop, void* ParamsPtr, FGenericStruct& OutValue); }; // PluginAHelper.cpp #include "PluginAHelper.h" #include "Engine/Engine.h" // 这里需要一个通用的参数容器,实际项目中可能需要定义更完善的FVariant类型或使用第三方库。 // 为简化示例,我们假设FGenericStruct是一个能容纳基础类型的自定义结构体。 // 实际实现中,你可以使用`TSharedPtr<FPropertyValue>`或类似机制。 bool UBlueprintFunctionInvoker::InvokeBlueprintFunction(UObject* TargetObject, const FString& FunctionName, const TMap<FString, FGenericStruct>& InParams, TMap<FString, FGenericStruct>& OutParams) { if (!TargetObject || FunctionName.IsEmpty()) { return false; } UFunction* Function = TargetObject->FindFunction(FName(*FunctionName)); if (!Function) { UE_LOG(LogTemp, Warning, TEXT("Function %s not found on object %s"), *FunctionName, *TargetObject->GetName()); return false; } // 分配参数内存 uint8* Params = (uint8*)FMemory::Malloc(Function->ParmsSize, Function->ParmsAlignment); FMemory::Memzero(Params, Function->ParmsSize); // 设置输入参数 for (TFieldIterator<FProperty> It(Function); It && (It->PropertyFlags & CPF_Parm); ++It) { FProperty* Prop = *It; FString ParamName = Prop->GetName(); // 如果是输入参数(非仅输出,或为返回参数) if (!Prop->HasAnyPropertyFlags(CPF_OutParm) || (Prop->HasAnyPropertyFlags(CPF_ReturnParm))) { const FGenericStruct* InputValue = InParams.Find(ParamName); if (InputValue) { if (!SetPropertyValue(Prop, Params, *InputValue)) { UE_LOG(LogTemp, Warning, TEXT("Failed to set input parameter %s for function %s"), *ParamName, *FunctionName); } } // 注意:如果蓝图函数参数有默认值,这里没找到传入值也应该没问题,因为内存已清零。 } // 初始化输出参数(如果需要) else if (Prop->HasAnyPropertyFlags(CPF_OutParm)) { // 可以在这里为输出参数设置一个初始值,例如bool初始化为false。 // 示例略。 } } // 执行调用 TargetObject->ProcessEvent(Function, Params); // 读取输出参数和返回值 for (TFieldIterator<FProperty> It(Function); It && (It->PropertyFlags & CPF_Parm); ++It) { FProperty* Prop = *It; FString ParamName = Prop->GetName(); if (Prop->HasAnyPropertyFlags(CPF_OutParm) && !Prop->HasAnyPropertyFlags(CPF_ConstParm)) { FGenericStruct OutValue; if (GetPropertyValue(Prop, Params, OutValue)) { OutParams.Add(ParamName, OutValue); } } if (Prop->HasAnyPropertyFlags(CPF_ReturnParm)) { FGenericStruct ReturnValue; if (GetPropertyValue(Prop, Params, ReturnValue)) { OutParams.Add(TEXT("ReturnValue"), ReturnValue); } } } // 清理 // 注意:对于包含UObject*等引用的参数,可能需要调用析构函数。 // 简单类型直接Free即可。复杂类型需要更细致的处理。 for (TFieldIterator<FProperty> It(Function); It && (It->PropertyFlags & CPF_Parm); ++It) { FProperty* Prop = *It; Prop->DestroyValue_InContainer(Params); // 销毁容器内的属性值 } FMemory::Free(Params); return true; } // SetPropertyValue 和 GetPropertyValue 的实现需要处理各种FProperty类型,这里是一个框架。 bool UBlueprintFunctionInvoker::SetPropertyValue(FProperty* Prop, void* ParamsPtr, const FGenericStruct& Value) { // 根据Prop的类型(FIntProperty, FStrProperty, FBoolProperty等) // 将Value中的值取出,并设置到ParamsPtr指向的内存中(使用ContainerPtrToValuePtr)。 // 这是一个繁琐但必需的类型转换层。 // 示例:处理Bool if (FBoolProperty* BoolProp = CastField<FBoolProperty>(Prop)) { bool* Ptr = BoolProp->ContainerPtrToValuePtr<bool>(ParamsPtr); *Ptr = Value.GetBool(); // 假设FGenericStruct有GetBool方法 return true; } // ... 处理其他类型 return false; }这个封装将复杂的参数内存管理隐藏起来,对外提供相对简单的字典接口。在实际项目中,你可能会使用TSharedRef<FPropertyValue>或类似UE内部使用的变体类型来更安全地传递参数。
5. 常见问题、性能考量与最佳实践
5.1 常见问题与排查
崩溃:访问违规(Access Violation)
- 原因A:参数内存块(
Params)大小或对齐方式错误。务必使用Function->ParmsSize和Function->ParmsAlignment。 - 原因B:
TargetObject指针无效或已被垃圾回收。确保对象生命周期有效,使用IsValid(TargetObject)检查。 - 原因C:在设置或读取复杂类型(如
FString,TArray)时,未使用对应的FProperty派生类进行正确的内存操作。对于FString,必须使用FStrProperty并调用赋值操作符或拷贝构造函数。
- 原因A:参数内存块(
函数调用成功,但蓝图端没反应
- 原因A:蓝图函数是“纯”函数(Pure)。纯函数不允许修改对象状态或产生副作用,通常用于获取值。
ProcessEvent可以调用它,但如果你期望它改变UI或播放音效,它可能不会执行。确保函数不是纯函数。 - 原因B:蓝图函数在错误的游戏线程上被调用。某些蓝图节点(如延迟
Delay、时间线Timeline)需要游戏线程上下文。确保你的ProcessEvent调用发生在游戏线程(主线程)。 - 原因C:参数值设置错误,导致蓝图逻辑分支未触发。使用UE的日志系统在蓝图函数开头打印传入的参数,进行调试。
- 原因A:蓝图函数是“纯”函数(Pure)。纯函数不允许修改对象状态或产生副作用,通常用于获取值。
FindFunction 返回 nullptr
- 原因A:函数名拼写错误或大小写不匹配。
- 原因B:该函数在蓝图中未被标记为可调用(虽然
ProcessEvent不强制要求,但FindFunction可能查找不到某些内部函数)。确保它是一个普通的Event或Function。 - 原因C:蓝图资源未编译或已损坏。尝试在编辑器中重新编译目标蓝图。
5.2 性能考量
- 缓存UFunction:* 如果同一函数需要被多次调用,应该在首次查找后缓存
UFunction*指针。查找UFunction本身是有开销的。 - 避免高频调用:
ProcessEvent的调用开销比直接的C++虚函数调用大得多。避免在每帧Tick中调用复杂的蓝图函数。 - 参数内存复用:对于调用非常频繁且参数结构固定的函数,可以考虑复用参数内存块,而不是每次都分配和释放。但要注意线程安全和内存泄漏。
5.3 最佳实践总结
- 设计先行:优先考虑通过接口(Interface)或事件分发器(Event Dispatcher/Dynamic Multicast Delegate)进行跨插件通信。
ProcessEvent应作为“最后的手段”,当双方无法共享头文件或需要极致动态性时才使用。 - 明确契约:将需要跨插件调用的蓝图函数签名(名称、参数类型、返回类型)以文档或共享注释的形式固定下来。一旦修改,调用方代码必须同步更新。
- 错误处理:对
FindFunction、对象有效性、参数类型转换等每一步都进行健壮的错误检查,并记录清晰的日志。 - 生命周期管理:密切关注目标蓝图对象的生命周期。使用弱引用(
TWeakObjectPtr)或依赖UE的垃圾回收机制,防止悬挂指针。 - 线程安全:
ProcessEvent和大多数蓝图交互必须在游戏线程进行。如果从其他线程发起调用,需要使用AsyncTask或FFunctionGraphTask将其派发到游戏线程。 - 封装与抽象:如示例所示,将复杂的调用逻辑封装成工具函数或类,向业务代码提供简洁的API,隔离底层反射的复杂性。
6. 替代方案与场景对比
虽然ProcessEvent很强大,但它不是唯一的跨插件通信方式。了解其他方案有助于做出更合适的选择。
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| ProcessEvent | 利用UE反射系统动态查找并调用函数。 | 1. 无需编译期依赖。 2. 高度动态,函数名和参数可在运行时决定。 3. 可直接调用任意蓝图 UFUNCTION。 | 1. 使用复杂,易出错。 2. 性能开销相对较大。 3. 类型安全靠手动保证,编译器无法检查。 | 1. 调用第三方、无法修改的插件蓝图。 2. 实现高度动态的插件系统(如Mod支持)。 3. 作为兜底机制,当其他通信方式不适用时。 |
| 接口(Interface) | 在公共模块定义C++接口,双方插件分别实现和调用。 | 1. 类型安全,编译器可检查。 2. 性能好,接近虚函数调用。 3. 代码清晰,依赖明确。 | 1. 需要公共头文件,产生编译期依赖。 2. 接口一旦发布,修改成本高。 | 1. 插件间有明确的、稳定的功能契约。 2. 项目内部插件,允许存在公共依赖模块。 |
| 事件分发器(Delegate) | 在公共模块定义多播委托,一方绑定,另一方广播。 | 1. 松耦合,广播方无需知道接收方。 2. 支持一对多通信。 3. 蓝图和C++均可方便使用。 | 1. 需要公共头文件定义委托签名。 2. 需要管理绑定的生命周期(解绑)。 | 1. 通知类事件(如“玩家死亡”、“资源加载完成”)。 2. 系统间状态同步。 |
| 子系统(Subsystem) | 通过UGameInstanceSubsystem或UWorldSubsystem提供全局访问点。 | 1. 引擎管理生命周期,自动创建销毁。 2. 提供清晰的单例访问模式。 3. 蓝图暴露友好。 | 1. 子系统本身通常定义在某个模块中,其他插件需要依赖该模块。 | 1. 提供全局管理服务(如成就系统、音频管理器)。 2. 作为插件间通信的中介注册中心。 |
| 消息总线(Message Bus) | 使用类似IMessageContext的发布-订阅模式。 | 1. 极度松耦合,通信双方完全不知晓对方。 2. 支持异步和跨进程。 | 1. UE内置支持较弱,可能需要第三方或自定义实现。 2. 复杂度高,调试困难。 | 1. 大型分布式插件架构。 2. 编辑器工具与运行时游戏通信。 |
选择建议:在新项目或模块设计初期,优先考虑接口和事件分发器。当遇到必须调用一个“黑盒”插件内的特定蓝图函数,且无法引入公共依赖时,ProcessEvent才是你的王牌。它给了你最大的灵活性,但也要求你承担更多的责任。
