根据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 + c 和 a + 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 / InstantiateAsync 和 Object.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 上,欢迎来试。
给 PurrNet 接入 YooAsset:让网络同步的资源也能热更
https://blog.rsjwy.com/archives/purrnet-yooasset-network-hot-update-integration
评论