一站式数据表导出流程·魔改

前言

大概一年前(居然都过去这么久了orz),我把我们之前独立游戏的读策划表的流程介绍了一下,传送门在这里。过了这么久,我手有点痒想做个游戏玩儿(做游戏真TM开心233)。整理了下之前的框架,把读表这块的流程迭代+优化了一波,现在在易用性和兼容性上都有了很大的进步。所以这里我决定重新整理一下,记录在这里。

工作流程

既然是一个流程的介绍,还是先把实际的工作流程走一遍会更清晰。

首先关于数据格式,我用到了两种,Excel的xlsx文件和自定义二进制文件,而不是网上常见的csvjson或者xml这些纯文本格式,主要考虑点在灵活性不够高,运行时读取的速度也不够快。其中xlsx用于编辑和调试,二进制文件用于打包后运行时快速读取。

跟去年的方案相比,大体上是一样的,主要有以下几点不同:

  1. 放弃了Sqlite数据库(这玩意用il2cpp老是编译不过,又没有找到纯C#的实现版本,就不用了)。
  2. 采取了自定义的二进制文件用于在正式环境中使用。
  3. 开发过程中不需要重复导表,编辑器模式直接读xlsx文档,最后只需要导一次即可,该操作可以集成到打包流程中。
  4. 解除了和游戏逻辑之间的耦合,可以抽出来很方便地复用。
  5. 把数据源抽象成接口,方便扩充新的数据源(目前有两种,Excel文档和二进制文件)。
  6. 放弃了之前用Python脚本来导表和生成代码,现在全部用C#实现,集成到Unity编辑器里。

当然还有本来就有的好处,也列出来,就不用去翻之前的文章(黑历史)了:

  1. 用Excel编辑,格式漂亮,排版自由,可以用公式。
  2. 自动生成对应的C#代码。
  3. 支持在读表过程中的自定义处理流程。

1. 设计表结构和编辑Excel表

每个数据表都需要一个单独的文档,主要目的是为了方便多人编辑,降低了冲突的概率(不过依然不能完全解决冲突,不能或者说不好合并这一点不如纯文本格式),创建一个新的xlsx表格文件,像下面这样的格式:

Fig.1

  • 每个字段一列,Id列必须存在,程序里对于所有的表都统一按Id来索引。
  • 第一行是注释行,用于解释字段的意义。
  • 第二行是字段名,建议使用Pascal命名法,即每个单词首字母大写,其余小写,并且第一个字符不能是数字。
  • 第三行是数据类型,目前只支持四种:inttextintstexts
类型说明示例
int整数123
text字符串Hello
ints整数数组,由半角逗号隔开123,456,789
texts字符串数组,由半角逗号隔开Hello,World

2.生成代码

在第一步中我们已经准备好了表结构和对应的数据,接下来为了方便程序使用,需要生成对应的表结构代码。这样做是为了减少程序人员的工作量以及避免手动写代码偶尔出现的手滑。生成的代码像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
[GameData("Test")]
public sealed class TestConfig : BaseConfig
{
/// <summary>
/// 整数值
/// </summary>
public int IntVal { get; private set; }

/// <summary>
/// 整数数组
/// </summary>
public int[] IntArray { get; private set; } = EmptyIntArray;

/// <summary>
/// 字符串值
/// </summary>
public string StrVal { get; private set; } = string.Empty;

/// <summary>
/// 字符串数组
/// </summary>
public string[] StrArray { get; private set; } = EmptyStringArray;

public static TestConfig Get(int id)
{
return ConfigManager.Instance.GetSingle<TestConfig>(id);
}

public override void InitWithData(IConfigData data)
{
Id = data.GetInt("Id");
IntVal = data.GetInt("IntVal");
var rawIntArray = data.GetString("IntArray");
IntArray = Utility.SplitToInt(rawIntArray);
StrVal = data.GetString("StrVal");
var rawStrArray = data.GetString("StrArray");
StrArray = Utility.SplitToString(rawStrArray);
}
}

可以看到,代码完全对应了Excel中的表结构,包括每个字段名的注释,配合IDE写代码美滋滋。然后之前字段名用Pascal规则命名也是为了和符合这里C#代码的命名规则。使用的时候,直接调用TestConfig.Get(id)就可以获得指定ID的数据记录了。

其中Utility.SplitToInt()Utility.SplitToString()是我自己写的字符串分割的方法,很简单,就不单独说了。至于其他的东西我下面会详细说明。这一步主要还是介绍如何生成代码:

生成代码的细节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
[MenuItem("数据表/生成Config代码")]
public static void GenerateConfigCode()
{
// 遍历目标目录的所有xlsx文件,然后调用:
GenSingleFile(filePath);
}
private static void GenSingleFile(string filePath)
{
var tableName = Path.GetFileNameWithoutExtension(filePath);
var fieldDeclare = new StringBuilder();
var fieldInit = new StringBuilder();
var columnIdx = 0;
while (true)
{
columnIdx += 1;
var fieldName = "从Excel文档里读到的字段名";
if (string.IsNullOrWhiteSpace(fieldName))
{
break;
}
var fieldType = "从Excel文档里读到的字段类型";
var fieldDesc = "从Excel文档里读到的字段注释";
BuildFieldCode(fieldDeclare, fieldInit, fieldName, fieldType, fieldDesc);
}
// 读取模板文件,替换掉模板文件中的占位符
}
private static void BuildFieldCode(StringBuilder fieldDeclare, StringBuilder fieldInit, string fieldName, string fieldType, string fieldDesc)
{
// Id字段不自动生成
if (fieldName.Equals("Id")) return;
fieldDeclare.Append(" /// <summary>\n");
fieldDeclare.Append($" /// {fieldDesc}\n");
fieldDeclare.Append(" /// </summary>\n");
fieldDeclare.Append(" ");
switch (fieldType)
{
case "int":
fieldDeclare.Append($"public int {fieldName} {{ get; private set; }}\n");
break;
case "ints":
fieldDeclare.Append($"public int[] {fieldName} {{ get; private set; }} = EmptyIntArray;\n");
break;
}
fieldDeclare.Append('\n');
switch (fieldType)
{
case "int":
fieldInit.Append($" {fieldName} = data.GetInt(\"{fieldName}\");\n");
break;
case "ints":
fieldInit.Append($" var raw{fieldName} = data.GetString(\"{fieldName}\");\n");
fieldInit.Append($" {fieldName} = Utility.SplitToInt(raw{fieldName});\n");
break;
}
// text和texts类似,为节省篇幅省略了
}

Fig.2

这次生成代码不用Python脚本了,直接集成到Unity编辑器里,方便了许多。

Fig.3

自定义处理流程

自动生成的代码我们当然不能随便改,不然下次生成可就全覆盖了。不过我为了保证一定的灵活性,还是可以写自定义代码的。比如说,表里的某些字段想使用枚举类型,然而Excel表里只能填整数,那我们就还是希望在代码里能避免使用魔数而是用特定的枚举类型来替代,这里就需要加一个自定义处理流程来把读到的整数字段提前转换成枚举类型。

当然更常见的场景是做一个自定义的筛选,比如说下面的GetRecordWithType()

1
2
3
4
5
6
7
8
/// 获取Type为指定值的第一条记录
public static FooConfig GetFirstRecordWithType(int type)
{
var all = ConfigManager.Instance.GetAll<FooConfig>();
// 因为不能写更多的using,所以就只能写方法的全名了
// 复杂的Linq表达式运行效率会比较低,应避免高频地调用
return System.Linq.Enumerable.First(all, item=>item.IntVal == val);
}

3.在Unity中使用

这次升级的重点之一就是在开发过程中不需要反复导表了,编辑器下游戏将直接读取Excel文档。使用C#读取Excel文件的话,有一个很好用的.Net库EPPlus。这个库的功能十分强大,除了读写单元格的数据,甚至可以操作Excel文档中的单元格格式,公式或者图表。当然我们这里只用得到读取单元格数据这一个功能啦。

首先游戏启动时,对数据表系统ConfigManager进行初始化,在第二步生成的代码中有InitWithData()这样一个方法,它接收一个参数,类型是IConfigData,这是我抽象出来的数据项,表示某个表的全部数据,定义如下:

1
2
3
4
5
6
7
8
9
10
11
public interface IConfigData
{
/// 读取下一条记录
bool Next();
/// 读取当前记录的整数值
int GetInt(string field);
/// 读取当前记录的字符串值
string GetString(string field);
/// 结束读取
void Close();
}

这玩意有点类似一个指针,使用GetInt()GetString()方法来获取当前指向的行的指定字段对应的数据,使用Next()方法来将指针移到下一行,返回false即表示已经到最后一行了。不过这里要注意,这里的Next()方法和系统枚举器Enumerator的不太一样,我这个要使用do-while循环:

1
2
3
4
do
{
// Read Data
} while (data.Next());

数据源

那么这个数据项从哪里获取呢,这里提出另一个概念:数据源(IDataSource)。数据源包含所有的表的所有数据,定义如下:

1
2
3
4
5
6
public interface IDataSource
{
void Init(string src = null);
void Uninit();
IConfigData GetData(string tableName);
}

使用GetData()方法获取单个表的数据。针对Excel数据源,在数据源的GetData()方法中读取对应的Excel文档,并把所有的数据读取出来并保存在ExcelData(实现IConfigData接口)里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
public class ExcelDataSource : IDataSource
{
private string _basePath;
public void Init(string src)
{
_basePath = src ?? $"{Application.dataPath}/../../Documents/GameConfig/";
}
public IConfigData GetData(string tableName)
{
var xlsPath = $"{_basePath}{tableName}.xlsx";
// 这里要保证文件存在,否则抛出异常,这里为了节省篇幅故略去
return new ExcelData(xlsPath, tableName);
}
}

public class ExcelData : IConfigData
{
public string TableName { get; }
private ExcelPackage _package;
private ExcelWorksheet _ws;
private readonly Dictionary<string, int> _dictFieldIdx = new Dictionary<string, int>();
private readonly string[] _curLine;
private int _curLineNumber;
internal ExcelData(string xlsPath, string tableName)
{
TableName = tableName;
var fileInfo = new FileInfo(xlsPath);
_package = new ExcelPackage(fileInfo);
_ws = _package.Workbook.Worksheets["Data"];
var columnCount = 1;
while (true)
{
var fieldName = _ws.Cells[2, columnCount].Value?.ToString();
if (string.IsNullOrWhiteSpace(fieldName)) break;
_dictFieldIdx[fieldName] = columnCount;
++columnCount;
}
_curLine = new string[columnCount - 1];
_curLineNumber = 4; // 从第四行开始
if (!Next()) throw new ConfigException("xls文件中一行数据都没有", tableName);
}

public bool Next()
{
try
{
int.TryParse(_ws.Cells[_curLineNumber, 1].Value?.ToString(), out var id);
if (id == 0) return false;
for (var i = 0; i < _curLine.Length; ++i)
{
var val = _ws.Cells[_curLineNumber, i + 1].Value;
_curLine[i] = val != null ? val.ToString() : "";
}
_curLineNumber += 1;
return true;
}
catch (Exception) { return false; }
}

public int GetInt(string field)
{
if (!_dictFieldIdx.TryGetValue(field, out var fieldIdx))
{
Logger.LogWarning(LogModule.Data, $"找不到字段名称:{field}");
return 0;
}
if (!int.TryParse(_curLine[fieldIdx - 1], out var ret) && !string.IsNullOrWhiteSpace(_curLine[fieldIdx - 1]))
{
Logger.LogWarning(LogModule.Data, $"不能转换为整数:{_curLine[fieldIdx - 1]}");
}
return ret;
}

public string GetString(string field)
{
if (!_dictFieldIdx.TryGetValue(field, out var fieldIdx))
{
Logger.LogWarning(LogModule.Data, $"找不到字段名称:{field}");
return string.Empty;
}
return _curLine[fieldIdx - 1];
}

public void Close()
{
_ws.Dispose();
_ws = null;
_package.Dispose();
_package = null;
}
}
}

然后倒回去看第二步的InitWithData()方法,额,大概就是这么用了。

ConfigManager

当数据表越来越多的时候,就需要统一自动化处理各个类了。这时候之前提到的ConfigManagerGameDataAttribute属性就派上用场了。首先看初始化方法,这个方法在启动游戏的时候调用:

1
2
3
4
5
6
public void Init()
{
_dictConfigData = new Dictionary<Type, object>();
_dataSource = new ExcelDataSource();
_dataSource.Init();
}

就是创建了数据源,数据源的初始化过程在上面已经介绍过了。我们之前生成的代码,各种Config类不是都有一个GameData的属性(Attribute)么,接下来根据这个属性来找到所有的Config类,注册到ConfigManager里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public void RegisterAllConfig()
{
var allTypes = GetType().Assembly.GetTypes();
var regMethod = typeof(ConfigManager).GetMethod("RegisterConfigType");
if (regMethod == null) return;
foreach (var item in allTypes)
{
var attr = item.GetCustomAttribute<GameConfigAttribute>();
if (attr == null) continue;
regMethod.MakeGenericMethod(item).Invoke(ConfigManager.Instance, new object[] { attr.TableName });
}
}
public void RegisterConfigType<T>(string tableName) where T : BaseConfig, new()
{
// 使用CreateAll方法来创建该数据表的所有行表示的数据结构对象(BaseConfig)
_dictConfigData[typeof(T)] = CreateAll<T>(tableName);
}
private Dictionary<int, T> CreateAll<T>(string tableName) where T : BaseConfig, new()
{
var data = _dataSource.GetData(tableName);
if (data == null) throw new ConfigException("数据没有被正确加载", tableName);
var ret = new Dictionary<int, T>();
do
{
var item = new T();
item.InitWithData(data);
item.CustomProcess(data);
ret.Add(item.Id, item);
} while (data.Next());
data.Close();
return ret;
}

这里由于RegisterConfigType()方法需要一个泛型参数,为了用反射调用这个方法,必须用MakeGenericMethod()方法,来实现对有泛型参数的方法的调用。

之后,要调用FinishInit()方法卸载掉数据源所使用的资源,因为这时候我们已经把所有的数据读到内存里了。

1
2
3
4
5
public void FinishInit()
{
_dataSource.Uninit();
_dataSource = null;
}

4.在正式环境中使用

因为我们在第三步中就已经把数据源切换抽象成接口了,所以只需要在ConfigManager里的初始化方法中根据预编译宏区分使用的数据源就可以了(当然如果数据源更多的话可能还需要一个工厂类来获得需要的数据源对象)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ConfigManager : Singleton<ConfigManager>
{
// ...
public void Init()
{
// ...
#if UNITY_EDITOR
_dataSource = new ExcelDataSource();
#else
_dataSource = new BinaryDataSource();
#endif
// ...
}
// ...
}

这一步主要介绍二进制文件的格式以及导入导出。

导表

终于需要导表了。在之前的做法里,我采用的是sqlite3数据库,但是sqlite是用c实现的,这样就必须涉及到native plugin,我还是想尽量使用纯C#(符合.Net Standard以保证平台无关)来实现所有的功能(当然,其实是因为我用il2cpp打包总是挂在链接阶段,提示找不到sqlite相关的方法,好像并没有把native的dll链接进去,所以就干脆不用了233)。

这里用到的是我自定义的二进制文件,大致的结构如下图:

Fig.4

加了一大堆的Magic Number用于校验,看着很多,但其实是有规律的,除了文件头尾,其余所有的校验用字节都位于循环节末尾,因为循环次数(比如某个表有多少行数据)是从文件中读取的,如果这个数值乱掉了变得很大,就很有可能会分配大量的堆内存,以及很长的读取时间,所以这些校验字节是为了保证所有的循环节都是期望中的操作。

为了配合这个数据结构,在C#里要设计一个结构一样的类,我把它叫做ConfigStructure,以及对应的子数据结构(用于表示单个表TableItem以及单个行RowItem)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
public class ConfigStructure
{
public class RowItem
{
public RowItem() { }
public RowItem(uint columnCount)
{
_columnCount = columnCount;
}
/// 方便起见,所有的数据均以字符串存储
public List<string> Fields { get; } = new List<string>();
private readonly uint _columnCount;
public int Write(BinaryWriter writer)
{
var byteCount = 0;
foreach (var field in Fields)
{
byteCount += ConfigStructureUtils.WriteString(writer, field);
}
return byteCount;
}
} // end of RowItem
public class TableItem
{
public static readonly byte[] TableHead = { 0x7a, 0xb1, 0xe4 };
/// 行分隔符
public static readonly byte[] RowSep = { 0x80, 0x35, 0xed };
/// 列(首行字段)分隔符
public static readonly byte ColSep = 0xc5;
public string TableName { get; set; }
public List<string> ColumnName { get; } = new List<string>();
public List<RowItem> RowData { get; } = new List<RowItem>();
public uint RowCount { get; set; }
/// 表序号,从0开始
public ushort TableIdx { get; set; }
public ushort ColumnCount { get; set; }
public int Write(BinaryWriter writer)
{
var byteCount = 0;
writer.Write(TableHead); // 校验头
byteCount += TableHead.Length;
byteCount += ConfigStructureUtils.WriteString(writer, TableName);
writer.Write(ColumnCount); // 列的数量
byteCount += sizeof(ushort);
foreach (var item in ColumnName)
{
// 各个列的名称
byteCount += ConfigStructureUtils.WriteString(writer, item);
writer.Write(ColSep);
byteCount += sizeof(byte);
}
writer.Write(RowCount); // 行的数量
byteCount += sizeof(uint);
for (var i = 0; i < RowCount; i++)
{
// 各个行的数据
byteCount += RowData[i].Write(writer);
writer.Write(RowSep);
byteCount += RowSep.Length;
}
return byteCount;
}
} // end of TableItem
public static readonly byte[] MagicHead = { 0xc0, 0x2f, 0x19 };
public static readonly byte[] MagicEnd = { 0xe2, 0xd0, 0xfc };
/// 表分隔符
public static readonly byte[] TableSeparator = { 0x3e, 0x9e, 0x2a };
public List<TableItem> TableList { get; } = new List<TableItem>();
public ushort TableCount { get; set; }
public int Write(BinaryWriter writer)
{
var byteCount = 0;
// 文件头
writer.Write(MagicHead);
byteCount += MagicHead.Length;
writer.Write(TableCount);
byteCount += sizeof(ushort);
for (var i = 0; i < TableCount; ++i)
{
byteCount += TableList[i].Write(writer);
writer.Write(TableSeparator);
byteCount += TableSeparator.Length;
}
// 文件尾
writer.Write(MagicEnd);
byteCount += MagicEnd.Length;
return byteCount;
}
}

可以看到每个单元(行,表,数据库)都有一个Write方法并返回一个整数。这个方法的作用是往指定的BinaryWriter里写数据,并返回这次一共写了多少字节的内容。

现在有了数据结构,接下来从Excel里读取对应的数据填充到这个ConfigStructure里面,然后用BinaryWriter创建一个新的文件,调用Write方法就可以把所有的内容写到文件里了,写完之后这个二进制文件是非常小的,如果精简掉那些校验字节,然后把整数字段优化一下(现在为了方便全存成string了),文件尺寸应该会更小。不过这毕竟只是个数据表,一般情况下不会对大小敏感的。。。

从Excel读取数据到ConfigStructure的过程和上面第三部中Excel数据源的代码是很相似的,这一步的代码就不在这堆了,不想搞得太长(不过好像已经太长了。。。)写文件的部分倒是很简单:

1
2
3
4
5
6
7
var data = new ConfigStructure();
var dstPath = Application.streamingAssetsPath + "/conf.bin";
using (var writer = new BinaryWriter(new FileStream(dstPath, FileMode.Create)))
{
var byteCount = data.Write(writer);
Logger.LogInfo(LogModule.Data, $"写入大小:{byteCount}字节");
}

导出完后就可以在我们预先设定好的路径(我用的是StreamingAssets/conf.bin)里找到导出的文件了。

Fig.5

读表

从二进制文件里读取数据,就要用到一个新的数据源了(BinaryDataSource):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
internal class BinaryDataSource : IConfigDataSource
{
// BinaryData是对应的单个表的数据
private readonly Dictionary<string, BinaryData> _data = new Dictionary<string, BinaryData>();
public void Init()
{
var src = Application.streamingAssetsPath + "/conf.bin";
if (!File.Exists(src))
{
throw new ConfigException("conf.bin does NOT exists");
}
var fs = new FileStream(src, FileMode.Open);
var reader = new BinaryReader(fs);
try
{
var binStructure = new ConfigStructure();
binStructure.Read(reader);
foreach (var item in binStructure.TableList)
{
_data.Add(item.TableName, new BinaryData(item));
}
}
catch (Exception e)
{
throw new ConfigException("读取配置表失败,msg=" + e);
}
finally
{
fs.Dispose();
reader.Dispose();
}
}
public void Uninit() { }
public IConfigData GetData(string tableName)
{
_data.TryGetValue(tableName, out var ret);
return ret;
}
}

和之前的Excel数据源不同的是,Excel数据源是在GetData里才开始读文件的,而二进制数据源是一开始就把整个二进制数据库(姑且称之为数据库)文件读到内存里了,毕竟只有一个文件嘛。然后BinaryData也就不在这里贴出完整的代码了,我们已经拿到TableItem了嘛,之后的事情就是从TableItem这个数据结构里一行一行地拿数据就是了。

值得一提的是binStructure.Read(reader);这一句,和上面的Write()方法类似,只是过程反过来了而已,二进制文件的文件结构是固定的,根据一定的规则一组几个字节地顺序读下来即可,遇到校验字节就检查一下,如果不通过就直接抛出异常。

然后回到这一步的第一段代码,你会发现已经和之前从Excel读数据已经完全一样的,其他的流程都不用动,到这里本次的所有内容就都已经介绍完啦~

优化空间

上面提到了枚举,如果枚举类型很常见,那其实需要手动写的重复性代码还是挺多的,想把这个搞成自动的。

下期预告

这次的内容就介绍完啦~~仿佛进入了高产模式(Flag)。接下来的话就先不扯框架相关的东西了,准备准备正儿八经做游戏吧,お楽しみに~