虚幻引擎中的反射(译)
原文链接:https://www.unrealengine.com/en-US/blog/unreal-property-system-reflection?sessionInvalidated=true
反射是程序的一种能力,借助于它可以在运行时查看自身。作为虚幻引擎中的基础技术,它相当有用,增强了众多的系统比如编辑器中的属性面板,对象序列化,垃圾回收,网络对象传输以及蓝图脚本和C++之间的通信等。不过C++语言本身并不提供任何形式的反射,因此虚幻引擎实现了一套自己的反射系统,通过它来收集,查询和修改C++中的类,结构,函数,成员变量和枚举的信息。在本文中我们提到反射通常是指属性系统,而不是图形学中的概念。
反射系统是可选的。如果你希望某些类型或者属性对反射系统可见,那么就必须给它们加上修饰宏,这样在编译工程时Unreal Header Tool (UHT) 才会去收集这些信息。
标示
为了标示一个头文件包含了反数据类型,我们需要在文件的头部包含一个特殊的文件。UHT会识别出这个文件需要处理,并且也会为该头文件加上反射系统的实现代码(更多的介绍请参见“反射的实现原理”)。示例如下:
#include "FileName.generated.h"
这时你就可以使用UENUM(), UCLASS(), USTRUCT(), UFUNCTION(), 以及 UPROPERTY()来修饰头文件中不同的类和类成员了。这几个宏必须加在类和成员声明的前面,另外还可以加上一些额外的特殊关键字。让我们来看看一个来自实际项目例子(来自StrategyGame):
//////////////////////////////////////////////////////////////////////////
// Base class for mobile units (soldiers)
#include "StrategyTypes.h"
#include "StrategyChar.generated.h"
UCLASS(Abstract)
class AStrategyChar : public ACharacter, public IStrategyTeamInterface
{
GENERATED_UCLASS_BODY()
/** How many resources this pawn is worth when it dies. */
UPROPERTY(EditAnywhere, Category=Pawn)
int32 ResourcesToGather;
/** set attachment for weapon slot */
UFUNCTION(BlueprintCallable, Category=Attachment)
void SetWeaponAttachment(class UStrategyAttachment* Weapon);
UFUNCTION(BlueprintCallable, Category=Attachment)
bool IsWeaponAttached();
protected:
/** melee anim */
UPROPERTY(EditDefaultsOnly, Category=Pawn)
UAnimMontage* MeleeAnim;
/** Armor attachment slot */
UPROPERTY()
UStrategyAttachment* ArmorSlot;
/** team number */
uint8 MyTeamNum;
[more code omitted]
};
这个头文件声明了一个继承自ACharacter的类AStrategyChar。UCLASS()来指定该类具有反射特性。与UCLASS()对应的,我们还在类定义内部插入了GENERATED_UCLASS_BODY() 宏。对于想要加入反射的类和结构体中,GENERATED_UCLASS_BODY() / GENERATED_USTRUCT_BODY()是必须的。通过这两个宏,我们给类和结构体注入了实现反射所必须的额外函数和类型信息。
在代码中,第一个反射属性是ResourcesToGather。它被指定为EditAnywhere 和Category=Pawn。这意味着这个属性可以在任意属性面板里编辑,并且属于Pawn这个类别。此外好几个函数指定了BlueprintCallable和一个类别,这意味这些函数都可以在Blueprints里被调用。
如MyTeamNum声明所示,在同一个类中混杂反射成员和非反射成员是没有问题的,只是要注意的是非反射成员对于所有基于反射的系统来说都是不可见的(比如缓存一个非反射的UObject指针通常来说是危险的,因为垃圾回收器并不知道你引用了它)。
你可以在ObjectBase.h里找到每一个说明符关键字(比如EditAnywhere和BlueprintCallable)的一段简短注释和用法说明。如果你不知道某个关键字是起做什么用的,按快捷键Alt+G会跳转到ObjectBase.h中对应的定义上去了(这些不是真的C++关键字,但是智能提示和VAX看起来不关心也分不清它们间的区别)。
更多的信息可以参见官网上的Gameplay Programming Reference。
局限
UHT并不是一个真正的C++解析器。它可以识别语言的常见子集,并且在解析时尽可能多的跳过任何它认为不相关的代码;同时仅关注反射的类,函数和属性。尽管这样,某些情况下它还是会出错的,所以当往一个已有的头文件里加上反射类型时,你可能要重写一些代码,或者要把已有的代码包在#if CPP / #endif里。你应该尽量避免把反射宏修饰过的属性或者函数包在 #if/#ifdef (WITH_EDITOR 和WITH_EDITORONLY_DATA除外)里,这是因为在某些构建配置里这些宏定义有可能不是true的,那么在生成的代码里去引用这些属性或者函数时就会报编译错误了。
对于虚幻引擎的反射来说,C++中绝大多数的数据类型都是支持的,但是并不是所有(特别是只有少数模版类型是支持的,比如TArray和TSubclassOf,并且它们的模版参数不能是嵌套类型)。如果你使用反射宏来修饰无法在运行时表示的数据类型,UHT会给你报一个描述性质的错误信息。
使用反射信息
虽然大部分的游戏代码在运行时使用反射系统给予的便利的同时,可以忽略反射系统,但是当你编写工具或者玩法系统时会发现反射还是很有用的。
反射系统的类型层级如下所示:
UField
UStruct
UClass (C++ class)
UScriptStruct (C++ struct)
UFunction (C++ function)
UEnum (C++ enumeration)
UProperty (C++ member variable or function parameter)
(更多不同类型的子类)
UStruct是聚合类结构(任何包含了其他成员的类型,比如C++类,结构,或者函数)的基础类型,不要把它和C++的结构混淆起来(与它对应的是UScriptStruct)。UClass可以包含函数或者属性作为它的成员,但UFunction和UScriptStruct只能局限于属性。
通过UTypeName::StaticClass() 或者 FTypeName::StaticStruct(),你可以获取某个反射C++类型的UClass 或UScriptStruct修饰;对于UObject实例来说,你可以通过Instance->GetClass()获得它的类型(因为结构体是没有共同的基类也没有反射机制所需的存储空间,所以是无法获得它的实例类型的)。
为了遍历一个UStruct所有的成员,你可以用一个TFieldIterator实例:
for (TFieldIterator<UProperty> PropIt(GetClass()); PropIt; ++PropIt)
{
UProperty* Property = *PropIt;
// Do something with the property
}
TFieldIterator的模版参数用作过滤器(通过该参数你可以使用UField来同时查看属性和函数,或者其中任意一个)。迭代器构造函数的第二个参数用来指定是否只需访问该类或者结构体中的属性/函数,还是也同时访问基类/结构(默认);这个参数取值不会对函数有任何的影响。
每个类型都有一组唯一的标志位(EClassFlags + HasAnyClassFlags, etc…)和一个继承自UField的通用元数据存储系统。反射宏中的说明符关键字可以作为标志位存储也可以作为元数据存储,取决于它们是被用于游戏运行时,还是只是在编辑器里。这样就可以实现在游戏运行时去除仅在编辑器用的元数据来达到节省内存的目的,而作为标志位存储的则一直可用。
你可以通过应用反射数据来实现不同的功能(比如枚举属性,按数据驱动的方式获取/设置属性,调用反射方法,甚至创建新实例);与其深入了解每个反射特性,不如浏览一下UnrealType.h 和 Class.h,然后跟踪调试一个和你要做的功能相近的样例来得更容易一些。
反射的实现原理
如果你仅仅只是想使用反射系统而已,那么可以毫不犹豫的跳过这一部分;但是知道它是怎么工作的可以让你在使用时更好的做出决定并了解它的局限性。
Unreal Build Tool (UBT) 和 Unreal Header Tool (UHT)在实现运行时的反射功能中扮演着核心的角色。UBT的工作就是扫描头文件,如果一个头文件包含至少一个反射类型则记录该头文件所在的模块。如果这些头文件在编译之后发生改变,UHT就会被唤起收集并更新对应的反射数据。UHT解析头文件,创建反射数据集合,然后生成包含反射数据(包含在每个模块都有的.generated.inl里)以及各类辅助类和函数(包含在每个头文件对应的.generated.h里)的C++代码。
之所以通过生成的C++代码来保存反射信息,一个主要的好处是这样可以确保这些信息和最终的二进制文件保持同步。把反射信息和引擎代码一起编译,并在启动时通过C++表达式来计算成员的偏移等信息,而不是逆向工程某个特定的平台/编译器/优化选项的组合,这样你永远都不会加载到错误的反射信息。UHT作为一个独立的程序,它不会修改任何生成的头文件,这样就避免了在UE3脚本编译中经常被抱怨的先有蛋还是先有鸡这样的问题。
生成的方法包括像StaticClass() / StaticStruct(),方便你获得某个类型反射数据的类型,生成的代码段则方便你在Blueprints或者网络传输中调用。这些东西必须作为类或者结构体的一部分来声明,这就是为什么GENERATED_UCLASS_BODY() 或GENERATED_USTRUCT_BODY()宏要包含在反射类型里,以及头文件中需加入包含定义这些宏的代码#include “TypeName.generated.h” 的原因。