文 | Je Zhang
腾讯互动娱乐 工程师
// 导语:本文主要介绍了序列化的概念、实现及其在游戏中的应用,特别是在Unreal Engine(UE)中的具体实现。文章首先解释了序列化的基本概念,即如何将内存中的对象状态转换为可持久化或网络传输的格式。接着,文章详细讨论了序列化的实现思路,包括逐字段处理和逐类型处理、代码自动生成与反射获取属性。文章还深入分析了UE中的序列化机制,并指出了序列化在实践中需要注意的问题,如版本控制、安全性和性能优化。
在文章的开始,我们先引入几个问题:
• 一款游戏,如何能保存玩家进度与状态,而在玩家重新游玩的时候,又如何能将玩家状态还原?
• 联网游戏,如何能保持一个对象在不同端的状态同步,其创建、销毁、位置或其它属性变更如何能在所有玩家端展现?
• 游戏工程,对场景中的对象属性进行更改,编辑器如何能为我们记录并持久化这次变更,甚至场景又是如何保存的?
相信有经验的同学看到这些问题,就能想到项目中的实际解决方案:
• 进度的保存,我们需要筛选出需要保存的对象、状态,编排出特定档案,然后保存到本地/注册表/服务器中
- 进度的还原是上面的逆过程:根据档案,我们重建出特定对象、对象的状态
• 联网属性同步,我们需要筛选出需要同步的对象、状态,编排出特定通信报文,通过通过网络通信传输到对端
- 对端收到报文后,则需要根据报文内容创建对象、变更特定对象的状态
• 场景对象修改,我们需要筛选出变更的对象、状态,编排出特定档案,然后保存为本地文件或服务器档案
- 场景重新打开时,需要根据文件,重建出场景、场景内对象的状态
这些问题,看似不同,实际上在较为底层的层次,分别需要调用文件系统、网络通信与引擎自身的编辑时资源管理系统;但是,在我们将上面的几个过程按时序分段考察时,却能发现,无论后续处理为何,我们在真正保存/传输之前都有一步可能重复的操作:筛选出特定对象、状态并对其进行编排。
而如何编排对象、状态,就是我们今天的主题:序列化。
严格说来,序列化的实现与内容并不复杂,其思路更是完全可以一句话讲清:将进程中的对象状态编排为提前约定的格式,以备后续的反序列化使用。
真正复杂的,是如何快速筛选、高效编排与重建,以及在长期环境下的版本兼容如何实现;也即:
• 表示:如何能够兼顾可读性和安全性,以及确定要序列化的内容
• 性能:同时包含了时间和空间上的高效性
• 兼容:随版本迭代,能否提供持续的后向兼容
我们甚至可以认为,在有了将内存数据编排为约定好的格式的思想之后,所有的实现或方案,都无外乎围绕着如何更高效、更便利、更通用(跨端、跨语言、跨版本)的表示与传递数据展开(现实是,我们没有一个能同时满足上面所有目标的方案,因此才有了大量的数据表示协议,如json、xml或是protocol buffer)。
由于实现思路和抽象模型相对稳定,而实现策略则可能因为方案甚至同一方案的不同版本而随时变更,因此,我们首先从更稳定的机制层展开。
核心概念
逐字段处理
假设我们有一个c++实现的贪食蛇小游戏,那么,在玩家运行时,其能够看到的内容可能包含:
• 当前所在关卡
• 关卡地形
• 苹果位置
• 蛇的位置与状态
• 玩家分数
• 玩家命数
此时,要实现一个存档系统,我们完全可以编写如下代码(假设我们还不知道json):
//存档的开始符号
const string SAVE_HEADER = "_SAVE_"
//存档中的分隔符
const string SAVE_SPLITTER = "||"
//获取序列化好的存档
string SerializeSaveData() {
auto save = SAVE_HEADER + SAVE_SPLITTER;
//GetScore()->number, 玩家分数
save = save + SAVE_SPLITTER + to_string(GetScore());
//GetLevel()->string, 关卡表示, 障碍物
save = save + SAVE_SPLITTER + to_string(GetLevel());
//StreamingSnake()->string, 蛇所占的格子, 蛇头位置与方向
//表示方式: 从蛇头开始到蛇尾方向,记录每个部位所在的网格.
save = save + SAVE_SPLITTER + StreamingSnake();
//StreamingAppleList()->string, 苹果所在格子
save = save + SAVE_SPLITTER + StreamingAppleList();
//GetLife()->number, 玩家复活次数
save = save + SAVE_SPLITTER + to_string(GetLife());
return save;
}
//获取指定idx的存档的名称
string GetSaveDataName(int idx) {
return "savedata"..to_string(idx);
}
//存档至编号X
void SaveGame(int idx) {
auto save = SerializeSaveData();
auto fileName = GetSaveDataName(idx);
auto f = fopen(fileName.c_str(), "w+");
fprintf(f, "%s", save);
fclose(f);
}
而在读档时,我们编写的代码则如下(存档的相反操作):
//反序列化存档
void UnserializeSaveData(string save) {
vector<const char*>states = split(save, SAVE_SPLITTER)
//0为存档头, SetScore(int i) 设置分数
SetScore(atoi(states[1]));
//SetLevel(int i) 设置并读取关卡
SetLevel(atoi(states[2]));
//InitSnake(const char*) 根据字符串信息初始化蛇
InitSnake(states[3]);
//SpawnApples(const char*) 根据字符串生成苹果
SpawnApples(states[4]);
//SetLife(int i) 设置命数
SetLife(atoi(states[5]));
}
//读取编号x的存档
void LoadGame(int idx) {
auto fileName = GetSaveDataName(idx);
auto f = fopen(fileName.c_str(), "r");
char save[65536];
fscanf(f, "%s", save);
UnserializeSaveData(save);
fclose(f);
}
上面的逻辑中,我们直接将所有属性按序保存/加载,这固然可以节省一些空间,但也会导致维护性的降低,有需要的话,也可以在每个属性前增加key(属性名称)。
由此,我们就实现了最简单的存读档系统,其和我们最开始的分析对应,Save/LoadGame对应文件操作,而Serialize/Unserialize则用户保存/读取世界中对象的状态。
简单游戏中,状态并不复杂,因此可以直接在一个方法中将所有需要关注的内容全部序列化;而对于稍微复杂的情况,我们也可以如上图这般,将各自的序列化逻辑分发出去,调用每个模块各自的序列化。通过为每个需要保存的对象定制逻辑,我们也有极大的自由度:蛇、苹果和关卡我们可以任选我们喜爱的方式表示,只要我们保证暴露到外部的为string即可(先不考虑兼容性)。
在更为复杂的游戏乃至场景中,要进行序列化,沿着上面的思路,我们也只需要将一些较为重度的逻辑拆分出独立的函数调用。这就是抽象带来的优点:让我们能仅关注一个较小领域、层次内的问题。
逐类型处理
显式序列化每条属性在简单的工作中已经足够胜任,但在规模继续增大,甚至引入其他领域的使用场景时,这种方式会开始出现问题:每出现一个新的需要保存的属性,我们便需要在Serialize/Unserialize中添加一次对应属性的调用,这不仅增加了工作量和维护难度,也没有多少成就感可言。
再考虑将我们的游戏改为联网版本,其中玩家的得分、苹果生成位置均从客户端同步的情况,此时我们要修改的便不只是上面的Serailize方法,还有Marshal相关的方法:一个属性,三个模块的变更。
• Marshal(封送)可以认为是序列化+网络/进程间通信,其与序列化相比,包含了通信过程
此外,我们的关卡也可能允许开发者/玩家自行搭建,而非手动生成,这又引入了编辑器,和编辑时与运行时统一的关卡表示,此时对关卡的变更,我们可能有更多模块需要同步变动。
再假设我们在本游戏之外又创作了一个简单的扫雷游戏,我们会发现,如果要在扫雷中也支持存档,那我们需要将上面的逻辑再重复实现一遍(或者更省事的方式,拷贝粘贴一遍),这自然更令人沮丧。
有什么方法能让我们的修改简单一点呢?或者说,在属性增加的时候,我们能不能将他们简单分类,并为每种数据定义各自统一的表示?当我们这样想的时候,对于面向对象语言,我们已经有了答案:按照变量的类型来将其简单划分,并提供每个类型的序列化方法即可。
• 这基于一个假设:每个类型可以对应多个实例,且复杂类型通过简单/内置数据类型组装而成:只有在这种情况下,我们以类型而非变量处理,才可以减少工作量。
• 在面向对象语言中,我们可以为少数几种内置数据类型实现具体的序列化;而复杂类型的序列化方法则是遍历自己需要序列化的成员,依次调用对应成员的序列化方法
为了降低复杂度,我们依然先用字符串来表示,定义如下规则:
• 仅记录字段名与值,不记录类型
• 数字和bool,以原始值表示,数字使用int64,bool使用true和false
• 其它所有内置类型,以字符串表示
• 所有字符串,以"和"包裹
• 类的字段名与值,视为类中的一组key-value pair
• 一个类/字典的所有kv,以{和}包裹
• 数组的表示,以[和]包裹
• 嵌套值时,根据嵌套值类型,以上面的对应方式表示
• 非字典/数组末尾元素的结尾必须加","符号
(如果这种表示让你觉得熟悉,那你是对的。因为这就是json的表示方法。)
我们再次重写上面的Serialize和Unserialize方法:
template<typename T>
string Serialize(T val) {return "";}
template<>
string Serialize(int val) {return to_string(val);}
template<>
string Serialize(const char* val) {return val;}
template<typename T>
string Serialize(vector<T>vec) {
string res = "[";
for(T item : vec) {
res += Serialize(item);
res += ",",
}
res += "]";
return res;
}
struct Grid {
int x;
int y;
};
class Snake {
//这里需要将下面的三个方法声明为友元函数. 为了节省空间, 我们将其省略
private:
struct Body {
Grid pos;
};
vector<Body> bodies; //下标0为头部, len-1为尾部
};
template<>
string Serialize(Grid grid) {return "{\"x\": " + Serialize(x) + "," + "\"y\": " + Serialize(y) + "}";}
template<>
string Serialize(Snake::Body body) {return Serialize(body.pos);}
template<>
string Serialize(Snake snake) {return "{\"bodies\":" + Serialize(snake.bodies) + "}";}
string SerializeSaveData() {
string res = "{";
res += "\"snake\":" + Serialize(GetSnake()) + ",";
res += "\"level\":" + Serialize(GetLevel()) + ",";
res += "\"life\":" + Serialize(GetLife()) + ",";
res += "\"score\":" + Serialize(GetScore());
res += "}";
return res;
}
其对应的序列化表示如下(我们进行了格式化,正常输出没有换行和空格):
{
"snake": {
"bodies": [
{"pos":{"x":0, "y":1}},
{"pos":{"x":0, "y":0}},
]},
"level": { //我们不再详细说明
},
"score": 80,
"life": 2
}
而在Unserialize时,我们同样执行上面的相反步骤即可:
template<typename T>
void Unserialize(string instr, T& val) {}
template<>
void Unserialize(string instr, string& val) {val = instr;}
template<>
void Unserailize(string instr, int& val) {val = atoi(instr.c_str());}
template<>
void Unserialize(string instr, const char*& val) {/*逻辑有些复杂, 我们不列具体实现*/}
template<typename ELEM>
void Unserailize(string instr, vector<ELEM>& vec) {
//去除开头结尾的[和]
//split: string[] split(const string& instr, int start, int end, string split)
string[] eleStrs = split(instr, 1, instr.length()-1, ",");
for(auto eleStr : eleStrs) {
ELEM ele;
Unserialize(eleStr, ele);
vec.push_back(ele);
}
}
template<>
void Unserailize(string instr, Grid& grid) {
string[] eleStrs = split(instr, 1, instr.length()-1, ",");
Unserailize(eleStrs[0], grid.x);
Unserialize(eleStrs[1], grid.y);
}
template<>
void Unserialize(string instr, Snake::Body& body) {
Unserialize(instr, body.pos);
}
template<>
void Unserailize(string instr, Snake& snake) {
//我们在这里简化一下, 因为我们只有bodies
//正常我们需要将内部分为key-value pairs, 而value则可能是动态类型
string bodyStrs = GetBodyStrs(instr);
Unserialize(bodyStrs, snake.bodies);
}
void UnserializeSaveData(string instr) {
//这里需要对instr进行parse, 我们最终将其还原为基本元素, 然后反向解析即可
//注意我们上面给每个属性的序列化表示都添加了各自的key, 且我们已经将类型字段集合视为了字典
//因此经过一种解析(尽管较为复杂),我们可以将instr变为字典
int level, life, score;
unordered_map<string, string> props = decode(instr);
Unserialize(props["level"], level);
Unserialize(props["life"], life);
Unserialize(props["score"], score);
SetLevel(level);
SetLife(life);
SetScore(score);
Snake snake;
Unserialize(props["snake"], snake);
SetSnake(snake);
}
如此,在定义过统一的表示方式后,在业务的某个类型需要序列化时,只需要:
• 定义类型的Serialize和Unserialize方法
• 修改统一的SerializeSaveData/UnserializeSaveData中的实现: 记录需要序列化的对象
之后,我们便不再需要为每个字段定义重复的序列化方法,只需要声明每个类型需要序列化的字段即可,在代码量增加时,这无疑可以大大减少我们的工作量。
需要特意指出的一点是,c++中有大量的指针/引用,我们对这些成员可解引用后正常序列化(也可以使用类指针的表示方法,将指针指向成员序列化到独立段,而持有指针的对象序列化时记录对应段的地址),但需要仔细处理可能出现的循环引用问题。
//如下面的两个类相互持有对方的引用,可能导致循环嵌套问题
class Student {
Class* _class;
};
class Class {
int grade;
vector<Student*> students;
};
代码的生成
在类型较多时,我们为每个类型实现各自的序列化/反序列化方法的形式依然显得过重,而且我们有大量的Serialize/Unserialize方法,手动生成对应逻辑无疑是痛苦的。
针对内置类型的序列化/反序列化逻辑,我们可以将其转移到统一框架中,因其在移植到其它项目的过程中几乎可以确定是必然需要的。
而如果觉得方法内部的方法调用过多,且方法调用形式不够直观,则我们可以为我们的序列化内容创建类型,并利用拷贝构造/赋值函数与隐式构造函数的联立来将其转为字典形式的kv插入形式。
//对应我们的序列化内容
class Archive {
public:
string output; //序列化内容, 实际进行序列化时, 我们仅存储该字段
unordered_map<string, Archive> properties; //过程性内容
//为每个基本类型声明到Archive的隐式构造函数
Archive(int ival) {output = Serialize(ival);}
};
//此后, Grid的序列化可变为如下形式:
template<>
Archive Serialize(Grid grid) {
Archive arc;
arc["x"] = grid.x;
arc["y"] = grid.y;
return arc;
}
• 为了支持重载,我们不再直接使用string,而是创建了专门的档案类,当然,我们也可以将类名叫做Json
• 如果对这种方式感兴趣,可自行阅读nlohmann::json的实现
但上面的实现中,无论使用什么方式,我们都逃不了为每个类型显式声明其需要序列化的所有字段,以及为类型实现序列化、反序列化方法的过程,这原因,如我们关于反射的文章中所说,还是由于我们在运行时并不知道类型的信息,也就无从知晓一个类型都有什么字段。
如果我们真的不想实现序列化/反序列化逻辑,且反射存在的情况下,则可以实现一个通用方法,其直接读取一个类型的所有字段,然后对所有成员执行序列化/反序列化,实际效果和上面是相似的:
template<T>
Archive Serialize(T prop) {
Archive arc;
for(auto fieldinfo : GetFields(T)) { //这里, 我们跳过对可能出现的循环引用的检查处理
arc["fieldinfo.name"] = GetFieldValue(prop, fieldinfo.name);
}
return arc;
}
而性能更好的方式自然还是生成代码,将每个类型的序列化/反序列化方法实际生成出来,以解决手工编写序列化逻辑的工作量和反射的运行时开销(但相比于反射,也导致了代码段增加):在编译前,利用类型信息,自动生成序列化/反序列化的代码,将其和我们的业务逻辑一同编译。
• 可参考protobuf的实现
在目前的阶段,我们先不考虑安全和性能问题。
版本与安全
至今为止,我们的讨论中还有一些隐含的假定:
• 类型是一成不变的
• 所有成员都是可以序列化/反序列化的
• 所有成员都是需要序列化/反序列化的
• 序列化内容一定要表示成字符串形式(尽管我们封装了档案类,但内部依然为string)
• 我们明文表示的序列化内容不会被修改或滥用,其一定是可信、完整的
后面几个假定,我们等下一部分再谈,现在,我们先来看一下类型一成不变,和所有成员都可以序列化的假设。事实上,我们可以很容易设想出不满足这两个假定的情形:
• 假设我们在版本更新后,引入了不同种类的蛇,则我们必然要在Snake中增加Type字段,而这一字段此前没有
• 假定我们的游戏有付费解锁内容(如皮肤),或者有账号系统,玩家解锁内容和密码必然不能序列化,或者至少不能以明文形式进行序列化
即使在网络传输中,我们也可能遇到版本不一致的问题:客户端依然为较老的版本,而服务器则进行了热更;此外,我们也可能会支持玩家以较老的客户端版本进行游戏。
针对第一个问题,我们可以增加版本号,并在反序列化时读取一下版本,在必要时进行升级。逻辑如下:
//save现在在output中增加了一个版本号字段
//反序列化存档
void UnserializeSaveData(Archive save) {
DoCompatibilityProcess(save); //根据save的版本号,判断是否需要升级,以及调用实际的升级兼容逻辑
vector<const char*> states = split(save, SAVE_SPLITTER)
//重复逻辑,不再列出
}
void DoCompatibilityProcess(Archive& save) {
auto version = save.version;
if (version < NEWEST_VERSION) {
auto ToCurVersionHandles = CompatibilityHandles[NEWEST_VERSION];
auto versionHandle = ToCurVersionHandles[version];
if (!versionHandle) LogError("存档过久,无法升级,请重新开启存档!");
versionHandle(save); //执行从save.version到最新version的更新
}
}
在了解机制后,重点则是策略的选择:版本记录的粒度,升级策略置于何处,其又在何时执行。在我们上面的逻辑中,假定了以SaveData作为版本粒度,即所有需要存档的内容统一作为一个模块进行版本管理;我们也假定了升级策略存放在一个多级字典中,在反序列化时执行。
但我们也应该看到,反序列化存档时传入的参数和反序列化一个实例、实例集合时传入的参数类型,在我们这里并没有什么不同:所有的序列化对象都是Archive。因此,我们也可以自由选择以实例或者业务自己打包在一起的类型集合作为版本控制单位。具体以什么为单位、在什么时机升级,更多的时候需要结合具体业务来确定。
而针对第二点,我们可以选择将不可以序列化的内容统一放在一个不会被序列化执行的实例中:在我们需要手动实现序列化的方案中,这是很容易实现的:我们只需要不定义其序列化逻辑,或在持有对应成员的所有对象的序列化方法中不添加对应成员序列化即可。
而在我们引入了反射后,想要不被序列化稍微有些复杂:这是因为我们目前的实现默认将类型的所有成员序列化。针对这种情况,一种方案是为我们的序列化内容进行加密保护,而另一种方案则是允许我们标注不需要序列化的部分,或自定义部分类型、变量的序列化逻辑。而这两种方案是可以并行的:我们可以将保密等级不高的内容全部序列化,然后在序列化时进行一次加密;或者我们也可以将部分不得不序列化的必须保密的内容先加密后再将其序列化。
而如果只是为了防止用户/环境对文件的巧合修改,我们也可以使用hash/crc来对序列化文件进行验证,其只需要增加一些字节来存储额外的散列值。
表示与性能
在版本与安全也简单涉猎后,我们还有最后的三个假设,而这与功能性无关,却影响了用户的体验,和我们的逻辑的实际性能表现:一个类型中有大量的由其他成员计算得出的中间变量,也有许多类型的状态甚至类型实例都是根据其他类型的状态在运行时生成的,这些内容的序列化是时间和空间上的双重浪费。
• 同样的,这又是一个反射引入后才需要解决的问题:手动序列化的版本中,我们直接在实现序列化逻辑不加入对应变量的序列化逻辑即可
针对上面的问题,我们的解决方案和在安全中类似:将反射的优先级后撤一些,并允许我们可以控制需要序列化哪些成员,甚至自定义部分类型的序列化逻辑。
template<T>
Archive Serialize(T prop) {
Archive arc;
//首先判断是否有自定义的Serialize方法
if (ContainMethod(T, "Serialize")
{
auto func = GetMethod(T, "Serialize");
return CallMethod(func, prop); //prop对应到c++编译器对成员函数默认添加的this
}
//否则, 我们检查fieldinfo是否有序列化标志,只有在有该标志的时候才对其序列化
for(auto fieldinfo : GetFields(T)) {
if (!HasFlag(fieldinfo, SerializeField)) continue;
arc["fieldinfo.name"] = GetFieldValue(prop, fieldinfo.name);
}
return arc;
}
而以字符串表示的假设,则是优劣参半的:
• 字符串明文表示可读性更高,因此时更容易截取、修改
• 序列化时需要创建大量字符串
• 许多类型使用string表示是对空间的浪费
• 有许多重复的字段名/变量值,我们目前的表示只是简单在每次出现时完整记录一下
- 这甚至还可能有循环嵌套的问题
• 反序列化逻辑较为复杂
• 我们没有记录类型
但以字符串明文表示也有优点:
• 对人友好,便于调试
• 方便进行版本控制/对比
• 可直接利用语言的长度控制逻辑
• 我们也不需要处理机器相关问题,如字节序处理
为了更好的理解我们上一段的内容,我们再次回顾我们的序列化字符串结构:
• 当我们展示出来的时候,相信读者已经能看出,这对阅读来说相当友好
{
"score": 80,
"life": 2,
"snake": {
"bodies": [
{"pos":{"x":0, "y":1}},
{"pos":{"x":0, "y":0}},
]
}
}
压缩策略的内容已经超越了本文,但我们可以简单介绍一些思路:
• 首先,我们可以很简单的进行一步压缩:bodies中的pos,x,y字段是重复的,我们完全可以不在每次出现时都展示;即使需要重复展示,我们也可以使用字段在类型中的字段id(可能只是一个char),而不需要使用string。
• 要进行进一步压缩的话,如果我们确定关卡长宽不超过65535,则我们完全可以使用16位进行表示,而不是使用默认的int。
• 如果要继续压缩:如果我们能够精心设计,那我们可以连字段名的字符串都省掉,而是将所有出现的字符统一记载,然后在出现原本字符的情况下只是使用偏移来记录一个名称,而偏移我们也不需要使用完整的int:一个序列化数据中不太可能出现上亿个字符串。
• 整个序列化文件我们可以不再使用string表示,而是使用字节流,此时我们可能可以直接复制内存,而不是先读取变量,然后进行特定序列化处理。
• 此外,我们的表示大量使用了嵌套结构,但在保存时,如果我们可以将嵌套结构拍平,则我们的运行时反序列化速度也可以大幅增加(拍平还可以减少循环引用的问题)。
• 借鉴我们从逐字段处理到逐类型处理的思路,我们也可以将序列化的数据分成几种简单的类型,然后在记录数据时先使用几个比特位记录其类型;而大部分类型的长度我们已经提前约定,对那些长度不定的类型,我们则可以使用额外的比特位记录其长度;即将数据改为TLV表示。
- TLV:Type, Length, Value
上面的部分方法,需要我们自定义在内存/文件中的表示方式,而不再使用c++默认提供的字符串:C风格字符串使用'\0'表示结尾的方式来记录一个字段名/值的结束,而C++字符串根据编译器实现往往也如此;
而在需要自定义表示的时候,我们也需要对应的表示字段结束的标志位,而既然我们已经决定自定义数据,那么精心选择结束标志,或者在记录字段时提前声明字段的长度即可。
一种(极简单的)可能序列化布局如下:
//仅仅用于演示,不一定有这个struct
struct FieldInfo {
bit[4] type; //不需要太多类型. 使用4位存储. 可能嵌套Archive
bit[28] length; //长度. 有些类型是不需要长度的, 此时可省略
byte[length] value; //实际内容. 字节流
#if WITH_NAME
uint16_t nameIndex; //名称的字段
#endif
};
//只是为了演示, 实际上并不一定有这个struct
struct Archive {
bit[1] hasHeader; //Header理应只存在一个. 若没有, 则后面直接为fieldCount
/* Header, 仅在hasHeader位为1时才存在 */
uint16_t version; //默认从0开始
uint16_t headerSize; //HeaderSize+1即为Content起始处的偏移
int32_t mask; //标志位. 如中间有一位表示序列化档案的字节序
//其它可能的文件头
/* Header结束 */
int32_t namesOffset; //字符串段起始偏移
uint16_t namesCount; //字符串数量
int32_t fieldOffset; //字段起始偏移
uint16_t fieldCount; //字段数量
/* Content */
const char*[] names; //字符串形式存储的常量, 可能被字节流引用
FieldInfo[] fields; //字节流(bytes)形式存储的字段
};
综上,我们便大概说明了序列化和其实现的思路,在这里,我们做一个总结:
• 序列化,指将内存中的状态转换为约定表示形式的过程
• 序列化结果可持久化,也可用于网络同步,或RPC/IPC中的参数传递
• 序列化的基本思路为逐类型声明类型的序列化逻辑,在拥有反射能力时也可使用反射来提供通用的序列化框架
• 逐类型序列化需要我们声明每个类型的Serialize/Unserialize方法;反射则允许自动实现序列化/反序列化代码
• 序列化还需要关注版本、安全、表示、性能问题
UE中的序列化
在经过上面的实现思路说明后,接下来我们可以看一下UE中的序列化,来对我们的抽象机制进行更深入的展开。
• UE不同版本中的序列化逻辑均有所不同;我们使用的UE版本为5.4
• 限于篇幅,本文仅分析了序列化编辑时对象并持久化到本地的流程
• 提醒下,如果只是相对序列化的实现有粗略的了解,那上面的部分已经足够:我们剩下的内容,有大半都是UE中的源代码,且较为深入了UE中序列化的实现细节;而序列化的核心思想,和我们上面并没有多少区别:遍历类型所有需要序列化的属性并依次处理
• 再次提醒下,下面有大量源码!
由于我们的实现中,每个类型都有自己的序列化方法,因此我们可以直接前往UE的基础类型UObject,并(果不其然)在其中发现Serialize方法:
//Obj.cpp
void UObject::Serialize(FArchive& Ar) { //EnterRecord表示开始记录
UObject::Serialize(FStructuredArchiveFromArchive(Ar).GetSlot().EnterRecord());
}
//使用FArchive来处理属性的序列化, 可被覆写
virtual void UObject::Serialize(FStructuredArchiveRecord Record) {
FArchive& UnderlyingArchive = Record.GetUnderlyingArchive();
//对象的类型, 对象的Outer(Outmost为FPackge, 成员则为其所归属的实例),对象的加载名称
UClass *ObjClass = GetClass();
UObject* LoadOuter = GetOuter();
FName LoadName = GetFName();
/* 加载类型对象的逻辑. 若类型有默认值, 则需要加载类型对象来获取默认值(Defaults) */
// 在有必要的情况下, 先序列化类型的LoadName, Outer, ObjClass. 我们在这里移除了部分条件判断
Record << SA_VALUE(TEXT("LoadName"), LoadName);
if (!UnderlyingArchive.IsIgnoringOuterRef()) Record << SA_VALUE(TEXT("LoadOuter"), LoadOuter);
if (!UnderlyingArchive.IsIgnoringClassRef()) Record << SA_VALUE(TEXT("ObjClass"), ObjClass);
/* 一些重命名/历史记录的的支持逻辑 */
// 类型实例真正的序列化逻辑. 在UE中有两种序列化方案: UPS与TPS
if (ObjClass != UClass::StaticClass())
{
//若使用UPS或需要在TPS中记录事务,则执行下面的分支
if (UnderlyingArchive.UseUnversionedPropertySerialization() || UnderlyingArchive.IsTransacting())
FOverridableManager::Get().SerializeOverriddenProperties(*this, Record);
//TPS的字段序列化
SerializeScriptProperties(Record.EnterField(TEXT("Properties")));
}
// 若存在GUID, 则将其序列化
FLazyObjectPtr::PossiblySerializeObjectGuid(this, Record);
/* 不需要保存的/编辑时历史记录相关的逻辑 */
}
对上面的方法简单总结,可以认为和我们在核心概念部分总结的逐类型处理部分的逻辑差不多:遍历一个类型的所有需要序列化的属性,然后依次对其进行序列化。
接下来依次对上面的方法进行展开。
操作符重载
首先是LoadName、LoadOuter和ObjClass侧的operator<<,其被UE覆写,和我们在抽象模型中提到的operator=功能类似,负责对<<右侧的对象进行序列化并保存到左侧的Record中:
//SA_VALUE即构建TNamedValue, 其有两个字段:
//Name: 内部有const char*成员,也可能为空结构体; 若有const char*, 则对应kvpair; 否则对应数组
//Value: T&, value字段
#define SA_VALUE(Name, Value) UE::StructuredArchive::Private::MakeNamedValue(FArchiveFieldName(Name), Value)
template<typename T>
FStructuredArchiveRecord& operator<<(UE::StructuredArchive::Private::TNamedValue<T>Item)
{
//递归调用operator <<
//EnterField返回了一个新的可用于序列化的slot, 展开见下一方法段
EnterField(Item.Name) << Item.Value;
return *this;
}
//StructuredArchiveSlot.cpp
// FStructuredArchiveSlot包含了Archive中的一个值, 或array/map中的一个字段. Slot中不存储该字段的名称和名字, 只有值的序列化表示
FStructuredArchiveSlot FStructuredArchiveRecord::EnterField(FArchiveFieldName Name)
{
//必要的前置逻辑, Archive记录要记录的内容
StructuredArchive.SetScope(*this);
StructuredArchive.CurrentSlotElementId = StructuredArchive.ElementIdGenerator.Generate();
/* 一些针对Archive中重复Key的检查逻辑 */
//在Archive的Formatter中记录名称, 在JsonFormatter中,其实现为
/*
void FJsonArchiveOutputFormatter::EnterField(FArchiveFieldName Name)
{
WriteOptionalComma(); //根据上面SetScope的逻辑判断是否需要添加 ,
WriteOptionalNewline(); //根据上面SetScope的逻辑判断是否需要添加 \n
WriteFieldName(Name.Name); //写入 {Name}:, 即json的key
}
*/
StructuredArchive.Formatter.EnterField(Name);
//返回一个可容纳值的Slot, 然后继续对slot使用operator<<
return FStructuredArchiveSlot(FStructuredArchiveSlot::EPrivateToken{}, StructuredArchive, Depth, StructuredArchive.CurrentSlotElementId);
}
其中, FStructuredArchiveSlot的继承关系如下图右侧,可见到其记录了id与depth(用于支持嵌套)
• Token为空
• EElementType有如下值: Root, Record, Array, Stream, Map, AttributedValue
而SetScope的实现则为将Archvie中depth超过新Slot的统一出栈:
• 想想我们上面的Snake的序列化,Snake-Bodies(数组)-pos为嵌套关系,而依次离开三层时,我们使用了},],}来标识我们离开了当前区域(Scope)
- 假定Bodies长度为2, 则在离开Bodies[1].pos.y时,我们需要:
1.y已经结束, 我们退出了一个字段(AttributedValue),然后将y弹出(LeaveAttributedValue)
- 同时,其为pos的字段,因此我们可能需要添加,(LeaveField)
3.接下来我们回到了pos, 而pos也没有新的字段,因此我们需要添加一个}, 然后将pos弹出(LeaveRecord)
- 同时,其为Bodies的元素,因此我们可能需要添加,(LeaveArrayElement)
5.然后我们回到了Bodies,而Bodies没有新元素,因此我们需要添加一个], 然后将Bodies弹出(LeaveArray)
- 同时,其为Snake的字段,因此我们可能需要添加,(LeaveField)
7.之后我们回到了Snake,而Snake没有新字段,因此我们需要添加一个},然后将Snake弹出
- 同时,其为root的一条,因此我们可能需要添加,(LeaveRecord)
• UE中的逻辑与上面类似(尽管其保存默认使用Binary),UE中要求元素必须线性序列化,同时使用深度决定是否需要退出
//StructuredArchive.cpp
void FStructuredArchive::SetScope(UE::StructuredArchive::Private::FSlotPosition Slot)
{
/*检查当前Slot有效的逻辑*/
//若有序列化内容的描述数据, 则我们可以根据序列化内容的类型执行对应处理
if (bRequiresStructuralMetadata)
{
for (int32 CurrentDepth = CurrentScope.Num() - 1; CurrentDepth > Slot.Depth; CurrentDepth--)
{
/*
*将当前元素出栈. 注意我们移除了检查逻辑
*执行逻辑: 先让Formatter添加离开标识(以json为例, 视情况添加]或})
*然后让当前Scope退出
*最后移除Slot
*/
const FElement& Element = CurrentScope[CurrentDepth];
switch (Element.Type)
{
case UE::StructuredArchive::Private::EElementType::Record:
Formatter.LeaveRecord(); //对应到JsonOutput, 添加}
break;
case UE::StructuredArchive::Private::EElementType::Array:
Formatter.LeaveArray(); //对应到JsonOutput, 添加]
break;
case UE::StructuredArchive::Private::EElementType::Stream:
Formatter.LeaveStream(); //对应到JsonOutput, 添加]
break;
case UE::StructuredArchive::Private::EElementType::Map:
Formatter.LeaveMap(); //对应到JsonOutput, 添加}
break;
case UE::StructuredArchive::Private::EElementType::AttributedValue:
Formatter.LeaveAttributedValue(); //对应到JsonOutput, 视情况决定是否添加}
break;
}
//CurrentScope: TArray<FElement>, 记录当前正在记录的内容及其嵌套关系
CurrentScope.RemoveAt(CurrentDepth, 1, EAllowShrinking::No);
/*根据目前CurrentScope的最后一个(即Remove前的倒数第二个)元素类型,执行对应逻辑
* Record: LeaveField
* Array: LeaveArrayElement
* Map: LeaveMapElement
* Stream: LeaveStreamElement
* AttributedValue: LeaveAttribute
*/
LeaveSlot();
}
}
else
{
// Remove all the top elements from the stack
CurrentScope.RemoveAt(Slot.Depth + 1, CurrentScope.Num() - (Slot.Depth + 1));
}
}
而返回的FStructuredArchiveSlot可以认为是FStructuredArchiveRecord的兄弟类(见上方的UML图),其区别在于Record还额外记录了字段的名称,这刚好对应了原本Record写入时根据Item.Name创建Slot,然后再在Slot中写入值的逻辑。
template<typename T>
FStructuredArchiveRecord& operator<<(UE::StructuredArchive::Private::TNamedValue<T>Item)
{
//递归调用operator <<
//EnterField返回了一个新的可用于序列化的slot, 展开见下一方法段
EnterField(Item.Name) << Item.Value;
return *this;
}
//上面的operator<<展开为如下:
template<typename T>
void FStructuredArchiveSlot::operator<< (T& Value)
{
//进入Slot, 若Archive已有其它元素, 则将深度超过D+1的元素弹出(假设Item.Name对应的Slot深度为D)
StructuredArchive.EnterSlot(*this);
StructuredArchive.Formatter.Serialize(Value);
StructuredArchive.LeaveSlot();
}
//JsonOutputFormatter.Serialize展开为如下:
void FJsonArchiveOutputFormatter::Serialize(UObject*& Value)
{
if (Value != nullptr && IsObjectAllowed(Value))
{
//FPackageIndex中只有int32类型的Index字段, 即在Json序列化时,并不是直接展开, 而是仅记录了对象地址
//直接展开既浪费空间,也有循环嵌套的问题
FPackageIndex ObjectIndex = ObjectIndicesMap->FindChecked(Value);
SerializeStringInternal(LexToString(ObjectIndex));
}
else
{
WriteValue(TEXT("null"));
}
}
//BinaryFormatter.Serialize则直接转发给了FArchive:
void FBinaryArchiveFormatter::Serialize(UObject*& Value)
{
Inner << Value; //Inner: FArchive&
}
UPS和TPS
对象真正的序列化逻辑如上面所述,为下面几行(望文生义,这些即遍历所有属性并依次序列化的实际处理方法):
// 类型实例真正的序列化逻辑. 在UE中有两种序列化方案: UPS与TPS
if (ObjClass != UClass::StaticClass())
{
//若使用UPS或需要在TPS中记录事务,则执行下面的分支
if (UnderlyingArchive.UseUnversionedPropertySerialization() || UnderlyingArchive.IsTransacting())
FOverridableManager::Get().SerializeOverriddenProperties(*this, Record);
//TPS的字段序列化, 所有字段值记录在Properties中
SerializeScriptProperties(Record.EnterField(TEXT("Properties")));
}
其中SeializeScriptProperties如下(我们省略了部分逻辑):
//Obj.cpp
void UObject::SerializeScriptProperties( FStructuredArchive::FSlot Slot ) const
{
FArchive& UnderlyingArchive = Slot.GetUnderlyingArchive();
UnderlyingArchive.MarkScriptSerializationStart(this);
if( HasAnyFlags(RF_ClassDefaultObject)) UnderlyingArchive.StartSerializingDefaults();
UClass *ObjClass = GetClass();
//根据Archive是否不输出为二进制文件而进入不同分支
if(UnderlyingArchive.IsTextFormat() || ((UnderlyingArchive.IsLoading() || UnderlyingArchive.IsSaving()) && !UnderlyingArchive.WantBinaryPropertySerialization()))
{ //输出为文本形式,或需要输出/输入且不希望为二进制形式
//为了性能考量, 记录一个DiffClass,DiffObject,然后对实例仅需记录DiffClass/DiffObject不同的部分
UObject* DiffObject = UnderlyingArchive.GetArchetypeFromLoader(this);
if (!DiffObject) DiffObject = GetArchetype();
//实例的DiffClass为自己的类型对象,类型的DiffClass则为自己的基类
UStruct* DiffClass = HasAnyFlags(RF_ClassDefaultObject) ? ObjClass->GetSuperClass() : ObjClass;
//Impersonator的目的与上面类似. 有些类型间没有继承关系, 但结构体相似度很高
const UObject* ThisObject = this;
if (const UObject* Impersonator = UE::Private::GetDataImpersonator(ThisObject))
{
ensureAlwaysMsgf(!HasAnyFlags(RF_ClassDefaultObject), TEXT("CDO '%s' shoudn't be impersonated"), *ThisObject->GetPathName());
ThisObject = Impersonator; //在这里, ThisObject从this变成了Impersonator
ObjClass = ThisObject->GetClass(); //UE5.4中还没有对使用impersonator时CDO的支持
DiffObject = ObjClass->GetDefaultObject(false);
}
/* 根据是否存在编辑时状态而进入不同分支, 编辑时需要序列化一些Debug数据 */
ObjClass->SerializeTaggedProperties(Slot, (uint8*)ThisObject, DiffClass, (uint8*)DiffObject, bBreakSerializationRecursion ? ThisObject : nullptr);
}
else if (UnderlyingArchive.GetPortFlags() != 0 && !UnderlyingArchive.ArUseCustomPropertyList )
{ //否则, 检查是否可用默认的二进制序列化方法(类型的所有属性都要序列化, 且存在类型)
UObject* DiffObject = UnderlyingArchive.GetArchetypeFromLoader(this);
if (!DiffObject) DiffObject = GetArchetype();
ObjClass->SerializeBinEx(Slot, const_cast<UObject*>(this), DiffObject, DiffObject ? DiffObject->GetClass() : nullptr);
}
else
{ //有自定义的属性列表时进入该方法
ObjClass->SerializeBin(Slot, const_cast(this));
}
if (HasAnyFlags(RF_ClassDefaultObject)) UnderlyingArchive.StopSerializingDefaults();
UnderlyingArchive.MarkScriptSerializationEnd(this);
}
上面对应文本、二进制的三个序列化方法中,Bin相关的较为简单:
SerializeBinEx在使用默认的序列化逻辑, 且已知对象类型时可用,其核心逻辑为:
//Class.cpp
void UStruct::SerializeBinEx( FStructuredArchive::FSlot Slot, void* Data, void const* DefaultData, UStruct* DefaultStruct ) const
{
if ( !DefaultData || !DefaultStruct ) //若DefaultData与DefaultStruct不同时存在, 直接使用SerializeBin
{
SerializeBin(Slot, Data);
return;
}
FUObjectSerializeContext* SerializeContext = FUObjectThreadContext::Get().GetSerializeContext();
/* 若需要记录序列化属性的路径, 则将其记录 */
//实际的序列化. 遍历类型字段, 逐一序列化. 实现见下方逻辑
for( TFieldIterator<FProperty> It(this); It; ++It )
It->SerializeNonMatchingBinProperty(Slot, Data, DefaultData, DefaultStruct);
}
//UnrealType.h
//仅序列化与Default中对应字段值不同的部分, 以节省开销
void FProperty::SerializeNonMatchingBinProperty( FStructuredArchive::FSlot Slot, void* Data, void const* DefaultData, UStruct* DefaultStruct)
{
FArchive& UnderlyingArchive = Slot.GetUnderlyingArchive();
FStructuredArchive::FStream Stream = Slot.EnterStream();
if( ShouldSerializeValue(UnderlyingArchive) )
{
for (int32 Idx = 0; Idx < ArrayDim; Idx++)
{
void* Target = ContainerPtrToValuePtr<void>(Data, Idx);
void const* Default = ContainerPtrToValuePtrForDefaults<void>(DefaultStruct, DefaultData, Idx);
if ( !Identical(Target, Default, UnderlyingArchive.GetPortFlags()) )
{
FSerializedPropertyScope SerializedProperty(UnderlyingArchive, this);
//SerializeItem根据Property类型不同而进行不同的序列化/反序列化. 且我们仅在Target与Default不同时才实际序列化
SerializeItem(Stream.EnterElement(), Target, Default);
}
}
}
}
SerializeBin则不需要对象具体类型, 而是直接遍历属性进行序列化:
void UStruct::SerializeBin( FStructuredArchive::FSlot Slot, void* Data ) const
{
FUObjectSerializeContext* SerializeContext = FUObjectThreadContext::Get().GetSerializeContext();
/* 若需要记录序列化属性的路径, 则将其记录 */
FArchive& UnderlyingArchive = Slot.GetUnderlyingArchive();
FStructuredArchive::FStream PropertyStream = Slot.EnterStream();
if (UnderlyingArchive.IsObjectReferenceCollector())
{//若仅需要记录引用,而不实际序列化引用对象, 才进入该分支
/* 一些加载引用地址的逻辑. 为了性能, 进行了一些平台特化/预取相关处理. 有些复杂, 直接移除 */
}
else if(UnderlyingArchive.ArUseCustomPropertyList)
{//若使用自定义的属性列表,则进入该分支. 有些属性是不需要序列化的,因此不使用PropertyLink
const FCustomPropertyListNode* CustomPropertyList = UnderlyingArchive.ArCustomPropertyList;
for (auto PropertyNode = CustomPropertyList; PropertyNode; PropertyNode = PropertyNode->PropertyListNext)
{
FProperty* Property = PropertyNode->Property;
if( Property )
{
UnderlyingArchive.ArCustomPropertyList = PropertyNode->SubPropertyList;
Property->SerializeBinProperty(PropertyStream.EnterElement(), Data, PropertyNode->ArrayIndex);
UnderlyingArchive.ArCustomPropertyList = CustomPropertyList;
}
}
}
else {//默认情况下进入该分支. PropertyLink是个链表,记录了对象的所有属性
for (FProperty* Property = PropertyLink; Property != NULL; Property = Property->PropertyLinkNext)
Property->SerializeBinProperty(PropertyStream.EnterElement(), Data);
}
}
//UnrealType.h
//SerializeBinProperty的实现如下. SerializeItem实现根据Property类型不同而不同, 如int读取4bytes, double则读取8bytes
//数组还可能一次序列化数组中的所有元素
void FProperty::SerializeBinProperty( FStructuredArchive::FSlot Slot, void* Data, int32 ArrayIdx = -1)
{
FStructuredArchive::FStream Stream = Slot.EnterStream();
if( ShouldSerializeValue(Slot.GetUnderlyingArchive()) )
{
const int32 LoopMin = ArrayIdx < 0 ? 0 : ArrayIdx;
const int32 LoopMax = ArrayIdx < 0 ? ArrayDim : ArrayIdx + 1;
for (int32 Idx = LoopMin; Idx < LoopMax; Idx++)
{
// Keep setting the property in case something inside of SerializeItem changes it
FSerializedPropertyScope SerializedProperty(Slot.GetUnderlyingArchive(), this);
SerializeItem(Stream.EnterElement(), ContainerPtrToValuePtr<void>(Data, Idx));
}
}
}
//为了帮助理解,我们列一些SerializeItem的实现:
//PropertyBool.cpp
void FBoolProperty::SerializeItem( FStructuredArchive::FSlot Slot, void* Value, void const* Defaults ) const {
check(FieldSize != 0);
uint8* ByteValue = (uint8*)Value + ByteOffset;
uint8 B = (*ByteValue & FieldMask) ? 1 : 0;
Slot << B;
*ByteValue = ((*ByteValue) & ~FieldMask) | (B ? ByteMask : 0);
}
//PropertyStruct.cpp
void FStructProperty::SerializeItem(FStructuredArchive::FSlot Slot, void* Value, void const* Defaults) const {
FScopedPlaceholderPropertyTracker ImportPropertyTracker(this);
Struct->SerializeItem(Slot, Value, Defaults);
}
void UScriptStruct::SerializeItem(FStructuredArchive::FSlot Slot, void* Value, void const* Defaults) {
FArchive& UnderlyingArchive = Slot.GetUnderlyingArchive();
const bool bUseBinarySerialization = UseBinarySerialization(UnderlyingArchive);
const bool bUseNativeSerialization = UseNativeSerialization();
// Preload struct before serialization tracking to not double count time.
if (bUseBinarySerialization || bUseNativeSerialization) UnderlyingArchive.Preload(this);
bool bItemSerialized = false;
if (bUseNativeSerialization) {
UScriptStruct::ICppStructOps* TheCppStructOps = GetCppStructOps();
check(TheCppStructOps); // else should not have STRUCT_SerializeNative
if (TheCppStructOps->HasStructuredSerializer())
bItemSerialized = TheCppStructOps->Serialize(Slot, Value);
else {
#if WITH_TEXT_ARCHIVE_SUPPORT
if (Slot.GetUnderlyingArchive().IsTextFormat()) {
FArchiveUObjectFromStructuredArchive Adapter(Slot);
FArchive& Ar = Adapter.GetArchive();
bItemSerialized = TheCppStructOps->Serialize(Ar, Value);
if (bItemSerialized && !Slot.IsFilled())
// The struct said that serialization succeeded but it didn't actually write anything.
Slot.EnterRecord();
Adapter.Close();
}
else
#endif
{
bItemSerialized = TheCppStructOps->Serialize(Slot.GetUnderlyingArchive(), Value);
if (bItemSerialized && !Slot.IsFilled())
// The struct said that serialization succeeded but it didn't actually write anything.
Slot.EnterRecord();
}
}
}
if (!bItemSerialized) {
if (bUseBinarySerialization) {
if (!UnderlyingArchive.IsPersistent() && UnderlyingArchive.GetPortFlags() != 0 && !ShouldSerializeAtomically(UnderlyingArchive) && !UnderlyingArchive.ArUseCustomPropertyList)
SerializeBinEx(Slot, Value, Defaults, this);
else
SerializeBin(Slot, Value);
}
else
SerializeTaggedProperties(Slot, (uint8*)Value, this, (uint8*)Defaults);
}
if (StructFlags & STRUCT_PostSerializeNative) {
UScriptStruct::ICppStructOps* TheCppStructOps = GetCppStructOps();
check(TheCppStructOps); // else should not have STRUCT_PostSerializeNative
TheCppStructOps->PostSerialize(UnderlyingArchive, Value);
}
}
SerializeTaggedProperties的实现则较为复杂,其由于性能考量, 与BinEx一样,仅序列化与默认值不同的部分,且根据是否使用UPS, 内部再次转发至不同方法。
//class.cpp
//Data类型为uint8*
void UStruct::SerializeTaggedProperties(FStructuredArchive::FSlot Slot, uint8* Data, UStruct* DefaultsStruct, uint8* Defaults, const UObject* BreakRecursionIfFullyLoad) const
{
FUObjectSerializeContext* SerializeContext = FUObjectThreadContext::Get().GetSerializeContext();
const bool bSaveSerializedPropertyPath = IsA<UClass>() && SerializeContext && !SerializeContext->SerializedPropertyPath.IsEmpty();
UE::FPropertyPathName PrevSerializedPropertyPath;
if (bSaveSerializedPropertyPath) {
PrevSerializedPropertyPath = MoveTemp(SerializeContext->SerializedPropertyPath);
SerializeContext->SerializedPropertyPath.Reset();
}
//根据是否使用Unversion, 分别转发至不同方法. UPS在UE4的高版本中引入
if (Slot.GetArchiveState().UseUnversionedPropertySerialization()) //UPS
SerializeUnversionedProperties(this, Slot, Data, DefaultsStruct, Defaults);
else //TPS
SerializeVersionedTaggedProperties(Slot, Data, DefaultsStruct, Defaults, BreakRecursionIfFullyLoad);
if (bSaveSerializedPropertyPath)
SerializeContext->SerializedPropertyPath = MoveTemp(PrevSerializedPropertyPath);
}
可以看到,上面根据是否使用Unversioned,而分别转发到了UPS(UnversionedPropertySerialization)和TPS(VersionedTaggedPropertySerialization)。两者的命名是很直观的:一个不进行版本控制,另一个则为属性添加了Tag。具体的逻辑,我们继续详细展开:
TPS
TPS的主要逻辑如下:
//class.cpp
//TPS逻辑较多(500行), 我们进行了大量删减,仅摘录主要逻辑:
void UStruct::SerializeVersionedTaggedProperties(FStructuredArchive::FSlot Slot, uint8* Data, UStruct* DefaultsStruct, uint8* Defaults, const UObject* BreakRecursionIfFullyLoad) const
{
using namespace UE;
checkf(Data, TEXT("Expecting a non null data ptr"));
FArchive& UnderlyingArchive = Slot.GetUnderlyingArchive();
FUObjectSerializeContext* SerializeContext = FUObjectThreadContext::Get().GetSerializeContext();
/* 对象为UClass时的可能的SerializationControlExtension逻辑, 用于覆写部分字段的数据 */
//判断是否启用字段的override, 是否支持属性的guid(蓝图类), 以及是否使用了重映射
FEnableOverridableSerializationScope OverridableSerializationScope(ControlContext.bEnableOverridableSerialization, ControlContext.OverriddenProperties);
const bool bArePropertyGuidsAvailable = (UnderlyingArchive.UEVer() >= VER_UE4_PROPERTY_GUID_IN_PROPERTY_TAG) && (!FPlatformProperties::RequiresCookedData() || UnderlyingArchive.IsSaveGame()) && ArePropertyGuidsAvailable();
const bool bUseRedirects = (!FPlatformProperties::RequiresCookedData() || UnderlyingArchive.IsSaveGame()) && !UnderlyingArchive.IsLoadingFromCookedPackage();
//接下来的代码分成了两段, 分别对应数据的读与写, 即Unreal中将Serialize和Unserialize合并在了一起
if (UnderlyingArchive.IsLoading()) //Unserialize
{
#if WITH_TEXT_ARCHIVE_SUPPORT
if (UnderlyingArchive.IsTextFormat()) //若支持字符串, 且档案为文本, 则直接反序列化即可
LoadTaggedPropertiesFromText(Slot, Data, DefaultsStruct, Defaults, BreakRecursionIfFullyLoad);
else
#endif // WITH_TEXT_ARCHIVE_SUPPORT
{
//定义一个获取要序列化的对象的所有字段-属性的方法.
//PropertyTag可理解为Tag(字段): Property(对应属性)的字典
auto TryFindPropertyBag = [PropertyBag = (FPropertyBag*)nullptr, bSearched = false, SerializeContext]() mutable -> FPropertyBag*
{
if (bSearched) return PropertyBag;
bSearched = true;
if (SerializeContext && SerializeContext->bSerializeUnknownProperty)
{//若存在未知属性,且对象为UObject,则获取/创建Object的OuterBag(默认为空)
if (UObject* Object = SerializeContext->SerializedObject)
PropertyBag = FPropertyBagRepository::Get().CreateOuterBag(Object);
}
return PropertyBag;
};
FStructuredArchive::FStream PropertiesStream = Slot.EnterStream();
//开始逐属性进行序列化
FProperty* Property = PropertyLink;
bool bAdvanceProperty = false; //是否需要跳过属性
int32 RemainingArrayDim = Property ? Property->ArrayDim : 0; //在对象为数组时,记录剩余数组元素数
while (true)
{ //这里假定了属性反序列化的顺序与序列化的顺序相同, 以节省寻找属性对应位置的开销
FStructuredArchive::FRecord PropertyRecord = PropertiesStream.EnterElement().EnterRecord();
FPropertyTag Tag; //见下方说明
PropertyRecord << SA_VALUE(TEXT("Tag"), Tag); //查找下一个对应Tag的部分,并将其值更新至Tag
if (Tag.Name.IsNone()) break;//未查找到,可以认为已经反序列化完成
if (bAdvanceProperty && --RemainingArrayDim <= 0)
{ //若需要跳过属性,则直接跳到下一个需要序列化的属性
Property = Property->PropertyLinkNext;
while (Property && !Property->ShouldSerializeValue(UnderlyingArchive))
Property = Property->PropertyLinkNext;
RemainingArrayDim = Property ? Property->ArrayDim : 0;
}
bAdvanceProperty = false; //现在必然不需要跳过了
if (bArePropertyGuidsAvailable && Tag.HasPropertyGuid)
{ //若类型对属性支持Guid(蓝图类),则尝试是否可用Guid查找到属性名称.重定向.
FName Result = FindPropertyNameFromGuid(Tag.PropertyGuid);
if (Result != NAME_None && Tag.Name != Result)
Tag.Name = Result;
}
if (Property == nullptr || Property->GetFName() != Tag.Name)
{ /* 若属性与Tag.Name未对应, 或属性为nullptr, 则暴力搜索下一个属性 */
} // 默认向链表的尾部搜索, 因为更可能发生的情况为由于值与默认值相同而跳过了序列化
const int64 StartOfProperty = UnderlyingArchive.Tell();
if (!Property) Property = CustomFindProperty(Tag.Name); //用户可自定义属性查找逻辑
Tag.SetProperty(Property); //填充Property
if (bUseRedirects) //若使用重定向,则使用重定向后的实际类型
if (UE::FPropertyTypeName NewTypeName = ApplyRedirectsToPropertyType(Tag.GetType(), Property); !NewTypeName.IsEmpty())
Tag.SetType(NewTypeName);
/* 属性路径记录相关的逻辑 */
if (Property) //属性依然存在, 则进行实际的反序列化
{ //内部有一层判定, 判断属性是否为编辑时独占/是否不需要序列化. 我们直接略过
FStructuredArchive::FSlot ValueSlot = PropertyRecord.EnterField(TEXT("Value"));
/* 新引入的实验性属性记录逻辑, 略过 */
bool bTryLoadIntoPropertyBag = false;
switch (Property->ConvertFromType(Tag, ValueSlot, Data, DefaultsStruct, Defaults))
{ //根据属性从Tag、Defaults中转换的结果执行对应逻辑. 默认为UseSerializeItem
case EConvertFromTypeResult::Serialized: //已序列化完毕. PropertyOptional在特定情况下返回该值
bAdvanceProperty = !UnderlyingArchive.IsCriticalError();
break;
case EConvertFromTypeResult::Converted: //已经转换完毕. Enum和部分数组返回该值
bAdvanceProperty = true;
bTryLoadIntoPropertyBag = true;
break;
case EConvertFromTypeResult::CannotConvert: //无法反序列化. 实验性的属性记录返回该值
bTryLoadIntoPropertyBag = true;
break;
case EConvertFromTypeResult::UseSerializeItem: //默认为该分支
if (const FName PropID = Property->GetID(); Tag.Type != PropID)
bTryLoadIntoPropertyBag = true;
else
{
uint8* DestAddress = Property->ContainerPtrToValuePtr<uint8>(Data, Tag.ArrayIndex);
uint8* DefaultsFromParent = Property->ContainerPtrToValuePtrForDefaults<uint8>(DefaultsStruct, Defaults, Tag.ArrayIndex);
//实际的序列化逻辑.
Tag.SerializeTaggedProperty(ValueSlot, Property, DestAddress, DefaultsFromParent);
bAdvanceProperty = !UnderlyingArchive.IsCriticalError();
}
break;
/* default. 正常不可能走到 */
}
if (bTryLoadIntoPropertyBag)
{ //Coverted, CannotConvert或使用序列化但Property的ID与Tag.Type不同时, 进入该分支
if (FPropertyBag* PropertyBag = TryFindPropertyBag())
{
Tag.SetProperty(nullptr);
UnderlyingArchive.Seek(StartOfProperty);
FStructuredArchive::FSlot ValueSlotCopy = PropertyRecord.EnterField(TEXT("Value"));
//内部实现中依然调用了FPropertyTag.SerializeTaggedProperty
PropertyBag->LoadPropertyByTag(SerializeContext->SerializedPropertyPath, Tag, ValueSlotCopy, Property->ContainerPtrToValuePtrForDefaults<uint8>(DefaultsStruct, Defaults, Tag.ArrayIndex));
}
}
}
else if (FPropertyBag* PropertyBag = TryFindPropertyBag(); PropertyBag && SerializeContext)
{ //Property不存在,但Tag存在时, 进入该分支
FStructuredArchive::FSlot ValueSlot = PropertyRecord.EnterField(TEXT("Value"));
PropertyBag->LoadPropertyByTag(SerializeContext->SerializedPropertyPath, Tag, ValueSlot);
}
/* Archive前进序列化档案对应的大小 */
}
}
}
else
{ //Serialize进入该分支.
FStructuredArchive::FRecord PropertiesRecord = Slot.EnterRecord();
bool bUseAtomicSerialization = false; //true表示序列化所有与Defaults不同的属性
if ( UScriptStruct* DefaultsScriptStruct = dynamic_cast<UScriptStruct*>(DefaultsStruct);)
bUseAtomicSerialization = DefaultsScriptStruct->ShouldSerializeAtomically(UnderlyingArchive);
//判断是否进行增量序列化
const bool bDoDeltaSerialization = UnderlyingArchive.DoDelta() && !UnderlyingArchive.IsTransacting() && (Defaults || bIsUClass);
//按顺序序列化属性. 我们期望属性序列化与反序列化的顺序相同. 若使用自定义属性列表则读取, 否则使用默认的PropertyLink
const FCustomPropertyListNode* CustomPropertyNode = UnderlyingArchive.ArUseCustomPropertyList ? UnderlyingArchive.ArCustomPropertyList : nullptr;
for (FProperty* Property = UnderlyingArchive.ArUseCustomPropertyList ? (CustomPropertyNode ? CustomPropertyNode->Property : nullptr) : PropertyLink;
Property;
Property = UnderlyingArchive.ArUseCustomPropertyList ? FCustomPropertyListNode::GetNextPropertyAndAdvance(CustomPropertyNode) : Property->PropertyLinkNext)
{
if (Property->ShouldSerializeValue(UnderlyingArchive))
{ //仅序列化需要序列化的属性
const int32 LoopMin = CustomPropertyNode ? CustomPropertyNode->ArrayIndex : 0;
const int32 LoopMax = CustomPropertyNode ? LoopMin + 1 : Property->ArrayDim;
TOptional<FStructuredArchive::FArray>StaticArrayContainer;
/* 若真的为数组, 则还需要在档案为文本格式的情况下记录数组长度 */
for (int32 Idx = LoopMin; Idx < LoopMax; Idx++) //不为数组时, 只会执行一遍
{
uint8* DataPtr = Property->ContainerPtrToValuePtr<uint8>(Data, Idx);
uint8* DefaultValue = Property->ContainerPtrToValuePtrForDefaults<uint8>(DefaultsStruct, Defaults, Idx);
if (StaticArrayContainer.IsSet() || CustomPropertyNode || !bDoDeltaSerialization)
{ //我们略过了部分Overridable相关的判断与逻辑
if (bUseAtomicSerialization) DefaultValue = NULL;
/* 编辑态时, 记录部分调试用数据 */
FPropertyTag Tag(Property, Idx, DataPtr);
if (bArePropertyGuidsAvailable && !UnderlyingArchive.IsCooking())
{ //同样的,在存在guid时记录guid
const FGuid PropertyGuid = FindPropertyGuidFromName(Tag.Name);
Tag.SetPropertyGuid(PropertyGuid);
}
TStringBuilder<256> TagName;
Tag.Name.ToString(TagName);
FStructuredArchive::FSlot PropertySlot = StaticArrayContainer.IsSet() ? StaticArrayContainer->EnterElement() : PropertiesRecord.EnterField(TagName.ToString());
PropertySlot << Tag;
int64 DataOffset = UnderlyingArchive.Tell();
//自定义属性列表相关的逻辑. FPropertyTag的序列化使用了对应的PropertyList
const FCustomPropertyListNode* SavedCustomPropertyList = nullptr;
if (UnderlyingArchive.ArUseCustomPropertyList && CustomPropertyNode)
{
SavedCustomPropertyList = UnderlyingArchive.ArCustomPropertyList;
UnderlyingArchive.ArCustomPropertyList = CustomPropertyNode->SubPropertyList;
}
//实际的序列化
Tag.SerializeTaggedProperty(PropertySlot, Property, DataPtr, DefaultValue);
if (SavedCustomPropertyList)
UnderlyingArchive.ArCustomPropertyList = SavedCustomPropertyList;
//Tag大小与档案位置相关的逻辑
Tag.Size = IntCastChecked<int32>(UnderlyingArchive.Tell() - DataOffset);
if (Tag.Size > 0 && !UnderlyingArchive.IsTextFormat())
{
DataOffset = UnderlyingArchive.Tell();
UnderlyingArchive.Seek(Tag.SizeOffset);
UnderlyingArchive << Tag.Size;
UnderlyingArchive.Seek(DataOffset);
}
}
}
}
}
if (!UnderlyingArchive.IsTextFormat())
{
// Add an empty FName that serves as a null-terminator
FName NoneTerminator;
UnderlyingArchive << NoneTerminator;
}
}
}
在TPS中,序列化使用了FPropertyTag,其声明和对应的序列化方法如下:
//PropertyTag.h
struct FPropertyTag
{ //部分字段在5.4中已经标为废弃. 我们移除了对应的注释, 以增加部分可读性
FProperty* Prop = nullptr;
FName Type; // Type of property
FName Name; // Name of property.
FName StructName; // Struct name if FStructProperty.
FName EnumName; // Enum name if FByteProperty or FEnumProperty
FName InnerType; // Inner type if FArrayProperty, FSetProperty, FMapProperty, or OptionalProperty
FName ValueType; // Value type if UMapPropery
int32 Size = 0; // Property size.
int32 ArrayIndex = INDEX_NONE; // Index if an array; else 0.
int64 SizeOffset = INDEX_NONE; // location in stream of tag size member
FGuid StructGuid;
FGuid PropertyGuid;
uint8 HasPropertyGuid = 0;
uint8 BoolVal = 0;// a boolean property's value (never need to serialize data for bool properties except here)
EPropertyTagSerializeType SerializeType = EPropertyTagSerializeType::Unknown;
EOverriddenPropertyOperation OverrideOperation; // Overridable serialization state reconstruction
void operator<<(FStructuredArchive::FSlot Slot, FPropertyTag& Tag); //该方法中定义Tag
};
void FPropertyTag::SerializeTaggedProperty(FStructuredArchive::FSlot Slot, FProperty* Property, uint8* Value, const uint8* Defaults) const
{
FArchive& UnderlyingArchive = Slot.GetUnderlyingArchive();
const int64 StartOfProperty = UnderlyingArchive.Tell();
/* 针对Bool的特殊处理. Bool的值直接存储在Tag中 */
/* 部分编辑态逻辑 */
//利用了c++的RAII,在构造函数中执行了PushProperty, 在析构函数中调用了PopProperty.
//内部转到了FArchive::PushSerializedProperty与FArchive::PopSerializedProperty
//但是,这怎么保证编译的时候不会被优化掉的?
FSerializedPropertyScope SerializedProperty(UnderlyingArchive, Property);
//同样, 在构造函数中复制了CurrentPropertyTag, 在析构函数中将其还原. 因此该类为thread_local的
FPropertyTagScope CurrentPropertyTagScope(this);
//实际上调用了Property自己的序列化逻辑. 这里的序列化必然也是内部区分Loading和Saving的
Property->SerializeItem(Slot, Value, Defaults);
const int64 EndOfProperty = UnderlyingArchive.Tell();
if (Size && (EndOfProperty - StartOfProperty != Size))
{
UnderlyingArchive.Seek(StartOfProperty + Size);
Property->ClearValue(Value);
}
}
在反序列化时, 存在档案为文本的情况, 其直接转发至了LoadTaggedPropertiesFromText,而内部实际上还是Tag::SerializeTaggedProperty:
void UStruct::LoadTaggedPropertiesFromText(FStructuredArchive::FSlot Slot, uint8* Data, UStruct* DefaultsStruct, uint8* Defaults, const UObject* BreakRecursionIfFullyLoad) const
{
FArchive& UnderlyingArchive = Slot.GetUnderlyingArchive();
const bool bUseRedirects = !FPlatformProperties::RequiresCookedData() || UnderlyingArchive.IsSaveGame();
int32 NumProperties = 0;
FStructuredArchiveMap PropertiesMap = Slot.EnterMap(NumProperties);
//直接遍历属性即可
for (int32 PropertyIndex = 0; PropertyIndex < NumProperties; ++PropertyIndex)
{
FString PropertyNameString;
FStructuredArchiveSlot PropertySlot = PropertiesMap.EnterElement(PropertyNameString);
FName PropertyName = *PropertyNameString;
/* 可能需要根据Property的Guid或redirect还原真正的PropertyName */
//同样的, 尝试使用名称获取属性;若未能寻找到,则使用尝试使用用户自定义的属性查找方法
FProperty* Property = FindPropertyByName(PropertyName);
if (Property == nullptr)
Property = CustomFindProperty(PropertyName);
if (Property && Property->ShouldSerializeValue(UnderlyingArchive))
{ //在Property寻找到,且应当被反序列化时执行
TOptional<FStructuredArchiveArray>SlotArray;
int32 NumItems = Property->ArrayDim;
/* 数组元素实际计算,并定义SlotArray */
for (int32 ItemIndex = 0; ItemIndex < NumItems; ++ItemIndex)
{
TOptional<FStructuredArchiveSlot>ItemSlot;
if (SlotArray.IsSet()) //仅在为数组时执行该分支
ItemSlot.Emplace(SlotArray->EnterElement());
else
ItemSlot.Emplace(PropertySlot);
FPropertyTag Tag;
ItemSlot.GetValue() << Tag;
Tag.SetProperty(Property);
Tag.ArrayIndex = ItemIndex;
Tag.Name = PropertyName;
if (bUseRedirects) //Type可能需要重定向
if (UE::FPropertyTypeName NewTypeName = ApplyRedirectsToPropertyType(Tag.GetType(), Property); !NewTypeName.IsEmpty())
Tag.SetType(NewTypeName);
if (BreakRecursionIfFullyLoad && BreakRecursionIfFullyLoad->HasAllFlags(RF_LoadCompleted))
continue;
switch (Property->ConvertFromType(Tag, ItemSlot.GetValue(), Data, DefaultsStruct, Defaults))
{
case EConvertFromTypeResult::Converted:
case EConvertFromTypeResult::Serialized:
case EConvertFromTypeResult::CannotConvert:
break;
case EConvertFromTypeResult::UseSerializeItem: //仅在该情况下才需要反序列化
if (const FName PropID = Property->GetID(); Tag.Type != PropID)
/* 属性对不上,输出Warn日志 */
else
{ //进行实际的反序列化
uint8* DestAddress = Property->ContainerPtrToValuePtr<uint8>(Data, Tag.ArrayIndex);
uint8* DefaultsFromParent = Property->ContainerPtrToValuePtrForDefaults<uint8>(DefaultsStruct, Defaults, Tag.ArrayIndex);
Tag.SerializeTaggedProperty(ItemSlot.GetValue(), Property, DestAddress, DefaultsFromParent);
}
break;
default: //永远都不可能走到该分支
check(false);
}
}
}
}
}
UPS
UPS的逻辑则为如下:
//UnversionedPropertySerialization.cpp
void SerializeUnversionedProperties(const UStruct* Struct, FStructuredArchive::FSlot Slot, uint8* Data, UStruct* DefaultsStruct, uint8* DefaultsData)
{
FArchive& UnderlyingArchive = Slot.GetUnderlyingArchive();
FStructuredArchive::FRecord StructRecord = Slot.EnterRecord();
if (UnderlyingArchive.IsLoading())
{ //首先加载Header, 若Header存在, 则根据Header和Schema(属性表)反序列化. 0直接读0即可
//FUnversionedHeader记录了属性的下标与其是否为0. 其序列化为16bits的fragments+1bit的0标志位
FUnversionedHeader Header;
Header.Load(StructRecord.EnterStream(TEXT("Header")));
if (Header.HasValues())
{ //类型的属性表,同样跳过仅编辑时生效的属性
FUnversionedSchemaRange Schema(Struct, SkipEditorOnlyFields(UnderlyingArchive));
if (Header.HasNonZeroValues())
{ //分情况, 非0值需要反序列化
FDefaultStruct Defaults(DefaultsData, DefaultsStruct);
FStructuredArchive::FStream ValueStream = StructRecord.EnterStream(TEXT("Values"));
for (FUnversionedHeader::FIterator It(Header, Schema); It; It.Next()) {
if (It.IsNonZero())
{ //第一行仅在编辑态中执行以增加反序列化性能, 其仅仅记录了引用
FSerializedPropertyScope SerializedProperty(UnderlyingArchive, It.GetSerializer().GetProperty());
It.GetSerializer().Serialize(ValueStream.EnterElement(), Data, Defaults);
}
else
It.GetSerializer().LoadZero(Data);
}
}
else //直接全部读0
for (FUnversionedHeader::FIterator It(Header, Schema); It; It.Next()) {
check(!It.IsNonZero());
It.GetSerializer().LoadZero(Data);
}
}
}
else
{ //序列化
FUnversionedPropertyTestRunner TestRunner({Struct, Data, DefaultsStruct, DefaultsData});
//根据schema(属性列表)和当前序列化模式确定需要保存的属性
const bool bDense = !UnderlyingArchive.DoDelta() || UnderlyingArchive.IsTransacting() || (!DefaultsData && !dynamic_cast<const UClass*>(Struct));
FDefaultStruct Defaults(DefaultsData, DefaultsStruct);
//类型的属性表,跳过仅编辑时生效的属性
FUnversionedSchemaRange Schema(Struct, SkipEditorOnlyFields(UnderlyingArchive));
FUnversionedHeaderBuilder Header;
for (FUnversionedPropertySerializer Serializer : Schema)
{
if (Serializer.GetProperty()->ShouldSerializeValue(UnderlyingArchive) &&
(bDense || !Serializer.IsDefault(Data, Defaults, UnderlyingArchive.GetPortFlags())))
//Header记录对应属性,以及对应属性值是否为0
Header.IncludeProperty(Serializer.ShouldSaveAsZero(Data));
else Header.ExcludeProperty(); //不需要记录该属性, 但我们需要记录我们跳过了一个属性
}
Header.Finalize(); //并不是finalizer,只是表示Header已经构造完成.
Header.Save(StructRecord.EnterStream(TEXT("Header"))); //记录header
if (Header.HasNonZeroValues())
{ //根据Header,遍历记录那些非0的值
FStructuredArchive::FStream ValueStream = StructRecord.EnterStream(TEXT("Values"));
for (FUnversionedHeader::FIterator It(Header, Schema); It; It.Next())
{ //FIterator根据Schema获取遍历属性表,根据Header判断对应值是否为Zero
if (It.IsNonZero())
{ //Values中仅序列化非0部分. 内部还是FProperty::SerializeItem
FSerializedPropertyScope SerializedProperty(UnderlyingArchive, It.GetSerializer().GetProperty());
It.GetSerializer().Serialize(ValueStream.EnterElement(), Data, Defaults);
}
}
}
}
}
将两种序列化方法进行对比,可以看到, UPS的序列化开销要比TPS的开销低得多,但更强地假定了成员顺序不变:
• 使用TPS,我们可根据Tag查询到Property,但开销更高(存储了大量状态信息)
• 使用UPS,我们必须假定schema不变(允许在尾部增加新字段)
UPackage
在TPS和UPS最后的对比中,我们给出了一个参考的类型数据序列化结果的结构图。但是,我们尚未知晓的还有很多,比如序列化结果存储在何处,Serialize的调用入口又在哪里。
序列化的入口有多处,我们以资产保存为例进行展开。
在UE中,资产保存在UPackage中,而其入口则为UPackage::Save(直接在UObject::Serialize处断点,然后查看调用栈即可)
//SavePackage.cpp
//我们直接在里面展开了Save2, 为此, 我们将Save中的形参名改为Save2中的形式
FSavePackageResultStruct UPackage::Save(UPackage* InPackage, UObject* InAsset,
const TCHAR* InFilename, const FSavePackageArgs& SaveArgs)
{
/* 我们在此直接将方法Save2展开 */ //return UPackage::Save2(InOuter, InAsset, Filename, SaveArgs);
/* 性能监测相关逻辑 */
//SaveContext将Package与Asset绑定, 并记录了存储状态
FSaveContext SaveContext(InPackage, InAsset, InFilename, SaveArgs);
//由于时间可能过长,因此分为8步,使用SlowTask分步完成. 每步对应一次EnterProgressFrame
const int32 TotalSaveSteps = 8;
FScopedSlowTask SlowTask(TotalSaveSteps, GetSlowTaskStatusMessage(SaveContext), SaveContext.IsUsingSlowTask());
SlowTask.MakeDialogDelayed(3.0f, SaveContext.IsFromAutoSave()); //设置存档超时, 若到达时间后依然未完成, 则弹窗提示
//1. 资产验证,资源验证
SlowTask.EnterProgressFrame();
SaveContext.Result = ValidatePackage(SaveContext);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
//2. 若不是并发保存, 则清空保存前所有可能访问该Package的缓存请求,并确保本地化存在,Package Loader也已经加载完毕
SlowTask.EnterProgressFrame();
{
if (!SaveContext.IsConcurrent()) {
UPackage* Package = SaveContext.GetPackage();
ConditionalFlushAsyncLoadingForSave(Package);
(*GFlushStreamingFunc)();
EnsurePackageLocalization(Package);
EnsureLoadingComplete(Package);
}
}
//3. PreSave1 Asset
SlowTask.EnterProgressFrame();
PreSavePackage(SaveContext);
if (SaveContext.GetAsset() && !SaveContext.IsConcurrent()) {
FObjectSaveContextData& ObjectSaveContext = SaveContext.GetObjectSaveContext();
UE::SavePackageUtilities::CallPreSaveRoot(SaveContext.GetAsset(), ObjectSaveContext);
SaveContext.SetPostSaveRootRequired(true);
SaveContext.SetPreSaveCleanup(ObjectSaveContext.bCleanupRequired);
}
//4. PreSave2 + Cook Cache, Objects in Package
SlowTask.EnterProgressFrame();
if (!SaveContext.IsConcurrent()) {
IPackageWriter* PackageWriter = SaveContext.GetPackageWriter();
if (!PackageWriter || !PackageWriter->IsPreSaveCompleted()) {
SaveContext.Result = RoutePresave(SaveContext); //依次调用Package中UObject的Presave
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
}
SaveContext.Result = BeginCachePlatformCookedData(SaveContext);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
//移除现有的写入, 防止写冲突
ResetLoadersForSave(SaveContext.GetPackage(), SaveContext.GetFilename());
}
//5. Save
SlowTask.EnterProgressFrame();
{ //InnerSave内部分为3步, 前两步为HarvestPackage, ValidateRealms, 第三步为SaveHarvestdRealms
SaveContext.Result = InnerSave(SaveContext);
}
//6. 移除脏标记, 更新FileSize
SlowTask.EnterProgressFrame();
if (SaveContext.Result == ESavePackageResult::Success) {
if (!SaveContext.IsKeepDirty())
SaveContext.GetPackage()->SetDirtyFlag(false);
if (SaveContext.IsUpdatingLoadedPath())
SaveContext.UpdatePackageFileSize(SaveContext.PackageHeaderAndExportSize);
}
//7. PostSave, Asset
SlowTask.EnterProgressFrame();
if (SaveContext.GetPostSaveRootRequired() && SaveContext.GetAsset()) {
UE::SavePackageUtilities::CallPostSaveRoot(SaveContext.GetAsset(), SaveContext.GetObjectSaveContext(), SaveContext.GetPreSaveCleanup());
SaveContext.SetPostSaveRootRequired(false);
}
//8. PostSave, UObject in Package
SlowTask.EnterProgressFrame();
PostSavePackage(SaveContext);
return SaveContext.GetFinalResult();
}
上文中出现的几个重点类和方法的关系如下:
FSaveContext是记录了所有保存资产时必要信息、中间状态的辅助类,其串联了相关的所有类,完整字段为:
//SaveContext.h
class FSaveContext {
public:
ESavePackageResult Result;
EPropertyLocalizationGathererResultFlags GatherableTextResultFlags =
EPropertyLocalizationGathererResultFlags::Empty;
//按照UE注释,下面这些应该位于FHarvestedRealm中, 但目前尚未转移
int64 PackageHeaderAndExportSize = 0;
int64 TotalPackageSizeUncompressed = 0;
int32 OffsetAfterPackageFileSummary = 0;
int32 OffsetAfterImportMap = 0;
int32 OffsetAfterExportMap = 0;
int64 OffsetAfterPayloadToc = 0;
int32 SerializedPackageFlags = 0;
TArray<FLargeMemoryWriter, TInlineAllocator<4>> AdditionalFilesFromExports;
FSavePackageOutputFileArray AdditionalPackageFiles;
private:
friend class FPackageHarvester;
// Args
UPackage* Package;
UObject* Asset;
FPackagePath TargetPackagePath;
const TCHAR* Filename;
FSavePackageArgs SaveArgs;
IPackageWriter* PackageWriter;
// State context
FUObjectSerializeContext* SerializeContext = nullptr;
FObjectSaveContextData ObjectSaveContext;
bool bCanUseUnversionedPropertySerialization = false;
bool bTextFormat = false;
bool bIsProcessingPrestreamPackages = false;
bool bIsFixupStandaloneFlags = false;
bool bPostSaveRootRequired = false;
bool bNeedPreSaveCleanup = false;
bool bGenerateFileStub = false;
bool bIgnoreHeaderDiffs = false;
bool bIsSaveAutoOptional = false;
// Mutated package state
uint32 InitialPackageFlags;
// Config classes shared with the old Save
FCanSkipEditorReferencedPackagesWhenCooking SkipEditorRefCookingSetting;
// Pointer to the EDLCookChecker associated with this context
FEDLCookCheckerThreadState* EDLCookChecker = nullptr;
// An object matching any GameRealmExcludedObjectMarks should be excluded from imports or exports in the game realm
const EObjectMark GameRealmExcludedObjectMarks;
// TArray<FCustomVersion>, FCustomVersion{Key: FGuid, Version: int32, ReferenceCount: int32, FriendlyName: FName}
FCustomVersionContainer CustomVersions;
// 根据是否为编辑态, 为Game或Editor
ESaveRealm CurrentHarvestingRealm = ESaveRealm::None;
// UE5.4中默认有3个, Game, Optional, Editor
TArray<FHarvestedRealm> HarvestedRealms;
// List of harvested illegal references
TArray<FIllegalReference> HarvestedIllegalReferences;
// Set of harvested prestream packages, should be deprecated
TSet<TObjectPtr<UPackage>> PrestreamPackages;
// Set of AssetDatas created for the Assets saved into the package
TArray<FAssetData> SavedAssets;
// Overrided properties for each export that should be treated as transient, and nulled out when serializing
TMap<UObject*, TSet<FProperty*>>TransientPropertyOverrides;
};
nnerSave内部分为3步, 前两步为HarvestPackage, ValidateRealms, 第三步为SaveHarvestdRealms。由于逻辑较为复杂,我们先对这三个方法建立一个直观的简单认识,其分别负责收集需要保存和保存对象依赖的内容、验证导出包,以及最后的保存。
第一步使用FPackageHarvester统计Package中的必要信息(Import、Export、Version):
//SavePackage2.cpp
ESavePackageResult InnerSave(FSaveContext& SaveContext)
{
TRefCountPtr<FUObjectSerializeContext>SerializeContext(FUObjectThreadContext::Get().GetSerializeContext());
SaveContext.SetSerializeContext(SerializeContext);
SaveContext.SetEDLCookChecker(&FEDLCookCheckerThreadState::Get());
TOptional<TGuardValue<bool>> IDOImpersonationScope;
if (UE::IsInstanceDataObjectSupportEnabled())
IDOImpersonationScope.Emplace(SerializeContext->bImpersonateProperties, true);
//任务分为3步, 同样有超时提示
const int32 TotalSaveSteps = 3;
FScopedSlowTask SlowTask(TotalSaveSteps, FText(), SaveContext.IsUsingSlowTask());
SlowTask.MakeDialogDelayed(3.0f, SaveContext.IsFromAutoSave());
//1. Harvest
SlowTask.EnterProgressFrame();
SaveContext.Result = HarvestPackage(SaveContext);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
//2. Validate
SlowTask.EnterProgressFrame();
SaveContext.Result = ValidateRealms(SaveContext);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
//更新Flag: GIsSavePackage. 之后可能改为与每个SaveContext相关, 而非全局标志
FScopedSavingFlag IsSavingFlag(SaveContext.IsConcurrent(), SaveContext.GetPackage());
SlowTask.EnterProgressFrame();
/* ToSaveRealms:
* Cooking: Game [+Optional]
* !Cooking: Editor
*/
for (ESaveRealm HarvestingContext : SaveContext.GetHarvestedRealmsToSave()) {
SaveContext.Result = SaveHarvestedRealms(SaveContext, HarvestingContext);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
}
//更新Package的LoadCompleted标志, Object独立的标志在SaveHarvestedRealms中设置
SaveContext.GetPackage()->SetFlags(RF_WasLoaded | RF_LoadCompleted);
return SaveContext.Result;
}
//HarvestPackage
ESavePackageResult HarvestPackage(FSaveContext& SaveContext)
{
SCOPED_SAVETIMER(UPackage_Save_HarvestPackage);
FPackageHarvester Harvester(SaveContext);
EObjectFlags TopLevelFlags = SaveContext.GetTopLevelFlags();
UObject* Asset = SaveContext.GetAsset();
//调用FPackageHarvester::HarvestExport, 将FTaggedExport入队ExportsToProcess与Exports
auto TryHarvestRootObject = [&Harvester, &SaveContext](UObject* InRoot) {
Harvester.TryHarvestExport(InRoot);
if (SaveContext.IsSaveAutoOptional()) {
FSaveContext::FSetSaveRealmToSaveScope RealmScope(SaveContext, ESaveRealm::Optional);
Harvester.TryHarvestExport(InRoot);
}
};
if (TopLevelFlags == RF_NoFlags) //TopLevelFlags在SaveContext初始化时传入, 默认为NoFlags
TryHarvestRootObject(Asset); //即默认export中仅有Asset自己
else { //若构造SaveContext时传入了TopLevelFlags, 则遍历Package中的Object, 检验具有对应Flag的Object,并将其入队
ForEachObjectWithPackage(SaveContext.GetPackage(), [&TryHarvestRootObject, TopLevelFlags](UObject* InObject) {
if (InObject->HasAnyFlags(TopLevelFlags))
TryHarvestRootObject(InObject); //此时导出所有具有对应Flag的object
return true;
}, true/*bIncludeNestedObjects */, RF_Transient);
}
//对ExportsToProcess列表中每个应导出的Object引用的对象处理Import, 需要处理Instance, Class, CDO, Super, Outer
//UE的注释: Add objects/names/others that are referenced when an object is being saved
//可能叫TryHarvestImport更好点. 不过里面的实现也很有迷惑性, 又覆写了<<, 不注意很容易和序列化混淆
while (FPackageHarvester::FExportWithContext ExportContext = Harvester.PopExportToProcess()) {
Harvester.ProcessExport(ExportContext);
}
/* 若有Optional(仅Game&Cooking下), 则将Optional中导出的实例改为对GameContext中对应实例的Import */
{ //精简PrestreamPackages. 仅在对应Package不是Import的情况下才将其记录(若已是Import,则表明无需Prestream)
FPackageHarvester::FHarvestScope RootReferenceScope = Harvester.EnterRootReferencesScope();
TSet<TObjectPtr<UPackage>>& PrestreamPackages = SaveContext.GetPrestreamPackages();
TSet<UPackage<UPackage>> KeptPrestreamPackages;
for (TObjectPtr<UPackage> Pkg : PrestreamPackages) {
if (!SaveContext.IsImport(Pkg)) {
KeptPrestreamPackages.Add(Pkg);
Harvester << Pkg;
}
}
Exchange(PrestreamPackages, KeptPrestreamPackages);
if (PrestreamPackages.Num() > 0) //将其记录到NamesReferencedFromPackageHeader
Harvester.HarvestPackageHeaderName(UE::SavePackageUtilities::NAME_PrestreamPackage);
/* WorldTileInfo相关的逻辑 */
}
if (!SaveContext.IsFilterEditorOnly()) //FArchive的CustomVersion
Harvester.UsingCustomVersion(FEditorObjectVersion::GUID);
SaveContext.SetCustomVersions(Harvester.GetCustomVersions()); //SaveContext的CustomVersion
SaveContext.SetTransientPropertyOverrides(Harvester.ReleaseTransientPropertyOverrides());
return ReturnSuccessOrCancel();
}
以一个简单的BP_Rifle(第一人称默认工程)为例, 其在HarvestPackage后,SaveContext如下:
HarvestedRealms则为:
Editor中的Exports, Imports, DirectImports分别为:
• 可以看到,一个BP_Rifle对应了三个对象: Instance, Class, ClassDefaultObject(CDO)
在序列化内容收集完毕后,SaveHarvestedRealms负责实际写入,并在此引入了FLinker,其为内存数据与文件的映射,而数据则写入到FArchive中,相关类型有:
保存对应的逻辑为:
//SavePackage2.cpp
ESavePackageResult SaveHarvestedRealms(FSaveContext& SaveContext, ESaveRealm HarvestingContextToSave)
{
//RAII, 绑定保存内容与收集的保存信息
FSaveContext::FSetSaveRealmToSaveScope Scope(SaveContext, HarvestingContextToSave);
//分成了12步的Task,但是现在已经不再允许出现超时了
const int32 TotalSaveSteps = 12;
FScopedSlowTask SlowTask(TotalSaveSteps, FText(), SaveContext.IsUsingSlowTask());
//1. Validate Exports
SlowTask.EnterProgressFrame();
SaveContext.Result = ValidateExports(SaveContext); //若Realm为空, 则直接返回, 且结果为Success
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
//2. Validate Imports
SlowTask.EnterProgressFrame();
SaveContext.Result = ValidateImports(SaveContext);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
/*3. 为CurrHaverestedRealm创建/设置LinkerSave, IPackageWriter, FBinaryArchiveFormatter
*在{Proj}/Saved中创建临时的写入文件(FArchiveFileWriterGeneric)
*写时总是写到新文件中,可防止崩溃一致性问题, 可联想ShadowPaging或Copy-On-Write
*/
SlowTask.EnterProgressFrame();
SaveContext.Result = CreateLinker(SaveContext);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
/*4. 初始化LinkerSave, 设置FileSummary:
* Summary
* NameMap
* GatherableText
* ImportMap(仅有条目, 根据HarvestRealm中的Imports创建)
* ExportMap(仅有条目, 根据HarvestRealm中的Exports创建)
* PackageNetplaydata
* ObjectIncidesMap(ReverseMapping)
* DependsMap
* SoftPackageReferenceList
* SearchableNamesMap
* ExportMap中的ClassIndex,TemplateIndex, SuperIndex,OuterIndex
* ImportMap中的OuterIndex, PackageImport
*/
SlowTask.EnterProgressFrame();
SaveContext.Result = BuildLinker(SaveContext);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
//从这里开始记录(EnterRecord)
FStructuredArchive::FRecord StructuredArchiveRoot = SaveContext.GetStructuredArchive()->Open().EnterRecord();
StructuredArchiveRoot.GetUnderlyingArchive().SetSerializeContext(SaveContext.GetSerializeContext());
//5. Header写入. 按上面的顺序写入, 之后保存Thumbnail, PreloadDependency
SlowTask.EnterProgressFrame();
SaveContext.Result = !SaveContext.IsTextFormat() ? WritePackageHeader(StructuredArchiveRoot, SaveContext) : WritePackageTextHeader(StructuredArchiveRoot, SaveContext);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
/*若有需要, 尝试生成SHA1密文. 默认不执行 */
/*6. 写入实际的Exports, 其在Header后面
* 将LinkerSave::ExportMap中的每个元素依次写入, 同步更新FObjectExport, 其对应的Exports格式为: {ObjectName: Object|CDO}
* Export中如SerialSize/SerialOffset等数据并不在此序列化, 但在此时才更新
*/
{ SlowTask.EnterProgressFrame();
FLinkerSave& Linker = *SaveContext.GetLinker();
/* 在为CookData的情况下将写入独立的Pacakge */
SaveContext.Result = WriteExports(StructuredArchiveRoot, SaveContext);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
}
//记录Exports写入完毕后的实际地址. 这里才是BulkData可以开始的地方
const int64 EndOfExportsOffset = SaveContext.GetLinker()->Tell();
int64 VirtualExportsFileOffset = EndOfExportsOffset;
//7. 写入BulkData, 在这里更新了FileSummary::BulkDataStartOffset
// BulkData有三种: DefaultBulkData, OptioanlBulkData, MemoryMappedBulkData
{ SlowTask.EnterProgressFrame();
SaveContext.Result = WriteBulkData(SaveContext, VirtualExportsFileOffset);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
SaveContext.GetLinker()->OnPostSaveBulkData();
}
/* 若有需要, 写入一个20Bytes的SHA密文 */
/* 8.9. 若有需要,写入AdditionalData和PayloadSidecar. 逻辑在SavePackageUtilities中 */
/* 在Exports和AdditonalFiles后面写下一个Tag,BulkData的实际位置也要跟随增加 */
// 10. 写入PackageTrailer(PayloadToc)
SlowTask.EnterProgressFrame();
SaveContext.Result = BuildAndWriteTrailer(PackageWriter, StructuredArchiveRoot, SaveContext, VirtualExportsFileOffset);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
// 记录Header与Exports的大小
if (SaveContext.GetCurrentHarvestingRealm() != ESaveRealm::Optional)
SaveContext.PackageHeaderAndExportSize = VirtualExportsFileOffset;
SaveContext.TotalPackageSizeUncompressed += VirtualExportsFileOffset;
for (const FSavePackageOutputFile& File : SaveContext.AdditionalPackageFiles)
SaveContext.TotalPackageSizeUncompressed += File.DataSize;
/*11.最后更新PackageHeader, 记录实际的ImportTable和ExportTable, 更新Generations
* ImportTable和ExportTable为数组, 其每个元素为FObjectImport|FObjectExport,其中记录内容为:
* FObjectImport: {ClassPackage, ClassName, OuterIndex, ObjectName, bImportOptional}
* FObjectExport: {ClassIndex, SuperIndex, TemplateIndex, OuterIndex, ObjectFlags, SerialSize, SerialOffset, bit[7], ...}
*/
SlowTask.EnterProgressFrame();
SaveContext.Result = UpdatePackageHeader(StructuredArchiveRoot, SaveContext);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
//12. HaverestRealm删除LinkerSaver, 重置StructuredArchive, Formatter. 将OptionalFile加入到待创建文件列表
//在CloseLinkerArchives时, 真正将数据写入磁盘(.tmp文件)
SlowTask.EnterProgressFrame();
SaveContext.Result = FinalizeFile(StructuredArchiveRoot, SaveContext);
if (SaveContext.Result != ESavePackageResult::Success) return SaveContext.Result;
//设置ExportMap中Export对象的标记位
{ SCOPED_SAVETIMER(UPackage_Save_MarkExportLoaded);
FLinkerSave* Linker = SaveContext.GetLinker();
for (auto& Export : Linker->ExportMap)
if (Export.Object) Export.Object->SetFlags(RF_WasLoaded | RF_LoadCompleted);
}
return SaveContext.Result;
}
在WriteExports中真正调用了对象的序列化(进入了我们最开始的UObject::Serialize):
//SavePackage2.cpp
//我们移除了性能检测相关的逻辑
ESavePackageResult WriteExports(FStructuredArchive::FRecord& StructuredArchiveRoot, FSaveContext& SaveContext)
{
FLinkerSave* Linker = SaveContext.GetLinker();
FScopedSlowTask SlowTask((float)Linker->ExportMap.Num(), FText(), SaveContext.IsUsingSlowTask());
FStructuredArchive::FRecord ExportsRecord = StructuredArchiveRoot.EnterRecord(TEXT("Exports"));
int32 LastExportSaveStep = 0;
//我们直接略去了编辑时检查并尝试将UObject序列化为文本的逻辑
for (int32 i = 0; i < Linker->ExportMap.Num(); i++)
{
if (GWarn->ReceivedUserCancel()) return ESavePackageResult::Canceled;
SlowTask.EnterProgressFrame();
FObjectExport& Export = Linker->ExportMap[i];
if (Export.Object)
{
Export.SerialOffset = Linker->Tell();
Linker->CurrentlySavingExport = FPackageIndex::FromExport(i);
Linker->CurrentlySavingExportObject = Export.Object;
FString ObjectName = Export.Object->GetPathName(SaveContext.GetPackage());
FStructuredArchive::FSlot ExportSlot = ExportsRecord.EnterField(*ObjectName);
if (Export.Object->HasAnyFlags(RF_ClassDefaultObject)) { //CDO序列化CDO
FArchiveUObjectFromStructuredArchive Adapter(ExportSlot);
Export.Object->GetClass()->SerializeDefaultObject(Export.Object, Adapter.GetArchive());
Adapter.Close();
}
else { //否则序列化对象
TGuardValue<UObject*>GuardSerializedObject(SaveContext.GetSerializeContext()->SerializedObject, Export.Object);
FArchiveUObjectFromStructuredArchive Adapter(ExportSlot);
Export.Object->Serialize(Adapter.GetArchive());
Adapter.Close();
}
Linker->CurrentlySavingExport = FPackageIndex();
Linker->CurrentlySavingExportObject = nullptr;
Export.SerialSize = Linker->Tell() - Export.SerialOffset;
/* 写入大小和偏移相关的检查 */
}
}
return Linker->IsError() ? ESavePackageResult::Error : ReturnSuccessOrCancel();
}
而FPackageFileSummary为UAsset的文件头:
//PackageFileSummary.h
struct FPackageFileSummary {
int32 Tag; //魔数, 0x9E2A83C1. 该数字还用于判断大小端
private:
FPackageFileVersion FileVersionUE; //UE文件版本, 内部有FileVersionUE4与FileVersionUE5两个字段
int32 FileVersionLicenseeUE; //0
FCustomVersionContainer CustomVersionContainer; //SaveContext::CustomVersions, 有重排
uint32 PackageFlags; //在SaveContext::InitialPackageFlags的基础上,可能修改部分标志位
public:
int32 TotalHeaderSize; //包含了NameTable, ImportMap, ExportMap的Header总大小
FString PackageName; //上次保存的Pakcage名
int32 NameCount; //Package中使用的Name数量
int32 NameOffset; //Name的起始偏移
int32 SoftObjectPathsCount; //Pakcage使用的SoftObjectPath的数量(后续类似字段不再注释)
int32 SoftObjectPathsOffset;
FString LocalizationId; //本地化ID, 是Package metadata中对应数据的副本
int32 GatherableTextDataCount;
int32 GatherableTextDataOffset;
int32 ExportCount;
int32 ExportOffset; //指向ExportTable
int32 ImportCount;
int32 ImportOffset; //指向ImportTable
int32 DependsOffset;
int32 SoftPackageReferencesCount;
int32 SoftPackageReferencesOffset;
int32 SearchableNamesOffset;
int32 ThumbnailTableOffset; //对应缩略图
UE_DEPRECATED(5.4, "Use GetSavedHash/SetSavedHash instead.")
FGuid Guid;
#if WITH_EDITORONLY_DATA
FGuid PersistentGuid;
#endif
TArray<FGenerationInfo> Generations; //此前生成的版本信息相关的数据
FEngineVersion SavedByEngineVersion; //生成此文件的引擎版本
FEngineVersion CompatibleWithEngineVersion; //可打开该文件的最低引擎版本
uint32 CompressionFlags; //压缩标志
uint32 PackageSource; //包来源, 用来表示是来自于Epic官方, 还是来自于Mod创作者
bool bUnversioned; //若为true, 则将不使用版本信息; 此时需要完整cook来保证正确性
int32 AssetRegistryDataOffset; //asset registry tag的位置
int64 BulkDataStartOffset; //实际存储的内容的起始位置
int32 WorldTileInfoDataOffset; //WorldTileInfo数据相关
TArray<int32> ChunkIDs;
int32 PreloadDependencyCount;
int32 PreloadDependencyOffset;
int32 NamesReferencedFromExportDataCount;
int64 PayloadTocOffset; //Toc: Table of contents
int32 DataResourceOffset;
};
FileSummary对应的实际对象状态如下:
而实际存储的文件布局(uasset)为:
• 在FileSummary写入uasset时,还额外存储了一些字段,如LegacyUE3version
真正的写入通过FLinkSave::Saver完成,其为FArchiveFileWriterGeneric,持有指向实际文件的句柄:
class FArchiveFileWriterGeneric : public FArchive {
protected:
FString Filename;
uint32 Flags;
int64 Pos;
TUniquePtr<IFileHandle> Handle;
TArray64<uint8> BufferArray;
int64 BufferSize;
bool bLoggingError;
};
写入完成后,我们可以简单检查一下Saver的状态:
文件中存储的数据与上面的分析过程是对应的:
• 文件头、NameMap:
• ExportTable:
• FObjectImport: {ClassPackage, ClassName, OuterIndex, ObjectName, bImportOptional}
FObjectExport: {ClassIndex, SuperIndex, TemplateIndex, OuterIndex, ObjectFlags, SerialSize, SerialOffset, bit[7], ...}
• 文件末尾、Tag与PayloadToc:
• Exports(这里使用了TPS,由于文件不同,因此和ExportTable中的起始偏移不同):
ParentClass: 35905-35934
Tag.Name 6B: 107, 对应ParentClass在NameMap的下标
Tag.TypeName 65: 101, 对应ObjectProperty
Tag.Size 0
PropertyTagFlags 0
BlueprintSystemVersion: 35935-35963
SimpleConstructionScript: 35964-35992
UberGraphPages: 35993-36037
FunctionGraphs: 36038-36082
CategorySorting: 36083-36211
LastEditedDocuments: 36212-36276
EditedObjectPath: 36277-36329
SavedViewOffset: 36330-36394
SavedZoomAmount: 36395-36431
EditedObjectPath: 36432-36484
SavedViewOffset: 36485-36549
SavedZoomAmount: 36550-36586
ThumbnailInfo: 36587-36615
GeneratedClass: 36616-36644
bLegacyNeedToPurgeSkelRefs: 36645-36669
BlueprintGuid: 36670-36734
NoneTerminator: 36735-36742
ObjectGuid: 36743-36746
序列化
最后,我们可以总结保存文件时的简单的流程来协助记忆(具体实现隐藏):
反序列化
在梳理过序列化的逻辑之后,相信反序列化也难不倒我们,而且我们之前也已经给出过反序列化需要的类/逻辑,在此,我们简单指一下对应逻辑的路径:
与FLinkerSave对应, 存在FLinkerLoad,其负责加载UPackage至内存:
//LinkerLoad.cpp 在内存中创建对象, 绑定其Class, 并为Class/Template设置状态
UObject* FLinkerLoad::CreateExportAndPreload(int32 ExportIndex, bool bForcePreload /* = false */)
{
UObject* Object = CreateExport(ExportIndex); //创建UObject,设置其Class, SuperClass等
if (Object && (bForcePreload || dynamic_cast<
UClass*>(Object) || Object->IsTemplate() || dynamic_cast<UObjectRedirector*>(Object)))
Preload(Object); //若为Class/Template, 则该方法结束时, 保证Object中已经拥有文件中存储的所有状态(即反序列化完成)
return Object;
}
//LinkerLoad.cpp 加载Linker对应的Package中的所有对象(ExportMap中)
void FLinkerLoad::LoadAllObjects(bool bForcePreload) {
if ((LoadFlags & LOAD_Async) != 0) bForcePreload = true;
for(int32 ExportIndex = 0; ExportIndex < ExportMap.Num(); ++ExportIndex) {
if (ExportIndex == MetaDataIndex) continue;
if (IsExportBeingResolved(ExportIndex)) continue;
UObject* LoadedObject = CreateExportAndPreload(ExportIndex, bForcePreload);
}
if(LinkerRoot) LinkerRoot->MarkAsFullyLoaded();
}
而UE中的Serialize方法,我们之前也提过,其不仅负责Serialize, 还负责Unserialize,其通过Ar.IsLoading来判断:
void T::Serialize(FArchive& Ar) {
if (Ar.IsLoading()) {
/* 反序列化相关 */
}
else {
/* 序列化相关 */
}
}
而加载一个文件的入口逻辑则在UObjectGlobals中,其方法与SavePackage相对:
//UObjectGlobals.cpp LoadPackage内部转发至此, 我们略过了部分逻辑
UPackage* LoadPackageInternal(UPackage* InOuter, const FPackagePath& PackagePath, uint32 LoadFlags, FLinkerLoad* ImportLinker, FArchive* InReaderOverride,
const FLinkerInstancingContext* InstancingContext, const FPackagePath* DiffPackagePath) {
/* 性能监测相关逻辑, 后续性能检测逻辑一并移除 */
if (PackagePath.IsEmpty()) return nullptr;
/* 异步加载逻辑 */
checkf(IsInGameThread(), TEXT("Unable to load %s. Objects and Packages can only be loaded from the game thread with the currently active loader '%s'."), *PackagePath.GetDebugName(), LexToString(GetLoaderType()));
UPackage* Result = nullptr;
#if WITH_EDITOR //保证加载时的一致性, 防止竞争
TGuardValue<ITransaction*> SuppressTransaction(GUndo, nullptr);
TGuardValue<bool> IsEditorLoadingPackage(GIsEditorLoadingPackage, GIsEditor || GIsEditorLoadingPackage);
#endif
TOptional<FScopedSlowTask> SlowTask;
/* SlowTask相关逻辑, 后续的SlowTask我们也略过 */
if (FCoreDelegates::OnSyncLoadPackage.IsBound())
FCoreDelegates::OnSyncLoadPackage.Broadcast(PackagePath.GetPackageNameOrFallback());
TRefCountPtr<FUObjectSerializeContext> LoadContext = ThreadContext.GetSerializeContext();
//BeginLoad
BeginLoad(LoadContext, *PackagePath.GetDebugName());
bool bFullyLoadSkipped = false;
FLinkerLoad* Linker = nullptr;
TArray<UPackage*>LoadedPackages;
{
const double StartTime = FPlatformTime::Seconds();
#if WITH_EDITOR
if (DiffPackagePath) {
if (!InOuter) InOuter = CreatePackage(*PackagePath.GetPackageName());
//为了能调用Linker中Private方法的对LinkerLoad的hack类
new FUnsafeLinkerLoad(InOuter, PackagePath, *DiffPackagePath, LOAD_ForDiff);
}
#endif
/* 获取真正的LoadContext, Package可能绑定了其它LoadContext */
/* 部分检查逻辑 */
Result = Linker->LinkerRoot;
auto EndLoadAndCopyLocalizationGatherFlag = [&] {
EndLoad(LoadContext, &LoadedPackages);
Result->ThisRequiresLocalizationGather(Linker->RequiresLocalizationGather());
};
/* EditorOnly相关, 部分EarlyReturn(已经加载完毕) */
if(LoadFlags & LOAD_ForDiff) Result->SetPackageFlags(PKG_ForDiffing);
Result->SetLoadedPath(PackagePath);
//下面是防止重复/循环加载的逻辑.
uint32 DoNotLoadExportsFlags = LOAD_Verify;
#if USE_CIRCULAR_DEPENDENCY_LOAD_DEFERRING
DoNotLoadExportsFlags |= LOAD_DeferDependencyLoads;
#endif
if ((LoadFlags & DoNotLoadExportsFlags) == 0) { //即没有重复/循环加载
FSerializedPropertyScope SerializedProperty(*Linker, ImportLinker ? ImportLinker->GetSerializedProperty() : Linker->GetSerializedProperty());
Linker->LoadAllObjects(GEventDrivenLoaderEnabled); //实际的加载逻辑
}
else bFullyLoadSkipped = true;
Linker->FinishExternalReadDependencies(0.0);
EndLoadAndCopyLocalizationGatherFlag(); //这里调用了EndLoad
#if WITH_EDITOR
GIsEditorLoadingPackage = *IsEditorLoadingPackage;
#endif
//清理逻辑
Linker->Flush();
if (!FPlatformProperties::RequiresCookedData()) Linker->FlushCache();
if (FPlatformProperties::RequiresCookedData()) {
if (!IsInAsyncLoadingThread()) {
if (GGameThreadLoadCounter == 0) {
if (Result && Linker->HasLoader()) ResetLoaders(Result);
if (Result && Result->GetLinker()) Linker->DestroyLoader();
Linker = nullptr;
}
else LoadContext->AddDelayedLinkerClosePackage(Linker);
}
else LoadContext->AddDelayedLinkerClosePackage(Linker);
}
}
if (!bFullyLoadSkipped) Result->SetFlags(RF_WasLoaded);
BroadcastEndLoad(MoveTemp(LoadedPackages));
return Result;
}
我们同样可以画出一个非常简单的流程示意图来辅助记忆,并将其和保存过程对比:
版本与安全
最后还有一些小主题,即UE中的安全与版本控制。同样限于篇幅,我们也仅仅简单带过。
经过我们上面的验证,可以发现,UE中是没有加密的,二进制文件明文存储,但允许使用SHA1散列来保证文件完整性、一致性,并简单防止恶意修改:
• 序列化时,可以对Header和Pacakge生成散列:
o //SavePackage2.cpp, SaveHarvestedRealms
o /* Header已经写入完毕. 若有需要, 则生成SHA密文 */
o TArray<uint8>* ScriptSHABytes = nullptr;
o { ScriptSHABytes = FLinkerSave::PackagesToScriptSHAMap.Find(*FPaths::GetBaseFilename(SaveContext.GetFilename()));
o if (ScriptSHABytes) SaveContext.GetLinker()->StartScriptSHAGeneration();
o }
o
o /* Package已保存完毕. 若有需要, 则生成20bytes的SHA密文 */
o { if (ScriptSHABytes && SaveContext.GetLinker()->ContainsCode()) {
o ScriptSHABytes->Empty(20);
o ScriptSHABytes->AddUninitialized(20);
o SaveContext.GetLinker()->GetScriptSHAKey(ScriptSHABytes->GetData());
o }
o }
o
• 反序列化时,若启用了SHA,则将进行对比:
o //UObjectGlobals.cpp, LoadPackageInternal
o /* 读取时判断是否需要检查SHA, 并在需要时根据文件内容生成对应属性 */
o uint8 SavedScriptSHA[20];
o bool bHasScriptSHAHash = FSHA1::GetFileSHAHash(*Linker->LinkerRoot->GetName(), SavedScriptSHA, false);
o if (bHasScriptSHAHash)
o Linker->StartScriptSHAGeneration();
o
o
o /* 检查SHA */
o if (bHasScriptSHAHash) {
o uint8 LoadedScriptSHA[20];
o Linker->GetScriptSHAKey(LoadedScriptSHA);
o
o if (FMemory::Memcmp(SavedScriptSHA, LoadedScriptSHA, 20) != 0)
o appOnFailSHAVerification(*Linker->GetPackagePath().GetLocalFullPath(), false);
o }
而在版本上,UE中提供了CustomVersion,其形式为{Key-VersionNo},存储在FArchive中,每个被序列化的对象可自行注册序列化时的Key与版本,在反序列化时则可根据序列化文件中的版本执行各自的兼容逻辑:
//HeightField.h
virtual void Serialize(FChaosArchive& Ar) override {
/* 其它逻辑 */
Ar.UsingCustomVersion(FExternalPhysicsCustomObjectVersion::GUID);
if (Ar.CustomVer(FExternalPhysicsCustomObjectVersion::GUID) >= FExternalPhysicsCustomObjectVersion::HeightfieldData)
{ //若文件中特定版本Key的版本号超越一定版本,则执行该分支
Ar << FlatGrid;
Ar << FlattenedBounds.Min;
Ar << FlattenedBounds.Max;
TBox<FReal, 3>::SerializeAsAABB(Ar, LocalBounds);
}
else CalcBounds(); //否则执行下面的分支
/* 后续逻辑 */
}
CustomVer则通过FCustomVersionRegistration注册,相关的方法有:
//Archive.cpp
//该调用在加载时不执行任何调用; 在保存时默认注册指定Key的最新版本号, 其存储于FCustomVersions中
void FArchive::UsingCustomVersion(const FGuid& Key) {
// 反序列化时不执行任何逻辑, 防止错误更新文件中记录的版本
if (IsLoading()) return;
ESetCustomVersionFlags SetVersionFlags = ArShouldSkipUpdateCustomVersion ? ESetCustomVersionFlags::SkipUpdateExistingVersion : ESetCustomVersionFlags::None;
const_cast<FCustomVersionContainer&>(GetCustomVersions()).SetVersionUsingRegistry(Key, SetVersionFlags);
}
//获取版本号, 只允许在加载时调用
int32 FArchiveState::CustomVer(const FGuid& Key) const {
auto* CustomVersion = GetCustomVersions().GetVersion(Key);
return CustomVersion ? CustomVersion->Version : -1;
}
//CustomVersion.h
//记录当前的最新版本, 其版本通过FCustomVersionRegistration注册
class FCurrentCustomVersions
{
static CORE_API TOptional<FCustomVersion> Get(const FGuid& Guid);
static CORE_API void Register(const FGuid& Key, int32 Version, const TCHAR* FriendlyName, CustomVersionValidatorFunc ValidatorFunc);
static CORE_API void Unregister(const FGuid& Key);
};
//创建对象意味着注册一个Key-Version的版本, 此外还有一个FDevVersionRegistration继承自该类
class FCustomVersionRegistration : FNoncopyable
{
public:
template<int N> FCustomVersionRegistration(FGuid InKey, int32 Version, const TCHAR(&InFriendlyName)[N], CustomVersionValidatorFunc InValidatorFunc = nullptr)
: Key(InKey) {
FCurrentCustomVersions::Register(InKey, Version, InFriendlyName, InValidatorFunc);
}
~FCustomVersionRegistration() {
FCurrentCustomVersions::Unregister(Key);
}
private:
FGuid Key;
};
版本注册则位于各个文件,或DevObjectVersion.cpp中:
//DevObjectVersion.cp
const FGuid FExternalPhysicsCustomObjectVersion::GUID(0x35F94A83, 0xE258406C, 0xA31809F5, 0x9610247C);
FDevVersionRegistration GRegisterExternalPhysicsCustomVersion(FExternalPhysicsCustomObjectVersion::GUID, FExternalPhysicsCustomObjectVersion::LatestVersion, TEXT("Dev-Physics-Ext"));
此外,也可以根据UEVer来直接进行版本控制:
//ObjectVersion.h
/* This enum is the version for UE5 and should ONLY be edited in UE5Main branch! */
enum class EUnrealEngineObjectUE5Version : uint32
{
//the UE4 version was 522, so UE5 will start from 1000 to show a clear difference
INITIAL_VERSION = 1000,
// Support stripping names that are not referenced from export data
NAMES_REFERENCED_FROM_EXPORT_DATA,
// Added a payload table of contents to the package summary
PAYLOAD_TOC,
// Added data to identify references from and to optional package
OPTIONAL_RESOURCES,
// Large world coordinates converts a number of core types to double components by default.
LARGE_WORLD_COORDINATES,
// Remove package GUID from FObjectExport
REMOVE_OBJECT_EXPORT_PACKAGE_GUID,
// Add IsInherited to the FObjectExport entry
TRACK_OBJECT_EXPORT_IS_INHERITED,
// Replace FName asset path in FSoftObjectPath with (package name, asset name) pair FTopLevelAssetPath
FSOFTOBJECTPATH_REMOVE_ASSET_PATH_FNAMES,
// Add a soft object path list to the package summary for fast remap
ADD_SOFTOBJECTPATH_LIST,
// Added bulk/data resource table
DATA_RESOURCES,
// Added script property serialization offset to export table entries for saved, versioned packages
SCRIPT_SERIALIZATION_OFFSET,
// Adding property tag extension,, Support for overridable serialization on UObject, overridable logic in containers
PROPERTY_TAG_EXTENSION_AND_OVERRIDABLE_SERIALIZATION,
// Added property tag complete type name and serialization type
PROPERTY_TAG_COMPLETE_TYPE_NAME,
AUTOMATIC_VERSION_PLUS_ONE,
AUTOMATIC_VERSION = AUTOMATIC_VERSION_PLUS_ONE - 1
};
//Quat.h 在UE5为了大世界而将Quat的精度从float改为double后, 需要对原本的数据进行兼容
inline FArchive& operator<<(FArchive& Ar, TQuat<double>& F)
{ //若已经为大世界版本后, 则直接反序列化即可
if (Ar.UEVer() >= EUnrealEngineObjectUE5Version::LARGE_WORLD_COORDINATES)
return Ar << F.X << F.Y << F.Z << F.W;
else { //否则, 需要读入4个float(原本的数据类型), 然后将其转为double
float X, Y, Z, W;
Ar << X << Y << Z << W;
if(Ar.IsLoading()) F = TQuat<double>(X, Y, Z, W);
}
return Ar;
}
此外,UE中还存在FileVer(映射到UEVer)与GameNetVer等其他维度/粒度的版本控制,不过对应的使用场景不多。
总结
综上,我们梳理了序列化的实现思路与UE中的具体实现,最后,我们可以进行总结:
• 序列化,指将内存中的数据转换为约定表示形式的过程
• 序列化结果可持久化,也可用于网络同步,或RPC/IPC中的参数传递
• 序列化的基本思路为逐类型声明需要序列化的成员,并提供最基础类型的序列化逻辑,在拥有反射能力时也可使用反射来提供通用的序列化框架
• 逐类型序列化需要我们声明每个类型的Serialize/Unserialize方法;反射则允许自动实现序列化/反序列化代码
• 序列化还需要关注版本、安全、性能问题
而在UE中,编辑时文件保存对应的序列化和反序列化流程可简单归纳如下:
从序列化到Object的Serialize的流程则可简单参考下图:
而Object内Property的序列化可以有TPS和UPS两种方案,前者记录了大量辅助信息从而可提供更好的版本兼容,而后者的性能和空间利用率则更高。
// 参考
1. UE4对象系统_序列化和uasset文件格式. https://www.jianshu.com/p/9fea500aaa4d
2. 针对UPS和TPS的说明. stonelzp.github.io
3. 近期知乎上写的比较详细的序列化文章. https://zhuanlan.zhihu.com/p/701516868
4. 《大象无形: 虚幻引擎程序设计浅析》. 书中有序列化相关的介绍,不过同样是基于UE4
5. https://json.nlohmann.me/