浅谈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图。
FArchiveFArchive
直译为文档类,序列化和反序列化全靠它。
作为基类,重写了各个版本的<<
操作符。
基本用法是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
,这个类是FLinkerLoad
和FLinkerSave
的基类,暂时没有需要我们注意的地方。
再接下来就是加载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文件中的对象也是这个名字。当你用包名.包名
来获取对象的时候,就会获取失败。
怎么解决呢?
有两种方法:
- 在序列化保存对象之前,用
UObject::Rename()
来把对象名称改成你想要的那个名字,LoadObject的时候,逗点后的对象名就用你指定的名字。 - 在每次创建完要被写话的对象之后,记录下它的名字到服务器或者磁盘,下次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文件是能被读取的。那么有几个疑问:
- 文件系统在遍历所有文件的过程中,能否找到我们的uasset文件?
- 我们的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_Transient
和RF_ClassDefaultObject
的flag - 一定要有
RF_Public
的flag - 不能是IsPendingKill的
断点调试发现第二个条件不符合,读出来的flag是RF_NoFlag
。
我们看看RF_Public
的解释:
UOBject的flag
那么究竟是哪个地方设置了UObject的flag呢?
可以通过两种途径:
NewObject
的时候有个参数可以设置flagUObject::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的Outer
和Flags
参数。
话说官方文档对这部分的解释真的是少,没办法,只能靠自己看了。
越简单的,越要完全理解。
三、反序列化第二次会失败的问题
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,点开它的ObjectFlag
s:
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)