
最近把手上一个跨端应用的更新机制整体重写了一遍。这个应用业务层用 uni-app 开发需要同时发布到 Android 手机和 Windows 桌面端Windows 端用 Electron 做了个壳。原来 Windows 的自动更新走的是 Electron 自带的 autoUpdaterAndroid 端则是 APK 整包升级加 uni-app 官方 wgt 热更新两套逻辑完全是割裂的。每次发版服务端要维护两套接口前端要维护两套更新代码发布节奏稍微快一点就撑不住。这个问题的本质不是“更新功能不好使”而是“两套方案带来的维护成本已经超过了它们各自的价值”。所以我花了两周时间把更新链路整个拆掉重做后端只保留一个版本检查接口Android 和 Windows 各自通过 uni-app 条件编译走自己的下载、安装、替换逻辑Electron autoUpdater 彻底从项目里摘除去。这篇文章就把完整方案的思路、接口设计、双端实现细节以及我在实际落地过程中踩过的坑都写清楚给同样做 uni-app 多端应用、又被 Electron autoUpdater 绑住手脚的团队一个可直接抄作业的参考。1. 为什么非要脱离 Electron autoUpdater1.1 代码签名个人开发者迈不过去的坎Electron 官方文档里对 autoUpdater 的使用前提写得很明确Windows 平台上建议给应用做代码签名。如果不签名更新下载回来的安装包很容易被 Windows SmartScreen 识别成“未知发布者”用户点击运行时会看到一整屏红色警告。你可以用 electron-updater 这个社区库来替代官方 autoUpdater它确实允许不签名就更新但实际的体验是没有签名证书的情况下安装包在不少杀毒软件和 SmartScreen 规则下依然容易被误报甚至有些企业环境会直接拦截未签名的可执行文件。代码签名证书的价格对个人开发者来说并不便宜即使买到了在 CI 环境里还得维护证书、密码、时间戳服务器等一整套东西。如果你的应用不是要上架微软商店只是给内部用户或特定客户分发这笔成本和维护负担就更不值当了。这是我的第一个核心痛点autoUpdater 这个方案天生是为“正规分发”设计的而很多 uni-app Electron 壳的项目恰恰是中小团队甚至个人开发者做的分发路径没那么正规。1.2 更新粒度太粗一个安装包 “背了太多锅”第二个问题更实际。我的应用是 Electron 加载 uni-app 编译出的 H5 静态资源业务逻辑全部在前端代码里。大部分发版只是改了 JS、CSS、图片资源Electron 壳、Node.js 依赖、系统原生能力完全没有变化。但 autoUpdater 的最小更新单位是整个安装包也就是说哪怕前端只改了一行文字也要让用户重新下载那个几百 MB 的 electron 安装包。这种更新粒度的浪费是双重的用户带宽和时间被浪费服务器流量费用也白白烧掉。对于更新频率比较高、每次改动量又很小的应用来说这是完全不可接受的。真正需要的机制是“资源级更新”把更新包从几百 MB 的安装包缩小到几十 MB 的静态资源压缩包Windows 端替换资源目录Android 端走 wgt 热更新两边逻辑对称这才是跨端应用该有的更新形态。1.3 双端割裂导致服务端和客户端都在重复造轮子第三个原因更偏向工程维护。Android 端的 uni-app 热更新需要一套版本检查接口Windows 端的 autoUpdater 需要一套 feed 接口latest.yml 或自定义 json两边版本管理完全独立。实际发版时一个人要同时处理两处配置稍不留神就把 Android 的版本校验规则用在了 Windows 端或者反过来。更让人头疼的是强制更新的逻辑没法复用Android 端可以在接口里返回 force_update 字段来控制是否弹不可关闭的更新框Windows 端要实现同样的效果得重新写一套 autoUpdater 的交互逻辑。所以“脱离 autoUpdater”并不是因为 autoUpdater 本身有多糟糕而是它和 uni-app 生态之间缺乏协同。做一个双端应用更新机制应当由业务层统一掌控而不是让桌面端被 Electron 生态绑架、移动端被 DCloud 生态绑架。自研一套更新逻辑从根上解决这个结构性问题。2. 更新服务端接口设计一份数据驱动两个平台2.1 版本检查接口双端唯一的公约数整个方案的核心是先定义好服务端接口这个接口就是双端更新逻辑的“公约数”。Android 和 Windows 都只调同一个接口拿版本信息再根据平台字段走不同的后续逻辑。我这边接口设计如下POST /api/update/check 请求参数 { platform: android, // android | windows app_version: 1.2.0, // 当前应用版本号 channel: stable, // 渠道stable / beta / internal device_id: xxx-xxx // 设备唯一标识 }返回结构{ code: 0, data: { need_update: true, latest_version: 1.3.0, package_type: zip, download_url: https://cdn.example.com/app/windows-1.3.0.zip, file_md5: a1b2c3d4e5f6..., file_size: 30485760, force_update: false, release_notes: [修复登录偶现失败, 导出功能增加格式选择], update_tips: 本次更新优化了数据导出稳定性 } }package_type 是关键字段两端对它做不同的解释。Android 端它可能是 apk 或 wgtWindows 端它可能是 zip 或 exe。这个设计让服务端逻辑足够简单不关心端上怎么更新只告诉客户端“这版本该用哪种包更新”以及“包在哪里”。实际落地时还可以根据 channel 参数做灰度服务端按 device_id 哈希取模决定返回的是最新版还是旧版这个见第五节。2.2 版本号比较的坑直接决定判断是否准确版本号比较看似简单实际上很容易写错。第一版我用的是字符串比较结果 1.9.0 被判定为大于 1.10.0导致用户永远更新不到新版本。后来换成了按点号分割、逐段比较数字的方式function compareVersion(current, latest) { const currentParts String(current).split(.).map(Number); const latestParts String(latest).split(.).map(Number); const maxLen Math.max(currentParts.length, latestParts.length); for (let i 0; i maxLen; i) { const a currentParts[i] || 0; const b latestParts[i] || 0; if (a b) return 1; if (a b) return -1; } return 0; }这个函数返回 1 表示当前版本比最新版本高通常意味着用户已经装了更新的版本-1 表示需要更新0 表示相同。注意我特意处理了版本号段数不一致的情况比如 1.2 和 1.2.0 应该视为相等1.2 和 1.2.1 应该返回 -1。版本判断建议前端和服务端都做一遍。服务端返回 need_update 字段做参考前端拿到后自己再比较一次。这样即使服务端接口被黑客篡改或者 CDN 上缓存了旧接口响应也不会出现版本倒灌。这个双保险在强更场景下尤其有必要。2.3 包类型与更新方式对应关系双端的包类型和更新方式整理成一张表后端同学看了这张表就明白该返回什么字段了平台包类型更新方式适用场景Androidwgtplus.runtime.install 热更新保留本地数据仅 uni-app 前端资源变化未改动原生插件Androidapk下载后跳转系统安装器新增原生插件、基础库升级、整包替换Windowszip解压覆盖 resources 下的 H5 资源目录仅前端资源变化Electron 壳未改动Windowsexe下载后静默安装覆盖Electron 主进程、Node 依赖、系统能力变更这张表的判断规则是优先走轻量更新Android 能走 wgt 就不走 apkWindows 能走 zip 就不走 exe。服务端发版脚本里可以写一个规则如果本次发版涉及原生插件或 Electron 壳则返回重包否则返回轻包。这样每次发版前不用手动判断该配哪边减少人为失误。3. Android 端升级APK 整包与 wgt 热更新的双通道3.1 检查更新的前端主流程Android 端我放在 uni-app 的 App 平台APP-PLUS逻辑里。核心流程是四步调接口拿版本元数据、比较版本、根据 package_type 走 wgt 或 apk 分支、更新完成后重启应用。async function checkAndroidUpdate() { const res await uni.request({ url: https://api.example.com/api/update/check, method: POST, data: { platform: android, app_version: getAppVersion(), device_id: getDeviceId(), channel: stable } }); const data res.data.data; if (!data.need_update) return; const needForce compareVersion(getAppVersion(), data.latest_version) 0 data.force_update; if (data.package_type wgt) { downloadWgt(data, needForce); } else if (data.package_type apk) { downloadApk(data, needForce); } }这里有个小细节need_update 虽然是服务端下发的但我还是会拿本地版本号跟 latest_version 再比一次。原因前面说过防止接口被异常数据污染。实际开发中我还遇到过服务端返回的 app_version 是字符串导致 uni.request 序列化报错的低级问题所以请求参数里记得把版本号 toString 一下。3.2 wgt 热更新几十秒内完成升级wgt 是 uni-app 官方支持的热更新包本质上是一份前端资源打包文件。下载后使用 HTML5 的 plus.runtime.install 去安装它function installWgt(filePath, force) { plus.runtime.install(filePath, { force: true }, () { if (force) { plus.runtime.restart(); } else { uni.showModal({ title: 更新完成, content: 是否立即重启应用生效, success: (e) { if (e.confirm) plus.runtime.restart(); } }); } }, (err) { console.error(wgt install error, err); }); }wgt 安装成功后不会自动生效必须重启一次应用这是很多刚接触热更新的同学最容易漏掉的一步。加一个可选的“立即重启”弹窗比直接强制重启体验好很多毕竟用户当时可能正在编辑一份重要内容。wgt 热更新有几个限制我实际用下来感觉必须提前知道wgt 包只能更新纯前端资源不能包含原生插件或相关原生代码如果 appid 不匹配会安装失败最关键的是 wgt 包的版本号必须高于当前安装包版本号否则报“安装包版本过低”错误。这个版本号指的是 manifest.json 里的版本号而不是接口返回的 latest_version。我踩过这个坑后端把 latest_version 改成了 1.3.0但 manifest.json 里还是 1.2.9结果 wgt 一直装不上。3.3 APK 整包升级安装权限与文件路径细节当 package_type 返回 apk 时说明本次更新不只是资源变化可能涉及原生插件或基础库升级这时候只能走整包安装。流程上先下载 apk 文件到本地然后调用系统安装器。function downloadApk(data, force) { const downloadTask uni.downloadFile({ url: data.download_url, filePath: ${plus.io.convertLocalFileSystemURL(_doc/update)}/app-${data.latest_version}.apk, success: (res) { if (res.statusCode 200) { openApk(res.filePath); } } }); }Android 8.0 以上版本要求在跳转安装器前申请“安装未知应用”权限这一步很容易被忽略。uni-app 环境里可以通过 plus.android 调用系统 intent 来打开应用的安装权限设置页也可以利用 uni-app 官方提供的 plus.runtime.openFile 直接打开 apk 文件让系统安装器接管后续流程。如果你用的是云打包则需要确保打包时的权限配置里勾选了安装未知应用相关权限。还有一个经常被问到的问题APK 加固后如何重新签名。如果你接入了加固服务比如 360 加固、腾讯乐固加固厂商一般会提供重新签名的工具。注意重新签名时一定要使用原来的签名 keystore否则升级包和已安装应用的签名不一致安装时系统会拒绝覆盖安装。我曾遇到加固后忘记重签导致测试机“应用未安装”的乌龙排查半天才发现是签名丢了。3.4 下载进度与强制更新的 UI 处理双端共用的一个体验细节是下载进度展示。uni.downloadFile 提供了 onProgressUpdate 回调可以实时拿到下载进度和已下载字节数downloadTask.onProgressUpdate((res) { const percent Math.round(res.progress); // 更新进度条 UI });强制更新场景下建议不要用可关闭的 showModal而是用一个全屏更新页避免用户跳过更新导致后续接口报错。我做了一个简单的 update 页面展示当前版本、新版本、更新内容列表、下载进度条。如果是强制更新页面上的“暂不更新”按钮直接隐藏。实际运营中发现非强制更新也会有不少用户一直拖着不升级所以我会在接口里额外加一个 update_deadline 字段超过截止日期后即使包里写的是非强制更新前端也会按强制更新来处理。4. Windows 端升级资源包替换与进程自重启完整链路4.1 先想清楚 Windows 端在更新什么Windows 端是 Electron 壳加载 uni-app 编译出的 H5 静态资源整个应用本质上是两层外层是 Electron 主进程和 Node 依赖内层是放在 resources 目录下的 www 文件夹由 uni-app H5 build 生成。autoUpdater 更新的是整个应用安装包而这套架构下完全没必要。大部分更新只需要替换 www 目录里的内容也就是更新资源包。既然要脱离 autoUpdater思路就是自研一个“资源替换器”下载 zip 资源包校验完整性然后替换 www 目录。只有当 Electron 壳本身要升级比如换了原生能力、升级了 Node 版本时才走 exe 安装包更新。这个轻量级逻辑就是替代 autoUpdater 的核心。4.2 preload 桥接渲染进程怎么调主进程能力uni-app 的 H5 页面跑在 Electron 的渲染进程里它不能直接访问 Node.js API。按照 Electron 安全规范必须在 preload 脚本里通过 contextBridge 把能力暴露给页面// preload.js const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(electronAPI, { downloadUpdate: (url, targetDir) ipcRenderer.invoke(download-update, url, targetDir), applyUpdate: (zipPath) ipcRenderer.invoke(apply-update, zipPath), getAppVersion: () ipcRenderer.invoke(get-app-version), quitAndInstall: () ipcRenderer.invoke(quit-and-install) });然后在主进程里用 ipcMain.handle 注册对应方法。渲染进程里的 uni-app 代码就可以通过 window.electronAPI.downloadUpdate(...) 来下载更新包了。注意这里的契约是异步 Promise渲染层要用 await 或 then 接收结果。安全提醒使用 contextBridge 时 preload 脚本必须在 webPreferences 里显式指定并且把 contextIsolation 设为 truenodeIntegration 设为 false。这是 Electron 官方推荐的安全配置也避免渲染进程直接拿到 Node 权限导致被注入攻击。4.3 下载与校验宁可慢一点不能坏一点主进程的下载方法可以用 Node.js 的 https 模块实现也可以用 Electron 的 net 模块。我更推荐 net 模块它能复用 Electron 的代理配置在公司网络环境下更稳定。下载完成后第一件事是校验 MD5必须确认文件完整才能进入替换流程。// 主进程 const crypto require(crypto); const fs require(fs); function checkMd5(filePath, expectedMd5) { const hash crypto.createHash(md5); const data fs.readFileSync(filePath); hash.update(data); return hash.digest(hex) expectedMd5; }实际使用中要注意大文件的 MD5 计算不要用 readFileSync 一次性读入内存几十 MB 的包还行几百 MB 就危险了。建议用 createReadStream 流式计算。另外下载完成后多留一步把 zip 解压后的目录大小和文件数上报给服务端和预期值做对比能发现不少 CDN 上静态包损坏的问题。4.4 替换文件与重启不要让杀毒软件和文件锁挡住你Windows 上替换正在运行中的文件是做不到的electron.exe 进程会锁定安装目录下的文件直接复制会报 EPERM 或 EBUSY。工程上最稳妥的做法是“三重进程接力”主进程收到应用更新指令后把一个独立的 Node.js 脚本写到临时目录然后 spawn 这个脚本主进程退出脚本等待几秒等主进程完全释放文件锁后执行替换最后重新拉起主进程。我的 updater 脚本简化后长这样// updater.js —— 作为独立子进程运行 const fs require(fs); const path require(path); const { execFile, spawn } require(child_process); const [,, appRoot, zipPath, exePath] process.argv; const wwwDir path.join(appRoot, www); setTimeout(() { // 解压 zip 到临时目录再整体替换 www 目录 // 这里用 extract-zip 或 adm-zip 解压也可以用 7z 命令行 // 替换完成之后 try { fs.rmSync(path.join(wwwDir, static), { recursive: true, force: true }); fs.copySync(path.join(tempDir, www), wwwDir); fs.rmSync(zipPath, { force: true }); } catch (err) { console.error(err); } // 重新拉起主程序detached 让新进程脱离当前进程组 spawn(exePath, [], { detached: true, stdio: ignore }).unref(); }, 3000);这个方案有几个关键细节父进程 spawn 子进程后要等待约 3 到 5 秒再退出这段时间给子进程脚本完成最终的启动动作spawn 时用 detached: true 配合 unref()让主进程退出后子进程不会被连带杀掉。替换 www 目录时如果旧文件被锁定可以先重命名旧目录再复制新目录比原地删除更容错。杀毒软件拦截也是一个现实问题。自研更新脚本天然容易触发杀软行为检测因为“子进程等待、修改自身文件、重新拉起”这个动作组合在病毒行为模式里太典型了。实际落地时建议把 updater 脚本打包进应用资源里并用 Electron 主程序来启动而不是临时生成并执行一份明文脚本在私有分发场景下可以把应用目录加入杀毒软件白名单发布 exe 更新包时尽量做好代码签名减少误报概率。4.5 从 autoUpdater 平滑迁移到自研更新如果你现在已经在用 autoUpdater 或 electron-updater迁移过来不用一刀切可以分三步走在 electron-builder 配置里移除 publish 配置停止生成 latest.yml 更新 feed这一步能把官方自动更新的“信号源”断掉。删除主进程里的 autoUpdater 引用和相关逻辑把对应能力替换成 ipcMain.handle 注册的更新方法。对已经安装的旧版本做“兜底”因为旧版本没有自研更新接口的代码它只能继续从旧的 feed 拉更新所以你需要发布一个“迁移版”安装包这个安装包内置了自研更新逻辑。如果连迁移版也无法通过 autoUpdater 推送就在应用内弹一个公告页提示用户手动下载。整个过程建议后端先保留旧 feed 接口一段时间等新版本的装机量超过 95% 再彻底下线否则会出现大量老用户永远停在旧版本的问题。5. 双端统一封装与踩坑记录5.1 一套代码管理双端更新条件编译的正确打开方式uni-app 项目里最方便的做法是用条件编译区分平台。公共逻辑如检查版本、比较版本、弹窗 UI 只写一遍平台差异通过条件编译去隔离export function checkUpdate() { uni.request({ url: https://api.example.com/api/update/check, method: POST, data: { platform: getPlatform(), app_version: getAppVersion(), device_id: getDeviceId() }, success: (res) { const data res.data.data; if (!data.need_update) return; // #ifdef APP-PLUS handleAndroidUpdate(data); // #endif // #ifdef H5 if (window.electronAPI) { handleWindowsUpdate(data); } // #endif } }); }这里有一个通用判断逻辑如果当前运行在 H5 平台需要判断是不是在 Electron 环境里。window.electronAPI 就是 preload 暴露的全局对象存在就认为是 Electron 壳环境不存在就是普通浏览器访问浏览器场景下直接忽略更新逻辑就好。这段判断只依赖运行时环境编译期条件编译负责平台间的代码裁剪两者不冲突。我建议把 checkUpdate 封装成一个全局模块在 app.vue 的 onLaunch 里调用一次同时设置一个定时器每隔 4 小时自动检查一次。更新检查是一个低频、轻量级操作没必要每次启动都检查但也不能完全不检查。5.2 我在实际项目里踩过的坑这套方案从第一版到稳定运行踩过的坑大概能写满一页纸。挑几个最有代表性的说说Android 下载 APK 时进程被回收。大文件下载需要几十秒甚至几分钟如果用户切到后台再回来进程可能已经被系统杀掉下载中断。uni-app 环境里没有特别好的前台服务方案我最终的策略是wgt 包可以在下载完成后断点续传APK 包则直接提示用户去浏览器或应用商店下载。如果你的 APK 超过 100 MB建议上传到应用市场或 OSS通过系统浏览器下载并安装体验比应用内下载更稳定。Windows 端文件被锁导致替换失败。第一次上线时我把替换逻辑直接放在主进程里结果 spwan 之后主进程还没来得及退出新进程启动时还在读 www 目录里的文件替换失败。后来改成“子进程 spawn 后主进程延时退出 前端页面显示‘更新完成正在重启’”才解决。延时不能太长也不能太短实测 3 秒在大部分机器上足够。MD5 校验不能在下载过程中省。我一度为了省流量在下载完成后直接解压结果某个 CDN 节点缓存了一份损坏的 zip导致用户安装失败。后来强制加上 MD5 校验发现损坏包几乎都是 CDN 回源时丢数据导致的校验能提前发现 95% 的问题。现在没有 MD5 字段的接口响应前端直接拒绝更新。wgt 热更新在加固后随机失败。我们的 Android 包做了加固结果部分机型上 wgt 安装失败率明显偏高。排查后发现是加固渠道对 wgt 包内的签名校验逻辑有兼容性问题。解决方案是不让加固服务处理 wgt 包只对 APK 整包做加固wgt 保持原始资源形态。如果你也要加固建议在加固前先在测试机上完整走一遍热更新流程确认加固策略不会破坏 wgt 的完整性校验。Electron 安全配置变更后 preload 失效。有一次为了修复安全问题把 contextIsolation 从 false 改成 true结果所有 window.electronAPI 调用都变成了 undefined更新功能静默失效。这个坑提示我任何 Electron 配置变更都要跑一遍完整的更新集成测试特别是 preload、沙箱、上下文隔离这些与安全强相关的配置。5.3 灰度发布与回滚策略服务端只需要多一个开关最后聊一下灰度发布。自研更新接口最大的优势就是灰度控制完全由自己掌握。我在服务端 update 配置里加了一个 publish_ratio 字段0 到 100 的整数表示放量百分比。后端根据 device_id 哈希取模小于这个百分比就返回新版信息否则就返回 need_update: false。这样就能实现“先放 10% 用户确认没问题再慢慢加到 100%”的标准灰度流程。灰度发布阶段最重要的配套是客户端版本上报每次启动时把当前版本号和 device_id 上报到统计接口服务端就能实时看到新版本的安装率。如果新版安装率异常低说明更新链路可能在某个环节卡住了这时候可以快速把灰度比例调回 0避免影响面扩大。回滚策略也很简单服务端把 latest_version 改回旧版本新版本就“消失”了。已经升级到新版的用户不需要回滚因为新版代码里的回滚逻辑会判断当前版本其实高于服务端“最新版”从而跳过更新。这里有一个前提条件永远不要在服务端把强制更新和回滚同时打开否则会出现“所有已升级用户被迫回滚”的尴尬。我的做法是强制更新接口单独加一个 maintenance 开关只有运维确认故障时才会手动打开。这套方案上线稳定运行后我最大的感受是更新逻辑从“两套各管各的”变成“一套骨架、两端填充”心里踏实了很多。现在大多数发版只需要在服务端上传一个新的 zip 或 wgt 包再改一下版本号和 MD5点个发布就完事Windows 端用户在应用内几分钟内就能收到更新再也不用等那个几百 MB 的安装包慢慢下载了。如果团队确实没精力自研更新服务先用 DCloud 官方的升级中心插件做 Android 端热更新也能解决一部分问题但 Windows 端它管不了。我的建议是哪怕不全部自研也要尽早把双端的更新逻辑抽象到同一层调用不然项目越大后面拆起来越痛。