上一篇介绍了electron版本更新之全量更新(vue)_Zoie_ting的博客-CSDN博客
但是全量更新的包太大了,而且也不是每次都改动很多东西需要全量更的,有没有什么办法可以少量更新呢?本篇就在上一篇的基础上,讲述一下如何进行增量更新。
词不达意,以图示例:
一、入手
打包项目,在dist中有个bundled,这个目录下就是我们经常修改的部分,包括css、js、图片、html等文件。本地安装一下,打开文件所在位置,会发现在resources目录下有个app.asar,其实这个文件是把所有bundled的内容压缩到一起了。
或许读者会质疑,那我们来实操一下:
- 首先安装asar:npm install -g asar
- 切到app.asar所在目录,执行:asar extract app.asar ./app-bundled
- 之后会在resources看到一个app-bundled文件夹,下面的内容几乎与dist中的bundled一样!
其实,在主进程中的win.loadURL('app://./index.html') ,就是运行的这个文件中的index.html。
因此,理论上,我们只需要把app.asar中的需要改的文件单独拎出,修改主进程的index.html加载路径就好了。
二、分包
这里要特别说明一下,版本更新需要有触发机制、不论是接口获取还是websocket推送还是配置文件声明。这个应该很好理解,接口获取和websocket推送比较简单,获取数据跟用户本地不一样时触发更新即可,笔者这里用配置文件(hotVersion.json)的方式说明一下。
在vue.config.js中配置打包时的分包文件,主要思想就是将平时经常修改的文件放到app.asar.unpacked中,将版本比对的hotVersion.json单独拎出,其余文件仍然放到app.asar中。
hotVersion.json中的内容只是个版本号,用于比对:{"version": "2.5.7"}。
这里用到asar和builderOptions中的files和extraResources配置项:
- asar:是否使用Electron的存档格式将应用程序的源代码打包到存档中,一定要设置成false
- file加入的文件就是新包中app.asar中的文件
- extraResources中:
- from表示从打完的包中的哪个路径下,这里是dist/bundled
- to表示将这些包输出到哪个路径,app.asar.unpacked表示在resources文件夹下的app.asar.unpacked文件夹中,./表示在resources文件夹下
- filter匹配的是文件名,特别注意的是,打到app.asar.unpacked中的文件一定是从bundled中过滤出来的,且在app.asar.unpacked中的文件一定不能在file中,因为一旦app.asar中有该文件,那么程序会自动使用该文件中的而不是我们想要的app.asar.unpacked中的
配置如下:
pluginOptions: {
electronBuilder: {
//...
asar: false,
builderOptions: {
productName: "test", //包名
extraResources: [
{
from: "dist/bundled",
to: "app.asar.unpacked",
filter: [
"!**/node_modules",
"!**/background.js",
"!**/background.js.LICENSE.txt",
"!**/favicon.ico",
"!**/package.json",
"!**/hotVersion.json",
],
},
{
from: "dist/bundled",
to: "./",
filter: [
"**/hotVersion.json"
],
},
],
files: [
"**/node_modules/**/*",
"**/background.js",
"**/background.js.LICENSE.txt",
"**/favicon.ico",
"**/package.json",
],
win: {
publish: [
{
provider: "generic",
url: "https:xxx", //更新服务器地址,可为空
},
],
//...
},
},
},
},
按照上面的配置,打包之后,安装到本地,在resources文件夹下会多出hotVersion.json和app.asar.unpacked文件夹,app.asar.unpacked下的内容就是我们配置的过滤出的经常修改的那些。执行asar extract app.asar ./app-bundled,可以看到多出的app-bundled文件夹下只有我们配置的files中的那些文件,再次强调一下,app.asar中不能存在与app.asar.unpacked相同的文件。
至此,我们只是将安装后的文件分离出来,并没有打出分包。继续配置:
Common Configuration - electron-builder在electron-builder中有几个钩子函数,这里我们需要用到afterPack,这里的函数将在打包之后运行,笔者需要在此打出分包,这里用到了adm-zip,请读者自行install。
新建afterPack.js,写入以下代码:
- targetPath表示安装到本地后的resources路径,也就是app.asar.unpacked所在路径,unpacked为app.asar.unpacked路径,也就是我们每次需要修改的文件的所在路径,利用adm-zip将该路径下的文件全部打包,将压缩包输出到当前打包路径下,即dist下,并命名为unpack.zip。
- 对dist/hotVersion.json执行写操作(没有该文件则自动生成),写入的内容正是我们hotVersion.json的内容
const path = require("path");
const AdmZip = require("adm-zip");
const fs = require("fs");
exports.default = async function (context) {
let targetPath;
if (context.packager.platform.nodeName === "darwin") {
targetPath = path.join(
context.appOutDir,
`${context.packager.appInfo.productName}.app/Contents/Resources`
);
} else {
targetPath = path.join(context.appOutDir, "./resources");
}
const unpacked = path.join(targetPath, "./app.asar.unpacked");
var zip = new AdmZip();
zip.addLocalFolder(unpacked);
zip.writeZip(path.join(context.outDir, "unpacked.zip"));
fs.writeFile(
path.join(context.outDir, "hotVersion.json"),
JSON.stringify(
{
version: require("./hotVersion.json").version
},
null,
2
),
(err, data) => {}
);
};
此时vue.config.js配置中需加入afterPack:
//...
files:[
//...
]
afterPack: "./afterPack.js",
//...
到这里,打包后的dist文件夹中就会生成unpack.zip和hotVersion.json,其中unpack.zip就是我们每次更新的包,换言之,我们每次将用户本地的hotVersion.json和服务器上的hotVersion.json的版本号进行比对,如果不一致,则下载unpack.zip,解压到app.asar.unpacked中,刷新页面,即可完成更新。
别忘了,electron默认运行的是app.asar中的index.html,在上面一顿操作之后,app.asar中已经没有index.html了,因此,打开后将会是白屏。不要慌,修改一下加载路径。
原来的加载index.html的配置如下:
import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'
//...
createProtocol('app')
win.loadURL('app://./index.html')
不再引用vue-cli-plugin-electron-builder/lib的createProtocol,新建createProtocol.js:
import { protocol } from 'electron'
import * as path from 'path'
import { readFile } from 'fs'
import { URL } from 'url'
export const createProtocol = (scheme, customProtocol, serverPath = __dirname) => {
(customProtocol || protocol).registerBufferProtocol(
scheme,
(request, respond) => {
let pathName = new URL(request.url).pathname
pathName = decodeURI(pathName) // Needed in case URL contains spaces
readFile(path.join(serverPath, pathName), (error, data) => {
if (error) {
console.error(
`Failed to read ${pathName} on ${scheme} protocol`,
error
)
}
const extension = path.extname(pathName).toLowerCase()
let mimeType = ''
if (extension === '.js') {
mimeType = 'text/javascript'
} else if (extension === '.html') {
mimeType = 'text/html'
} else if (extension === '.css') {
mimeType = 'text/css'
} else if (extension === '.svg' || extension === '.svgz') {
mimeType = 'image/svg+xml'
} else if (extension === '.json') {
mimeType = 'application/json'
} else if (extension === '.wasm') {
mimeType = 'application/wasm'
}
respond({ mimeType, data })
})
}
)
}
修改加载路径,改为加载app.asar.unpacked中的index.html:
let createProtocol = require("./config/createProtocol.js").createProtocol;
//...
createProtocol(
"app",
"",
path.join(process.resourcesPath, "./app.asar.unpacked")
);
win.loadURL("app://./index.html");
至此,打包安装后运行electron就不是白屏了。
如果需要发布增量版本,只需要修改hotVersion.json的内容,打包后将hotVersion.json和unpack.zip放在服务器上,检测时如果hotVersion.json本地与服务器不一致,则更新。
三、更新
注:这里接上一篇全量更新的逻辑,如果有不清楚的,可以看上一篇:electron版本更新之全量更新(vue)_Zoie_ting的博客-CSDN博客
启动,检测是否存在全量更新:
if (process.env.WEBPACK_DEV_SERVER_URL) {
//...
} else {
createProtocol(
"app",
"",
path.join(process.resourcesPath, "./app.asar.unpacked")
);
win.loadURL("app://./index.html");
checkForUpdates();
}
function checkForUpdates() {
autoUpdater.checkForUpdates();
}
如果存在更新包,将自动从autoUpdater.setFeedURL(url)配置的url下载,下载完成后在update-downloaded处理。其中defaultId是配置默认选择项,值是buttons的索引,例如这里的配置是0,表示“否”。cancelId是配置的也是0,表示如果不选择buttons的值而是直接关闭弹出的确认框时,按照选择“否”处理:
import {
//...
dialog
} from "electron";
autoUpdater.on("update-downloaded", () => {
dialog.showMessageBox({
type: "info",
buttons: ["否", "是"],
title: "应用更新",
message: "更新包下载完成",
detail: "请选择是否立即更新",
defaultId: 0,
cancelId: 0,
}).then((res) => {
if (res.response === 1) {
autoUpdater.quitAndInstall();
} else {
}
});
});
如果不存在全量更新,则检测是否存在增量更新版本:
let currentIncrementUpdate = ""; //本地版本
autoUpdater.on("update-not-available", () => {
// 读取本地hotVersion
fs.readFile(
path.join(process.resourcesPath, "./hotVersion.json"),
"utf8",
(err, data) => {
if (err) {
//...
} else {
//记录本地的版本号,因为我们需要比对本地版本号和线上是否相同再触发更新
currentIncrementUpdate = JSON.parse(data).version;
incrementUpdate();
}
}
);
});
检测是否存在增量更新,如果存在,则下载线上的版本包:
let obsIncrementUpdate = ""; //服务器版本
let currentIncrementUpdate = ""; //本地版本
// 增量更新
async function incrementUpdate() {
let oldPath = process.resourcesPath + "/app.asar.unpacked";
let targetPath = process.resourcesPath + "/unpacked.zip";
request(
{
method: "GET",
uri: "https://xxx/hotVersion.json",
},
function (err, response, body) {
if (response.statusCode == 200) {
// 服务器版本
obsIncrementUpdate = JSON.parse(body).version;
//两个版本号不同,触发更新
if (currentIncrementUpdate != obsIncrementUpdate) {
let req = request({
method: "GET",
uri: "https://xxx/unpacked.zip", //增量更新包在服务器上的路径
});
try {
let out = fs.createWriteStream(targetPath);
let received_bytes = 0;
let total_bytes = 0;
req.pipe(out);
req.on("response", function (data) {
total_bytes = parseInt(data.headers["content-length"]);
});
req.on("data", function (chunk) {
received_bytes += chunk.length;
});
req.on("end", function () {
if (req.response.statusCode === 200) {
if (received_bytes === total_bytes) {
updateAtOnce(oldPath, targetPath, obsIncrementUpdate);
} else {
out.end();
//...省略错误处理
}
} else {
//网络波动,下载文件不全
out.end();
//...省略错误处理
}
});
req.on("error", (e) => {
out.end();
//网络波动,下载文件不全
if (received_bytes !== total_bytes) {
//...省略错误处理
} else {
//...省略错误处理
}
});
} catch (err) {
//...省略错误处理
}
} else {
}
} else {
//读取线上的hotVersion错误
//...省略错误处理
}
}
);
}
已经下载完毕线上的增量更新包,给出提示,是否立即更新:
async function updateAtOnce(oldPath, targetPath, obsIncrementUpdate) {
dialog
.showMessageBox({
type: "info",
buttons: ["否", "是"],
title: "应用更新",
message: "更新包下载完成",
detail: "请立即完成更新",
defaultId: 0,
cancelId: 0,
})
.then((res) => {
if (res.response === 1) {
//立即更新
handleIncreaseUpdate(oldPath, targetPath, obsIncrementUpdate);
} else {
}
});
}
处理立即更新:判断是否存在app.asar.unpacked.old,如果存在,则删除该文件夹和该文件夹下的所有文件,将app.asar.unpacked先备份为app.asar.unpacked.old,解压已下载的unpack.zip到app.asar.unpacked,在这个过程中一旦发生异常,将app.asar.unpacked.old恢复成app.asar.unpacked。
之所以要单独写这个函数,是为了后面的优化,这个函数主要处理的就是下载完的安装。如果已经有安装包,可以进行强更,只需要执行这个函数即可。
//删除目标文件夹以及文件夹下的所有文件
function deleteOld(url) {
var files = [];
if (fs.existsSync(url)) {
files = fs.readdirSync(url);
files.forEach(function (file, index) {
var curPath = path.join(url, file);
if (fs.statSync(curPath).isDirectory()) {
deleteOld(curPath);
} else {
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(url);
}
}
async function handleIncreaseUpdate(
oldPath,
targetPath,
obsIncrementUpdate,
reload = true
) {
//删除目标文件夹以及文件夹下的所有文件
deleteOld(oldPath + ".old");
// 建立.old备份
fs.rename(oldPath, oldPath + ".old", (err) => {
if (err) {
//...省略错误处理
return;
}
// 解压
let zip = new AdmZip(targetPath);
// 把整个压缩包完全解压到 app.asar.unpacked 目录中
zip.extractAllToAsync(oldPath, true, (err) => {
if (err) {
//恢复
fs.rename(oldPath + ".old", oldPath, (err) => {});
return;
}
//解压完之后别忘了要修改本地hotVersion文件的版本号,否则会一直触发更新
fs.writeFile(
path.join(process.resourcesPath, "./hotVersion.json"),
JSON.stringify(
{
version: obsIncrementUpdate,
},
null,
2
),
(err, data) => {
if (err) {
//...省略错误处理
} else {
currentIncrementUpdate = obsIncrementUpdate;
if (reload) {
//重启应用
app.relaunch();
app.exit(0);
} else {
}
}
}
);
});
});
}
四、定时更新
上文笔者已经介绍了正常情况下更新的流程已经处理,但是这是在用户启动的时候检测了一次,如果用户启动之后没有退出过软件,那按照上述的实际上是没办法更新的,所以需要加入定时更新。
需要注意的是,如果已经安装过全量包了,就不要再让用户下载了,也就是说不再检测更新了。
笔者这里假设是两小时检测一次(预留足够长的时间,确保在这个时间内可以下载完全量更新包(因为增量更新包比全量小)),修改如下:
let timeInterval = null; //检测更新
var updateDownloading = false; //正在下载全量更新包
function checkForUpdates() {
/防止如果有其他的触发机制,每次先清除定时器,每次触发则重新计时
clearInterval(timeInterval);
timeInterval = null;
// 已下载完成或尚未下载
if (!updateDownloading) {
autoUpdater.checkForUpdates();
timeInterval = setInterval(() => {
if (!updateDownloading) autoUpdater.checkForUpdates();
}, 7200000);
} else {
}
}
autoUpdater.on("update-available", (info) => {
updateDownloading = true;
});
autoUpdater.on("update-downloaded", () => {
updateDownloading = false;
}
修改incrementUpdate():
let obsIncrementUpdate = ""; //服务器版本
let currentIncrementUpdate = ""; //本地版本
let hasCheckWaitUpdate = false; //稍后更新
let downloadApplying = false;
// 增量更新
async function incrementUpdate() {
let oldPath = process.resourcesPath + "/app.asar.unpacked";
let targetPath = process.resourcesPath + "/unpacked.zip";
if (hasCheckWaitUpdate) {
dialog
.showMessageBox({
type: "info",
buttons: ["否", "是"],
title: "应用更新",
message: "更新包下载完成",
detail: "请立即完成更新",
defaultId: 0,
cancelId: 0,
})
.then((res) => {
if (res.response === 1) {
handleIncreaseUpdate(oldPath, targetPath, obsIncrementUpdate);
}
});
}
return;
}
if (downloadApplying) {
dialog
.showMessageBox({
type: "info",
buttons: ["我知道了"],
title: "应用更新",
message: "更新包正在下载",
detail: "请耐心等待",
})
.then((res) => {});
return;
}
request(
{
method: "GET",
uri: "https://xxx/hotVersion.json",
},
function (err, response, body) {
if (response.statusCode == 200) {
// 服务器版本
obsIncrementUpdate = JSON.parse(body).version;
//两个版本号不同,触发更新
if (currentIncrementUpdate != obsIncrementUpdate) {
downloadApplying = true;
let req = request({
method: "GET",
uri: "https://xxx/unpacked.zip", //增量更新包在服务器上的路径
});
try {
let out = fs.createWriteStream(targetPath);
let received_bytes = 0;
let total_bytes = 0;
req.pipe(out);
req.on("response", function (data) {
total_bytes = parseInt(data.headers["content-length"]);
});
req.on("data", function (chunk) {
received_bytes += chunk.length;
});
req.on("end", function () {
if (req.response.statusCode === 200) {
if (received_bytes === total_bytes) {
updateAtOnce(oldPath, targetPath, obsIncrementUpdate);
} else {
out.end();
//...省略错误处理
downloadApplying = false;
}
} else {
//网络波动,下载文件不全
out.end();
//...省略错误处理
downloadApplying = false;
}
});
req.on("error", (e) => {
out.end();
//网络波动,下载文件不全
if (received_bytes !== total_bytes) {
//...省略错误处理
} else {
//...省略错误处理
}
downloadApplying = false;
});
} catch (err) {
//...省略错误处理
downloadApplying = false;
}
} else {
}
} else {
//读取线上的hotVersion错误
//...省略错误处理
}
}
);
}
修改updateAtOnce():
async function updateAtOnce(oldPath, targetPath, obsIncrementUpdate) {
hasCheckWaitUpdate = true;
//...
}
修改handleIncreaseUpdate():
let decompressing = false; //正在解压
let hasCheckWaitUpdate = false; //稍后更新
let downloadApplying = false;
async function handleIncreaseUpdate(
oldPath,
targetPath,
obsIncrementUpdate,
reload = true
) {
if (!fs.existsSync(targetPath)) {
hasCheckWaitUpdate = false;
downloadApplying = false;
return;
}
// 不能重复处理文件
if (decompressing) {
return;
}
decompressing = true;
//...
fs.rename(oldPath, oldPath + ".old", (err) => {
if (err) {
//...省略错误处理
hasCheckWaitUpdate = false;
downloadApplying = false;
decompressing = false;
return;
}
// ...
zip.extractAllToAsync(oldPath, true, (err) => {
if (err) {
//...
downloadApplying = false;
decompressing = false;
return;
}
//解压完之后别忘了要修改本地hotVersion文件的版本号,否则会一直触发更新
fs.writeFile(
//...
(err, data) => {
if (err) {
//...省略错误处理
} else {
currentIncrementUpdate = obsIncrementUpdate;
hasCheckWaitUpdate = false;
if (reload) {
//刷新页面
win.webContents.reloadIgnoringCache();
} else {
}
}
downloadApplying = false;
decompressing = false;
}
);
});
});
}
五、优化与兜底
5.1 强制更新
切换账号或者退出系统时,检测是否已经下载了安装包但还没有更新,强制更:
ipcMain.on("mustUpdate", (event, args) => {
if (hasCheckWaitUpdate) {
let oldPath = process.resourcesPath + "/app.asar.unpacked";
let targetPath = process.resourcesPath + "/unpacked.zip";
handleIncreaseUpdate(oldPath, targetPath, obsIncrementUpdate);
setTimeout(() => {
closeWinAll();
}, 2000);
} else {
closeWinAll();
}
});
5.2 弹窗减少
上面我们是定时检测更新,每次都会给出立即更新的确认框,但是用户可以不选择也不关闭,这样就会导致弹框反复弹出,他每个弹框都可以点击,但是当他点击其中一个弹框的时候,我们已经更新了,所以后面的点击可能会报错,为了解决这个问题,我们可以限制弹框只出现一次:
let messageBox = null; //立即更新提示框
async function incrementUpdate() {
//...
if (hasCheckWaitUpdate) {
if (!messageBox) {
messageBox = dialog
.showMessageBox({
//...
})
.then((res) => {
messageBox = null;
//...
});
}
return;
}
//...
}
async function updateAtOnce(oldPath, targetPath, obsIncrementUpdate) {
//...
if (!messageBox) {
messageBox = dialog
.showMessageBox({
//...
})
.then((res) => {
messageBox = null;
//...
});
}
}
总结
以上就是笔者对electron的增量更新及优化的主要介绍,如果有什么记录不全的,欢迎提问~