🧑💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
一个角色能在桌面上动起来,只是桌宠开发的开始。真正影响使用体验的,往往是几个看似普通的问题:重复启动会不会出现两个宠物?关闭设置窗口后应用还在不在?拖动人物为什么会误触单击?透明窗口为什么挡住了后面的软件?退出时配置是否来得及保存?
本文以我开发的 Windows AI 桌宠「伴星」为例,拆解这些交互背后的实现思路。重点不在动画素材,而在 Electron 生命周期、Vue 指针事件、跨进程通信和异步资源管理。
阅读说明:内容基于项目当前源码的静态核对。文中代码均为解释原理的简化示例,不是可直接替换项目文件的完整实现;Windows 多屏、混合 DPI 和异常退出等场景仍需要实机验证。
一、先划清边界:Vue 处理意图,Electron 操作系统窗口
普通网页里拖动元素,改变的是 DOM 的位置。桌宠则不同:人物绘制在一个透明窗口里,要让它跨桌面移动,最终必须改变操作系统窗口的位置。
因此,我把整个交互分成三层:
text
Vue 渲染进程
识别按下、移动、松开、单击、双击
↓
preload
暴露有限的 petApi 能力
↓
Electron 主进程
校验调用来源与参数,移动窗口、切换穿透、保存数据
这条边界也适用于启动和退出:Vue 可以发出"退出"意图,但不应该拥有任意执行系统命令的能力。
简化的 preload 接口如下:
javascript
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('petApi', Object.freeze({
movePet: (value) => ipcRenderer.invoke('pet:move-pet', value),
setMouseIgnore: (value) => ipcRenderer.invoke('pet:set-mouse-ignore', value),
quit: () => ipcRenderer.invoke('pet:quit'),
}));
重点不是给 IPC 换个名字,而是只暴露明确的业务操作。渲染层不需要获得完整的 ipcRenderer,更不需要开启 Node 集成。
二、应用启动:先恢复状态,再创建窗口
2.1 用单实例锁避免重复桌宠
桌宠通常是长驻应用。如果每次双击图标都创建一个新进程,桌面很快就会出现多个人物、多个托盘和多份后台任务。
Electron 提供了单实例锁:
javascript
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on('second-instance', () => {
openSettings();
});
}
没有拿到锁的新实例直接退出;已经运行的实例收到 second-instance 后打开设置窗口。这里没有选择再创建一个人物,而是把重复启动转化为"找到正在运行的应用"。
2.2 启动顺序决定第一帧是否正确
项目的主要启动流程是:
text
取得单实例锁
↓
等待 app.whenReady()
↓
读取配置与上次位置
↓
检查是否已进入退出状态
↓
注册 IPC、创建桌宠窗口
↓
创建托盘、应用设置、监听显示器变化
为什么先读取位置?如果先显示默认窗口,再异步恢复位置,人物会从屏幕角落突然跳到旧位置。
为什么读取后还要检查退出状态?因为 await 期间应用可能已经收到退出请求。没有这道检查,退出流程刚开始,启动回调却又创建了新窗口。
当前代码还显式设置了 openAtLogin: false。手动启动应用和开机自启动是两件事,不能因为程序能运行就默认开启登录启动。
2.3 透明窗口不只是 transparent
桌宠窗口需要组合多个属性:
javascript
const win = new BrowserWindow({
show: false,
transparent: true,
frame: false,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
focusable: false,
hasShadow: false,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
},
});
win.once('ready-to-show', () => {
win.showInactive();
});
这些选项分别解决不同问题:无边框负责外观,透明负责背景,跳过任务栏避免长期占位,非聚焦窗口与 showInactive() 尽量避免打断用户正在输入的内容。
但是,聊天输入框需要键盘焦点。项目打开内置聊天时会临时允许窗口聚焦,并关闭鼠标穿透;关闭聊天后再恢复非打扰展示。
陪伴状态与输入状态应使用不同的焦点策略。
三、窗口关闭与应用退出,必须分开设计
关闭一个设置窗口,不应该等同于退出整个桌宠。
项目对 window-all-closed 注册了空处理函数,没有在这里调用 app.quit()。这是托盘型应用常见的策略:窗口数量变为零,不自动触发退出。
不过,这个事件处理本身也不会自动恢复被关闭的桌宠窗口。是否重建窗口是另一个产品决策,不能把"进程没有退出"理解为"人物一定还在"。
真正的退出入口来自托盘菜单和桌宠菜单,它们最终统一调用 app.quit(),再由 before-quit 收口处理。
四、退出不是一行 app.quit(),而是一个两阶段协议
桌宠退出时,可能同时存在:
- 尚未写完的设置;
- 防抖中的位置保存;
- 正在执行的模型请求;
- 划词辅助子进程与系统语音进程;
- 运动定时器与对话记忆写入。
如果立即结束应用,可能丢失最新位置或配置;如果只等待保存,却允许新任务继续进入,程序又可能一直无法收尾。
4.1 第一阶段:停止接收新工作
项目使用 quitting 标记"正在退出":停止运动和划词 helper,取消网络请求,终止已登记的语音子进程,并销毁托盘。
IPC 入口也检查这个标记。退出期间收到新操作,直接拒绝执行。
这相当于先关掉业务入口,再清理在途工作。
4.2 第二阶段:等待写入,再真正退出
核心结构可以简化成:
javascript
let quitting = false;
let quitFlushed = false;
app.on('before-quit', (event) => {
if (quitFlushed) return;
event.preventDefault();
if (quitting) return;
quitting = true;
stopBackgroundTasks();
Promise.allSettled([
persistPosition(),
saveQueue,
dialogue.flush(),
]).then(() => {
quitFlushed = true;
app.quit();
});
});
这里两个标记不能混为一谈:
quitting:第一轮退出已开始,防止重复清理和新任务进入。quitFlushed:本轮等待已结束,允许第二次app.quit()通过。
第二次调用仍会触发 before-quit。如果没有放行条件,退出逻辑就会不断拦截自己。
Promise.allSettled() 的作用是等待各项写入结束,即使其中某项失败,也继续完成退出流程。它不代表所有数据保存成功,也不能解决一个永远不结束的 Promise。
这套流程保障的是正常退出的尽力落盘,不是断电、强杀或系统异常情况下的绝对保证。
4.3 防止"退出后复活"
还有一种更隐蔽的竞态:
text
设置保存开始
↓
用户退出,托盘被销毁
↓
旧保存任务结束
↓
完成回调试图刷新托盘、启动划词或恢复漫游
因此,不能只在退出按钮处检查状态。更新托盘、广播状态、启动 helper 等副作用入口,也需要识别退出状态。
异步任务完成,不代表应用还处于允许执行后续动作的阶段。
五、拖拽人物:识别手势,而不是按下就移动
5.1 为什么使用 Pointer Events
项目使用 pointerdown、pointermove、pointerup 组织手势,并在按下时调用:
javascript
event.currentTarget.setPointerCapture(event.pointerId);
指针捕获解决的是事件连续性:鼠标快速移出人物区域后,仍能把后续移动和松开事件交给当前元素处理,避免拖动半途失联。
同时还监听窗口级的 pointerup、pointercancel,以及元素的 lostpointercapture。结束函数必须允许重复调用,因为一次交互可能经过多个结束入口。
5.2 用移动阈值区分点击与拖动
鼠标按下后,人手通常会有几像素抖动。如果一点位移都算拖拽,单击几乎无法稳定触发。
项目配置的拖动阈值为 5,判断采用起点到当前点的欧氏距离:
javascript
const distance = Math.hypot(
event.screenX - drag.startX,
event.screenY - drag.startY,
);
if (!moved && distance <= dragThreshold) return;
因此,按下时只进入"候选拖动"状态;超过阈值,才把 moved 设置为 true,切换拖拽动作并移动窗口。
这里还有一条关键规则:一旦确认拖动,就取消尚未执行的单击定时器。 否则人物已经被拖走,之前排队的点击行为仍可能突然出现。
5.3 屏幕坐标与窗口内坐标不要混用
拖动窗口需要关注屏幕位移,所以项目使用相邻事件的 screenX/screenY 计算增量:
javascript
const dx = event.screenX - drag.x;
const dy = event.screenY - drag.y;
window.petApi.movePet({ dx, dy });
命中哪个按钮则使用 clientX/clientY,因为它们属于当前视口坐标,适合传给 document.elementFromPoint()。
Electron 的窗口边界采用 DIP。不能把名字里带 screen 的坐标直接理解为原始物理像素,再随意乘以 devicePixelRatio。混合 DPI 显示器之间的真实拖动效果,需要专门实测。
5.4 主进程才是窗口移动的最终执行者
渲染层传入的是移动意图,主进程仍需检查:
- 是否来自桌宠窗口及可信主 frame;
dx/dy是否为有限数;- 是否需要限制单次位移;
- 移动结果是否超出显示器工作区。
当前实现将单次增量限制在 ±1000,再用工作区边界夹取窗口位置。同时停止已有运动,避免自动漫游与手动拖动争抢窗口。
多屏场景还必须允许负坐标:放在主屏左边的显示器,完全可能拥有负的横坐标。
六、拖拽的手感:倾斜、速度与松手惯性
人物跟随窗口移动只是基础。项目还根据水平拖动速度设置倾斜目标,并通过弹簧与阻尼,让角度平滑追随、松手回正。
速度估计使用平滑处理:
javascript
vx = vx * 0.35 + (dx / seconds) * 0.65;
它比单次位移直接除以时间更不容易抖动。倾斜目标限制在 ±18°,视觉动画由 requestAnimationFrame 更新。
需要区分两个独立效果:
- 人物倾斜:发生在渲染层,是视觉反馈。
- 松手抛掷:发生在主进程,真实改变窗口位置。
松手时,如果最后一次移动距当前已经超过 120ms,就按零速度释放,避免"拖到目的地停了一会儿,松手却突然飞出去"。
主进程仅在自由漫游模式、且不受交互状态阻挡时启动释放运动。速度经过非线性压缩,小速度不触发抛掷;后续物理更新包含重力、边界反弹和地面摩擦。
对自动运动与释放运动,项目还检查移动路径,而不仅是终点,避免窗口从不相连的显示器之间直接穿过空白区域。手动拖动则采用工作区夹取,二者不是同一套边界算法。
七、单击、双击与拖动为什么会互相干扰
双击通常伴随着两次 click,随后才收到 dblclick。如果第一次 click 就立即执行单击行为,那么双击一定会先触发一次单击。
项目采用延迟确认单击:
javascript
function onClick() {
clearTimeout(clickTimer);
if (moved || dragging) return;
clickTimer = setTimeout(() => {
if (!moved && !dragging) performSingleClick();
}, 260);
}
function onDoubleClick() {
clearTimeout(clickTimer);
if (!moved && !dragging) openOfficialWebsite();
}
实际处理关系如下:
| 行为 | 判断方式 | 最终结果 |
|---|---|---|
| 单击 | 未越过拖动阈值,延迟结束 | 展示操作栏、点击反馈或台词 |
| 双击 | 在单击确认前收到双击事件 | 取消待执行单击,打开对应官网 |
| 拖动 | 位移超过阈值 | 移动人物,抑制拖动结束后的 click |
260ms 是项目当前采用的取舍,不是读取了操作系统的双击时间。若用户以更慢节奏完成系统认可的双击,先前的单击可能已经执行,因此不能宣称这套固定延迟能完全隔离所有双击场景。
当前源码的单击也不只是"换一句话":在合适的待机或完成状态下,它会从点击动作池选动作;固定动作、编程模式或其他受保护状态下则走台词反馈。双击打开官网,与内置对话面板是两个不同入口。
八、鼠标穿透:透明不等于不挡鼠标
透明窗口的背景虽然看不见,但仍然占着一个矩形窗口区域。用户点击人物周围的透明部分时,如果不处理,事件仍可能被桌宠截获。
Electron 提供了窗口级穿透:
javascript
petWindow.setIgnoreMouseEvents(true, { forward: true });
关键是根据当前位置动态切换:
text
鼠标位于人物、按钮、菜单或对话区域
→ 关闭穿透,接收点击
鼠标位于无交互的透明区域
→ 开启穿透,让下层软件接收操作
正在拖动、打开菜单或输入聊天
→ 保持可交互,避免中途失去控制
渲染层通过 elementFromPoint() 找到鼠标下的元素,再用选择器判断是否属于交互区域,只在状态发生变化时通知主进程,避免每次移动都重复发送相同 IPC。
在 Windows 上,forward: true 可转发鼠标移动消息,帮助应用在开启穿透后继续识别重新进入可交互区域的移动。它不等于转发所有点击事件。
还要注意:项目这里是 DOM 命中判断,不是逐像素透明度命中。人物容器内部即使有透明像素,也可能仍然算人物区域。想让点击完全贴合轮廓,需要额外的命中区域设计,不能单靠透明 CSS。
九、位置记忆:防抖、串行与正常退出快照
窗口移动事件触发很频繁,每次都写文件既浪费 I/O,也容易产生写入竞争。
项目将位置保存设计为:
- 移动后等待 1000ms,后续移动重置计时器;
- 保存时读取窗口坐标,生成整数
x/y快照; - 通过独立 Promise 队列串行写入;
- 先写临时文件,再通过
rename替换目标; - 正常退出时立即取最终位置快照并等待队列。
串行队列同时连接成功与失败分支:
javascript
const result = positionQueue.then(save, save);
positionQueue = result.catch(() => {});
这样,一次写入失败不会让整条队列永久停在 rejected 状态。
位置独立保存在用户数据目录的 pet-position.json,不混入包含 API 密钥的加密设置草稿。移动人物,不应该顺便覆盖用户正在编辑的模型配置。
恢复位置时还要根据当前显示器重新夹取;显示器被拔掉后,旧位置可能已经不存在。这里能保证的是按现有工作区重新归位,不是无条件相信上次坐标。
十、组件卸载也需要"小型退出流程"
应用级退出之外,Vue 组件本身也可能卸载或刷新。
交互模块需要清理单击定时器、随机动作定时器、倾斜动画帧、窗口级指针事件、音频资源和 IPC 订阅。
项目通过 preload 让订阅函数返回解除函数,再由组件统一执行。异步回调则配合 disposed 标记检查,避免组件已卸载后继续修改界面。
清理资源不只是为了省内存,还为了避免旧回调干扰下一次交互:旧单击延迟不应在新一轮拖动里执行,旧动作结束事件也不应把新动作切回待机。
十一、建议重点验收的场景
这些交互不能只靠"拖一下能动"就判定完成。建议至少覆盖:
- 连续双击启动图标,确认只保留一个主实例。
- 关闭设置窗口,确认桌宠和托盘仍可使用。
- 配置保存尚未结束时退出,观察是否出现异常回调或后台残留。
- 按下后轻微抖动再松开,确认仍能单击。
- 快速拖出人物区域再松开,确认不会卡在拖动状态。
- 拖动结束后不触发单击;双击尽量不触发额外动作,并测试较慢双击。
- 鼠标在透明区、人物、操作栏、聊天面板之间移动,检查穿透和焦点切换。
- 多屏负坐标、混合缩放、拔掉显示器后重启,检查位置恢复。
- 正常退出后再次启动,检查位置;强杀场景单独记录,不把它与正常退出保证混为一谈。
上述是建议验收清单,不代表本文编写过程中已经逐项完成测试。
结语
桌宠最难的部分,并不是把一段视频放进透明窗口,而是让几个系统协作得足够自然:
- 启动时只保留一个实例,先恢复状态再显示人物;
- 退出时先阻止新任务,再等待在途写入;
- 拖动时用阈值与指针捕获保证手势连续;
- 点击时协调单击延迟、双击取消与拖动抑制;
- 透明区域通过动态穿透,把鼠标控制权还给用户;
- 跨进程操作始终通过有限接口和主进程校验执行。
这些思路不只适用于桌宠,也适用于桌面悬浮球、截图工具、翻译浮窗和托盘型效率工具。做好它们,桌面应用才会从"能运行"变成"不会打扰用户、也不会在边界条件下失控"。
如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。
