> 技术文档 > Unity 配置二进制化(Protobuf):轻量化加载与高效管理_unity git 二进制

Unity 配置二进制化(Protobuf):轻量化加载与高效管理_unity git 二进制


1. 前言

在Unity 项目中,我们常常需要灵活地管理配置数据,如资源路径、关卡参数等。以往我们会将配置写成 JSON 或 XML,运行时再读取并解析。随着项目规模的增长,这种方式会存在:

  • 文本文件体积大、加载慢

  • 配置结构不够严谨,易出错

  • 运行时反序列化开销大

在大型项目中,配置数据往往以 JSON 存储,但随着数据量增长,解析开销和包体积也急剧上升。本文将通过一个将 Excel 表格转为 Protobuf 二进制并通过 Resources.Load 加载的完整流程示例,实现 50%+ 的包体积压缩和近 3 倍 的加载速度提升。

示例:这是同样数据转换json跟二进制,大小相差68.3%

2. 需求分析

  1. 配置集中管理:所有资源路径写在一个配置文件中。

  2. 二进制序列化:将配置文件转换为二进制,以减少文件体积并加快加载。

  3. Runtime 加载:使用 Unity 的 Resources.Load 或 Addressables 接口,读取并反序列化配置。

3. 插件Protobuf下载

直接github拉取就可以

https://github.com/protobuf-net/protobuf-net/tree/main/src/protobuf-net

3. 代码部分

这个脚本提供了将配置表转换 json文件与二进制文件两种格式的选择,json文件的插件下载我之前讲过了:Unity将表格转换为Json文件,并进行读取(保姆级教程)_unity excel转json-CSDN博客

using LitJson;using OfficeOpenXml;using ProtoBuf;using System;using System.Collections;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text;using System.Text.RegularExpressions;using UnityEditor;using UnityEngine;public class ExcelToCSharpEditor : EditorWindow{ private enum Mode { Classes, Data } private enum DataFormat { JSON, BINARY } [SerializeField] private Mode currentMode = Mode.Classes; [SerializeField] private string excelFilePath = \"\"; [SerializeField] private string outputDir = \"\"; [SerializeField] private bool prettyJson = false; [SerializeField] private DataFormat dataFormat = DataFormat.JSON; private const string PrefsExcelPathKey = \"ExcelToCSharpEditor_LastExcelPath\"; private const string PrefsOutputDirKey = \"ExcelToCSharpEditor_LastOutputDir\"; [MenuItem(\"Tools/Data/Excel To C# Classes\")] public static void ShowWindowForClasses() => ShowWindow(Mode.Classes); [MenuItem(\"Tools/Data/Excel To JSON OR BIN\")] public static void ShowWindowForData() => ShowWindow(Mode.Data); private static void ShowWindow(Mode mode) { var window = GetWindow(); window.titleContent = new GUIContent(mode == Mode.Classes ? \"Excel To C#\" : \"Excel To Data\"); window.currentMode = mode; } private void OnEnable() { // 读取上次选择的 Excel 文件路径和输出目录 excelFilePath = EditorPrefs.GetString(PrefsExcelPathKey, excelFilePath); outputDir = EditorPrefs.GetString(PrefsOutputDirKey, outputDir); } private void OnGUI() { GUILayout.Label(\"1. 选择 Excel 文件:\", EditorStyles.boldLabel); if (GUILayout.Button(\"浏览\")) { string path = EditorUtility.OpenFilePanel(\"选择 Excel 文件\", \"\", \"xlsx\"); if (!string.IsNullOrEmpty(path)) { excelFilePath = path; EditorPrefs.SetString(PrefsExcelPathKey, excelFilePath); } } EditorGUILayout.LabelField(\"已选: \", string.IsNullOrEmpty(excelFilePath) ? \"无\" : excelFilePath); GUILayout.Space(5); GUILayout.Label(\"2. 选择输出目录:\", EditorStyles.boldLabel); if (GUILayout.Button(\"选择输出目录\")) { string path = EditorUtility.OpenFolderPanel(\"选择输出目录\", \"Assets\", \"\"); if (!string.IsNullOrEmpty(path)) { outputDir = path; EditorPrefs.SetString(PrefsOutputDirKey, outputDir); } } EditorGUILayout.LabelField(\"已选: \", string.IsNullOrEmpty(outputDir) ? \"无\" : outputDir); GUILayout.Space(10); if (currentMode == Mode.Data) { GUILayout.Label(\"3. 数据格式选择:\", EditorStyles.boldLabel); dataFormat = (DataFormat)EditorGUILayout.EnumPopup(\"格式\", dataFormat); if (dataFormat == DataFormat.JSON) { prettyJson = EditorGUILayout.Toggle(\"格式化 JSON\", prettyJson); if (prettyJson)  EditorGUILayout.HelpBox(\"开启后会给导出的 JSON 添加缩进和换行,方便阅读\", MessageType.Info); } } GUILayout.FlexibleSpace(); if (GUILayout.Button(\"导出\", GUILayout.Height(30))) { if (string.IsNullOrEmpty(excelFilePath) || string.IsNullOrEmpty(outputDir)) { Debug.LogError(\"请先选择 Excel 文件和输出目录\"); } else { try {  if (currentMode == Mode.Classes)  { GenerateClassesFromExcel(excelFilePath, outputDir); Debug.Log(\"C# 类生成成功\");  }  else  { if (dataFormat == DataFormat.JSON) SaveToJson(excelFilePath, outputDir, prettyJson); else SaveToBinary(excelFilePath, outputDir); Debug.Log($\"数据文件({dataFormat})导出成功\");  }  AssetDatabase.Refresh(); } catch (Exception ex) {  Debug.LogError($\"导出出错: {ex}\"); } } } } #region Excel 数据读取 private Dictionary<string, List<Dictionary>> ReadExcelData(string path) { var result = new Dictionary<string, List<Dictionary>>(); var fi = new FileInfo(path); using var pkg = new ExcelPackage(fi); foreach (var sheet in pkg.Workbook.Worksheets) { if (sheet.Dimension == null || sheet.Dimension.End.Row < 4) continue; int cols = sheet.Dimension.End.Column; var headers = new Dictionary(); var types = new Dictionary(); for (int c = 1; c <= cols; c++) { string h = sheet.Cells[2, c].Text.Trim(); string t = sheet.Cells[3, c].Text.Trim().ToLower(); if (string.IsNullOrEmpty(h) || headers.ContainsKey(h)) continue; headers[h] = c; types[c] = t; } var rows = new List<Dictionary>(); for (int r = 4; r <= sheet.Dimension.End.Row; r++) { var rowdict = new Dictionary(); bool empty = true; foreach (var kv in headers) {  var cell = sheet.Cells[r, kv.Value];  object v = ConvertCellValue(cell, types[kv.Value]);  rowdict[kv.Key] = v;  if (!string.IsNullOrEmpty(cell.Text.Trim())) empty = false; } if (!empty) rows.Add(rowdict); } if (rows.Count > 0) result[sheet.Name] = rows; } return result; } private object ConvertCellValue(ExcelRange cell, string columnType) { string txt = cell.Text.Trim(); if (string.IsNullOrEmpty(txt)) { return columnType switch { \"int\" or \"integer\" => 0, \"float\" or \"double\" => 0m, \"bool\" or \"boolean\" => false, \"datetime\" => DateTime.MinValue, _ => string.Empty, }; } return columnType switch { \"int\" or \"integer\" => int.TryParse(txt, out var i) ? i : 0, \"float\" or \"double\" => decimal.TryParse(txt, out var d) ? d : 0m, \"bool\" or \"boolean\" => txt == \"1\", \"datetime\" => DateTime.TryParse(txt, out var dt) ? dt : DateTime.MinValue, _ => txt, }; } #endregion #region JSON 导出 private void SaveToJson(string excelPath, string outDir, bool pretty) { var data = ReadExcelData(excelPath); string json; if (pretty) { var writer = new JsonWriter { PrettyPrint = true, IndentValue = 4 }; JsonMapper.ToJson(data, writer); json = writer.ToString(); } else { json = JsonMapper.ToJson(data); } json = Regex.Unescape(json); Directory.CreateDirectory(outDir); string fp = Path.Combine(outDir, \"GameData_Json.json\"); File.WriteAllText(fp, json, new UTF8Encoding(true)); Debug.Log($\"[Exporter] JSON 文件已写入: {fp}\"); } #endregion #region Protobuf 序列化 导出 private void SaveToBinary(string excelPath, string outDir) { var raw = ReadExcelData(excelPath); var gd = new GameData(); foreach (var sheetName in raw.Keys) { var listDict = raw[sheetName]; var prop = typeof(GameData).GetProperty(sheetName); var field = typeof(GameData).GetField(sheetName); if (prop == null && field == null) { Debug.LogWarning($\"未在 GameData 中找到 {sheetName} 对应的成员,已跳过\"); continue; } Type itemType = (prop != null ? prop.PropertyType.GetGenericArguments()[0] : field.FieldType.GetGenericArguments()[0]); IList targetList = (IList)(prop != null ? prop.GetValue(gd) : field.GetValue(gd)); foreach (var row in listDict) { var inst = Activator.CreateInstance(itemType); foreach (var kv in row) {  var p = itemType.GetProperty(kv.Key);  if (p != null) p.SetValue(inst, kv.Value);  else itemType.GetField(kv.Key)?.SetValue(inst, kv.Value); } targetList.Add(inst); } } Directory.CreateDirectory(outDir); string bytesPath = Path.Combine(outDir, \"GameData.bytes\"); using (var fs = File.Create(bytesPath)) Serializer.Serialize(fs, gd); long size = new FileInfo(bytesPath).Length; Debug.Log($\"[Exporter] Protobuf 二进制文件写入成功,文件大小 = {size} 字节 ({bytesPath})\"); } #endregion #region C# 类 生成 (含 Protobuf 注解 和 namespace) private void GenerateClassesFromExcel(string excelPath, string outDir) { var fi = new FileInfo(excelPath); using var pkg = new ExcelPackage(fi); var sb = new StringBuilder(); sb.AppendLine(\"using System;\"); sb.AppendLine(\"using System.Collections.Generic;\"); sb.AppendLine(\"using ProtoBuf;\"); sb.AppendLine(); var classNames = new List(); foreach (var sheet in pkg.Workbook.Worksheets) { if (sheet.Dimension == null || sheet.Dimension.End.Row < 3) continue; string cls = SanitizeClassName(sheet.Name); classNames.Add(cls); sb.AppendLine(\"[ProtoContract]\"); sb.AppendLine($\"public class {cls}\"); sb.AppendLine(\"{\"); int cols = sheet.Dimension.End.Column; int memberIndex = 1; for (int c = 1; c <= cols; c++) { string colName = sheet.Cells[2, c].Text.Trim(); string dataType = sheet.Cells[3, c].Text.Trim(); if (string.IsNullOrEmpty(colName) || string.IsNullOrEmpty(dataType))  continue; string propName = SanitizePropertyName(colName); string csType = ConvertToCSharpType(dataType); sb.AppendLine($\" [ProtoMember({memberIndex})]\"); sb.AppendLine($\" public {csType} {propName} {{ get; set; }}\"); sb.AppendLine(); memberIndex++; } sb.AppendLine(\"}\"); sb.AppendLine(); } sb.AppendLine(\"[ProtoContract]\"); sb.AppendLine(\"public partial class GameData\"); sb.AppendLine(\"{\"); for (int i = 0; i < classNames.Count; i++) { string cls = classNames[i]; sb.AppendLine($\" [ProtoMember({i + 1})]\"); sb.AppendLine($\" public List {cls} {{ get; set; }} = new List();\"); sb.AppendLine(); } sb.AppendLine(\"}\"); string outFile = Path.Combine(outDir, \"GameDataTable.cs\"); Directory.CreateDirectory(Path.GetDirectoryName(outFile)); File.WriteAllText(outFile, sb.ToString(), Encoding.UTF8); Debug.Log($\"[Exporter] C# + Protobuf 文件写入: {outFile}\"); } #endregion private string SanitizeClassName(string name) { var s = Regex.Replace(name, \"[^\\\\w]\", \"\"); if (string.IsNullOrEmpty(s) || char.IsDigit(s[0])) s = \"_\" + s; return s; } private string SanitizePropertyName(string name) { var s = Regex.Replace(name, \"[^\\\\w]\", \"\"); if (string.IsNullOrEmpty(s) || char.IsDigit(s[0])) s = \"_\" + s; return s; } private string ConvertToCSharpType(string dataType) { return dataType.ToLower() switch { \"int\" or \"integer\" => \"int\", \"float\" or \"double\" => \"decimal\", \"bool\" or \"boolean\" => \"bool\", \"datetime\" => \"DateTime\", _ => \"string\", }; }}

4. 如何使用及功能解释

1.Excel To C# Classes

自动化的将表格的数据转换为C#脚本类

例如:

2.Excel To JSON OR BIN

将表格转换为json数据或者二进制

5.序列化跟查看管理器(ConfigManager)

我这里的ConfigManager使用的单例管理器,小伙伴如果没有的话,可以直接用Awake或者改为静态类就是可以的。

大体介绍一下这个脚本的作用:

UseBinary:是否是二进制orJson解析

Init:初始序列化(一般在游戏开始时调用,然后他会将数据反射,存入字典中)

GetById:根据ID查找查询

using LitJson;using ProtoBuf;using System;using System.Collections;using System.Collections.Generic;using System.Linq;using System.Reflection;using UnityEngine;public class ConfigManager : SingletonPatternBase{ [Header(\"数据格式切换\")] [Tooltip(\"勾选后优先从 .bytes (Protobuf) 解析;否则走 JSON 解析\")] public bool UseBinary = true; private GameData _gameData; private readonly Dictionary _listCache = new(); private readonly Dictionary _intIdCache = new(); public void Init(TextAsset asset) { Debug.Log($\"ConfigManager: 开始初始化 (UseBinary={UseBinary})\"); var ta=asset; // 1. 反序列化 JSON 或 Protobuf if (UseBinary) { if (ta == null) { Debug.LogError(\"ConfigManager: 找不到 GameData.bytes\"); return; } Debug.Log($\"[Runtime] Loaded bytes length = {ta.bytes.Length}\"); try { using (var ms = new System.IO.MemoryStream(ta.bytes)) {  _gameData = Serializer.Deserialize(ms); } Debug.Log(  $\"[Runtime] Protobuf 反序列化后 SceneIn = {_gameData.SceneIn.Count}, Keys = {_gameData.Keys.Count}\"); } catch (Exception e) { Debug.LogError($\"ConfigManager: Protobuf 反序列化失败:{e}\"); return; } } else { if (ta == null) { Debug.LogError(\"ConfigManager: 找不到 GameData_Json.json\"); return; } Debug.Log($\"[Runtime] Loaded JSON length = {ta.text.Length}\"); try { _gameData = JsonMapper.ToObject(ta.text); Debug.Log($\"[Runtime] JSON 反序列化后 SceneIn = {_gameData.SceneIn.Count}, Keys = {_gameData.Keys.Count}\"); } catch (Exception e) { Debug.LogError($\"ConfigManager: JSON 反序列化失败:{e}\"); return; } } // 2. 构建缓存与索引 BuildCaches(); } private void BuildCaches() { _listCache.Clear(); _intIdCache.Clear(); int tableCount = 0; Type dataType = typeof(GameData); // 支持 public 属性 和 public 字段 IEnumerable members = new List() .Concat(dataType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) .Concat(dataType.GetFields(BindingFlags.Public | BindingFlags.Instance)); foreach (var m in members) { Type memberType; object value; if (m is PropertyInfo pi) { memberType = pi.PropertyType; value = pi.GetValue(_gameData); } else if (m is FieldInfo fi) { memberType = fi.FieldType; value = fi.GetValue(_gameData); } else continue; // 只处理 List if (!memberType.IsGenericType || memberType.GetGenericTypeDefinition() != typeof(List)) continue; var list = value as IList; if (list == null) continue; Type itemType = memberType.GetGenericArguments()[0]; _listCache[itemType] = list; tableCount++; // —— 先找 public 属性 Id,再找 public 字段 Id —— var idProp = itemType.GetProperty(\"Id\", BindingFlags.Public | BindingFlags.Instance) ?? itemType.GetProperty(\"id\", BindingFlags.Public | BindingFlags.Instance); var idField = itemType.GetField(\"Id\", BindingFlags.Public | BindingFlags.Instance) ?? itemType.GetField(\"id\", BindingFlags.Public | BindingFlags.Instance); if (idProp != null && idProp.PropertyType == typeof(int)) { CreateIntIndex(itemType, list, o => (int)idProp.GetValue(o)); } else if (idField != null && idField.FieldType == typeof(int)) { CreateIntIndex(itemType, list, o => (int)idField.GetValue(o)); } else { Debug.LogWarning($\"ConfigManager: 类型 `{itemType.Name}` 没有整型 Id 字段或属性,跳过索引\"); } } Debug.Log($\"ConfigManager: 初始化完成,共处理 {tableCount} 张表\"); } // 辅助:根据 keySelector 构建 Dictionary private void CreateIntIndex(Type itemType, IList list, Func keySelector) { Type dictT = typeof(Dictionary).MakeGenericType(typeof(int), itemType); var dict = Activator.CreateInstance(dictT) as IDictionary; int count = 0; foreach (var obj in list) { int key = keySelector(obj); if (!dict.Contains(key)) { dict.Add(key, obj); count++; } } _intIdCache[itemType] = dict; Debug.Log($\"ConfigManager: `{itemType.Name}` 索引 {count} 条\"); } ///  获取整张表  public List GetList() { if (_listCache.TryGetValue(typeof(T), out var list)) return (List)list; Debug.LogError($\"ConfigManager.GetList: 未找到表 `{typeof(T).Name}`\"); return null; } ///  按整型 Id 查询  public T GetById(int id) { if (_intIdCache.TryGetValue(typeof(T), out var dictObj) && dictObj is Dictionary dict && dict.TryGetValue(id, out var val)) { return val; } return default; }}

6.效果演示

我建议大家都加上int的Id,这样才好方便查询