LMX
博客精选作品支持关于
Command Palette

Search for a command to run...

博客

Electron更新做快:在OTA上的几次推倒重来

LMX2026年9月29日20 分钟阅读#Electron#LobeHub#OTA#Sparkle

记录 LobeHub 的 Electron 更新实践,从 Renderer OTA 的兼容性困境到 Shell/Core 拆分、内容寻址差分、Pack 与 Range 下载、启动回滚,以及 Sparkle 补丁链。

页边批注为本站的阅读理解与实践补充,不属于原文。

其实对 Electron 这方面的更新,我也已经有过很长时间的探索了。

最早是在做 Folo 的时候,我给它做过一套 renderer 层的 OTA。那套东西完全是我自己手搓的,没有借助 AI。当时没有考虑太多,很多边界情况没处理好,架构也谈不上好。

到了 LobeHub,我接着把这件事往下做。这一轮是一次次改出来的,按 PR 大致能串成这样:

  • 8 月 24 日,renderer 热更新上线,不重启 App 就能换掉前端。
  • 之后几天连着改 OTA 包:gzip 压缩,只保留基线和最近两个增量、按发布版本隔离、换成 V2 包格式。
  • 9 月 12 日,在 macOS 的 canary 渠道试 Sparkle 差分更新,这个 PR 还没合并。
  • 9 月 28 日,把 app.asar 拆成一层很薄的 shell 和一个带签名、能单独 OTA 的 core,支持 reload、relaunch 和回滚。
  • 9 月 29 日,内置 core 也打包成 ASAR,OTA 包支持按范围下载。

常规的 Electron 更新,最常规的方式就是直接全量更新,通过 electron-updater 就可以完成。

为什么我不想用 electron-updater 的全量更新

在 LobeHub 里,一次全量更新从 App 退出到重新打开,差不多要 10 秒。我平时用 Mac,这 10 秒每次发版都得等一遍。macOS 上 electron-updater 底层走的是 Squirrel.Mac:先把新版本完整的 zip 下载下来,等 App 退出后,再由一个叫 ShipIt 的进程把整个 .app 替换掉,最后重新拉起。

electron-updater 也不是每次都下全量包,它有一套增量下载机制,叫 blockmap。可这套机制在我这里有两个问题:生成慢,而且小版本升级照样要下好几兆。

blockmap 怎么省流量

打包的时候,electron-builder 会把安装包切成很多小块,每块十几 KB,给每一块算一个指纹,存进一个 .blockmap 文件。更新时,客户端拿新旧两份 blockmap 逐块对比:指纹一样的块从本地旧包里直接复制,只有指纹变了的块才去下载。

思路没有问题,问题出在它切的是压缩之后的安装包。

electron-updater 的压缩包 blockmap 与 Sparkle 解压后文件差分对比

先看上半部分。压缩有点像把一整本书改写成速记:原文只改了一个字,速记从这个字开始往后的写法可能整段都变了。于是改动点之前的块还能对上,之后的块大面积对不上,客户端只能把它们重新下一遍。偏偏业务代码全在 app.asar 里,它是安装包里最大的文件,几乎每次发版都会动到它。

asar 自己也在放大改动。它的文件头是一张目录表,记着每个文件从第几个字节开始。某个文件多了几个字节,排在它后面的文件起始位置全都要改,这张表也跟着大半重写。

再看下半部分,Sparkle 走的是另一条路。它的 BinaryDelta 工具直接比较旧版和新版两个 .app 目录:没变的文件不进补丁;变了的文件用 bsdiff 算出二进制差异,补丁里只记“哪里改了、改成什么”。比较发生在解压之后,所以改一行代码,补丁大小基本只跟真实改动的量有关,不会被压缩放大。

至于生成慢:blockmap 要在每次打包时把整个安装包从头到尾扫一遍,逐块算指纹。在 LobeHub 的 CI 里,这一步本身就要等上一阵。

renderer 层 OTA:不用动 asar

最简单的是 renderer 层的 OTA,它甚至不需要动 asar。

为了说清楚为什么不用动 asar,先简单过一下 asar 里有什么。

app.asar 是 Electron 打包后的应用代码归档,结构上类似一个 tar 包。启动时,Electron 先读取里面 package.json 的 main 字段,执行主进程入口;主进程再创建窗口,把 renderer 加载进来。

一个完整的 asar 里一般有三部分:main、preload 和 renderer。关键在于,窗口加载哪个地址是我们在 main 里自己写的(win.loadURL() 或 win.loadFile()),并不要求它一定来自 asar。所以可以在 App 里打包一份 renderer 当兜底,之后把新版本的 renderer 下载到 asar 外面,让窗口直接加载新的那份,这就实现了 renderer 的 OTA。

main 和 preload 保留在 app.asar,窗口优先加载 userData 中的兼容 Renderer

图里 asar 中的 main 和 preload 始终不变。main 启动时检查 userData 里有没有和自己兼容的新 renderer:有就加载那份,没有就回退到 asar 里内置的版本。

为了做防篡改,所以我们需要进行签名和验证。

哈希校验回答“内容是否一致”,签名验证回答“是否由可信发布方授权”。两者不能互相替代:如果下载文件和校验值都来自可被篡改的来源,仅比对哈希仍不足以确认更新可信,文件树与加载入口也应纳入可信清单。

当然,最开始那一版在 Folo 里面并没有考虑那么多,所以这种防篡改的能力其实根本就没有做。后来在 LobeHub 里,我们给产物加上了公私钥签名校验。

这里的难点在于:你如何能够确定你的这一次发布是能够命中 OTA,还是应该走全量?

能够走到 OTA 的前提条件是:

  1. main 的 ABI 没有改变
  2. Electron 的版本没有改变
  3. main 的依赖没有改变

之前在 Folo 里面使用的是扁平的项目结构,并不存在 workspace 中有非常多的共享 package。这个 package 不会存在既在 main 中被使用、又在 renderer 中被使用的情况。

但是在 LobeHub 中,这种情况被放大了。在整个项目的 workspace package 依赖树中,它会表现得更加复杂:你无法判断一个子包中的代码被修改了,它能否触发 OTA;如果稍有不慎,也有可能会导致因为 ABI 的问题而报错。

Folo 的独立依赖与 LobeHub 中 main、renderer 共享 package 的依赖关系

拿 LobeHub 里真实的依赖举例:@lobechat/const 被 @lobechat/utils 引用,而 @lobechat/utils 同时被 desktop 的 main 和 renderer 引用。只改 const 里一个常量,看起来是前端的事,main 的产物却也跟着变了。依赖树越深,这条路径越难靠肉眼看出来。

根据 main、preload、Electron 和 ABI 等输入判断 Renderer OTA 或全量更新

也就是说,一旦 preload 和 main 的代码或者依赖的产物有变动,那么就不能考虑 renderer OTA 了。

为了解决这个问题,我们后续又提了一连串 PR 去改进,想让判断命中得更准、粒度更细,搞清楚这次发布到底要不要、能不能走 OTA:给 main 的产物算哈希、改成从源码输入推导兼容性、稳定兼容性判定和全量发布状态。

但事实证明,这个方案的复杂度非常高。到 renderer OTA 被替换掉之前,光是判断“这次能不能 OTA”的脚本就有将近一千行:mainHash.mjs 469 行、mainHashGraph.mjs 376 行、validateMainHash.mjs 118 行,配套测试还有 582 行。这套算法是借助前沿模型一起写出来的,可读性和可维护性却真的非常差。

只是为了 OTA 一个 renderer,就要写这么长、这么复杂的算法去做判断。回头看,我觉得这个方案的方向本身就错了。

换个方向:asar 只做引导器

Discord 的本体应用里不打包任何业务代码,它的 asar 只是一个资源引导器。这也带来了一个很让人诟病的问题:每次下载完 Discord 打开,总会先弹出一个 update 小窗;网络不好的话,可能要等非常久。

我们也可以沿着 Discord 的思路走:把 asar 当成引导器,由它去载入 main 层和 renderer 层的代码。这样以后就不用再考虑 renderer 更新会不会受 main 影响,只需要看 main 依赖的底层有没有变,就能判断这次该走全量还是 OTA。

Shell/Core 拆分把兼容性判断收敛到更小的边界,并没有消除兼容性问题。移植时应先列明 shell 提供的接口、原生依赖和运行时约束,再决定哪些变更必须走全量;边界不清楚,复杂度就会重新回到判定脚本里。

这里的底层指的是:Electron 版本一旦升级或改变,ABI 就会跟着变;此外还有原生的 native 代码和 native bindings。

不过我并不认同 Discord 把实际代码完全留在 App 本体之外的做法。所以我们吸收它的优点,再加上自己的一点:把业务代码作为一份基底,同样打包进 App。这样刚装好的 App 不用联网就能直接打开。

传统完整 ASAR、Discord 引导器和 LobeHub Shell/Core 基底的架构对比

三种做法的差别在于业务代码放在哪。传统做法全塞进 app.asar,没有 OTA 的余地;Discord 全放在外面,首次打开只能先等下载;LobeHub 两头都占:安装包里带一份 core 基底,打开就能用,之后的新版本以差分补丁的形式下到 userData,由 shell 决定加载哪一份。

后续的 OTA,则用前面提到的、类似 Sparkle 的差分更新。

先在 Canary 上验证

这次迁移,我们先在 Canary 频道上验证。Canary 每次合入分支都会触发一次更新,节奏非常快。

按传统方式全量构建,等所有安装包出来,9 月底那几天的中位数是 64 分钟,最快一次也要 43 分钟;走 OTA 之后,只动 renderer 的发版中位数 4.3 分钟,现在的 core OTA 在 runner 空闲时端到端 8 分钟左右就能发完,ship 的速度大幅提升。

core 怎么做增量

另外要考虑的是 OTA 怎么做增量。一个完整、经过全量压缩的 App 大约 200 MB;而 core 的产物如果不压缩、整包下载,也要 100 多 MB,省不了太多。所以我们对 core 里的文件做了内容寻址,这样后面也能支持跨版本的增量更新。

这部分改过两版:9 月 28 日合并了一版,9 月 29 日又改了一版。先说 9 月 28 日那版(#19501)。

CI 发版时,core 里的每个文件都按 sha256 算出指纹,以 objects/<sha256>.zst 的形式单独上传。如果旧版本里有对应的文件,再额外生成一份 zstd 差分补丁 patches/<旧 sha256>-<新 sha256>.zst。manifest 里记下整棵文件树:每个路径对应哪个 sha256、多大。

这里的差分补丁用的是 zstd 的字典压缩:把旧文件当成字典,去压缩新文件。zstd 压缩时会在字典里找重复的片段,没变的部分只需要记一句“从旧文件第几个字节开始,复制多长”,只有真正改过的字节才会写进补丁。客户端拿同一个旧文件当字典解压,就能还原出新文件,再用 sha256 校验,对上了才写入。

哪个旧文件配哪个新文件,也有讲究。内容相同的直接复用;路径相同的做补丁。Vite 的产物文件名里带着内容哈希(index-a1b2c3d4.js),改一行代码文件名就变了,所以还会去掉这段哈希,再按名字配对。补丁只给 renderer 和 CLI 的文件做;新文件小于 16 KB,或者补丁超过新文件一半大小时,就不做补丁,直接下完整对象。

内容寻址、文件配对、zstd 字典差分和客户端重建流程

客户端拿到 manifest 后,先对照本地已有的文件(内置基底、当前版本、对象缓存)算出缺哪些 sha256。缺的文件优先下差分补丁,在本地旧文件上打出新文件;没有补丁就下完整的对象。缺得太多(超过 400 个)或者中途失败,就退回去下整包。最后按文件树把对象一个个链接或复制到新版本的目录里。

因为是按内容而不是按版本号找文件,跳过几个版本再更新也没问题:只要某个文件的内容在本地出现过,就不用再下载。

这一版的问题出在安装上:core 里有几千个零散的小文件,安装时要一个个复制,小文件复制非常慢。这也是 9 月 29 日又把 core 改成 asar 的原因,后面细说。

第二版:core 打成 asar,OTA 走 Range

9 月 29 日我又发现前一天的方案也不好,主要有这几个原因:

  1. 每次生成 OTA 时,一个 JS 文件就对应 S3 上的一个对象,上传操作太多。
  2. 400 个文件的上限没有按实际大小去算:缺的 401 个文件加起来哪怕只有 300 KB,也会去下全量,这不合理,应该按实际体积来判断。
  3. 差分完之后,userData 里存的还是平铺的全量散文件。

于是就有了 9 月 29 日的改动(#20191):

  • 再用 asar 把 core 封装一次,安装包里放的是一个 core.asar,而不是几千个散文件。
  • CI 上的 OTA 产物也合并了一次:把所有 zst 帧合成一个文件,客户端再按 HTTP Range 去取需要的那几段。
  • 加载业务代码时按优先级搜索:先找有没有可用的 OTA 版本,没有再 fallback 到内嵌的 core.asar。

合并后的文件叫 pack:每个文件压好的 zstd 帧原样首尾拼接,不再套一层 ZIP,也不整体再压缩。manifest 里记下每个文件在 pack 里的偏移和长度,这份索引和其他字段一起签名。客户端算出缺哪些文件后,只发 Range: bytes=起点-终点 请求去取对应的片段,相邻的片段合并成一个请求。每个片段下完先校验压缩后的哈希,解压后再校验一次内容哈希。

将独立 zstd 帧合成 pack,客户端按签名索引使用 Range 下载所需片段

400 的上限也去掉了。下片段还是下整包,改成按估算耗时来选:预计耗时 ≈ 传输字节数 ÷ 吞吐量 + 请求轮数 × 单次请求延迟,哪个方案小就用哪个。

增量更新应优化完成时间,而不只是下载字节数。小片段过多时,请求延迟、并发限制与重试都可能抵消流量收益;估算模型还应结合实际链路校准,并保留整包退路。文件数量阈值只能作为保护措施,不宜替代耗时判断。

启动时,shell 依次找:待生效的新版本(staged)→ 当前版本 → 上一个版本 → 内置的 core.asar。每个候选都要过几道检查:ABI 和 shell 对得上、manifest 签名有效、渠道一致、版本比内置的新。一个版本连续 3 次启动失败会被拉黑,自动退到下一个候选。

散文件:量过之后决定不改

前面的第 3 个问题,我本来打算把 userData 里的 OTA 版本也组装成 asar。真量了一遍,结论反过来了。

一个 OTA 版本大约 1700 个文件、111 MB,其中 40% 的文件不超过 4 KB。但组装发生在后台下载之后,不在启动路径上:macOS 从一个 OTA 版本切到下一个要 300–340 ms;Windows 上一次真实的首次 OTA,下载 10.3 MB、组装 1707 个文件,端到端 4.19 秒。

磁盘也没有想象中浪费。store 和各个版本目录都在 userData/core-ota 下,版本之间没变的文件用硬链接共享同一个 inode,多保留一个版本实际多占 0 KB;保留的版本也只有 current、previous 和待生效的 staged。唯一的重复是第一次 OTA:要把整个版本从内置的 core.asar 里写出来一份,约 108 MB,这一份改成 asar 也省不掉。反过来,如果每个版本都打成一个 asar,版本之间就没法共享 inode,current 加 previous 的磁盘占用接近翻倍,还得为改布局专门发一次完整安装包。所以散文件留着。

散文件版本通过硬链接共享内容,而每个 ASAR 版本都保留完整副本

硬链接可以理解成给磁盘上同一份内容起第二个文件名。左边的 cores/v1/a.js 和 cores/v2/a.js 是两个名字,背后是 store 里同一份数据,所以多留一个版本,只有真正改过的文件会多占空间。右边的 asar 是一个完整的大文件,没法和别的版本共用里面的内容,每个版本都是一整份。

复用硬链接的前提是对象内容保持不可变。原地改写任一链接指向的文件,会影响共享它的其他版本,回滚副本也可能随之失效。新内容应写入新对象,再切换引用;运行时日志、配置和缓存应与代码对象分开。文件系统说明。

文件校验只该在更新时做

量数据的时候还发现了一个问题:shell 每次启动都会把当前 OTA 版本里的每个文件读一遍、算一次 sha256,而且是在窗口出现之前。更新阶段其实已经校验过了:CoreStore.assemble 在临时目录里给每个组装出的文件算哈希,通过后才换到正式目录、标记 staged。启动时这一遍是重复的。

这里的“重复”是特定威胁模型下的性能判断,不代表 stat 与内容哈希等价。更新时校验、启动时检查存在性和大小,是把完整性保障集中在安装阶段;若还需检测安装后的同尺寸篡改,就要另行设计内容校验或防写机制。

#20211 把启动检查改成逐个文件 stat:只看文件在不在、大小对不对,签名、shellAbi 和入口文件的检查都保留。在同一个真实 OTA 版本(1706 个文件、111 MB)上,resolveCore 的耗时:

resolveCore 启动校验耗时:逐个 SHA-256 与逐个 stat 对比

Windows 测试机是 i5-13600KF + NVMe,关着 Defender 实时保护,算是最好的情况;macOS 是 M4 Max。开着杀毒软件的普通 Windows 机器上,修复前的数字只会更难看。

代价是:组装完成之后,如果有人在本地改了 userData 里的文件、又恰好没改变大小,启动时发现不了。截断、删除、被杀毒软件隔离这类情况仍然会被拦下。能随意写 userData 的程序本来就有很多别的办法影响 App,这个取舍我能接受。另外这个改动动的是 shell,按 shellAbi 的规则,只能跟完整安装包一起发。

CI 上快了多少

我从 GitHub Actions 里拉了 9 月 25 日到 29 日所有成功的 Canary 发版,按这次走的路径分类,统计从第一个 job 开始到最后一个 job 结束的时间:

2026 年 9 月 25–29 日 Canary 发版耗时:全量、Renderer OTA、Core OTA 散文件和 Pack + Range

全量构建慢在 macOS:Apple Silicon 的构建 job 要 16–24 分钟,那几天还带着 Intel 版本,一个 job 就是 28–45 分钟,之后还要合并 macOS 产物、发布、上传 S3。OTA 只构建 core,第二版每个平台的构建 job 只要 3.5–5 分钟。

core OTA 的整条流水线比单个 job 长很多,时间花在排队上。比如 9 月 29 日 12:00 那次,三个平台的构建各自只要 4 分钟左右,却是一个接一个等到 runner 才开始跑,整条流水线用了 25 分钟。runner 空闲的时候,最快一次端到端是 8.1 分钟。

reload 还是 relaunch

这套拆分还有一个好处:能很明确地知道这次改的是 renderer 层还是 main 层。判断很直接:CI 对比新旧两棵文件树,改动的文件全在 dist/renderer/ 下就标成 reload,否则标成 relaunch。

  • main 层有改动:退出 App 再重启一下就好。下载、组装、校验在 App 运行时就已经在后台做完了,重启时 shell 直接切到新版本,相当于只是重新启动一次。
  • 只有 renderer 层有改动:App 都不用退出,把 renderer 的目录切到新版本,reload 一下窗口就行。reload 之后还会做一次启动检查,新版本起不来就切回原来的目录。

按文件树变化选择 main relaunch 或 Renderer reload,并在失败时回退

下一步:Stable 换成 Sparkle

现在这套 OTA 只在 Canary 上跑,因为 Canary 发版非常频繁;Stable 目前还没有用 OTA。Stable 这边还是要解决全量更新慢的问题,我的答案是把 electron-updater 换成 Sparkle。

替换的 PR 我已经开过了(#19459,先在 macOS 的 canary 渠道试 Sparkle 差分更新),后面会接着把它做完。方案本身已经验证过:

  • 我之前写过一个库 electron-sparkle-updater:Sparkle.framework 的 N-API 桥,加上 appcast 生成、EdDSA 签名和差分更新的发版工具链。
  • 我自己的 Electron 应用 Kansoku 已经换成了 Sparkle。
  • 我还给社区开源项目 Lody 也换成了 Sparkle(LodyAI/Lody#173)。

拿 Kansoku 最近几次发版的真实产物来看,全量 zip 大约 120 MB,Sparkle 的 delta 包小得多:

Kansoku 的完整 ZIP 与 Sparkle delta 下载体积对比

小版本之间只要 0.7–1 MB;跨了两个版本、改动也多的那次是 14.3 MB,仍然只有全量的八分之一左右。这就是开头说的,Sparkle 在解压后的 .app 上按文件做 diff,补丁大小跟着真实改动走。

安装也快得多。Sparkle 从退出 App 到新版本重新打开,几秒钟就能完成;electron-updater 有时候要十几秒才会打开。

我增强过的 Sparkle Updater 还支持跨多个版本打补丁,像游戏补丁一样一路打上去。原版 Sparkle 的每个 delta 只能从某一个旧版本直接升到最新版,发版时通常只给最近一两个版本生成 delta;落后好几个版本的用户,就只能下全量。增强版会把历史上每次发版留下的 delta 串起来:从 A 升到 E,依次打 A→B、B→C、C→D、D→E 四个补丁。

原版 Sparkle 的单跳 delta 与增强版连续历史补丁链对比

补丁链的代价是每一跳都要把整个 App 读几遍做校验。实测在 M4 Max 上,250 MB 的 App 每跳约 0.23 秒。但只要网速在 100 Mbps 以下,下载量的差距就远大于这点校验时间:100 Mbps 下,下载 261 MB 的全量包要 24 秒,20 跳、每跳 1 MB 的补丁链只要 8 秒。

Sparkle 只管 macOS。Windows 和 Linux 上 electron-updater 的差分更新(NSIS、AppImage)本身够用,继续保留。


原文作者:Innei。原文发表于 2026 年 9 月 29 日:中文原文 ,本文仅用于自我学习。

评论交流

支持作者
上一篇

AI 时代,如何守住判断力与自我价值

下一篇

AI Coding 时应该把最好的模型放在前面

LMX BlogWeb · TypeScript · Vue · Node.js
创作者
@LMXZQQ
版本
暂无
日期
2026-10-30
文章
29
部署平台
Nginx
模板源码
GitHub
许可证
MIT License
字体
Geist
技术栈
  • next@16.4.3
  • react@19.3.1
  • @base-ui/react@1.8.0
  • shadcn@4.12.0
  • tailwindcss@4.3.3
访问统计
  • OpenPanel
  • Google Analytics
供智能体阅读
  • llms.txt
  • index.md
灵感来源
  1. Tailwind CSS
  2. shadcn/ui
  3. Vercel
  4. Apple
  5. Evil Charts
  6. Devouring Details
  7. Skiper UI
  8. Making Software
  9. shadcncraft
维护者
静水流深 / LMX
1602
Command Palette

Search for a command to run...