小桌宠2.0来袭!Electron+Vue3+Three.js实现自己独一无二的桌面宠物

2,057 阅读2分钟

书接上回,由于本人建模水平是三个小时看视频速成,结合AI辅助两个小时做出来的小兔子模型,着实有点丑,所以添加了模型管理模块,用户可以添加自己喜欢的模型,自定义展示。

项目已开源: 地址,欢迎star


一、功能升级亮点 🚀

  1. 模型系统升级
    • 可以切换不同的模型,并且可以添加用户上传的模型
    • 支持读取glb模型文件动画,自定义动画触发形式
    • 世界环境光照等参数可以自定义
  2. 配置持久化
    • 可以存储模型,光照,相机位置等参数
  3. 添加系统托盘
    • 支持开机自启状态记忆

二、技术要点分享 💻

模型系统升级

组件级刷新策略

在Vue中通过key绑定实现暴力刷新,本质是利用虚拟DOM的差异比对机制。当场景/模型参数变更时,通过递增sceneUpdateKey modelUpdateKey强制销毁

<template>
  ...
  <TresCanvas v-if="loaded" v-bind="gl" :key="`${sceneUpdateKey}`">
    <TresPerspectiveCamera v-bind="camera" />
    <OrbitControls :enable-zoom="false" :enable-pan="false" />
    <Suspense>
      <TresMesh :key="modelUpdateKey">
        <Pet @update:actions="updateActions" />
      </TresMesh>
    </Suspense>
    <TresDirectionalLight v-bind="light" />
    <TresAmbientLight v-bind="ambientLight" />
  </TresCanvas>
  ...
</template>
  • ✅ 优势:确保所有Three.js资源完全释放
  • ⚠️ 注意点:频繁重建获修改参数可能影响性能(在场景颜色变化时使用VueUse的防抖函数useDebounceFn)
  • 💡 优化空间:后续可考虑对象池复用策略
响应式数据通信(这里特别需要注意)

错误示例:直接在不同页面共享Vue响应式对象(天真.jpg)

// 试图在Home.vue 和 PetView.vue 中直接使用useModel
const model = ref('')
const useModel = () => {
  const updateModel = (newUrl: string) => {
    model.value = newUrl
  }
  return {
    model,
    updateModel,
  }
}

问题分析:

在前端项目中确实可以,但在Electron中,由于模型编辑页面,宠物主页面是两独立窗口,每个BrowserWindow都是独立进程,内存空间完全隔离,响应式系统仅在单窗口内有效

正确方案:主进程消息中转,在更新数据时候发送主线程

解决方案演进

graph TD
    A[编辑窗口] -->|IPC| B[主进程]
    B -->|广播| C[渲染窗口]
    C --> D[Three.js场景更新]
// 在PetView.vue编辑页面中
window.ipcRenderer.send('update-scene-settings', config)
// main.ts
ipcMain.on('update-model-actions', (_event, actions) => {
  // 广播给所有窗口
  BrowserWindow.getAllWindows().forEach((window) => {
    if (!window.isDestroyed()) {
      window.webContents.send('model-actions-changed', actions)
    }
  })
})
// 在Home.vue模型渲染页面中接收其他窗口消息
onMounted(() => {
  window.ipcRenderer.on('update-model-actions', (_, actions) => {
    ...
  })
})

关键实现细节

  • 主进程使用BrowserWindow.getAllWindows()获取所有窗口实例
  • 采用webContents.send()进行定向消息推送
  • 内存防护:窗口销毁时需移除监听器(示例代码中onUnmounted处理)
模型存储问题(文件系统)
  • 对文件的处理(electron/file.ts)
  • 这里要注意的是读取时候需要区分系统默认和用户上传的模型的路径
// 获取所有模型文件
export async function getAllModels() {
  const systemDir = getSystemModelsPath()
  const userDir = getUserModelsPath()

  // 获取系统默认模型
  const systemModels = await fs
    .readdir(systemDir)
    .then((files) =>
      files
        .filter((file) => file.endsWith('.glb'))
        .map((file) => ({
          name: path.parse(file).name,
          path: path.parse(file).name + '.glb', // 根据环境返回不同的路径
          isSystem: true,
        }))
    )
    .catch(() => [])

  // 获取用户上传的模型
  const userModels = await fs
    .readdir(userDir)
    .then((files) =>
      files
        .filter((file) => file.endsWith('.glb'))
        .map((file) => ({
          name: path.parse(file).name,
          path: `file://${path.join(userDir, file)}`, // 使用 file:// 协议的完整路径
          isSystem: false,
        }))
    )
    .catch(() => [])

  return [...systemModels, ...userModels]
}

关键实现细节

  • 使用fs.readdir读取目录内容
  • 使用path.join构建完整路径
  • 使用file://协议确保路径正确(这里Trae可是帮我了我的大忙,点名表扬一下)
  • 使用isSystem标记模型来源

配置持久化

使用electron-store存储配置

// 主进程初始化
import Store from 'electron-store'
const store = new Store()

// 保存配置示例
ipcMain.handle('save-settings', (_, settings) => {
  store.set('settings', settings) // 自动序列化存储
})

// 读取配置示例
ipcMain.handle('get-settings', () => {
  return store.get('settings') || {}
})

// 清空配置
ipcMain.handle('clear-settings', () => {
  store.clear()
})

系统托盘

系统托盘的创建(electron/tray.ts)

export function createTray(callback: () => void) {
  // 创建托盘图标
  const icon = nativeImage.createFromPath(
    path.join(process.env.VITE_PUBLIC, 'rabbitRound.png')
  )
  tray = new Tray(icon.resize({ width: 16, height: 16 }))

  // 创建托盘菜单
  const contextMenu = Menu.buildFromTemplate([
    {
      label: '开机启动',
      type: 'checkbox',
      checked: app.getLoginItemSettings().openAtLogin,
      click: (menuItem) => {
        app.setLoginItemSettings({
          openAtLogin: menuItem.checked,
          path: process.execPath,
        })
      },
    },
    // 版本号
    {
      label: `版本号: v${app.getVersion()}`,
      enabled: false,
    },
    { type: 'separator' },
    {
      label: '退出',
      click: () => {
        callback()
      },
    },
  ])

  tray.setToolTip('小桌宠')
  tray.setContextMenu(contextMenu)
}
// 销毁托盘
export function destroyTray() {
    ...
}

销毁机制

  • 注意窗口生命周期管理处理托盘(destroyTray的调用时机)

三、下一步计划 🔮

  1. UI美化:争取摆脱"开发者审美"的标签
  2. 数据备份:让用户不再担心数据丢失
  3. 自动更新:从此告别每次都要下载安装
  4. 更多交互:正在考虑让桌宠学会跳《极乐净土》

欢迎来GitHub围观这个在代码和美术之间反复横跳的项目(记得star哦⭐)!说不定下个版本就有你贡献的模型呢~

项目地址


四、思考讨论 ✨

当需要实现多窗口数据实时同步时,如果用WebSocket的方式代替IPC方案会怎么样?欢迎在评论区讨论你的见解! 💬