0三句话速览
三句话速览
① 对外只有一个入口:RevUI(门面) // 业务只跟它打交道 ② 状态只有一个主人:RevUIManager(管理器) // 谁开着、谁在池里、谁盖着谁,全由它记账 ③ 需要 Unity 的事只有一处:RevUIRoot(常驻根节点) // 建 Canvas、摆六层、挡点击、每帧重排
本篇怎么读
| 你想知道 | 去哪看 |
|---|---|
| 这套东西由哪几个部件组成、谁调用谁 | 第二章「整体骨架」 |
| 点一下"打开面板",框架内部到底做了什么 | 第三章「跟着一次打开走完全程」 |
| 哪些钩子什么时候被调、为什么有先后 | 第四章「生命周期」 |
| 为什么用特性 + 反射、为什么要池、为什么自研动画… | 第五章「关键设计决策」 |
| 框架"故意不做"什么(以及代价) | 第六章「刻意的取舍与边界」 |
| 要不要再拆 System / BusinessLogic 两层 | 第七章 |
| 想写界面(怎么用) | 同目录《UI 系统 · 使用说明》 |
1它要解决什么问题
1.1 起点:写一个界面要写五个脚本
作者在项目组里用的那套 UI 框架极其复杂 —— 写一个 UI 功能要写好几个脚本,各管一段:
| 脚本 | 它管什么 |
|---|---|
ViewData | 界面的数据 |
FromView | UGUI 控件绑定 |
FromLogic | 控件逻辑绑定 |
System | 网络消息收发处理 |
BusinessLogic | 业务总控:注册、启动、销毁这个业务功能 |
写一个界面,就要在五个文件之间来回跳。太复杂了。 而且这套东西是用 TypeScript(puerts)写的 —— 在 Unity 工程里用 TS 写 UI 很别扭、很丑陋:编译链、类型、调试、和 C# 侧的边界,全是额外的心智负担。
更关键的矛盾:分这么多层只是为了热更,而热更本身又只是"半套"
把界面切成这么多层,唯一的正当理由是热更新 —— 界面逻辑放进 TS,就能不发版改界面。但项目里的实际情况是:
- 只有大版本之内的小更新才走热更;
- 一整个大版本更新,必须重新打包发版。
那这笔账就不划算了:为了"偶尔一次小更新",长期付出"每个界面五个脚本 + 一整套 TS 工具链"的成本。既然大版本反正要重新发版,还不如全量上 HybridCLR 这类 C# 热更方案 —— 语言统一在 C#,分层也可以顺着 C# 的习惯重新设计,不必为了 TS 的边界把界面切得那么碎。
于是想到:留下真正值得复用的部分,其余按自己的方式重写
- 留下真正有复用价值的东西 —— 比如
ViewData(数据与界面分离)、Part(可复用的界面单元)这类"结构上就对"的设计; - 融合作者跟唐老师学到的 UI 框架里的做法(面板 + 钩子 + 声明式绑定这一路);
- 目标就三个词:高复用、易用、傻瓜式 —— 写一个界面 = 一个脚本 + 一个预制体,不该先学会一套分层学说才能动手。
1.2 三条目标:后面每个决策都是它们的展开
| 目标 | 为什么是它 | 落到代码上就是 |
|---|---|---|
| ① 复用安全 | 复用是性能(不加载、不实例化、不重绑),但复用是 bug 温床:上次的滚动位置、选中的页签、输入框里的字、"还挂着上次的数据"—— 这类 bug 偶尔才出现,最难查。 所以复用必须是有契约的路径,不能各凭自觉。 | 实例池 + 必被调用的 OnReuse();框架在调用它之前先替你清空上一次的数据;复用时再兜一次"摘事件" |
| ② 异常可见 | UI 的问题几乎都是静默失败:"点了没反应""图不见了""数据不对",根源往往是节点改名了、预制体没进包、控件类型写错了。如果只给一句 null,就只能靠断点猜。 | 凡是"本该成功却没成功"的地方都报能照着改的错:列出根节点下真实存在的节点名、给出三条排查路径、明确指出该换哪个入口 |
| ③ 分层靠结构 | 参考实现的四层很干净,代价是一个界面要写好几个文件。这里要的是用结构约束行为,而不是靠注释约定 —— 该只做装配的钩子就必须只做装配。 | 一个面板仍然只有一个脚本,但每个钩子只许干一件事;带数据的界面连"怎么把数据画上去"都是抽象方法,不写编译不过 |
1.3 具体盯的是哪几件事
| UI 最容易失控的地方 | 不解决会怎样 | 框架的做法(细节见第三、五章) |
|---|---|---|
| 界面怎么找到 | 路径写在调用处 → 改目录/改名就散落一地;加载失败只留一句 null | 路径写在面板类自己身上(一行特性),打开时框架去读 —— 调用处一行路径都不写,也就没有"调用处拼错路径" |
| 控件怎么拿到 | 手写 Find("Panel/Panel/Button"),节点一改名就静默变 null | [RevBind] 字段按"字段名 ↔ 节点名"自动绑定;找不到时报错并列出真实节点名 |
| 表现 / 数据 / 逻辑怎么分 | 混在一个 Update 里,改一处崩三处 | 用钩子职责分开(装配 / 落屏 / 业务),但仍然只有一个脚本 |
| 谁盖着谁、谁挡住点击 | 每个弹窗预制体自己摆遮罩、自己设 sortingOrder,早晚互相打架 | 六层结构 + 按层自动铺挡板;层内顺序 = 打开顺序,由框架统一排 |
| 开关与复用时的资源/状态 | 关掉不销毁 → 残留;关掉就销毁 → 反复加载卡顿 | 实例池 + 清理契约 + 延迟一帧销毁 + 资源引用配对释放,全由管理器记账 |
| 事件泄漏 | 面板没了,监听还挂在全局事件上 → 空引用、鬼畜刷新 | 关闭/释放时框架自动 RevEvent.RemoveAllByOwner(this):机制保证,不靠纪律 |
1.4 比早期版本多出来的不是"功能",是"保证"
这套系统的前身是 BasePanel(219 行)+ UIMgr(335 行),只做"开 / 关 / 隐藏"。新版文件多了,但它补的每一条都是给业务的保证:
| 维度 | 早期版本 | 现在 |
|---|---|---|
| 控件获取 | Awake 里把 Button/Toggle/Slider/Text… 全量扫进字典,再弱类型取 | [RevBind] 强类型字段;只扫"你真的重写了回调"的那几类控件,且每类型只反射一次 |
| 事件绑定 | 每个按钮 AddListener(() => ClickBtn(名字)) —— 每个实例每次 Awake 都分配一个闭包 | 每个交互节点一个"继电器"对象,创建时挂一次,复用时不再重挂 |
| 层级 / 遮罩 | 四层,靠一个 UI/Canvas 预制体摆好;遮罩要每个弹窗自己记得摆 | 六层代码建(零资源依赖);按层自动铺透明挡板,点遮罩关该层最上面的弹窗 |
| 复用 | "隐藏不销毁",没有清理契约 → 残留全靠人记 | 实例池 + OnReuse 契约 + 框架代清数据 |
| 异步加载合并 | 有(占位 + 回调累加,思路保留) | 有,且更彻底:在途请求真合并,多份回调都拿到同一个实例 |
| 第三方依赖 | 硬依赖 DOTween(扩展 1000+ 行) | 零依赖:转场是可插拔钩子,动画是自研内核(决策 8) |
2整体骨架:四个角色、两圈分层
2.1 一张图看懂依赖方向
先看全景。箭头是"调用/依赖"方向,箭头是单向的 —— 这一点很重要,它保证了"业务不碰实现、实现不反向依赖业务":
业务代码 │ 只调这一个入口 ▼ RevUI(门面 · 静态类 · 无状态) // Facade\RevUI.cs │ Open / Close / Back / Preload / DumpStats … ▼ RevUIManager(管理器 · 单例 · 非 MonoBehaviour) // Implementation\RevUIManager.cs ├─ 索引:打开中 / 加载中 / 打开顺序 / 互斥组 / 实例池 ├─ 找资源:RevResManager(资源系统,见《资源加载系统》) ├─ 解析声明:RevUIPanelMeta(读特性,一次)→ RevUIBindPlan(算绑定计划,一次) ├─ 存 / 取实例:RevUIPanelPool(私有持有) └─ 首用时创建 ▼ RevUIRoot(常驻场景 · MonoBehaviour) // Implementation\RevUIRoot.cs ├─ 建 Canvas + 六层挂点(Scene/Normal/Popup/Toast/Guide/Top) ├─ RevUIPopupMask(每层一块挡板,私有持有) ├─ EventSystem 兜底 / UI 相机 ├─ Update → 处理"延迟一帧的销毁" └─ LateUpdate → 回调管理器:层内排序 + 遮罩摆位 + 被盖住通知 面板侧(业务继承它,框架驱动它) RevUIPanel ──用──▶ RevUIBinder(装配)· RevUIAnim(动画)· RevEvent(事件) RevUIPart ──只认──▶ IRevUIPartHost(宿主接口,因此能跨面板复用)
2.2 四个角色:各自为什么存在、为什么不能合并
| 角色 | 它的唯一职责 | 为什么必须单独存在(合并会怎样) |
|---|---|---|
门面RevUI静态 · 无状态 | 业务唯一入口:转发 + 失败时给人话文案。 | 如果让业务直接调管理器:业务就绑死在一个具体实现上,将来换实现(或加一层队列/优先级)要改所有调用处。门面把"说什么"和"怎么做"分开:门面负责叫法,管理器负责做法。 |
管理器RevUIManager单例 · 非 MonoBehaviour | 唯一的"账本":谁开着、谁在加载、打开顺序、互斥组、池、资源引用配对。 | 账本只能有一本。它刻意不是 MonoBehaviour:不需要挂在场景对象上、不需要每帧回调,因此不受"根节点被销毁 / 场景切换"影响;根节点没了它会重建(见决策 7)。 |
根节点RevUIRoot常驻场景 · MonoBehaviour | 所有"必须靠 Unity 才能做"的事:建 Canvas 与六层挂点、挡板、EventSystem、每帧重排、延迟销毁。 | 这些事需要 Update / LateUpdate 与场景对象,只能由 MonoBehaviour 干。把"记账"和"干活"分开,管理器才能保持干净、可推理。 |
池 + 挡板RevUIPanelPoolRevUIPopupMask | 池:按面板分桶存实例;挡板:每层一块透明挡板。 | 它们不是单例,分别由管理器和根节点私有持有。理由很朴素:谁创建谁销毁、谁知道它的生命周期。做成全局单例,就会出现"根节点重建了、旧挡板还在"这类对不上的状态。 |
2.3 两圈分层:哪些文件是"纯 C#",为什么
26 个文件里有一圈完全不引用 UnityEngine 的纯 C# 文件。这不是洁癖,而是为了能被验证:
| 纯 C# 内核(可丢进普通 .NET 工程跑) | 它管什么规则 |
|---|---|
Core\RevUIDefine.cs | 词汇与规则:六层的顺序、状态枚举、遮罩按层怎么推断、面板进哪个画布、画布排序号 |
Core\RevUIAttributes.cs | 声明式特性的定义([RevUIPanel] / [RevUIPart] / [RevBind]) |
Support\RevUIPanelMeta.cs | 把特性解析成元数据:目录规范化、资源名默认取类名、拼出资源键 |
Support\RevUIBindPlan.cs | 绑定计划:字段 ↔ 节点名 ↔ 控件类型、"这个类重写了哪些回调" |
Core\RevUIWidgetEvents.cs | 方法特性(如 [RevButtonClick])的扫描、形状校验与派发 |
Animation\RevUIEase.cs · RevUIAnimSpec.cs · RevUIAnimEngine.cs | 缓动曲线、动画规格、动画内核(时间 → 系数) |
因为这些规则的特点都是"写的时候看不出错,跑起来才知道":"UI/Panel/" 会不会拼出双斜杠?省略资源名到底取什么?Popup 层默认挡不挡?_btnClose 该变成哪个节点名?只重写了滚动回调会不会顺手把点击也挂上?
抽成纯 C# 之后,这些问题可以在不开 Unity 的情况下用真断言逐条验证(见第八章)—— 而不是"打开工程、进 Play、点一遍、看起来对了"。
2.4 26 个文件怎么归类
按"它是哪一类角色"分,一眼就能看全(详细职责见第九章):
| 分类 | 数量 | 文件 |
|---|---|---|
| 门面 | 1 | Facade\RevUI.cs |
| 管理器与根 | 4 | Implementation\RevUIManager.cs · RevUIRoot.cs · RevUIPanelPool.cs · RevUIPopupMask.cs |
| 声明与解析 | 6 | Core\RevUIDefine.cs · RevUIAttributes.cs · RevUIWidgetEvents.cs · Support\RevUIPanelMeta.cs · RevUIBindPlan.cs · RevUIBinder.cs |
| 面板侧基类 | 6 | Core\RevUIPanel.cs · RevUIPanel.Generic.cs · RevUIPart.cs · Interfaces\IRevUIPartHost.cs · IRevUIUserEvents.cs · Support\RevUIButtonPressRelay.cs |
| 配置与钩子 | 2 | Support\RevUISetting.cs · RevUIUnityHooks.cs |
| 动画 | 7 | Animation\ 下全部:门面 / 内核 / 规格 / 落点 / 驱动 / 缓动 / 控件反馈 |
3跟着一次"打开面板"走完全程
架构图看着抽象,跟着一次打开走一遍就具体了。下面这一步一步,就是源码里真实发生的顺序(行号见第九章的文件表)。
3.1 第一件事:先看"有没有现成的" —— 五级查找
打开面板时,框架不会傻乎乎地"每次都加载 + 实例化",而是按从便宜到昂贵的顺序找五遍。这个顺序本身就是性能设计:
| 级 | 查找条件 | 命中后的动作 | 为什么排在这一级 |
|---|---|---|---|
| ① | 正在加载中(_loading 命中) | 把你的回调并进在途的那一次,直接返回 | 最便宜:什么都不用做。而且这是正确性 —— 同一面板被连点两次,只该加载/创建一个实例,两份回调都要收到通知 |
| ② | 已经打开(_opened 命中) | BringToTop() 置顶;有数据就 SetData,没有就 RefreshView();然后回调 | 次便宜:实例已经在场景里了。注意不会重走 OnOpen —— 它本来就是打开的 |
| ③ | 池里有闲置实例(RevUIPanelPool 命中) | Take 取出 → InternalReuse()(框架先替你清数据、再调 OnReuse)→ 登记 → 完整走一遍打开 | 省掉"加载 + 实例化"两笔大开销,代价是要清残留 —— 所以必须有 OnReuse 契约 |
| ④ | 资源缓存里已有(预热过,或编辑器直读模式) | 同步取到预制体 → 立刻实例化 → 登记 → 打开 | 资源已经在内存里,同步能完成就别绕异步(少一帧延迟,业务手感更好) |
| ⑤ | 都不是 | LoadAsync 异步加载 → 加载回来再实例化 → 登记 → 打开 | 最贵的一级,也是真机首次打开的常态(预制体在 AB 包里) |
只有"已打开 / 池里有 / 资源缓存里有"这三条快路能在同一帧同步完成。真机第一次打开面板时预制体还要从 AB 包里读,必然异步 —— 所以框架提供了回调式与 OpenAsync 两种入口,同步入口拿不到实例时会明确告诉你该换哪个入口,而不是返回一个 null 让你查半天。
3.2 实例化之后:先"装配",再"打开"
拿到实例不是就完了 —— 控件还是空的、数据还没画。框架把这段拆成两件不同性质的事:
- 装配(只做一次):按绑定计划把字段填上控件、按需挂上 UGUI 监听,然后依次调用
OnBindView()(你的装配代码)与OnInit()。它跟"打开几次"无关,所以池化复用不会重来。 - 进入打开流程:置为
Opening→ 激活物体 → 播放转场 → 转场结束置为Opened→ 调OnOpen()→ 调OnRefreshView()画数据 → 通知所有 Part 打开 → 播放显示动画 → 最后调业务传进来的回调。
为什么要分这么清?因为二者的"频率"不同:装配一辈子一次,打开可以很多次。混在一起写,复用就会变成"重新绑一遍"(白花性能)或"忘了重画"(数据是旧的)——这两种 bug 都很常见。
3.3 挂到哪、盖住谁:重排为什么放到"渲染前一刻"
打开/关闭面板时,框架不会立刻去调兄弟节点顺序、也不会立刻挪挡板,而只是挂一个"待重排"的脏标记;真正干活的是根节点的 LateUpdate(本帧画面渲染之前):
// 一帧里可能开了 3 个、关了 2 个面板 —— 每次开关都立刻重排 = 同一帧排 5 次 开关面板 → 只打脏标记 // O(1),很便宜 … LateUpdate(渲染前) → 一次做完: ① 各层内部按打开顺序排兄弟节点 ② 找出"谁提供本层遮罩" → 把挡板插到它正下方 ③ 通知"被盖住 / 恢复"变化的面板(OnCovered)
这样做的三个附带好处:① 同一帧开关多次只排一次;② 排序发生在渲染前,所以"本帧开的面板本帧就看到正确层级",不会闪一下;③ 排序只有一个地方做,不会出现"这里排了、那里又设了 sortingOrder"的打架。
Scene → Normal → Popup → Toast → Guide → Top),层内 = 打开顺序(后开的在上面)。全部靠同一 Canvas 下的子节点顺序表达,不需要每个面板自己设排序号。
3.4 关闭:为什么"关"是异步的
关闭不是一句 SetActive(false),而是一条有始有终的流程:
Closing → 通知 Part 关闭 → 播关闭转场 → OnClose() → 摘掉本面板所有事件 → 播隐藏动画 → 置 Closed → 管理器收尾 ├─ 可复用(KeepAlive)→ 失活后放回池 └─ 否则 → 挂进"待销毁"队列,下一帧才真正销毁 + 还资源引用
因为中间有转场和动画,所以"关闭"必然跨帧 —— 这才需要 Closing 这个状态(见第四章)。延迟一帧销毁则是因为:关闭请求很可能发生在"正在遍历某个列表/正在派发事件"的过程中,边遍历边删对象是经典崩法,所以统一推到下一帧的 Update 里做。
3.5 一次打开里的五个"异步边界"
读完上面,就能理解为什么这套框架里"打开"不是一个瞬时动作。源码里一共有五处会让时序"跨帧":
| 边 | 在哪里 | 什么时候会真的异步 |
|---|---|---|
| A | 预制体加载 | 资源不在缓存里时(真机首次)= 后续帧完成;缓存命中时同步完成 |
| B | 面板自定的打开转场 PlayOpenTransition | 你重写了这个钩子并自己做了动画时;默认实现是立刻回调(不拖帧) |
| C | 面板预设的显示动画 | 用了一行预设动画时;用 None 则同步完成 |
| D | 预制体级 Part 的创建 | Part 自己的预制体要加载时 |
| E | 实例销毁 | 总是延迟一帧(避免遍历中删除) |
4生命周期:四个状态 + 一组钩子
4.1 四个状态:为什么不是"开 / 关"两个
不存在 ──创建──▶ Opening ──转场结束──▶ Opened ──调 Close──▶ Closing ──转场+动画结束──▶ Closed ▲ │ └──────────── 复用再打开(走池,装配不重来)◀──────────────┤ └─▶(KeepAlive 回池 / 否则下帧销毁)
| 状态 | 为什么需要它 | 代码里怎么体现 |
|---|---|---|
Opening | 打开要经历"转场 + 动画",中间的每一刻都既不是没开、也不是已就绪。没有这个状态,就会出现"动画还没播完,业务回调已经拿它当已打开用了"。 | 状态设为 Opening 后才激活物体;转场回调里先做状态守卫再往下走 |
Opened | 真正的"可用"时刻:此时数据已画、动画已播完,业务可以对它做任何事。 | OnOpen 与业务回调都在进入这个状态之后触发 |
Closing | 关闭也跨帧(转场 + 隐藏动画)。它同时是一个安全阀:正在关闭的实例不会被拿来复用,避免"关到一半又被打开"的错乱。 | 发起关闭立刻从管理器索引里摘掉;期间再打开同面板会走池/新建,而不是复用这个正在关闭的 |
Closed | 一个明确的终点,决定它去哪:回池待命,还是被销毁。 | 管理器按缓存模式收尾:回池 / 挂进待销毁队列 |
4.2 钩子清单:谁在什么时候被调、调几次
钩子指的是"框架在固定时机回头调用你的方法"。下面这张表是完整清单(按真实调用顺序):
| 钩子 | 什么时机 | 调几次 | 为什么需要它 |
|---|---|---|---|
OnBindView() | 装配末尾(首次创建时) | 一次 / 实例 | 必写。只做表现装配:拿控件引用、挂交互。因为它是抽象方法,你不可能"忘了写" |
OnInit() | 紧接着 OnBindView | 一次 / 实例 | 放"一辈子只做一次"的初始化(比如订阅一个长期服务)。复用时不会再调 |
OnReuse() | 从池里取出、即将再打开时 | 每次复用 | 清界面残留(滚动位置、页签、输入框、选中态)。框架已经把数据替你清了,界面残留只有你知道怎么清 |
OnOpen() | 转场结束、状态已是 Opened | 每次打开 | 放"每次打开都要做的事":发请求、启动倒计时。此时界面已就绪,能安全操作 |
OnRefreshView() | 打开时必调;SetData 时按需 | 多次 | 带数据的界面里它是抽象方法:强制你写清"数据怎么落到界面上"。它只许落屏,不许发请求 |
OnDataChanged(old, new) | 每次 SetData | 多次 | 知道"变了什么",做增量更新(改一个数字就不必整屏重画) |
OnCovered(bool) | 被上层遮罩盖住 / 恢复时 | 状态变化时 | 让"被挡住"的界面可以省掉自己的开销(停特效、停动画、暂停刷新)。状态没变不会调,所以不会反复触发 |
OnClose() | 关闭转场结束、摘事件之前 | 每次关闭 | 收尾:停计时器、提交未保存的输入 |
OnRelease() | 真正销毁前 | 一次 / 实例 | 只有"确定不再复用"时才来。放不可逆的清理 |
PlayOpenTransitionPlayCloseTransition | 打开 / 关闭中 | 每次 | 把转场做成可插拔钩子:默认什么都不做、立刻回调;想加自己的动画就重写它(因此框架不依赖任何缓动库) |
OnClick / OnToggleChanged / OnSliderChanged / OnInputChanged / OnInputEndEdit / OnDropdownChanged / OnScrollChanged / OnLongPress / OnLoosen | 对应控件发生交互时 | 每次交互 | 按节点名分发的交互入口:控件改名字/换结构时,改的是预制体,代码里的分支字符串一眼可见(比硬编码 Find 路径好查) |
OnPartChanged(part) | Part 通知宿主时 | 按需 | 让 Part 通过宿主与外界沟通,而不是 Part 之间互相引用(决策 4) |
4.3 四条容易被误解的规则
| 现象 | 为什么是这样设计的 |
|---|---|
复用时 不会再调 OnBindView / OnInit | 它们管的是"结构"(控件引用、一次性订阅),复用并没有换结构。重新绑一遍是白花性能,也把"一次性初始化"重复执行了两次 |
OnReuse 在取出时调,而不是关闭时 | 越贴近"再次打开"越安全:关闭到再次打开之间可能还有别的逻辑在读这个面板的数据;在关闭时就清,会把这些逻辑打断 |
还没打开时 SetData 不会立刻重画 | 数据先存着,等 OnOpen 之后统一画一次。否则"设数据 → 立刻重画(界面还没就绪)→ 打开又重画"白干一次 |
打开一个已经打开的面板,不会重走 OnOpen | 它本来就是开的。合理行为是"置顶 + 刷新数据";如果每次都重跑打开流程,业务里的"初始化请求"就会被重复发 |
5关键设计决策(源码为什么这样写)
这一章是全篇的重点:挑出这套框架里"看起来怪、其实有理由"的十个写法,每个都说清不这么做会怎样、源码里怎么体现、代价是什么。行号对应 Assets\Revolution\Runtime\RevUISystem\。
决策 1:用"特性 + 反射"声明,而不是代码生成
问题:UI 绑定天然有个两难 —— 手写 Find("Panel/Panel/Btn") 是硬编码,改名就悄悄变 null;而"代码生成"(王者的 UI_BINDING 那套)虽然编译期就能报错,但要多一整套生成流程:改预制体要重新生成、生成物要入库、生成器和手写代码要分工。
做法:把"意图"写成声明([RevUIPanel] 说清资源在哪、放哪层;[RevBind] 说清字段绑哪个节点),运行时读一次、缓存起来。这条路把"多一道流程"换成了"第一次运行就报错"。
源码里长这样:Core\RevUIAttributes.cs:105-108 明写了"为什么不学那套代码生成";Support\RevUIPanelMeta.cs:84-86、:104-117 规定解析失败必须报错而不是静默猜路径;Support\RevUIBinder.cs:107-111、:200-215 在找不到节点时把"根节点下真实存在的节点名"全列出来,直接告诉你该改成什么。
代价:① 错误从"编译期"推迟到"运行期第一次",所以框架必须把这句错误写得足够好(这就是决策 1 里花最多代码的部分);② 反射本身有开销 —— 所以有了决策 2。
决策 2:绑定计划 —— 每个类型只反射一次
问题:反射慢,但 UI 实例化很频繁(同一个面板可能开开关关几十次)。如果每次实例化都去"扫字段、读特性、按名字找节点",开销会翻倍;如果每次都全量扫一遍所有子控件(早期版本那样),更浪费。
做法:把"这个类型该怎么绑"算成一份计划(RevUIBindPlan):字段表、规范化后的节点名、以及需要哪些控件事件(WantsClick / WantsScroll…)。计划按业务类型缓存,运行时只做"字符串 → 找节点 → 取组件 → 赋值"。
源码里长这样:Support\RevUIBindPlan.cs:90 的缓存表以 Type 为键,:100-109 是"命中就用、没有才算",:116-143 是唯一的计算点;Support\RevUIBinder.cs:13-19 说明它相对旧做法改了三处(全量扫 → 按需扫、每按钮闭包 → 继电器、每次重挂 → 挂一次)。
代价:缓存是按类型算的,所以"某个具体实例的字段要不要绑"不能靠计划决定 —— 这没问题,因为绑什么本来就由类型决定。真正的代价在决策 2 的诚实版:走方法特性(如 [RevButtonClick])派发时,每次触发会有一点点小分配(反射调用 + 参数数组),所以高频事件(滚动、输入)建议用重写回调 —— 这句代价明写在 Core\RevUIWidgetEvents.cs:40-44。
决策 3:一个面板只有一个脚本 —— 分层靠"钩子职责",不靠拆类
问题:参考实现把界面拆成 View / Logic / System / BusinessLogic 四层,干净但一个界面要写好几个文件、还要跨语言。要不要照抄?
做法:不拆类,改成"一个类,但每个钩子只许干一件事":OnBindView 只装配、OnRefreshView 只落屏、OnClick 等交互回调只做业务转发。用结构约束,而不是靠注释约定 —— 带数据的界面里 OnRefreshView 是抽象方法,不写就编译不过。
源码里长这样:Core\RevUIPanel.cs:6-18 是这段取舍的原文;:93-98 与 :110-114 用注释把三个钩子的边界钉死;Core\RevUIPanel.Generic.cs:28-30、:51 把"有数据就必须写清数据怎么落屏"变成编译期强制。
代价:逻辑量大的面板会变胖。这是刻意接受的:补救办法不是提前拆类,而是"什么时候该抽出去"有明确信号(见第七章)。
决策 4:Part 只认"宿主接口" —— 可复用单元的解耦
问题:一个界面里经常有"能整块搬去别处"的东西(背包格子、奖励条目、页签组)。如果它就是面板里的一个节点,搬不走;如果做成"小面板",又会牵扯层级、遮罩、独立开关一堆它不需要的东西。
做法:引入 RevUIPart:依附于宿主的可复用单元,生命周期完全跟随宿主(宿主开它就开、宿主关它就关),并且只通过一个四成员接口与宿主沟通,所以能跨面板复用。它有两种形态:摆在宿主预制体里的节点级(零加载),和独立预制体的预制体级(可跨面板复用)。
源码里长这样:Core\RevUIPart.cs:6-11 定义"Panel 与 Part 的区别";Interfaces\IRevUIPartHost.cs:6-15、:22-38 是那四个成员(拿挂点 / 宿主开着吗 / 请求关闭 / 通知宿主);Support\RevUIPanelMeta.cs:161 用"有没有写 root"来判定是哪种形态;Part 与 Part 之间不直接通信,必须经宿主中转。
代价:宿主侧要负责"刷新哪些 Part"(框架不自动刷全部 Part,否则一次页签变化会带出整屏重绘);另外 Part 的关闭是同步的,所以给它配"隐藏动画"通常没意义。
决策 5:层级靠"结构"决定,遮罩由框架自动铺
问题:弹窗要挡住下面的点击,这是 UI 最常见的需求,也是最容易做乱的地方:每个弹窗自己摆一块遮罩、自己设 sortingOrder,最后没人说得清"现在谁在最上面"。
做法:把"位置"变成结构:六个层从下到上固定;层内顺序 = 打开顺序;"谁提供本层遮罩"由层级规则推断(例如 Popup / Guide / Top 层默认挡),挡板由框架自动插到那个面板正下方。
源码里长这样:Core\RevUIDefine.cs:20-41 明确要求"枚举值必须是从下到上的连续序号",管理器按它建挂点、推遮罩、排序;Implementation\RevUIRoot.cs:29-30 点明"层间关系靠子节点顺序,不让每个面板自己设 sortingOrder";Implementation\RevUIPopupMask.cs:36 是"一层一块挡板"(不是每面板一块、也不是全局唯一),:64 把它插到对应面板正下方。
透明 Image 虽然看不见,但仍然会生成网格、参与绘制,全屏就是一笔白白多出来的 overdraw。这里的挡板用的是不生成任何顶点的 Graphic(RevUIRaycastBlocker,Implementation\RevUIPopupMask.cs:18-20):射线照样命中,GPU 零开销。
代价:想让某个浮层"看一眼但不打断操作",必须显式声明(Mask = None);不然它会按层级默认规则挡住下面。
决策 6:UI 专用实例池 + 清理契约(不套通用对象池)
问题:界面关掉就销毁 → 下次打开要重新加载 + 实例化,明显卡顿;关掉不销毁、下次直接用 → 快,但"上次的东西还在"。更微妙的是:能不能用框架里现成的通用对象池?
做法:做专用的 RevUIPanelPool。理由很实在:界面复用需要重置的东西远多于普通对象(控件引用、监听、数据、滚动位置、选中态、输入内容、倒计时…),通用池只懂"失活 / 激活",管不了这些。所以复用必须是"有契约的路径":池只负责存取,清理由必被调用的 OnReuse() 负责,而框架先替你把数据清掉。
源码里长这样:Implementation\RevUIPanelPool.cs:6-12 明确写了"不要用通用 GameObjectPool"的结论;:44-48 说明为什么 OnReuse 放在"取出时"调;Support\RevUISetting.cs:41-45 的 MaxCachedPanels 默认是 1 —— 一个面板同时只会开一份,多出来的回收掉,避免"池变成内存黑洞"。
代价:每个面板都要写清"复用时哪些残留要清"。框架把能替你做的都做了(清数据、兜底摘事件),但界面上的残留只有你懂。
决策 7:异步安全 —— 状态守卫、在途合并、延迟一帧销毁
问题:UI 的时序是"异步 + 并发"的高发区:连点两次打开、加载还没回来就被关掉、关闭动画播到一半又被打开、遍历列表时删对象……每一种都会变成偶发 bug。
做法:四条具体的防线:
| 防线 | 不设它会怎样 | 源码体现 |
|---|---|---|
| 在途合并 | 连点两次 → 加载两次、创建出两个实例,两个回调各拿一个(业务以为只有一个界面) | Implementation\RevUIManager.cs:113-118:命中在途请求就把回调并进去,只加载一次 |
| 状态守卫 | 关闭动画播到一半又点打开 → 打开的回调按"已关闭"的实例继续跑,状态错乱 | Core\RevUIPanel.cs:299-306、:335-340、:348-352:每个异步回调里先验状态,变了就放弃这一步 |
| 延迟一帧销毁 | 在事件派发/列表遍历中途销毁对象 → 空引用或"集合被修改" | Implementation\RevUIRoot.cs:501-505、:544:只挂进待销毁队列,下一帧 Update 统一处理 |
| 根节点可重建 | 根被销毁(切场景/上层代码误删)后,管理器还拿着死引用 | Implementation\RevUIManager.cs:108、:577-590:每次打开前确认根可用,不可用就清索引重建;加载回来发现根没了就只还资源引用 |
代价:代码里到处能看到"先判断再做事"的守卫(读起来啰嗦一点,但每条守卫都对应一个曾经会偶发的事故)。另外业务必须接受"打开完成要用回调"这件事(见 3.5)。
决策 8:动画自己写 —— 采样模型 + 余量结转 + 不受暂停影响
问题:界面动效通常第一反应是引一个缓动库(早期版本就是硬依赖 DOTween,扩展写了 1000+ 行)。但依赖会带来三个约束:包体与版本、暂停时的行为、以及"每帧零分配"这个性能底线能不能保证。
做法:自研一个小内核,三条铁律:①采样模型(用"已过时长 / 总时长"算缓动系数,不是每帧累加,掉帧不改变总时长、不累积误差);②帧余量结转(延迟、循环、往返时把超出部分带进下一段,不丢时间);③零 GC 热路径(运行时对象池 + 句柄带版本号,防止"停错动画")。时间源用不受时间缩放影响的 unscaledDeltaTime。
源码里长这样:Animation\RevUIAnimEngine.cs:11-17 就是这三条铁律的原文;Animation\RevUIAnimDriver.cs:6-13 说明"复用 RevMono 驱动、不新起隐藏宿主"和"用 unscaledDeltaTime 所以暂停时 UI 照样能播";Animation\RevUIAnim.cs:264-265 规定"同一控件只留一个动画"(新动画顶掉旧的),避免两个动画抢同一个属性。
代价:自己维护一套缓动与内核(约 7 个文件);能力上也刻意收窄(不做"缓动强度混合"那类进阶参数)—— 换来的是零依赖、暂停时行为可控、以及全局关掉动画时业务一行都不用改。
决策 9:事件防泄漏做成"机制",而不是"纪律"
问题:面板注册了全局事件,面板关了却忘了退订 —— 这是 UI 最常见的内存/空引用来源,而且"靠人记得"必然会在某个赶工的下午失守。
做法:注册事件时带上 owner: this,面板关闭/释放时框架一行代码把"登记在它名下的事件"全部摘掉。复用时再兜一次。
源码里长这样:Core\RevUIPanel.cs:27-29 就是这条设计的注释("机制而非纪律");关闭流程里 RevEvent.RemoveAllByOwner(this) 是固定步骤;OnDestroy 里还有一次兜底。
代价:只有"登记在面板名下"的事件才摘得掉 —— 用别的 owner 注册的监听(例如挂在某个长期服务上)仍需自己按生命周期管。
决策 10:Canvas / EventSystem / 图层由框架自建,但不抢项目的
问题:接进任何一个工程,第一道坎都是"UI 根在哪"。让业务自己建 → 每个项目一套摆法、升级框架要重摆;框架强行接管 → 和项目已有的 Canvas / EventSystem 打架。
做法:折中:优先用可替换的预制体(设了路径就加载),载不到就用代码建并给一句警告;EventSystem 只在场景里一个都没有时才兜底创建;图层名统一成一个(默认 UI),框架自己建的东西都归到这一层。
源码里长这样:Implementation\RevUIRoot.cs:96-137(预制体优先、代码兜底)、:229-237 与 :239-254(EventSystem 只兜底、不抢)、Support\RevUISetting.cs:93-99(默认预制体路径)、:114-120(图层名)。
EventSystem 的输入模块优先用反射去找新输入系统的 InputSystemUIInputModule,找不到才退回旧的 StandaloneInputModule。原因是:新输入系统是可选包,如果直接 using 它,没装那个包的工程会编译不过(Implementation\RevUIRoot.cs:256-260、:262-290)。框架的存在不能给工程增加"必须装什么"的前提。
代价:配置项变多(渲染模式、画布架构、相机、图层、EventSystem 开关…),但你可以在不改代码的前提下适配自己的工程。
决策 11:默认单 Canvas,只有必要时才"动静分离"
问题:Unity 的 UI 有一个绕不开的性能特性:同一块 Canvas 上只要有一个元素发生变化,整块 Canvas 的网格就会重建。于是"界面一多就卡"的常见对策是把 Canvas 拆开 —— 常驻不变的放一块、频繁变化的放另一块,让变化不要波及静态内容。要不要默认就这么拆?
做法:默认单 Canvas(简单、少一层心智负担;绝大多数界面根本到不了这个瓶颈),同时提供"三 Canvas 动静分离"作为可选架构:常用(根画布)/ 静态 / 动态三块,面板可以用属性声明自己进哪块。并且明确一条纪律:拆分要靠 Profiler 实测决定,不达标就退回单 Canvas。
源码里长这样:Core\RevUIDefine.cs:129-147 是画布架构与画布类型的定义;:204-221 规定"动静分离时,静态 / 动态子画布只收 Scene 层,其余层一律放回常用画布" —— 否则弹窗会被常用画布里的界面盖住;Implementation\RevUIRoot.cs:199-218 给每个子画布都挂上射线组件(不挂就点不到);Implementation\RevUIRoot.cs:73、:178 说明这个配置只在根节点创建时读一次,之后不再变。
代价:① 拆分不是免费的:弹窗 / 引导层必须留在最上层画布,混合了静态与动态内容的预制体要自己拆成两个面板才会真的分离;② 收益完全取决于具体界面,必须用 Profiler 对比验收;③ 配置只在第一次打开面板前有效,运行中改没用。
这套框架里所有"看起来怪"的写法,都在回答同一个问题:怎么让"一个界面 = 一个脚本 + 一个预制体"这件事在真机异步、随时关闭、随时复用、还可能被上层打断的情况下,依然不泄漏、不串状态、不静默失败。
于是就有了:三层"每类型只算一次"的类型缓存(元数据 / 绑定计划 / 事件特性)、状态机 + 状态守卫保住的异步安全、池 + OnReuse + 一行摘事件保住的复用安全、脏标记 + LateUpdate 保住的重排安全、unscaledTime + 自研采样引擎保住的动效安全。
6刻意的取舍与边界
一个框架的成熟度,往往体现在它明确不做什么上。下面这些"故意不这么做",每一条都能在源码注释里找到原文:
| 刻意不做 / 不这样写 | 为什么 | 代价或边界 |
|---|---|---|
不做代码生成(不学 UI_BINDING 那套) | 少一道流程:改预制体不用重新生成、不用管生成物入库与分工 | 错误从编译期推迟到"第一次运行",所以报错文案必须够好(决策 1) |
不拆 View / Logic / System / BusinessLogic 多类 | 那样切是为了热更边界与跨语言边界(TS),这里两者都不存在 | 大面板会变胖 → 用第七章的信号决定何时抽服务 |
界面不用通用对象池(RevPool) | 界面复用要重置的东西远多于普通对象,通用池只懂"失活 / 激活" | 多了一个专用池要维护(约 117 行),换来的是清理契约成立 |
| 遮罩不用全屏透明 Image | 透明仍会生成网格、全屏就是一笔白花的 overdraw | 要自己写一个"不生成顶点"的 Graphic(约 100 行的挡板组件) |
不让每个面板自己设排序号(sortingOrder) | 排序只能有一个地方做,否则早晚互相打架 | "想插到某人上面"只能用层级 + 打开顺序表达,不能随手调数字 |
| EventSystem 只兜底、输入模块用反射找 | 不抢项目自己的;且新输入系统是可选包,直接 using 会让没装包的工程编译不过 | 反射代码略长;模块类型改名时要跟着改 |
预留参数 LayerStep 没接入 | 当前架构层间靠子节点顺序,不靠排序号步进 | 改它没有任何效果 —— 别被名字骗了(源码里写明了) |
| 解析失败直接报错,不静默猜路径 | 静默猜路径 = 把问题推到"图怎么不出现"那一刻,更难查 | 首次接入时报错更"硬",得照着文案改 |
| 名字规范化只做两条例外,其余严格匹配 | 规范化规则越多越难推理,隐藏 bug 越多 | _btnClose / m_title 这类命名要按约定来 |
| 只扫"你确实要用"的控件,每个交互节点一个继电器不写闭包 | 全量扫 + 每按钮闭包 = 白花性能与 GC(旧框架的写法) | 框架要能准确判断"你要用哪些"(靠"重写了哪些回调 + 有没有方法特性") |
| 输入框的两个事件分开挂 | 旧框架混在一起,导致每敲一个字都触发一次"结束编辑" | 业务要分两个回调写(OnInputChanged / OnInputEndEdit) |
长按不用 Update 轮询,且在"抬起时"派发 | 轮询白耗;抬起时派发才能保证一次操作最多"长按 + 松开",不与点击抢瞬间 | 需要一个 UGUI 指针事件继电器(多一个小文件) |
| 不自动刷新所有 Part | 否则一次页签变化会带出整屏重绘 | 宿主自己决定刷哪个(RefreshParts() 或指定 Part) |
| Part 的关闭是同步的 | 依附单元的价值是"跟着宿主",不是独立演一段离场 | 给 Part 配 HideAnimation 通常没意义 |
| 关域重载时只清运行时索引,不清类型缓存 | 缓存里是 Type / FieldInfo / 字符串,不引用场景对象,清了纯属白花 | 需要分清"什么该清、什么可以留"(这条本身就是一次 bug 的产物) |
7System / BusinessLogic 要不要做
参考实现的 UI 是四层,本框架收成了一层(一个面板 = 一个脚本 + 职责钩子)。那 System(跨界面共享的业务能力)与 BusinessLogic(单屏流程编排)还有没有必要单独做?
结论:不新增这两个类,而是把它们各自解决的问题,落在框架已有的两个机制上。
7.1 为什么不需要(那三条约束都不在)
| 参考实现必须有这两层的约束 | 这里的情况 |
|---|---|
| ① 热更边界:界面逻辑在 TS(可热更)、系统逻辑在 C#(不可热更),分层就是"能不能热更"的分界线 | 不存在:本框架不做代码热更(资源热更与大版本锚定,和代码分层无关) |
| ② 跨语言边界:Puerts 桥接有成本,分层是为了减少跨语言调用与传参 | 不存在:全 C#,一次普通方法调用而已 |
| ③ 组织边界:界面同学与逻辑同学分工,接口层是契约 | 弱化:中小团队 / 单人时,"契约"用接口表达就够,不需要"一层" |
| ④ 界面与游戏内核解耦(唯一与热更无关、仍然成立的价值) | 仍然需要 —— 但框架已经备好落点:服务定位器 |
7.2 那两个"落点"是什么
| 参考实现的层 | 在本框架里落到哪 | 现成机制 |
|---|---|---|
| System | 业务服务接口 + 服务定位器 | RevServiceLocator:组合根装配(Build() 之后改不了)、单例 / 作用域两种生命周期、逆序释放、循环依赖检测、可每帧驱动 |
| BusinessLogic | 面板自己的数据对象 + 钩子 | RevUIPanel<TData>:装配 / 落屏 / 业务三个钩子各管一件事,数据走单向流 SetData → OnDataChanged → RefreshView |
7.3 什么时候必须重新拆(诚实的边界)
| 信号 | 怎么做 |
|---|---|
| 同一套流程被 ≥2 个界面复用(如"购买流程"被商城 / 背包 / 英雄详情共用) | 抽成领域服务(IPurchaseService)注册到服务定位器 —— 不是新增 UI 层 |
| 逻辑要"无 UI 也能跑"(单测 / 离线算 / 与服务端同规则) | 写成纯 C#、不引用 UnityEngine 的类放服务层;服务定位器模块本身就是这样,能丢进普通 .NET 工程跑断言 |
| 单屏逻辑 > 500 行,或状态机复杂(多分支、可中断、可回退) | 抽 XxxFlow 流程类;面板只剩"调用 + 落屏" |
| 将来真的要上代码热更 | 那时才需要"可热更逻辑 vs 不可热更表现"的物理分层 —— 这是唯一会让结论翻转的条件 |
7.4 代价对照("不做两层"不是偷懒)
| 再加 System + BusinessLogic 两层 | 服务 + 面板(本框架) | |
|---|---|---|
| 每屏文件数 | 3~4 | 1~2(面板 + 可选数据对象) |
| 数据流 | 视图 ← Logic ← BusinessLogic ← System(4 跳,改一处要顺链找) | 面板 ← 服务(1 跳) |
| 生命周期 | 要额外管"两层各自的生灭与释放" | 面板关了就没了;服务由容器统一释放 |
| 真正的代价 | 多一层跳转、状态可能存两份,排查路径变长 | 逻辑量大时面板会变胖 → 按 7.3 的信号及时抽服务 |
SetData)。
唯一值得写进框架的是"面板基类加一个 Services 便捷属性"这类便利 —— 而它被明确否决了:那需要"全局默认容器"的概念,正是服务定位器注释里批判过的老路(到处赋值、谁都能改)。业务自己包一层显式入口更清楚。
8验证结果
这套框架的验证分两层:纯逻辑内核在工程外跑真断言(不开 Unity),整个程序集用 Unity 真实引用编译。
| 验证项 | 结果 |
|---|---|
| 工程外行为断言:目录规范化 / 资源名默认取类名 / 遮罩按层推断 / 字段名 → 节点名 / 显式路径优先 / 继承字段收集 / "只挂重写的回调" / 元数据与绑定计划缓存 / 忘了写特性的报错文案 … | 65 项全部通过 |
画布归属与排序规则断言:单 Canvas 一律常用 / 静态与动态子画布只收 Scene 层 / 其它层放回常用并告警 / 未知枚举兜底 / 排序号 根−2 < 根−1 < 根 / 常用画布在最上 | 12 项全部通过 |
整程序集编译:Revolution.Runtime 与 Revolution.Editor(Unity 2022.3 真实引用 + UNITY_EDITOR) | 0 错误 0 警告(本篇成文时重跑复核) |
语言版本 LangVersion 9.0(对齐 Unity 2022.3) | 编译通过 |
三 Canvas 改动后用引擎引用重编(玩家配置 + UNITY_EDITOR 两套) | 0 错误,UI 模块 0 警告 |
断言跑在"要提交的那份源码"上(工程直接链接真实文件,不是复制品)。它验的都是"写的时候看不错、跑起来才知道"的规则,例如:
- 特性里写
"UI/Panel/"、"\\UI\\Panel\\"、" UI/Panel "→ 都得到UI/Panel(不会拼出双斜杠); - 省略资源名 → 取类名;显式写了 → 以显式为准;资源键 = 根目录 + 资源名;
Popup层默认挡、Toast层默认不挡;显式声明永远优先;_btnClose/m_title/__x→ 节点名btnClose/title/x(全是下划线时原样返回,不会给出空名字);- 只重写了
OnScrollChanged→ 只挂滚动监听;一个都没重写 → 连扫子节点都省了; - 基类与派生类里的
[RevBind]字段都会被收集,且不重复。
CS0103:绑定计划里调静态方法漏写类名 —— 纯内核抽出来后,Unity 还没打开就先红了一次;② 用 Unity 引用编译时报类型不存在:门面里把异步源的返回类型名按印象写错了,编译器把它拦下。
这两条正好说明"能脱机验证 + 真引用编译"的价值:它们都是肉眼不容易发现、但一跑就炸的错。
9附:关键文件索引
路径均在 Assets\Revolution\Runtime\RevUISystem\ 下。标注"纯 C#"的文件不引用 UnityEngine,可脱机验证。
| 文件 | 职责 |
|---|---|
Facade\RevUI.cs | 业务唯一入口:打开 / 关闭 / 返回 / 查询 / 预热 / 诊断;只转发 + 失败时给人话文案 |
Implementation\RevUIManager.cs | 唯一状态中心:五级查找、在途合并、互斥组、返回栈、层级重排、池与资源引用配对、诊断 |
Implementation\RevUIRoot.cs | 常驻场景根:建 Canvas / 子画布 / 六层挂点 / 挡板 / EventSystem / UI 相机;Update 处理延迟销毁,LateUpdate 触发重排 |
Implementation\RevUIPanelPool.cs | 面板实例专用池(按面板分桶、后进先出、上限 MaxCachedPanels);不套用通用对象池 |
Implementation\RevUIPopupMask.cs | 弹窗挡板:每层一块、不生成顶点(零 overdraw)、自动插到对应面板下方、点它关该层最上面的弹窗 |
Core\RevUIPanel.cs | 面板基类:生命周期钩子、状态、绑定触发、数据入口、自动摘事件、转场钩子 |
Core\RevUIPanel.Generic.cs | RevUIPanel<TData>:强类型数据入口 + 强制实现 OnRefreshView |
Core\RevUIPart.cs | Part 基类:依附单元、节点级 / 预制体级两种形态、异步创建、资源引用配对释放 |
Core\RevUIDefine.cs 纯 C# | 公共词汇与规则:六层顺序、状态、缓存模式、遮罩模式、画布架构 / 画布类型、画布排序号 |
Core\RevUIAttributes.cs 纯 C# | 声明式特性:[RevUIPanel] / [RevUIPart] / [RevBind];含"为什么不代码生成"的理由 |
Core\RevUIWidgetEvents.cs 纯 C# | 方法特性路径(9 类控件事件):按类型扫描 + 缓存 + 参数形状校验 + 派发 |
Support\RevUIPanelMeta.cs 纯 C# | 特性 → 元数据解析与缓存(目录规范化、资源名默认取类名、拼资源键);报错文案也在这 |
Support\RevUIBindPlan.cs 纯 C# | 绑定计划:字段 ↔ 节点名 ↔ 控件类型、"这个类重写了哪些回调"(WantsXxx) |
Support\RevUIBinder.cs | 装配执行者:按计划赋值控件、按需装 UGUI 监听、自定义控件的自动事件注册口 |
Support\RevUIButtonPressRelay.cs | 交互节点的"按住 / 松开"继电器(UGUI 不报这两个事件),阈值用不受暂停影响的时间 |
Support\RevUISetting.cs | 全局配置 + 统一日志出口(含异常隔离 Guard):适配 / 排序 / 缓存 / 遮罩 / 动画开关 / 渲染模式 / 图层 |
Support\RevUIUnityHooks.cs | 进 Play 前清管理器运行时索引(关掉"域重载"时也能反复运行) |
Interfaces\IRevUIPartHost.cs | Part 的宿主契约(四成员)—— Part 能跨面板复用的关键 |
Interfaces\IRevUIUserEvents.cs | 控件事件 → 宿主回调的内部转发口(让绑定器 / 继电器只写一份) |
Animation\RevUIAnim.cs | 动画门面:预设一行播、ScaleTo / FadeTo / Breathe、悬停反馈、停止与恢复 |
Animation\RevUIAnimEngine.cs 纯 C# | 动画内核:采样推进 + 帧余量结转 + 循环往返 + 运行时对象池 + 带版本号的句柄 |
Animation\RevUIAnimSpec.cs 纯 C# | 动画规格与预设:三通道掩码(透明度 / 缩放 / 位移)+ 一组现成预设(位移用"自身宽高比例",换分辨率不穿帮) |
Animation\RevUIAnimTarget.cs | 采样落点:把系数写进 CanvasGroup / RectTransform;没 CanvasGroup 自动补,基准值只取一次 |
Animation\RevUIAnimDriver.cs | 每帧推进(复用 RevMono,不新起隐藏宿主;用不受暂停影响的时间) |
Animation\RevUIEase.cs 纯 C# | 缓动曲线纯函数(边界恒等、可脱机断言) |
Animation\RevUIWidgetFeedback.cs | 控件反馈:悬停放大 + 按下缩小(走"目标值动画",连点不会越缩越小) |
Resources\RevUIPrefab\RevUICanvas(根节点模板,用于相机模式 / 自定义根)与 Resources\RevUIPrefab\RevUICamera(UI 相机模板)。
两者都载不到时框架会退回"代码自建 + 一句警告",所以不放这两个资源也能跑。
10本期没做的(可后续扩展)
| 项 | 说明 |
|---|---|
| 面板清单窗口(编辑器工具) | 列出所有面板的"类名 / 资源路径 / 层级 / 缓存方式",一键校验"类名 ↔ 预制体"是否对得上、路径是否真的存在(可复用打包工具那套扫描) |
| 按模板一键创建面板脚本 | 从选定目录生成"面板类 + 数据类 + 骨架",省掉手写特性与钩子(第七章说的"值得做的工具") |
| MessageBox(消息框) | 按参考文档的结论做:队列 + 优先级 + 去重,按钮只转发事件不处理业务;它是标准面板,自定义内容用"空壳 + 内容插槽"的做法 |
| 常用 Part | 无限滚动列表、页签组、通用奖励格 —— 都是"写一次、多处复用"的典型 |
| 面板资源分组可配 | 现在面板预制体统一走一个资源分组;将来可以按面板声明自己的分组,便于按功能卸资源 |
| 打开优先级 | 现在打开是即时的;可以给异步打开加优先级参数,让"读条期预加载"与"临时弹窗"排队更合理 |