游戏开发:配置表字符串池&延迟加载

游戏开发:配置表字符串池&延迟加载

编码文章call10242025-07-27 14:13:235A+A-

作者在前文《 国际化文本管理 》分享了国际化文本的处理方案,但我在末尾提到:对于多数项目而言,实现隐式全局文本表可能就结束了,但其实还有很大的优化空间。


那么这个优化空间在哪呢?

这还得从配置表的另外两个问题——文本重复和延迟加载需求说起。



问题分析

文本重复


以物品的icon配置为例,这里的重复存在两种情况:

  1. 不同物品的icon路径完全相同,即字符串值完全相同

  2. 不同物品的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/";    // ...}

通过将路径拆分,可以大幅缩减配置的文本长度和重复内容,但问题并没有被完全解决:

  1. 剩下的文件名仍然可能是相同的

  2. 将目录单独配置以后, 会导致运行时的字符串拼接 ,而且也需要池化

  3. 不同物品的描述文本也可能是一样的



延迟加载需求

另一个问题是,像任务、剧情等包含大量文本的表格,如果这些文本和其它数据同时加载到内存,那么游戏一启动,表格就占用了大量的内存。

但实际上, 玩家单次游戏期间,访问到的配置量是极少的 -- 可能不足5%;而且在客户端,配置表90%以上的内存开销都在字符串上,所以配置表文本提前加载导致的内存浪费是非常夸张的。


所以,配置表文本的延迟加载是非常有必要的。常见的解决方案有两种:

  1. 设计显式全局文本表,将需要延迟加载的文本放入全局文本表,然后通过Id引用目标文本

  2. 针对剧情这类包含大量文本的表格进行单独的加载优化,比如按行加载


全局文本表的主要问题:

  1. 策划配置不方便

  2. 全局文本表可能很大

  3. 翻译时上下文缺失,不能达到最佳翻译


针对特定表格优化的主要问题:

  1. 缺乏通用性

  2. 更高的复杂度


其实很多项目没有做文本的延迟加载,主要还是项目的体量不大,文本带来内存压力不那么明显;但对于大型项目,文本的延迟加载还是很有必要的。


配置表是否需要按行延迟加载?

我见过客户端的配置表按行加载的,就是用到某一行的时候再加载某行。这种方式确实可以避免一部分不必要的加载,从而节省内存。

不过,我不是很赞同该方案,因为像物品表和任务表这种,我们经常是一启动就需要迭代建立缓存的,所以这些表都不会走到延迟加载; 只有剧情表这种,不需要迭代的,才会走到延迟加载


其实我们只要实现了字符串的延迟加载,其它部分的内存占用是很低的,就可以在启动的时候就全部加载到内存;这种方式不仅更简单,而且容易做到对开发者透明。




字符串池化实现

隐式全局文本表

解决问题的第一步,就是先实现隐式全局文本表。隐式全局文本表最初是为国际化文本设计的,表格工具会将所有需要翻译的文本收集起来,生成全局文本表,并将原表 导出数据中的文本 替换为文本Id。


现在,我们增加一个内容:除了需要翻译的文本外, 允许通过options指定额外加入全局文本表的字段


在作者的实现中,我将该选项命名为intern,即对应java/c#编程语言中的字符串Intern方法,即字符串如果在字符串常量池中存在,则返回池化字符串的引用,否则将该字符串加入字符串常量池,并返回this。

public class string {    public static string Intern(string str) {}}

PS:所以,如果项目没有提前做处理的话,可以在运行时执行intern消除重复。


现在,隐式全局文本表的内容便像这样:

11


C


C

12

string

int32

string

string

13

location

id

srcText

destText

14

单元格坐标

文本id

原始文本

目标文本



...

...

...


item_10001_icon

104


10001.png


item_10002_icon

105


10002.png


item_10003_icon

106


10002.png

PS:关于隐式全局文本表,请阅读《 国际化文本管理 》。


共享字符串表(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字节[StructLayout(LayoutKind.Explicit)]private readonly struct Item{    [FieldOffset(0)] public readonly int ssti;    [FieldOffset(4)] public readonly int offset;    [FieldOffset(8)] 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:这么硬核的文章哪里还有呢? 稀罕作者 点赞 推荐 都可以增加作者的创作动力哦。

点击这里复制本文地址 以上内容由文彬编程网整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!
qrcode

文彬编程网 © All Rights Reserved.  蜀ICP备2024111239号-4