Ue4_序列化淺析

Ue4_序列化淺析

1. 序列化基本概念

序列化是指將對象轉換成字節流,從而存儲對象或將對象傳輸到內存、數據庫或文件等的過程。 它的主要用途是保存對象的狀態,以便能夠在需要時重新創建對象。 反向過程稱爲“反序列化”。 (通俗來說就是保存和讀取的過程分別爲序列化和反序列化)

而在維基百科裏面是這樣解釋的。

序列化(serialization)在計算機科學的數據處理中,是指將數據結構或對象狀態轉換成可取用格式(例如存成文件,存於緩衝,或經由網絡中發送),以留待後續在相同或另一臺計算機環境中,能恢撤消先狀態的過程。依照序列化格式重新獲取字節的結果時,可以利用它來產生與原始對象相同語義的副本。對於許多對象,像是使用大量引用的複雜對象,這種序列化重建的過程並不容易。面向對象中的對象序列化,並不概括之前原始對象所關係的函數。這種過程也稱爲對象編組(marshalling)。從一系列字節提取數據結構的反向操作,是反序列化(也稱爲解編組、deserialization、unmarshalling)。

序列化的工作原理

下圖展示了序列化的整個過程。

序列化圖

對象被序列化成流,其中不僅包含數據,還包含對象類型的相關信息,如版本、區域性和程序集名稱。 可以將此流中的內容存儲在數據庫、文件或內存中。

2. Ue4的序列化

Ue4的序列化使用了訪問者模式(Vistor Pattern),將序列化的存檔接口抽象化,其中FArchive爲訪問者, 其它UObject實現了void Serialize( FArchive& Ar ),接口的類爲被訪問者。FArchive可以是磁盤文件訪問, 內存統計,對象統計等功能。

Vistor Pattern
在這裏插入圖片描述

以下是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<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代碼部分的序列化,由於篇幅有限,只列舉部分出來。

在這裏插入圖片描述

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中。

uasset文件格式在這裏插入圖片描述

  • File Summary 文件頭信息。
  • Name Table 包中對象的名字表。
  • Import Table 存放被該包中對象引用的其它包中的對象信息(路徑名和類型)。
  • Export Table 該包中的對象信息(路徑名和類型)。
  • Export Objects 所有Export Table中對象的實際數據。

舉一個例子更好地解釋uasset的文件格式。

以一個班級(UPackage)來舉例子,首先班級裏有很多學生(對象),但是這個班級充滿了戀愛的味道(對象之間互相引用,還引用了其他班的對象,甚至還有多角戀),而且老師爲了解決同桌早戀問題,經常讓全班換座位(每次加載內存地址都會改變)。此時來了一個管理者(FArchive),希望能夠記錄全班談戀愛對象配對名單。

在這裏插入圖片描述

  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. 最後全班所有人都會被恢復爲和原始對象一致的人。也許小王同學喜歡的那個人的座位號變了(反序列化後指針的值被修正),但是新的座位號上坐着的人,是和他當年喜歡的小紅一模一樣的人。

在這裏插入圖片描述

通俗地來說就是這樣的過程。從這個過程中,我們能獲取到一些非常有趣的信息和
經驗:

  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;
}

在這裏插入圖片描述

當按下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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章