Jelajahi Sumber

fix: 优化 WGT 热更新全局检查机制

- 支持冷启动、页面显示及前台状态下触发更新检查
- 增加每 30 秒一次的前台轮询,后台自动停止
- 增加并发锁和检查间隔,避免重复请求及弹窗
- 清理旧版持久化锁,防止异常退出后永久跳过更新
- 修复整包升级回退事件可能丢失的问题
Zimo 1 Minggu lalu
induk
melakukan
6c8ad51fa0
4 mengubah file dengan 130 tambahan dan 57 penghapusan
  1. 33 4
      App.vue
  2. 3 0
      components/upgrade.vue
  3. 19 5
      main.js
  4. 75 48
      utils/hot-update.js

+ 33 - 4
App.vue

@@ -1,6 +1,25 @@
 <script>
 import { initAppDatabase } from '@/utils/appDb';
-import { runWgtHotUpdate } from '@/utils/hot-update';
+import {
+	HOT_UPDATE_CHECK_INTERVAL,
+	runWgtHotUpdate
+} from '@/utils/hot-update';
+
+let hotUpdateTimer = null;
+
+const stopHotUpdatePolling = () => {
+	if (hotUpdateTimer) {
+		clearInterval(hotUpdateTimer);
+		hotUpdateTimer = null;
+	}
+};
+
+const startHotUpdatePolling = () => {
+	stopHotUpdatePolling();
+	hotUpdateTimer = setInterval(() => {
+		runWgtHotUpdate();
+	}, HOT_UPDATE_CHECK_INTERVAL);
+};
 
 export default {
 	onLaunch: (options) => {
@@ -64,19 +83,29 @@ export default {
 		 * 2. 热更新检查失败时我们希望“静默降级,不阻断原有启动”。
 		 * 3. 因此这里直接触发即可,让原有启动逻辑保持原样继续往下走。
 		 */
-		runWgtHotUpdate();
+		runWgtHotUpdate({ forceCheck: true });
 		// #endif
 	},
 	onExit: () => {
+		// #ifdef APP-PLUS
+		stopHotUpdatePolling();
+		// #endif
 		// #ifdef APP
 		// sqlite.closeDB('app')
 		// #endif
 	},
 	onShow: function () {
-		// console.log('App Show')
+		// #ifdef APP-PLUS
+		// 进入前台立即检查一次,并在停留前台期间每 30 秒轮询。
+		runWgtHotUpdate();
+		startHotUpdatePolling();
+		// #endif
 	},
 	onHide: function () {
-		// console.log('App Hide')
+		// #ifdef APP-PLUS
+		// 后台不轮询,避免无效耗电和网络请求;再次进入前台会立即恢复。
+		stopHotUpdatePolling();
+		// #endif
 	}
 };
 </script>

+ 3 - 0
components/upgrade.vue

@@ -51,6 +51,7 @@
 
 <script>
 import {
+  consumePendingAppUpgradeCheck,
   isHotUpdateRunning,
   HOT_UPDATE_FALLBACK_APP_CHECK_EVENT,
 } from "@/utils/hot-update.js";
@@ -135,6 +136,7 @@ export default {
     // #endif
   },
   created() {
+    this.pendingHotUpdateFallbackAppCheck = consumePendingAppUpgradeCheck();
     uni.$on("upgrade-app", this.bindEmit);
     uni.$on(
       HOT_UPDATE_FALLBACK_APP_CHECK_EVENT,
@@ -155,6 +157,7 @@ export default {
       this.appCheckVersion();
     },
     handleHotUpdateFallbackAppCheck() {
+      consumePendingAppUpgradeCheck();
       if (!this.t) {
         this.pendingHotUpdateFallbackAppCheck = true;
         return;

+ 19 - 5
main.js

@@ -4,9 +4,13 @@ import languages from './locale/index'
 import {
 	setupPinia
 } from './store';
-import {
-	createSSRApp
-} from 'vue'
+import {
+	createSSRApp
+} from 'vue'
+
+// #ifdef APP-PLUS
+import { runWgtHotUpdate } from '@/utils/hot-update'
+// #endif
 
 import {
 	createI18n
@@ -23,9 +27,19 @@ export function createApp() {
 	setupPinia(app)
 	app.use(i18n)
 	// 把t函数添加到全局属性
-	app.config.globalProperties.$t = i18n.global.t
+	app.config.globalProperties.$t = i18n.global.t
+
+	// #ifdef APP-PLUS
+	// uni-app 的页面 onShow 会应用全局 mixin,因此任意页面显示时都能触发检查。
+	// 热更新模块内部负责并发合并和三十秒去重,不会因页面切换连续请求或弹窗。
+	app.mixin({
+		onShow() {
+			runWgtHotUpdate()
+		}
+	})
+	// #endif
 
 	return {
 		app,
 	}
-}
+}

+ 75 - 48
utils/hot-update.js

@@ -1,27 +1,30 @@
 import config from "@/utils/config";
 
 /**
- * 这里用本地存储做一个非常轻量的“启动期热更新锁”。
- *
- * 为什么不用纯内存变量:
- * 1. App.vue 的 onLaunch 和页面组件 mounted 不在同一个文件里,直接共享内存状态不直观。
- * 2. 现有项目已经在登录页、首页挂了旧的整包升级弹窗组件,我们需要一个跨文件、最小改动的互斥标记。
- * 3. 用 storage 的好处是组件侧只要读一个 key 就能知道“启动阶段是否已经有热更新流程在跑”。
- *
- * 注意:
- * 这个锁只是为了解决“启动期并发弹窗”的问题,不是分布式锁,也不是强一致锁。
- * 对当前这个 uni-app 项目来说,这样的复杂度已经足够,而且侵入性最低。
+ * 旧版本使用过的持久化锁 key,仅用于启动时清理遗留值。
+ * 当前版本的互斥状态由模块内 hotUpdatePromise 管理,不会跨进程形成死锁。
  */
 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__";
+const HOT_UPDATE_FALLBACK_PENDING_KEY =
+  "__APP_WGT_HOT_UPDATE_FALLBACK_PENDING__";
+
+/**
+ * 页面 onShow、App onShow 和路由切换可能在很短时间内连续触发。
+ * 默认三十秒只向服务端检查一次;显式传入 forceCheck 时可用于用户手动检查。
+ */
+export const HOT_UPDATE_CHECK_INTERVAL = 30 * 1000;
+
+let hotUpdatePromise = null;
+let lastCheckStartedAt = 0;
 
 /**
  * 当用户点击“稍后再说”时,我们把当前版本记下来。
  *
  * 这样做的原因:
- * 1. 热更新检查放在 onLaunch,意味着每次冷启动都会检查一次。
- * 2. 如果用户已经明确拒绝了某个版本,下一次冷启动继续弹同一个版本,体验会比较差。
+ * 1. 热更新会在冷启动、回到前台和页面显示时检查
+ * 2. 如果用户已经明确拒绝了某个版本,后续页面切换继续弹同一个版本,体验会比较差。
  * 3. 只记录“被跳过的目标版本”,新版本来了仍然会重新提示。
  */
 const HOT_UPDATE_SKIP_VERSION_KEY = "__APP_WGT_HOT_UPDATE_SKIP_VERSION__";
@@ -39,33 +42,39 @@ const HOT_UPDATE_API_URL =
   config.default.apiUrl + config.default.apiUrlSuffix + "/rq/iot-app/newWgt";
 
 /**
- * 读取启动期热更新锁
+ * 读取当前进程的热更新运行状态
  *
  * 对页面组件的意义:
- * 旧的整包升级逻辑挂在页面里,页面 mounted 时如果发现启动热更新还在跑,
+ * 旧的整包升级逻辑挂在页面里,页面 mounted 时如果发现热更新还在跑,
  * 就应该先让路,避免同时弹两个升级框。
  */
 export const isHotUpdateRunning = () => {
-  return uni.getStorageSync(HOT_UPDATE_LOCK_KEY) === "1";
+  return Boolean(hotUpdatePromise);
 };
 
 /**
- * 设置/清理热更新锁。
- *
- * 输入:
- * - running: Boolean,true 表示流程开始,false 表示流程结束。
- *
- * 输出:
- * - 无返回值,副作用是修改 storage 中的互斥标记。
+ * 旧版本把运行锁写进了持久化 storage。如果 App 在下载或安装过程中被系统
+ * 强杀,这个值不会进入 finally 清理,后续每次启动都会永久跳过更新。
+ * 新实现使用模块内 Promise 作为进程锁;这里仅清理由旧版本遗留的死锁。
  */
-const setHotUpdateRunning = (running) => {
-  if (running) {
-    uni.setStorageSync(HOT_UPDATE_LOCK_KEY, "1");
-  } else {
+const clearLegacyHotUpdateLock = () => {
+  if (uni.getStorageSync(HOT_UPDATE_LOCK_KEY)) {
     uni.removeStorageSync(HOT_UPDATE_LOCK_KEY);
   }
 };
 
+/**
+ * 整包回退事件可能早于 upgrade 组件创建。额外保存一次待处理标记,组件挂载后
+ * 仍能消费它,避免事件丢失。
+ */
+export const consumePendingAppUpgradeCheck = () => {
+  const pending = uni.getStorageSync(HOT_UPDATE_FALLBACK_PENDING_KEY) === "1";
+  if (pending) {
+    uni.removeStorageSync(HOT_UPDATE_FALLBACK_PENDING_KEY);
+  }
+  return pending;
+};
+
 /**
  * 等待 plus 对象可用。
  *
@@ -508,9 +517,7 @@ const installWgtPackage = (filePath, appid) => {
  * 2. 这一步和旧的页面级整包升级不同,旧逻辑 Android 是直接安装 apk。
  * 3. 热更新的目标是“尽快让新前端资源生效”,因此安装完成后立即重启是最直接的方案。
  *
- * 注意这里在 restart 前先清掉锁:
- * - 因为 storage 会跨重启保留。
- * - 如果不先清,应用重启后旧组件会误以为“热更新一直在运行中”,造成后续检查失效。
+ * 运行锁现在是进程内 Promise;应用重启会自然释放,不再依赖持久化 storage 清锁。
  */
 const restartAppAfterInstall = (targetVersion) => {
   return new Promise((resolve) => {
@@ -519,7 +526,6 @@ const restartAppAfterInstall = (targetVersion) => {
       content: `资源版本 ${targetVersion} 已安装完成,点击确定后将立即重启应用使更新生效。`,
       showCancel: false,
       success: () => {
-        setHotUpdateRunning(false);
         plus.runtime.restart();
         resolve();
       },
@@ -531,7 +537,6 @@ const restartAppAfterInstall = (targetVersion) => {
          * - 到这里说明安装已经完成。
          * - 如果不重启,用户还会继续运行旧资源,和“安装成功”的状态不一致。
          */
-        setHotUpdateRunning(false);
         plus.runtime.restart();
         resolve();
       },
@@ -556,7 +561,7 @@ const showHotUpdateError = (error, title = "更新失败") => {
 };
 
 /**
- * 对外暴露的启动期热更新入口
+ * 内部热更新执行流程,由公开入口负责并发合并和调用频率控制
  *
  * 这是你后续在 App.vue onLaunch 里真正调用的函数。
  *
@@ -574,18 +579,10 @@ const showHotUpdateError = (error, title = "更新失败") => {
  * - Promise<{ status: string, ... }>
  *   主要用于日志或后续扩展,目前 App.vue 不需要依赖它的返回值。
  */
-export const runWgtHotUpdate = async () => {
+const executeWgtHotUpdate = async () => {
   // #ifdef APP-PLUS
-  if (isHotUpdateRunning()) {
-    return {
-      status: "locked",
-      message: "已有热更新流程在执行,跳过重复检查",
-    };
-  }
-
   let currentStage = "prepare";
   let finalResult = null;
-  setHotUpdateRunning(true);
 
   try {
     currentStage = "plus-ready";
@@ -656,14 +653,8 @@ export const runWgtHotUpdate = async () => {
     };
     return finalResult;
   } finally {
-    /**
-     * 正常情况下:
-     * - 无更新 / 用户取消 / 下载失败 / 安装失败,都会走到这里清锁。
-     * - 安装成功后会在 restart 之前先清锁,所以这里再次清理也没有副作用。
-     */
-    setHotUpdateRunning(false);
-
     if (finalResult?.status === "fallback-app-check-version") {
+      uni.setStorageSync(HOT_UPDATE_FALLBACK_PENDING_KEY, "1");
       uni.$emit(HOT_UPDATE_FALLBACK_APP_CHECK_EVENT, finalResult);
     }
   }
@@ -674,3 +665,39 @@ export const runWgtHotUpdate = async () => {
     message: "当前不是 APP-PLUS 环境,跳过 .wgt 热更新检查",
   };
 };
+
+/**
+ * 在任意页面均可调用的热更新入口。
+ *
+ * - 同一时刻只执行一个检查/下载/安装流程。
+ * - 自动触发默认受三十秒检查间隔限制。
+ * - 用户主动点击“检查更新”时传 { forceCheck: true } 可忽略时间间隔。
+ */
+export const runWgtHotUpdate = ({ forceCheck = false } = {}) => {
+  // #ifdef APP-PLUS
+  clearLegacyHotUpdateLock();
+
+  if (hotUpdatePromise) {
+    return hotUpdatePromise;
+  }
+
+  const now = Date.now();
+  if (!forceCheck && now - lastCheckStartedAt < HOT_UPDATE_CHECK_INTERVAL) {
+    return Promise.resolve({
+      status: "throttled",
+      message: "距离上次热更新检查时间较短,跳过重复检查",
+    });
+  }
+
+  lastCheckStartedAt = now;
+  hotUpdatePromise = executeWgtHotUpdate().finally(() => {
+    hotUpdatePromise = null;
+  });
+  return hotUpdatePromise;
+  // #endif
+
+  return Promise.resolve({
+    status: "skip-non-app-plus",
+    message: "当前不是 APP-PLUS 环境,跳过 .wgt 热更新检查",
+  });
+};