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

Electron 是一个时下非常流行的、融合了Node与V8引擎、允许开发者使用前端技术进行跨平台客户端开发的框架,有很多成熟的产品都在使用这样一个框架,比如射手影音、迅雷等。

开源社区为这个框架提供了一套配套的通用打包、自动更新方案,这一套方案也被相当多的项目采用,我个人的项目也在使用这一套通用的方案管理打包和自动更新。但是在项目上线后,这一套通用方案的不灵活也随之暴露了出来 —— 在打包分发后,开发者将失去对已打包资源的掌控,没有办法基于远程进行修改,导致项目出现了一些紧急故障的时候,开发者只能再次打包项目的所有资源给用户推送一个全量更新,这对用户来说是一个非常不好的体验。

相较而言,在移动客户端上被广泛运用的热更新方案是一个非常好的解决办法,但是在Electron这一套框架下,让人很意外的是,虽然这个框架本身热度非常高,但是开源社区内并没有非常完善的、实现热更新的方法/框架/依赖可用。

开源社区提供了一些方案,但是这些方案在实现上并不是很理想,出于这样的原因,我自发地探索、研究,根据我现有项目的特点实现了一个热更新方案。

在后文我会详细介绍这一套方案,给“Electron下如何实现热更新”这个问题提供一个思路。

在这之前,我们先看一下现成的方案都是如何实现热更新的:

现有方案

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

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

替换app.asar

Electron 项目的资源在打包后都会归于 /resource/app.asar 这么一个二进制文件内,为了实现热更新,最简单、直观的一个方式就是用一个从服务器上下载下来的新 asar 文件替换已有的 asar 文件,这样一来用户不需要重复安装整个程序,客户端本身可以在一个静默状态下实现资源文件的替换与应用(下一次用户启动时,新的app.asar被加载,即达成了替换的目的)。

但是这里存在一个问题,只要 Electron 程序启动,app.asar 就会处于一个被占用的状态,热更新本身追求的是轻量化、高效化,启动另一个程序,用一个额外的进程来监控app.asar的状态对其进行替换,实际上是一个很不优雅的操作,热更新模块应该集成在Electron项目内部。

对于这一问题,开源社区的一些开发者提出了一个解决方案:在程序包下载完成、用户退出程序时,启动一个外挂的、静默执行的 C 程序去执行包的替换(本质上就是一个文件删除和重命名的工作)。但是这个方案又引入了一个新的问题 —— Electron的完全退出是比较难监控的,这种方法执行替换会有失败的可能。

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

重组app.asar

这个思路其实和上面那种类似,存在的问题也比较类似,其主要区别在于客户端在这种方案下只需要下载特定的几个更新了的文件,然后通过一个Electron程序结束后执行的外挂程序把文件重组到 app.asar 里面。

而前一种方案则需要下载一个完整的app.asar,但是对于一个热更新,我们可能只更新了所有项目资源文件中的少数文件,大多数文件以及项目依赖都是不会有变动的,直接下载整个app.asar实际上下载了不少冗余的内容。

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

分离资源,不打包页面

这个方案建立在应用本身是 unpacked 形式的基础上,即应用只打包 node_modules 等依赖资源,而不打包页面资源与核心逻辑,或整个项目本身都不执行打包,直接以源文件或或混淆过的源文件的形式进行分发。

这样一来就不存在app.asar会被占用问题,且更新操作可以完全在 Electron 内部进行,因为Electron只会对这些未封包的资源文件做一次读取,读取之后资源文件就会被立刻释放。

这个方案还有一个衍生方案,在这个方案内,开发者向用户分发的应用里直接不包含核心逻辑、页面资源,只分发体积较大的依赖资源,页面资源以及核心逻辑完全由Electron程序通过URL从远程服务器上加载。

然而,这一类方案虽然看似实现上非常美好,但是他们却是这些现成方案中最不优雅的。其不优雅的地方在于它们完全抛弃了 asar,使得应用的页面文件和核心逻辑得不到保护(混淆对于HTML、JS等文件来说意义不是特别大,混淆过度反而会影响程序的执行效率),asar本质上是一种类似于压缩包的一种二进制封装,它能够给应用提供一个基本的保护。

在这种情况下,热更新的过程中也会引入一些新的问题,比如更加复杂的版本控制问题、文件列表的自动化维护、尽可能减少下载链接数等优化问题。

新方案

实际上我们完全没有必要弃用asar,asar本身是一个非常好的封装,通过技术手段,我们可以围绕 asar 做更多的文章。

通过看 Electron 官方对 asar 的描述,实际上Electron的开发团队已经对Node中的 fs 等模块打了补丁,使得这些模块可以视asar为一个虚拟文件夹,开发者可以很轻松地通过fs等模块操作 asar 内的文件,窗体的 loadFile 也可以直接读取 asar 内的文件。

由于热更新并不需要考虑 node_modules 内的依赖文件和 Electron框架本身(如果是依赖或框架程序主体存在问题,开发者只能通过全量更新来修复),实际上我们要更新的就是核心逻辑与页面文件。在这样一个思路下,我们完全可以只对核心逻辑和页面文件进行打包,然后只下发这样一个 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还是实际路径,就靠一个加载、校验的流程去控制。

通过根据客户端版本号控制客户端获取热更新的 feed,直接在线上服务期的更新源上做版本号的区分,我们可以确保客户端能够收到与其基础版本对应的热更新,不会出现热更新资源包和程序基础版本不匹配的情况。

对于热更新文件的加载,我们只需要根据上述约定的描述文件,对热更新资源包的存在状态以及版本进行判别,决定是否要让程序加载其中的内容即可。对于资源路径的控制,我们只需要构造一个buildPath函数,根据资源包的存在情况去构造loadFile以及程序逻辑内require等方法需要的路径,使其指向资源包或原有的app.asar(热更新不存在或被撤回时)即可。

这个方案还有一点好处在于,它不需要考虑任何 app.asar 被占用的问题,因为asar 资源包是在Electron加载了其中的页面后才会被 Electron 作为虚拟文件夹挂载、占用,所以在 asar 下载完成后,buildPath方法会及时对路径进行调整,使应用窗体下一次加载资源的时候就能加载到已经更新了的页面。对于多窗口应用,我们可以把整个热更新模块作为一个 global 的变量使用,这样我们就可以在所有窗口里实现统一的资源路径替换。

考虑到 Electron 对 fs 打了补丁,热更新模块直接运行在 Electron 主线程下,所以在整个模块中很多 fs 操作会导致刚下载的 asar 被立刻作为虚拟文件夹挂载,导致后续无法再进行更多操作,为了便于校验包的正确性,在确认包可用前我们不应以 .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;