第 24 章 · JSON、DataDictionary 与 DataList
进阶章。读到这里可以先去第五部,做完闭环 4 之后再回来。本章是为「数据结构复杂、不规则、变化少」的同步场景准备的工具。
这一章解决:当一份要同步的数据是「3×3 棋盘 + 每格的占领玩家 + 每格的最后落子时间 + 当前轮到谁」这种结构灵活但稳定的对象时,用
[UdonSynced] int[]拆字段写起来累。VRCJson把任意DataDictionary/DataList转成字符串,一个字段同步搞定。
先看一下
闭环 2 / 闭环 3 同步的是 5–10 个 [UdonSynced] 字段。每加一个新字段,写一行、改 OnDeserialization、改 UI。10 个字段还能管,30 个字段时维护成本上升。
VRCJson 把 DataDictionary 转成 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这一行
最小例子:3×3 棋盘
Section titled “最小例子:3×3 棋盘”落到代码上是一个 DataDictionary 容纳:board(DataList 嵌套 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.TrySerializeToJson 和 TryDeserializeFromJson 都返回 bool 表示成功 / 失败。下面这些场景会失败:
| 失败场景 | 表现 | 处理 |
|---|---|---|
字典里有 double.NaN 或 double.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 会先落成 Double | 用 ReadInt / 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)+ 拆字段。
何时用 VRCJson
Section titled “何时用 VRCJson”按第 21 章决策表,JSON 适合的象限:
- 字节大但变化频率低。整张棋盘几 KB,每秒最多几次改动。
- 结构复杂、字段多。10+ 个字段开始 JSON 比拆字段维护成本低。
- 结构会演进。新版本加字段时 JSON 天然兼容,旧版本字段缺失走默认值;拆字段要改同步定义。
- 迟入恢复要看到。
[UdonSynced] string是同步字段,迟入玩家自然拿到当前 JSON。
不适合的象限:
- 单字段几个 byte:直接
[UdonSynced]简单类型,JSON overhead 更高。 - 高频改动(每秒几十次):每次重发整个 JSON 撞 11 KB/s 预算。
- 包含引用 /
NaN/Infinity:序列化失败。 - 关键性能路径(每帧更新):JSON 解析 CPU 开销不可忽略,每帧解析会卡。
翻译节 · 社区为什么造 RPC 层
Section titled “翻译节 · 社区为什么造 RPC 层”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 兼容状态。下面三种情况下值得考察:
- 同步字段类型支持矩阵不够(自定义 struct、嵌套数组)。
- 一个对象上要同步几十个字段,朴素
[UdonSynced]维护累。 - 跨多个对象的状态分发逻辑复杂,希望有总线。
不引入它的理由:多一份依赖。本卷的所有闭环都不引入第三方 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序列化的五种失败模式 - 能解释为什么
DataDictionarykey 必须是 string - 能用第 21 章决策表判断「这一段同步该不该用 JSON」
- 能说出
UdonNetworkSyncLibrary适合 / 不适合引入的判断 - 能默写 JSON 字段字节估算的近似公式
- VRChat Creator Docs · Data Containers / VRCJson —
VRCJson.TrySerializeToJson/TryDeserializeFromJson的官方说明。 - VRChat Creator Docs · DataDictionary —
DataDictionaryAPI、string key 限制、安全读取与克隆行为。 - VRChat Creator Docs · DataList —
DataListAPI、JSON 同步建议、数组与DataList的取舍。 - GitHub · Xytabich/UNet — manual sync
byte[]消息系统案例。 - GitHub · lizhirui/UdonNetworkSyncLibrary — 复杂参数 RPC 与复杂类型同步库案例。
- 第 19 章 · 状态同步还是命令同步 — 「字节大、改动频率低、迟入要看到」象限的判断。
- 第 21 章 · [参数�