深入UE——UObject(十三)类型系统-反射实战

引言

上篇章节总结了类型系统的最后一些小知识点,为了免于说都是纯理论知识,本篇我们来讲一些利用反射的例子。

获取类型对象

如果想获取到程序里定义的所有的class,方便的方法是:

TArray<UObject*> result;
GetObjectsOfClass(UClass::StaticClass(), result);   //获取所有的class和interface
GetObjectsOfClass(UEnum::StaticClass(), result);   //获取所有的enum
GetObjectsOfClass(UScriptStruct::StaticClass(), result);   //获取所有的struct

GetObjectsOfClass是UE4已经写好的一个很方便的方法,可以获取到属于某个UClass*下面的所有对象。因此如果用UClass::StaticClass()本身,就可以获得程序里定义的所有class。值得注意的是,UE4里的接口是有一个配套的UInterface对象来存储元数据信息,它的类型也是用UClass*表示的,所以也会获得interface。根据前文,enum会生成UEnum,struct会生成UScriptStruct,所以把参数换成UEnum::StaticClass()就可以获得所有的UEnum*对象了,UScriptStruct::StaticClass()就是所有的UScriptStruct*了,最后就可以根据这些类型对象来反射获取类型信息了。

而如果要精确的根据一个名字来查找某个类型对象,就可以用UE4里另一个方法:

template< class T > 
inline T* FindObject( UObject* Outer, const TCHAR* Name, bool ExactClass=false )
{
    return (T*)StaticFindObject( T::StaticClass(), Outer, Name, ExactClass );
}

UClass* classObj=FindObject<UClass>(ANY_PACKAGE,"MyClass");   //获得表示MyClass的UClass*

这样就可以轻易的获得不同类型的对象。FindObject内部的原理在下大章节内存管理再讲述。

遍历字段

在获取到了一个类型对象后,就可以用各种方式去遍历查找内部的字段了。为此,UE4提供了一个方便的迭代器TFieldIterator<T>,可以通过它筛选遍历字段。

const UStruct* structClass; //任何复合类型都可以
//遍历属性
for (TFieldIterator<UProperty> i(structClass); i; ++i)
{
    UProperty* prop=*i; 
}
//遍历函数
for (TFieldIterator<UFunction> i(structClass); i; ++i)
{
    UFunction* func=*i; 
    //遍历函数的参数
    for (TFieldIterator<UProperty> i(func); i; ++i)
    {
        UProperty* param=*i; 
        if( param->PropertyFlags & CPF_ReturnParm ) //这是返回值
        {

        }
    }
}
//遍历接口
const UClass* classObj; //只有UClass才有接口
for (const FImplementedInterface& ii : classObj->Interfaces)
{
    UClass* interfaceClass = ii.Class;
}

给模板参数T传UFunction就可以获得类型下的所有函数,通过这也可以遍历获得UFunction下的参数列表。当然TFieldIterator也可以再传其他参数的控制是否包含基类的字段、是否包含废弃的字段、是否包含接口里的字段。TFieldIterator的内部实现其实也挺简单的,一是通过SuperStruct来获得Super,二是通过Interfaces来获得实现的接口,三是用Field->Next来遍历字段。信息数据都是全的,迭代遍历就简单了。

遍历枚举的字段也很简单:

const UEnum* enumClass;
for (int i = 0; i < enumClass->NumEnums(); ++i)
{
    FName name = enumClass->GetNameByIndex(i);
    int value = enumClass->GetValueByIndex(i);
}

还有遍历元数据的:

#if WITH_METADATA
const UObject* obj;//可以是任何对象,但一般是UField才有值
UMetaData* metaData = obj->GetOutermost()->GetMetaData();
TMap<FName, FString>* keyValues = metaData->GetMapForObject(obj);
if (keyValues != nullptr&&keyValues->Num() > 0)
{
    for (const auto& i : *keyValues)
    {
        FName key=i.Key;
        FString value=i.Value;
    }
}
#endif

当然,如果想精确查找的话,也有相应的方法。

//查找属性
UProperty* UStruct::FindPropertyByName(FName InName) const
{
    for (UProperty* Property = PropertyLink; Property != NULL; Property = Property->PropertyLinkNext)
    {
        if (Property->GetFName() == InName)
        {
            return Property;
        }
    }
    return NULL;
}

//查找函数
UFunction* UClass::FindFunctionByName(FName InName, EIncludeSuperFlag::Type IncludeSuper) const;

查看继承

得到类型对象后,也可以遍历查看它的继承关系。 遍历继承链条:

const UStruct* structClass; //结构和类
TArray<FString> classNames;
classNames.Add(structClass->GetName());
UStruct* superClass = structClass->GetSuperStruct();
while (superClass)
{
    classNames.Add(superClass->GetName());
    superClass = superClass->GetSuperStruct();
}
FString str= FString::Join(classNames, TEXT("->")); //会输出MyClass->UObject

那反过来,如果想获得一个类下面的所有子类,可以这样:

const UClass* classObj; //结构和类
TArray<UClass*> result;
GetDerivedClasses(classObj, result, false);
//函数原型是
void GetDerivedClasses(UClass* ClassToLookFor, TArray<UClass *>& Results, bool bRecursive);

GetDerivedClasses也是UE4里写好的一个方法,内部用到了HashMa方式(TMap<UClass*, TSet<UClass*>> ClassToChildListMap)保存了类到子类列表的映射。

那么怎么获取实现了某个接口的所有子类呢?呃,没啥好办法,因为可能用的不多,所以没有保存这层映射关系。我们只能暴力的遍历出来:

TArray<UObject*> result;
GetObjectsOfClass(UClass::StaticClass(), result);

TArray<UClass*> classes;
for (UObject* obj : result)
{
    UClass* classObj = Cast<UClass>(obj);
    if (classObj->ImplementsInterface(interfaceClass))//判断实现了某个接口
    {
        classes.Add(classObj);
    }
}

获取设置属性值

有了UProperty之后,就可以方便的反射获得其值:

template<typename ValueType>
ValueType* UProperty::ContainerPtrToValuePtr(void* ContainerPtr, int32 ArrayIndex = 0) const
{
    return (ValueType*)ContainerVoidPtrToValuePtrInternal(ContainerPtr, ArrayIndex);
}
template<typename ValueType>
ValueType* UProperty::ContainerPtrToValuePtr(UObject* ContainerPtr, int32 ArrayIndex = 0) const
{
    return (ValueType*)ContainerVoidPtrToValuePtrInternal(ContainerPtr, ArrayIndex);
}

void* UProperty::ContainerVoidPtrToValuePtrInternal(void* ContainerPtr, int32 ArrayIndex) const
{
    //check...
    return (uint8*)ContainerPtr + Offset_Internal + ElementSize * ArrayIndex;
}
void* UProperty::ContainerUObjectPtrToValuePtrInternal(UObject* ContainerPtr, int32 ArrayIndex) const
{
    //check...
    return (uint8*)ContainerPtr + Offset_Internal + ElementSize * ArrayIndex;
}
//获取对象或结构里的属性值地址,需要自己转换成具体类型
void* propertyValuePtr = property->ContainerPtrToValuePtr<void*>(object);
//包含对象引用的属性可以获得对象
UObject* subObject = objectProperty->GetObjectPropertyValue_InContainer(object);

UE4特意加了void*和UObject*的重载来分别获得结构和对象里的属性值,内部其实有更多的check代码被我忽略掉了。UE4里面把外部的结构或对象值叫做Container(容器),非常合理,包裹着属性的外部的东西不就是容器嘛。另一个可以见到的是属性值的获取其实也非常简明,Offset_Internal就是一开始的时候STRUCT_OFFSET()的时候传进来的属性在结构里内存偏移值。ElementSize是元素内存大小,可以通过ArrayIndex数组索引(比如int values[10]这种固定数组的属性)获取数组里第几号元素值。

也因为获取到的是存放属性值的指针地址,所以其实也就可以*propertyValuePtr=xxx;方便的设置值了。当然如果是从字符串导入设置进去,UE4也提供了两个方法来导出导入:

//导出值
virtual void ExportTextItem( FString& ValueStr, const void* PropertyValue, const void* DefaultValue, UObject* Parent, int32 PortFlags, UObject* ExportRootScope = NULL ) const; 

//使用
FString outPropertyValueString;
property->ExportTextItem(outPropertyValueString, property->ContainerPtrToValuePtr<void*>(object), nullptr, (UObject*)object, PPF_None);

//导入值
const TCHAR* UProperty::ImportText( const TCHAR* Buffer, void* Data, int32 PortFlags, UObject* OwnerObject, FOutputDevice* ErrorText = (FOutputDevice*)GWarn ) const;
//使用
FString valueStr;
prop->ImportText(*valueStr, prop->ContainerPtrToValuePtr<void*>(obj), PPF_None, obj);

ExportTextItem和ImportText实际上也是我们在编辑器里在Details面板里选择一个属性进行Ctrl+C Copy和Ctrl+V Paste的时候调用的方法。UE4实际上都是把他们序列化成字符串传递的。

反射调用函数

既然能够得到UFunction了,我们当然也就可以调用他们了。虽然这部分机制是关于蓝图的,但是提前罗列出来也未尝不可。在一个UObject上通过名字调用UFunction方法最简单的方式大概是:

//方法原型
int32 UMyClass::Func(float param1); 

UFUNCTION(BlueprintCallable)
int32 InvokeFunction(UObject* obj, FName functionName,float param1)
{
    struct MyClass_Func_Parms   //定义一个结构用来包装参数和返回值,就像在gen.cpp里那样
    {
        float param1;
        int32 ReturnValue;
    };
    UFunction* func = obj->FindFunctionChecked(functionName);
    MyClass_Func_Parms params;
    params.param1=param1;
    obj->ProcessEvent(func, &params);
    return params.ReturnValue;
}
//使用
int r=InvokeFunction(obj,"Func",123.f);

特别注意的是,我们需要定义一块内存用来包装存储参数和返回值,就像注册函数的时候那样。在gen.cpp里也是这么一块内存,表示参数的UProperty里的Offset其实就是针对这块内存而说的。所以为了能够正确的根据Offset再反取出来值来,这块内存的字段内存布局必须和函数注册时候的保持严格一致!所以字段声明的顺序是必须和gen.cpp里一致。也就是按照先参数顺序后返回值的顺序。

ProcessEvent也是UE4里事先定义好的非常方便的函数,内部会自动的处理蓝图VM的问题。当然,更底层的方法也可以是:

//调用1
obj->ProcessEvent(func, &params);
//调用2
FFrame frame(nullptr, func, &params, nullptr, func->Children);
obj->CallFunction(frame, &params + func->ReturnValueOffset, func);
//调用3
FFrame frame(nullptr, func, &params, nullptr, func->Children);
func->Invoke(obj, frame, &params + func->ReturnValueOffset);

调用123其实是差不多等价的,在没有obj的情况下调用static函数,可以用调用3的方式。我们知道写在蓝图里的函数和事件最终也都是会编译生成的UFunction对象的,所以用此方法可以直接调用蓝图里的成员函数和自定义事件。

当然我们也见到上述的方法也有不便之处,必须手动定一个参数结构和固定的函数原型。这是我自己写的一个通过反射调用函数的方法:

template<typename... TReturns, typename... TArgs>
void InvokeFunction(UClass* objClass, UObject* obj, UFunction* func, TTuple<TReturns...>& outParams, TArgs&&... args)
{
    objClass = obj != nullptr ? obj->GetClass() : objClass;
    UObject* context = obj != nullptr ? obj : objClass;
    uint8* outPramsBuffer = (uint8*)&outParams;

    if (func->HasAnyFunctionFlags(FUNC_Native)) //quick path for c++ functions
    {
        TTuple<TArgs..., TReturns...> params(Forward<TArgs>(args)..., TReturns()...);
        context->ProcessEvent(func, &params);
        //copy back out params
        for (TFieldIterator<UProperty> i(func); i; ++i)
        {
            UProperty* prop = *i;
            if (prop->PropertyFlags & CPF_OutParm)
            {
                void* propBuffer = prop->ContainerPtrToValuePtr<void*>(&params);
                prop->CopyCompleteValue(outPramsBuffer, propBuffer);
                outPramsBuffer += prop->GetSize();
            }
        }
        return;
    }

    TTuple<TArgs...> inParams(Forward<TArgs>(args)...);
    void* funcPramsBuffer = (uint8*)FMemory_Alloca(func->ParmsSize);
    uint8* inPramsBuffer = (uint8*)&inParams;

    for (TFieldIterator<UProperty> i(func); i; ++i)
    {
        UProperty* prop = *i;
        if (prop->GetFName().ToString().StartsWith("__"))
        {
            //ignore private param like __WolrdContext of function in blueprint funcion library
            continue;
        }
        void* propBuffer = prop->ContainerPtrToValuePtr<void*>(funcPramsBuffer);
        if (prop->PropertyFlags & CPF_OutParm)
        {
            prop->CopyCompleteValue(propBuffer, outPramsBuffer);
            outPramsBuffer += prop->GetSize();
        }
        else if (prop->PropertyFlags & CPF_Parm)
        {
            prop->CopyCompleteValue(propBuffer, inPramsBuffer);
            inPramsBuffer += prop->GetSize();
        }
    }

    context->ProcessEvent(func, funcPramsBuffer);   //call function
    outPramsBuffer = (uint8*)&outParams;    //reset to begin

    //copy back out params
    for (TFieldIterator<UProperty> i(func); i; ++i)
    {
        UProperty* prop = *i;
        if (prop->PropertyFlags & CPF_OutParm)
        {
            void* propBuffer = prop->ContainerPtrToValuePtr<void*>(funcPramsBuffer);
            prop->CopyCompleteValue(outPramsBuffer, propBuffer);
            outPramsBuffer += prop->GetSize();
        }
    }
}

哇,这么复杂!不是我坏,而是这个函数处理了各种情况。

  1. 利用了UE4本身内部的模板类TTuple来自动的包装多个参数,这样就不用手动的去定义参数结构体了。多返回值也是用TTuple来返回的。
  2. 可以调用C++里定义的成员函数和静态函数,同时也支持调用定义在蓝图里的成员函数,事件,蓝图库里的函数。因为蓝图里的函数支持多输入多输出值,蓝图函数在编译之后,参数常常就会比C++里看起来的多一些,函数中的临时变量也常常被当做一个UProperty了。所以如何压参到蓝图VM就会比C++里多一些额外的步骤。我在上面的函数里面在栈上面生成了一块FMemory_Alloca(func->ParmsSize);内存来当做函数运行时候的参数内存。当然,这块内存在反射调用前必须从函数参数集合里初始化,反射调用后又必须把值拷贝回返回参数上。
  3. 在调用静态函数的时候,因为其实不需要UObject*,所以可以传nullptr,于是就可以把相应的UClass*来当做context调用对象了。
  4. 这个函数处理的最复杂的情况,对于无参数和无返回值的情况,读者朋友们可以自己用不定参数模板偏特化和重载来定义出不同的版本来。也是比较容易的。
  5. 我们也看到函数参数里需要你提供UClass*、UObject*和UFunction*,这些参数利用上面的FindFunctionByName类似方式来查找。我们也可以在这上面继续加上一些便利方法来暴露给蓝图。

假如你还想就像在蓝图中调用蓝图函数库一样,只提供函数名字和参数就可以调用,你可以这样:

template<typename... TReturns, typename... TArgs>
static void InvokeFunctionByName(FName functionName,TTuple<TReturns...>& outParams,TArgs&&... args)
{
    /*
    错误!在PIE模式下,有可能会获得SKEL_XXX_C:Func这个对象,里面的Script为空
    UFunction* func = FindObject<UFunction>(ANY_PACKAGE, *functionName.ToString()); 
    */
    UFunction* func = (UFunction*)StaticFindObjectFast(UFunction::StaticClass(), nullptr, functionName, false, true, RF_Transient); //exclude SKEL_XXX_C:Func
    InvokeFunction<TReturns...>(func->GetOuterUClass(), nullptr, func, outParams,Forward<TArgs>(args)...);
}

值得注意的是UFunction*的查找方式。我们不能简单的用FindObject来查找,因为蓝图在编译编译后Cook前,会生成两个类:BPMyClass_C和SKEL_BPMyClass_C,后者里面也有个同名的UFunction*对象,但里面的Script是空的,导致不能进蓝图VM调用。同时也观察到这个UFunction*对象的ObjectFlags包含RF_Transient标志(意思是不保存),因此就可以用这个来把此对象过滤掉从而查找到正确的UFunction*对象。

这些函数自己再包装一下然后BlueprintCallable一下,就可以在C++和BP端都可以尽情的通过函数名字调用函数了,祝你好运!

运行时修改类型

让我们继续扩宽一下思路,之前已经详细讲解过了各大类型对象的构造过程,最后常常都是到UE4CodeGen_Private里的调用。既然我们已经知道了它运行的逻辑,那我们也可以仿照着来啊!我们也可以在常规的类型系统注册流程执行完之后,在游戏运行的半途过程中,动态的去修改类型甚至注册类型,因为说到底UE4编辑器也就是一个特殊点的游戏而已啊!这种方式有点类似C#的emit的方式,用代码去生成代码然后再编译。这些方式理论上都是可以通的,我来提供一些思路用法,有兴趣的朋友可以自己去实现下,代码贴出来就太长了。

  1. 修改UField的MetaData信息,其实可以改变字段在编辑器中的显示信息。MetaData里有哪些字段,可以在ObjectMacros.h中自己查看。
  2. 动态修改UField的相应的各种Flags数据,比如PropertyFlags,StructFlags,ClassFlags等,可以达成在编辑器里动态改变其显示行为的效果。
  3. 动态添加删除UEnum对象里面的Names字段,就可以动态给enum添加删除枚举项了。
  4. 动态地给结构或类添加反射属性字段,就可以在蓝图内创建具有不定字段的结构了。当然前提是在结构里预留好属性存放的内存,这样UProperty的Offset才有值可指向。这么做现在想来好像也不知道能用来干嘛。
  5. 同属性一样,其实参照对了流程,也可以动态的给蓝图里暴露函数。有时候这可以达成某种加密保护的奇效。
  6. 可以动态的注册新结构,动态的构造出来相应的UScriptStruct其实就可以了。
  7. 动态注册新类其实也是可以的,只不过UClass的构造稍微要麻烦点,不过也没麻烦到哪去,有需求了就自然能照着源码里的流程自己实现一个流程出来。
  8. 再甚至,其实某种程度上的用代码动态创建蓝图节点,填充蓝图VM指令其实也是可行的。只不过想了想好像一般用不着上这种大手术。

总结

本篇我们讲解了类型系统反射的应用,这些的应用知识其实都得基于对类型系统的结构含义有比较深刻的基础上。看到编辑器里的一个字段想修改它,应该就能想到在C++里肯定有对应的,就算被隐藏了,也会想到用反射去获取看看。

下篇开始讲解UObject内存管理,学习完内存管理之后,就会更有一种,心中想要哪个对象,就能立即随心所欲的去获取它的掌控感,同时也对对象应该怎么保持,什么时候被释放了,为什么被释放了,有更深刻的认识。

猜你喜欢

转载自blog.csdn.net/ttod/article/details/133265816