Electron 客户端自动更新

随着科技的进步,啊不,是网络通信技术的提升,客户端用户不再受限于拨号上网那样的小水管,百兆宽带触手可达,随时随地自动更新版本成为了标配。作为跨端框架的翘楚,Electron 自然也是内置了自动更新功能,但查阅其官网发现其提供的 autoUpdater 并无明确的操作路径可言,读完仍是一头雾水,尤其是还需要私有 CDN 部署时更是两眼一抹黑。

漫漫更新路

让我们从零开始,更新逻辑其实很简单,每次发版时将更新文件分发到 CDN 上,客户端通过检查 CDN 上有无更新文件继而下载文件并安装即可完成更新。抛开上传下载这种技术问题不谈,要解决两点:

  • 什么版本可以更新
  • 可以更新到什么版本

说人话就是从哪儿来,要到哪儿去。本文将要为你解答的就是如何通过一系列配置服务及本地设置,完成包含灰度更新、强制更新、静默更新以及 GUI 更新过程展示在内的可操纵动态更新策略。

发布与更新

首先确定一点,我们依然用的是 Electron 提供的更新功能,但主要用了 electron-builder 封装后的 electron-updater。这里的文档和 Electron 官方文档比较类似,有点啰嗦,下面就使用自定义 CDN 这条路提纲挈领地给大家梳理关键步骤。

生成制品信息

这里假定你一定是通过 electron-builder 进行打包了,需要在 electron-builder-config 中加入如下字段(详细字段配置

1
2
3
4
5
6
7
8
const config = {
...others,
publish: {
provider: 'generic',
channel: 'latest-win32',
url: 'https://your.cdn/update-path',
},
}

这里假设你的更新文件会被放在 https://your.cdn/update-path 目录下,通过这个配置打出来的安装文件就会多出一个 latest-win32.yml 文件,这个文件长下面这样子。

制品信息

这里面主要包含了版本号、更新资源文件的文件名及校验 hash 及发布日期等关键信息,对于后续步骤最重要的就是资源的文件名了。

将安装包与 yml 文件一起上传到 CDN 的 https://your.cdn/update-path 目录下就完成了生成制品信息的这一步。

配置自动更新信息

来到这一步需要保证 https://your.cdn/update-path/latest-win32.yml 已经是可以访问到的了,后面就是如何在端内把 electron-updater 支棱起来。

首先安装 electron-updater: npm i electron-updater
这里作者实操中有个问题,electron-updater 打包后失效了,暂未明确原因,故在 webpack 中将其设为 externals 并在最终由 electron-builder 打包的目录 projectDir 里安装了 electron-updater。

接下来,为了开发调试我们需要做一点骚操作,在下图所示目录中有 app-update.yml 文件。
mac app-update.yml
windows app-update.yml
这个文件里面内容是这样的,将它复制到项目根目录下并改名叫 dev-app-update.yml,后面就能调试更新了。

需要说明的是,macOS 上自动更新只有在签名后的 App 上才能进行,在后续步骤的退出并安装前会校验签名,校验失败时会报错。

自动更新

进入激动人心的代码环节!

1
2
3
4
5
6
7
8
9
10
11
12
13
import { autoUpdater } from 'electron-updater';

// 设置为指定发布版本,以防错读为 Electron 版本
autoUpdater.currentVersion = APP_VERSION;
autoUpdater.setFeedURL({
provider: 'generic',
channel: os.platform() === 'darwin' ? 'latest' : 'latest-win32',
url: `https://your.cdn/update-path`,
});
autoUpdater.checkForUpdates();
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall();
});

对,就是这么简单,一旦下载更新完成立即以迅雷不及掩耳之势退出 App 进行更新并重启。这是不是太快了点?都没留给用户反应的时间了。别着急,可以通过 autoUpdater 上的各种事件,参考这篇文章做一个漂亮的更新界面出来。
https://blog.csdn.net/Wonder233/article/details/80563236

autoUpdater 事件:

  • error
  • checking-for-update
  • update-available
  • update-not-available
  • download-progress
  • update-downloaded

精细化更新

静默更新

如果把上面的退出更新步骤去掉,离静默更新就只差一步了。

1
2
3
4
+ autoUpdater.autoInstallOnAppQuit = true;
- autoUpdater.on('update-downloaded', () => {
- autoUpdater.quitAndInstall();
- });

至这一步为止,你已经做完了一个不断更新到最新版本的 Electron App 所需要的一切了。

强制更新

即使你设置了每次都会自动更新,依然免不了有用户不肯买账,或者说会在各种网络差的情况下没法及时更新到最新版本,我们可以通过下发一个配置文件,来控制一些有废弃 API 或者有严重 bug 的版本被继续使用。

例如在配置系统上生成一个如下的配置,其中 force_update_version_list 就是一串 semver 规范的版本范围。
配置字段

在使用时只需要判断一下 APP_VERSION 是否在这些个区间内即可。

1
2
3
4
5
6
import * as semver from 'semver';

const config = await fetchUpdateConfig(key);
const forceUpdateVersions = config.force_update_version_list;
const shouldForceUpdate = forceUpdateVersions.length &&
semver.satisfies(APP_VERSION, forceUpdateVersions.join('||'));

这里在发出拉取更新配置请求时出现了一个 key,这个 key 提供了本地去决定使用哪个配置组的能力,比如测试就填 Test,线上默认为 Production,方便测试。

灰度更新

强制更新解决了哪些版本必须更新的问题,如果我们只想让某些版本或是用户更新到指定版本呢?这也就是通常所说的金丝雀发布、A/B 测试之类的了。同样可以用从网络拉取一个配置文件来解决,正好内网配置平台也满足我们的这种需要。

配置下发

首先配置系统支持根据 uin、ip 等进行灰度发布,我们选择了将 uid 后两位截取为 uin 上传到灰度名单,配置系统拿到上传的 uin 后根据灰度规则(上图配的是 30% 的比例)下发最新更改的配置项。

逐天放开灰度比例

直至 100% 比例后,可以进一步替换官网链接,完成全量发布。

更新路径划分

聪明的读者已经发现了,在发布与更新中,我们设置了统一的更新目录 https://your.cdn/update-path,如果有不同的更新版本,我们就需要设置不同的文件或是目录来控制。该用哪一种呢?

版本排布方式 优势 劣势
按目录 一个目录一个版本 无统一更新地址
按文件 目录层级扁平 文件混杂难分清

综合考虑后,我们选择了按目录划分版本的方式。
版本
文件

在上面的自动更新代码中替换如下内容即可享受精细控制的灰度更新功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { autoUpdater } from 'electron-updater';

+ const config = await fetchUpdateConfig(key);

// 设置为指定发布版本,以防错读为 Electron 版本
autoUpdater.currentVersion = APP_VERSION;
autoUpdater.setFeedURL({
provider: 'generic',
channel: os.platform() === 'darwin' ? 'latest' : 'latest-win32',
- url: `https://your.cdn/update-path`,
+ url: `https://your.cdn/update-path/${config.version}`,
});
autoUpdater.checkForUpdates();
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall();
});

尾声

按渠道分发

更新和下载一样是为了分发,当我们有了更多渠道时也可能需要考虑渠道间的差异性。渠道包可以通过配置文件进行区分,更新时只更新资源而不更新配置文件,这样就可以做到不同的安装渠道在同一更新下保持自身渠道特殊性。

永远递增你的版本号

The lastest, the best.

Electron 踩坑

来鹅厂做了俩常规项目(更新文件上传方式至 COS 及修改移动端顶部 UI)后参与了文档 App 的桌面 Electron App 开发,在 UI 层上又是一个坑接一个坑。主要有下述几点:

  • 实现适应于内置 React 组件大小的 BrowserWindow,同时保证首次加载时页面不会错乱
  • 使自定义弹窗可拖拽并且内部表单组件可使用
  • BrowserWindow 内弹出超出 Window 范围的菜单
  • 跨端编译打包后发现 runtime 对不上

使 BrowserWindow 自适应 React 组件

我们知道 ElectronBrowserWindow 的默认尺寸为 800 × 600,虽然可以初始化设置弹窗大小,但是并不知道弹窗内容尺寸呀!于是乎,需要先隐藏弹窗直至 compsnentDidMount 时获取到 dialog 内容尺寸再设置 BrowserWindow 的大小并显示。

1
2
3
4
5
6
7
8
9
10
11
12
// main-process 打开弹窗
import { ipcMain } from 'electron'

export function openDialog() {
const dialog = new BrowserWindow({
show: false
})
ipcMain.once('dialog-did-mount', () => {
dialog.show()
})
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// renderer 页面在组件加载后设置宽高并返回消息显示 window
import React, { useEffect, useRef } from 'react'
import { ipcRenderer, remote } from 'electron'

export default function Dialog() {
const dialogRef = useRef()
useEffect(() => {
const window = remote.getCurrentWindow()
const { clientWidth, clientHeight } = dialogRef.current
window.setSize(clientWidth, clientHeight, true/* animate */)
window.center()
ipcRenderer.send('dialog-did-mount')
})
return <div ref={dialogRef}>
contents...
</div>
}

拖拽窗口

官方解答
遇到的坑是设置好 dialog 内部 button/inputno-drag 后发现 dui(某鹅厂组件库)会直接在 body 下生成 DOM 节点,哪怕设置上了 dui-* 这样的通配符都没用,在 Windows 上点击事件还是穿透了组件,只好给整个内容的区域都打上了 -webkit-app-region: no-drag

弹出超出 Window 的菜单

官方做法
设计觉得 Windows 下不好看,于是要自定义 BrowserWindow

1
2
3
4
5
6
7
8
9
10
11
// main-process 打开弹窗
import { BrowserWindow } from 'electron'

function openMenu(x, y) {
const menu = new BrowserWindow({ x, y })
menu.loadUrl(`file://${__dirname}/menu.html`)
menu.on('did-finish-load', () => {
menu.show()
})
}
ipcMain.on('open-menu', (event, x, y) => openMenu(x, y))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// renderer 渲染进程捕获触发元素
import { remote } from 'eletron'
import React, { useRef, useEffect } from 'react'

export default function App() {
const btnRef = useRef()
useEffect(() => {
const window = remote.getCurrentWindow()
const { x: windowX, y: windowY } = window.getBounds()
const { x: elX, y: elY, height } = btnRef.current.getBoundingClientRect()
const x = windowX + elX
const y = windowY + elY + height
ipcRenderer.send('open-menu', x | 0, y | 0)
})
return <div>
content...
<button ref={btnRef}>点我出菜单</button>
</div>
}

其中

1
ipcRenderer.send('open-menu', x | 0, y | 0)

非常重要 😂 因为 Electron 打开 menu 的 x & y 只认整型,而 getBoundingClientRect() 返回了浮点数,直接用就崩了……

区分「开发时」「编译时」和「运行时」

跨端开发的优势就是 Write Once, Run Everywhere。代码能贴近运行时判断就贴近运行时判断,不过为了开发和打包大小,有如下几个优化思路。

  • 跨端开发 UI 需要调试查看其他端上的状态,所以会需要一个全局的样式开关,目前只区分了 macOSWindows,写作
    1
    2
    // constants/setting.ts
    const useMacStyle = process.platform === 'darwin'
    开发时只需要按需加 ! 取反就可以方便切换样式了,process.platform 是啥?这就是编译时了。
  • 编译时需要确定目标对象,一般会写成不同脚本或者是一个脚本里根据 platform 分发并写入进程参数,为了锁死各种依赖关系,假设某处写了 process.platform === 'darwin 如果 platform 不符合就会直接剪枝掉后面的部分。
  • 而运行时就广泛得多,比如关闭所有窗口时默认退出 App。
    1
    2
    3
    4
    5
    6
    7
    import os from 'os'
    import { app } from 'electron'
    app.on('window-all-closed', () => {
    if (os.platform() !== 'darwin') {
    app.quit()
    }
    })
    再比如根据系统类型开启不同的提示文本,这些都需要运行时判断,虽然也可以直接编译时判断,但终究不够灵活。

__结论__:类似于配置下发的流程,如果是偏开发侧的内容可以在一处统一管理,如果是偏向本地系统的功能可以根据实际运行环境开闭,做到尽量少依赖于编译时以求在多端最大化复用代码逻辑。


To be continued……