天天看点

Ue4_序列化浅析Ue4_序列化浅析

Ue4_序列化浅析

1. 序列化基本概念

序列化是指将对象转换成字节流,从而存储对象或将对象传输到内存、数据库或文件等的过程。 它的主要用途是保存对象的状态,以便能够在需要时重新创建对象。 反向过程称为“反序列化”。 (通俗来说就是保存和读取的过程分别为序列化和反序列化)

而在维基百科里面是这样解释的。

序列化(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另一台计算机环境中,能恢撤消先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。对于许多对象,像是使用大量引用的复杂对象,这种序列化重建的过程并不容易。面向对象中的对象序列化,并不概括之前原始对象所关系的函数。这种过程也称为对象编组(marshalling)。从一系列字节提取数据结构的反向操作,是反序列化(也称为解编组、deserialization、unmarshalling)。

序列化的工作原理

下图展示了序列化的整个过程。

Ue4_序列化浅析Ue4_序列化浅析

对象被序列化成流,其中不仅包含数据,还包含对象类型的相关信息,如版本、区域性和程序集名称。 可以将此流中的内容存储在数据库、文件或内存中。

2. Ue4的序列化

Ue4的序列化使用了访问者模式(Vistor Pattern),将序列化的存档接口抽象化,其中FArchive为访问者, 其它UObject实现了

void Serialize( FArchive& Ar )

,接口的类为被访问者。FArchive可以是磁盘文件访问, 内存统计,对象统计等功能。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d5zHfudi-1599206254232)(C:\Users\andyhkmo\Desktop\picture\Ue4_Serialization.png)]

Ue4_序列化浅析Ue4_序列化浅析

以下是FArchive的类继承如下:

Ue4_序列化浅析Ue4_序列化浅析

下面是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<class TEnum>
    FORCEINLINE friend FArchive& operator<<(FArchive& Ar, TEnumAsByte<TEnum>& 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<TIsEnumClass<EnumType>::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)进行复制。

3. UObject的序列化

UObject

的序列化和反序列化都对应函数

Serialize

。通过传递进来的FArchive的类型不同而进行不同的操作。

此处主要讨论反序列化,UObject通过反序列化的方式从持久存储中读出一个对象,也是需要先实例化对象,然后才能反序列化,而非通过一堆数据,直接就反序列化获得对象。

反序列化过程:

当实例化一个对象之后,传递一个FArchive参数调用反序列化函数,接下来具体过程如下:

  1. 通过GetClass函数获取当前的类信息,通过GetOuter函数获取Outer。这个Outer实际上指定了当前UObject会被当作为哪一个对象的子对象进行序列化。
  2. 判断当前等待序列化的对象的类UClass的信息是否被载入,没有的话:

    a. 预载入当前类的信息;

    b. 预载入当前类的默认对象CDO的信息;

  3. 载入名字
  4. 载入Outer
  5. 载入当前对象的类信息,保存于ObjClass对象中。
  6. 载入对象的所有脚本成员变量信息。这一步必须在类信息加载后,否则无法根据类信息获得有哪些脚本成员变量需要加载。

    对应函数为

    SerializeScriptProperties

    ,序列化在类中定义的对象属性。

    a. 调用

    FArchive.MarkScriptSerializationStart

    ,标志脚本序列化数据开始;

    b.调用

    SerializeTaggedProperties

    ,序列化对象属性,并且加入tag;

    c. 调用

    FArchive.MarkScriptSerializationEnd

    ,标志脚本序列化数据结束。

以下大概解释一下UObject代码部分的序列化,由于篇幅有限,只列举部分出来。

Ue4_序列化浅析Ue4_序列化浅析

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<UObject *>(this), DiffObject, DiffObject ? DiffObject->GetClass() : NULL );
	}
	else
	{
		ObjClass->SerializeBin( Ar, const_cast<UObject *>(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),然后序列化对象与类默认对象的差异。这样就节约了大量的子类对象序列化后的存储空间。

接下来就是改如何去切一块豆腐了。

4. uasset文件格式

UE中使用统一的格式存储资源(uasset, umap),每个uasset对应一个包(package),存储一个UPackage对象时,会将该包下的所有对象都存到uasset中。

Ue4_序列化浅析Ue4_序列化浅析
  • File Summary 文件头信息。
  • Name Table 包中对象的名字表。
  • Import Table 存放被该包中对象引用的其它包中的对象信息(路径名和类型)。
  • Export Table 该包中的对象信息(路径名和类型)。
  • Export Objects 所有Export Table中对象的实际数据。

举一个例子更好地解释uasset的文件格式。

以一个班级(UPackage)来举例子,首先班级里有很多学生(对象),但是这个班级充满了恋爱的味道(对象之间互相引用,还引用了其他班的对象,甚至还有多角恋),而且老师为了解决同桌早恋问题,经常让全班换座位(每次加载内存地址都会改变)。此时来了一个管理者(FArchive),希望能够记录全班谈恋爱对象配对名单。

Ue4_序列化浅析Ue4_序列化浅析
  1. 在包最前方有两张表,导出表 Export Table和导入表 Import Table,前者可以理解为本班人员名单,后者可以理解为隔壁班人员名单;
  2. 当序列化当前包内一个对象的时候,遇到一个 UObject指针怎么办?此时肯定不能直接序列化指针的值。这类似于管理者在记录小王喜欢的对象小红的时候,不能直接记录小王喜欢的人的座位(内存地址),否则第二天座位一变动,就出事了。此时管理者灵机一动。

    a. 拿出导出表,往里面加了一项:1号小红。

    b. 修改“小王喜欢的对象”字段,将其从一个座位编号,变成了导出表里面的

    个项的编号:1号。

    c.查看谁是小红真正的男朋友( NewObject的时候指定的 Outer,到时候由他负责真正序列化小红。

    d.继续存储其他有关的信息:如果遇到普通数据(小王的名字, FName;小王的年龄,Int8),就直接序列化,如果遇到 UObject,就重复第二和第三步。

  3. 这时候管理者发现小刚喜欢的对象居然是隔壁班的小花,管理者无奈,只能再找出一张表:导入表,然后加了一项-1:小花(导入表项为负,导出表项为正,方便区分)。总不能把隔壁班的人也给序列化到本班的数据里面吧。然后把这项的编号替换到小刚喜欢的人的座位里。
  4. 全部记录完毕,把两张表都保存起来,对象本身的数据则逐个排放在表后面,存

    放起来。

反序列化

第二天,管理者(负责反序列化的FArchive)来到教室,教室一个学生都没有(内存此时完全为空,没有任何对象信息)。

  1. 此时,管理者拿出自己昨天记录的信息,从后面抽出一个对象,看看对象是什么类型,根据这个类型,把对象先模塑出来:

    a. 如果UClass类型数据还没载入,先把UClass载入了,并把CDO给读取了。

    b. 根据UClass信息,模塑一个对象–通俗来说,管理者先在塑造了一个假人出来,但是这个假人目前还没有任何特征。

  2. 接着根据这个对象的类信息,读取等大的数据,并根据类信息中包含的成员变量信息,判断这个成员变量的类型,执行以下步骤:

    a. 假如是基础对象(名字,FName;年龄,Int8),就直接把这个对象给反序列化。此时这个假人拥有了名字和年龄。还有一些其他的属性。

    b. 此时不可避免地遇到了UObject类型的成员变量。此时,管理者查看这个成员变量的PackageIndex 是负的还是正的。

    如果是正的,则检查ExportTable导出表,看看这个对象有没有被序列化,如果有,就把对应对象的指针替换,否则就先造个假人丢在那里,等待此人的Outer负责实际序列化。

    如果是负的,则检查ImportTable导入表,看看对应的Package有没有载入内存,没载入,就载入该Package;如果已经载入了,管理者直接到该Package找到该对象的地址填到表项处。

  3. 最后,经历一波折腾,全班会经历这样一个过程:

    a. 首先班上会逐渐出现原来的同学和一些假人(已经被 Newobject模塑出来,,但是还没有根据反序列化信息恢复成原始对象的对象);

    b. 随后假人会逐渐被还原为原始对象。即随着读取的信息越来越多,根据反序列化后的信息还原成和原始对象一致的对象越来越多;

    c. 最后全班所有人都会被恢复为和原始对象一致的人。也许小王同学喜欢的那个人的座位号变了(反序列化后指针的值被修正),但是新的座位号上坐着的人,是和他当年喜欢的小红一模一样的人。

Ue4_序列化浅析Ue4_序列化浅析

通俗地来说就是这样的过程。从这个过程中,我们能获取到一些非常有趣的信息和

经验:

  1. 序列化必要的、差异性的数据

    不必要的引用不需要被序列化和反序列化。因此如果你的成员变量没有被 UPROPERTY标记,其不会被序列化。如果你的这个成员变量值与默认值一致,也不会占用空间进行序列化

  2. 先模塑对象,再还原数据

    这个过程笔者多次重点阐述,就是为了强调虚幻引擎的

    这个设计。先把对象通过 Newobject模塑,然后还原差异性的数据。且被模塑出的

    对象会作为其他对象修正指针指向的基础。正如前文所言,小明不会因为小王的对

    象小红还没被序列化就束手无策,没被序列化就直接实例化一个假人丢在那,大不

    了以后读取到小红的数据时,把那个假人的信息改成和小红一样就好。

  3. 对象具有“所属”关系

    由 NewObject指定的 Outer负责序列化和反序列化。

  4. 鸭子理论

    叫起来像鸭子,看起来像鸭子,动起来像鸭子,那就是鸭子。说话像小红,看起来像小红,做事情像小红,那就是小红。也就是说,如果一个对象的所有成员变量与原始对象一致(指针的值可以不同,但指向的对象要一致),则该对象就是原始对象。

5. FlinkerLoad和FlinkerSave

负责将uasset文件中的对象加载到内存中,起桥梁作用。

6. 例子:SaveData And Load Data

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<uint8> 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;
}
           
Ue4_序列化浅析Ue4_序列化浅析

当按下Q的时候我们就保存,按下E的时候就加载数据。

当我们已经保存数据了,然后再把character的数据改动,此时再按下E,这时候character又变回了原来的数据。

Ue4_序列化浅析Ue4_序列化浅析
Ue4_序列化浅析Ue4_序列化浅析

UE4版本 4.20

参考:

简书blog

大象无形 虚幻引擎程序设计浅析

SaveSystem in ue4 wiki

Save Actor Data And Load Actor Data

Save Data And Load Data

继续阅读