原生 UI 层:托盘、通知、悬浮窗与共享渲染资源
原生 UI 层(src/ui/)是 SpinningMomo 原生 Win32 C++ 后端中独立于 WebView2 前端的桌面 UI 子系统,负责托盘图标、右键上下文菜单、悬浮窗以及跨窗口共享的渲染/动画资源,并通过 core::commands 命令注册表与 core::events 事件总线与业务功能层联动。
Purpose and Scope
本页覆盖以下内容:
src/ui/下四大原生 Win32 UI 模块的职责划分:floating_window、tray_icon、context_menu、webview_window(模块清单见 AGENTS.md)。- 共享渲染与动画资源:
src/ui/composition_animation/与src/ui/context_menu/内的render_context/painter绘制基础设施。 - 命令注册表(
core::commands)如何驱动托盘图标与上下文菜单,以及事件总线如何把后台事件唤醒到 Win32 消息循环。 - 与
features::notifications、features::overlay、features::preview等业务模块的协作边界,以及"预览窗 / 叠加层互斥"这类 UI 层冲突行为。 - 相关本地化键(
src/locales/zh-CN.json、src/locales/en-US.json)与安装器对托盘常驻进程的处理约定。
以下相关主题有意留给兄弟页面,本页只做交叉指引:
- WebView2 前端(Vue 3 应用)页面与前端 RPC 客户端:属于 Web 前端架构页,不在本页范围(见 AGENTS.md)。
- RPC 端点注册与 JSON-RPC 传输细节:属于 RPC/通信层页(见 AGENTS.md)。
- overlay / preview / recording 等业务内部实现:属于各 feature 页;本页只描述它们与原生 UI 的交互面。
core::events事件总线自身实现:属于 core 基础设施页;本页仅从消费方视角描述post()唤醒消息循环的行为。
Overview
SpinningMomo(旋转吧大喵)是一个仅面向 Windows 的桌面工具,服务于游戏《无限暖暖》的摄影、截图、录制等围绕游戏窗口的工作流(见 AGENTS.md)。整体采用"原生 Win32 C++ 后端 + 内嵌 WebView2 前端"的两进程形态,二者通过 JSON-RPC 2.0 通信(WebView bridge 用于生产,HTTP + SSE 用于浏览器内开发调试)。
在 WebView 前端之外,应用还需要一组必须以原生 Win32 形态存在的桌面 UI:
- 托盘图标(tray icon)——应用常驻系统托盘。安装器在升级/卸载时明确要求用户"退出应用(包括托盘图标)",证明托盘是应用的常驻宿主形态(见 Package.en-us.wxl)。
- 上下文菜单(context menu)——托盘与悬浮窗上的原生右键菜单,其条目不是硬编码,而是由
core::commands注册表动态生成。 - 悬浮窗(floating window)——叠加层(Overlay)等覆盖在游戏窗口之上的轻量原生窗口。
- WebView 宿主窗口(webview window)——承载 Vue 前端的 WebView2 容器窗口。
设计哲学上,整个后端不使用 OOP 类继承层级,而是"POD 结构体 + 自由函数":所有状态集中存放在 core::AppState(以 std::unique_ptr 成员持有各子系统状态),函数是接受 AppState& 的自由函数(见 AGENTS.md)。原生 UI 层同样遵循这一约定。
字符串编码约定:内部处理统一 UTF-8(std::string),调用 Win32 API 时转 UTF-16(std::wstring),通过 utils::string 转换(见 AGENTS.md)。这直接影响原生 UI 层:所有菜单文本、窗口标题在进入 Win32 调用前都要经过一次编码转换。
Architecture
下图展示原生 UI 层在整体架构中的位置与依赖关系。实线箭头表示 AGENTS.md 中明文声明的驱动/通信关系;虚线表示基于目录结构与本地化键推断的协作面(推断点在正文逐一说明)。
各组成部分的职责与依据:
| 组件 | 职责 | 依据 |
|---|---|---|
ui::tray_icon | 系统托盘图标,常驻后台的入口 | AGENTS.md、Package.en-us.wxl |
ui::context_menu | 原生右键菜单,由命令注册表生成条目 | AGENTS.md |
ui::floating_window | 悬浮窗(叠加层宿主),含 events.hpp 与 layout.cpp | 目录清单(见下文模块构成表) |
ui::webview_window | WebView2 容器窗口,承载 Vue 前端 | AGENTS.md |
ui::composition_animation | UI 动画支持 | 目录清单:src/ui/composition_animation/animation.{hpp,cpp} |
core::commands | 绑定动作、开关态、i18n 键与可选热键的注册表;"托盘图标与上下文菜单由该注册表驱动" | AGENTS.md |
core::events | 类型擦除事件总线,post() 异步投递并经 PostMessageW 唤醒 Win32 消息循环 | AGENTS.md |
初始化顺序上,原生 UI 层位于核心基础设施之后、业务功能服务之前:main.cpp → Application::Initialize() → core::initializer::initialize_application(),大致顺序为"先核心基础设施,再原生 UI,再功能服务,最后扩展与启动任务"(见 AGENTS.md)。这个顺序是必然的:命令注册表、事件总线、i18n 都是 UI 的前置依赖,而托盘图标一旦创建就要能响应由业务层注册的命令。
模块构成:src/ui/ 的实际目录结构
以下文件清单通过目录列表工具核实,是原生 UI 层的真实物理边界:
| 模块 | 文件 | 说明 |
|---|---|---|
composition_animation | animation.hpp / animation.cpp | UI 动画(共享渲染资源的一部分) |
context_menu | state.hpp / types.hpp | 菜单状态(POD 结构体)与类型定义 |
context_menu | interaction.hpp / interaction.cpp | 用户交互处理 |
context_menu | layout.hpp / layout.cpp | 菜单布局计算 |
context_menu | message_handler.hpp / message_handler.cpp | Win32 消息处理(承接 PostMessageW 唤醒) |
context_menu | painter.hpp / painter.cpp | 菜单绘制 |
context_menu | render_context.hpp / render_context.cpp | 渲染上下文(与 painter 配合的共享渲染资源) |
floating_window | floating_window.hpp / floating_window.cpp | 悬浮窗本体 |
floating_window | events.hpp / layout.cpp | 悬浮窗事件与布局 |
context_menu 是本层中拆分最细的模块(9 个源文件),floating_window 次之。这种拆分方式符合项目"POD 结构体 + 自由函数、状态集中在 AppState"的哲学:每个 .hpp/.cpp 对应一个自由函数簇或一个 POD 状态片段,而不是一个继承体系中的类。
说明:
ui::*的完整模块清单(floating_window、tray_icon、context_menu、webview_window)声明于仓库级指南,且本页源探索预算有限,未能逐一读取每个.cpp的内部实现。上述目录清单来自实际的src/ui/**/*文件列表,tray_icon与webview_window的具体源文件名在本页采证范围内未逐一列出;相关实现细节以仓库源码为准,本页不虚构。
核心机制
1. 命令注册表驱动的菜单与托盘
core::commands 注册表把"动作 + 开关状态 + i18n 键 + 可选热键"绑定为一条命令,托盘图标与上下文菜单的条目由该注册表驱动生成(见 AGENTS.md)。这一设计的关键意图:
- 单一事实来源:菜单条目、托盘交互、热键共享同一份命令定义。新增功能时只需向注册表注册命令,原生 UI 层自动获得菜单项,无需分别修改托盘与菜单代码。
- i18n 天然内建:命令绑定的是 i18n 键而非字面文本。本地化文件中可见对应键位,例如叠加层开关:
"menu.overlay_toggle": "叠加层",Source: zh-CN.json
"menu.overlay_toggle": "Overlay",Source: en-US.json
本地化以 menu.* 为命名空间承载菜单命令文本,notification.action.* 承载通知按钮文本(如 notification.action.view = "查看"、notification.action.retry = "重试",见 zh-CN.json)。注意 src/locales/*.json 修改后必须重跑 node scripts/generate-embedded-locales.js(见 AGENTS.md)——原生 UI 在运行时读取的是内嵌后的语言资源。
- 开关态直读:注册表条目带 toggle 状态,菜单可据此渲染选中/未选中(例如
menu.overlay_toggle对应叠加层开/关)。
2. 事件总线唤醒 Win32 消息循环
core::events 是类型擦除的事件总线,提供同步 send() 与异步 post();post() 通过 PostMessageW 唤醒 Win32 消息循环(见 AGENTS.md)。这是原生 UI 层能响应后台线程事件的关键桥梁:
- 后台工作(截图完成、录制状态变化、更新检查等)运行在 Asio 协程/线程池上;
- 这些工作不能直接触碰 UI,于是通过
post()把事件安全地转回消息循环所在线程; ui::context_menu/message_handler.cpp这类消息处理器随后在 UI 线程上消费事件并更新菜单/托盘/悬浮窗状态。
这解释了为什么 context_menu 拥有独立的 message_handler 文件:它是事件进入 UI 线程后的落点。
3. 共享渲染资源:render_context 与 painter
context_menu 自带 render_context 与 painter 两个渲染相关文件,composition_animation 提供动画能力。二者共同构成原生 UI 层的共享绘制基础设施:
painter负责把布局结果(layout.cpp产出)绘制到设备上下文;render_context封装绘制所需的状态/资源,使菜单绘制不与布局、交互耦合;composition_animation/animation为菜单出现/消失等过渡提供动画。
这种"布局 / 绘制 / 交互 / 消息处理"四分离的拆分,让 context_menu 的每个关注点都可独立演进,也符合项目"自由函数簇"组织方式。
Core Flow:托盘右键到命令执行的端到端路径
流程要点:
- 菜单内容是按需从注册表拉取的,因此业务层启动后新增的命令可以即时出现在菜单里。
- 用户点击产生的 Win32 消息在
message_handler内处理,这是唯一允许触碰 UI 状态的线程边界。 - 业务执行结果经事件总线
post()回到消息循环,刷新菜单开关态;刷新过程可经composition_animation做过渡动画。
UI 层冲突处理:预览窗与叠加层互斥
原生 UI 窗口之间存在资源互斥约束,本地化文件中保留了面向用户的提示文案:
"message.preview_overlay_conflict": "预览窗和叠加层功能冲突,已自动关闭另一功能",
"message.preview_start_failed": "预览窗启动失败: ",
"message.overlay_start_failed": "叠加层启动失败: ",Source: zh-CN.json
对应英文文案见 en-US.json。行为语义是:当用户同时开启预览窗(features::preview)与叠加层(features::overlay)时,系统自动关闭其中一个并给出提示。这类冲突仲裁按项目规范属于 usecase 编排层(usecase.hpp/.cpp 是顶层编排层,可跨模块协调 core::*、UI 与扩展,见 AGENTS.md),原生 UI 层只负责呈现仲裁结果与失败提示。
头文件与依赖纪律
原生 UI 层必须遵守仓库级的头文件纪律(见 AGENTS.md):
- 项目内每个头文件在不依赖 PCH 的情况下必须自包含:显式 include
vendor/std.hpp与所需 vendor 门面;src/pch.hpp仅加速这些相同依赖。 - 尖括号外部 include 只允许出现在
src/vendor/内;src/vendor/windows/下的 SDK 门面与物理 SDK 头一一对应,不建领域聚合门面。 - 对原生 UI 层的含义:
src/ui/**中直接出现的应当是项目头与已存在的 vendor 门面;新引入的低频 Win32 SDK 依赖应留在调用点本地(例如floating_window.cpp里需要的窗口类注册头),只有稳定高频依赖才进 PCH。
Configuration Options
原生 UI 层自身没有独立的配置文件;其可配置面通过命令注册表与本地化体系间接暴露。与页面相关的可配置项:
| 配置面 | 形式 | 默认/取值 | 说明 |
|---|---|---|---|
| 菜单条目 | core::commands 注册表项 | 无静态默认 | 动作 + toggle 态 + i18n 键 + 可选热键(见 AGENTS.md) |
| 菜单/通知文案 | src/locales/*.json | zh-CN / en-US 双语 | 修改后需重跑 node scripts/generate-embedded-locales.js(见 AGENTS.md) |
| 热键 | 命令注册表的可选绑定 | 未绑定 | 与命令一一关联,随命令生效 |
| 叠加层/预览窗开关 | menu.overlay_toggle 等命令 toggle | 关 | 两者互斥,同时开启自动关闭其一 |
本页采证范围内未发现原生 UI 层专属的运行时配置结构体(如托盘图标路径、悬浮窗位置持久化字段)。依据仓库规则不做臆测;如需确认请查阅
src/ui/floating_window/state与core::AppState定义。
Professional Notes
失败模式与边界(基于已核实证据):
- 预览/叠加冲突:见上文 Core Flow 后的"UI 层冲突处理",UI 层以提示消息反馈自动仲裁结果。
- 窗口启动失败:
message.preview_start_failed/message.overlay_start_failed携带错误详情后缀(冒号 + 空格),说明异常路径会把底层错误文本拼给用户。 - 卸载/升级时的托盘常驻:安装器本地化明确要求用户先退出应用(包括托盘图标),否则 MSI 报
CloseAppError(见 Package.en-us.wxl)。这是运维层面需要注意的原生 UI 生命周期约束。
并发模型:
- 依据项目模式(见 AGENTS.md):错误处理统一
std::expected<T, std::string>,无异常控制流;异步基于 Asio 协程,RPC 处理器返回asio::awaitable<RpcResult<T>>。 - 事件总线
post()是后台线程 → UI 线程的唯一安全通道(PostMessageW跨线程投递)。任何业务线程直接改 UI 状态都会破坏该纪律。
性能与运维:
- 后端经 xmake 构建,Release 输出位于
build\windows\x64\release\(见 AGENTS.md)。 - 端到端场景测试通过
tests/scenarios/以 JSON-RPC 驱动编译后的SpinningMomo.exe,会在隔离便携沙箱中运行(见 AGENTS.md)——测试前需关闭运行中的应用实例,再次印证托盘常驻形态。
扩展点:
- 新增托盘/菜单能力:向
core::commands注册命令并补menu.*i18n 键,原生 UI 层自动获得入口;随后按需在 feature 层实现动作本体。 - 新增通知动作按钮:补
notification.action.*键(现有view/retry模式,见 zh-CN.json)。 - 新增原生窗口类型:在
src/ui/<name>/下按state.hpp(POD)+ 自由函数簇组织,状态注册进core::AppState。
测试覆盖说明:本页采证范围内未读取
src/ui/**专属测试文件,无法核实托盘/菜单的单测覆盖情况;仅能确认场景级测试通过 JSON-RPC 驱动整体进程。此为明确的证据缺口,不做虚构。
Related Links
- AGENTS.md — 仓库级架构与约定(两进程模型、ui::* 清单、命令注册表、初始化顺序)
- src/ui/context_menu/ — 上下文菜单入口头文件
- src/ui/floating_window/floating_window.hpp — 悬浮窗入口头文件
- src/ui/composition_animation/animation.hpp — 动画支持
- src/locales/zh-CN.json — 中文菜单/通知文案
- src/locales/en-US.json — 英文菜单/通知文案
- installer/Package.en-us.wxl — 安装器对托盘常驻进程的处理文案