实现 Electron 热更新的思路和方法

Electron是一个非常流行的框架,但是这个框架由于它在打包上用的通用解决方案不够灵活,导致整个软件没有办法很好地支持热更新。

在网络上我也找了很多方案,但是感觉实现都不理想,于是自己尝试在Fastnote上实现了一个热更新,效果还不错。

这一篇主要是把我的思路做一个简单总结,然后和网络上其他方案做一个对比。

现有方案

很奇怪的是,Electron这么流行的一个框架,居然没有什么很好的、现成的热更新方案。大厂使用类似技术/框架的程序支持体验很好的热更新,所以很显然Electron是一定有办法做热更新的,重点在于这个热更新怎么设计。

遇到问题第一件事情自然是先找有没有现成的依赖,避免重复造轮子。看了一圈下来,轮子是有,但是使用体验很尴尬。

替换app.asar

Electron打包后文件都在/resource/app.asar下,为了实现热更,最简单、直观的一个方式就是去用一个下载下来的新asar包替换已有的asar包,下一次程序启动的时候就自动应用新的asar了。

但是这里存在一个问题,只要Electron启动,app.asar就会处于一个被占用的状态。其他开发者对这个方案提出的解决方案是,通过一个外挂的、静默执行的C程序,在新的包下载完成之后,一旦程序退出,通过node构造一个进程启动这个外挂的新程序,在一定延迟之后完成对app.asar的替换。

很显然,这个方案不是很优雅。首先替换文件有延迟,否则不能确保app.asar被解除占用,假如这个时候用户在延迟没到的时候又启动了应用,那么app.asar又被占用了;第二,app.asar包括了完整的node_modules等,每一次下载的东西比较多,对服务器消耗较大;第三,外挂的C语言程序在打包时要特殊处理,增加麻烦,而且前端程序员可能会不太好定制其行为。

对于外挂的C程序,如果处理不好的话它也可能会造成一个一闪而过的弹窗,对用户的使用体验造成影响。

这种方案在我个人看来体验不好,所以我没有选择用。

重组app.asar

这个思路其实和上面那种类似,问题也是差不多的问题,区别在于用户这一次只需要下载特定的几个更新了的文件,然后把文件重组到app.asar里面。

相较于上一种方案,这个方案节约了更新服务器的流量和带宽,但是实现起来更加复杂,需要通过外挂的、静默执行的程序/脚本执行重组这一部分的操作。

分离资源,不打包页面

这个思路是在unpacked程序的基础上,只打包node_modules等部分,而不打包页面资源,页面资源直接使用经过混淆的源文件。

这样一来不存在整个二进制文件的占用问题,且更新操作可以完全在Electron内部进行。

这个方案还有一个衍生方案,及分发的程序里干脆就不包含页面资源,页面资源由程序从服务器上以远程URL拉取,辅以一定的缓存控制。

这两个方案不优雅的地方在于,它们完全抛弃了asar,页面文件得不到一个基本的二进制保护,而且页面本身如果不散装下载的话,还涉及到一个zip的解包,徒增麻烦。

asar本身就把文件打包好了,也提供了一个基本的保护,想要更强的保护也可以围绕asar做更多的文章,没有必要弃用asar。

新方案

看了一圈,没有符合自己想法的现成的轮子,那么久只能自己造轮子。

用过看Electron官方对asar的描述,他们已经对fs等做了补丁,可以直接读取asar内的文件,窗体的load也可以直接读取asar内的文件。

考虑到node_modules和Electron本地并不需要很经常的更新,实际上我们需要更新的就是页面文件,其实我完全可以只对页面文件打包,然后只下发包含页面文件的asar给用户。

客户端添加一个检测服务器上热更新资源包的能力,对线上资源进行获取。为了确保获取方面,约定线上资源包的描述文件包含下面四个属性:

1、适用的软件版本,确保资源包和依赖是相符的。
2、资源包本身的版本,确保用户获取到的热更新总是最新的。
3、撤包标志,确保某个更新包出错后可以回滚至没有应用更新的状态。
4、hash,必备,确保下载的包是正确的。

通过控制更新的feed,直接在更新源上做版本号的区分,可以确保客户端在更新之前能收到更新,更新之后能及时清理掉不必要的热更包。

对于热更文件的加载,我的判断是只要存在热更包,优先从热更包加载页面。由于asar是可以作为一个虚拟文件夹直接读取的,实现类似于:

1
2
let viewpath = global.hotfix.buildPath('index.html');
win.loadFile(viewpath);

然后实现这样一个buildPath方法:

1
2
3
buildPath(page) {
return asarPath ? path.resolve(asarPath, page) : path.resolve(__dirname, `../public/${page}`);
}

就能优先从热更新的asar内加载页面文件。至于怎么控制asarPath是null还是实际路径,就靠一个加载、校验的流程去控制。

这个还有一点好处在于,它不需要考虑任何app.asar被占用的问题,因为这些asar是加载了里面的页面才会被Electron作为虚拟文件夹挂载、占用,所以在asar下载完成后即时改变asarPath路径,窗体下一次加载的时候就能加载到热更的页面。

把整个热更新模块作为一个global的变量使用,就可以在所有窗口里实现统一的页面路径替换。

这个地方需要注意一个点,考虑到Electron对fs打了补丁,热更新模块直接运行在Electron主线程下,所以在整个模块中很多fs操作会导致刚下载的asar被立刻作为虚拟文件夹挂载,导致后续无法再进行更多操作,所以在确认包可用之前不应以.asar作为文件的扩展名。

考虑到页面文件是可能有node集成,可能会存在对node_modules的调用。由于require本身是相对的,在热更的asar里不包括node_modules,所以我们要把require绝对性地指向app.asar。这个很好做到,一个全局变量搞定问题。

总结

总体做下来这一个方案主要难在思路上,理解了asar在Electron中是个什么东西,文件是怎么从asar加载的,关联关系如何,进而不难得到这个方案。

实现是很容易的,基于gulp的自动打包、上传也很好做,描述文件甚至可以配置成简单的自动生成,没有什么太大的难度,思路有了实现就快了。

相较于其他方案,这个方案的优势在于:

  • 仅单独对页面文件进行更新,热更包很轻量
  • 利用了asar,文件没有失去asar的基本二进制保护,不是完全暴露的
  • 无需考虑app.asar被占用的问题,即时更新,下次加载立刻生效
  • 支持非常方便的撤包、推送新包,app.asar本身的文件不受影响,可以直接回退到app.asar

劣势在于:

  • 仅适用于临时的功能修复、小幅调整,安全更新只能覆盖页面的业务逻辑,不能覆盖依赖
  • 考虑到页面和页面之间的关联、依赖关系,热更新只能是全量的页面文件包。非全量包涉及到从app.asar抽出文件进行包的重组,实现起来较为复杂,小型项目没有必要这么做。
  • 方案对现有程序的侵入性较大,每一个窗体的JS和每一个页面内对node_modules的require都需要修改路径、指向。

适用性上,这一套方案并不适合以下几类Electron项目:

  • 对某些更新频率很高的模块有严重依赖,这一套方案只能更新页面,不能同步更新node_modules。
  • 大量逻辑通过主进程完成的项目,这一套方案的更新主要是针对渲染进程下的程序。
  • 严重依赖某些本地模块的项目,本地模块本身就不太好用asar处理,用这种方案更新会加倍痛苦。

当然你可以基于这个思路另外维护一个可热更的依赖包,不过在渲染进程范围内,这很没必要,涉及到主进程的话这一套方案就更不优雅了。

实现参考

这里提供一个完整的热更新工具类代码,基本上是这个方案的核心了。

以下代码隶属于Fastnote项目,根据项目的许可协议,下面代码受GPLv3协议保护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
const { app } = require('electron');
const appVersion = app.getVersion();

const fs = require('fs');
const path = require('path');
const request = require('request');
const FileValidation = require('./fileValidation');

// path
let feed;
let userDataPath = app.getPath('userData');
let dirPath;
let manifestPath;
let asarPath;

const hotfix = {
hotfixBuild: null,
init(indebug) {
feed = '';
dirPath = `${userDataPath}/hotfix${indebug ? '_dev': ''}`;
// 检查dirpath是否存在
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath);
}
manifestPath = `${dirPath}/manifest.json`;
return this.loadCheck();
},
async loadCheck() {
// 检查热更新manifest是否存在
if (!fs.existsSync(manifestPath)) {
console.info('Hotfix manifest does not existed.');
this.onlineCheck();
return false;
}
// 存在,则读入
let manifest;
try {
manifest = fs.readFileSync(manifestPath);
} catch (e) {
manifest = null;
console.error('Hotfix manifest read error.');
}
// 读入失败,视为热更新损坏,重新检查
if (!manifest) {
if (this.deletePatch()) {
this.onlineCheck();
}
return false;
}
manifest = JSON.parse(manifest);
this.hotfixBuild = manifest.build;
// 比较应用版本
if (manifest.version !== appVersion) {
// 热更版本和应用不符
if (this.deletePatch()) {
this.onlineCheck();
}
return false;
}
if (manifest.revoke) {
asarPath = null;
this.onlineCheck();
return;
}
// 应用版本符合,抽出resource名称
asarPath = `${dirPath}/${manifest.resource}`;
// 检查asar是否存在
if (!fs.existsSync(asarPath)) {
// 不存在,视为热更新损坏,需要删除之后重新检测
if (this.deletePatch()) {
this.onlineCheck();
}
return;
}
// 在线检测,检查是否有更新的、同一版本的热更可以下载
this.onlineCheck();
},
async onlineCheck() {
let manifest = await this.downloadManifest();
if (!manifest) {
return false;
}
// 线上hotfix版本不一致或build不一致,忽略
if (manifest.version !== appVersion) {
return false;
}
if (manifest.build <= this.hotfixBuild) {
return false;
}
// 撤包
if (manifest.revoke) {
// 删除现有的内容防止被加载
this.deletePatch();
fs.writeFileSync(manifestPath, JSON.stringify(manifest));
asarPath = null;
return false;
}
// 下载asar资源
let resourceRet = await this.downloadResource(manifest);
if (!resourceRet) {
// 发生下载错误,忽略
return false;
}
// 下载事件完成
let resourcePath = `${dirPath}/${manifest.resource}`;
let cachePath = resourcePath.replace('.asar', '.download');
// 下载完成但没有下载到东西也会返回true,要再对文件做检测,不存在则跳出这一次任务
if (!fs.existsSync(cachePath)) {
return false;
}
let hash = await FileValidation.sha256(cachePath);
if (hash !== manifest.check) {
// 文件校验失败,视为下载失败,删除文件并忽略
if (fs.existsSync(cachePath)) {
fs.unlinkSync(cachePath);
}
return false;
}
// 文件检查无误,写入新的manifest,更新全局asarPath
fs.renameSync(cachePath, resourcePath);
fs.writeFileSync(manifestPath, JSON.stringify(manifest));
asarPath = `${dirPath}/${manifest.resource}`;
return true;
},
downloadManifest() {
return new Promise((resolve, reject) => {
request.get(`${feed}/manifest.json`, (err, res, body) => {
if (err || res.statusCode != 200) {
console.error('Cannot download manifest file.');
return resolve(null);
}
resolve(JSON.parse(body));
});
});
},
downloadResource(manifest) {
return new Promise((resolve, reject) => {
request.get(`${feed}/${manifest.resource}`)
.on('error', () => {
console.error('Hotfix resource download failed.');
resolve(false);
})
.on('close', () => {
console.info('Hotfix download completed');
resolve(true);
})
.pipe(fs.createWriteStream(`${dirPath}/${manifest.resource.replace('.asar', '.download')}`));
});
},
deleteAsar() {
if (asarPath && fs.existsSync(asarPath)) {
if (!fs.unlinkSync(asarPath)) {
return false;
}
}
return true;
},
deletePatch() {
if (manifestPath && fs.existsSync(manifestPath)) {
console.info('Start deleting hotfix manifest file.');
if (!fs.unlinkSync(manifestPath)){
return false;
}
}
if (asarPath && fs.existsSync(asarPath)) {
console.info('Start deleting hotfix resource file.');
if (!fs.unlinkSync(asarPath)) {
return false;
}
}
return true;
},
buildPath(page) {
return asarPath ? path.resolve(asarPath, page) : path.resolve(__dirname, `../public/${page}`);
}
};

module.exports = hotfix;