From c6221d30ca1f839b2d81438d453d3950f9bf9e9a Mon Sep 17 00:00:00 2001 From: joywayer Date: Mon, 22 Jun 2026 07:28:34 +0800 Subject: [PATCH] =?UTF-8?q?Design=20=C2=A78.4.1=EF=BC=9A=E8=A1=A5=20Device?= =?UTF-8?q?Kit=209=20=E9=A1=B9=E6=8E=A5=E5=8F=A3=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 §3.1 异步 handler 6 项 + §3.2 反向 callback 3 项,含本轮全文 audit 新发现的 3 项反向 callback(前一轮 audit 漏列): §3.1 异步 handler 6 项(DeviceHandlers.register): - 【10】vibrator — AudioServicesPlaySystemSound + cb "vibrator" - 【11】repeatvibrator — 同 vibrator(msext RootVC.m:1820 沿用历史) - 【12】canclevibrator — AudioServicesDisposeSystemSoundID + cb "canclevibrator" - 【13】gamepastetext — UIPasteboard.general.string → cb 返回内容 - 【14】gameCopytext — UIPasteboard.general.string = data + cb "gameCopytext" - 【21】getphoneInfo — cb "getphoneInfo"(大写 I)+ bridge.call("getphoneinfo" 小写 i!,data: 表 A 6 字段) ※ 大小写 i 不一致是 msext 历史契约,严格保持 §3.2 反向 callback 3 项(DeviceHandlers.bindReverseCallbacks): - 【2】 getBattery — UIDevice.batteryLevelDidChangeNotification data: String(format: "%.2f", level) 0~1 - 【3】 getnetwork — NWPath 变化 data: "1"无网/"2"WiFi/"3"蜂窝 - 【4】 appservice — App 前后台 data: "1"=后台/"2"=前台(命名错位沿用) 辨析 polyfill 与 callback 同名问题:契约 §3.4.1 polyfill 的同步 getter `getbattery()` / `getnetwork()` 与 §3.2 反向 `getBattery` / `getnetwork` 名字重合但语义不同(前者 H5 同步问当前值,后者 Native 事件推变化), 两条路径并存互不矛盾。在 §8.4.1 新增辨析段澄清。 支撑组件骨架:BatteryMonitor / AppLifecycleMonitor(NetworkMonitor 已在 §8.4 现有);§8.4.2 与 msext 6 维度差异表。 Co-Authored-By: Claude Opus 4.7 --- docs/H5-Native-Implementation-Design.md | 202 ++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/docs/H5-Native-Implementation-Design.md b/docs/H5-Native-Implementation-Design.md index b966e00..055b03a 100644 --- a/docs/H5-Native-Implementation-Design.md +++ b/docs/H5-Native-Implementation-Design.md @@ -2139,6 +2139,208 @@ public final class NetworkMonitor { 用 `Network.framework` 替换 `AFNetworkReachabilityManager`。 +#### 8.4.1 H5 device handlers + 系统事件反向 callback 实现骨架 + +DeviceKit 共承载 **9 项契约接口**: +- §3.1 异步 handler 6 项:`vibrator` / `repeatvibrator` / `canclevibrator` / `gamepastetext` / `gameCopytext` / `getphoneInfo` +- §3.2 反向 callback 3 项:`getBattery`(电量变化推送)/ `getnetwork`(网络变化推送)/ `appservice`(前后台切换推送) + +注:契约 §3.2 [2][3]的反向 `getBattery` / `getnetwork` 名字与 §3.4.1 polyfill 的同步 getter `getbattery()` / `getnetwork()` 重合但语义不同: +- **polyfill** = H5 同步问"当前是什么"(一次性快照,注入到 `window.settings`) +- **callback** = Native 异步推"刚刚变成了什么"(事件驱动,通过 `bridge.call(...)` 推给 H5) +- 两条路径并存、互不矛盾,H5 根据使用场景选其一 + +```swift +// Source/Bridge/Handlers/DeviceHandlers.swift +import UIKit +import AudioToolbox + +@MainActor +public struct DeviceHandlers { + + let bridge: BridgeProtocol + let pasteboard: UIPasteboard = .general + // BatteryMonitor / NetworkMonitor / AppLifecycleMonitor 在 register() 内启动 + // 并把回调钩到 bridge.call(...),构成"事件 → callback"的反向链路 + + let batteryMonitor: BatteryMonitor + let networkMonitor: NetworkMonitor + let appLifecycle: AppLifecycleMonitor + + public func register() { + // ─── §3.1 异步 handler ────────────────────────────── + bridge.register("vibrator", handler: vibrator) + bridge.register("repeatvibrator", handler: repeatVibrator) + bridge.register("canclevibrator", handler: cancelVibrator) + bridge.register("gamepastetext", handler: gamePasteText) + bridge.register("gameCopytext", handler: gameCopyText) + bridge.register("getphoneInfo", handler: getPhoneInfo) + + // ─── §3.2 反向 callback 钩子 ──────────────────────── + bindReverseCallbacks() + } + + // MARK: - 【10】 vibrator — 单次振动 + // + // 契约 §3.1 [10]:入参忽略 → AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + // responseCallback: "vibrator"(注意:vibrator 系列的 callback 是 + // handler 名字本身,不是 "Response from xxx", + // 与 audio 系列约定不同;参考 msext Bridge.m 约定) + // + // 当前实现见 Source/Bridge/Handlers/VibratorHandler.swift(Phase 1.15 已落地, + // 本节是把它放进 DeviceHandlers 聚合时的等价骨架) + + private func vibrator(_ data: BridgeData?, _ cb: BridgeCallback?) async { + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + cb?(.string("vibrator")) + } + + // MARK: - 【11】 repeatvibrator — 重复振动(实际同 vibrator) + // + // 契约 §3.1 [11]:入参 int 但被忽略,原 msext RootVC.m:1820 实现就是单次 vibrate + // 沿用历史,不真的循环 + + private func repeatVibrator(_ data: BridgeData?, _ cb: BridgeCallback?) async { + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + cb?(.string("repeatvibrator")) + } + + // MARK: - 【12】 canclevibrator — 释放系统振动音 + // + // 契约 §3.1 [12]:AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate) + // 实际意义有限(kSystemSoundID_Vibrate 是系统常量,dispose 也不会真的"取消"), + // 但 H5 仍会调用,必须 noop 实现 + callback 维持契约 + + private func cancelVibrator(_ data: BridgeData?, _ cb: BridgeCallback?) async { + AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate) + cb?(.string("canclevibrator")) + } + + // MARK: - 【13】 gamepastetext — 读剪贴板 + // + // 契约 §3.1 [13]: + // 入参 : 忽略 + // responseCallback: 剪贴板字符串内容(不是 "gamepastetext"),nil 时回空串 + // + // 注意:iOS 14+ 读剪贴板会弹"已粘贴自 XX"系统提示,无法关闭,是平台行为 + // 不算契约违反;如未来 H5 业务希望静默读,需 H5 端配合改用 UIPasteboard 检测 API + + private func gamePasteText(_ data: BridgeData?, _ cb: BridgeCallback?) async { + let text = pasteboard.string ?? "" + cb?(.string(text)) + } + + // MARK: - 【14】 gameCopytext — 写剪贴板 + // + // 契约 §3.1 [14]: + // 入参 data : string 要写入剪贴板的文本 + // responseCallback: "gameCopytext" + + private func gameCopyText(_ data: BridgeData?, _ cb: BridgeCallback?) async { + if let text = data?.asString { + pasteboard.string = text + } + cb?(.string("gameCopytext")) + } + + // MARK: - 【21】 getphoneInfo(大写 I)— 请求设备信息 + // + // 契约 §3.1 [21]: + // 入参 : 忽略 + // responseCallback: "getphoneInfo"(注意大写 I) + // 反向调用: bridge.call("getphoneinfo"(小写 i!), data: 表 A, callback: nil) + // + // 大小写 i 不一致是 msext 历史遗留契约,必须严格保持: + // - H5 调原生时用大写:bridge.callHandler("getphoneInfo", ...) + // - 原生回调 H5 时用小写:bridge.callHandler("getphoneinfo", ...) + // + // 表 A 字段名固定(PhoneAdresseMAC / PhoneDeviceBrand / PhoneIMEI / PhoneModel / + // PhoneProvidersName / PhoneVersion),值统一为 string,详见 Contract §3.2 表 A + + private func getPhoneInfo(_ data: BridgeData?, _ cb: BridgeCallback?) async { + cb?(.string("getphoneInfo")) + // 反向 push 设备信息快照(注意 handler 名小写 i) + let snapshot = DeviceInfo.snapshot + bridge.call("getphoneinfo", + data: .object(snapshot.mapValues { .string($0) }), + callback: nil) + } + + // MARK: - §3.2 反向 callback 钩子(事件驱动) + + private func bindReverseCallbacks() { + // 【2】 getBattery — UIDeviceBatteryLevelDidChangeNotification + // 数据:字符串 "%.2f" 形式的小数电量(0~1) + UIDevice.current.isBatteryMonitoringEnabled = true + batteryMonitor.onChange = { [weak bridge] level in + let str = String(format: "%.2f", level) + bridge?.call("getBattery", data: .string(str), callback: nil) + } + batteryMonitor.start() + + // 【3】 getnetwork — NWPath 变化 + // 数据:字符串 "1"=无网 / "2"=WiFi / "3"=蜂窝 + networkMonitor.stateDidChange = { [weak bridge] state in + bridge?.call("getnetwork", data: .string(state.rawValue), callback: nil) + } + networkMonitor.start() + + // 【4】 appservice — App 前后台切换 + // 数据:字符串 "1"=进入后台 / "2"=回到前台(命名错位,沿用历史) + appLifecycle.onBackground = { [weak bridge] in + bridge?.call("appservice", data: .string("1"), callback: nil) + } + appLifecycle.onForeground = { [weak bridge] in + bridge?.call("appservice", data: .string("2"), callback: nil) + } + appLifecycle.start() + } +} + +// MARK: - 支撑监控器(Source/DeviceKit/) + +@MainActor +public final class BatteryMonitor { + public var onChange: ((Float) -> Void)? + public func start() { + NotificationCenter.default.addObserver( + forName: UIDevice.batteryLevelDidChangeNotification, + object: nil, queue: .main + ) { [weak self] _ in + self?.onChange?(UIDevice.current.batteryLevel) + } + } +} + +@MainActor +public final class AppLifecycleMonitor { + public var onBackground: (() -> Void)? + public var onForeground: (() -> Void)? + public func start() { + let nc = NotificationCenter.default + nc.addObserver(forName: UIApplication.didEnterBackgroundNotification, + object: nil, queue: .main) { [weak self] _ in + self?.onBackground?() + } + nc.addObserver(forName: UIApplication.willEnterForegroundNotification, + object: nil, queue: .main) { [weak self] _ in + self?.onForeground?() + } + } +} +``` + +#### 8.4.2 与原 msext 的差异 + +| 维度 | msext 现状(不要照抄) | 新外壳决策 | +|------|--------------------|----------| +| 振动取消 | `AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate)` 同款 | 同款(契约硬约束,结构性意义有限但 H5 仍依赖 callback) | +| 剪贴板 | `[UIPasteboard generalPasteboard].string` | `UIPasteboard.general.string`(行为等价;iOS 14+ "已粘贴"提示是平台行为,不算契约违反) | +| 电量监听 | `UIDeviceBatteryLevelDidChangeNotification` + `[NSString stringWithFormat:@"%.2f"]` | `Notification.batteryLevelDidChangeNotification` + `String(format:)`(等价) | +| 网络监听 | `AFNetworkReachabilityManager` | `Network.framework` `NWPathMonitor`(ADR-006,无 AF 依赖) | +| 前后台 | `UIApplicationDidEnterBackgroundNotification` + `UIApplicationWillEnterForegroundNotification` 各自挂大厅 VC | `AppLifecycleMonitor` 集中处理,多 VC 复用 | +| getphoneInfo 大小写 | 大写 I 入 / 小写 i 出 | 严格保持(契约硬约束,msext Bridge.m 沿用) | + ### 8.5 微信 / QQ 多发起方派发(关键设计) #### 问题背景