跳转到内容

第 24 章 · JSON、DataDictionary 与 DataList

约 8 分钟 难度:4 动手章

进阶章。读到这里可以先去第五部,做完闭环 4 之后再回来。本章是为「数据结构复杂、不规则、变化少」的同步场景准备的工具。

这一章解决:当一份要同步的数据是「3×3 棋盘 + 每格的占领玩家 + 每格的最后落子时间 + 当前轮到谁」这种结构灵活但稳定的对象时,用 [UdonSynced] int[] 拆字段写起来累。VRCJson 把任意 DataDictionary / DataList 转成字符串,一个字段同步搞定。

先看一下

闭环 2 / 闭环 3 同步的是 5–10 个 [UdonSynced] 字段。每加一个新字段,写一行、改 OnDeserialization、改 UI。10 个字段还能管,30 个字段时维护成本上升。

VRCJsonDataDictionary 转成 JSON 字符串,单字段同步即可:

{
"board": [[0,1,0],[2,0,1],[0,0,2]],
"currentPlayer": 1,
"moveCount": 7,
"lastMoveAt": 4523.12
}

这一份数据用一个 [UdonSynced] string boardJson 同步即可。代价:字符串体量(每次约 100–500 bytes)、序列化 / 反序列化 CPU、几条硬限制(NaN / Infinity / 引用类型不能装)。

这一章会拿到什么

  • VRCJson + DataDictionary + DataList 的最小完整例子(3×3 棋盘)
  • 序列化 / 反序列化的失败模式与处理
  • JSON 字段适合 / 不适合的判断
  • 社区方案视野:UdonNetworkSyncLibrary 等 RPC 层为什么会出现

依赖前面

  • 第 4 章 manual sync 的 OnPreSerialization / OnDeserialization
  • 第 19 章状态同步五维度(JSON 通常对应「字节大、迟入要看到、改动频率低」象限)
  • 第 21 章决策表里 VRCJson 这一行

落到代码上是一个 DataDictionary 容纳:boardDataList 嵌套 DataList)+ currentPlayer(逻辑上是 int)+ moveCount(逻辑上是 int)+ lastMoveAt(double)。注意:VRCJson 反序列化后,JSON 里的数字都会变成 Double 类型,示例里用 ReadInt 做一次兼容读取。

using VRC.SDK3.Data;
[UdonSynced] private string boardJson; // 同步字段:JSON 字符串
private int ReadInt(DataToken token)
{
if (token.TokenType == TokenType.Double) return (int)token.Double;
if (token.TokenType == TokenType.Int) return token.Int;
return 0;
}
// Owner 端:每次落子调用
private void SetCell(int row, int col, int playerId)
{
var dict = ParseBoard(boardJson); // 反序列化当前
var board = dict["board"].DataList;
var rowList = board[row].DataList;
rowList.SetValue(col, new DataToken(playerId));
dict.SetValue("currentPlayer", new DataToken(NextPlayer(playerId)));
dict.SetValue("moveCount", new DataToken(ReadInt(dict["moveCount"]) + 1));
dict.SetValue("lastMoveAt", new DataToken(Networking.GetServerTimeInSeconds()));
if (VRCJson.TrySerializeToJson(dict, JsonExportType.Minify, out var s))
{
boardJson = s.String;
RequestSerialization();
}
}
// 远端:解析当前棋盘
private DataDictionary ParseBoard(string json)
{
if (string.IsNullOrEmpty(json)) return CreateEmptyBoard();
if (VRCJson.TryDeserializeFromJson(json, out var token) && token.TokenType == TokenType.DataDictionary)
return token.DataDictionary;
return CreateEmptyBoard();
}
private DataDictionary CreateEmptyBoard()
{
var dict = new DataDictionary();
var board = new DataList();
for (int r = 0; r < 3; r++)
{
var row = new DataList();
for (int c = 0; c < 3; c++) row.Add(new DataToken(0));
board.Add(new DataToken(row));
}
dict.SetValue("board", new DataToken(board));
dict.SetValue("currentPlayer", new DataToken(1));
dict.SetValue("moveCount", new DataToken(0));
dict.SetValue("lastMoveAt", new DataToken(0.0));
return dict;
}

UI 端读取:

public override void OnDeserialization()
{
var dict = ParseBoard(boardJson);
UpdateBoardUI(dict["board"].DataList);
SetTurnLabel(ReadInt(dict["currentPlayer"]));
}

整段代码量比起拆 9 个 int[] cell + 1 个 int currentPlayer + 1 个 int moveCount + 1 个 double lastMoveAt 字段并没有少。优势在加新字段时:要加一个「这一格被谁攻击过几次」的统计,朴素方案要新加一个 int[9] cellAttacks 同步字段;JSON 方案在 dict.SetValue("cellAttacks", ...) 里加一行就完事,UI 端 OnDeserialization 里读一次。

字段加到 20 个、30 个时这一灵活性显著。少于 10 个字段,朴素拆字段更直接。


VRCJson.TrySerializeToJsonTryDeserializeFromJson 都返回 bool 表示成功 / 失败。下面这些场景会失败:

失败场景表现处理
字典里有 double.NaNdouble.PositiveInfinity序列化失败NaN 替换成业务约定的「无效值」(比如 -1)
字典里有 UdonBehaviour 引用序列化失败JSON 不能装引用类型,改用 playerId / 索引
字典里有 VRCPlayerApi序列化失败同上
DataDictionary 的 key 不是 string序列化失败JSON 标准只允许 string key
反序列化时字符串格式错误TryDeserializeFromJson 返回 false回退到默认空棋盘,记一条 Debug.LogWarning
嵌套 JSON 顶层合法、内部有坏值顶层反序列化可能先返回 true,访问嵌套值时才报 UnableToParse读取嵌套值时继续用 TryGetValue / TokenType 检查
反序列化后的数字按 Int 直接读JSON number 会先落成 DoubleReadInt / ReadFloat 这类 helper 做转换
根节点不是 DataDictionary / DataList序列化失败根节点统一包一层字典或列表
字符串接近 manual sync 单次上限(官方当前约 280 KB)序列化能成,但发送会被 rate limit 拖慢,甚至失败要么压缩 JSON(用 Minify),要么拆成多个字段,要么改用字节打包(第 23 章)

写法上每次序列化都套 if (TrySerializeToJson(...)),反序列化也套 if (TryDeserializeFromJson(...))。失败时回退到默认值,并写一条警告日志。Owner 那边失败的话状态不发出去,远端就用上次的状态;远端解析失败的话本地用默认棋盘,下次成功的反序列化会覆盖。

if (VRCJson.TrySerializeToJson(dict, JsonExportType.Minify, out var token))
{
boardJson = token.String;
RequestSerialization();
}
else
{
Debug.LogWarning("[Board] serialize failed, keep last value");
}

JSON 字符串字段的字节代价 ≈ 字符串长度(UTF-8 编码,ASCII 字符 1 byte,中文 3 bytes)。

3×3 棋盘最小化 JSON 约 80 字符,序列化后 80 bytes。每次落子改 1 格,整段重发 80 bytes。比起拆 9 个 int[] cell(36 bytes)+ 几个标量(10 bytes)多一倍。

棋盘扩到 8×8(围棋小棋盘),JSON 约 250 bytes;扩到 19×19(标准围棋),约 1.4 KB。manual sync 单次上限仍然装得下,但每次落子重发 1.4 KB,按 2 秒一手算,总流量 700 bytes/s,单字段就吃掉 11 KB/s 级别总预算的 6% 左右。

判断标准:JSON 字段大小到几 KB 就要回头看是不是该用增量同步(第 23 章 dirty mask)+ 拆字段。


按第 21 章决策表,JSON 适合的象限:

  • 字节大但变化频率低。整张棋盘几 KB,每秒最多几次改动。
  • 结构复杂、字段多。10+ 个字段开始 JSON 比拆字段维护成本低。
  • 结构会演进。新版本加字段时 JSON 天然兼容,旧版本字段缺失走默认值;拆字段要改同步定义。
  • 迟入恢复要看到[UdonSynced] string 是同步字段,迟入玩家自然拿到当前 JSON。

不适合的象限:

  • 单字段几个 byte:直接 [UdonSynced] 简单类型,JSON overhead 更高。
  • 高频改动(每秒几十次):每次重发整个 JSON 撞 11 KB/s 预算。
  • 包含引用 / NaN / Infinity:序列化失败。
  • 关键性能路径(每帧更新):JSON 解析 CPU 开销不可忽略,每帧解析会卡。

UdonNetworkSyncLibrary / UNet 这类仓库是社区在「早期 SendCustomNetworkEvent 不能传复杂参数、[UdonSynced] 不能直接装复杂结构」之间造的 RPC 层。

Xytabich/UNet:README 描述它用 manual sync 的同步 byte[] 做可靠二进制数据传输、序列化、消息管理和严格顺序投递。它的默认消息大小、包大小、吞吐和往返时间都比较保守,适合作为「为什么有人会用 byte[] 自建协议」的案例,不适合作为本卷默认依赖。SDK 3.8.1 加了参数化网络事件后,简单事件传参已经有官方通道。

UdonNetworkSyncLibrary:README 描述它提供复杂参数 RPC、定向 / 广播发送、发送者识别、ping / RTT、复杂类型变量同步等能力,并依赖 UdonObjectSerializerLibrary。README 没有给出弃用声明;实际采用前仍要看 GitHub 的最近提交、issue 和 SDK 兼容状态。下面三种情况下值得考察:

  1. 同步字段类型支持矩阵不够(自定义 struct、嵌套数组)。
  2. 一个对象上要同步几十个字段,朴素 [UdonSynced] 维护累。
  3. 跨多个对象的状态分发逻辑复杂,希望有总线。

不引入它的理由:多一份依赖。本卷的所有闭环都不引入第三方 RPC 层,让 SDK 自带能力把核心问题撑住。如果做的是 Vol.4 主题(社区生态深耕),它会更有价值。



挑一条试。

  • 把 3×3 棋盘改成 8×8,跑两个客户端 Build & Test 看每次落子的字节数(OnPostSerialization 给)。8×8 的 JSON 字符串到几百 bytes,落子频率 2 秒一次,是否撞 KB/s 预算?
  • 在棋盘字典里加一个 "history": [...] 数组装最近 10 步走子。和第 22 章命令日志比,这两种方案哪个更适合棋盘场景?给出三句话理由。
  • 故意往字典里塞 dict.SetValue("badKey", new DataToken(double.NaN)),跑序列化看 TrySerializeToJson 返回什么。处理失败时如何让玩家看到一个「提交失败请重试」的提示?

场景预期实际
1. 3×3 棋盘正常落子JSON 字段同步,远端 UI 更新
2. 8×8 棋盘满载JSON 单次 < 1 KB,正常同步
3. 19×19 围棋棋盘JSON 约 1.4 KB,正常同步但开始撞预算
4. 故意塞 NaN序列化失败,记 warning,状态保持上一帧
5. Owner 端无效 JSON远端 TryDeserialize 失败,UI 用默认空棋盘
6. Owner 转移瞬间新 Owner 拿到当前 JSON 字段,继续 SetCell

  • 能默写 VRCJson 序列化的五种失败模式
  • 能解释为什么 DataDictionary key 必须是 string
  • 能用第 21 章决策表判断「这一段同步该不该用 JSON」
  • 能说出 UdonNetworkSyncLibrary 适合 / 不适合引入的判断
  • 能默写 JSON 字段字节估算的近似公式

  • VRChat Creator Docs · Data Containers / VRCJsonVRCJson.TrySerializeToJson / TryDeserializeFromJson 的官方说明。
  • VRChat Creator Docs · DataDictionaryDataDictionary API、string key 限制、安全读取与克隆行为。
  • VRChat Creator Docs · DataListDataList API、JSON 同步建议、数组与 DataList 的取舍。
  • GitHub · Xytabich/UNet — manual sync byte[] 消息系统案例。
  • GitHub · lizhirui/UdonNetworkSyncLibrary — 复杂参数 RPC 与复杂类型同步库案例。
  • 第 19 章 · 状态同步还是命令同步 — 「字节大、改动频率低、迟入要看到」象限的判断。
  • 第 21 章 · [参数�