Phase 2.C:ExternalSubscriptions 生命周期管理(防双发预备)

按 Design §2.4.2 把 battery / network / appservice 三组外部事件源的订阅
挂钩与 VC 生命周期配对:viewWillAppear → setup,viewWillDisappear →
teardown。配套约束:未来 Phase 6 子游戏 push 上来时大厅自动 teardown,
避免桥事件双发(栈深 ≤ 2,§2.4.3)。

WebContainerViewController 改造:
  - 新增 setupExternalSubscriptions():
    * 幂等 start 3 个 monitor(NetworkMonitor / BatteryMonitor /
      AppLifecycleObserver)
    * 设 BatteryMonitor.onChange → evaluateJavaScript("window.app_getbattery=N")
      + bridge.call("getBattery", "%.2f")
    * 设 NetworkMonitor.onChange → evaluateJavaScript("window.app_getnetwork=N")
      + bridge.call("getnetwork", "1"/"2"/"3")
    * 设 AppLifecycleObserver.onBackground/Foreground → bridge.call("appservice",
      "1"/"2")
  - 新增 teardownExternalSubscriptions():
    * 解绑 onChange / on{Background,Foreground} = nil
    * 释放对 bridge/webView 的 closure 引用避免子游戏 push 后双发
    * monitor 不停(保持后台 currentXxx 状态新鲜),下次 setup 直接 resume
  - viewWillAppear / viewWillDisappear override 调 setup / teardown 配对
  - writeAppDataFiles 精简:只写首次值 + 启动 NetworkMonitor/BatteryMonitor
    (需要 currentXxx 读首次值);AppLifecycleObserver 启动挪到 setup

至此 Phase 2 全部完成(2.A 8 个 handler + 2.B 5 项反向 callback + 2.C
生命周期管理)。

BuildProject 通过

Plan §5 Phase 2.13 勾选

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-22 19:02:35 +08:00
co-authored by Claude Opus 4.7
parent 4986f36967
commit 09f071e38f
2 changed files with 55 additions and 20 deletions
+5 -3
View File
@@ -372,9 +372,11 @@ Contract Design Plan(本文档)
##### 2.C 外部订阅生命周期 ##### 2.C 外部订阅生命周期
- [ ] **2.13** 实现 Design §2.4.2 的 `ExternalSubscriptions` 模式 - [x] **2.13** 实现 Design §2.4.2 的外部订阅生命周期管理
- `viewWillAppear` resume / `viewWillDisappear` suspend - `setupExternalSubscriptions()`(viewWillAppear 调):幂等启动 3 个 monitor + 设 onChange 闭包(battery / network 走 evaluateJavaScript + bridge.call 双轨;appservice 仅 bridge.call)
- 防双发:栈深 ≥ 2 时下层不发桥事件 - `teardownExternalSubscriptions()`(viewWillDisappear 调):解绑 onChange / on{Background,Foreground} = nil,释放 bridge/webView 引用避免子游戏 push 后双发
- `writeAppDataFiles` 精简为只写首次值 + 启动 NetworkMonitor/BatteryMonitor(读 currentXxx 用),AppLifecycleObserver 启动挪到 setup
- 单例 monitor 全局只有一个 closure 引用,未来 Phase 6 子游戏 setup 时会覆盖(大厅已 teardown 不会冲突,符合 §2.4.3 栈深 ≤ 2 约束)
#### 验收 #### 验收
@@ -75,11 +75,22 @@ public final class WebContainerViewController: UIViewController {
public override var canBecomeFirstResponder: Bool { true } public override var canBecomeFirstResponder: Bool { true }
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// 外部订阅生命周期:栈顶时挂钩,避免子游戏 push 上来后双发(§2.4.2)
setupExternalSubscriptions()
}
public override func viewDidAppear(_ animated: Bool) { public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
becomeFirstResponder() becomeFirstResponder()
} }
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
teardownExternalSubscriptions()
}
public override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { public override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
shakeHandler.handleMotionEnded(motion) shakeHandler.handleMotionEnded(motion)
} }
@@ -216,10 +227,10 @@ public final class WebContainerViewController: UIViewController {
) )
} }
/// 写 4 个 app_*.js 文件,并挂钩 battery / network / appservice 三组钩子: /// 写 4 个 app_*.js 文件首次值(loadFileURL 之前调一次,monitor 已启动)。
/// 每次变化时同时(1)重写对应 .js 文件(下次 reload 读到新值) /// 业务期变化的桥事件挂钩由 `setupExternalSubscriptions` / `teardownExternalSubscriptions`
/// 和(2)`bridge.call` 反向推送给当前页 H5(§3.2 [2][3][4])。 /// 配对管理(详见 Design §2.4.2 ExternalSubscriptions 生命周期),避免大厅 push
/// 与原 msext NewRootVC.initJSdata + changebattery + changenetstate + appservice 等价。 /// 子游戏后两个 VC 都在监听导致 H5 收到双倍桥事件。
private func writeAppDataFiles(resolved: ResolvedVersion) throws { private func writeAppDataFiles(resolved: ResolvedVersion) throws {
let writer = AppDataWriter( let writer = AppDataWriter(
bundleConfig: .shared, bundleConfig: .shared,
@@ -227,27 +238,40 @@ public final class WebContainerViewController: UIViewController {
containerRole: .lobby containerRole: .lobby
) )
// 启动 3 个 monitor(幂等:内部 started 标记) // 启动 2 个 currentXxx 类 monitor(幂等)以便读首次值;
// AppLifecycleObserver 不需要 currentXxx,挪到 setup 启动
NetworkMonitor.shared.start() NetworkMonitor.shared.start()
BatteryMonitor.shared.start() BatteryMonitor.shared.start()
AppLifecycleObserver.shared.start()
// 首次写入:4 个文件全部落盘 // 首次写入:4 个文件全部落盘
try writer.writeInitial() try writer.writeInitial()
try writer.writeBattery(BatteryMonitor.shared.currentLevel) try writer.writeBattery(BatteryMonitor.shared.currentLevel)
try writer.writeNetwork(NetworkMonitor.shared.currentCode) try writer.writeNetwork(NetworkMonitor.shared.currentCode)
}
// MARK: - 外部订阅生命周期(Phase 2.C / Design §2.4.2)
//
// 把 battery / network / appservice 三个外部事件源的订阅挂钩与 VC 生命周期
// 配对:viewWillAppear → setup,viewWillDisappear → teardown。
// 配套约束:未来 Phase 6 子游戏 push 上来时,大厅的 viewWillDisappear 会自动
// 触发 teardown,避免桥事件双发(参 §2.4.3 栈深 ≤ 2)。
//
// 业务期变化时**不重写文件**,改为直接 evaluateJavaScript 重新赋值 window.app_*
// 全局变量(H5 已加载完,<script src> 不会再 fetch;重新赋值后 H5 业务期同步读
// app_xxx 永远拿到最新值)。同时触发 §3.2 反向 callback 推送给注册了 handler 的
// H5 业务。详见 docs/H5-Native-Implementation-Design.md §7.5.2 / §7.5.4。
private func setupExternalSubscriptions() {
// 幂等启动 3 个 monitor(writeAppDataFiles 可能已经启动了 NetworkMonitor /
// BatteryMonitor;AppLifecycleObserver 在 setup 内首次启动)
NetworkMonitor.shared.start()
BatteryMonitor.shared.start()
AppLifecycleObserver.shared.start()
let bridge = bridgedWebView.bridge let bridge = bridgedWebView.bridge
let webView = bridgedWebView.webView let webView = bridgedWebView.webView
// 业务期变化时**不重写文件**,改为直接 evaluateJavaScript 重新赋值
// window.app_* 全局变量(H5 已加载完,<script src> 不会再 fetch;
// 重新赋值后 H5 业务期同步读 app_xxx 永远拿到最新值)。
// 同时触发 §3.2 反向 callback 推送给注册了 handler 的 H5 业务。
// 详见 docs/H5-Native-Implementation-Design.md §7.5.2 / §7.5.4。
// §3.2 [2] getBattery — UIDevice 电量变化 // §3.2 [2] getBattery — UIDevice 电量变化
// data: 字符串 "%.2f" 形式的小数电量
BatteryMonitor.shared.onChange = { level in BatteryMonitor.shared.onChange = { level in
let value = String(format: "%.2f", level) let value = String(format: "%.2f", level)
Task { @MainActor in Task { @MainActor in
@@ -256,8 +280,7 @@ public final class WebContainerViewController: UIViewController {
bridge.call("getBattery", data: .string(value), callback: nil) bridge.call("getBattery", data: .string(value), callback: nil)
} }
// §3.2 [3] getnetwork — NWPath 变化 // §3.2 [3] getnetwork — NWPath 变化(1 无网 / 2 WiFi / 3 蜂窝)
// data: 字符串 "1"=无网 / "2"=WiFi / "3"=蜂窝
NetworkMonitor.shared.onChange = { code in NetworkMonitor.shared.onChange = { code in
Task { @MainActor in Task { @MainActor in
_ = try? await webView.evaluateJavaScript("window.app_getnetwork=\(code);") _ = try? await webView.evaluateJavaScript("window.app_getnetwork=\(code);")
@@ -266,8 +289,6 @@ public final class WebContainerViewController: UIViewController {
} }
// §3.2 [4] appservice — App 前后台切换(命名错位沿用历史) // §3.2 [4] appservice — App 前后台切换(命名错位沿用历史)
// data: "1"=进入后台 / "2"=回到前台
// appservice 无对应 app_* 全局变量,只走 bridge.call 反向通知
AppLifecycleObserver.shared.onBackground = { AppLifecycleObserver.shared.onBackground = {
bridge.call("appservice", data: .string("1"), callback: nil) bridge.call("appservice", data: .string("1"), callback: nil)
} }
@@ -276,6 +297,18 @@ public final class WebContainerViewController: UIViewController {
} }
} }
private func teardownExternalSubscriptions() {
// 仅解绑 onChange / on{Background,Foreground} 回调,monitor 继续在
// 后台运行(保持 currentXxx 状态新鲜),下次 setup 时直接 resume。
// 单例 monitor 全局只有一个 closure 引用,子游戏 setup 时会覆盖;
// 大厅 viewWillDisappear 时 teardown 释放对当前 bridge/webView 的引用
// 避免双发。
BatteryMonitor.shared.onChange = nil
NetworkMonitor.shared.onChange = nil
AppLifecycleObserver.shared.onBackground = nil
AppLifecycleObserver.shared.onForeground = nil
}
// MARK: - Alerts (terminal / retry states) // MARK: - Alerts (terminal / retry states)
private var appDisplayName: String { private var appDisplayName: String {