深入UE——UObject(十二)类型系统-总结

引言

又是总结了,前面我们用了挺长的段落来一一讲解UE4里类型系统的结构、信息收集、构造注册和绑定链接。但还剩一些小主题和一些梳理没讲完,最后一波带走。

UClass对象成长的几个阶段

相信很多读者朋友们,在研究了一段时间,或者看了这一系列文章下来后,虽然感觉懂得了挺多东西,但可能依然会像我刚开始自己研究一样,脑袋里去回想整个流程,却依然会有点模糊,感觉掌握的不够牢靠。初始懵懂,大家都是管中窥豹盲人摸象,但如果想要做到肆意亵玩,就要走进科学,多角度观察。按我的经验来说,此时有两种视角就很有效用:过程和数据。过程关注一个个步骤是怎么承上启下的,数据关注高楼林立的对象是怎么平地起的。

针对类型系统来说,前面都讲了各个函数调用的序列,其中穿插着也讲了UClass*对象的构造的几个阶段。但是零碎的讲述和专门的整理是两回事!

一个UClass*要经历这么几个阶段:

  1. 内存构造。刚创建出来一块白纸一般的内存,简单的调用了UClass的构造函数。UE里一个对象的构造,构造函数的调用只是个起点而已。
  2. 注册。给自己一个名字,把自己登记在对象系统中。这步是通过DeferredRegister而不是Register完成的。
  3. 对象构造。往对象里填充属性、函数、接口和元数据的信息。这步我们应该记得是在gen.cpp里的那些函数完成的。
  4. 绑定链接。属性函数都有了,把它们整理整理,尽量优化一下存储结构,为以后的使用提供更高性能和便利。
  5. CDO创建。既然类型已经有了,那就万事俱备只差国家包分配一个类默认对象了。每个UClass都有一个CDO(Class Default Object),有了CDO,相当于有了一个存档备份和参照,其他对象就心不慌。
  6. 引用记号流构建。一个Class是怎么样有可能引用其他别的对象的,这棵引用树怎么样构建的高效,也是GC中一个非常重要的话题。有了引用记号流,就可以对一个对象高效的分析它引用了其他多少对象。

UMetaData

前面对于这个元数据,几乎都略过了,因为它是只在Editor模式下使用的。在所有的类型对象Construct的一步就是AddMetaData:

#if WITH_METADATA
void AddMetaData(UObject* Object, const FMetaDataPairParam* MetaDataArray, int32 NumMetaData)
{
    if (NumMetaData)
    {
        UMetaData* MetaData = Object->GetOutermost()->GetMetaData();//得到Package属于的MetaData
        for (const FMetaDataPairParam* MetaDataParam = MetaDataArray, *MetaDataParamEnd = MetaDataParam + NumMetaData; MetaDataParam != MetaDataParamEnd; ++MetaDataParam)
        {
            MetaData->SetValue(Object, UTF8_TO_TCHAR(MetaDataParam->NameUTF8), UTF8_TO_TCHAR(MetaDataParam->ValueUTF8)); //添加数据
        }
    }
}
#endif
UMetaData* UPackage::GetMetaData()
{
#if WITH_EDITORONLY_DATA
    if (MetaData == NULL)
    {
        MetaData = FindObjectFast<UMetaData>(this, FName(NAME_PackageMetaData));
        if(MetaData == NULL)
        {
            MetaData = NewObject<UMetaData>(this, NAME_PackageMetaData, RF_Standalone | RF_LoadCompleted);
        }
    }
    if (MetaData->HasAnyFlags(RF_NeedLoad))
    {
        MetaData->GetLinker()->Preload(MetaData);
    }
    return MetaData;
#else
    return nullptr;
#endif
}

从这里能知道的有三件事,一是UMetaData是属于UPackage关联的,而不是跟某个UField直接绑定。第二是UMetaData在Runtime下是被略过去的。三是UMetaData也是个对象。继续查看UMetaData的定义:

class COREUOBJECT_API UMetaData : public UObject
{
public:
    TMap< FWeakObjectPtr, TMap<FName, FString> > ObjectMetaDataMap;//对象关联的键值对
    TMap< FName, FString > RootMetaDataMap;//包本身的键值对
};

const FString& UField::GetMetaData(const FName& Key) const
{
    UPackage* Package = GetOutermost();
    UMetaData* MetaData = Package->GetMetaData();
    const FString& MetaDataString = MetaData->GetValue(this, Key);
    return MetaDataString;
}

这个ObjectMetaDataMap的定义就很有意思,FWeakObjectPtr用弱指针引用UObject对象,这样就不会阻碍对象的GC;键用FName,因为键只有固定的一些(Category,Tooltip这些);值用FString就可以爱写啥写啥了。

思考:为何不把Map<FName,FString> MetaDataMap直接放进UObject里? 一个直接的思维可能是直接在UObject里添加个字段,这样所有的UObject就都可以有额外的元数据了。但有时候过于直接是有害的,直男们应该都懂……UE这么设计,就是为了脱钩! 嵌入的方式有可见的坏处:

  • 加大了UObject本身的Size,不轮用到元数据没有,都得承受多一个字段的负担。就算只放进UField里,也只是减轻,因为还有很多UField并没有多余的元数据需要记录。
  • 请神容易送神难,嵌入进去后,就比较难再拆出来了,这些元数据信息只是在编辑器模式下提供给编辑器界面行为用的,在Cook的时候是要略去的,这个时候如果MetaDataMap在真正的对象里面,在一个二进制流里单独想拆出来一部分,这种手术的难度可是比较高的。而如果UMetaData是个独立的对象,这样它就算保存也是保存在文件里一整块地方,单独拆这个楼就容易多了。

拆出来当然会多一层间接访问,多了效率的负担和CacheMiss的机会。但审时度势来说,UMetaData的使用只在编辑器下用,编辑器稍微慢一点点无所谓,没有游戏那种帧率要求。且访问UMetaData的频率并不高,只在初始化界面的时候获取一下来改变UI。UMetaData再设计成UPackage是关联的(Outer是UPackage),而UPackage是序列化保存的单位,这样UMetaData就可以当做一个独立的部分来进行加载或释放了。

GRegisterCast和GRegisterNative的作用

前面有讲过一个static初始化阶段还有两个收集点:IMPLEMENT_CAST_FUNCTION收集到GRegisterCast,IMPLEMENT_VM_FUNCTION收集到GRegisterNative,但并没有机会来讲那些是用来干嘛的。这些其实就是一些函数用来做对象的转换和蓝图虚拟机的一些基础函数。把虚拟机里运行的指令码和真正的函数指针绑定起来。这些是蓝图的部分,以后讲到蓝图的时候再介绍。

Flags

还有一个东西是有点略过的,就是各种Flags的枚举。UE利用这些枚举标志来判断对象的状态和特征。 重要的有:

  • EObjectFlags:对象本身的标志。
  • EInternalObjectFlags:对象存储的标志,GC的时候用来检查可达性。
  • EObjectMark:用来额外标记对象特征的标志,用在序列化过程中标识状态。
  • EClassFlags:类的标志,定义了一个类的特征。
  • EClassCastFlags:类之间的转换,可以快速的测试一个类是否可以转换成某种类型。
  • EStructFlags:结构的特征标志。
  • EFunctionFlags:函数的特征标志。
  • EPropertyFlags:属性的特征标志。

具体的请读者们自己去查看定义了,太多了就不一一解释了。这也是一种常用的C++惯用法,枚举标志来表示叠加的特征。

总结

在絮絮叨叨了这么一大套流程之后,我不知道读者朋友们对UE的这部分流程是个什么感觉。这套流程这么设计是否流畅合理?整体回想起来是否脉络清晰呢?答案是否定的。作为一个能把这部分啰嗦写成十篇文章的人来说,我都觉得里面的弯弯绕绕太多,何况普通的开发者去阅读呢。这里面固然有着实践的权衡,但其实也是疏于整理,历史包袱太重。

从一个方面来说,CoreUObject这部分固然是不用暴露给普通用户使用的,所以即使内部再怎么复杂都没有关系。我们经常讲一个模块应该高内聚低耦合,但这句话的语境其实是针对模块的边界时候讲的,但边界的判断只是个视角问题。换种眼光,一个函数的外部也算是边界。所以,对于一个模块内部来说,如果内部纠缠不清,那它其实也只是外强中干。但凡事也都有个程度问题,小病只要不影响生活质量那就算伴随一辈子其实也没事,引擎的内部这一点点流程复杂,从完美主义者上来说不够优雅,但其实只要负责的那个人能够Hold得住,其实也就能心平气和的接受了。

那是不是就可以这么过去了呢?诚然对于UE的这么核心的模块,我们都没有什么话语权去修改,但作为一个技术人员,秉着精益求精的态度,对于自己负责的项目模块,在自己的一亩三分地里,就要尽量的做到优雅设计了。提供给别人用的模块更是如此。

对于游戏引擎,在这里我想谈的一个非常重要的话题是信心!引擎开发者的信心和引擎使用者的信心。经济学里,信心是非常重要的,人们对未来的预期会很大的影响社会经济,甚至可以说货币的本质就是信心。而在游戏引擎领域,信心也非常重要。游戏开发人员相信引擎能帮他实现想要的效果,引擎开发人员相信自己能把引擎不断升级迭代越来越好,这样才能不断正向循环。

信心跟架构有什么关系?关系其实很大,当一个引擎的代码,模块的代码,看起来读起来理解起来改造起来,都显得很艰深晦涩的话,人们就会逐渐开始丧失信心。就拿CoreUObject打比方,普通的开发者自己去研读它的代码,发现太复杂了很难搞懂,请问他会有很强的信心用好这个引擎吗?他只会想这个引擎里还藏着好多秘密,我就当个普通人用吧,自己小心一点,别踩到坑了。人的心理对于模糊不清的东西就是会失去掌控感,而没有安全感就会畏首畏尾不前,开始保守主义,慢慢的潜意识里就算引擎新出了厉害功能,也不敢去使用,因为怕踩坑。会害怕是因为新技术自己不懂,印象里就觉得很难懂,因为过往的都很难懂。慢慢的在游戏引擎市场里就开始传出了这个引擎很难用的风气。而对于引擎开发人员自己来说,如果理解维护一个模块的成本太大,也会逐渐的丧失信心。开发软件项目的时候,有时会出现一种失控的状况,出bug了修复靠运气和防御,开发人员也尽量不想去碰它。这种模块某种意义上来说已经走完了生命期,行将就木了。引擎是产品,代码其实也是产品,对外对内都是,代码就是要给人读的,提供给别人的就一定要漂亮一点,否则大家就直接只敲01得了。

其实我有时候也在犹豫,知道了解类型系统内部的构建过程这些知识有什么用?费这么多口舌,越是艰深的内容看的人也越是少,从功利主义上来说,我直接画张结构图,然后示例几种用法不就好了嘛。但这后来想了,其实还是有两点好处的。首先是信心,把东西剖开了就没有秘密,大家用起来改造起来心就稳。其次是扩宽了思维的广度,为实际编程提供更多思路。

举个小例子,假如你看到编辑器里某个属性,想在C++里去修改它的值,结果发现它不是public的,甚至有可能连头文件都是private的,这个时候如果对类型系统结构理解不深的人可能就放弃了,但懂的人就知道可以通过这个对象遍历UProperty来查找到这个属性从而修改它。

还有一个例子是如果你做了一个插件,调用了引擎编辑器本身的Details面板属性,但又想隐藏其中的一些字段,这个时候如果不修改引擎往往是难以办到的,但是如果知道了属性面板里的属性其实也都是一个个UProperty来的,这样你就可以通过对象路径获得这个属性,然后开启关闭它的某些Flags来达成效果。这也算是一种常规的Hack方式。

所以,你懂得越多,就越是能在你想象不到的地方回报给你。

下一篇,讲讲类型系统反射在实战中大概有哪些应用。

猜你喜欢

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