源设计起源 · 为什么要做这个模块
SceneManager.LoadSceneAsync,剩下的全靠自己;这个模块就是把那些坑一次填掉。
- 没它会怎样:进度条先卡在 90%,再往回跳:Unity 的
AsyncOperation.progress在激活前最高只到 0.9,采样值本身还会抖动 —— 于是"卡在 90% 不动"和"进度条回跳"成了两件家常便饭,玩家看到的就是"卡住了"。 - 小场景会"闪一下":几十毫秒就加载完的场景,进度条一闪而过、画面突然切换 ——
minSeconds让进度按时间节奏走,到点才激活。 - 重复请求会互相踩:两个
LoadSceneAsync叠着跑就是黑屏 —— 所以正在切换时忽略 + 告警,而不是排队或硬切。 - 场景名写错会一路黑到底:
LoadSceneAsync返回null而不是抛异常,很容易一路走到黑 —— 所以当场判掉,并报人话原因。 - 旧场景的残留会跟过来:池化实例、画面残留跟着新场景走 —— 所以默认清对象池(
AutoClearPool)。 - 换来了什么:进度只增不减、到 100% 才放行;
await直接可用(或挂OnProgress/OnLoadStart事件);每条失败都给得出人话原因。
本篇只讲为什么;怎么用见同目录《使用说明》。
〇三句话速览(完整用法见《使用说明》)
① 切场景 await RevScene.LoadAsync("Battle"); // 异步(推荐) ② 接进度 RevScene.OnProgress += p => bar.value = p; // 挂一次,全局生效 ③ 收摊子 RevScene.OnLoadStart += _ => RevUI.ShutdownAll(); // 对象池框架已默认清
就这三步。剩下都是这三步的细节。
一它解决什么问题
场景切换是"看起来简单、做起来到处是坑"的典型:Unity 只给了 SceneManager.LoadSceneAsync,剩下的全靠自己。这个模块把下面这些坑一次填掉:
| 问题 | Unity 原生行为 | 本模块的做法 |
|---|---|---|
| 进度条卡 90% | AsyncOperation.progress 激活前最高 0.9 | 关掉自动激活 + 归一化到 0~1 + 到 100% 才放行 |
| 进度条回跳 | 采样值会抖动 | 只增不减(进度换算里保证) |
| 小场景"闪一下" | 加载几十毫秒就完了 | minSeconds:进度按时间节奏走,到点才激活 |
| 重复请求互相踩 | 两个 LoadSceneAsync 叠着跑 → 黑屏 | 正在切换时忽略 + 告警 |
| 场景名写错黑屏 | LoadSceneAsync 返回 null(不抛异常) | 当场判掉并报人话原因 |
| 旧场景残留 | 池化实例 / 画面跟着走 | 默认清对象池(AutoClearPool) |
| 进度怎么接 | 自己每帧读 progress | await 直接可用;或挂 OnProgress 事件 |
二四个关键设计决策(含代价)
决策 1:关掉 Unity 的自动激活(allowSceneActivation = false)
为什么:Unity 在"激活前"只把进度报到 0.9,直接等 isDone 就是"卡 90% + 瞬间跳满"。关掉自动激活后,激活时机由我们掌握 —— 显示进度到 100%(且满足最短时长)才放行。
代价:激活前新场景不参与光照/初始化,多占一点点内存;且加载期间的 Time 仍是旧场景的(所以进度用 unscaledDeltaTime,与时间缩放无关)。
决策 2:进度换算做成纯 C#(RevSceneProgressTracker)
为什么:这堆规则(0.9 归一化、只增不减、时间限速、何时允许激活)全是可验证的纯逻辑,但藏在协程 / MonoBehaviour 里就只能靠肉眼跑场景验证。抽成纯类后:时间以参数进来(Tick(raw, deltaTime)),脱机断言 20 条(含 1000 帧随机采样验证单调性)。
代价:调用方要自己把 deltaTime 喂进来(本模块由 Unity 侧 loader 负责,业务无感)。
决策 3:接入面双通道 —— await 与事件并存
为什么:
await版适合"切完接着干"(await RevScene.LoadAsync("Battle"); 开战斗界面)—— 用框架自带的RevTask,不引第三方异步库;- 事件版适合"加载界面自己管自己"(
OnLoadStart开、OnProgress更新、OnLoaded关),业务代码里一个await都不用写。
两条通道共用同一份状态(RevScene.State / Progress),混用也不会打架。想明确"故意不 await"就写 .Forget()(替代 async void,意图清晰且异常不会被吞)。
代价:事件是静态的、全局生效 —— 所以订阅方自己要负责退订(或放在长期存活的对象上)。
决策 4:切之前只自动清对象池,其余交给 OnLoadStart
为什么:"哪些东西该跨场景保留"是业务决定:有的游戏保留 BGM、有的保留常驻界面、有的保留已加载的资源分组。框架替你做主就会"想保留的被清掉"。
所以:只清"肯定不该跟着走"的那一类(对象池里的旧实例),其余给一个必然被调用的时机 OnLoadStart,一行挂上即可。
代价:多写一行(换来的是"不会被框架擅自清掉东西")。
三"轻量"具体体现在哪
| 项 | 做法 |
|---|---|
| 不新起宿主 | 没有隐藏 GameObject、没有 DontDestroyOnLoad 的管理器 —— 等待靠 RevTaskScheduler.NextFrame()(零分配) |
| 不引第三方 | 异步用框架自带的 RevTask(async RevTask / RevTask.Yield / .Forget()),Unity 的异步操作由 RevTaskUnityExtensions 桥接 |
| 不套框架 | 没有"加载步骤 / 权重 / 状态机"那一套(那属于加载框架,不属于场景切换);需要就自己在 OnLoadStart / OnLoaded 里编排 |
| 文件少 | 5 个 .cs:2 个纯 C# 内核(状态枚举 + 进度换算)+ 门面 + Unity 驱动 + 日志出口 |
| 无轮询 | 业务不需要 Update 里查 IsLoading:完成后有 OnLoaded 事件 / await 续体 |
四与其它模块的边界
| 模块 | 关系 |
|---|---|
RevTask | 依赖:异步原语(本模块不引第三方异步库) |
RevObjectPool | 默认清理:切场景前 RevPool.ClearAll()(可用 AutoClearPool = false 关掉) |
RevUISystem | 不依赖:加载界面是普通面板;想关全部界面就在 OnLoadStart 里 RevUI.ShutdownAll() |
RevResourceSystem | 不依赖:按业务域卸资源由你在 OnLoadStart 里 RevResBootstrap.Instance.Shutdown(group) |
RevSoundSystem | 不依赖:停音效同理(RevSound.StopAll()) |
| 热更新 | 无关:本模块只管本机场景切换;AB 的远程更新见 README《不做什么:热更新与远程更新》 |
五验证结果
| 项 | 结果 |
|---|---|
Revolution.Runtime 编译 | 0 错误 0 警告(含本模块 5 个文件 / 494 行) |
Revolution.Editor 编译 | 0 错误 |
| 纯 C# 依赖检查 | Core/ 两个文件零引擎标识符(能单独链接进普通 .NET 工程编译) |
| 工程外行为断言 | 20 / 20 通过:90% 平台期换算、越界 clamp、原始值回退不回退、1000 帧随机采样单调性、最短时长限速与激活门控、Complete / Reset、异常输入不炸 |
| 场景名写错 | 返回 null 的分支被当场判掉 → Failed + 人话原因(不黑屏) |
| 并发请求 | 第二次请求被忽略并告警(不叠加载) |
断言工程是临时的(只链接 Core/ 两个纯文件),跑完即删 —— 与其它模块的"可脱机断言"做法一致。
六附:关键文件索引
| 文件 | 职责 |
|---|---|
Runtime\RevScene\Core\RevSceneDefine.cs | 公共词汇:加载状态枚举(纯 C#) |
Runtime\RevScene\Core\RevSceneProgressTracker.cs | 进度换算内核(纯 C#):0.9 归一化 + 只增不减 + 最短时长限速 + 激活门控 |
Runtime\RevScene\Facade\RevScene.cs | 业务唯一入口:Load / LoadAsync / ReloadAsync / 状态查询 / 四个事件 / 配置 |
Runtime\RevScene\Support\RevSceneLoader.cs | 实际执行者:拒绝并发 → 清理 → allowSceneActivation = false 加载盯进度 → 收尾广播 |
Runtime\RevScene\Support\RevSceneLog.cs | 统一日志出口(tag = Scene,默认走 RevLog) |
七本期没做的(可后续扩展)
| 项 | 说明 |
|---|---|
| Additive 叠加场景 / 多场景管理 | 现在是 Single 切换;要做"战斗场景 + 常驻场景"叠加,需要在门面加 LoadAsync(name, LoadSceneMode) 与场景集合管理 |
| 加载界面预制体 | 现在只给进度事件;要做"开箱即用的加载界面",需要一个约定名(例如 UI/Loading)由框架自动开/关 |
| 进度权重合并(场景 + 资源) | 常见做法是"场景占 60%、预加载资源占 40%";本模块只管场景,合并逻辑留给业务或在 OnProgress 里自己算 |
场景名常量生成(RevScenePath) | 像 RevResPath / RevSoundPath 那样由工具生成场景名常量,避免手写字符串写错 |
| 切换前的确认流程(战斗中禁切) | 现在调用即切;要"战斗中点退出先弹确认",在 OnLoadStart 之外自己拦一层即可 |