跳转到内容

多窗口架构与生命周期管理

多窗口架构与生命周期管理

学习目标

  • 理解 Cherry Studio 基于 electron-vite 的多入口构建机制
  • 掌握 5 个 HTML 入口的职责划分与渲染进程代码组织
  • 了解 WindowService 对窗口创建、定位、聚焦、销毁的全生命周期管理
  • 掌握窗口间通信(IPC 中转)模式与 electron-window-state 位置记忆
  • 理解系统级文本选择检测(selection-hook)与 SelectionService 的协同
  • 了解透明窗口闪烁问题的解决方案与平台差异处理

项目实践

5 个 HTML 入口

Cherry Studio 在 electron.vite.config.tsrollupOptions.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.htmlMCP 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 — 窗口生命周期管理

WindowServicesrc/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,负责在操作系统层面监听用户的文本选择行为。工作流程:

  1. 用户在任何应用中用鼠标选中文本
  2. selection-hook 捕获选中文本事件,获取文本内容和鼠标坐标
  3. 主进程 SelectionService 收到通知
  4. 在选区附近创建 selectionToolbar.html 工具栏(小浮窗,显示快捷操作按钮)
  5. 用户点击”AI 对话”等操作按钮后,创建 selectionAction.html 弹窗
  6. 选中文本通过 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 已发布预编译的二进制文件,支持三大平台

参考来源