前言
这是对上一篇消息系统的重置和补充,之前的消息系统在使用上并不灵活,使用起来过于死板,于是参考了Lyra的消息系统并对原有的项目进行改进。
在新的消息系统中,将使用充分使用C++模板函数和UScriptStruct的特性,使得消息统统能够支持更灵活的定义和数据传递。
项目使用UE版本为5.2。
消息系统概述
消息系统的一个重要特性就是可以降低代码的耦合度,使代码更加简洁。
在UE中,如果当一个对象需要向另一个对象想要对另一个对象的某些变化做出响应,一般而言可以使用使用虚幻的委托来实现这个功能,但美中不足的是我们依然需要先在两个对象间建立联系随后绑定一方的委托,这依然带来了代码耦合度的提升。所以为了进一步优化我们的代码,我们便可以使用消息系统,当一个对象需要对一些变化做出响应时,就只需要监听一条特定的消息,等待消息的发起者将消息广播给所有监听者即可,这样一来我们便不用关系消息的发起者是谁,只需要处理消息发送过来的数据即可。
为了实现上述描述的消息系统,我们需要实现以下功能:
- 提供消息的监听功能
- 消息的发起者能够将消息发送给所有监听者
- 消息系统能够将消息发起者发送的数据传递给所有监听者
- 在监听者不需要时能够及时对消息进行解绑
为了方便管理我们的消息,所以我们可以先创建一个消息子系统用于管理所有的消息,之后所有的功能都将在这个类里实现。
注册消息
注册消息的目的是能够监听消息的变化并处理一些数据,所以我们需要一个注册的消息通道和一个接收到的数据类型,在UE中,我们使用FGameplayTag来作为消息通道,使用UStruct来作为绑定的数据类型。
因为我们可以知道这个监听者的回调函数应该长这样:
1 | void Callback(FGameplayTag Channel, const FSomeStruct& SomeStruct) |
为了能匹配通道类型和数据类型,我们将这个回调函数作为观察者注册进消息系统,当进行广播时由消息系统来调用这个回调函数。
这样我们的注册函数的样子大致如下:
1 | void Register(FGameplayTag Channel, class SomeClass* Owner, void(SomeClass::*Callback)(FGameplayTag, const FSomeStruct&)); |
为了能够对类型进行匹配,我们将其改为模板函数,并在这个函数中对传进来的参数进行一些处理,在这一步对消息数据的类型进行解析同时将回调函数进行注册:
1 | // 这个的Owner不一定会是UObject类型,只是UE中大多数的类都是使用UObject的,所以这里使用UObject作为默认类型 |
对回调函数进行注册
先看代码:
1 | template<typename FMessageStructType, typename Owner = UObject> |
这一步我们先创建了一个Owner的弱引用并在lambda表达式中捕获,并且在函数体中执行Owner的回调函数,在这一步需要确保回调函数的参数和数据类型能够匹配。
对数据的类型进行解析
这一步我们需要对回调函数进一步封装,使之能够获取正确的数据类型:
1 | template<typename FMessageStructType, typename Owner = UObject> |
在这一步我们定义了一个Thunk函数,这个函数的主要作用是将发送过来的数据转化为与回调函数类型匹配的数据
在这一步完成后我们便完成了对注册内容的初步解析,随后我们需要将解析的内容保存在消息系统中,所以我们使用一个结构体来对消息进行保存:
1 | USTRUCT() |
有了存储的结构以后还需要让消息系统持有这个结构体,已知一个通道可以存储多个监听,又有多个消息通道,所以选择使用TMap的key来存储所有的消息通道,用一个数组来存储所有该消息通道下的监听者。为了便于使用,需要将数组进行一层包装:
1 | /** |
1 | UCLASS() |
随后我们将解析的内容保存进这个结构体并由消息系统持有:
1 | void RegisterListener_Internal(FGameplayTag Channel, TFunction<void(FGameplayTag, const void*)>&& Callback, const UScriptStruct* StructType) |
直到这一步我们便完成了消息的注册功能,接下来将消息进行广播。
广播消息
这一步就比较简单,广播消息时只需要设置消息的通道和发送的数据即可,在消息系统内部会对数据进行处理,对外提供如下方法:
1 | template<typename FMessageStructType> |
从消息的注册到广播可以发现对回调的处理是由内及外和由外及内的,在注册时,我们对回调函数进行封装处理使之变成广播时易于调用的类型,在广播时又逐层解析调用真正的回调函数。这一切都发生在Thunk函数中,这个函数便是消息系统能够支持任意类型数据结构的基石之一。
对消息的解绑
当一个对象不在需要监听消息时便需要对消息进行解绑。
当下的问题时,我们只在消息注册时保存了这份消息,而注册者却什么都不知道,所以在注册成功后我们需要返回一个句柄,当注册者不再需要监听时便通过这个句柄来解除绑定,所以我们需要一个句柄结构体同时对现有的一些结构体进行一些小小的修改:
1 | USTRUCT(BlueprintType) |
在Handle和Data这两个结构体中都包含一个HandleId,这个id由消息系统分配并由Handle和Data各自持有一份,在解绑时通过对比HandleId来确认是否为同一个注册的消息。
我们对外提供解绑方法:
1 | void Unregister(FGameplayMessageHandle& Handle) |
在Handle中我们同样提供了解绑的方法,该方法对消息系统提供的解绑方法进行了一次封装,这样在解绑时我们可以直接调用这个Handle中的Unregister方法。
1 | void Unregister() |
至此我们便完成了消息系统的三个基本功能,接下来我们可以在一个类中进行测试:
1 | USTRUCT(BlueprintType) |
我们在蓝图中分别调用这几个函数进行测试,可以看到消息正常发送和接受。
总结
这里我们实现了在C++中使用消息系统,但UE并不只有C++,消息系统需要同样支持蓝图调用才算完整,后面我们会对消息系统进行蓝图支持。
暂时完整的代码
代码中对获取方法提供了封装,使之更易于调用。
此外我们还可以使用宏对注册和广播进行封装,使之更加简洁易用,不过这里并未提供。
提供的代码与上文中的代码略有不同,主要在于参数传递时有一个UScriptStruct*类型的参数,这个是用于提供蓝图支持的,暂时可以忽略。
头文件
1 | // Fill out your copyright notice in the Description page of Project Settings. |
源文件
1 | // Fill out your copyright notice in the Description page of Project Settings. |