架构解析 · 设计与实现论证

场景系统 · 架构解析

这份文档回答"为什么这么设计":每个决策换来了什么、代价是什么、边界画在哪。

本篇只讲"为什么":为什么这么设计、每个决策换来了什么、代价是什么、边界画在哪。
想学怎么用 → 看同目录的《使用说明》,那里是手把手,本篇不重复。

源设计起源 · 为什么要做这个模块

一句话 切场景是"看起来简单、做起来到处是坑"的典型 —— Unity 只给了 SceneManager.LoadSceneAsync,剩下的全靠自己;这个模块就是把那些坑一次填掉。

本篇只讲为什么;怎么用见同目录《使用说明》。

〇三句话速览(完整用法见《使用说明》)

① 切场景      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)
进度怎么接自己每帧读 progressawait 直接可用;或挂 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 与事件并存

为什么:

两条通道共用同一份状态(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 之外自己拦一层即可