念一叨

游戏存档序列化小谈

2024 年 3 月 19 日晚,天客隆的师弟刘丛玮发了这么句吐槽:

我真是一得阁拉米,因为多打了一个 s 害我找了半个小时 bug。

速效治标

我粗看了一眼,这是一段将游戏数据存进字典的方法。 他在循环中遍历了一些需要记录的数据源,将它们的类型转换成字符串,再作为索引写进字典。 Bug 的来源在于他在字典索引里不小心多打了一个 s,这样获取类型的对象就是储存这些数据源的数组了,所以获取到的永远都是一个数组类型。

我指出,这问题一半源于代码语义化。 所谓「代码语义化」,就是把代码里的每个标识符都写成人话。 这样写出来的代码不需要太多注释,直接读就能读懂七八分。 这样做,会使得内涵相近的标识符(如「价格总和」与「价格均值」)在字面上也相近,故而容易打错。 (注意,这样做并不会导致角色相近的标识符也容易混淆,如「教师列表」与「学生列表」)

但也不完全是。 九成九的情况里,在相近的标识符相混淆时,静态类型检查会杀死问题。 这是因为,虽然易混淆的标识符在字面上很接近,但它们的类型通常大相径庭。 像图中的例子,saveable 是对象而 saveables 是数组,从根本上就不同。 造成问题的直接原因是,GetType() 这个方法的作用类型太宽泛了。 任何 C# 里的 object 都可以调用这个方法。

我提出,应当将计算数据索引的逻辑抽成一个方法。 既然所有数据源对象都实现了 ISaveable 接口,那这个方法就可以作为它的扩展方法,像下面这样:

我帮他自动更正了「saveable」的拼写错误。

public static class SavableUtility {
	public static string GetISavableKeyName(this ISavable savable) {
		if(savable == null)
			throw new System.NullReferenceException("Serialization Error: Cannot get savable key name based on a null value.");
		return savable.GetType().ToString();
	}
}

这样既不用更改接口代码,又可以吃到静态类型检查的红利,因为 GetISavableKeyName 只能在实现了 ISavable 的对象上调用。 如果再像之前那样把主语对象写成了 ISavable 数组,就会编译报错。

场景无关性

然后,我又注意到他的代码里充斥着 object。 这绝对不是什么好事。 对此,他解释道:

呃呃,这个是因为刚重构了代码。 原来这一层本来是底层管理 GameObject 的各种存档信息的。 刚多加了一层,把这层改为管理所有 Savable 组件的管理层来解耦。 这个忘了改。 用 object 是因为存档要用的数据类型多且杂。

我回复:

似乎合理一些了,我还是觉得直接存场景相关数据不健康。 我来的话,至少也是存的时候先洗一遍成场景无关的纯数据。

他并不明白什么是「场景无关的纯数据」。 我当时也没有想出简单的解释来,所以作此博客。

要解释这个术语,不如先来说说反面。 所谓「场景有关」,是指数据与游戏场景中存活的对象相关联,可能会随着游戏运行动态改变。 诸如此类的数据,有如场景物体的位置、刚体速度等等。 它们最大的特点是与隶属的对象强关联的。 要让一组数据有意义,就必须指派其所隶属的对象。 这在语义上就相当于说这些数据是随着隶属对象的存活而存在的。 倘若一朝隶属对象被销毁了(或者压根还没被创建),这些数据也就失去了意义。

在他的设计里,他用了一个组件类来实现游戏对象数据的抓取。 这些数据是直接从游戏对象身上读出来的,也就是说它们是场景有关的。 这就逃不过前面说的问题: 读的时候,游戏物体的增减会导致记录项的增减; 写的时候,没法保证记录项能够一对一地应用到正确的游戏物体上。 所以我说:「至少也是存的时候先洗一遍成场景无关的纯数据。」

那么什么叫「场景无关」呢? 那就是说,不依赖于游戏场景的运行时也能够自立其存在意义的数据。 此类数据的例子有:剧情树的状态、玩家生命值、背包物品,等等。 它们可以直接写进存档,也可以随时无痛读取到游戏中去。

你可能会问:「玩家生命值怎么会和运行时无关呢?没运行哪来的生命值?」 Well,这个问题解释起来有点抽象。 归根结底,所有数据都要被解释(interpret)。 譬如说一个正方形的边长,如果只是单拎出来一个数,而不是“真的用尺子去量”,那它就只是一个数。 但关键在于,你知道这个数是什么意思,你知道如果你用尺子去量那个正方形的边长,所得到的结果就将会是这个数。 这就叫「解释」。 数据本身是可以脱离于解释存在的;只不过脱离了解释,数据就暂时失去了意义。 就像一份游戏存档如果没有读取进正在运行的游戏的话,就是一串死的二进制序列。

实装方法论

还有另外一个问题:如果说一定要场景无关的话,那么那些场景有关的数据,该如何读写呢? 总不能就无视它们吧? 我们可以看看 Unity 官方是怎么做的。

在 Unity 中,「数据的储存与读取」这两件事分别叫做「序列化」与「反序列化」。 这是比较科学规范的称呼。 为什么这样叫呢? 其实你仔细想想,储存数据,其实就是把内存里的东西整吧整吧整成一串规范的、运行时无关的格式,存到硬盘里去。 把东西变成一串格式,所以叫序列化。 相反的过程,那就是序列化了。

默认情况下,所有场景资产都是经过序列化的。 这一点可以用文本编辑器强行打开场景资产来验证,你会看到里面都是一行行格式规范的可阅读文本。 场景里面的所有对象,除了那些本身就是引用的其他资产,都一并被序列化进了场景文件。 (这也就是为什么你无法跨场景选择对象或者在场景外的资产里引用场景内的对象的原因——它们根本就不是独立的资产) 所有 MonoBehaviour 的公有字段,只要是 Unity 原生支持的类型,也都序列化到了场景文件里。 这些类型包括常见的 floatstringVector3GameObjectComponent 等等。 只有被序列化的字段,才能够保存进工程里。 那么如果你想序列化一个自定义类型的数据项,要怎么做呢? 答案是:给那个类型添加 System.Serializable属性(attribute),像这样:

[System.Serializable]
public struct RandomStruct {
	public string name;
	public float value;
}

有了这个属性标记,Unity 就知道「哦,这玩意虽然在我的原生支持范围内,但我还是要去尝试记录一下」。

介绍了这么多背景设定,我想给你看的重点在于:从始至终,没有什么东西在主动提供数据。 一直是 Unity editor 在天上俯视整个项目,然后把该序列化的数据项读取、写入。 序列化模块从来不应当影响游戏本体的逻辑,它应当在一旁旁观。 「把这层改为管理所有 Savable 组件的管理层」这种做法还有另外一个隐患:如果开发者漏掉了应当添加的地方,就会造成数据不完整; 而旁观者的做法就没有这个隐患,因为它总是会遍览全局。(不需要在乎性能问题,谁会时时刻刻都要序列化呢)

一个理想的序列化模块的设计如下:

在序列化时,模块从标记过的数据源(在本例中,ISavable 就足够了)那里抓取数据,整合成序列化过的一体,再输出到下游(比如 IO 模块); 在反序列化时,模块接过来序列化过的一体数据,解析分项,再挨个赋到对应的数据源上去。

现在只剩一个问题了:还是没解决场景无关的事啊,怎么确保引用的数据源对象存在呢? 在 Unity 里,这个问题的解决方案是:把源对象也作为序列化的目标。 反序列化时,如果源对象已经存在,就直接赋;如果不存在,就现场创建一个确保它存在,然后再赋。 为了搞清楚哪个是哪个,Unity 还额外序列化了对象的 GUID。 可惜,这个 GUID 是仅在 editor 里有效的设定,在游戏打包运行之后就无了(你总不能指望动态创建的对象也有 GUID)。 因此,你得自己想个办法把源对象区分开来。