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 的实际工作流程,适配工作就变的非常简单了,我们需要接管 CodeTargetDataTarget 两个过程,一个负责生成 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());
    }
}

比如上文提到的 构造函数生成 以及 Union 生成

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 作为配表的序列化需求,读完这篇文章建议参造源码,设计一套适合你们的流程