# UI 系统 本节将会讲解 2.B 的渲染树与 UI 系统的工作原理,以及一些常用 API。 ## 创建一个自己的 UI 管理器 样板提供 `UIController` 类,允许你在自己的一个 UI 中创建自己的 UI 管理器,例如在样板中,游戏画面本身包含一个 UI 管理器,分为了封面、加载界面、游戏界面三种,其中游戏界面里面还有一个游戏 UI 管理器,我们常用的就是最后一个游戏 UI 管理器。 ### 创建 UIController 实例 我们从 `@motajs/system-ui` 引入 `UIController` 类,然后对其实例化: ```ts import { UIController } from '@motajs/system-ui'; // 传入一个字符串来表示这个控制器的 id export const myController = new UIController('my-controller'); ``` ### 获取 UI 控制器 可以通过 id 来获取到这个控制器,或者直接引入对应文件中的控制器: ```ts import { UIController } from '@motajs/system-ui'; import { myController } from './myController'; const myController = UIController.get('my-controller'); ``` ### 添加到渲染树 接下来,可以直接调用 `myController.render` 方法来添加到你自己的 UI 中: ```tsx {myController.render()} ``` ## UI 显示模式 ### 内置显示模式 UI 管理器内置了两种显示模式,只显示最后一个以及显示所有。其中前者常用于级联式 UI,例如 `设置 -> 系统设置 -> 快捷键设置`,这时候只会显示最后一个 UI,前面的 UI 不会显示。后者常用于展示信息类的 UI,例如在地图上展示怪物信息等。我们可以通过下面这两个方法来设置 UI 显示模式,立即生效,但不推荐频繁切换,建议一个控制器只使用**一种**显示模式: ```ts // 设置为只显示最后一个 myController.lastOnly(); // 设置为显示所有 myController.showAll(); ``` ### 栈模式 对于级联式 UI,我们希望在关闭一个 UI 时,在其之后的 UI 也能关闭,例如对于上面提到的 `设置 -> 系统设置 -> 快捷键设置` 级联 UI,当我们关闭设置界面时,我们会希望系统设置和快捷键设置也一并关闭,而不是需要手动关闭。这时候,栈模式就可以做到这一点,启用栈模式时,关闭一个 UI 后,在其之后的 UI 也会全部关闭。我们依然可以使用上面两个方法来设置是否启用栈模式: ```ts // 设置为显示最后一个,启用栈模式,不过 lastOnly 默认启用栈模式,因此参数可不填 myController.lastOnly(true); // 设置为显示最后一个,不启用栈模式 myController.lastOnly(false); ``` ### 自定义显示模式 ::: info 这一小节内容不重要,没有特殊需求的可以不看。 ::: 样板内置的两个显示模式以及栈模式已经能够满足绝大多数情况,不过可能还会有一些非常特殊的情况满足不了,这时候我们可以使用 `showCustom` 方法来自定义一个显示模式。这个方法要求传入一个参数,参数需要是 `IUICustomConfig` 对象,对象要求实现 `open` `close` `hide` `show` `update` 五个方法,我们来介绍一下如何做出一个自定义显示模式。 方法说明如下: - `open` 方法会在一个 UI 打开时调用,例如默认的 `lastOnly` 模式其实就是在打开 UI 时将 UI 添加至栈末尾,然后隐藏在其之前的所有 UI - `close` 方法会在一个 UI 关闭时调用,例如默认的 `lastOnly` 模式就会在这个时候把在传入 UI 之后的所有 UI 一并关闭 - `hide` 方法会在一个 UI 隐藏时调用,默认的 `lastOnly` 模式会在这个时候把 UI 隐藏显示 - `show` 方法会在一个 UI 显示时调用,默认的 `lastOnly` 模式会在这个时候把 UI 启用显示 - `update` 方法会在切换显示模式时调用,默认的 `lastOnly` 模式会在这个时候把最后一个 UI 显示,之前的隐藏 那么,假如我们要做一个反向 `lastOnly`,即只显示第一个,添加 UI 时添加至队列开头,我们可以这么写: ```ts import { IUICustomConfig, IUIInstance } from '@motajs/system-ui'; const myCustomMode: IUICustomConfig = { open(ins: IUIInstance, stack: IUIInstance[]) { stack.forEach(v => v.hide()); // 隐藏当前所有 UI stack.unshift(ins); // 将要打开的 UI 添加至队列开头 ins.show(); // 显示要打开的 UI }, close(ins: IUIInstance, stack: IUIInstance[], index: number) { stack.splice(0, index + 1); // 关闭传入 UI 及其之前的所有内容 stack[0]?.show(); // 显示第一个 UI }, hide(ins: IUIInstance, stack: IUIInstance[], index: number) { ins.hide(); // 直接隐藏 }, show(ins: IUIInstance, stack: IUIInstance[], index: number) { ins.show(); // 直接显示 }, update(stack: IUIInstance[]) { stack.forEach(v => v.hide()); // 先隐藏所有 UI stack[0]?.show(); // 然后显示第一个 UI } }; myController.showCustom(myCustomMode); // 应用自己的显示模式 ``` ## 设置 UI 背景 我们可以为 UI 设置背景组件,背景组件在 UI 打开时常亮。我们推荐使用此方法来为 UI 设置背景,因为它可以搭配 `keep` 防抖动来使用,避免出现 UI 闪烁的问题。现在,我们使用样板内置的 `Background` 背景组件作为例子,来展示如何设置背景: ```ts import { Background } from '@user/client-modules'; // 传入背景组件作为背景,然后设置参数,使用 winskin.png 作为背景 myController.setBackground(Background, { winskin: 'winskin.png' }); ``` 默认情况下,当我们打开 UI 时,背景组件将会自动展示,不过我们也可以手动控制背景组件是否显示,它的优先级高于系统优先级: ```ts myController.hideBackground(); // 隐藏背景组件,即使有 UI 已经打开,也不会显示背景 myController.showBackground(); // 显示背景组件,在 UI 已经打开的情况下展示,没有 UI 打开时不显示 ``` ## 背景维持防抖动 有时候,我们需要关闭当前 UI 然后立刻打开下一个 UI,例如使用一个道具时可能会打开一个新的页面,这时候会先关闭道具背包界面,再打开道具的页面,这时候可能会出现短暂的“背景丢失”,这是因为 UI 的挂载需要时间,在极短的时间内如果没有挂载上,那么就会在屏幕上什么都不显示,上面设置的背景 UI 也不会显示,会引起一次闪烁,观感很差。为了解决这个问题,我们提供了背景维持防抖动的功能,使用 `keep` 方法来实现: ```ts const keep = myController.keep(); ``` 调用此方法后,在下一次 UI 全部关闭时,背景会暂时维持,直到有 UI 打开,也就是说它会维持一次 UI 背景不会关闭,下一次就失效了。这样的话,如果我们去使用一个打开页面的道具,就不会出现闪烁的问题了。不过,假如我们使用了一个没有打开页面的道具,会有什么表现?答案是背景一直显示着,用户就什么也干不了了,这显然不是我们希望的,因此 `keep` 函数的返回值提供了一些能力来让你关闭背景,它们包括: ```ts // 推荐方法,使用 safelyUnload 安全地卸载背景,这样如果有 UI 已经打开,不会将其关闭 keep.safelyUnload(); // 不推荐方法,调用后立刻关闭所有 UI,不常用 keep.unload(); ``` ## 打开与关闭 UI 在 UI 编写章节已经提到了打开和关闭 UI 使用 `open` 和 `close` 方法,现在我们更细致地讲解一下如何打开与关闭 UI。打开 UI 使用 `open` 方法,定义如下: ```ts function open( ui: IGameUI, props: UIProps, alwaysShow?: boolean ): IUIInstance; ``` 其中第一个参数表示要打开的 UI,第二个表示传给 UI 的参数,第三个表示 UI 是否永远保持显示状态(除非被关闭),不受到显示模式的影响。同种 UI 可以打开多个,也可以在不同的控制器上同时打开多个相同的 UI。例如,如果我们想在主 UI 控制器中添加一个常量的返回游戏按钮,就可以这么写: ```ts // BackToGame 是自定义 UI,第三个参数传 true 来保证它一直显示在画面上 myController.open(BackToGame, {}, true); ``` 关闭 UI 使用 `close` 方法,传入 UI 实例,即 `open` 方法的返回值,没有其他参数。例如: ```ts const MyUI = defineComponent(props => { // 所有通过 UI 控制器打开的,同时按照 UI 模板填写了 props 的 UI 都包含 controller 和 instance 属性 props.controller.close(props.instance); }, myUIProps); ``` 除此之外,还提供了一个关闭所有 UI 的: ```ts function closeAll(ui?: IGameUI): void; ``` 其中参数表示要关闭的 UI 类型,不填时表示关闭所有 UI,填写时表示关闭所有指定类型的 UI。例如我想关闭所有 `EnemyInfo` UI,可以这么写: ```ts // EnemyInfo 是自定义 UI myController.closeAll(EnemyInfo); ``` ## 渲染系统的树结构 接下来我们来讲解一下渲染系统的一些工作原理。下面的部分由 `DeepSeek R1` 模型生成并稍作修改。 ### 结构原理 想象一棵倒着生长的树: - 根节点:相当于画布本身,是所有元素的起点 - 枝干节点:类似文件夹,可以包含其他元素 - 叶子节点:实际显示的内容,如图片、文字等 ### 运作特点 - 层级管理:子元素永远在父元素的"内部"显示 - 自动排序:像叠扑克牌一样,后添加的元素默认盖在之前元素上方,不过也可以通过参数来调整顺序 - 智能裁剪:父元素就像相框,超出范围的内容自动隐藏 ## 渲染系统的事件系统 ### 事件传递三阶段 1. 收件扫描(捕获阶段):从根部开始层层扫描,寻找可能接收事件的元素,类似快递分拣中心扫描包裹目的地 2. 精准投递(目标阶段):找到实际触发事件的元素进行处理,就像快递员将包裹送到收件人手中 3. 回执确认(冒泡阶段):处理结果沿着原路返回汇报,如同收件人签收后系统更新物流状态 将事件分为三个阶段,是为了让交互更加符合直觉,你也不想点击内层按钮的时候外层按钮也被触发吧) ### 特殊处理机制 - 紧急拦截:任何环节都可以标记"无需继续传递" - 批量处理:多个事件自动合并减少处理次数 - 智能过滤:自动忽略不可见区域的事件 ## 冒泡更新 ### 工作原理 当某个元素发生变化时:自动通知直系父元素,父元素检查自身是否需要调整,继续向上传递直到根部,最终统一计算所有需要改变的位置,并在下一帧执行更新。 ### 设计优势 - 精准定位:只更新受影响的部分画面 - 避免重复:多个子元素变化只需一次整体计算 - 顺序保障:始终从最深层开始逐层处理 ## 懒更新机制 ### 工作模式 1. 收集阶段:记录所有需要改变的内容(如颜色变化、文字修改) 2. 等待时机:一般是等待到下一帧 3. 批量处理:一次性完成所有修改 ### 实际效益 - 性能优化:减少像频繁开关灯的资源浪费 - 流畅保障:避免连续小改动导致的画面闪烁 - 智能调度:优先处理用户可见区域的变化