序列化是指将对象转换成字节流,从而存储对象或将对象传输到内存、数据库或文件等的过程。 它的主要用途是保存对象的状态,以便能够在需要时重新创建对象。 反向过程称为“反序列化”。 (通俗来说就是保存和读取的过程分别为序列化和反序列化)
而在维基百科里面是这样解释的。
序列化(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另一台计算机环境中,能恢撤消先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。对于许多对象,像是使用大量引用的复杂对象,这种序列化重建的过程并不容易。面向对象中的对象序列化,并不概括之前原始对象所关系的函数。这种过程也称为对象编组(marshalling)。从一系列字节提取数据结构的反向操作,是反序列化(也称为解编组、deserialization、unmarshalling)。
下图展示了序列化的整个过程。
对象被序列化成流,其中不仅包含数据,还包含对象类型的相关信息,如版本、区域性和程序集名称。 可以将此流中的内容存储在数据库、文件或内存中。
Ue4的序列化使用了访问者模式(Vistor Pattern),将序列化的存档接口抽象化,其中FArchive为访问者, 其它UObject实现了void Serialize( FArchive& Ar )
,接口的类为被访问者。FArchive可以是磁盘文件访问, 内存统计,对象统计等功能。
以下是FArchive的类继承如下:
下面是FArchive提供的一些简单的接口。一部分代码已经去掉。
class CORE_API FArchive
{
public:
/** Default constructor. */
FArchive();
/** Copy constructor. */
FArchive(const FArchive&);
/**
* Copy assignment operator.
*
* @param ArchiveToCopy The archive to copy from.
*/
FArchive& operator=(const FArchive& ArchiveToCopy);
/** Destructor. */
virtual ~FArchive();
public:
FORCEINLINE friend FArchive& operator<<(FArchive& Ar, ANSICHAR& Value);
FORCEINLINE friend FArchive& operator<<(FArchive& Ar, WIDECHAR& Value);
FORCEINLINE friend FArchive& operator<<(FArchive& Ar, uint8& Value);
template
FORCEINLINE friend FArchive& operator<<(FArchive& Ar, TEnumAsByte& Value);
FORCEINLINE friend FArchive& operator<<(FArchive& Ar, int8& Value);
FORCEINLINE friend FArchive& operator<<(FArchive& Ar, uint16& Value);
FORCEINLINE friend FArchive& operator<<(FArchive& Ar, int16& Value);
FORCEINLINE friend FArchive& operator<<(FArchive& Ar, uint32& Value);
FORCEINLINE friend FArchive& operator<<(FArchive& Ar, bool& D);
FORCEINLINE friend FArchive& operator<<(FArchive& Ar, int32& Value);
FORCEINLINE friend FArchive& operator<<(FArchive& Ar, long& Value);
FORCEINLINE friend FArchive& operator<<( FArchive& Ar, float& Value);
FORCEINLINE friend FArchive& operator<<(FArchive& Ar, double& Value);
FORCEINLINE friend FArchive& operator<<(FArchive &Ar, uint64& Value);
/*FORCEINLINE*/friend FArchive& operator<<(FArchive& Ar, int64& Value);
template <
typename EnumType,
typename = typename TEnableIf::Value>::Type
>
FORCEINLINE friend FArchive& operator<<(FArchive& Ar, EnumType& Value)
{
return Ar << (__underlying_type(EnumType)&)Value;
}
friend FArchive& operator<<(FArchive& Ar, struct FIntRect& Value);
friend CORE_API FArchive& operator<<(FArchive& Ar, FString& Value);
public:
virtual void Serialize(void* V, int64 Length) ;
virtual void SerializeBits(void* V, int64 LengthBits);
virtual void SerializeInt(uint32& Value, uint32 Max);
};
由上面可以看到,FArchive原始支持一些基本的数据结构的序列化的。但是具体实现过程需要在子类部分实现。
主要是通过重载operator<<实现,具体实现函数只要是Serialize(void* V, int64 Length),并在在该函数中调用Memcpy(void* dest, void* src, int64 Length)进行复制。
UObject
的序列化和反序列化都对应函数Serialize
。通过传递进来的FArchive的类型不同而进行不同的操作。
此处主要讨论反序列化,UObject通过反序列化的方式从持久存储中读出一个对象,也是需要先实例化对象,然后才能反序列化,而非通过一堆数据,直接就反序列化获得对象。
反序列化过程:
当实例化一个对象之后,传递一个FArchive参数调用反序列化函数,接下来具体过程如下:
通过GetClass函数获取当前的类信息,通过GetOuter函数获取Outer。这个Outer实际上指定了当前UObject会被当作为哪一个对象的子对象进行序列化。
判断当前等待序列化的对象的类UClass的信息是否被载入,没有的话:
a. 预载入当前类的信息;
b. 预载入当前类的默认对象CDO的信息;
载入名字
载入Outer
载入当前对象的类信息,保存于ObjClass对象中。
载入对象的所有脚本成员变量信息。这一步必须在类信息加载后,否则无法根据类信息获得有哪些脚本成员变量需要加载。
对应函数为SerializeScriptProperties
,序列化在类中定义的对象属性。
a. 调用FArchive.MarkScriptSerializationStart
,标志脚本序列化数据开始;
b.调用SerializeTaggedProperties
,序列化对象属性,并且加入tag;
c. 调用FArchive.MarkScriptSerializationEnd
,标志脚本序列化数据结束。
以下大概解释一下UObject代码部分的序列化,由于篇幅有限,只列举部分出来。
UObject::Serialize( FArchive& Ar )
UObject通过实现Serialize接口来序列化对象数据。
void UObject::Serialize( FArchive& Ar )
{
// These three items are very special items from a serialization standpoint. They aren't actually serialized.
UClass *ObjClass = GetClass();
UObject* LoadOuter = GetOuter();
FName LoadName = GetFName();
// ........
// 中间省略了一部分代码
//
// Serialize object properties which are defined in the class.
// Handle derived UClass objects (exact UClass objects are native only and shouldn't be touched)
if (ObjClass != UClass::StaticClass())
{
SerializeScriptProperties(Ar);
}
// 省略一部分代码
// 序列化在类中定义的对象属性。
// 添加GUID
// Serialize a GUID if this object has one mapped to it
FLazyObjectPtr::PossiblySerializeObjectGuid(this, Ar);
// Invalidate asset pointer caches when loading a new object
if (Ar.IsLoading() )
{
FSoftObjectPath::InvalidateTag();
}
// Memory counting (with proper alignment to match C++)
SIZE_T Size = GetClass()->GetStructureSize();
Ar.CountBytes( Size, Size );
}
void UObject::SerializeScriptProperties( FArchive& Ar ) const
void UObject::SerializeScriptProperties( FArchive& Ar ) const
{
Ar.MarkScriptSerializationStart(this);
if( HasAnyFlags(RF_ClassDefaultObject) )
{
Ar.StartSerializingDefaults();
}
UClass *ObjClass = GetClass();
if( (Ar.IsLoading() || Ar.IsSaving()) && !Ar.WantBinaryPropertySerialization() )
{
//@todoio GetArchetype is pathological for blueprint classes and the event driven loader; the EDL already knows what the archetype is; just calling this->GetArchetype() tries to load some other stuff.
UObject* DiffObject = Ar.GetArchetypeFromLoader(this);
if (!DiffObject)
{
DiffObject = GetArchetype();
}
// 省略部分代码
// 序列化对象属性,并且加入tag
ObjClass->SerializeTaggedProperties(Ar, (uint8*)this, HasAnyFlags(RF_ClassDefaultObject) ? ObjClass->GetSuperClass() : ObjClass, (uint8*)DiffObject, bBreakSerializationRecursion ? this : NULL);
}
else if ( Ar.GetPortFlags() != 0 && !Ar.ArUseCustomPropertyList )
{
//@todoio GetArchetype is pathological for blueprint classes and the event driven loader; the EDL already knows what the archetype is; just calling this->GetArchetype() tries to load some other stuff.
UObject* DiffObject = Ar.GetArchetypeFromLoader(this);
if (!DiffObject)
{
DiffObject = GetArchetype();
}
ObjClass->SerializeBinEx( Ar, const_cast(this), DiffObject, DiffObject ? DiffObject->GetClass() : NULL );
}
else
{
ObjClass->SerializeBin( Ar, const_cast(this) );
}
if( HasAnyFlags(RF_ClassDefaultObject) )
{
Ar.StopSerializingDefaults();
}
Ar.MarkScriptSerializationEnd(this);
}
ObjClass->SerializeTaggedProperties(Ar, (uint8)this, HasAnyFlags(RF_ClassDefaultObject) ? ObjClass->GetSuperClass() : ObjClass, (uint8)DiffObject, bBreakSerializationRecursion ? this : NULL);
在这个函数中主要序列化属性和添加tag,由于代码太大,就不一一列出来了。
下面借用大象无形这本书对UObject序列化所提出的切豆腐理论:
切豆腐理论
对于硬盘上保存的数据来说,其本身不具备“意义”,其含义取决于我们如何解释这一段数据。我们每次反序列化一个C++基本对象,例如一个float浮点数,我们是从豆腐最开头切下一块和浮点数长度一样大的豆腐,然后把这块豆腐解释为一个浮点数。那读者会问,你怎么知道这块豆腐就是浮点数?不是布尔值?或者是某个字符串?答案是,你按照什么样的顺序把豆腐排起来的,并按照同样顺序切,那就能保证每次切出来的都是正确的。我放了三块豆腐:豆腐1(foat)、豆腐2(bool)、豆腐3( double)。然后把它们按顺序排好靠紧,于是豆腐之间的缝隙就没了。我可以把这三块豆腐看成一块完整的大豆腐,放冰箱里冻起来。下次要吃的时候,我该怎么把它们切成原样?很简单,我知道豆腐1、豆腐2、豆腐3的长度,所以我在1号长度处切一刀,1号+2号长度处切一刀。妥了!三块豆腐就分开了。
递归序列化/反序列化
一个类的成员变量会有以下类型:C++基本类型、自定义类型的对象、自定义类型的指针。关于指针问题,我们将会在后文分析。这里主要分析前两者。第一个,对于C++基本类型对象,我们可以用切豆腐理论序列化。那么自定义类型怎么办? 毕竟这个类型是我自己定义的,我有些变量不想序列化(比如那些无关紧要的、可以由其他变量计算得来的变量)怎么解决?答案是,如果需要序列化自定义类型,就调用自定义类型的序列化函数。由该类型自行决定。于是这就转化为了一棵像树一样的序列化过程。沿用前文的切豆腐理论。当我们需要向豆腐列表增加一块由别人定义的豆腐的时候,我们遵循谁定义谁负责的原则,让别人把豆腐给我们。切豆腐的时候,由于我们只知道接下来要切的豆腐的类型,却不知道具体应该怎么切,我们就把整块豆腐都交给那个人,让他自己处理。切完了再还给我们。
理解这两个概念之后,我们就能开始分析虚幻引擎的反序列化过程。这其实是一个两步过程,其中第一步可选:
(1) 从另一块豆腐中加载对象所属的类信息,一旦加载完成就像获得了一张当前豆腐的统计表,这块豆腐都有哪几节,每节对应类型是什么,都在这张表里。
(2) 根据统计表,开始切豆腐。此时我们已经知道每块豆腐切割位置和获取顺序了,还原成员变量简直如同探囊取物。 同时,虚幻引擎也对序列化后的大小进行了优化。我们不妨思考前文所述的切豆腐论,如果我们完成序列化整个类,那么对于继承深度较深的子类,势必要序列化父类的全部数据。那么每个子类对象都必须占用较大的空间。有没有办法优化?我们会发现其实子类对象很多数据是共同的,它们都是来自同样父类的默认值。这些数据只需要序列化一份就可以了。换句话说:
虚幻引擎序列化每个继承自 CLass 的类的默认值(即序列化CDO),然后序列化对象与类默认对象的差异。这样就节约了大量的子类对象序列化后的存储空间。
接下来就是改如何去切一块豆腐了。
UE中使用统一的格式存储资源(uasset, umap),每个uasset对应一个包(package),存储一个UPackage对象时,会将该包下的所有对象都存到uasset中。
举一个例子更好地解释uasset的文件格式。
以一个班级(UPackage)来举例子,首先班级里有很多学生(对象),但是这个班级充满了恋爱的味道(对象之间互相引用,还引用了其他班的对象,甚至还有多角恋),而且老师为了解决同桌早恋问题,经常让全班换座位(每次加载内存地址都会改变)。此时来了一个管理者(FArchive),希望能够记录全班谈恋爱对象配对名单。
在包最前方有两张表,导出表 Export Table和导入表 Import Table,前者可以理解为本班人员名单,后者可以理解为隔壁班人员名单;
当序列化当前包内一个对象的时候,遇到一个 UObject指针怎么办?此时肯定不能直接序列化指针的值。这类似于管理者在记录小王喜欢的对象小红的时候,不能直接记录小王喜欢的人的座位(内存地址),否则第二天座位一变动,就出事了。此时管理者灵机一动。
a. 拿出导出表,往里面加了一项:1号小红。
b. 修改“小王喜欢的对象”字段,将其从一个座位编号,变成了导出表里面的
个项的编号:1号。
c.查看谁是小红真正的男朋友( NewObject的时候指定的 Outer,到时候由他负责真正序列化小红。
d.继续存储其他有关的信息:如果遇到普通数据(小王的名字, FName;小王的年龄,Int8),就直接序列化,如果遇到 UObject,就重复第二和第三步。
这时候管理者发现小刚喜欢的对象居然是隔壁班的小花,管理者无奈,只能再找出一张表:导入表,然后加了一项-1:小花(导入表项为负,导出表项为正,方便区分)。总不能把隔壁班的人也给序列化到本班的数据里面吧。然后把这项的编号替换到小刚喜欢的人的座位里。
全部记录完毕,把两张表都保存起来,对象本身的数据则逐个排放在表后面,存
放起来。
反序列化
第二天,管理者(负责反序列化的FArchive)来到教室,教室一个学生都没有(内存此时完全为空,没有任何对象信息)。
此时,管理者拿出自己昨天记录的信息,从后面抽出一个对象,看看对象是什么类型,根据这个类型,把对象先模塑出来:
a. 如果UClass类型数据还没载入,先把UClass载入了,并把CDO给读取了。
b. 根据UClass信息,模塑一个对象–通俗来说,管理者先在塑造了一个假人出来,但是这个假人目前还没有任何特征。
接着根据这个对象的类信息,读取等大的数据,并根据类信息中包含的成员变量信息,判断这个成员变量的类型,执行以下步骤:
a. 假如是基础对象(名字,FName;年龄,Int8),就直接把这个对象给反序列化。此时这个假人拥有了名字和年龄。还有一些其他的属性。
b. 此时不可避免地遇到了UObject类型的成员变量。此时,管理者查看这个成员变量的PackageIndex 是负的还是正的。
如果是正的,则检查ExportTable导出表,看看这个对象有没有被序列化,如果有,就把对应对象的指针替换,否则就先造个假人丢在那里,等待此人的Outer负责实际序列化。
如果是负的,则检查ImportTable导入表,看看对应的Package有没有载入内存,没载入,就载入该Package;如果已经载入了,管理者直接到该Package找到该对象的地址填到表项处。
最后,经历一波折腾,全班会经历这样一个过程:
a. 首先班上会逐渐出现原来的同学和一些假人(已经被 Newobject模塑出来,,但是还没有根据反序列化信息恢复成原始对象的对象);
b. 随后假人会逐渐被还原为原始对象。即随着读取的信息越来越多,根据反序列化后的信息还原成和原始对象一致的对象越来越多;
c. 最后全班所有人都会被恢复为和原始对象一致的人。也许小王同学喜欢的那个人的座位号变了(反序列化后指针的值被修正),但是新的座位号上坐着的人,是和他当年喜欢的小红一模一样的人。
通俗地来说就是这样的过程。从这个过程中,我们能获取到一些非常有趣的信息和
经验:
序列化必要的、差异性的数据
不必要的引用不需要被序列化和反序列化。因此如果你的成员变量没有被 UPROPERTY标记,其不会被序列化。如果你的这个成员变量值与默认值一致,也不会占用空间进行序列化
先模塑对象,再还原数据
这个过程笔者多次重点阐述,就是为了强调虚幻引擎的
这个设计。先把对象通过 Newobject模塑,然后还原差异性的数据。且被模塑出的
对象会作为其他对象修正指针指向的基础。正如前文所言,小明不会因为小王的对
象小红还没被序列化就束手无策,没被序列化就直接实例化一个假人丢在那,大不
了以后读取到小红的数据时,把那个假人的信息改成和小红一样就好。
对象具有“所属”关系
由 NewObject指定的 Outer负责序列化和反序列化。
鸭子理论
叫起来像鸭子,看起来像鸭子,动起来像鸭子,那就是鸭子。说话像小红,看起来像小红,做事情像小红,那就是小红。也就是说,如果一个对象的所有成员变量与原始对象一致(指针的值可以不同,但指向的对象要一致),则该对象就是原始对象。
负责将uasset文件中的对象加载到内存中,起桥梁作用。
MyActor.h
UCLASS()
class HELLOWORLD_API ASaveActor : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
ASaveActor();
public:
friend FArchive& operator<<(FArchive& Ar, ASaveActor& SaveActorRef);
UPROPERTY(EditAnywhere)
float Health;
};
MyActor.cpp
FArchive & operator<<(FArchive & Ar, ASaveActor & SaveActorRef)
{
Ar << SaveActorRef.Health;
return Ar;
}
Character.h
UCLASS()
class HELLOWORLD_API ATP_ThirdPersonCharacter : public ACharacter
{
GENERATED_BODY()
public:
//
void SaveLoadData(FArchive& Ar, float& HealthToSaveOrLoad, int32& CurrentAmmoToSaveOrLoad, FVector& PlayerLocationToSaveOrLoad);
// 你好
UPROPERTY(EditAnywhere)
class ASaveActor* SaveActorRef;
UFUNCTION(BlueprintCallable, Category = SaveLoad)
bool SaveData();
UFUNCTION(BlueprintCallable, Category = SaveLoad)
bool LoadData();
UPROPERTY(EditAnywhere)
float Health;
UPROPERTY(EditAnywhere)
int32 CurrentAmmo;
UPROPERTY(EditAnywhere)
FVector RandomLocation;
};
Character.cpp
void ATP_ThirdPersonCharacter::SaveLoadData(FArchive& Ar, float& HealthToSaveOrLoad, int32& CurrentAmmoToSaveOrLoad, FVector& PlayerLocationToSaveOrLoad)
{
//Save or load values
Ar << HealthToSaveOrLoad;
Ar << CurrentAmmoToSaveOrLoad;
Ar << PlayerLocationToSaveOrLoad;
Ar << *SaveActorRef;
}
bool ATP_ThirdPersonCharacter::SaveData()
{
FBufferArchive ToBinary;
SaveLoadData(ToBinary, Health, CurrentAmmo, RandomLocation);
//No data were saved
if (ToBinary.Num() <= 0) return false;
//Save binaries to disk
bool result = FFileHelper::SaveArrayToFile(ToBinary, TEXT(SAVEDATAFILENAME));
//Empty the buffer's contents
ToBinary.FlushCache();
ToBinary.Empty();
return result;
}
bool ATP_ThirdPersonCharacter::LoadData()
{
TArray BinaryArray;
//load disk data to binary array
if (!FFileHelper::LoadFileToArray(BinaryArray, TEXT(SAVEDATAFILENAME))) return false;
if (BinaryArray.Num() <= 0) return false;
//Memory reader is the archive that we're going to use in order to read the loaded data
FMemoryReader FromBinary = FMemoryReader(BinaryArray, true);
FromBinary.Seek(0);
SaveLoadData(FromBinary, Health, CurrentAmmo, RandomLocation);
//Empty the buffer's contents
FromBinary.FlushCache();
BinaryArray.Empty();
//Close the stream
FromBinary.Close();
return true;
}
当按下Q的时候我们就保存,按下E的时候就加载数据。
当我们已经保存数据了,然后再把character的数据改动,此时再按下E,这时候character又变回了原来的数据。
UE4版本 4.20
参考:
简书blog
大象无形 虚幻引擎程序设计浅析
SaveSystem in ue4 wiki
Save Actor Data And Load Actor Data
Save Data And Load Data