讲个故事(一):一个Electron应用实践的不完全指北

506 阅读7分钟

前言:来龙去脉,故事梗概

  这个故事得从公司里的老收银项目讲起,它是一个.net开发的windows程序,CS架构的前辈,第一版问世的时候,我还在幼儿园里活泥巴。前辈大哥已经默默无闻的为公司奉献了快20年,再锋利的兵刃也生出了锈痕。公司为了给现有业务锦上添花,想搞推荐业务,类似于顾客来买了A,系统会给他推荐B,还会展示一些商品明细、积分、金额、提示等等,需要加工收银系统的数据,这时候就出现了两种方案。

再苦一苦前辈吧

   第一种方案是把推荐系统作为一个子模块,放在收银系统里开发,这样可以做到数据的自产自销。但公司本身是一个传统公司,数字化信息化转型比较晚,收银项目也是外部公司搞的。本身我们自己团队内部没有.net/C++大神,所有需求都得付费,系统像个黑盒,出个故障排查起来也很费劲。加之十几年没有换过的UI和“元气”积累,费劲的很。最终放弃了这个方案。

新兄弟

   第二种方案就是把推荐系统作为一个单独的应用,让收银系统和推荐系统做数据交互,全新的架构。随之也引出了两个比较尖锐的问题:

技术栈选型?

   因为是运行在windows上的应用,这个事情又落在了前端开发团队身上,可选的主流方案做了一遍调研,最终决定用Electron做一个应用外壳,类似于webview嵌入h5的形式,H5就采用vue3 + vite这一套。这样做的好处:

  • Electron只做最基础的功能,提供一些底层的支持,打包体积"不会很大"。
  • H5发版更新比较方便,特别是拥有万级线下门店体量的公司,如果改一个字体色号都要下发更新包去更新,体验非常差。
  • 技术栈比较成熟,社区的一些解决方案都很完善。
  • 还是写js/ts,而不是cpp、dart等

整体架构.png

其中主进程的基础模块包括:

  • 与收银应用的数据通信
  • 数据的加密与解密
  • 数据的持久化存储
  • 应用的自动更新、开机自启、窗口操作、快捷键等常见功能

数据交互方式?

WebSocket(被动接收)

websocket (2).png 这种方式存在几个弊端:

  • 服务端需要与万级的客户端维持长连接,服务端压力大
  • 需要去维护每个长连接的状态并且保证实时存活
  • 客户端显示数据的速度对网络要求高

IPC管道通信(主动请求)

IPC.png

这种方式的好处:

  • 可以毫秒级响应订单等原始数据,就算服务端挂掉也不影响一些数据的展示
  • 对服务器压力小,不需要承担太多的心智成本和负担

所以,最终选择了IPC管道通信。

正文:Do it!

    正如上面所说,项目是采用Electron + H5的形式,所以会新建两个独立的项目:Electron项目,主要是做应用程序的外壳,用来处理底层逻辑。另一个则是Vue3的H5项目,用来写业务逻辑,展示数据。

两个进程.png

壳子

处理双面屏

   因为推荐系统是面向顾客的,而收银系统面向店员。所以需要两块屏幕,副屏作为主屏的拓展屏来展示推荐系统。这就需要去找到主屏和副屏,在主屏上显示身份验证、消息弹窗等窗口,在副屏上全屏显示推荐界面窗口。

// 显示器屏幕识别
this.screen = this.displays.find(display => {
  // 副屏,起始坐标不为0
  return display.bounds.x !== 0 || display.bounds.y !== 0
})
this.otherScreen = this.displays.find(display => {
  // 主屏,起始坐标为0
  return display.bounds.x === 0 || display.bounds.y === 0
})

// 兼容如果只有一块屏幕
if (!this.screen && !!this.displays?.length) {
  this.screen = this.displays[0]

  if (this.displays?.length > 1) {
    this.otherScreen = this.displays[1]
  }
}

多窗口初始化

   首先需要创建一个BrowserWindow(应用程序窗口),然后在窗口中加载需要渲染的h5页面。不同的窗口可能会根据情况去设置例如大小、位置、是否显示菜单栏、标题icon、是否自动化全屏等。

const windowConfigs = {
  // 推荐窗口:全屏、副屏的左上坐标为起点、隐藏菜单
  main: {
    autoHideMenuBar: true,
    fullscreen: true,
    minimizable: false,
    x: this.screen.bounds.x,
    y: this.screen.bounds.y,
    title: '推荐窗口',
    icon: 'icon.ico'
  },
  // 校验窗口:固定宽高、主屏居中显示、不隐藏菜单
  verification: {
    autoHideMenuBar: false,
    fullscreen: false,
    width: 400,
    height: 200,
    center: true,
    title: '验证程序',
    icon: 'icon.ico'
  }
  //........
}

// 创建好窗口,就可以创建BrowserView(视图)并挂载到Window中了,然后加载对应H5页面的URL。
const createWindow = (type) => new BrowserWindow(widnowConfig[type])

IPC管道通信

const net = require('net')
const fs = require('fs')
const path = require('path')
const iconv = require('iconv-lite')

// 管道名称
const pipeName = 'test_pipe'

// 兼容32位系统
const pipePath =
  process.platform === 'win32'
    ? path.join('\\\\?\\pipe', pipeName)
    : `/tmp/${pipeName}`

// 移除管道
const removeServerPipePath = serverPath => {
  try {
    process.platform !== 'win32' && fs.unlinkSync(serverPath)
  } catch (e) {}
}

// 创建
const createPipeMan = handler => {
  const server = net.createServer((connc) /* connc: 监听器 */ => {
    // 监听收银系统往管道里发送的消息
    connc.on('data', buffer => {
      try {
        // 从字符编码解码为字符串
        handler(iconv.decode(buffer, 'utf-8'))
        connc.write(`{ success: true, message: '' }`)
      } catch (err) {
        connc.write(`{ success: false, message: ${err} }`)
      }
    })
  })

  server.on('close', () => {
    removeServerPipePath(pipePath)
  })
}
  

其他

    一些其他的功能比如快捷键注册、程序的开机自启等,基本上都有现成的api

//快捷键注册例子:
globalShortcut.register('Control+Shift+K', () => win.close())

// 开机自启
const AutoLaunch = require('auto-launch')
const enableAutoStart = app => {
  try {
    // 创建一个 AutoLaunch 实例
    const appAutoLauncher = new AutoLaunch({
      name: 'cincofine-app', // 替换为你的应用名称
      path: app.getPath('exe') // Electron 应用的执行路径
    })
    // 启动应用时,检查是否设置了开机自启
    appAutoLauncher
      .isEnabled()
      .then(isEnabled => {
        if (!isEnabled) {
          appAutoLauncher.enable() // 如果没有启用,则启用
        }
      })
      .catch(err => {
        // 错误处理
        console.error('Error checking auto-launch: ', err)
      })
  } catch (err) {
    console.log(err)
  }
}

H5

   这就到我们更擅长的领域了,只要拿到主进程给到我们的数据,其他的开发就很简单了。

import { useIpcRenderer } from '@vueuse/electron';

onMounted(() => {
  const ipcRenderer = useIpcRenderer()
  ipcRenderer.on("test", (_: any, ...args: any[]) => {
  
    // do something
    
    ipcRenderer.send('test-cb', {
        // 需要给主进程处理的数据
    }) 
  })
 })

混淆"加密"

   应用开发完成后还可能遇到安全问题,比如被人拿到了安装包,通过一系列骚操作把源码给反编译出来,甚至更改一些代码把有问题的安装包下放到了门店,虽然概率微乎其微,但是也要防患于未然。选择的方法是用javascript-obfuscator将主流程的代码做混淆。只需要在package.json里配置:


{
  "name": "cincofine-app",
  "version": "1.0.0",
  "main": "obscure-main.js",
  "scripts": {
    "dev": "npm run obs && chcp 65001 && set NODE_ENV=development && electron .",
    "start": "chcp 65001 && set NODE_ENV=development && electron .",
    "obs": "javascript-obfuscator main.js --output obscure-main.js",
    "update": "node version.js",
    "build": "npm run obs && node version.js && set DEBUG=electron-builder && set NODE_ENV=production && electron-builder --win --x64 --ia32 --publish=always",
    "only-build": "npm run obs && set DEBUG=electron-builder && set NODE_ENV=production && electron-builder --win --x64 --ia32 --publish=always"
  }

  • obs指令会将主文件main.js通过javascript-obfuscator混淆生成obscure-main.js
  • 项目的主入口将main.js换成混淆过的obscure-main.js
  • 当然,也可以通过配置文件做到更灵活的混淆和加密

打包构建

   这里选择了electron-builder作为构建工具,package.json配置如下:

  "build": {
    "appId": "cincofine-app",
    "productName": "cincofine-app",
    "win": {
      "target": [
        {
          "target": "nsis",
          "arch": [
            "ia32",
            "x64"
          ]
        },
        "squirrel"
      ],
      "icon": "logo.ico"
    },
    "nsis": {
      "oneClick": false,
      "allowToChangeInstallationDirectory": true,
      "createDesktopShortcut": true,
      "createStartMenuShortcut": true,
      "shortcutName": "cincofine-app"
    },
    "squirrelWindows": {
      "iconUrl": "https://xxxxxx/fs/logo.png",
      "artifactName": "${productName}_${version}.${ext}"
    },
    "publish": [
       {
        "provider": "generic",
        "url": "https://xxxxxxx/update/win32"
      },
      {
        "provider": "generic",
        "url": "https://xxxxxxx/update/win64"
      }
    ]
   }
}

版本服务器

   选择的是开源的electron-release-server,支持docker部署,文件存储支持硬盘、pg数据库、亚马逊云(国内的云要重写skipper的adapter)。值得一提的是,ers上传文件时有一段提示,也规定了上传文件的类型:

1720056787671.png

在windows系统下,我们需要用户新下载的文件(第一版)必须是exe和msi类型,而升级文件必须是nupkg类型。然而我第一次选择的生成安装程序的框架是NSIS,无论怎么构建,最终产物都没有一个.nupkg的文件。后来才知道这个nupkg格式的文件得由Squirrel或者其他框架生成(感兴趣的可以搜一下这些东西的区别和概念),这是一个NuGet包。而NSIS它只生成是用于安装和卸载应用程序的可执行文件exe。

   但是我们希望用户在更新和安装的时候能够灵活的选择磁盘,还有一些定制化的配置,这是Squirrel实现不了的,它主打一个简单、快捷,一个动画就给你装完了,不给你留太多的操作空间。所以最后我们做了妥协,采用了两种架构并行的方式构建产物,这样一来就同时拥有了灵活定制化的exe文件和支持ers升级对比的nupkg文件,把这两个文件同时管理到ers上。缺点就是每次都要构建两种产物且用户每次升级都是全量替换(本来就是个壳子,改动不是很大,能忍!!)。

自动更新

    有了版本服务器,接下来就是在代码里支持自动更新,还有一些提示和异常处理。

const { autoUpdater } = require('electron-updater')
const { dialog } = require('electron')

const checkUpdate = (win, view) => {
  autoUpdater.autoDownload = false

  autoUpdater.checkForUpdates().then(res => {
    if (!res) {
      view.webContents.send('guide-done')
    }
  })
  autoUpdater.on('error', () => {
    dialog
      .showMessageBox({
        type: 'info',
        title: '更新错误',
        message: '请联系运营人员,尝试使用老版本',
        buttons: ['是']
      })
      .then(res => {
        if (res.response === 0) {
          view.webContents.send('guide-done')
        }
      })
  })
  autoUpdater.on('update-not-available', info => {
    view.webContents.send('guide-done')
  })

  autoUpdater.on('update-available', message => {
    dialog
      .showMessageBox({
        type: 'info',
        title: '更新可用',
        message: '有一个新版本可用,是否现在下载并安装更新?',
        buttons: ['是', '否']
      })
      .then(result => {
        if (result.response === 0) {
          // 用户选择了‘是’
          autoUpdater.downloadUpdate()
        } else {
          const data = {
            message: '有新版本,用户选择不更新',
            code: -1,
            error: ''
          }
          view.webContents.send('guide-done', data)
        }
      })
  })

  autoUpdater.on('download-progress', progressObj => {
    // 发送进度到渲染进程
    view.webContents.send('download-progress', progressObj)
  })

  autoUpdater.on('update-downloaded', () => {
    dialog
      .showMessageBox({
        type: 'info',
        title: '安装更新',
        message: '更新已下载,应用将重启并进行更新。',
        buttons: ['现在重启', '稍后重启']
      })
      .then(result => {
        if (result.response === 0) {
          // 用户选择了‘现在重启’
          autoUpdater.quitAndInstall()
        } else {
          view.webContents.send('guide-done')
        }
      })
  })
}

异常监控

   比较常用的Sentry,不管是在electron还是H5页面都非常好用。支持被动上报,也支持主动上报异常,一些因为数据返回异常出现的问题可以选择手动上传到Sentry,留下跟后端同学扯皮的证据,方便甩锅。代码配置起来也很容易:

// sentry初始化,被动上报
try {
  Sentry.init({
    dsn: 'sentry dsn地址'
  })
} catch {
   console.log('sentry初始化失败') 
}

// 主动上报
Sentry.captureException(err);

遇到的问题

electron兼容性

   目前遇到比较棘手的系统配置是一台32位的win7,用25版本的electron会提示各种各样的dll有问题,尝试过修复,但最终弹了一个《无法识别的错误》,没办法把版本降到了21.4.4才正常安装。但是也会发现在这个版本的电脑上,有些配置是不生效的,比如窗体的透明背景,设置了以后发现还是白色的,在64位系统上是没有这个问题的。

发布版本的管理

   我们希望在ers上的版本和gitlab代码仓库上的tag版本是一一对应的,并且防止发版忘了修改版本而替换了原来版本的问题,所以我们写了一个脚本来强制规范开发人员。

const inquirer = require('inquirer')
const { execSync } = require('child_process')

function getCurrentVersion() {
  const packageJson = JSON.parse(
    execSync('npm version --json', { encoding: 'utf8' })
  )
  return packageJson.version
}

function checkUncommittedChanges() {
  const status = execSync('git status --porcelain', { encoding: 'utf8' })
  return status !== ''
}

function commitChanges(commitMessage) {
  execSync('git add -A')
  execSync(`git commit -m "${commitMessage}"`)
}

function updateVersion(versionType) {
  const command = `npm version ${versionType}`

  try {
    if (checkUncommittedChanges()) {
      throw new Error(
        'You have uncommitted changes. Please commit them before bumping the version.'
      )
    }

    const output = execSync(command, { encoding: 'utf8' })
    console.log(`Updated to new version: ${output}`)

    execSync('git push')
    execSync('git push --tags')
    console.log('Changes pushed to remote repository.')
  } catch (error) {
    console.error(`Failed to update version: ${error}`)
  }
}

async function main() {
  console.log(`Current version is: ${getCurrentVersion()}`)

  try {
    const { versionType } = await inquirer.prompt([
      {
        type: 'list',
        name: 'versionType',
        message: 'Choose the version type to update:',
        choices: ['patch', 'minor', 'major']
      }
    ])

    const { commitMessage } = await inquirer.prompt([
      {
        type: 'input',
        name: 'commitMessage',
        message: 'Enter a commit message for your changes:',
        when: checkUncommittedChanges
      }
    ])

    if (commitMessage) {
      commitChanges(commitMessage)
    }

    updateVersion(versionType)
  } catch (error) {
    console.error(error)
  }
}

main()

  • 首先通过obs对代码做混淆
  • 提示用户选择版本升级的类型 1720061134924.png
  • 填写commit,自动推送代码到仓库
  • 采用新版本打包构建

结尾

   Electron实践到此为止,可能存在很多瑕疵和问题,希望大家多多留言交流,感谢阅读。