Luban 适配 MemoryPack
Luban 适配 MemoryPack
目标 & 背景
知道 CySharp 的 MemoryPack 序列化工具已经有段时间了,最近几天刚好有时间,准备给 Luban 增加 MemoryPack 相关的适配,这次适配我期望达到如下目标
- 以 Plugin 的形式对 Luban 进行扩展
- 使用 MemoryPackWriter 模拟二进制写入过程,不借助其他格式二次生成
- Demo 要 同时兼容 Json/MemoryPack 两种格式,由开发者自行选择
MemoryPack 序列化过程
为了摸清楚 MP 的序列化过程,着实花了一些功夫,MP 的源码中,各种 SourceGen、interface 静态接口等特性,导致花了很多时间,后面我就省略过程,直接说结论
如果你也希望自行 Debug,建议在 Unity 中进行调试,会比 dotnet 版本的省事一些
基础类型
如 int, bool, float 等,这些基础类型在写入时,非常简单,只需要调用 WriteUnmanaged 方法即可,但如果是 string 则需要调用 WriteString 方法
writer.WriteUnmanaged(1);
writer.WriteUnmanaged(true);
writer.WriteString("xxx");
Pack 对象
如 xxx class,与之对应的是 Luban 中的 Bean,在处理这种类型时,需要判断当前类是否为 abstract,并增加对应的 UnionHeader,在此基础上增加当前类有多少个字段 ObjectHeader
if(obj is null)
{
if(obj is abstract type)
{
writer.WriteNullUnionHeader();
}
else
{
writer.WriteNullObjectHeader();
}
return;
}
if(obj is abstract type)
{
writer.WriteUnionHeader(tag index);
}
writer.WriteObjectHeader(property count);
集合
如 list, map, array 等,在序列化时,需要判断是否为空,并追加 CollectionHeader,下面以 list 为例
if(list.Count <= 0)
{
writer.WriteNullCollectionHeader();
return;
}
writer.WriteCollectionHeader(list.Count);
对于 map 类型,只需要在循环中先写入 key,再写入 value 即可
foreach(var pair in map)
{
write(pair.key);
write(pair.value);
}
可空类型
在 Luban 中,如果直接给字段增加 ? 此时就变成了 nullable 类型,在处理 unmanaged nullable 类型时需要额外注意,他们的实际大小是正常的两倍
比如,enum 和 enum? 实际大小分别是 4 和 8,int 和 int? 分别是 4 和 8
但是 MemoryPack 并没有提供直接写入 nullable 类型的接口,必须是 unmanaged,因此在处理时,最简单的做法就是写两遍
if(field.IsNullable)
{
writer.WriteUnmanaged(0);
}
writer.WriteUnmanaged(0);
但是在面对实际为 null 的字符串时,需要做的是写入 NullCollectionHeader,但如果是 string.Empty 则只需要正常调用 WriteString
if(xxxstring is null)
{
writer.WriteNullCollectionHeader();
return;
}
writer.WriteString(xxxstring);
MemoryPack 注意事项
Source Generator 生成规则
MemoryPack 借助于 Source Generator 的代码生成,实现了 Reflection-free,最开始使用时,由于惯性思维,误以为任何标记为 MemoryPackOrder 的字段都会被接管,但实际并不是
[MemoryPackable]
public partial class Test
{
[MemoryPackOrder(0)]
public int value1;
[MemoryPackOrder(1)]
private int value2;
}
这里的 value2 并不会参与 Source Generator 的代码生成,在我使用 json 序列化时,偶尔会使用下方代码中的方式来隐藏一些业务的调用过程
这里将需要翻译的字段用一个类隐藏掉,实际业务调用时感受不到 TranslateText 这个类的存在
public partial class Test
{
[JsonIgnore]
public string name => _name.text;
[JsonProperty("name")]
private TranslateText _name;
}
针对这些类似的场景,MemoryPack 通过构造函数作为 work around,这样即使字段是 private set 或者 readonly 也依然没有问题,因此需要在代码生成模板中追加构造函数的实现
Union 类型
在处理集成关系时,我们需要在基类上追加 MemoryPackUnion 来标记序列化的类型,这里以 AShape 为例
[MemoryPackable]
[MemoryPackUnion(0, typeof(Triangle))]
[MemoryPackUnion(1, typeof(Circle))]
[MemoryPackUnion(2, typeof(Rectangle))]
public abstract partial class AShape
{
[MemoryPackConstructor]
public AShape()
{
}
}
Luban 适配
搞清楚 MemoryPack 的实际工作流程,适配工作就变的非常简单了,我们需要接管 CodeTarget 和 DataTarget 两个过程,一个负责生成 cs 文件,另一个负责生成序列化后的 bytes 文件
CodeTarget
这里要做的内容实际非常少,挂一个 CodeTarget 指明当前为 cs-memorypack 类型,并创建 CsharpMemoryPackTemplateExtension 来接管 scriban 中的一些自定义函数
[CodeTarget("cs-memorypack")]
public class CsharpMemoryPackCodeTarget : CsharpCodeTargetBase
{
protected override void OnCreateTemplateContext(TemplateContext ctx)
{
base.OnCreateTemplateContext(ctx);
ctx.PushGlobal(new CsharpMemoryPackTemplateExtension());
}
}
DataTarget
同样的这里需要使用 DataTarget 标记 memorypack 作为数据的输出字段
[DataTarget("memorypack")]
public class MemoryPackDataTarget : DataTargetBase
{
xxx
}
在 Luban 的语义下,一个 table 最终会输出一个 bytes 文件,而 table 分为三种类型
- Single 表,只有一组数据
- Map 表,key 和 value 的键值对
- List 表,多主键的表
这三种类型,在我提供的默认模板中,最终接受序列化的字段并不一样
public partial class SingleCategory : ACateogry
{
public static SingleConfig Single { get; private set; }
[MemoryPackOrder(0)]
public readonly SingleConfig single;
public SingleCategory(SingleConfig single)
{
this.single = single;
}
}
public partial class MapCategory : ACateogy
{
public static MapCategoryInstance Instance { get; private set; }
[MemoryPackOrder(0)]
public readonly IReadOnlyDictionary<int, MapConfig> dic;
public MapCategory(IReadOnlyDictionary<int, MapConfig> dic)
{
this.dic = dic;
Instance = this;
}
}
public partial class ListCategory : ACategory
{
public static ListCategory Instance { get; private set; }
[MemoryPackOrder(0)]
public readonly IReadOnlyList<ListConfig> list;
// 后续初始化
private readonly Dictionay<(int, string), ListConfig> _group = new();
public ListCategory(IReadOnlyList<ListConfig> list)
{
this.list = list;
Instance = list;
}
}
在这三种类型下,都只有一个字段接受序列化,因此需要统一追加 ObjectHeader(1)
writer.WriteObjectHeader(1);
而剩下的内容就可以交给 Visitor 进行实现了
Visitor
这里的代码实际就是把 MemoryPack 序列化的规则还原一下,直接阅读 MemoryPackDataVisitor.cs 文件即可
本地化
由于本地化需要同时解决首包和热更后,本地化字段复用的问题,假设当前游戏需要适配三种语言,中文、繁体中文以及英语,在首包的本地化文件需要同时包含这三种语言,而热更包中可以根据语言的不同,选择性下载不同语言的本地化文件,json 可以非常容易的解决这种情况,但是在 MemoryPack 中解决就非常麻烦
比如下方的文件为首包的本地化文件,包含所有语言的字段
{
"确定": {
"key": "确定",
"text_cn": "确定",
"text_en": "Submit",
"text_tw": "確定"
}
}
在热更后,假设用户语言为中文,此时会下载中文的本地化文件,此时只有 text_cn 字段
{
"确定": {
"key": "确定",
"text_cn": "确定"
}
}
而实际我们在定义时,不同语言对应不同的类,借助 json 实现了两种场景的代码结构复用
[Serializable]
public abstract class ALocalizeConfig
{
public string key;
public abstract string value { get; }
}
[Serializable]
public class LocalizeConfig_CN : ALocalizeConfig
{
[JsonProperty]
public string text_cn { get; private set; }
public override string value => text_cn;
}
因此在用户指定的本地化文件夹中的 table,最终生成时,依然使用 json 作为输出格式
最后
目前暂时移除了 ref 相关的代码生成,后面有空再加回去。当前版本的序列化是根据我们实际使用的场景进行定制的,因此如果你也有使用 MemoryPack 作为配表的序列化需求,读完这篇文章建议参造源码,设计一套适合你们的流程
- 感谢你赐予我前进的力量