2025-05-14 Unity 网络基础13——Protobuf

发布于:2025-05-15 ⋅ 阅读:(13) ⋅ 点赞:(0)

1 Protobuf

​ Protobuf 全称为 protocol-buffers(协议缓冲区),是谷歌提供给开发者的开源的协议生成工具。

​ Protobuf 可以基于协议配置文件生成 C++、Java、C#、Objective-C、PHP、Python、Ruby、Go 等语言的代码文件,通用性强、稳定性高,可以节约出开发自定义协议工具的时间,是商业游戏开发中常选择的协议生成工具。

​ protocol-buffers 官网:https://developers.google.com/protocol-buffers

1.1 导入 Protobuf

  1. 进入官网,左侧选择“Downloads”页面,进入“release page”。

    image-20250514003826165
  2. 网页最下方选择“protobuf-30.2.zip”下载。

    image-20250514003920681
  3. 下载并解压后,进入“\protobuf-30.2\csharp\src”目录下,找到“Google.Protobuf.sln”解决方案并打开。使用 Visual Studio 或者 Rider 都可。

    image-20250514004049190
  4. 以 Rider 为例,右键“Google.Protobuf”项目,点击“构建所选项目”。

    image-20250514004331118
  5. 进入“\protobuf-30.2\csharp\src\Google.Protobuf\bin\Debug”目录,可看到多个版本的构建文件。这里选择 net45,点击进入。

    image-20250514004509181
  6. 在 Unity 中创建“Plugins/Protobuf ”录,将 net45 目录下所有 dll 文件拷贝至其中。等待 Unity 编译完成后,导入 Protobuf 成功。

    image-20250514004716908

1.2 导入编译器

  1. 下载对应操作系统的“protoc.zip”文件。

    image-20250514004858259
  2. 解压后,找到“\protoc-30.2-win64\bin”目录下的“protoc.exe”文件。

    image-20250514005104305
  3. 在 Unity 项目路径下创建文件夹 Protobuf,将“protoc.exe”文件复制到该文件夹中。

    image-20250514005232464

2 配置规则

​ Protobuf 中配置文件的后缀统一使用 .proto,可以通过多个后缀为 .proto 的配置文件进行配置。

​ 在 Unity 的 Assets 文件夹下创建文件夹 Protobuf,在该目录下创建 test.txt 文件,将其后缀名更改为 .proto 并打开。

​ 在 Rider 中,可安装插件“Protobuf”对 .proto 文件提供高亮和智能提示功能。

​ 注意,需要先禁用自带的“Protocol Buffers”插件。

image-20250514022512467

2.1 注释

​ 注释:支持///* */

// 规则1:注释方式
// 注释方式一
/* 注释方式二 */

2.2 版本号

​ 版本声明(必须位于首行)。

syntax = "proto3"; // 默认为 proto2

2.3 命名空间

package GamePlayerTest; // 这决定了命名空间

2.4 消息类

message TestMsg{
    ...
}

​ 成员声明方式:数据类型 字段名 = 唯一编号;

2.4.1 float / double

// = 1 不代表默认值 而是代表唯一编号 方便我们进行序列化和反序列化的处理
float testF = 1; // C# - float

double testD = 2; // C# - double

2.4.2 int/sint/uint

  • int:使用可变长度编码。对负数编码效率低下。
  • sint:使用可变长度编码。Signed int 值。这些比常规 int 更有效地编码负数。
  • uint:使用可变长度编码。

​ 变长编码会根据数字的大小使用对应的字节数来存储,如 1、2、4 个字节。是 Protobuf 实现的优化部分,可以尽量少的使用字节数来存储内容。

// 变长编码
// 1 2 4 8
int32 testInt32 = 3; // C# - int 它不太适用于来表示负数 请使用 sint32
int64 testInt64 = 4; // C# - long  它不太适用于来表示负数 请使用 sint64

// 更实用与表示负数类型的整数
sint32 testSInt32 = 5; // C# - int 适用于来表示负数的整数
sint64 testSInt64 = 6; // C# - long 适用于来表示负数的整数

// 无符号 变长编码
// 1 2 4
uint32 testUInt = 7; // C# - uint 变长的编码
uint64 testULong = 8; // C# - ulong 变长的编码

2.4.3 fixed/sfixed

  • fixed:始终为 4/8 个字节。如果值通常较大,则比 uint 更高效。
  • sfixed:始终为 4/8 个字节。
// 固定字节数的类型
fixed32 testFixed32 = 9; // C# -uint 它通常用来表示大于2的28次方的数 ,比uint32更有效 始终是4个字节
fixed64 testFixed64 = 10; // C# -ulong 它通常用来表示大于2的56次方的数 ,比uint64更有效 始终是8个字节

sfixed32 testSFixed32 = 11; // C# - int 始终4个字节
sfixed64 testSFixed64 = 12; // C# - long 始终8个字节

2.4.4 bool/string/bytes

  • bool:布尔值。
  • string:字符串必须始终包含 UTF-8 编码或 7 位 ASCII 文本,并且长度不能超过 232
  • bytes:可以包含任何不超过 232 的任意字节序列。
// 其它类型
bool testBool = 13; // C# - bool 
string testStr = 14; // C# - string
bytes testBytes = 15; // C# - BytesString 字节字符串

2.5 特殊标识

2.5.1 required

  • 必须赋值(proto2 特有,proto3 已移除)。
// required 必须赋值的字段
required float testF = 1; //C# - float

2.5.2 optional

  • 可以不赋值的字段(proto3 默认)。

    字段处于以下两种可能的状态之一:

    • 该字段已设置,并包含从连线显式设置或解析的值。它将被序列化。
    • 该字段未设置,并将返回默认值。它不会被序列化。
// optional 可以不赋值的字段
optional double testD = 2; //C# - double

2.5.3 repeated

  • 可重复字段(数组)。
// 数组List
repeated int32 listInt = 16; // C# - 类似List<int>的使用

2.5.4 map

  • 成对的键/值字段类型。
// 字典 Dictionary
map<int32, string> testMap = 17; // C# - 类似Dictionary<int, string> 的使用

2.6 枚举

// 枚举的声明
enum TestEnum {
    NORMAL = 0; // 第一个常量必须映射到 0
    BOSS = 5;
}

2.7 默认值

类型 默认值
string 空字符串
bytes 空字节
bool false
数值类型 0
message 取决于语言,C# 为空
enum 0

2.8 允许嵌套

​ 嵌套一个类在另一个类当中,相当于是内部类。

message TestMsg{

    message TestMsg3 {
        int32 testInt32 = 1;
    }

    enum TestEnum2 {
        NORMAL = 0; // 第一个常量必须映射到 0
        BOSS = 1;
    }

    TestMsg3 testMsg3 = 20;

    TestEnum2 testEnum2 = 21;
}

2.9 保留字段

​ 如果修改了协议规则,删除了部分内容,为了避免更新时重新使用已经删除了的编号,可以利用 reserved 关键字来保留字段,这些内容就不能再被使用了。

// int32 testInt3233333 = 22;

// 告诉编译器 22 被占用 不准用户使用
// 之所以有这个功能 是为了在版本不匹配时 
// 反序列化不会出现结构不统一 解析错误的问题
reserved 22;

reserved testInt3233333;

message Foo {
    reserved 2, 15, 9 to 11;
    reserved "foo", "bar";
}

​ reserved 仅影响 protoc 编译器行为,运行时 JSON 解析不受保留名称的影响。

2.10 导入定义

​ 在 Protobuf 文件夹下创建 test2.proto 文件,并写入如下内容:

syntax = "proto3";

// 规则 3:命名空间
package GamePlayerTest;

// 这决定了命名空间

message HeartMsg {
    int64 time = 1;
}

​ 在 test.proto 中即可导入 test2.proto:

syntax = "proto3";

// 规则 2:版本号

// 规则 1:注释方式
// 注释方式一
/* 注释方式二 */

// 规则 11:导入定义
import "test2.proto";

// 规则 3:命名空间
package GamePlayerTest;

// 这决定了命名空间


// 规则 4:消息类
message TestMsg {
    ...
}

附:完整 .proto 代码

test.proto

syntax = "proto3";

// 规则 2:版本号

// 规则 1:注释方式
// 注释方式一
/* 注释方式二 */

// 规则 11:导入定义
import "test2.proto";

// 规则 3:命名空间
package GamePlayerTest;

// 这决定了命名空间


// 规则 4:消息类
message TestMsg {
    // 浮点数
    // = 1 不代表默认值 而是代表唯一编号 方便我们进行序列化和反序列化的处理
    // required 必须赋值的字段
    float testF = 1; // C# - float
    // optional 可以不赋值的字段
    optional double testD = 2; // C# - double

    // 变长编码
    // 所谓变长 就是会根据 数字的大小 来使用对应的字节数来存储  1 2 4 
    // Protobuf 帮助我们优化的部分 可以尽量少的使用字节数 来存储内容
    int32 testInt32 = 3; // C# - int 它不太适用于来表示负数 请使用 sint32

    // 1 2 4 8
    int64 testInt64 = 4; // C# - long  它不太适用于来表示负数 请使用 sint64

    // 更实用与表示负数类型的整数
    sint32 testSInt32 = 5; // C# - int 适用于来表示负数的整数
    sint64 testSInt64 = 6; // C# - long 适用于来表示负数的整数

    // 无符号 变长编码
    // 1 2 4
    uint32 testUInt = 7; // C# - uint 变长的编码
    uint64 testULong = 8; // C# - ulong 变长的编码

    // 固定字节数的类型
    fixed32 testFixed32 = 9; // C# -uint 它通常用来表示大于2的28次方的数 ,比uint32更有效 始终是4个字节
    fixed64 testFixed64 = 10; // C# -ulong 它通常用来表示大于2的56次方的数 ,比uint64更有效 始终是8个字节

    sfixed32 testSFixed32 = 11; // C# - int 始终4个字节
    sfixed64 testSFixed64 = 12; // C# - long 始终8个字节

    // 其它类型
    bool testBool = 13; // C# - bool 
    string testStr = 14; // C# - string
    bytes testBytes = 15; // C# - BytesString 字节字符串

    // 数组List
    repeated int32 listInt = 16; // C# - 类似List<int>的使用

    // 字典 Dictionary
    map<int32, string> testMap = 17; // C# - 类似Dictionary<int, string> 的使用

    // 枚举成员变量的声明 需要唯一编码
    TestEnum testEnum = 18;

    // 声明自定义类对象 需要唯一编码
    // 默认值是null
    TestMsg2 testMsg2 = 19;

    // 规则 8:允许嵌套
    // 嵌套一个类在另一个类当中 相当于是内部类
    message TestMsg3 {
        int32 testInt32 = 1;
    }

    TestMsg3 testMsg3 = 20;

    // 规则 9:允许嵌套
    enum TestEnum2 {
        NORMAL = 0; // 第一个常量必须映射到 0
        BOSS = 1;
    }

    TestEnum2 testEnum2 = 21;

    // int32 testInt3233333 = 22;

    bool testBool2123123 = 23;

    GamePlayerTest.HeartMsg testHeart = 24;

    // 告诉编译器 22 被占用 不准用户使用
    // 之所以有这个功能 是为了在版本不匹配时 
    // 反序列化不会出现结构不统一 解析错误的问题
    reserved 22;
//    reserved testInt3233333;
}

// 枚举的声明
enum TestEnum {
    NORMAL = 0; // 第一个常量必须映射到 0
    BOSS = 5;
}

message TestMsg2 {
    int32 testInt32 = 1;
}

test2.proto

syntax = "proto3";

// 规则 3:命名空间
package GamePlayerTest;

// 这决定了命名空间

message HeartMsg {
    int64 time = 1;
}

3 生成 C# 代码

  1. 在 Protobuf 目录下创建文件夹 CodeGen,用于存放生成的 C# 代码。

  2. 打开 Powershell 窗口,进入 protoc.exe 所在文件夹,输入转换指令生成 C# 代码。

    protoc -I="配置路径" --csharp_out="输出路径" 配置文件名

    protoc -I="你的项目路径\Assets\Protobuf" --csharp_out="你的项目路径\Assets\Protobuf\CodeGen" test.proto
    
    image-20250514022827278

    注意

    ​ 路径不要有中文和特殊符号,避免生成失败。

  3. 在 Unity 中引入生成的 .cs 文件,通过 Google.Protobuf 命名空间使用。

    using UnityEngine;
    
    namespace Lesson
    {
        using GamePlayerTest;
    
        public class Lesson40 : MonoBehaviour
        {
            private void Start()
            {
                var msg = new TestMsg();
                msg.TestBool = true;
    
                // 对应的和List以及Dictionary使用方式一样的 数组和字典对象
                msg.ListInt.Add(1);
                print(msg.ListInt[0]);
                msg.TestMap.Add(1, "xxx");
                print(msg.TestMap[1]);
    
                // 枚举
                msg.TestEnum = TestEnum.Boss;
    
                // 内部枚举
                msg.TestEnum2 = TestMsg.Types.TestEnum2.Boss;
    
                // 其它类对象
                msg.TestMsg2           = new TestMsg2();
                msg.TestMsg2.TestInt32 = 99;
    
                // 其它内部类对象
                msg.TestMsg3           = new TestMsg.Types.TestMsg3();
                msg.TestMsg3.TestInt32 = 55;
    
                // 在另一个生成的脚本当中的类 如果命名空间不同 需要命名空间点出来使用
                msg.TestHeart = new GamePlayerTest.HeartMsg();
            }
        }
    }
    

4 使用 Protobuf

4.1 序列化

​ 先向 TestMsg 中写入一些内容,然后序列化为二进制文件。

  • 使用方法
    • msg.WriteTo(fs)
    • msg.ToByteArray()
using UnityEngine;

namespace Lesson
{
    using System.IO;
    using GamePlayerTest;
    using Google.Protobuf;

    public class Lesson41 : MonoBehaviour
    {
        private void Start()
        {
            // 主要使用
            // 1.生成的类中的 WriteTo方法
            // 2.文件流FileStream对象
            var msg = new TestMsg();
            msg.ListInt.Add(1);
            msg.TestBool  = false;
            msg.TestD     = 5.5;
            msg.TestInt32 = 99;
            msg.TestMap.Add(1, "xxx");
            msg.TestMsg2           = new TestMsg2();
            msg.TestMsg2.TestInt32 = 88;
            msg.TestMsg3           = new TestMsg.Types.TestMsg3();
            msg.TestMsg3.TestInt32 = 66;

            msg.TestHeart      = new GamePlayerTest.HeartMsg();
            msg.TestHeart.Time = 7777;

            print(Application.persistentDataPath);
            using (FileStream fs = File.Create(Application.persistentDataPath + "/TestMsg.bytes"))
            {
                // 需要 using Google.Protobuf; 才不会报错
                // Google.Protobuf 中会调用扩展方法进行隐式转换
                msg.WriteTo(fs);
            }
        }
    }
}

​ 将上述代码挂载到 Unity 场景中并运行后,在 Application.persistentDataPath 目录下可看到序列化文件。

image-20250514024353114

​ 在网络传输中,使用内存流进行传输。

using UnityEngine;

namespace Lesson
{
    using System.IO;
    using GamePlayerTest;
    using Google.Protobuf;

    public class Lesson41 : MonoBehaviour
    {
        private void Start()
        {
            // 主要使用
            // 1.生成的类中的 WriteTo方法
            // 2.内存流MemoryStream对象

            var msg = new TestMsg();
            
            ...

            using (var ms = new MemoryStream())
            {
                msg.WriteTo(ms);
                
                // var bytes = msg.ToByteArray();
                var bytes = ms.ToArray();
                
                print("字节数组长度:" + bytes.Length);
            }
        }
    }
}

4.2 反序列化

​ 将本地二进制文件反序列化为 TestMsg 对象。

  • 使用方法:TestMsg.Parser.ParseFrom(fs)
using UnityEngine;

namespace Lesson
{
    using System.IO;
    using GamePlayerTest;
    using Google.Protobuf;

    public class Lesson41 : MonoBehaviour
    {
        private void Start()
        {
            // 主要使用
            // 1.生成的类中的 Parser.ParseFrom方法
            // 2.文件流FileStream对象
            using (FileStream fs = File.OpenRead(Application.persistentDataPath + "/TestMsg.bytes"))
            {
                TestMsg msg2 = null;
                msg2 = TestMsg.Parser.ParseFrom(fs);
                print(msg2.TestMap[1]);
                print(msg2.ListInt[0]);
                print(msg2.TestD);
                print(msg2.TestMsg2.TestInt32);
                print(msg2.TestMsg3.TestInt32);
                print(msg2.TestHeart.Time);
            }
        }
    }
}

​ 将上述代码挂载到 Unity 场景中并运行后,结果如下。

image-20250514024611411

​ 在网络传输中,使用内存流进行传输。

using UnityEngine;

namespace Lesson
{
    using System.IO;
    using GamePlayerTest;
    using Google.Protobuf;

    public class Lesson41 : MonoBehaviour
    {
        private void Start()
        {
            byte[] bytes;
            
            ...
            
            // 主要使用
            // 1.生成的类中的 Parser.ParseFrom方法
            // 2.内存流MemoryStream对象
            using (var ms = new MemoryStream(bytes))
            {
                print("内存流当中反序列化的内容");
                TestMsg msg2 = TestMsg.Parser.ParseFrom(ms);
                print(msg2.TestMap[1]);
                print(msg2.ListInt[0]);
                print(msg2.TestD);
                print(msg2.TestMsg2.TestInt32);
                print(msg2.TestMsg3.TestInt32);
                print(msg2.TestHeart.Time);
            }
        }
    }
}

5 Protobuf-Net

​ 早期的 Protobuf 并不支持 C#,国外大佬 Marc Gravell 在 Protobuf 基础上进行 .Net 环境下的移植,并发布到 GitHub。

与官方 Protobuf 的区别

特性 Google.Protobuf Protobuf-net
开发语言 多语言支持 专为 .NET 优化
API 设计 基于代码生成 支持属性标记+动态序列化
Unity 兼容性 需新版本 Unity 支持老版本 Unity
性能优势 通用性强 GC 优化更好,IL 动态生成代码

注意

  1. Protobuf 不支持 .Net3.5 及以下版本。

    如果想在 Unity 老版本中使用 Protobuf,只能使用 Protobuf-Net。

    在较新版本的 Unity 中不存在这个问题。

  2. 如何判断是否支持?

    把 Protobuf 相关 dll 包导入后能够正常使用不报错,则证明支持。

​ Protobuf-Net 是较老的生产方式,用于解决老版本 Unity 使用 Protobuf 的问题,使用方式和 Protobuf 类似,只是获取 DLL 文件、protoc.exe 文件的方式不同。