跳转到内容

第 4 章 · 手动同步的生命周期

约 6 分钟 难度:1 动手章

第 2 章和第 3 章都用过这一行:

RequestSerialization();

第 2 章里写过一句「它是请求,不是立即发送」。这一章把这句话展开。理解这条生命周期是 Vol.2 后面所有「同步失败」「同步迟到」「同步多花了一倍带宽」类问题排查的基础。


UdonBehaviour 的 Inspector 里有一个 Synchronization Method 字段,三个值各自含义不同。None 表示这个 UdonBehaviour 不参与网络,同步字段不会被同步、网络事件也发不出去。Continuous 是连续同步:Owner 端字段一变,VRChat 自动尝试发送,每次发送上限约 200 字节,单个连续同步的字符串约 126 字符。Manual 是手动同步:Owner 必须显式 RequestSerialization() 才会发送,每次发送上限约 49 KB(注意是上限,不是每帧 49 KB)。

数字以官方 Network Specs and Tips 为准。SDK 演进可能调整。

同一对象上多个 UdonBehaviour 模式不一致时,VRChat 会采用更严格的同步方式,例如 Manual 和 Continuous 混在一起时按 Manual 行为处理。VRC Object Sync 是专用同步组件,建议不要和承载大状态的 Manual 脚本挤在同一个 GameObject 上。

第二章实现 C 那种「按钮翻转 isOn」的场景默认 Continuous 就够。这一章想展开的细节,主要是 Manual 模式下的生命周期,因为 Manual 把每个时刻都暴露给了开发者。


Owner 端:

┌─────────────────────────────┐
你的代码 ─→ RequestSerialization() │
│ │
│ (等待下一个 network tick) │
↓ │
OnPreSerialization() ← 这里更新真正要发的字段
│ │
│ (VRChat 把字段打包成数据包)│
│ (通过网络发出) │
↓ │
OnPostSerialization(result) ← 报告 result.success / result.byteCount
│ │
└─────────────────────────────┘

远程客户端端:

(收到数据包)
│ (VRChat 解包,准备应用字段)
[FieldChangeCallback] 对应的 property setter
│ (网络同步或 SetProgramVariable 更新字段时触发)
│ (setter 里要自己写回 backing field)
│ (整批同步数据应用完)
OnDeserialization() ← 适合「已经全部收到,做整体处理」
OnDeserialization(DeserializationResult) ← 同上,可读发送/接收时间

四个回调,每个都对应一个具体时刻。UdonSharp 里字段级回调通过 [FieldChangeCallback(nameof(SomeProperty))] 属性绑定到字段;当网络同步或 SetProgramVariable 更新这个字段时,新值会交给对应 property 的 setter,setter 里要自己写回 backing field。这背后接近 Udon Graph 的 OnVariableChanged 事件。


OnPreSerialization:发送前的最后一刻

Section titled “OnPreSerialization:发送前的最后一刻”

OnPreSerialization 在 Owner 本地、序列化即将真正发生的那一帧触发。

OnPreSerialization 主要拿来做两类事:更新版本号或序号(stateVersion += 1)、打时间戳(sentAt = Time.realtimeSinceStartup)。把临时计算的派生数据写进同步字段、把 DataDictionary 转 JSON 字符串塞进同步字段(VRCJson 用法在第三部 19 章),也属于这一档。

不要在这里做耗时计算,它在 network tick 上跑,代价直接叠到本帧。如果一份「准备发送」的逻辑要改十个字段,先怀疑是状态结构没拆好,而不是把所有改动都塞进 OnPreSerialization

[UdonSynced] private int stateVersion;
[UdonSynced] private int currentWave;
public override void OnPreSerialization()
{
stateVersion++; // 每次发出去都把版本号 +1
}

OnPostSerialization(SerializationResult result) 在序列化尝试完成后在 Owner 本地触发。SerializationResult 有两个字段:

  • success (bool):这次发送是不是成功。
  • byteCount (int):实际发送的字节数。

工程化同步层的入口在这一行:

public override void OnPostSerialization(SerializationResult result)
{
if (!result.success)
{
// 失败了。等下一次或者降级处理。
pendingResend = true;
}
Debug.Log($"[Sync] sent {result.byteCount} bytes");
}

为什么要关心 success?因为 Manual 同步有 per-object 的 rate limit:发送的数据越多,下一次能发送的时间窗口就被推后越远。如果在 success == false 的情况下继续狂调 RequestSerialization,结果只是把更多请求堆在队列里,VRChat 仍然按它的节奏走。这种时候应该让本地状态机停一下、降低发送频率、或者把 payload 切片。

第六部 36 章讲对象同步降级时会再次回到 OnPostSerialization,把它作为整个降级链路的事件源。


OnDeserialization 与字段级回调的区别

Section titled “OnDeserialization 与字段级回调的区别”

远程客户端那边有两个层面的回调。一是字段级回调,每个发生变化的同步字段都会触发一次。在 UdonSharp 里通过 [FieldChangeCallback(nameof(SomeProperty))] 把字段绑定到一个 property;网络同步或 SetProgramVariable 更新字段时,不会直接写字段,而是调用这个 property 的 setter。setter 里要自己把 value 写回 backing field。二是 OnDeserialization,它是整批同步数据全部应用完之后再触发的一次。

字段级回调的最小写法:

[UdonSynced, FieldChangeCallback(nameof(CurrentWave))]
private int _currentWave;
public int CurrentWave
{
get => _currentWave;
set
{
// FieldChangeCallback 不会自动写回字段,这里必须自己赋值
_currentWave = value;
UpdateWaveLabel();
}
}

举个例子:Owner 在同一次序列化里更新了 currentWaveenemyCountThisWave,远程客户端的执行顺序是:

(VRChat 内部)→ 触发 CurrentWave property setter(参数是新的 currentWave,setter 内写回字段)
→ 触发 EnemyCountThisWave property setter(setter 内写回字段)
→ 触发 OnDeserialization() ← 此时整批同步已经应用完

所以一条工程纪律:如果一个回调里要读多个同步字段,写在 OnDeserialization,不要写在字段级回调里[FieldChangeCallback] 适合「这个字段变了我就立刻反应」,OnDeserialization 适合「整批应用之后做一次整体处理」。

OnDeserialization(DeserializationResult) 的扩展版会带 sendTimereceiveTime(基于 Time.realtimeSinceStartup),可以算出端到端延迟,做更精细的体感优化。第五部 28 章会用到。


合作防守世界的「波次状态」对象。它要同步:当前波次号、本波剩余敌人数、阶段(准备 / 进行 / 结算)。这些字段不需要每帧更新,但每次更新都要可靠送达,适合 Manual 模式。

using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon.Common;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class WaveState : UdonSharpBehaviour
{
[UdonSynced] public int currentWave;
[UdonSynced] public int enemiesRemaining;
[UdonSynced] public byte phase; // 0 prepare / 1 fight / 2 settle
[UdonSynced] private int stateVersion;
public void StartNextWave()
{
if (!Networking.IsOwner(gameObject)) return;
currentWave++;
enemiesRemaining = currentWave * 5;
phase = 1;
RequestSerialization();
}
public override void OnPreSerialization()
{
stateVersion++;
}
public override void OnPostSerialization(SerializationResult result)
{
if (!result.success)
{
Debug.LogWarning($"[WaveState] send failed at v{stateVersion}");
}
}
public override void OnDeserialization()
{
// 这里所有 [UdonSynced] 字段都是最新值,可以做整体应用
UpdateUIPanel();
}
private void UpdateUIPanel()
{
// 把 currentWave / enemiesRemaining / phase 同步到 UI
}
}

这段代码包含 Manual 模式下的所有关键元素:[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)] 类级别的标记、Owner 检查、RequestSerialization、四个回调里只写有意义的两个、版本号自增。后面所有 GameState、玩家请求、卡牌出牌之类的同步对象都会复用这套结构。


回到第 2 章实现 C 的按钮,把它从 Continuous 改成 Manual。改动需要:

  1. 在类上加 [UdonBehaviourSyncMode(BehaviourSyncMode.Manual)] 或在 Inspector 里把 Sync Method 改成 Manual。
  2. Interact 里的 RequestSerialization() 保留(其实之前 Continuous 时这一行可以省略,但写了也没坏)。
  3. 加上 OnPostSerialization(SerializationResult result) 回调,把 result.success 打到 Debug.Log

改完之后多客户端测试一次,按几下按钮观察日志:每次按下都能看到一条「sent N bytes」。这是 Manual 模式给的第一手反馈:你能看到每次同步真的发出去了多少

进阶问题:如果连续在 0.5 秒内按 10 下按钮,日志会有多少条?为什么?

(提示:rate limit + network tick 的组合。多余的 RequestSerialization 不会失败,但被合并到下一次实际发送里。)


Manual 同步有四个明确时刻:请求、OnPreSerialization、发送、OnPostSerialization;远端有字段级回调(UdonSharp 用 [FieldChangeCallback])和 OnDeserialization(整批级)。RequestSerialization 是请求,下一个 network tick 才生效,可以重复调用、rate limit 会合并它们。OnPostSerialization(result) 是工程化同步层的入口,能告诉这次发送的成败和字节数。抢权后改字段必须 RequestSerialization,这一条是 Vol.2 同步代码里复用率最高的纪律。


  • VRChat Creator Docs · Network Specs and Tips — Continuous / Manual 上限、rate limit、SerializationResult 字段的官方数据。
  • VRChat 汉化文档 · Network Components — 中文版回调说明。
  • 社区文章 · UdonSharp 同步方法指南OnPreSerialization / OnPostSerialization 的实战用法。
  • 附录 · 术语表Continuous SyncManual SyncRequestSerializationOnPreSerializationOnPostSerializationOnDeserialization 的客观定义。