多窗口架构与生命周期管理
多窗口架构与生命周期管理
学习目标
- 理解 Cherry Studio 基于
electron-vite的多入口构建机制 - 掌握 5 个 HTML 入口的职责划分与渲染进程代码组织
- 了解
WindowService对窗口创建、定位、聚焦、销毁的全生命周期管理 - 掌握窗口间通信(IPC 中转)模式与
electron-window-state位置记忆 - 理解系统级文本选择检测(
selection-hook)与SelectionService的协同 - 了解透明窗口闪烁问题的解决方案与平台差异处理
项目实践
5 个 HTML 入口
Cherry Studio 在 electron.vite.config.ts 的 rollupOptions.input 中定义了 5 个构建入口,每个入口对应一个独立的 Electron BrowserWindow:
| HTML 入口 | 窗口角色 | 渲染进程代码目录 | 用途 |
|---|---|---|---|
index.html | 主窗口 | src/renderer/src/ | 主应用界面:聊天、设置、助手管理 |
miniWindow.html | 悬浮窗 | src/renderer/src/windows/mini/ | 快速对话悬浮小窗 |
selectionToolbar.html | 选中文本工具栏 | src/renderer/src/windows/selection/ | 鼠标选中文本后浮出的快捷工具栏 |
selectionAction.html | 选中文本操作弹窗 | src/renderer/src/windows/selection/ | 选中文本后的详细操作面板 |
traceWindow.html | MCP Trace 查看器 | — | 查看 MCP 调用的追踪信息 |
多入口构建配置
electron-vite 的构建配置为每个窗口生成独立的 renderer bundle:
构建配置伪代码:
rollupOptions: input: index: resolve(rootDir, "index.html") miniWindow: resolve(rootDir, "miniWindow.html") selectionToolbar: resolve(rootDir, "selectionToolbar.html") selectionAction: resolve(rootDir, "selectionAction.html") traceWindow: resolve(rootDir, "traceWindow.html")每个 HTML 入口加载各自的 React 根组件,因此每个窗口拥有 独立的 Redux Store,窗口之间的状态不共享。
WindowService — 窗口生命周期管理
WindowService(src/main/services/WindowService.ts)是主进程中所有窗口管理的核心服务类。它采用工厂方法 + 单例模式:
WindowService 伪代码结构:
class WindowService { private mainWindow: BrowserWindow | null private miniWindow: BrowserWindow | null private selectionToolbar: BrowserWindow | null private selectionAction: BrowserWindow | null private traceWindow: BrowserWindow | null private windowStateMap: Map<string, WindowState>
// 创建主窗口 createMainWindow(): BrowserWindow { 加载 electron-window-state 记忆的位置与尺寸 创建 BrowserWindow, 传入 webPreferences (preload, contextIsolation) 注册 "close", "resize", "move" 事件以保存状态 加载 index.html 返回 mainWindow }
// 创建悬浮窗 createMiniWindow(): BrowserWindow { 如果 miniWindow 已存在 → 聚焦并返回 创建 BrowserWindow (较小尺寸, 始终置顶, 可选透明) 加载 miniWindow.html 注册关闭事件: 销毁后清空引用 返回 miniWindow }
// 创建选中文本工具栏 createSelectionToolbar(x, y): BrowserWindow { 如果已存在 → 更新位置并聚焦 创建无边框、透明的小窗口 定位到 (x, y) — 鼠标选区位置 加载 selectionToolbar.html }
// 创建选中文本操作弹窗 createSelectionAction(): BrowserWindow { 如果已存在 → 聚焦并返回 创建 BrowserWindow 加载 selectionAction.html }
// 创建 Trace 查看器 createTraceWindow(): BrowserWindow { 如果已存在 → 聚焦并返回 创建 BrowserWindow 加载 traceWindow.html }
// 通用: 聚焦窗口 focusWindow(windowName): 如果窗口最小化 → 恢复 窗口 show() + focus()
// 通用: 销毁窗口 destroyWindow(windowName): 窗口 close() 清空引用为 null}electron-window-state — 窗口位置记忆
electron-window-state 库负责在窗口关闭时自动保存位置和尺寸,创建时自动恢复:
位置记忆伪代码:
// 主窗口创建时const stateManager = windowStateKeeper({ defaultWidth: 1080, defaultHeight: 670, file: "main-window-state.json" // 存储于用户数据目录})
const win = new BrowserWindow({ x: stateManager.x, y: stateManager.y, width: stateManager.width, height: stateManager.height})
stateManager.manage(win) // 自动监听 resize/move/close 并保存对于辅助窗口(悬浮窗、工具栏),通常使用固定位置或不记忆状态,因为它们的位置往往由用户当前操作上下文决定(如鼠标位置)。
窗口间通信模式
5 个窗口运行在独立的渲染进程中,窗口之间不直接通信,而是通过主进程中转:
通信模式总结:
- 渲染进程 → 主进程:
ipcRenderer.invoke()调用主进程的处理函数 - 主进程 → 渲染进程:
webContents.send()向目标窗口广播事件 - 渲染进程 ← 主进程:
ipcRenderer.on()监听来自主进程的事件
SelectionService — 系统级文本选择检测
Cherry Studio 实现了一个完整的”划词翻译/对话”功能,涉及两个层面的协同:
selection-hook (版本 2.0.1) 是一个 native addon,负责在操作系统层面监听用户的文本选择行为。工作流程:
- 用户在任何应用中用鼠标选中文本
selection-hook捕获选中文本事件,获取文本内容和鼠标坐标- 主进程
SelectionService收到通知 - 在选区附近创建
selectionToolbar.html工具栏(小浮窗,显示快捷操作按钮) - 用户点击”AI 对话”等操作按钮后,创建
selectionAction.html弹窗 - 选中文本通过 IPC 传递到弹窗内的 React 组件
SelectionService 伪代码:
class SelectionService { private hook: SelectionHook // native addon 实例 private enabled: boolean
start(): hook.on("select", (text, x, y) => { if (text && text.length > 0) { WindowService.createSelectionToolbar(x, y, text) } }) hook.start()
stop(): hook.stop()
// 当用户点击工具栏按钮时 openActionPanel(text): WindowService.createSelectionAction() // 通过 IPC 将文本发送到 selectionAction 渲染进程 mainWindow.webContents.send("selection:text", text)}深链接协议
Cherry Studio 注册了自定义 URL 协议 CHERRY_STUDIO_PROTOCOL(定义在 ProtocolClient.ts),用于处理从外部链接打开应用的场景:
深链接处理伪代码:
const CHERRY_STUDIO_PROTOCOL = "cherry-studio"
// 应用启动时注册app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL)
// 处理深链接app.on("second-instance", (event, argv) => { const url = parseDeepLink(argv) handleDeepLink(url) // 如: cherry-studio://chat?id=xxx})
app.on("open-url", (event, url) => { handleDeepLink(url)})
function handleDeepLink(url): 解析 URL path 和 query params 根据 path 路由到对应操作: /chat/:id → 打开指定对话 /assistant/:id → 切换到指定助手 /mcp/:server → 打开 MCP 服务器配置 聚焦主窗口透明窗口与硬件加速
Cherry Studio 的悬浮窗和工具栏使用了透明窗口(transparent: true),但透明窗口在某些平台上会出现闪烁问题。解决方案:
透明度配置伪代码:
const isWindows = process.platform === "win32"const isMac = process.platform === "darwin"
// 关闭硬件加速以解决透明窗口闪烁if (需要透明窗口) { app.disableHardwareAcceleration()}
// Windows 平台: 禁用窗口动画减少闪烁if (isWindows) { app.commandLine.appendSwitch("wm-window-animations-disabled")}
// 创建透明窗口new BrowserWindow({ transparent: true, frame: false, backgroundColor: "#00000000", hasShadow: false})渲染进程窗口代码组织
每个辅助窗口在 src/renderer/src/windows/ 下有独立的代码目录:
src/renderer/src/windows/├── mini/ # 悬浮小窗│ ├── index.tsx # React 根组件│ ├── store/ # 独立的 Redux Store│ └── components/ # 悬浮窗专用组件└── selection/ # 选中文本相关窗口 ├── toolbar/ # 工具栏 │ ├── index.tsx │ └── components/ └── action/ # 操作弹窗 ├── index.tsx └── components/每个窗口加载自己的入口文件,创建独立的 Redux Store。这种设计确保了:
- 隔离性:一个窗口的状态变化不会影响其他窗口
- 轻量化:辅助窗口只加载必要的代码,不需要完整的 SPA bundle
- 独立性:主窗口关闭后,悬浮窗仍可独立运行
React 19 兼容补丁
Cherry Studio 使用 React 19,需要引入 @ant-design/v5-patch-for-react-19 兼容补丁。每个窗口的入口文件都需要应用此补丁:
React 19 兼容伪代码:
// 每个窗口的入口文件import "@ant-design/v5-patch-for-react-19"import React from "react"import { createRoot } from "react-dom/client"import { Provider } from "react-redux"import { store } from "./store"import App from "./App"
const root = createRoot(document.getElementById("root")!)root.render( <Provider store={store}> <App /> </Provider>)问题与规避
透明窗口闪烁
现象:在 Windows/Linux 上,透明窗口(transparent: true)在移动或动画时出现画面撕裂或闪烁。
规避方案:
- 全局关闭硬件加速(
app.disableHardwareAcceleration()),代价是 GPU 加速不可用 - Windows 平台禁用窗口动画(
wm-window-animations-disabled) - 设置
hasShadow: false避免阴影渲染问题
多窗口焦点竞争
现象:多个窗口同时存在时,用户点击一个窗口后,另一个窗口可能被意外聚焦或置顶。
规避方案:
- 悬浮窗设置
alwaysOnTop: true确保始终可见 - 工具栏使用
focusable: false避免抢夺焦点 - 使用
WindowService.focusWindow()统一管理焦点逻辑
选中文本 Hook 的权限
现象:selection-hook 作为 native addon,在 macOS 上可能需要辅助功能权限(Accessibility)才能跨应用获取选中文本。
规避方案:
- 在应用启动时检测权限状态
- 如权限不足,引导用户到系统设置中授权
- 提供手动关闭划词功能的选项
窗口状态文件损坏
现象:electron-window-state 保存的 JSON 文件可能在异常退出时损坏,导致下次启动时窗口位置异常。
规避方案:
- 创建窗口时验证坐标是否在屏幕范围内
- 使用
screen.getAllDisplays()检查窗口是否仍在可用显示器上 - 如坐标无效,回退到默认位置
设计取舍
多窗口 vs 单窗口多标签
Cherry Studio 选择了多窗口架构而非单窗口多标签,原因如下:
| 维度 | 多窗口方案 | 单窗口多标签 |
|---|---|---|
| 划词工具栏 | 可在任意应用上方浮动 | 无法覆盖其他应用 |
| 悬浮小窗 | 独立于主窗口,可拖动 | 受限于主窗口范围 |
| MCP Trace 查看器 | 独立窗口方便对比调试 | 需要切换标签页 |
| 构建复杂度 | 需要多入口构建管线 | 单入口,简单 |
| 状态共享 | 需通过 IPC 中转 | 同一 Store,直接共享 |
| 资源占用 | 多个渲染进程,内存较高 | 单个渲染进程 |
对于 AI 桌面客户端场景,多窗口的灵活性收益大于资源成本,尤其是划词功能和悬浮对话需要在任意应用中随时可用。
独立 Redux Store vs 共享 Store
每个窗口使用独立的 Redux Store 而非共享:
- 优点:隔离性好,辅助窗口轻量化,崩溃不影响其他窗口
- 缺点:需要同步的状态必须通过 IPC 显式传递
- 取舍:AI 对话场景中,各窗口的上下文差异较大(主窗口看完整对话历史,悬浮窗看快速对话),独立 Store 更符合领域隔离
原生 Hook vs 系统 API
划词功能选择了 selection-hook native addon 而非 Electron 内置方案:
- 原因:Electron 自身无法跨应用检测文本选择,必须依赖操作系统级 hook
- 风险:native addon 需要针对每个平台编译,增加构建复杂度
- 缓解:
selection-hook已发布预编译的二进制文件,支持三大平台
参考来源
- Cherry Studio 源码:
electron.vite.config.ts,src/main/services/WindowService.ts,src/main/services/SelectionService.ts,src/main/services/ProtocolClient.ts - electron-vite 文档: https://electron-vite.org/guide/build.html#multiple-renderer-builds
- electron-window-state: https://github.com/mawie81/electron-window-state
- Electron BrowserWindow 文档: https://www.electronjs.org/docs/latest/api/browser-window
- selection-hook: https://www.npmjs.com/package/selection-hook
- @ant-design/v5-patch-for-react-19: https://www.npmjs.com/package/@ant-design/v5-patch-for-react-19