游戏开发:配置表字符串池&延迟加载
作者在前文《 国际化文本管理 》分享了国际化文本的处理方案,但我在末尾提到:对于多数项目而言,实现隐式全局文本表可能就结束了,但其实还有很大的优化空间。
那么这个优化空间在哪呢?
这还得从配置表的另外两个问题——文本重复和延迟加载需求说起。
问题分析
文本重复
以物品的icon配置为例,这里的重复存在两种情况:
不同物品的icon路径完全相同,即字符串值完全相同
不同物品的icon属于同一个目录,只有最后的文件名不同,即字符串的前缀相同
针对icon这类路径配置,常见的解决方案是:提前约定资源目录,表格中只配置最终的文件名。
最常见的实现是将项目用到的目录常量定义在代码中,就像这样:
public static class PathConst {
public const string AudioDir = "Resources/Audio/";
public const string PrefabDir = "Resources/Prefab/";
public const string ItemIconDir = "Resources/Image/Item/Icon/";
// ...
}
通过将路径拆分,可以大幅缩减配置的文本长度和重复内容,但问题并没有被完全解决:
剩下的文件名仍然可能是相同的
将目录单独配置以后, 会导致运行时的字符串拼接 ,而且也需要池化
不同物品的描述文本也可能是一样的
延迟加载需求
另一个问题是,像任务、剧情等包含大量文本的表格,如果这些文本和其它数据同时加载到内存,那么游戏一启动,表格就占用了大量的内存。
但实际上, 玩家单次游戏期间,访问到的配置量是极少的 -- 可能不足5%;而且在客户端,配置表90%以上的内存开销都在字符串上,所以配置表文本提前加载导致的内存浪费是非常夸张的。
所以,配置表文本的延迟加载是非常有必要的。常见的解决方案有两种:
设计显式全局文本表,将需要延迟加载的文本放入全局文本表,然后通过Id引用目标文本
针对剧情这类包含大量文本的表格进行单独的加载优化,比如按行加载
全局文本表的主要问题:
策划配置不方便
全局文本表可能很大
翻译时上下文缺失,不能达到最佳翻译
针对特定表格优化的主要问题:
缺乏通用性
更高的复杂度
其实很多项目没有做文本的延迟加载,主要还是项目的体量不大,文本带来内存压力不那么明显;但对于大型项目,文本的延迟加载还是很有必要的。
配置表是否需要按行延迟加载?
我见过客户端的配置表按行加载的,就是用到某一行的时候再加载某行。这种方式确实可以避免一部分不必要的加载,从而节省内存。
不过,我不是很赞同该方案,因为像物品表和任务表这种,我们经常是一启动就需要迭代建立缓存的,所以这些表都不会走到延迟加载; 只有剧情表这种,不需要迭代的,才会走到延迟加载 。
其实我们只要实现了字符串的延迟加载,其它部分的内存占用是很低的,就可以在启动的时候就全部加载到内存;这种方式不仅更简单,而且容易做到对开发者透明。
字符串池化实现
隐式全局文本表
解决问题的第一步,就是先实现隐式全局文本表。隐式全局文本表最初是为国际化文本设计的,表格工具会将所有需要翻译的文本收集起来,生成全局文本表,并将原表 导出数据中的文本 替换为文本Id。
在作者的实现中,我将该选项命名为intern,即对应java/c#编程语言中的字符串Intern方法,即字符串如果在字符串常量池中存在,则返回池化字符串的引用,否则将该字符串加入字符串常量池,并返回this。
public class string {
public static string Intern(string str) {}
}
PS:所以,如果项目没有提前做处理的话,可以在运行时执行intern消除重复。
现在,隐式全局文本表的内容便像这样:
共享字符串表(SST)
解决问题的第二步,是将翻译后和池化后的文本去重(即池化),提取为共享字符串表(Shared String Table),然后让隐式全局表中的目标文本指向共享字符串表。
现在,原表导出数据、隐式全局文本表、共享字符串表的关系如下:
现在,以物品的icon为例,其处理如下:
public class ItemCfg {
// icon在全局文本表中的id(即单元格坐标)
private int _icon;
// 通过属性屏蔽底层实现
public string icon => SstMgr.GetString(_icon);
}
// 共享字符串表管理器
public static class SstMgr {
private static readonly Dictionary<int, int> indexMap; // 索引信息
private static readonly Dictionary<int, string> itemMap; // 最终文本
// 先查ssti,再查文本
public static string GetString(int locationId) {
if (!indexMap.TryGetValue(locationId, out int ssti)) {
return locationId.ToString();
}
return itemMap[ssti];
}
}
Q:是否可以删除隐式全局文本表 ,直接将共享字符串的索引(ssti)存储在原表导出数据中?
A:不可以。这里还存在着另一个需求: 多语言文本切换 。我们允许玩家在进入游戏前或游戏中切换语言包,如果我们将SST中的索引直接存储到原表导出数据中,那么不同语言导出的原表数据将不一致,那么多语言文本切换时就需要切换所有表格的数据。
这并不是我们所期望的, 而隐式全局文本的存在,为原表的导出数据提供了稳定的ID引用 ;因此在切换语言包时,只需要切换隐式全局文本表和共享字符串表。
不过,在引入SST后,隐式全局文本表中便不再包含业务数据,因此我们可以不再使用文本格式存储,而是直接使用二进制格式,就像这样:
sheetName,dataId,fieldName,locationId,ssti,
sheetName,dataId,fieldName,locationId,ssti,
...
字符串延迟加载
文本的延迟加载,相对来说要简单一点,我们先从最基础的实现开始,再一点点优化。
首先,我们需要一个对象来记录字符串的信息,基础数据结构如下:
// 实际内存开销:8 * 4 = 32 字节
internal class Item {
public readonly int ssti; // 文本id
public readonly string value; // 文本
public readonly Stream stream; // 关联的文件流
public readonly int offset; // 在文件中的偏移
}
PS:value和stream可以合用一个字段,以减少内存。
当 value 字段为nul(或stream不为nul),表示字符串尚未加载到内存,用户在查询时就需要从stream中加载,代码示例如下:
public static class SstMgr {
private static readonly Dictionary<int, Item> itemMap;
//
private string GetStringImpl(int ssti) {
var item = itemMap[ssit];
if (item.value != null) return item.value;
// 考虑多线程查询文本的情况
string value;
lock (item.stream) {
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024)
//
stream.Seek(item.offset, SeekOrigin.Begin);
_ = stream.Read(buffer, 0, 4 + 1 + 2); // 7个字节的Head
int len = ByteBufferUtil.GetInt16LE(buffer, 4 + 1); // 字符串长度
_ = stream.Read(buffer, 0, len);
value = Encoding.UTF8.GetString(buffer, 0, len);
//
ArrayPool<byte>.Shared.Return(buffer);
}
item = new Item(item.ssti, value, null, -1);
itemMap[ssit] = item;
return value;
}
}
PS:字典不需要是ConcurrentDictionay,读者可以想一想为什么。
索引内联
在最前面给出的查询过程中,我们是先根据单元格坐标id查询ssti,再查询最终文本。但仔细思考,可以发现:我们可以在启动的时候就让单元格id指向最终Item,从而省去运行时的额外查询。
public static class SstMgr {
// 单元格坐标 => Item
private static readonly Dictionary<int, Item> locationId2ItemMap = new();
//
public static void Init() {
// ...
Dictionary<int, Item> sstStringMap = ReadSstFiles();
Dictionary<int, int> indexMap = ReadIndexFile();
foreach (var pair in indexMap) {
int locationId = pair.Key;
int ssti = pair.Value;
if (sstStringMap.TryGetValue(ssti, out Item item)) {
locationId2ItemMap.Add(locationId, item);
}
}
// ...
}
}
值类型Item(非必须)
在项目较大的情况下,表格中文本数量可达数十万,那我们可不可以将Item改为值类型,减少内存开销和GC关注的对象数呢?
可以,但有一定的难度。
// 内存消耗:16字节
[ ]
private readonly struct Item
{
[public readonly int ssti; ]
[public readonly int offset; ]
[public readonly object streamOrValue; ]
}
在前面,我们是通过value是否为null确定文本是否已加载的,如果我们使用值类型,那么value字段就是隔离的。如果不做处理,那么同一个字符串就可能被加载多次,从而导致池化逻辑失效。
解决方案其实并不复杂:当我们加载字符串后,将新的Item拷贝到其它同样指向该Item的单元格即可。
真正复杂的是如何在不引入额外数据结构的情况下,高效定位同样指向该Item的单元格。作者的实现依赖了定制的LinkedDictionary —— 一个始终保证插入序的字典;在切换到C#编程时,我始终找不到Java LinkedHashMap的等价物,而始终保持插入序的字典对于一些底层业务来说非常重要,于是我自己写了一个,并提供了大量的有用方法。
回到问题,在加载索引后,我们先按照ssti进行排序, 让指向同一个字符串的单元格相邻 。
public static void Init() {
// ...
KeyValuePair<int, int>[] sortedIndexMap = ReadIndexMap(indexFile).ToArray();
Array.Sort(sortedIndexMap, (a, b) => {
int r = a.Value.CompareTo(b.Value);
return r != 0 ? r : a.Key.CompareTo(b.Key);
});
// ...
}
然后在加载字符串以后, 根据当前单元格id前后迭代 ,将ssti相同的value进行替换。
public static class SstMgr {
// 保持插入序的字典,并包含一些辅助方法
private static readonly LinkedDictionary<int, Item> locationId2ItemMap = new();
public static string GetString(int locationId) {
//...
// 需要拷贝到其它Item -- 已提前相邻
int key = locationId;
while (locationId2ItemMap.PrevKey(key, out int prevKey, out Item prevItem)) {
if (prevItem.ssti != item.ssti) {
break;
}
locationId2ItemMap[prevKey] = item;
key = prevKey;
}
key = locationId;
while (locationId2ItemMap.NextKey(key, out int nextKey, out Item nextItem)) {
if (nextItem.ssti != item.ssti) {
break;
}
locationId2ItemMap[nextKey] = item;
key = nextKey;
}
return value;
}
}
将Item实现为值类型有一定的复杂度,但收益并不明显,因此大家在项目中可以不进行该优化。
其它
SST分块
考虑到所有的字符串集中在同一个文件,可能导致较大的单体文件,因此我们可以将字符串存储到多个文件中 —— 即分块存储。
作者采用的是Hash分块,即使用字符串的hash值取模,代码如下:
int idx = Math.Abs(value.GetHashCode()) % partitions.Count;
Partition partition = partitions[idx];
在存储分块以后,我们的文本Id也是分块的,这就避免了文本ID重复。
private static int MakeGuid(int partition, int value) {
if (value >= PARTITION_FACTOR) { // 常量为10W
throw new ArgumentException("overflow: " + value);
}
return partition * PARTITION_FACTOR + value;
}
单元格坐标
在《 游戏配置:表格设计(新) 》一文中,我们提到:“普通表的第一列固定为主键,这样我们就可以通过【 表名 + 主键 + 列名 】定位到任一单元格,这对于我们的一些工具来说非常重要”,这里便是该设计的重要应用。
不过,在实际的实现中,我还增加了数组下标字段,因为我提供了 List 类型的翻译和池化功能。
public readonly struct Location : IEquatable
{
public readonly string sheetName; // 表
public readonly string dataId; // 行
public readonly string fieldName; // 列
public readonly int index; // 数据下标,0~9
}
不过,我限制了要翻译或池化的字符串List最多9个元素;这样就只需要为单元格分配稳定Id,数组元素的ID便可以根据单元格的坐标计算,从而保持连续且稳定。
我们来看下工具导出的内容以及生成的Class:
PS:导出的Dson文本是给策划看的,运行时使用的是Dson二进制。
查询缓存
该设计是配合List池化而设计的,如果每次查询都创建一个List,那肯定要不得,因此我们需要将查询结果缓存下来。
// 生成的代码
public class TestCfg {
public ImmutableList<string> list1 => _list1Cache ??= SstMgr.GetStringList(_list1);
public ImmutableList<string> list2 => _list2Cache ??= SstMgr.GetStringList(_list2);
}
PS:在切换语言包时,需要清理对应的缓存。
测试用例
现在让我们看看SST导出的文件和测试用例的输出:
结语
这一部分的理论知识并不算复杂,但要真正实现好,还是有难度的。
这部分的代码已在作者的BigCat仓库中,搜索SstMgr和SstGenerator即可查看到对应的代码,不过代码可能有编译错误,因为最新的commons我还没有发布到nuget仓库。
PS:这么硬核的文章哪里还有呢? 稀罕作者 、 点赞 和 推荐 都可以增加作者的创作动力哦。