import config from "@/utils/config"; /** * 这里用本地存储做一个非常轻量的“启动期热更新锁”。 * * 为什么不用纯内存变量: * 1. App.vue 的 onLaunch 和页面组件 mounted 不在同一个文件里,直接共享内存状态不直观。 * 2. 现有项目已经在登录页、首页挂了旧的整包升级弹窗组件,我们需要一个跨文件、最小改动的互斥标记。 * 3. 用 storage 的好处是组件侧只要读一个 key 就能知道“启动阶段是否已经有热更新流程在跑”。 * * 注意: * 这个锁只是为了解决“启动期并发弹窗”的问题,不是分布式锁,也不是强一致锁。 * 对当前这个 uni-app 项目来说,这样的复杂度已经足够,而且侵入性最低。 */ export const HOT_UPDATE_LOCK_KEY = "__APP_WGT_HOT_UPDATE_RUNNING__"; export const HOT_UPDATE_FALLBACK_APP_CHECK_EVENT = "__APP_WGT_HOT_UPDATE_FALLBACK_APP_CHECK__"; /** * 当用户点击“稍后再说”时,我们把当前版本记下来。 * * 这样做的原因: * 1. 热更新检查放在 onLaunch,意味着每次冷启动都会检查一次。 * 2. 如果用户已经明确拒绝了某个版本,下一次冷启动继续弹同一个版本,体验会比较差。 * 3. 只记录“被跳过的目标版本”,新版本来了仍然会重新提示。 */ const HOT_UPDATE_SKIP_VERSION_KEY = "__APP_WGT_HOT_UPDATE_SKIP_VERSION__"; /** * 这里单独声明接口地址,而不是直接写死在 uni.request 里。 * * 这样做的原因: * 1. 现有项目已经有 config 环境配置,说明项目本身就是按环境切换接口域名的。 * 2. 热更新检查应该沿用现有配置习惯,避免你上线后再去改很多地方。 * 3. 这个路径是本次方案“约定”的服务端接口,你可以直接让后端按这个接口实现, * 也可以只改这里一处,把路径替换成你们实际的地址。 */ const HOT_UPDATE_API_URL = config.default.apiUrl + config.default.apiUrlSuffix + "/rq/iot-app/newWgt"; /** * 读取启动期热更新锁。 * * 对页面组件的意义: * 旧的整包升级逻辑挂在页面里,页面 mounted 时如果发现启动热更新还在跑, * 就应该先让路,避免同时弹两个升级框。 */ export const isHotUpdateRunning = () => { return uni.getStorageSync(HOT_UPDATE_LOCK_KEY) === "1"; }; /** * 设置/清理热更新锁。 * * 输入: * - running: Boolean,true 表示流程开始,false 表示流程结束。 * * 输出: * - 无返回值,副作用是修改 storage 中的互斥标记。 */ const setHotUpdateRunning = (running) => { if (running) { uni.setStorageSync(HOT_UPDATE_LOCK_KEY, "1"); } else { uni.removeStorageSync(HOT_UPDATE_LOCK_KEY); } }; /** * 等待 plus 对象可用。 * * 为什么即便在 APP-PLUS 里也还要做这一步: * 1. 你要求热更新接在启动阶段,启动阶段最容易踩到“plus 尚未完全就绪”的时序问题。 * 2. 现有 App.vue 的 onLaunch 里已经直接用了 plus.globalEvent,这说明项目默认运行在 App-Plus 环境。 * 3. 但热更新比普通日志更敏感,因为它要马上调用 plus.runtime.getProperty / install / restart, * 所以这里显式兜底一次,会比“直接假设 plus 一定存在”更稳。 * * 成功分支: * - plus 已就绪,resolve,继续后续热更新流程。 * * 失败分支: * - 理论上这里没有主动 reject,超时场景也继续等待; * 如果 plus 始终不可用,后续逻辑不会开始,也就不会影响原有启动逻辑。 */ const waitForPlusReady = () => { return new Promise((resolve) => { if (typeof plus !== "undefined") { resolve(); return; } document.addEventListener( "plusready", () => { resolve(); }, { once: true } ); }); }; /** * 使用 plus.runtime.getProperty 读取当前资源包信息。 * * 这是这次方案里最关键的一步,也是你特别强调不能替换掉的地方。 * * 为什么必须用它: * 1. 你要做的是 .wgt 资源热更新,比较的是“当前资源包版本”和“服务端资源包版本”。 * 2. plus.runtime.getProperty(plus.runtime.appid) 返回的是当前应用资源信息, * 其中 wgtinfo.version 才是最贴近“资源版本”的值。 * 3. plus.runtime.version 更偏运行时/客户端信息,uni.getSystemInfo 也不是这个场景最合适的来源, * 所以这里严格按你的要求使用 getProperty。 * * 输出: * - resolve(wgtInfo) * 其中最常用的字段包括: * - appid: 当前应用的 DCloud appid * - version: 当前资源版本 * - name: 当前应用名称 */ export const getCurrentWgtInfo = () => { return new Promise((resolve, reject) => { try { plus.runtime.getProperty(plus.runtime.appid, (wgtInfo) => { if (!wgtInfo || !wgtInfo.version) { reject(new Error("未能获取当前应用资源版本信息")); return; } resolve(wgtInfo); }); } catch (error) { reject(error); } }); }; /** * 一个简单的版本比较函数。 * * 为什么这里仍然保留本地比较,而不是完全信任服务端的 update 字段: * 1. 服务端当然应该负责判断版本,但客户端再做一次兜底,可以避免后端配置错误导致重复更新。 * 2. 这对“启动期自动更新”尤其重要,因为一旦后端误配,用户会在每次启动都被弹框打扰。 * * 比较规则: * - a > b 返回 1 * - a = b 返回 0 * - a < b 返回 -1 */ const compareVersion = (a = "", b = "") => { const aList = String(a) .split(".") .map((item) => Number(item || 0)); const bList = String(b) .split(".") .map((item) => Number(item || 0)); const length = Math.max(aList.length, bList.length); for (let i = 0; i < length; i += 1) { const aNum = aList[i] || 0; const bNum = bList[i] || 0; if (aNum > bNum) return 1; if (aNum < bNum) return -1; } return 0; }; /** * 判断服务端返回的地址是否真的是 .wgt 文件。 * * 这里额外去掉 query/hash,是为了兼容这类地址: * - https://example.com/app/update.wgt?sign=xxx * - https://example.com/app/update.wgt#download */ const isWgtPackageUrl = (url = "") => { const normalizedUrl = String(url) .trim() .split("#")[0] .split("?")[0] .toLowerCase(); return normalizedUrl.endsWith(".wgt"); }; /** * 请求服务端检查是否存在新的 .wgt 资源包。 * * 为什么这里直接使用 uni.request,而不是复用现有 utils/request.js: * 1. 现有 request 封装默认会带 token、tenant-id、loading、401 刷新令牌等业务逻辑。 * 2. 热更新检查发生在 App 启动最早阶段,不应该依赖登录态,也不应该因为 token 过期影响更新检查。 * 3. 启动期代码越“去业务化”,越不容易被后续鉴权改造牵连。 * * 请求参数说明: * - appid: 用来确保服务端下发的是当前应用对应的更新包。 * - version: 当前资源版本,服务端据此判断是否需要更新。 * - platform: 当前平台,便于服务端做 Android / iOS 区分。 * - name: 可选,便于后端日志排查。 * * 服务端返回格式约定: * { * code: 0, * data: { * update: true, * packageType: "wgt", * version: "1.3.6", * wgtUrl: "https://example.com/app/1.3.6/update.wgt", * note: "1. 修复巡检表单保存异常\\n2. 优化首页加载速度", * force: false * }, * message: "success" * } * * 字段用途: * - code: 业务状态码,0 表示接口业务成功。 * - data.update: 服务端是否认为当前客户端需要更新。 * - data.packageType: 更新类型。这里约定 wgt 表示资源热更新,native 表示整包更新。 * - data.version: 服务端最新资源版本。 * - data.wgtUrl: .wgt 下载地址。 * - data.note: 更新说明,弹窗里给用户看。 * - data.force: 是否强制更新。true 时不展示取消按钮。 * - message: 服务端通用提示。 */ const requestHotUpdateInfo = () => { console.log("11", 11); return new Promise((resolve, reject) => { uni.request({ url: HOT_UPDATE_API_URL, method: "GET", header: { "Content-Type": "application/json", "tenant-id": "1", }, timeout: 10000, success: (response) => { if (response.statusCode !== 200) { reject( new Error( `热更新检查接口请求失败,HTTP 状态码:${response.statusCode}` ) ); return; } resolve(response.data || {}); }, fail: (error) => { reject( new Error( error?.errMsg || "热更新检查接口请求失败,请检查网络或服务端状态" ) ); }, complete: () => { /** * 这里故意不做 UI 处理,只保留 complete 生命周期位置。 * * 原因: * 启动期的检查更新是“后台检查”,不是用户手动点按钮触发的请求。 * 如果这里也弹 loading,会和现有登录页/首页首屏体验打架。 */ }, }); }); }; /** * 规范化服务端返回,统一后续分支判断。 * * 为什么要单独做这一层: * 1. 启动流程最怕到处写 if/else,后面维护时很难看出到底在哪个条件提前返回。 * 2. 统一成“shouldUpdate + reason + payload”的结构,后面 orchestration 会清晰很多。 * * 成功输出示例: * { * shouldUpdate: true, * reason: "has-update", * payload: { ...服务端 data... } * } */ const normalizeUpdateResult = (response, currentVersion) => { console.log("response", response); const code = response?.code; const message = response?.message || response?.msg || ""; const data = response?.data || {}; if (code !== 0) { return { shouldUpdate: false, reason: "server-error", message: message || "热更新接口返回了业务错误", }; } // if (!data.update) { // return { // shouldUpdate: false, // reason: "no-update", // message: "服务端确认当前版本无需更新", // }; // } // if (data.packageType && data.packageType !== "wgt") { // return { // shouldUpdate: false, // reason: "not-wgt", // message: `服务端返回的是 ${data.packageType} 更新,不属于本次 .wgt 热更新流程`, // payload: data, // }; // } if (data.wgtUrl && !isWgtPackageUrl(data.wgtUrl)) { return { shouldUpdate: false, reason: "fallback-app-check-version", message: "服务端返回的 wgtUrl 不是 .wgt 文件地址,回退到原有整包升级检查流程", payload: data, }; } if (!data.version || !data.wgtUrl) { return { shouldUpdate: false, reason: "invalid-data", message: "服务端返回缺少 version 或 wgtUrl,无法继续热更新", }; } if (compareVersion(data.version, currentVersion) <= 0) { return { shouldUpdate: false, reason: "not-newer", message: "服务端返回的版本不高于当前版本,跳过本次热更新", payload: data, }; } const skippedVersion = uni.getStorageSync(HOT_UPDATE_SKIP_VERSION_KEY); if (!data.force && skippedVersion === data.version) { return { shouldUpdate: false, reason: "skipped-same-version", message: "用户之前已经跳过该版本,本次启动不再重复提示", payload: data, }; } return { shouldUpdate: true, reason: "has-update", payload: data, }; }; /** * 组装展示给用户的更新文案。 * * 这里没有引入 i18n,也没有复用页面组件里的 UI。 * * 原因: * 1. 热更新发生在 App 启动阶段,应该尽量减少对页面层、全局实例、组件状态的依赖。 * 2. 现有项目的旧升级逻辑是在组件里拿 $t,这条链路不适合直接照搬到启动期。 * 3. 启动期用系统级的 uni.showModal 更稳,也更符合“最小侵入式改造”。 */ const buildUpdateContent = (payload) => { const note = payload.note ? String(payload.note) : "修复已知问题,优化使用体验"; const content = [`发现新的资源版本:${payload.version}`, "", note]; if (payload.force) { content.push("", "该更新为强制更新,请完成安装后继续使用。"); } return content.join("\n"); }; /** * 提示用户是否开始热更新。 * * 成功分支: * - 用户点击确认,resolve(true),继续下载。 * * 失败/取消分支: * - 非强更时,用户点击取消,记录本次跳过的版本,resolve(false)。 * - 强更时,没有取消按钮,只能确认。 */ const confirmHotUpdate = (payload) => { return new Promise((resolve) => { uni.showModal({ title: payload.force ? "发现重要更新" : "发现新版本", content: buildUpdateContent(payload), showCancel: !payload.force, confirmText: "立即更新", cancelText: "稍后再说", success: (result) => { if (result.confirm) { uni.removeStorageSync(HOT_UPDATE_SKIP_VERSION_KEY); resolve(true); return; } if (!payload.force && result.cancel) { uni.setStorageSync(HOT_UPDATE_SKIP_VERSION_KEY, payload.version); } resolve(false); }, fail: () => { resolve(false); }, }); }); }; /** * 下载 .wgt 文件。 * * 为什么这里要单独拆出来: * 1. 下载是一个明显独立的异步阶段,最需要看清楚成功和失败分支。 * 2. 下载是启动期流程里耗时较长的一步,单独封装后更方便控制提示方式。 * * 用户可见表现: * - 下载中:顶部展示固定 loading 提示,不展示实时百分比。 * - 下载成功:loading 自动关闭,进入安装阶段。 * - 下载失败:loading 关闭,并在上层统一提示“热更新失败”。 * * 输出: * - resolve(tempFilePath) 返回下载到本地后的临时文件路径。 */ const downloadWgtPackage = (wgtUrl) => { return new Promise((resolve, reject) => { uni.showLoading({ title: "下载更新中", mask: true, }); uni.downloadFile({ url: wgtUrl, timeout: 600000, success: (response) => { if (response.statusCode === 200 && response.tempFilePath) { resolve(response.tempFilePath); return; } reject( new Error( `热更新包下载失败,HTTP 状态码:${response.statusCode || "unknown"}` ) ); }, fail: (error) => { reject(new Error(error?.errMsg || "热更新包下载失败")); }, complete: () => { uni.hideLoading(); }, }); }); }; /** * 安装下载完成后的 .wgt 包。 * * 为什么安装时要带 appid: * 1. 官方文档说明可以传入 appid 做校验。 * 2. 这可以避免服务端配置错误时,把不属于当前应用的资源包安装进来。 * * 为什么 force 设为 false: * 1. force=true 会跳过版本校验,风险更高。 * 2. 现在我们已经在服务端和客户端各做了一层版本判断,没有必要强制覆盖旧包。 */ const installWgtPackage = (filePath, appid) => { return new Promise((resolve, reject) => { try { plus.runtime.install( filePath, { appid, force: false, }, () => { resolve(); }, (error) => { reject( new Error( error?.message || `热更新安装失败,错误码:${error?.code || ""}` ) ); } ); } catch (error) { reject(error); } }); }; /** * 安装完成后提示用户并重启应用。 * * 为什么这里显式调用 plus.runtime.restart: * 1. .wgt 安装完成只是把新资源放到了运行环境,想让用户真正跑到新代码,还需要重启应用。 * 2. 这一步和旧的页面级整包升级不同,旧逻辑 Android 是直接安装 apk。 * 3. 热更新的目标是“尽快让新前端资源生效”,因此安装完成后立即重启是最直接的方案。 * * 注意这里在 restart 前先清掉锁: * - 因为 storage 会跨重启保留。 * - 如果不先清,应用重启后旧组件会误以为“热更新一直在运行中”,造成后续检查失效。 */ const restartAppAfterInstall = (targetVersion) => { return new Promise((resolve) => { uni.showModal({ title: "更新完成", content: `资源版本 ${targetVersion} 已安装完成,点击确定后将立即重启应用使更新生效。`, showCancel: false, success: () => { setHotUpdateRunning(false); plus.runtime.restart(); resolve(); }, fail: () => { /** * 即便弹窗失败,我们也仍然执行重启。 * * 原因: * - 到这里说明安装已经完成。 * - 如果不重启,用户还会继续运行旧资源,和“安装成功”的状态不一致。 */ setHotUpdateRunning(false); plus.runtime.restart(); resolve(); }, }); }); }; /** * 统一展示“热更新失败”提示。 * * 为什么不把所有失败都提示给用户: * 1. 启动阶段的“检查接口失败 / 无网络”很常见,如果每次启动都弹错,会严重影响体验。 * 2. 所以我们只对“用户已经明确点击开始更新之后”的失败给出弹窗提示。 * 3. 纯检查阶段失败,记录日志后继续进入原有流程即可。 */ const showHotUpdateError = (error, title = "更新失败") => { uni.showModal({ title, content: error?.message || "热更新执行失败,请稍后重试", showCancel: false, }); }; /** * 对外暴露的启动期热更新入口。 * * 这是你后续在 App.vue onLaunch 里真正调用的函数。 * * 整体执行顺序: * 1. 等待 plus 就绪 * 2. 获取当前资源版本 * 3. 请求服务端检查更新 * 4. 判断是否存在新的 .wgt 包 * 5. 提示用户确认 * 6. 下载 .wgt * 7. 安装 .wgt * 8. 重启应用 * * 返回值: * - Promise<{ status: string, ... }> * 主要用于日志或后续扩展,目前 App.vue 不需要依赖它的返回值。 */ export const runWgtHotUpdate = async () => { // #ifdef APP-PLUS if (isHotUpdateRunning()) { return { status: "locked", message: "已有热更新流程在执行,跳过重复检查", }; } let currentStage = "prepare"; let finalResult = null; setHotUpdateRunning(true); try { currentStage = "plus-ready"; await waitForPlusReady(); currentStage = "read-local-version"; const wgtInfo = await getCurrentWgtInfo(); currentStage = "request-server"; const response = await requestHotUpdateInfo(); const updateResult = normalizeUpdateResult(response, wgtInfo.version); if (!updateResult.shouldUpdate) { finalResult = { status: updateResult.reason, message: updateResult.message, data: updateResult.payload || null, }; return finalResult; } currentStage = "confirm"; const confirmed = await confirmHotUpdate(updateResult.payload); if (!confirmed) { finalResult = { status: "cancelled", message: "用户取消本次热更新", }; return finalResult; } currentStage = "download"; const filePath = await downloadWgtPackage(updateResult.payload.wgtUrl); currentStage = "install"; await installWgtPackage(filePath, wgtInfo.appid); currentStage = "restart"; await restartAppAfterInstall(updateResult.payload.version); finalResult = { status: "restarted", message: "热更新安装完成,应用已触发重启", }; return finalResult; } catch (error) { console.error("[hot-update] 执行失败:", currentStage, error); if (currentStage === "download") { showHotUpdateError(error, "下载失败"); } else if (currentStage === "install" || currentStage === "restart") { showHotUpdateError(error, "安装失败"); } else { /** * 检查阶段失败只打日志,不主动打扰用户。 * * 为什么这么处理: * - 当前项目 onLaunch 里还有数据库初始化、url scheme 参数接收等逻辑。 * - 热更新检查只是“加分项”,不应该因为检查失败阻断原有启动流程。 * - 这样最符合“最小侵入式改造”的目标。 */ } finalResult = { status: "error", stage: currentStage, error, }; return finalResult; } finally { /** * 正常情况下: * - 无更新 / 用户取消 / 下载失败 / 安装失败,都会走到这里清锁。 * - 安装成功后会在 restart 之前先清锁,所以这里再次清理也没有副作用。 */ setHotUpdateRunning(false); if (finalResult?.status === "fallback-app-check-version") { uni.$emit(HOT_UPDATE_FALLBACK_APP_CHECK_EVENT, finalResult); } } // #endif return { status: "skip-non-app-plus", message: "当前不是 APP-PLUS 环境,跳过 .wgt 热更新检查", }; };