实现说明

系统简述

一种允许在特定时机执行指定操作的机制,通常需要包括事件源、事件监听、事件调度等部分。

通过事件系统,我们可以更为方便地实现两个较为独立的系统之间的调用。

而引入一个好用的事件系统之后,我们也可以更多的使用“观察者模式”设计逻辑。

观察者是一种行为设计模式,允许你定义一种订阅机制,可在对象事件发生时通知多个“观察”该对象的其他对象。——观察者设计模式 (refactoringguru.cn)

设计思路

我们需要提供一个更为完善的事件系统,从而达到实现类似需求时,无需再建立功能向的事件系统。

该系统应该实现以下几点目标:

  • 易用性
    • 应尽可能减少系统的理解门槛
      • 提高逻辑的可读性
      • 完善系统的相关文档
      • 规范事件系统的维护流程
    • 应能便捷的由某一系统发送事件
      • C++中发送
      • 蓝图中发送
      • Lua中发送
    • 应能便捷的由某一系统监听事件
      • C++中监听
      • 蓝图中监听
      • Lua中监听
  • 扩展性
    • 应能便捷的扩展一种新事件
    • 提高技术方案的严谨性避免出现不兼容更新
  • 全面性
    • 应能处理DS与客户端的同步问题
    • 应能解决C++&蓝图&Lua的三方通信
    • 应能处理多监听者顺序问题
  • 鲁棒性
    • 尽量减少使用事件系统时的强制要求
  • 性能
    • 应在运行时减少不必要的事件分发]

功能实现

事件定义

使用Class作为指定事件的一种索引,防止纯Name式带来的误操作,同时也方便后续以Event为单位扩展。

我们定义一个Object类型UDidaEvent作为事件的定义,事件对象管理他的所有监听器。

同时我们提供了Event类与FName的转换,这对于高效标记某一事件较为使用。

可以调用UDidaEventLibrary::GetEventKeyByClass等EventKey接口来使用。

事件管理

我们定义一个以UGameInstanceSubsystem为基类的UEventSubsystem作为事件的管理类。

EventSubsystem会在需要的时候创建事件的实例。

事件触发

基础调用

定义了一个函数库UDidaEventLibrary,他具有触发事件的静态函数InvokeEvent。

触发函数应具有如下参数:

参数描述
Outer用作实例化Payload的Outer,同时会被用作获取EventSubsystem
Event所需要触发的事件
Channel如果不为None,只有不设Channel或相同Channel的监听会被触发
Payload需要指定的载荷参数的类型

高级调用

如果每次都需要开发者自己实例化Payload会让触发事件较为复杂,所以我们定义一个更好用的K2Node。

继承UK2Node可以自己实现一个高级结点UK2Node_DidaInvokeEvent。

核心函数为AllocateDefaultPins与ExpandNode,他们分别定义了结点的所有Pin与Pin之间的连接关系。

跨端调用

在事件触发函数的所有参数中,因为Payload是开发者自定义的,所以其需要设计数据如何编码解码。

通过UE4的反射机制可以实现如下函数:

FString UDidaEventPayload::MakeString_Implementation() const
{
   FString String;
   FJsonObjectConverter::UStructToJsonObjectString(GetClass(), this, String, 0, 0, 0, nullptr, false);
   return String;
}

他会把类中所有数据编码为一个Json字符串,同理可以实现解码函数:

void UDidaEventPayload::LoadString_Implementation(const FString& String)
{
   TSharedPtr<FJsonObject> JsonObject;
   const TSharedRef<TJsonReader<>> JsonReader = TJsonReaderFactory<>::Create(String);
   if (!FJsonSerializer::Deserialize(JsonReader, JsonObject) || !JsonObject.IsValid())
   {
      FString Name;
      if(IsValid(GetClass()))
      {
         Name = GetClass()->GetName();
      }
      LOG_CATEGORY_WARN(LogDidaEvent, "{} Failed", Name);
      return;
   }
   FJsonObjectConverter::JsonObjectToUStruct(JsonObject.ToSharedRef(), GetClass(), this);
}

通过这种设计,可以实现Payload在网络上的传输,同时提供了开发者自定义编码解码规则的空间。

事件监听

定义了一个IDidaEventListenerInterface作为触发器通用函数的定义,主要用于RegisterListener与UnregisterListener函数。

当注册时,会把监听器注册在DidaEvent的监听器列表中以供触发时查找。

性能优化

考虑大部分监听器只关心自己所需关心的触发,因此引入了Channel频道机制。

思路上只有注册与广播相同Channel的事件会被触发(不指定Channel应所有都触发)。

  • Invoke一个None,所有Listen都会被触发(无论Listen有没有指定Channel)
  • Invoke一个Channel,相同Channel的Listen会被触发
  • Listen一个None,所有Invoke都会接收(无论Invoke有没有指定Channel)
  • Listen一个Channel,相同Channel的Invoke会接收

对应的提供了如何使用Object来动态生成Channel的接口UDidaEventLibrary::GetObjectChannel。

监听器顺序

在大部分时候我们不需要关系多个监听器之间的顺序,他们应该是独立执行的。

但在有些时候,对于同一时间我们喜欢触发一部分监听器时他们能按定义的顺序执行,因此在监听器中引入了ListenerPriority机制。

Listener应实现优先级层级PriorityLayer与优先级数值ListenerPriority。

会根据优先级进行排序来决定触发事件的顺序,排序时会先根据优先级分层排序,再根据优先级大小排序,枚举值越大优先级越大,优先级大的会被排在前面。

为了语义清晰,目前定义了几个特殊的层,除了这几个特殊的分层,其余根据枚举值的大小来确认优先级。

语义化分层转义为
DefaultLayer_5
LowLayer_3
HighLayer_7

当添加这些分层时,会被转译为对应的分层。

为了定义清晰,应优先使用分层来解决多监听器顺序问题,避免仅使用数值导致的难以维护问题。

C++相关逻辑

一般使用DidaEventLibrary函数库中的相关函数来调用,传入所需参数即可。

  • UDidaEventLibrary::CreateListenerVariable创建一个监听器
  • UDidaEventLibrary::InvokeEvent触发一个事件
  • UDidaEventLibrary::SpawnDidaEventPayload<UDidaEventPayload_XXX>创建载荷
  • UDidaEventLibrary::GetObjectChannel获取Object的Channel