https://github.com/RSJWY/PurrNet

根据PurrNet内部已经支持的Addressable对照实现。

记录了在 PurrNet 分支上实现 YooAsset 一等公民集成的过程:场景同步、网络 Prefab 注册表、一键网络生成,以及一次"不动业务代码"的 IL 拦截自动生成。

背景:两个好用的东西,凑不到一起

做联网游戏绕不开两件事:

  • 网络同步 —— 我们用的是 PurrNet,一个完全免费、功能不阉割的 Unity 网络框架,场景同步、Prefab 生成、RPC 都是开箱即用。

  • 资源热更 —— 用的是 YooAsset,国产的商业级资源管理方案,按"包(Package)+ 定位地址(Location)"组织资源。

问题在于:PurrNet 的网络对象生成依赖 Prefab 注册表 —— 服务器和客户端必须就"第 N 号 Prefab 是什么"达成一致,网络消息里只传一个 ID,双方各自查表还原。这套机制天生为"打进包里的资源"设计,也支持 Addressables,但对 YooAsset 完全没有概念。

结果就是:从 YooAsset 加载出来的 Prefab,没法直接网络生成;YooAsset 打包的场景,也没法走 PurrNet 的场景同步。你只能自己写胶水代码,手动保证两端加载顺序一致、ID 一致 —— 这正是最容易出 bug 的地方。

所以这次改动的目标很明确:让 YooAsset 资源像普通 Prefab 一样,享受 PurrNet 的全套同步能力。改动共涉及 85 个文件、约 5400 行新增代码,全部代码通过 YOOASSET_PURRNET_SUPPORT 宏隔离 —— 不装 YooAsset 包的项目完全不受影响。

整体架构

改动沿着 PurrNet 已有的 Addressables 集成的形状展开,一共五块拼图:

下面挑几个设计上最有意思的点展开讲。

一、核心难题:跨端一致的 Prefab ID

网络同步的本质是"传 ID,不传对象"。但 YooAsset 的资源是运行时动态加载的,没有 Build Settings 场景索引这种天然 ID。怎么办?

答案是用确定性的字符串键做哈希:每个注册项用 (packageName, location) 这一对儿编码成唯一的 wire key,例如:

purrnet-yooasset-prefab-v1:12:PurrNetTestsTestYooAssetNetworkPrefab
        前缀                包名长度 包名        定位地址

注意"包名长度"前缀这个细节 —— 因为包名和地址都是任意字符串,不带长度直接拼会产生歧义(ab + ca + bc 拼出来一样)。这个编码有专门的单元测试(NetworkYooAssetKeyTests)守护。

两端用同一份注册表资产,算出同一个 key,哈希后就是同一个 Prefab ID。只要两端注册表内容一致,ID 天然一致,不需要任何运行时协商。

二、YooAssetNetworkPrefabs 注册表

这是一个 ScriptableObject 实现的 IPrefabProvider,挂到 NetworkManager 上即可。能力包括:

  • 启动预加载LoadAllAsync):进游戏前把注册的 Prefab 全部加载好,之后同步生成零等待;

  • 链式注册表:一个注册表可以引用其他注册表,递归合并、按 key 去重 —— 适合按功能模块拆分注册表;

  • 运行时注册:动态内容也能注册进来;

  • 加载状态门控:客户端还没加载完对应 Prefab 时,生成消息会先排队,等资源就绪再落地(配合 YooAssetSyncModule 的状态包实现),彻底消灭"资源没下好,生成报空"这类时序 bug。

手写注册表太痛苦,所以配套做了编辑器自动生成:直接读 YooAsset 的 BundleCollectorSetting,一键把收集器里的资源变成注册项,还支持:

  • 分组名 / 标签 过滤;

  • 实现 IYooAssetNetworkPrefabRule 接口写自定义规则,决定哪些资产有资格成为网络 Prefab。

三、场景同步

ScenesModule 新增了 YooAsset 分部类,API 与内置场景同步对齐:

// 服务器上加载一个 YooAsset 场景(无需加入 Build Settings)
var sceneId = await networkManager.sceneModule.LoadYooAssetSceneAsync(
    "MyPackage", "Assets/Scenes/Battle.unity");

// 卸载
await networkManager.sceneModule.UnloadYooAssetSceneByLocation(
    "MyPackage", "Assets/Scenes/Battle.unity");

场景状态会复制给所有客户端,包括中途加入的玩家 —— 迟到者连上后自动加载对应场景,拿到和其他人一致的 SceneID,随后场景内的网络对象照常下发。重连不会重复加载,Host 模式下加载/卸载也只发生一次。

四、一键网络生成

注册表就绪后,生成一个网络物体就是一行的事:

// 加载(如未加载)并网络生成
var go = await networkManager.SpawnYooAssetAsync(
    "MyPackage", "Assets/Prefabs/Enemy.prefab",
    position: spawnPoint.position,
    rotation: Quaternion.identity);

// 走网络管线销毁
networkManager.DespawnYooAsset(go);

已经预加载过的 Prefab 还有同步版本 SpawnYooAsset

五、最"黑科技"的部分:IL 拦截自动生成

上面这些都还需要你"主动调 API"。但实际项目里,存量代码大量写着:

var handle = package.LoadAssetSync<GameObject>("Enemy");
var go = handle.InstantiateSync();   // 普通实例化,不联网!

难道要把所有 InstantiateSync 翻出来改成 SpawnYooAssetAsync?不用。

PurrNet 本来就有 IL 后处理器(Mono.Cecil),会把 Object.Instantiate 改写到自己的 UnityProxy。这次把同样的手法扩展到了 YooAsset:编译期把 AssetHandle.InstantiateSync / InstantiateAsyncObject.Destroy 的调用重写到 YooAssetProxy,代理内部检查实例化的 Prefab 是否命中注册表 —— 命中就走网络生成/销毁,没命中就原样执行。

效果就是:存量的 YooAsset 实例化代码一行不改,编译完就自动联网了。

六、NetworkYooAsset:能过网的资源引用

最后一块拼图是一个轻量结构体,对标框架里的 NetworkAddressable

public NetworkYooAsset enemyIcon;   // 可以作为 SyncVar 或 RPC 参数

// 发送端:只把 (包名, 地址) 编码成字符串 key 上线
// 接收端:解包后自动从自己本地的 YooAsset 包异步加载出资源
Sprite icon = (Sprite)enemyIcon.Asset;

线上只走一个很短的 key 字符串;接收方反序列化时自动 LoadAssetAsync 并把句柄挂回结构体。用完调 Release() 释放句柄即可。[DontPack] 标记保证句柄本身不会被序列化 —— 句柄是本地对象,过网毫无意义。

测试与验证

  • 编辑模式NetworkYooAssetKeyTests 覆盖 key 编码/解码的边界(长度前缀、歧义拼接等);

  • 播放模式YooAssetSceneTransferScenario 完整跑通"服务器加载 YooAsset 场景 → 客户端同步 → 迟到玩家补发 → 卸载重载"全链路;

  • 手动示例Assets/Examples/YooAssetTest 下有一整套可交互的测试脚本(场景加载、注册表生成、IL 拦截生成、RPC 验证),附有 README 说明搭建步骤。

结语

这次集成的核心思路可以总结为一句话:把 YooAsset 的"包名 + 地址"变成网络世界里的一等身份标识。注册表负责确定性 ID,场景模块负责状态复制,IL 拦截负责让旧代码无感升级,NetworkYooAsset 负责让资源引用自由过网 —— 四层各司其职,最终让"热更资源"和"网络同步"这两件原本互相打架的事,变成一行 API 的事。

代码在分支 yooasset 上,欢迎来试。