MeteorCat / 游戏二进制序列化

Created Mon, 22 Jul 2024 21:18:40 +0800 Modified Wed, 29 Oct 2025 23:24:54 +0800
1178 Words

游戏二进制序列化

近些年游戏序列化工具不断迭代更新, 比较知名的是 Protobuf|MessagePack 这种序列化, 如果性能要求不高甚至 JSON|XML 这种广泛集成语言也是可以作为游戏数据载体.

但是实际在项目使用当中发现其实 Protobuf 问题也很多, 包括以下问题:

  • 项目引入不可预测的复杂性, 有时候版本可能出现冲突( ProtobufV2 和 ProtobufV3 )
  • 没办法适应性动态处理数据, 结构变动必须要对 proto 文件再编译导入游戏当中
  • 而且对于小众语言来说, 可能根本没有具体实现处理
  • 有的H5游戏上架平台对于底层数据读写功能要求很严格导致可能无法引入

特别是游戏项目, 很多都不采用外网软件库当中的项目引入而是自己内网重写相关功能, 所以对于游戏数据传输其实推荐更加采用原生二进制读写:

  • 基本上是编程语言都支持, 真正跨平台处理, 不需要额外引入别的依赖
  • 序列化过程可以自己动态处理, 不需要编译直接热更

这里采用 Java 语言做示例, 讲解怎么二进制在游戏当中怎么构建和传输.

数据结构

网络传输的数据结构一般常规以下几种:

  • byte|int8: 8位, 1字节, 一般用来替代 bool 值做 0|1 状态等
  • short|int16:16位, 2字节
  • int|int32: 32位, 4字节
  • long|int64: 64位, 8字节
  • float: 32位单精度
  • double: 64位双精度
  • bytes: 二进制, 字符串做二进制处理, 首位 int32 后面就是结构数据
  • list: 列表数据, 首位 int32 后面就是结构数据

还有比较少用到的:

  • char: 单字符
  • boolean: 布尔类型

一般原生数值类型不需要做处理, 如下直接填充就行:

public class Main {
    public static void main(String[] args) {
        // 数值类型可以直接写入二进制流, 客户端是可以直接取出的
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        buffer.putInt(1000);
        buffer.putLong(10000L);
        buffer.putShort((short) 1);
        buffer.put((byte) 1);
        buffer.flip();

        byte[] message = new byte[buffer.remaining()];
        buffer.get(message);
        System.out.println("Basic: " + Arrays.toString(message));
        // 打印内容如下:
        // Basic: [0, 0, 3, -24, 0, 0, 0, 0, 0, 0, 39, 16, 0, 1, 1]
    }
}

Java默认采用大端网络序列做二进制处理

C# 之类的游戏客户端可以直接通过协议解包获取二进制主体解析, 这些基础类型没什么可以讲解重点是复合类型:

public class Main {
    public static void main(String[] args) {
        // 复杂类型可能就需要处理下
        ByteBuffer mix = ByteBuffer.allocate(1024);

        // 常规的二进制流, 首位必须要编码int32标识长度之后再填充二进制
        byte[] bytes = new byte[]{0x03, 0x02};
        mix.putInt(bytes.length);
        mix.put(bytes);

        // 字符串其实也是变相的二进制流, 不过推荐采用 utf8处理的二进制流
        // 同样也是首位填充长度
        String msg = "Hello.World";
        byte[] msgBytes = msg.getBytes(StandardCharsets.UTF_8);
        mix.putInt(msgBytes.length); // 必须编码长度
        mix.put(msgBytes);


        // 列表是什么类型都能处理
        // 复杂类型全部都要首位 int32 + 后续内容填充
        int[] intList = new int[]{1, 2, 3, 4, 5, 6, 7, 8, 9};
        mix.putInt(intList.length);
        for (int i : intList) {
            mix.putInt(i); // 遍历写入
        }

        // 打印混合数据
        byte[] mixMessage = new byte[mix.remaining()];
        mix.get(mixMessage);
        System.out.println("Mix: " + Arrays.toString(mixMessage));
        // 打印内容如下:
        // [0, 0, 0, 2, 3, 2, 0, 0, 0, 11, 72, 101, 108, 108, 111, 46, 87, 111, 114, 108, 100, 0, 0, 0, 9, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 4, 0, 0, 0, 5, 0, 0, 0, 6, 0, 0, 0, 7, 0, 0, 0, 8, 0, 0, 0, 9]
    }
}

复合数据类型比较特殊, 因为内部长度并不是确定的, 所以需要提供标识长度给 服务端|客户端 做初始化缓冲区; 而如果想转化成结构类对象可能需要抽象实现自己的序列和反序列化功能:

public class LoginMessage extends Message {
    // 其他略

    /**
     * 读取客户端传递的二进制数据
     */
    public boolean read(ByteBuffer buf) {
        // 用户Name
        this.userId = readString(buf); // 内部 Message.readString 读取 [int32][bytes] 获取
        // 登陆令牌
        this.token = readString(buf); // 同上
        // 时间
        this.time = readLong(buf); // 直接获取 [int64] 固定类型不需要附带长度
        return true;
    }
}

这种解包方式在游戏项目当中十分常见, 对于项目侵入性低且编写方便.