乐趣区

Electron酷家乐客户端开发实践分享-软件自动更新

作者:钟离,酷家乐 PC 客户端负责人
原文地址:https://webfe.kujiale.com/electron-autoupdate/
酷家乐客户端:下载地址 https://www.kujiale.com/activity/136
文章背景:在酷家乐客户端在 V12 改版成功后,我们积累了许多的宝贵的经验和最佳实践。前端社区里关于 Electron 知识相对较少,因此希望将这些内容以系列文章的形式分享出来。
系列文章:

  • 【Electron】酷家乐客户端开发实践分享 — 入坑篇
  • 【Electron】酷家乐客户端开发实践分享 — 软件自动更新
  • 【Electron】酷家乐客户端开发实践分享 — 浏览器启动客户端
  • 【Electron】酷家乐客户端开发实践分享 — 进程通信
  • 不定期更新 …

更新原理

在讲客户端更新方案之前,我们先了解一下 web 和客户端更新的原理

web 应用

在 web 应用的世界里,我们通常会更新 web 服务器上的前端代码(模板、HTML,也可能是 js、css),来发布新的功能。在此之后用户再访问我们的 web 服务器,拿到的已经是更新过后的前端代码了。

web 应用更新如此方便,得益于它中心化存储的方式:

  1. web 应用的前端代码,一般集中储存在服务器或云服务上
  2. 浏览器每次都会都会去服务器拉取最新的资源,用户本机实际上没有持久化储存 web 应用的代码

浏览器缓存也算是在用户本机存储了前端代码,但是在 web 应用需要更新的时候,肯定是会禁用缓存的,否则这次发布对有缓存的用户无效。

客户端

和 web 应用的中心化储存不同,客户端的代码实际上是一种 分布式存储,每个用户电脑上都有一份完整的代码文件,有点像git

用户在电脑上安装客户端,实际上会将客户端代码文件持久储存到本机。例如在 MacOS 上,代码文件存放在 /Applications 目录下。

客户端内嵌 web 页面的更新方式,和上面讲到的 web 应用更新是一样的,不再赘述(参考移动 APP 内嵌的 H5 页面更新)

结论

web 应用的更新,实际上是更新服务端代码文件

客户端的更新,实际上是更新用户电脑上代码文件

具体实现

Electron 官网有关于更新的教程 Updating Applications,但是都不能满足业务需求:

  1. update.electron.org,代码必须托管在 github 上,pass
  2. electron-builder,windows 下只支持 NSIS,而且需要搭建 HTTP 服务。更新程序 UI 和交互定制也不是很友好
  3. Deploying an Update Server,这个方案需要部署一个 update server,也比较麻烦

因此,我们使用的是自己实现的一套更新流程。

1、检查更新

检查更新 是整体流程的第一个步骤。如果有更新,后续的更新逻辑才会执行。通常我们会在 软件启动时 检查更新。

检查更新的策略,实际上是 将本地客户端的版本与远程版本 进行一次对比,然后根据版本对比的结果来给出不同的更新展示。

远程版本

相比于自己搭建一个 update server,维护一个远程的 JSON 数据成本是很低的。这个远程数据可以是一个后端接口或者 cdn 上的 json 文件,并且可以在需要更新的时候,及时更新远程数据的内容

这个远程 JSON 数据里面一般会存放版本号、更新内容介绍以及发布时间:

const updateData = axios.get('https://some-update.json');
console.log(updateData);
/*
{
    version: '1.0.0',
    changeLogs: ['来个开发祭天','新增了???????? 的功能'],
    time: '2019-06-06',
}
*/
本地版本

在 Electron 中获取本地版本是非常简单的 app.getVersion

const localVersion = app.getVersion(); // 0.0.1
版本对比

通常,远程版本号大于本地版本时,即认定为有更新。在有更新的情况下,我们还可以根据版本号里的 major、minor、patch 版本变动,来制定不同的更新策略。

// 远程版本 > 本地版本
const shouldUpdate = semver.gt(removeVersion, localVersion);

// 例子:major 版本号变化时,给出强的更新提示。否则给出正常更新提示

const isMajorUpdate = semver.diff(removeVersion, localVersion) === 'major';

if (!shouldUpdate) return; // 无更新,不走后续

if (isMajorUpdate) {console.log('给出强势更新')
} else {console.log('给出普通的更新提示')
}

对于版本号的操作使用 semver

2、更新提示

检查到软件更新之后,需要给出更新提示来提醒到用户。此时,我们会使用一个窗口承载更新提示的内容,后面统称为更新窗口。

更新窗口内部代码示例:

const updateData = axios.get('some-update.json')
// 检查更新的逻辑,省略

if (!shouldUpdate) return; // 无更新

// 执行到这里,肯定有更新了。拿到更新数据,渲染窗口内容
ReactDom.render(<App
    updateData={updateData}
    onUpdate={() => console.log('用户点击了更新')}
/>, '#app');

// 有更新,主动展示窗口(更新窗口默认是隐藏的)currentWindow.show();

3、更新本机文件

当用户点击了更新按钮之后,那么意味着我们可以开始进行最后一步了。

最后的这一步骤,我们分两步进行:

  1. 获取到最新的安装程序(.dmg or .exe),因为最新的代码文件就在安装程序中
  2. 替换掉用户本机上的代码文件

这一步骤,可以交给用户来做,也可以由我们帮用户来做。我们来看看这两种情况下,分别是如何实现的。

交给用户来更新

首先,我们需要更新网站客户端下载页上的安装程序资源至最新。然后,用户点击更新按钮之后,直接用本机默认浏览器打开下载页,让用户自己下载、安装,安装程序正常执行完毕之后,本身就可以覆盖本机代码文件。

  1. 用户通过浏览器,在下载页获取到了最新的安装程序。
  2. 用户手动打开了安装程序,并执行完毕安装程序。

此法用户体验不是很好,但是优点也很明显:节省了很多开发成本,直接复用了 web 页面来做更新。

如果采用这种策略,那么代码会非常简单:

// 点击更新按钮
function handleUpdate() {shell.openExternal('https://www.kujiale.com/activity/136'); // 打开一个下载页,剩下的交给用户
    
}
我们帮用户更新

当然,为了追求更好的用户体验,直接在更新窗口的代码中实现功能是更好的。

第一步,下载最新的安装程序,并且给出下载进度展示。下载进度功能推荐使用 request-progress 来做。当然,也可以使用 NodeJs 原生的 httpstream模块来实现下载进度展示,这里不详细讲解。

下载到的安装程序,可以暂时存放到用户电脑的临时文件夹中

const fs = require('fs');
const request = require('request');
const progress = require('request-progress');

// 点击更新按钮
function handleUpdate(){
    // 根据版本号拼接安装程序地址
    const downloadUrl = `https://someupdate/${updateData.version}/installer.dmg`;
    // 用 request 下载
    progress(request(downloadUrl))
    .on('progress', (state) => {
        // 进度
        console.log(state)
    })
    // 写入到临时文件夹
    .pipe(fs.createWriteStream(path.join(app.getPath('temp'), 'installer.dmg')))
}

进度展示示例图:

第二步,将安装程序中的代码文件更新到用户本机上,此时有两种方案:

  1. 直接打开安装程序,用户跟随安装程序指引稍作点击即可完成安装。
  2. 解压安装程序中的内容,并将内容更新到用户本机

在 windows 下,安装程序里面是有业务逻辑的:操作注册表、卸载程序、快捷方式等等,因此我们选择第一种方案。

const {shell, app} = require('electron');
shell.openItem('your installer exe path'); // 打开下载好的安装程序
app.quit(); // 退出当前客户端

在 MacOS 下,我们所发行的 dmg 文件其实没有业务逻辑,因此可以使用方案二,直接把 .app 目录解压出来,然后拷贝到 /Applications 目录即可。在 MacOS 下,解压 dmg 文件可以使用 hdiutil。

const cp = require('child_progress');
const path = require('path');
const fs = require('fs-extra');

// 下载完毕之后的 dmg 文件,文件内的.app 目录名为 Test
const installerPath = '/your_installer.dmg'; 

// 使用 hdiutil 来解压 dmg 文件内部资源,解压后的资源目录为 /Volumes/your_installer
 cproc.execSync(`hdiutil attach ${installerPath} -nobrowse`, {stdio: ['ignore', 'ignore', 'ignore']
 });
 
 // 删掉原有的.app 目录
 fs.removeSync('/Applications/Test.app'); 
 
 // 把 Volumes 目录下的.app 目录拷贝到 /Applications 中,更新完毕
 fs.copySync('/Volums/your_installer/Test.app', '/Applications');
 
 // 重启应用
 app.relaunch();
 app.quit();

最后

欢迎大家在评论区讨论,技术交流 & 内推 -> zhongli@qunhemail.com

退出移动版