1.前言

大家好,我是开罗小8。游戏配置表的存储方式分为很多种,包括XML,JSON,ScriptableObject等等,其中XML和JSON采取文本的形式保存配置表,如果配置表的内容很多,会使得配置表体积变大,内存占用变多,最近我在学习Protobuf,Protobuf一般用于网络消息的序列化与反序列化,以体积小效率高为优点,突发奇想能不能用于配置表,于是有了此次尝试。

首先看一下最终成果。

4种配置格式大小比较

Items配置表中共有约4万条配置,其中.asset为ScriptableObject,.bytes为本文章最终转化的配置文件,可见体积相比于原始的xml,少了50%左右

本文总共实现以下功能

  1. 使用Protobuf存储Excel中的配置文件
  2. 自动生成Excel表中配置的代码
  3. 配置支持懒加载,只序列化需要的配置
  4. 配置支持按ID或按指定字段的值进行高效查找

需要的库:

  • protobuf-net:用于序列化与反序列化
  • DocumentFormat.OpenXml:读写Excel文件

2.实现过程

读取Excel表并生成相应的代码文件

Excel的格式如下

  • 第一行为字段名称
  • 第二行为字段类型
  • 第三行为代码标签,标记字段用于生成查找代码或者其他用于
  • 第四行为注释
  • 第五行开始为配置内容

读取并遍历Excel的部分代码如下

using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using ProtoBuf;
using System.Text;
using System.Text.RegularExpressions;
using DocumentFormat.OpenXml.Wordprocessing;


using (SpreadsheetDocument spreadsheetDocument = SpreadsheetDocument.Open(xlsxPath, false))
{
    WorkbookPart workbookPart = spreadsheetDocument.WorkbookPart;
    if (workbookPart == null)
    {
        throw new Exception("无法获取工作簿内容");
    }
    Sheet firstSheet = workbookPart.Workbook.Sheets.Elements<Sheet>().FirstOrDefault();
    if (firstSheet == null)
    {
        throw new Exception("Excel中没有Sheet");
    }
    string className = excelName;//取文件名字作为类名

    WorksheetPart worksheetPart = workbookPart.GetPartById(firstSheet.Id) as WorksheetPart;
    if (worksheetPart == null)
    {
        throw new Exception("无法获取工作表内容");
    }

    Dictionary<int,string> fieldNameDic = new Dictionary<int, string>();
    Dictionary<int,string> fieldTypeDic = new Dictionary<int, string>();
    Dictionary<int,string> commentDic = new Dictionary<int, string>();
    Dictionary<int,List<string>> selectIndexDic = new Dictionary<int, List<string>>();

    SharedStringTable sharedStringTable = workbookPart.SharedStringTablePart?.SharedStringTable;
    IEnumerable<OpenXmlElement> elements = null;
    if (sharedStringTable != null) 
    {
        elements = sharedStringTable.Elements();
    }
    List<OpenXmlElement> sharedStringTableElements = null;
    if (elements != null)
    {
        sharedStringTableElements = elements.ToList();
    }
    foreach (Row row in worksheetPart.Worksheet.Descendants<Row>())
    {
        int lineIndex = (int)row.RowIndex.Value;
        foreach (Cell cell in row.Descendants<Cell>())//遍历每一行,取第一行内容作为字段的名字,获取字段信息
        {
            string cellValue = GetCellValue(cell, sharedStringTableElements);
            int collIndex = GetColumnIndex(cell);
            //根据项目需求自行实现,此处省略具体实现步骤
            if(lineIndex == 1)
            {
                //记录字段名
            }
            else if(lineIndex == 2)
            {
                //记录字段类型
            }
            else if(lineIndex == 3)
            {
                //记录记录脚本标记
            }
            else if(lineIndex == 4)
            {
                //记录注释
            }
        }
     }
}


    private static string GetCellValue(Cell cell, List<OpenXmlElement> sharedStringTable)
    {
        if (cell == null || cell.CellValue == null)
            return string.Empty;

        var value = cell.CellValue.InnerText;
        if (sharedStringTable == null)
        {
            return value;
        }
        // 处理共享字符串(类型为string时,值是共享字符串表的索引)
        if (cell.DataType != null && cell.DataType.Value == CellValues.SharedString)
        {
            if (int.TryParse(value, out int index) && sharedStringTable != null && index < sharedStringTable.Count)
            {
                return sharedStringTable[index].InnerText;
            }
        }
        // 其他类型(数字、日期等)直接返回原始值
        return value;
    }
    public static int GetColumnIndex(Cell cell)
    {
        string cellValue = cell.CellReference.Value;
        int columnIndex = 0;
        foreach (char c in cellValue)
        {
            if (char.IsLetter(c))
            {
                // 字母部分转换为列索引(A=1, B=2, ..., Z=26, AA=27 等)
                columnIndex = columnIndex * 26 + (c - 'A' + 1);
            }
        }

        return columnIndex;
    }

记录Excel中代码的关键信息,替换到代码模板中,代码模板如下

/*
|--------------------------------------------------|
|本文件为自动生成,Tools/生成配置脚本,请勿手动修改|
|--------------------------------------------------|
*/
using System.IO;
using UnityEngine;
using ProtoBuf;
using System.Collections.Generic;
[ProtoContract]
public class VO_{0}
{
{1}
    public void Serialize(MemoryStream ms)
    {
        Serializer.Serialize(ms, this);
    }
}
public class CONFIG_{0}: CONFIG_Base<VO_{0}, CONFIG_{0}>
{
    protected override string GetConfigPath()
    {
        return Path.Combine(Application.dataPath, $"{PathConst.CONFIG_PATH}/VO_{0}.bytes");
    }
{5}
    #1public static VO_{0} GetDataBy{2}(int {2})
    {
        return Instance.Select({3},{2});
    }#1
    #2public static List<VO_{0}> GetDataListBy{2}(int {2})
    {
        return Instance.SelectList({3}, {2});
    }#2
    #3public static VO_{0} GetDataBy{2}And{3}(int {2},int {3})
    {
        return Instance.Select({4},{2},{3});
    }#3
    #4public static List<VO_{0}> GetDataListBy{2}And{3}(int {2},int {3})
    {
        return Instance.SelectList({4},{2},{3});
    }#4
}

代码模板解释

将Excel中的字段信息读取完毕后,读取代码模板中的文本,将信息替换进去

{0}会被替换成类名

{1}会被替换成Excel中的每一个字段

{2}{3}{4}会替换成对应方法所需的参数

#1开头和#1结尾用于标记生成的查找方法,替换时使用正则表达式提取

以上面的配置表为例,生成的代码的结构应该为

/*
|--------------------------------------------------|
|本文件为自动生成,Tools/生成配置脚本,请勿手动修改|
|--------------------------------------------------|
*/
using System.IO;
using UnityEngine;
using ProtoBuf;
using System.Collections.Generic;
[ProtoContract]
public class VO_ExcelTemplate
{
    /// <summary>
    /// 注释
    /// </summary>
    [ProtoMember(1)]
    public int ID;
    /// <summary>
    /// 注释
    /// </summary>
    [ProtoMember(2)]
    public string Name;
    /// <summary>
    /// 注释
    /// </summary>
    [ProtoMember(3)]
    public int Age;
    /// <summary>
    /// 注释
    /// </summary>
    [ProtoMember(4)]
    public int Group;
    public void Serialize(MemoryStream ms)
    {
        Serializer.Serialize(ms, this);
    }
}
public class CONFIG_ExcelTemplate: CONFIG_Base<VO_ExcelTemplate, CONFIG_ExcelTemplate>
{
    protected override string GetConfigPath()
    {
        return Path.Combine(Application.dataPath, $"{PathConst.CONFIG_PATH}/VO_ExcelTemplate.bytes");
    }
    public static VO_ExcelTemplate GetDataByAgeAndGroup(int Age,int Group)
    {
        return Instance.Select(1,Age,Group);
    }
    public static List<VO_ExcelTemplate> GetDataListByAgeAndGroup(int Age,int Group)
    {
        return Instance.SelectList(1,Age,Group);
    }
    public static VO_ExcelTemplate GetDataByGroup(int Group)
    {
        return Instance.Select(2,Group);
    }
    public static List<VO_ExcelTemplate> GetDataListByGroup(int Group)
    {
        return Instance.SelectList(2, Group);
    }
}

VO_ExcelTemplate 中的Serialize方法用于序列化当前类

CONFIG_ExcelTemplate 里面GetConfigPath用于获取当前配置的路径,用于初始化配置

其他方法均为根据输入的参数查找对应配置的方法

Age:SelectGroup:1

Group:SelectGroup:1|SelectGroup:2

CONFIG_Base是配置的基类,负责初始化配置

using ProtoBuf;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;

public abstract class CONFIG_Base<T1,T2> where T2 : CONFIG_Base<T1, T2>, new()
{
    public static T2 Instance
    {
        get
        {
            if(instance == null)
            {
                instance = new T2();
            }
            return instance;
        }
    }
    protected static T2 instance;
    protected bool isInit = false;
    protected byte[] bytes;
    protected IDIndex idIndex;
    protected SelectIndex selectIndex;

    protected Dictionary<int, T1> _dataDic = new Dictionary<int, T1>();
    protected T1 Deserialize(byte[] bytes)
    {
        using (MemoryStream ms = new MemoryStream(bytes))
        {
            T1 deserializedObj = Serializer.Deserialize<T1>(ms);
            return deserializedObj;
        }
    }
    protected abstract string GetConfigPath();
    protected void InitConfig()
    {
        bytes = File.ReadAllBytes(GetConfigPath());
        using (MemoryStream ms = new MemoryStream(bytes))
        {
            idIndex = new IDIndex();
            idIndex.Read(ms);
            selectIndex = new SelectIndex();
            selectIndex.Read(ms);
            var keys = idIndex.offsetDic.Keys.ToArray();
            for (int i = 0; i < keys.Length; ++i)
            {
                int ID = keys[i];
                (long, int) offset = idIndex.offsetDic[ID];
                long newOffset = offset.Item1 + ms.Position;
                idIndex.offsetDic[ID] = (newOffset, offset.Item2);
            }
        }
        isInit = true;
    }
    public T1 _GetDataByID(int ID)
    {
        if (!isInit)
        {
            InitConfig();
        }
        if (_dataDic.ContainsKey(ID))
        {
            return _dataDic[ID];
        }
        if (idIndex.offsetDic.ContainsKey(ID))
        {
            (long, int) offset = idIndex.offsetDic[ID];
            byte[] temp = new byte[offset.Item2];
            Array.Copy(bytes, offset.Item1, temp, 0, offset.Item2);
            T1 vo = Deserialize(temp);
            _dataDic.Add(ID, vo);
            return vo;
        }
        else
        {
            return default;
        }
    }
    public List<T1> _GetAllList()
    {
        if (!isInit)
        {
            InitConfig();
        }
        List<T1> list = new List<T1>();
        var keys = idIndex.offsetDic.Keys.ToArray();
        for (int i = 0; i < keys.Length; ++i)
        {
            int ID = keys[i];
            T1 vo = GetDataByID(ID);
            list.Add(vo);
        }
        return list;
    }

    public static T1 GetDataByID(int ID)
    {
        return Instance._GetDataByID(ID);
    }
    public static List<T1> GetAllList()
    {
        return Instance._GetAllList();
    }
    public T1 Select(int sindex,int value1)
    {
        if (!isInit)
        {
            InitConfig();
        }
        if (selectIndex.selectDic1.ContainsKey(sindex))
        {
            if (selectIndex.selectDic1[sindex].ContainsKey(value1))
            {
                int ID = selectIndex.selectDic1[sindex][value1][0];
                return _GetDataByID(ID);
            }
        }
        return default;
    }
    public List<T1> SelectList(int sindex, int value1)
    {
        if (!isInit)
        {
            InitConfig();
        }
        List<T1> list = new List<T1>();
        if (selectIndex.selectDic1.ContainsKey(sindex))
        {
            if (selectIndex.selectDic1[sindex].ContainsKey(value1))
            {
                for(int i = 0; i < selectIndex.selectDic1[sindex][value1].Count; ++i)
                {
                    int ID = selectIndex.selectDic1[sindex][value1][i];
                    T1 data = _GetDataByID(ID);
                    if(data != null)
                    {
                        list.Add(data);
                    }
                    
                }
            }
        }
        return list;
    }
    public T1 Select(int sindex,int value1,int value2)
    {
        if (!isInit)
        {
            InitConfig();
        }
        if (selectIndex.selectDic2.ContainsKey(sindex))
        {
            long v1 = value1;
            long v2 = value2;
            long key = (v1 << 32) | v2;
            if (selectIndex.selectDic2[sindex].ContainsKey(key))
            {
                int ID = selectIndex.selectDic2[sindex][key][0];
                return _GetDataByID(ID);
            }
        }
        return default;
    }
    public List<T1> SelectList(int sindex, int value1,int value2)
    {
        if (!isInit)
        {
            InitConfig();
        }
        List<T1> list = new List<T1>();
        if (selectIndex.selectDic2.ContainsKey(sindex))
        {
            long v1 = value1;
            long v2 = value2;
            long key = (v1 << 32) | v2;
            if (selectIndex.selectDic2[sindex].ContainsKey(key))
            {
                int ID = selectIndex.selectDic2[sindex][key][0];
                T1 data = _GetDataByID(ID);
                if(data != null)
                {
                    list.Add(data);
                }
            }
        }
        return list;
    }
}

核心原理如下

IDIndex使用Dictionary<int,(long,int)> 的结构存储每一个配置的位置信息 

key为ID,long为配置在字节数组中的起始位置,int为配置的长度

在序列化时,也将这个字典序列化进配置中,初始化时首先反序列化这个字典

根据这些内容就可以做到根据ID查找配置的位置以及大小,并反序列化为对应的对象

实现随用随序列化,节省内存

SelectIndex同理,只不过以对应属性的值作为Key,存储配置ID,不再存储起始位置与大小

对于同时查找两个字段值的,采用将两个值通过位运算的形式组合在一起作为key

SelectIndex使用两个字典存储不同组的索引

Dictionary<int, Dictionary<int, List<int>>> selectDic1;
Dictionary<int, Dictionary<long, List<int>>> selectDic2;

第一个字典存储只有一个组的索引

第二个字典存储有两个组的索引

字典的key存储组编号

目前只支持最多查找两个的值,并且只支持int类型的字段

Logo

这里是一个专注于游戏开发的社区,我们致力于为广大游戏爱好者提供一个良好的学习和交流平台。我们的专区包含了各大流行引擎的技术博文,涵盖了从入门到进阶的各个阶段,无论你是初学者还是资深开发者,都能在这里找到适合自己的内容。除此之外,我们还会不定期举办游戏开发相关的活动,让大家更好地交流互动。加入我们,一起探索游戏开发的奥秘吧!

更多推荐