浅谈UE4的序列化

浅谈UE4的序列化

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!

一、结合用例浅谈UE4序列化

1.1 需求
我写文章,不爱一上来就讲道理、贴代码,而是喜欢先提需求、提问题,然后围绕这个需求的实现再一步步挖掘源码。

我们的需求是要对游戏中某些核心逻辑的代码做一个快照,这个快照可以保存到磁盘,可以上传到服务器;拿到生成的快照还可以快速地恢复现场。

其实所谓的快照,不外乎就是对必要的对象做「序列化」。

针对序列化,其实UE4本身就写了一套。老祖宗UObject身上就有一个Serialize()方法,这个方法负责对整个类里面的「某些信息」做序列化。其中,被UPROPERTY()宏标记的属性,一般都是会被序列化的。

序列化到磁盘之后,UE4是将序列化的「二进制」数据以.uasset后缀的文件保存起来。使用LoadObject可以重新将uasset文件反序列化成UObject

所以目前来看,UE4这套序列化是完全够用的。就用它了!

1.2 开干!(与遇到的问题)
序列化尝试
搜索互联网可得此文《UE4 – Save a texture as a new asset》

这篇文章介绍了如何使用UPackage::Save()方法来保存一个Package。

Pacakge的概念这里可以先等同于一个uasset文件在内存里的表示。

基本方法就是先创建一个空Package

UPackage* MyPkg = CreatePackage(*PackagePath);
UMyClass* MyInstance = NewObject<UMyClass>(nullptr);

做完后再调用UPackage::Save()方法。这是一个静态方法,第一个参数是刚才创建的Package,第二个参数就是要被序列化保存下来的对象,第四个参数是要保存的路径,所以最后代码就大概是这样:

UPackage::SavePackage(MyPkg, MyInstance, EObjectFlags::RF_Public|EObjectFlags::RF_Standalone, *PackageFileName, GError, nullptr, true, true, SAVE_NoError);

其中特别要注意的是路径的问题。上面总共出现了两个路径:

  • 包的路径。包的路径一般用的是/Game/xxx这种形式,表示要将文件保存在你项目的Content/目录下,包是xxx
  • 包的「保存路径」。这里的保存路径指的是「绝对路径」或者「相对路径」,也就是uasset文件最终保存的地方,要对应于包的路径,如果对应上面一条,那么这个路径可能就是cpp ../../../你的项目名/Content/xxx.uasset。这里注意两点:文件的后缀名必须是uasset,虽说不是uasset也能保存成功,但是后面反序列化LoadObject时会有问题;保存路径要和包路径对应。

其他的参数这里我也没有深究,先照着抄。

一波操作之后确实能够保存一个uasset文件到指定路径,大小大概是1KB。虽然总觉得不对,但也没法看清楚里面的内容,也查不了,所以先假定它没问题,继续走下一步反序列化!

反序列化
上一步确实能够保存了个xxx.uasset文件下来。保存完之后再尝试反序列化一波,然后发现根本反序列化不进来。

反序列化是这么写的:

UMyClass* LoadedInstance = LoadObject<UMYClass>(nullptr, TEXT("/Game/xxx.xxx"));

这里要记住上面这个LoadObject的路径,它对后面的剧情将起到关键性作用。

当然试验不会那么容易成功,这里LoadObject出来的结果是个nullptr,证明了反序列化成实例失败了。

问题总结
下面进行问题总结。

一、不知道序列化成功了没有,因为看到文件特别小,特别不科学。
二、反序列化是肯定失败了,那么为何失败了呢,是找不到文件、还是UObject反序列化失败、还是单纯的路径错误?

带着这两个问题,我们继续往下一探究竟。

1.3 关于「反」序列化的基础知识
在真正地查清楚问题之前,需要先对UE4的原理有一个大概的了解。

类结构
先来个简易UML图。

FArchive
FArchive直译为文档类,序列化和反序列化全靠它。

作为基类,重写了各个版本的<<操作符。

基本用法是Ar << IntMember,作用是把IntMember序列化到Ar中。

如果你查看官方文档FArchive有各式各样数量繁多的派生类。其中负责把UObject序列化到磁盘和从磁盘反序列化生成UObject实例的分别是:

  • FLinkerSave
  • FLinkerLoad

在后文中我们会稍微展开一点讲。

UObject::Serialize()方法
在开始讲Linker之前,当然需要先简单地带过一下UObject::Serialize()方法。这个方法的作用恰如其名,负责序列化。

它有两个版本,其中第一个版本长这样:

// Object.h
virtual void Serialize(FArchive& Ar);
// Object.cpp
#define IMPLEMENT_FARCHIVE_SERIALIZER( TClass ) void TClass::Serialize(FArchive& Ar) { TClass::Serialize(FStructuredArchiveFromArchive(Ar).GetSlot().EnterRecord()); }
IMPLEMENT_FARCHIVE_SERIALIZER(UObject)

把宏展开就是:

void UObject::Serialize(FArchive& Ar) 
{
    UObject::Serialize(FStructuredArchiveFromArchive(Ar).GetSlot().EnterRecord());
}

其实就是从FArchive中取出Record,然后把它当做参数传给另一个版本的UObject::Serialize()。另一个版本又长这样:

virtual void Serialize(FStructuredArchive::FRecord Record);

所以这个第二个版本才是真正的主角。

再展开讲,就会比较大篇幅,所以先止步于此,后面会再提到它。

Linker家族

说回UML图中提到的Linker。

首先是Linker最顶层的基类,叫做FLinkerTable,这个结构体可以参考文章《Ue4_序列化浅析》

可以得知FLinkerTable的结构与uasset文件的内容是一一对应的。也就是说我们可以猜测,当uasset被加载到内存里的时候,查看FLinkerTable的内容就能知道uasset里面究竟是什么内容。

接下来是FLinker,这个类是FLinkerLoadFLinkerSave的基类,暂时没有需要我们注意的地方。

再接下来就是加载uasset的主力军FLinkerLoad

LinkerLoad的大致工作流程
简单地描述一下我们加载uasset文件的一个大概流程。

首先我们需要知道我们要加载的包的路径,然后调用LoadObject来载入。

就像上面说到的:

UMyClass* LoadedInstance = LoadObject<UMYClass>(nullptr, TEXT("/Game/xxx.xxx"));

稍微在网上搜过一点点「UE4 加载文件」或者「UE4 序列化」的读者们一定知道,LoadObject有一层层的包装,大概调用流程是这样的:

这就是我们常常知道的故事的前半截,加载Object会一路调用下去,最终的目的是调用LoadPackage加载一个包。

而故事的后半截,主人公FLinkerLoad终于出现。

LoadPackageIntenal()中会根据路径创建一个对应的FLinkerLoad,它被创建完后会马上执行自身的Tick()

我们来看看Tick里面都是在干什么(只贴关键代码):

Status = CreateLoader(TFunction<void()>([]() {}));
// ...
Status = SerializePackageFileSummary();
// ...
Status = SerializeNameMap();

可以看到,Tick其实就是在一点点加载uasset的内容进来。具体内容同学们可以自己摸索,主要就是把上面贴的那个图里面的每个部分都读取到Linker中。

1.4 关于序列化的基础知识
这一小节讲的是序列化。

根据网上的资料以及各种断点调试,可以确切地知道一般负责序列化的FArchive的类型为FArchiveSaveTagExports。看它的名字就知道它是负责将UObject中被UPROPERTY宏打上标签的数据序列化的。

具体的一些概念可以参考文章《UE4中的Serialization》

文章大概介绍了两种序列化的方法:TPS和UPS。

这里我们用的是TPS。

顺着UPackage::Save()方法往下看,在SavePackage.cpp的2419行附近能找到这句关键代码:

PackageExportTagger.TagPackageExports(ExportTaggerArchive, bRoutePresave);

调用了FPackageExportTagger::TagPackageExports()

再继续看SavePackage.cpp的1793行附近:

ExportTagger.ProcessBaseObject(Base);

调用的是FArchiveSaveTagExports::ProcessBaseObject()

void FArchiveSaveTagExports::ProcessBaseObject(UObject* BaseObject)
{
    (*this) << BaseObject;
    ProcessTaggedObjects();
}

这个代码就非常简单了,其实就是调用BaseObject的Serialize()方法进行序列化,把自己作为UObject::Serialize(FArchive& Ar)的参数。

1.5 验证序列化是否成功(以及修改结果)
根据前文的提到的,可以得知结论:

只要是序列化,一定会走UObject::Serialize()方法。

但是我在这边断点的时候,根本没有走到这里。

上一小节讲序列化的过程的时候已经讲清楚了序列化的流程,那么现在所需要做的就是在每一个部分打断点,看有没有进来。

最后可以定位到程序可以正确地跑到FArchiveSaveTagExports::operator<<()

FArchive& FArchiveSaveTagExports::operator<<(UObject*& Obj)
{
    if (!Obj || Obj->HasAnyMarks(OBJECTMARK_TagExp) || Obj->HasAnyFlags(RF_Transient) || !Obj->IsInPackage(Outer))
    {
        return *this;
    }
}

但是在第一个if这里就被劝退了。

用A、B、C、D四个临时变量来看看if是有哪些条件符合了:

bool A = !Obj;
bool B = Obj->HasAnyMarks(OBJECTMARK_TagExp);
bool C = Obj->HasAnyFlags(RF_Transient);
bool D = !Obj->IsInPackage(Outer);

可以看到在目前的情况下,D是true。字面意义上就是Obj不在Outer这个包里面。

那么Outer是什么呢?Outer就是我们调用UPackage::Save()时传入的第一个参数,也就是要被保存的包。也就是说,只要我们要保存的UObject所在的包不是我们要保存的那个包,那直接就不能进行序列化了。

那么UObject怎么指定自己所属的包呢?答案是在NewObject的时候就应该把Package作为Outer传入。

在上面这个例子中,代码就应该改成这样:

UPackage* MyPkg = CreatePackage(*PackagePath);
UMyClass* MyInstance = NewObject<UMyClass>(MyPkg);

再运行一次程序,可以看到导出来的uasset文件成功地从1KB变成了2KB!

1.6 验证反序列化是否成功
经历过上面的摸索和修正,可以先假设序列化这一步是没问题了。接下来要做的是寻找一下反序列化会失败的原因。

首先怀疑路径是不是错了。于是跟着网上的文章《UE4:四种加载资源的方式》改了很多个版本的路径,还是不对。

这个时候也就只能一路断点调试了。

求证反序列化是否成功
前文讲基础知识的时候,讲到第一次加载文件时,最终都是会调用LoadPackageInternal,它又会创建一个FLinkerLoad来负责加载资源。

于是直接断点到LoadPackageInternal()的这一行:

Linker = GetPackageLinker(InOuter, *FileToLoad, LoadFlags, nullptr, nullptr, InReaderOverride, &InOutLoadContext, ImportLinker, InstancingContext);

这样跳到下一行就可以看到Linker的内容。Linker继承于FLinkerTables,记录着uasset的全部信息。而从参考文献中可以得知,uasset中的ExportMap包含着它的所有Object数据。

于是点开Linker的内容,这个时候可以看到基类的ExportMap的内容。我这里可以清楚地看到,之前序列化的UMyClass是可以正确地被生成的!

取出UObject
既然Package已经被成功加载,对应的UObject也被正确地序列化,那为什么最后返回的是nullptr呢?

既然加载进了Package,那么下一步就是从Package中取出数据了。

UE4是怎么从Package中取出数据的呢?

看看StaticLoadObjectInternal()方法中,在调用了ResolveName来尝试加载Package之后,有这么一句关键的语句:

Result = StaticFindObjectFast(ObjectClass, InOuter, *StrName);

很明显这一步的目的就是从内存中取出我们已经加载好的UObject。

一路往下点,先是调用到常规的Internal实现:

UObject* StaticFindObjectFastInternal()

接下来最终调用到:

UObject* StaticFindObjectFastInternalThreadSafe(FUObjectHashTables& ThreadHash, const UClass* ObjectClass, const UObject* ObjectPackage, FName ObjectName, bool bExactClass, bool bAnyPackage, EObjectFlags ExcludeFlags, EInternalObjectFlags ExclusiveInternalFlags)
{
    ExclusiveInternalFlags |= EInternalObjectFlags::Unreachable;

    // If they specified an outer use that during the hashing
    UObject* Result = nullptr;
    if (ObjectPackage != nullptr)
    {
        int32 Hash = GetObjectOuterHash(ObjectName, (PTRINT)ObjectPackage);
        FHashTableLock HashLock(ThreadHash);
        for (TMultiMap<int32, class UObjectBase*>::TConstKeyIterator HashIt(ThreadHash.HashOuter, Hash); HashIt; ++HashIt)
        {
        }
    }
    // ...
}

为了从庞大的所有UObject中找到我们要的那个,需要使用UObject的哈希值来找。看上面这段代码的作用,就是算出UObject的哈希值,然后根据这个哈希值得到一个迭代器,最后再从迭代器中筛选出目标Object

既然目标Object是存在的,而使用算出来的哈希值又找不到它,那么可以推算出哈希值本身是有问题的。

看看哈希值是怎么算的:

static FORCEINLINE int32 GetObjectOuterHash(FName ObjName,PTRINT Outer)
{
    return GetTypeHash(ObjName) + (Outer >> 6);
}

关系到哈希值的参数有两个:

  • ObjName也就是传进来的ObjName的名字。ObjName的类型是一个FName,FName有自己的一套计算哈希值的公式。
  • Outer的地址在这个场景中,Outer就是前面加载完毕的Package本身。

前面已经验证过加载完的Package基本是没问题的,那么要怀疑的就是ObjName这个参数了。

1.7 如何才能在Package里面找到对应的Object(以及包的路径的具体意思)
那么ObjName这个参数从哪来的呢?

回忆一
再一次回忆起前面LoadObject的时候填的路径:

UMyClass* LoadedInstance = LoadObject<UMYClass>(nullptr, TEXT("/Game/xxx.xxx"));

各位有没有想过,为什么路径里面,最后那个包名要写两次,一定要写xxx.xxx呢?

其实逗点前面的那部分是包名,而后面那部分是「你要加载的Object的名字」。

回忆二
回忆起之前断点调试的时候点开的FLinkerTable::ExportMap中,加载进来的内容。

记得不,里面那个实例的名字叫做什么?

也许是MyClass_0,也许是MyClass_1,还可能是MyClass_12345。总之!它的名字不会是MyClass

那么你怎么可能够用MyClass作为ObjName把目标对象正确的搜索到呢。

结论与解决方案
结论:LoadObject的路径包括两个部分,逗点前的是包名,逗点后的是对象名。

由于创建实例的时候,默认的名字是{你的类名}_{序号},所以最终被序列化到uasset文件中的对象也是这个名字。当你用包名.包名来获取对象的时候,就会获取失败。

怎么解决呢?

有两种方法:

  1. 在序列化保存对象之前,用UObject::Rename()来把对象名称改成你想要的那个名字,LoadObject的时候,逗点后的对象名就用你指定的名字。
  2. 在每次创建完要被写话的对象之后,记录下它的名字到服务器或者磁盘,下次LoadObject的时候,使用这个记录的名字来读取对应的uasset。

这样一波操作之后,就成功地将文件读取到了内存中。

1.8 技术总结
下面进行技术总结。

第一,你要有一个问题,带着问题去看代码,比如在这里我的问题就是如何在Runtime中进行UObject的序列化。

第二,搜寻网上所有能找到的文章,这一步的目的是让我们最起码对各个类和模块有一个基础的概念。

第三,一边看代码,一边自己画点UML图把你所知道的类关系组合起来,让自己心里有个概念。

第四,配合断点调试证明自己的猜想。

第五,真正地使用上面的知识来解决你的问题。

所以这就是一个大弯,在你时间不会特别紧的情况下,尽量先做好调研,再解决问题,会比你直接撞到问题上提高很多效率。

二、UE4中序列化文件的显示问题

上面是讲到将一系列UObject序列化为一个uasset文件。

虽然UObjects能够正常被序列化,也能够成功地被反序列化成实例,但是目前为止还有一个缺陷,就是它无法被资源浏览器识别和显示出来。

使用《UE4新增Asset类型》文章中提到的方法,可以自己创建一个与自定义资源同类型的资源,但是对于我们自己创建的快照却无法显示。

为了能够在资源浏览器显示这个快照,需要我们自己去翻对应的源码,找到它不被显示出来的原因。

2.1 从资源管理器的源码开始
因为是资源管理器不显示资源,所以应该先看资源管理器的源码。

怎么找到对应的源码呢,这个时候就要请出最重要的工具。

鼠标停留在资源管理器的主窗口,可以看见它的实现逻辑。

点进去就能看见代码:

TSharedRef<SAssetTileView> SAssetView::CreateTileView()
{
    return SNew(SAssetTileView)
        .SelectionMode( SelectionMode )
        .ListItemsSource(&FilteredAssetItems)
        .OnGenerateTile(this, &SAssetView::MakeTileViewWidget)
        .OnItemScrolledIntoView(this, &SAssetView::ItemScrolledIntoView)
        .OnContextMenuOpening(this, &SAssetView::OnGetContextMenuContent)
        .OnMouseButtonDoubleClick(this, &SAssetView::OnListMouseButtonDoubleClick)
        .OnSelectionChanged(this, &SAssetView::AssetSelectionChanged)
        .ItemHeight(this, &SAssetView::GetTileViewItemHeight)
        .ItemWidth(this, &SAssetView::GetTileViewItemWidth);
}

写过UI的同学应该对于这段代码理解会很快,列表的数据来源明显就是上面写的FilteredAssetItems

有了入口,接下来的往回推就简单了,不断地使用IDE的「查找引用」的功能,最终找出数据的来源。

对于每一个数据来源,在其遍历中都打印出资源的名字来,用来判断我们的自定义资源是在哪一步被过滤掉了。

这部分UI的代码太过于繁琐,我就直接给答案了。自定义快照资源的过滤不是在资源管理器这个模块中发生的,而是在上流被过滤的。

资源浏览器的上流便是文件系统,这其中的关系又太过于繁杂,我也没有深入去了解。

2.2 反向推理
利用uasset后缀名全局搜索
我们理一下思路。在UE4中,每一个资源应该对应一个uasset文件。在上次试验中,我们证明了这个uasset文件是能被读取的。那么有几个疑问:

  1. 文件系统在遍历所有文件的过程中,能否找到我们的uasset文件?
  2. 我们的uasset文件是在哪里被过滤掉的,过滤的条件是什么?

这时候就要祭出全局搜索大法,全局搜素「.uasset"」关键字,可以找到引用的地方在FPackageName类。这个类有几个方法和uasset后缀名有关系,其中包括:

-IsAssetPackageExtension()
-IsPackageFileName()

分别查找其引用,最终可以找到类FAssetDataDiscovery。这个类的作用便是搜集所有的Asset信息。

于是又是一路寻找数据的根源。

缓存资源信息文件
FAssetDataGatherer::Run()方法中,有这个代码:

FDiskCachedAssetData* DiskCachedAssetData = DiskCachedAssetDataMap.Find(PackageName);

可以查到这个DiskCachedAssetDataMap其实来源于缓存文件你的工程名/Intermidiate/CachedAssetRegistry.bin

其中有几句代码,它从DiskCachedAssetDataMap中取出信息,而后读取字段AssetDataList,如果这个字段非空,那么将其加入到LocalAssetResults列表(这个列表就是数据源)。

for (const FAssetData& AssetData : DiskCachedAssetData->AssetDataList)
{
    LocalAssetResults.Add(new FAssetData(AssetData));
}

从断点调试得知我们的自定义资源就是因为DiskCachedAssetData->AssetDataList是空而被筛选掉。

那么下一步就是查清楚:为什么这个列表会是空,这个列表的数据从哪来的?

缓存资源信息文件的生成
你的工程名/Intermidiate/CachedAssetRegistry.bin这个文件删掉之后再断点调试,就可以查到这个文件是如何生成的。

如果在缓存文件中找不到对应的asset信息,那么会执行这几句代码:

if (!bLoadedFromCache)
{
    ReadContexts.Emplace(PackageName, Extension, AssetFileData);
}

然后尝试读取Asset文件:

ParallelFor(ReadContexts.Num(),
[this, &ReadContexts](int32 Index)
{
    FReadContext& ReadContext = ReadContexts[Index];
    ReadContext.bResult = ReadAssetFile(ReadContext.AssetFileData.PackageFilename, ReadContext.AssetDataFromFile, ReadContext.DependencyData, ReadContext.CookedPackageNamesWithoutAssetData, ReadContext.bCanAttemptAssetRetry);
},
EParallelForFlags::Unbalanced | EParallelForFlags::BackgroundPriority
);

一直怼着ReadAssetFile()方法读,找到代码:

if ( !PackageReader.ReadAssetRegistryData(AssetDataList) )
{
    if ( !PackageReader.ReadAssetDataFromThumbnailCache(AssetDataList) )
    {
        // It's ok to keep reading even if the asset registry data doesn't exist yet
        //return false;
    }
}

ReadPackageDataMain()函数中:

// ReadPackageDataMain()
int32 ObjectCount = 0;
BinaryArchive << ObjectCount;
// ...
for (int32 ObjectIdx = 0; ObjectIdx < ObjectCount; ++ObjectIdx)
{
    // ...
    OutAssetDataList.Add(new FAssetData(PackageName, ObjectPath, FName(*ObjectClassName), MoveTemp(TagsAndValues), PackageFileSummary.ChunkIDs, PackageFileSummary.PackageFlags));
}

这里有一个for循环,根据上面反序列化出来的ObjectCount的数量来加载Asset并写入到OutAssetDataList,这个列表对应的就是上头提到的DiskCachedAssetData->AssetDataList

ObjectCount从何而来
这个ObjectCount是从FArchive中反序列化得到的,属于Package本身的基础信息之一。

讲道理,一个字段既然能被读出来,就肯定有被写入的地方,由于ObjectCount这个名字实在是太泛了,用它来全局搜索实在是不靠谱。这个时候我注意到它上一个返序列化的字段的代码:

BinaryArchive << OutDependencyDataOffset;

于是全局搜索了DependencyDataOffset,找到SavePackageUtilities.cpp文件中的对应序列化代码:

AssetRegistryRecord << SA_VALUE(TEXT("AssetRegistryDependencyDataOffset"), AssetRegistryDependencyDataOffset);

往下继续看,果然接着就是ObjectCount的序列化代码:

TArray<UObject*> AssetObjects;
if (!(Linker->Summary.PackageFlags & PKG_FilterEditorOnly))
{
    // Find any exports which are not in the tag map
    for (int32 i = 0; i < Linker->ExportMap.Num(); i++)
    {
        FObjectExport& Export = Linker->ExportMap[i];
        if (Export.Object && Export.Object->IsAsset())
        {
            AssetObjects.Add(Export.Object);
        }
    }
}
int32 ObjectCount = AssetObjects.Num();
FStructuredArchive::FArray AssetArray = AssetRegistryRecord.EnterArray(SA_FIELD_NAME(TEXT("TagMap")), ObjectCount);

这里的逻辑很简单,就是从Linker的ExportMap中读出UObject,添加到AssetObjects列表中,最终又将列表长度赋值给ObjectCount,并序列化。

在第一节中我们验证出FLinker::ExportMap不是空的,那么肯定就是在上面这个if中被筛选掉了。

从进入UObject::IsAsset()方法中,看见UE4对于是否是资源的判断很简单,要符合几个条件:

  • 不能有RF_TransientRF_ClassDefaultObject的flag
  • 一定要有RF_Public的flag
  • 不能是IsPendingKill的

断点调试发现第二个条件不符合,读出来的flag是RF_NoFlag

我们看看RF_Public的解释:

UOBject的flag
那么究竟是哪个地方设置了UObject的flag呢?

可以通过两种途径:

  • NewObject的时候有个参数可以设置flag
  • UObject::SetFlags()也可以设置

回想一下上一节中的代码:

UMyClass* MyClass = NewObject<UMyClass>(MyPkg);

只需要这么改即可:

UMyClass* MyClass = NewObject<UMyClass>(MyPkg, UMyClass::StaticClass, TEXT("MyAssetName"), RF_Public);

2.3 技术总结
下面进行技术总结。

看源码的方法论
首先是看源码。资源部分的代码真的是非常大块,如果想一点一点地啃,不是不行,但是很费时间,而且烧脑。从问题出发确实可以给自己提供一个非常好的入口。

查这部分的源码的技巧如上提及的,有两个方法:

  • 用UE4的工具查看对应Slate的生成代码,然后顺着数据的来源往上读。
  • 直接搜索你觉得有用的一些关键字,如上面提到的.uasset",直接从uasset的读取开始看。

第二种方法其实稍微需要一些基础,在搜索之前你要对这部分的设计思路有一个大概的猜测,比如说你要知道:资源读取,必然是从文件的搜索开始,文件搜索之后便是要读取其文件信息,然后进行某些筛选,最后将通过筛选的资源添加到最终的列表。

反复读基础
顺着自己猜测的思路去看,一一对应上来,在搜索代码和理解代码上就会轻松许多。

第二个是写了UE4一定时间了,要反过来去深入了解一些基础函数、基础模块。

比如NewObject()函数,之前我们用的时候都是直接无参数调用。现在踩坑了才知道NewObject()里面其实每一个参数都非常重要,有时候甚至会导致一些莫名其妙的问题。所以对于常用的函数,最好完全读懂它所有的参数,比如NewObject的OuterFlags参数。

话说官方文档对这部分的解释真的是少,没办法,只能靠自己看了。

越简单的,越要完全理解。

三、反序列化第二次会失败的问题

3.1 第二次反序列化失败
在前两章中我们已经成功地进行了「一次」序列化和反序列化,这一切都看起来很好很妙。直到我需要进行第二次序列化的时候,问题就出来了。

第一次序列化能够非常完美地序列化出对象来,能够正常使用。然而当我在Editor中重新点击「Play」按钮,再一次要求反序列化的时候,返回的是空。

3.2 跑过游戏之后无法再编辑序列化文件
除此之外还有一个很匪夷所思的现象。

从IDE启动Editor之后,可以正常地打开和编辑n次(0 <= 0 <= +无限)序列化资源(指的就是我们上面序列化的那个文件)。然而只要有一次在没有打开这个资源的情况下,点击Play打开了一次游戏,那么停止游戏之后任你再怎么双击这个文件,都会发现没办法再打开。

并且EditorLog会打印报错:

LogAssetEditorSubsystem: Error: Opening Asset editor failed because of null asset

3.3 问题初探
不管是在游戏里面调用LoadObject,还是在资源浏览器中双击一个资源(读者朋友可以自行打断点调试),最终都会调用到LoadObject()函数。

根据我断点调试的结果,在第一次调用LoadObject()的时候,确实会走一遍LoadPackage()然后StaticFindObject()的流程。但是到了第二次LoadObject()的时候,由于Package已经被加载了,所以不需要重新LoadPackage,而是直接寻找对应的Object即可。

关键就在于为什么找不到这个Object。

ResolveName()函数中,有这句代码:

InPackage = StaticFindObjectFast(UPackage::StaticClass(), InPackage, *PartialName);

断点调试的时候,可以从UPackage::LinkerLoad找到对应的LinkerLoad,由此找到它的ExportMap。很奇怪的是ExportMap虽然不为空,但是里面每一项的Object指针都是NULL

3.4 怀疑
于是怀疑是不是GC的锅,毕竟从Editor进入Runtime一次,对应的Object指针就变成了nullptr。但是我们看ExportTable的实现,上面并无UPROPERTY()的标签,所以这些UObject的引用数量并不会因为UPROPERTY()标签而增加。

说到GC,那么就必须怀疑一下EObjectFlags了。

前面章节中说过我们通过给UObject设置RF_Public的Flags来达到让编辑器能够识别和编辑资源的效果。这个Flags应该是一个很重要的特性。

3.5 证实
回过头来想,为啥Editor创建的资源就可以正常地多次被反序列化呢,它的序列化参数究竟和我们的有什么不同?

随便打开一个已存在的其他资源,然后在UPackage::Save()函数上打断点,然后在Editor下点击Save。

这个时候可以断点到第二个参数Base,点开它的ObjectFlags:

RF_Public | RF_Standalone | RF_Transaction | RF_WasLoaded | RF_LoadComplete 

然后把这一串flag全部设置到我们自己要序列化的文件上去。

可以发现问题被解决了!

然后再通过排除法,一个一个把flag去掉,最终发现关键在于:

RF_Standanlone

想要资源能够被多次序列化,能够在Runtime运行之后还能在Editor下编辑文件,就必须设置这个flag。

3.5 原理
那么为什么设置了RF_Standalone就可以避免Object被置空呢?这就需要我们先全局搜一下这个flag的引用,找到可疑的地方深入去看。


这是侑虎科技第1465篇文章,感谢作者佐味供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)

作者主页:https://www.zhihu.com/people/sato-momeji

再次感谢佐味的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)