# Daoqi iOS 外壳新项目实施设计 > 配套文档:[`H5-Native-Contract.md`](./H5-Native-Contract.md) (契约,必须 1:1 落地) > 本文档:实现蓝图,开发团队拿到即可启动 > 写作日期:2026-06-21 --- ## 项目性质声明(读前必读) 本文档描述的是一个 **greenfield 全新 iOS 项目** 的完整架构设计。 请用"从零开始建一个新 iOS App"的姿态阅读: - 文件结构、命名、模块边界、技术栈、并发模型、依赖选型 — 完全独立设计,按 Swift / iOS 现代最佳实践 - 一切原生内部实现追求 **架构优雅、高效、高性能、专业、成熟** - 不背任何历史包袱,不复刻任何既有 iOS 项目的代码 - 整体姿态:**架构师从零起设计一个新 App**,而不是"工程师维护/重构一个老 App" ### 设计中需要"照搬"什么 只有以下三类外部契约要求新项目按既定形式落地,其它一切自由: | 契约类型 | 来源 | 必须照搬什么 | 文档位置 | |---------|------|------------|---------| | H5 契约 | H5 端业务代码 | 桥接口名 / 参数字段名 / 数据结构 / JS 协议 / 初始化字面值 | `H5-Native-Contract.md` | | 服务端契约 | 后台 API | 签名算法 / 字段顺序 / URL 路径 / 错误码 | 本文档 §6 | | 三方 SDK 契约 | 微信/QQ/高德/Agora/七牛 SDK 文档 | 注册顺序 / 文件类型 flag / 回调签名 | 本文档 §4 §14 | **判断某项是否属于契约的方法**:问"外部能否感知"。能感知 → 照搬;不能 → 自由设计。 ### 渐进集成原则(未启用 SDK 处理方式) 部分桥接 handler 当前业务**暂未使用**(子游戏视频房间、闲聊分享、Bugly 监控等)。新项目首版**不集成这些 SDK**,但 **handler 仍然必须注册**,内部 stub 实现 —— 立即调 `responseCallback` 返回契约要求的字面值,不做实际业务,不依赖任何未集成的 SDK。 理由: - H5 端代码不知道哪些 SDK 没集成,它仍然会调 `createRoom` / `friendsSharetypeUrlToptitleDescript(sharetype=3)` 等。若 handler 不注册,H5 会走"bridge handler not found"错误分支,业务流程中断 - Stub handler 保证契约不被违反,即使功能没启用也不会引起 H5 端报错 - 未来某天业务启动这些功能时,只需把 stub 换成真实 SDK 调用,**契约边界不需要任何改动** 详细的 stub 清单见 §5 / §8 / §14。 --- ## 0. TL;DR | 项 | 决策 | |----|------| | 语言 | **Swift 5.10+**(Swift 6 strict concurrency 渐进开启) | | 最低系统 | **iOS 14.0**(覆盖 99.5% 现役设备,删掉所有 iOS <9 旧路径) | | UI 层 | **UIKit + Storyboard 极少**,以代码 UI 为主;不引入 SwiftUI(WebView 容器场景不受益) | | 并发 | **async/await + Actor**,弃用 Combine 与 GCD 散落用法 | | Web 渲染 | **WKWebView** 单例 + ProcessPool 复用 | | H5 桥 | 自研 Swift 桥(实现 WebViewJavascriptBridge 的 JS 端协议作为契约边界)+ 弹层注入 `window.settings.*` polyfill | | 网络 | **URLSession + async/await + Codable**,自研 HTTPClient,Endpoint 协议化 | | 持久化 | UserDefaults + FileManager;**不引入 CoreData/SQLite** | | 依赖管理 | **纯 SPM + Vendor `.xcframework` 手动**(完全不引入 CocoaPods 工具链);开源 / 官方 SPM 走 SPM,闭源二进制无 SPM 的走 Vendor 手动 | | 架构 | **Modular Clean Architecture** + **Coordinator 路由** | | 监控 | **默认接入 Sentry**(崩溃 + 性能 + breadcrumb);提供编译开关 `SENTRY_ENABLED` 作为关闭逃生口 | | 包名 / Target | 与现 `msext` 解耦,新名 `Daoqi`(支持企业签 / 超签 / TF 同包并存) | | 上架 | 由项目方业务决定;设计层面不预设(若上架则补 PrivacyInfo.xcprivacy / ATT 文案,模块化解耦不影响其它代码) | --- ## 1. 设计目标 & 非目标 ### 1.1 目标 1. **契约零偏差** — H5 不改一行代码,在新外壳上行为与现网一致(以 `H5-Native-Contract.md` §10 验收清单为准) 2. **架构优雅** — 模块边界清晰,单一职责;新增桥接接口/SDK 只动一处 3. **高性能** — 启动 < 1.5 s 进大厅 H5;桥接消息往返 < 16 ms(单帧);视频房间 1080p@30 稳定 4. **专业成熟** — 包含完整的崩溃监控/日志/单测/集成测/CI 流程;团队随时可换人维护 5. **可演进** — 新增渠道/功能不需要回改既有模块;SDK 可热替换(如 Agora → 自研) ### 1.2 非目标(避免过度设计) - 不做 ARM64 之外的架构(armv7/armv7s 已无现役设备) - 不做 iPadOS 适配优化(产品定位是 iPhone) - 不引入 SwiftUI(纯 WebView 容器,SwiftUI 不会更快也不会更优雅) - 不做 Combine + RxSwift 任何一种响应式流(用 async/await 足矣) - 不做云原生 / 后端服务(范畴外) --- ## 2. 模块化架构 ### 2.1 顶层依赖图 ``` ┌──────────────────────────────────────────────┐ │ App │ 入口、Coordinator、生命周期 └──────────────────────────────────────────────┘ │ │ │ ▼ ▼ ▼ ┌─────────────────┐ ┌──────────────┐ ┌─────────────┐ │ Features │ │ Core │ │ SDKs │ │ (Lobby/SubGame │ │ (Bridge/Net/ │ │ (WeChat/ │ │ /Overlay/...) │ │ Resource) │ │ Agora/...) │ └─────────────────┘ └──────────────┘ └─────────────┘ │ │ │ └──────────────┴──────────────┘ │ ▼ ┌──────────────────────┐ │ Foundation │ 纯工具、扩展、协议 └──────────────────────┘ ``` **依赖方向严格自上而下**,任意一层不引用同层兄弟模块、不反向依赖。 ### 2.2 SPM 包结构 按 Swift Package Manager 切分,每个目录是一个 SPM target: ``` Daoqi/ ├── Package.swift # 顶层 SPM 配置(多 target) ├── Sources/ │ ├── App/ # 入口 │ ├── AppCoordinator/ # 路由 │ ├── Lobby/ # 大厅 feature │ ├── SubGame/ # 子游戏 feature │ ├── Overlay/ # 弹层 feature │ ├── BridgeCore/ # H5 桥核心(协议+消息总线) │ ├── BridgeHandlers/ # 桥 handler 注册(纯函数) │ ├── WebViewKit/ # WKWebView 封装、JS 注入 │ ├── NetworkKit/ # HTTPClient、SGGateway facade │ ├── ResourceKit/ # gamehall.zip 解压、渠道注入、目录管理 │ ├── AudioKit/ # 录音/播放/AMR-WAV 转码 │ ├── ShareKit/ # 分享统一入口 │ ├── LoginKit/ # 微信授权 │ ├── LocationKit/ # 高德定位 │ ├── DeviceKit/ # 设备信息/电池/网络/振动 │ ├── VideoRoomKit/ # Agora 封装 │ ├── AnalyticsKit/ # Sentry(默认启用) / JAnalytics 极光(暂不集成,Noop 占位) │ ├── Logger/ # 统一日志 │ └── Foundation+/ # 通用扩展 ├── Vendor/ # 闭源 SDK(.framework / .a) │ ├── WechatSDK.framework # 启用 │ ├── AMapLocationKit.framework # 启用 │ └── libopencore-amr*.a # 启用 │ # 暂不集成(对应代码模块用 Noop 占位): │ # - JAnalytics.framework # 启用用户统计时再放入 │ # - XianliaoSDK.framework # 启用闲聊分享时再放入 │ # - AgoraRtcKit.framework # 启用视频房间时再放入(或改用 SPM) ├── Resources/ │ ├── gamehall.zip │ ├── Info.plist │ ├── Daoqi.entitlements │ ├── Assets.xcassets │ └── ChannelInjection/ # 11 个渠道注入目录,打包脚本生成 │ ├── qiniudomain/ │ ├── gameid/ │ ├── channel/ │ ├── gamedir/ │ ├── gamestart/ │ ├── gameconfig/ │ ├── market/ │ ├── agent/ │ ├── appversion/ │ ├── other/ │ └── appleconfig/ ├── Tests/ # 单测 │ ├── BridgeCoreTests/ │ ├── ResourceKitTests/ │ ├── NetworkKitTests/ │ └── ... ├── UITests/ # XCUI 集成测 ├── Scripts/ # 打包/渠道注入脚本 │ ├── inject_channel.sh │ ├── archive.sh │ └── version_bump.sh └── Daoqi.xcworkspace # Xcode 工作区 ``` > **切分粒度提示**:上述 17 个 SPM target 是初始规划,M0 立项时跑一次 `xcodebuild -scheme Daoqi clean build` baseline 测量。若 clean build 时间 > 60s,把强耦合模块合并:`Lobby` / `SubGame` / `Overlay` 合为 `Containers`;`DeviceKit` 并入 `Foundation+`;`AudioKit` 与 `ShareKit` 看依赖深度可保持独立或合并。**目标稳态 8-10 个 target**,以日常开发体验为先。 ### 2.3 模块职责矩阵 | 模块 | 暴露能力 | 内部依赖 | |------|---------|---------| | `BridgeCore` | `BridgeProtocol`、`BridgeMessage`、`BridgeBus`(actor) | Foundation+ | | `WebViewKit` | `BridgedWebView`(包装 WKWebView+消息桥), `JSInjector` | BridgeCore, ResourceKit, Foundation+ | | `BridgeHandlers` | `LobbyHandlers`、`SubGameHandlers`、`OverlayHandlers` | BridgeCore, 各 Kit(AudioKit/ShareKit/...) | | `Lobby` / `SubGame` / `Overlay` | ViewController + Coordinator | WebViewKit, BridgeHandlers | | `NetworkKit` | `HTTPClient`(async), `GameAPIClient`(签名 + Endpoint) | Foundation+ | | `ResourceKit` | `BundleConfig`(渠道读取), `ResourceUnzipper`, `SandboxPaths` | Foundation+ | | `AudioKit` | `AudioRecorder`, `AudioPlayer`, `VoiceCoder`(AMR↔WAV) | ResourceKit, NetworkKit | | `ShareKit` | `ShareCenter`(协议) → `WeChatShare` 启用 / `XianliaoShare` 暂以 `NoopSharePlatform` 替代 | SDK Wrappers | | `LoginKit` | `WeChatAuth`(async OAuth) | SDK Wrappers, NetworkKit | | `LocationKit` | `LocationService` | AMapKit Wrapper | | `DeviceKit` | `DeviceInfo`, `BatteryMonitor`, `NetworkMonitor`, `Vibrator`, `Pasteboard` | Foundation+ | | `VideoRoomKit` | `VideoRoom`(协议) → `NoopVideoRoom` 当前启用 / `AgoraVideoRoom` 留蓝图 | (Agora SDK 暂未引入) | | `AnalyticsKit` | `CrashReporter`(协议) → `SentryCrashReporter` 启用 / `Tracker`(协议) → `NoopAnalytics` 当前启用 / `JAnalyticsTracker` 留蓝图 | SDK Wrappers | --- ### 2.4 WebContainer 容器模型与生命周期 大厅 / 子游戏 / 弹层在新架构中是**同一种 `WebContainer` 的不同角色**,共享基类,差异只在注入的 handler 集合和入口 URL: ``` WebContainerViewController(基类 ~200 行) ├── WKWebView + ProcessPool 复用 ├── BridgeBus 创建与公共 handler 注册委托 ├── JSInjector(app_data.js / app_battery.js / app_network.js) ├── 生命周期 → 桥事件(appservice / getBattery / getnetwork) ├── 摇一摇 motionEnded → shakeEnd └── 外部订阅生命周期管理(suspend/resume) ▲ │ 继承 + 注入 ┌────┴────────────────────┐ ▼ ▼ ▼ Lobby SubGame Overlay (20 handler) (23 handler) (3 handler) + 大厅 URL + 子游戏 URL + 外链 URL + VideoRoom + JSExport polyfill + CallCenter + RecordUploader ``` #### 2.4.1 状态保持原则:push 不销毁 大厅 → 子游戏使用 `UINavigationController.pushViewController`,**大厅 VC + WebView + Bridge + JS 上下文全程保留**在 navigation stack 里。子游戏 → 大厅用 `popViewController`,大厅状态原封不动恢复。 理由: - H5 端业务隐式依赖大厅状态(列表滚动、玩家余额、待办通知等) - `getWebdata` callback 要求大厅 handler 一直注册着,新建大厅会丢数据 - 返回瞬时(无 loadFileURL 黑屏) #### 2.4.2 外部订阅暂停 — 避免桥事件双发 旧架构隐患:大厅和子游戏都监听 `enterForeground` / 电池 / 网络 等通知,前后台切换时**两个都响应**,H5 收到双倍桥事件。 新架构方案:WebContainer 用 viewWillAppear / viewWillDisappear 管理外部订阅生命周期,**任何时刻只有最顶层可见 VC 向 H5 派发桥事件**: ```swift public class WebContainerViewController: UIViewController { private let externalSubs: ExternalSubscriptions // 注入 override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) externalSubs.resume() // resume 内部:bind battery/network/lifecycle 监听 // 立即派发一次当前状态(避免 H5 错过暂停期间的变化) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) externalSubs.suspend() // suspend 内部:unbind 所有监听,WebView 不释放,JS 继续运行 } } @MainActor final class ExternalSubscriptions { weak var bridge: BridgeProtocol? private let battery = BatteryMonitor() private let network = NetworkMonitor() private let lifecycle = AppLifecycleObserver() func resume() { battery.onChange = { [weak bridge] level in bridge?.call("getBattery", data: .string(String(format: "%.2f", level)), callback: nil) } network.onChange = { [weak bridge] state in bridge?.call("getnetwork", data: .string(state.rawValue), callback: nil) } lifecycle.onChange = { [weak bridge] state in bridge?.call("appservice", data: .string(state == .background ? "1" : "2"), callback: nil) } battery.start(); network.start(); lifecycle.start() // 立即派发一次,补齐暂停期间的状态 battery.emitNow(); network.emitNow() } func suspend() { battery.stop(); network.stop(); lifecycle.stop() // 解绑回调,避免在子游戏 push 上来后大厅仍收到事件 } } ``` #### 2.4.3 栈深约束 ≤ 2 — 防止内存堆栈 旧架构隐患:H5 连续调 SwitchOverGameData 时,navigation stack 不断增长(`[Lobby, SubA, SubB, SubC, ...]`),内存爆炸。 新架构方案:Coordinator 在路由时显式判断: ```swift @MainActor final class AppCoordinator { private let nav: UINavigationController /// 进入子游戏:保证 nav stack 最多两层(Lobby + 当前 SubGame) func showSubGame(_ params: SwitchGameParams) { if nav.topViewController is SubGameViewController { nav.popViewController(animated: false) // 先退出当前子游戏 } let vc = SubGameViewController(params: params, coordinator: self) nav.pushViewController(vc, animated: true) } /// 打开弹层(Overlay 是浮层,允许 [Lobby, SubGame, Overlay] 三层) func showOverlay(_ params: OverlayParams) { let vc = OverlayViewController(params: params, coordinator: self) nav.pushViewController(vc, animated: true) } func popToLobby() { nav.popToRootViewController(animated: true) } } ``` → 任意 H5 操作下,navigation stack 最多 3 层(Lobby + SubGame + Overlay),内存占用可预测。 #### 2.4.4 WebView 数据隔离策略 新架构主张大厅 / 子游戏 / 弹层共用同一个 `WKProcessPool` 加速启动,但**默认共享 Cookie 和 LocalStorage**(因为它们都属于同一个 dataStore)。这在不同源 H5 间可能产生意外串扰。 策略: - **大厅 + 子游戏** 共用 `WKWebsiteDataStore.default()`(都是项目自家 H5,共享业务态合理 — 比如登录态、用户偏好等) - **弹层 Overlay**(加载第三方外链时)使用 `WKWebsiteDataStore.nonPersistent()` 私有 store,Cookie / LocalStorage 不写盘,**不污染主业务态** ```swift extension WKWebsiteDataStore { static let businessStore = WKWebsiteDataStore.default() // 大厅 + 子游戏 static let overlayStore = WKWebsiteDataStore.nonPersistent() // 弹层外链 } ``` OverlayViewController 在 WKWebViewConfiguration 上设置 `websiteDataStore = .overlayStore` 即可隔离。 #### 2.4.5 副作用清单:进入 / 退出子游戏的强制动作 无论 H5 端逻辑如何变化,以下副作用由 Coordinator + Handler 在路由时**强制完成**,不允许 H5 端遗漏: | 时机 | 强制副作用 | |------|----------| | 大厅 → 子游戏(SwitchOverGameData) | ① `audio.stopBackground()` ② `WXApiManager` delegate 切上下文(详见 §8.4) | | 子游戏 → 大厅(backgameData) | ① `audio.stopBackground()` ② `videoRoom.leave()` ③ 通过 `.subGameDidReturn` 通知 LobbyHandlers 回调 `getWebdata` | | 进入 Overlay(OpenurlTitleData) | ① 节流(3 秒内重复打开吞掉) | | 退出 Overlay(settings.finishweb / settings.backgameData) | ① 弹层 backgameData 同样发 `.subGameDidReturn` 通知,LobbyHandlers / SubGameHandlers 监听并 callback `getWebdata` 给当前 active 容器的 H5 | --- ## 3. H5 桥接核心实现 ### 3.1 设计要点 WVJB 的 JS 端协议很简单:页面加载完成后,JS 会执行 `_setupWebViewJavascriptBridge`,把队列 message 通过 `iframe src` 或 `console.log` 通知原生。新外壳**自己实现 WVJB-JS 端的协议**,而不是引用 OC 版 WebViewJavascriptBridge 库: - 优点:纯 Swift、无 OC 依赖、可严格并发安全、单元测试容易 - 落地路径:把 WebViewJavascriptBridge 的 JS 端代码(`WebViewJavascriptBridge.js.txt`)作为 WKUserScript 注入到 WKWebView,原生侧自研 `WKScriptMessageHandler` 接收并分发消息 ### 3.2 协议骨架 ```swift // BridgeCore/BridgeProtocol.swift public protocol BridgeProtocol: AnyObject, Sendable { /// 注册一个 H5 → Native handler。重复名覆盖。 func register(_ name: String, handler: @escaping BridgeHandler) /// 主动调用 H5 端的 handler。callback 可选。 func call(_ name: String, data: BridgeData?, callback: BridgeCallback?) } public typealias BridgeHandler = @Sendable (BridgeData?, BridgeCallback?) async -> Void public typealias BridgeCallback = @Sendable (BridgeData?) -> Void public enum BridgeData: Sendable { case string(String) case number(Double) case bool(Bool) case null case array([BridgeData]) case object([String: BridgeData]) // 字段访问语法糖 public subscript(key: String) -> BridgeData? { ... } public var asString: String? { ... } public var asInt: Int? { ... } } ``` ### 3.3 关键实现 ```swift // WebViewKit/BridgedWebView.swift @MainActor public final class BridgedWebView: UIView { public let webView: WKWebView public let bridge: BridgeBus public init(config: WebViewConfig) { let cfg = WKWebViewConfiguration() cfg.processPool = SharedProcessPool.shared // 进程池复用 cfg.preferences.javaScriptEnabled = true cfg.preferences.javaScriptCanOpenWindowsAutomatically = false cfg.preferences.minimumFontSize = 10 cfg.allowsInlineMediaPlayback = true // 注入 WVJB-JS 协议 let userScript = WKUserScript( source: JSInjector.wvjbProtocol, injectionTime: .atDocumentStart, forMainFrameOnly: true) cfg.userContentController.addUserScript(userScript) self.webView = WKWebView(frame: .zero, configuration: cfg) self.bridge = BridgeBus(webView: webView, controller: cfg.userContentController) super.init(frame: .zero) layout() // 契约 §4.1:与现网保持的 ScrollView 配置 webView.scrollView.contentInsetAdjustmentBehavior = .never // iOS 11+ webView.scrollView.bounces = false webView.scrollView.showsVerticalScrollIndicator = false webView.scrollView.showsHorizontalScrollIndicator = false webView.scrollView.isScrollEnabled = false // ⚠️ 契约要求,漏则 H5 上下滑动失控 } } // BridgeCore/BridgeBus.swift @MainActor public final class BridgeBus: BridgeProtocol { private var handlers: [String: BridgeHandler] = [:] private weak var webView: WKWebView? private var pendingCallbacks: [String: BridgeCallback] = [:] private var callbackCounter = 0 public func register(_ name: String, handler: @escaping BridgeHandler) { handlers[name] = handler } public func call(_ name: String, data: BridgeData?, callback: BridgeCallback?) { var payload: [String: Any] = ["handlerName": name] if let data = data { payload["data"] = data.jsonObject } if let callback = callback { callbackCounter += 1 let cbId = "objc_cb_\(callbackCounter)" pendingCallbacks[cbId] = callback payload["callbackId"] = cbId } let json = try! JSONSerialization.data(withJSONObject: payload) let js = "WebViewJavascriptBridge._handleMessageFromObjC('\(json.base64)')" webView?.evaluateJavaScript(js) } // 接收 H5 → Native func didReceive(_ message: [String: Any]) async { guard let name = message["handlerName"] as? String else { return } let data = (message["data"]).map(BridgeData.from) let cbId = message["callbackId"] as? String let cb: BridgeCallback? = cbId.map { id in { [weak self] resp in self?.call(id, data: resp, callback: nil) } } if let handler = handlers[name] { await handler(data, cb) } else { Log.warn("Bridge: no handler for \(name)") cb?(nil) } } } ``` ### 3.4 弹层桥(window.settings polyfill) H5 弹层页约定使用 `window.settings.xxx(...)` 同步调用形式(契约边界)。新项目弹层 WebView 用 WKWebView,通过注入一段 polyfill 把 `window.settings` 转换为 `WKScriptMessageHandler` 消息: ```swift // WebViewKit/OverlayBridge.swift public enum OverlayBridge { public static let polyfill = """ window.settings = { backgameData: function(data) { window.webkit.messageHandlers.overlayBackgameData.postMessage(String(data)); }, browser: function(url) { window.webkit.messageHandlers.overlayBrowser.postMessage(String(url)); }, finishweb: function() { window.webkit.messageHandlers.overlayFinishweb.postMessage(""); } }; """ } ``` 3 个 `WKScriptMessageHandler` 在 OverlayViewController 内注册即可。 #### 3.4.1 大厅 / 子游戏的 window.settings 同步 getter polyfill(契约 §附录 A 9 项 getter) **背景**:契约 §附录 A 列出 28 项旧桥 JSExport 方法(iOS<9 路径),与 §3.1 异步 callback 主表去重后多出 **9 项 H5 同步只读 getter**: | getter | 返回类型 | 业务语义 | 数据源 | |---|---|---|---| | `getchannelName()` | string | 渠道 ID(11 项渠道注入之一)| `BundleConfig.shared.channel` | | `getmarketname()` | string | 市场 ID(11 项渠道注入之一)| `BundleConfig.shared.market` | | `getOther()` | string | 渠道 `other` 字段 | `BundleConfig.shared.other` | | `getothername(name)` | string | 按 H5 传入 key 动态读 11 项渠道注入任意字段 | `BundleConfig.shared.value(forKey: name)` | | `getcompareCode()` | int | 业务校验码(msext `RootVC.m` 沿用 zip 版本号或固定值) | 待原 msext 取值确认(Phase 2 实施时查 `RootVC.m:1560` 附近 `getcompareCode` 真实返回值,并对齐) | | `getbattery()` | double | 当前电池电量 0.0–1.0 | `UIDevice.current.batteryLevel`(启动期 snapshot 一次) | | `getnetwork()` | int | 当前网络类型(0 无 / 1 WiFi / 2 蜂窝) | `NWPathMonitor` 当前 path(loadFileURL 前 snapshot) | | `getGameinstall(name)` | int | 子游戏目录是否存在(0/1) | 扫 `SandboxPaths.subGameRoot(name)` 后注入已安装列表 | | `getGameplay(jsondata)` | void | 契约 §3.1 [19]**空实现**,H5 仍会调,原生 noop 即可 | — | **为什么不走 BridgeBus 异步 callback**:H5 端代码形式是 `var ch = window.settings.getchannelName()`、`var b = window.settings.getbattery()` 等**同步表达式**(取值后立即用于业务判断),WKWebView 时代 native 无法同步返回 JS 值(异步 evaluateJavaScript 改不了 H5 端代码 → 违反契约原则 A)。唯一不破契约的实现路径:**WebView 加载前在 `documentStart` 注入完整数据快照 + 同步 JS getter polyfill**,本地查询零延迟。 **数据快照时机**: - 静态字段(渠道 / market / other / appVersion 等 11 项渠道注入):app 启动期读 `ChannelConfig.plist` 后即不变,全程一次即可 - 动态字段(getbattery / getnetwork):**每次 `loadFileURL` 前重新 snapshot 注入**(精度足够,原 msext 自身也只在 `viewDidLoad` 取一次,H5 业务里"启动时刻电量值"被复用整个会话) - 已安装子游戏列表(getGameinstall):每次 loadFileURL 前扫描沙盒 + 注入 → SwitchOverGameData 解压新子游戏后自然在下次 loadFileURL 刷新 **实现骨架**: ```swift // Source/WebView/SettingsBridgePolyfill.swift // // 把契约 §附录 A 的 9 项 H5 同步 getter 实现为 documentStart 注入的 JS polyfill, // 数据全部本地查询、零延迟,与原 msext JSExport 同步行为等价。 // // 注入流程: // WebContainerViewController.loadFileURL(lobbyIndex) 调用前 // → SettingsBridgePolyfill.makeUserScript(BundleConfig + DeviceSnapshot + InstalledGames) // → BridgedWebView 把 WKUserScript 加到 WKUserContentController // → loadFileURL → H5 一加载即可同步读 window.settings.getXxx() @MainActor public enum SettingsBridgePolyfill { /// 构造一段 documentStart 注入的 JS。data 是 Native 端拼好的快照。 public static func makeUserScript(snapshot: Snapshot) -> WKUserScript { let json = (try? JSONSerialization.data(withJSONObject: snapshot.jsonObject)) .flatMap { String(data: $0, encoding: .utf8) } ?? "{}" let source = """ (function() { window.__nativeSnapshot = \(json); window.settings = window.settings || {}; // ── 11 项渠道注入(静态,启动期一次性快照)──────────── window.settings.getchannelName = function() { return window.__nativeSnapshot.channel || ""; }; window.settings.getmarketname = function() { return window.__nativeSnapshot.market || ""; }; window.settings.getOther = function() { return window.__nativeSnapshot.other || ""; }; window.settings.getothername = function(name) { if (!name) return ""; return (window.__nativeSnapshot.channelConfig || {})[name] || ""; }; // ── 业务校验码 + 设备动态字段(每次 loadFileURL 前刷新)── window.settings.getcompareCode = function() { return window.__nativeSnapshot.compareCode || 0; }; window.settings.getbattery = function() { return window.__nativeSnapshot.battery || 0.0; }; window.settings.getnetwork = function() { return window.__nativeSnapshot.network || 0; }; // ── 子游戏安装查询(每次 loadFileURL 前快照已安装列表)─ window.settings.getGameinstall = function(name) { if (!name) return 0; var list = window.__nativeSnapshot.installedGames || []; return list.indexOf(name) >= 0 ? 1 : 0; }; // ── 已知空实现(契约 §3.1 [19],H5 仍会调)──────────── window.settings.getGameplay = function(_jsondata) { /* no-op */ }; })(); """ return WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: true) } public struct Snapshot: Sendable { public let channelConfig: [String: String] // 11 项渠道注入完整字典 public let channel: String public let market: String public let other: String public let compareCode: Int public let battery: Double public let network: Int // 0/1/2 public let installedGames: [String] public var jsonObject: [String: Any] { [ "channelConfig": channelConfig, "channel": channel, "market": market, "other": other, "compareCode": compareCode, "battery": battery, "network": network, "installedGames": installedGames ] } /// 从 BundleConfig + DeviceKit + SandboxPaths 拼装当前快照 @MainActor public static func make() -> Snapshot { let bc = BundleConfig.shared return Snapshot( channelConfig: bc.asDictionary, channel: bc.channel, market: bc.market, other: bc.other, compareCode: CompareCodeProvider.current(), // 见下文 battery: DeviceKit.batteryLevel(), network: NetworkMonitor.shared.currentTypeCode, installedGames: SandboxPaths.installedSubGames() ) } } } ``` **`WebContainerViewController` 接入点**: ```swift private func runBootPipelineSteps() async throws { // ... ensureReady / fetch / resolve / upgrade ... // 6. loadFileURL 前注入 settings polyfill splash.update(text: "加载大厅...", progress: nil) let snapshot = SettingsBridgePolyfill.Snapshot.make() bridgedWebView.installSettingsPolyfill(snapshot: snapshot) bridgedWebView.webView.loadFileURL( SandboxPaths.lobbyIndex, allowingReadAccessTo: SandboxPaths.lobbyRoot ) } // Source/WebView/BridgedWebView.swift extension BridgedWebView { /// 在 contentController 重置后追加 polyfill UserScript, /// 然后 loadFileURL 即可让 H5 在 documentStart 同步访问 window.settings.getXxx() public func installSettingsPolyfill(snapshot: SettingsBridgePolyfill.Snapshot) { let controller = webView.configuration.userContentController // 注:WebViewJavascriptBridge.js 这条 atDocumentStart UserScript 在 init 时 // 已加入并保持不变;这里只追加 settings polyfill,互不影响 controller.addUserScript(SettingsBridgePolyfill.makeUserScript(snapshot: snapshot)) } } ``` **与原 msext 的差异**: | 维度 | msext 现状 | 新外壳决策 | |------|----------|---------| | JS↔Native 桥 | iOS 9+ JSContext + JSExport(同步原生返回值) | WKUserScript 注入 + 纯 JS polyfill(同步本地返回) | | 数据传递 | JS 每次调用 selector → 进 ObjC runtime → 返回 | 启动期一次性 snapshot 注入 JS 全局,业务期纯 JS 查询 | | 渠道注入读取 | `[FuncPublic getFilePath:@"other" PathType:3]` 扫目录 | 直接读 `BundleConfig.shared.channelConfig` 字典 | | 性能 | 每次 H5 调用都有 JSContext 跨域开销(µs 级) | 业务期纯 JS 查询(ns 级) | | 跨容器一致性 | 大厅 / 子游戏 / 弹层各自暴露 selector,要同步维护 | 同一份 `SettingsBridgePolyfill` 多处复用,单点定义 | **注意事项**: - `compareCode` 业务语义需对照 msext `RootVC.m:1560` 附近 `getcompareCode` 真实返回逻辑(Phase 2 实施前查清);当前 Design 暂列字段,实现时补真值 - 验收:契约 §10 验收清单里所有"H5 同步取渠道值 / 设备状态"的项目(如 `window.settings.getchannelName() === channel注入值`)通过即视为契约等价 - 子游戏容器(SubGame WebContainer)也走同一份 polyfill,仅 `installedGames` 字段在子游戏内意义不同(一般不会再调 getGameinstall) #### 3.4.2 OpenurlTitleData handler — 大厅打开 threeView 弹层(契约 §3.1 [15]) `OpenurlTitleData` 不在弹层内部,而是**大厅** WVJB 桥的异步 handler,作用是**触发**一个新的 OverlayViewController(threeView 等价容器)push 到导航栈,让 H5 弹层页面在 OverlayViewController 内运行。OverlayViewController 内部的 H5 才用 §3.4 的 `window.settings.{backgameData/browser/finishweb}` polyfill 通讯。 ```swift // Source/Bridge/Handlers/OpenurlTitleDataHandler.swift @MainActor public struct OpenurlTitleDataHandler { let bridge: BridgeProtocol let coordinator: LobbyCoordinator // 负责 push OverlayViewController /// 节流状态:第一次调用后 3 秒内被吞掉(first_Time 标志) /// 用类持有可变状态而非 actor,因为本 handler 必须在 MainActor, /// 且 last open Date 只在主线程读写 private final class Throttle { var lastOpen: Date? } private let throttle = Throttle() public func register() { bridge.register("OpenurlTitleData", handler: handle) } // MARK: - 【15】 OpenurlTitleData // // 契约 §3.1 [15]: // 入参 url : string 待加载 URL(HTTP) // 入参 "title " : string ⚠️ 键名末尾有空格,沿用历史,必须 obj["title "] // 入参 data : string 业务数据,透传给 threeView 内的 H5 // 入参 orientation : int 0=竖屏 / 1=横屏(实际本项目固定横屏,参考即可) // responseCallback : "OpenurlTitleData" // 节流 : 第一次调用后 3 秒内重复调用被吞掉(first_Time 标志) private func handle(_ data: BridgeData?, _ cb: BridgeCallback?) async { defer { cb?(.string("OpenurlTitleData")) } // 1. 节流:3 秒内重复调用直接返回(cb 仍要回,否则 H5 会等) let now = Date() if let last = throttle.lastOpen, now.timeIntervalSince(last) < 3 { Log.info("OpenurlTitleData throttled (last open \(String(format: "%.2f", now.timeIntervalSince(last)))s ago)") return } throttle.lastOpen = now // 2. 解析入参(注意 "title " 末尾空格) guard let obj = data?.asObject, let urlStr = obj["url"]?.asString, let url = URL(string: urlStr) else { Log.warn("OpenurlTitleData: bad url payload") return } let title = obj["title "]?.asString ?? "" // ⚠️ "title " let payload = obj["data"]?.asString ?? "" let orientation = obj["orientation"]?.asInt ?? 1 // 默认横屏 // 3. 触发 push OverlayViewController let request = OverlayRequest( url: url, title: title, data: payload, orientation: orientation ) coordinator.pushOverlay(request) } } public struct OverlayRequest: Sendable { public let url: URL public let title: String public let data: String public let orientation: Int // 0=竖屏 / 1=横屏(沿用契约,本项目实际只用横屏) } ``` **OverlayViewController 接入**: ```swift extension LobbyCoordinator { public func pushOverlay(_ request: OverlayRequest) { let vc = OverlayViewController(request: request) navigationController?.pushViewController(vc, animated: true) } } @MainActor public final class OverlayViewController: UIViewController { private let request: OverlayRequest private let webView: WKWebView public init(request: OverlayRequest) { self.request = request // 配 WKWebView:注入 §3.4 window.settings polyfill(backgameData/browser/finishweb) let cfg = WKWebViewConfiguration() cfg.userContentController.addUserScript( WKUserScript(source: OverlayBridge.polyfill, injectionTime: .atDocumentStart, forMainFrameOnly: true) ) // 把 H5 业务 data 透传到 JS 全局(H5 弹层页代码读 window.app_data 获取) let escaped = request.data .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") cfg.userContentController.addUserScript( WKUserScript(source: "window.app_data = \"\(escaped)\";", injectionTime: .atDocumentStart, forMainFrameOnly: true) ) self.webView = WKWebView(frame: .zero, configuration: cfg) super.init(nibName: nil, bundle: nil) title = request.title.trimmingCharacters(in: .whitespaces) } // 略:webView 布局 + load(request) + WKScriptMessageHandler 注册 3 个 polyfill 消息 } ``` #### 3.4.3 与原 msext 的差异 | 维度 | msext 现状 | 新外壳决策 | |------|----------|----------| | 弹层 WebView | iOS 8 时代 `UIWebView` + JSExport `Bridgetwo` | `WKWebView` + `WKUserScript` 注入 polyfill(同步 settings.* 等价) | | `"title "` 末尾空格 | `[dict objectForKey:@"title "]` | `obj["title "]?.asString` 严格保持,注释说明 | | 节流 first_Time | `static BOOL first_Time` + `NSTimer` 3 秒后重置 | 闭包内 final class 持 `Date` lastOpen,更线程友好 | | H5 数据透传 | `[js evaluateScript:[NSString stringWithFormat:@"app_data='%@'", data]]` | `WKUserScript` atDocumentStart 注入,避免 race | | orientation 入参 | 入参 string 转 int | BridgeData.asInt 直接拿;本项目实际固定横屏 | ### 3.5 性能优化 - **ProcessPool 复用**:大厅、子游戏、弹层共用同一个 `WKProcessPool`,Cookie/Cache 共享,避免重复初始化(800 ms → 50 ms) - **JS 注入用 `WKUserScript`**:而不是 `evaluateJavaScript`,在 document load 之前就完成,避免 race - **桥消息批处理**:H5 端 message queue 累积一帧再 flush,减少 evaluateJavaScript 次数 - **结构化日志**:Bridge 全链路用 `os_log` 类型化,Release 自动去 `.debug` 级别 ### 3.6 WebView 进程崩溃恢复 iOS 系统在内存压力下会 kill `WKWebView` 的 Web Content Process,UI 上表现为**整个 WebView 变成白屏**,JS 全停,但 native 这边的 `WKWebView` 对象本身还在 — 系统不会自动回调 navigation delegate,如果不主动处理,用户看到的就是一个永久白屏的页面。 WebContainerViewController 必须实现 `webViewWebContentProcessDidTerminate:`: ```swift extension WebContainerViewController: WKNavigationDelegate { func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { Log.warn("WKWebView content process terminated, attempting reload") crashReporter.captureMessage("WebContentTerminated:\(self.containerRole)", level: .warning) let now = Date() recentTerminations.append(now) recentTerminations = recentTerminations.filter { now.timeIntervalSince($0) < 60 } if recentTerminations.count >= 3 { // 1 分钟内连续 3 次崩溃 → 不再自动 reload,弹错误页让用户主动重试 showFatalErrorView() return } // 退避:第 1 次立即,第 2 次 1s,第 3 次 3s let delay = TimeInterval(recentTerminations.count - 1) * (recentTerminations.count - 1) DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak webView] in webView?.reload() } } } ``` 要点: - **必须 reload**,不能依赖系统自动恢复(WebKit 故意不自动 reload,让 App 自己决定策略) - **崩溃次数退避**:连续多次崩溃通常意味着真实 bug 或低内存,无限 reload 会陷入循环。1 分钟 ≥ 3 次切到 fallback UI - **Sentry 上报**:每次崩溃都打 breadcrumb,持续高崩溃率应该触发告警 - **大厅 / 子游戏 / 弹层都受这条覆盖**,因为它们都继承自 WebContainerViewController --- ## 4. 启动流程重新设计 ### 4.1 整体时序(目标 < 1.5 s 进 H5) ``` SceneDelegate.scene(_:willConnectTo:options:) T=0 │ ├─► (并行) BundleConfig.preload() T=0 ← 渠道注入读取 │ ↓ 同步读 11 个目录名,纯内存,~5 ms │ ├─► (并行) SDK 注册 T=0 │ • WeChat ~10 ms │ • AMap (privacy + key) ~15 ms │ • crashReporter.start() ~20 ms(Sentry 默认启用) │ (JAnalytics 极光 / Xianliao 闲聊 / Agora 声网 / Bugly 当前未集成,见 §14.2) │ 并发 Task,await 全部完成 │ ├─► Window 创建 + LobbyViewController push T=50ms │ ▼ LobbyViewController.viewDidLoad │ ├─► (并行) ResourceUnzipper.ensureReady() T=50ms │ • 检查 Library/Caches/{gamedir}/{gamestart}/version.xml │ • 不存在 → 异步解压 gamehall.zip(后台队列) │ • 存在 → 直接通过 │ ├─► (并行) BridgedWebView 创建 + 注册 20 handler T=50ms │ • 同步,~30 ms │ ▼ ▼ await Both T=300ms (假设首次解压 250ms,缓存命中 < 50ms) │ ├─► JSInjector.writeAppData(...) T=300ms │ • app_data.js / app_battery.js / app_network.js │ ├─► webView.loadFileURL(...) T=320ms │ • file:// 加载,WebKit 内部 ~500 ms 到 DOMContentLoaded │ ▼ DOMContentLoaded → JS 桥握手完成 T < 1.5s ``` ### 4.2 关键代码 ```swift // App/SceneDelegate.swift final class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { guard let scene = scene as? UIWindowScene else { return } Task { @MainActor in await Bootstrapper.shared.run() // 并发 SDK 注册 let lobby = LobbyViewController() let nav = NavigationController(rootViewController: lobby) window = UIWindow(windowScene: scene) window?.rootViewController = nav window?.makeKeyAndVisible() } } } // App/Bootstrapper.swift @MainActor final class Bootstrapper { static let shared = Bootstrapper() private(set) var isReady = false func run() async { async let cfg: Void = BundleConfig.preload() async let sdks: Void = registerSDKs() _ = await (cfg, sdks) isReady = true } private func registerSDKs() async { await withTaskGroup(of: Void.self) { group in group.addTask { WeChatSDK.registerFull(appId: AppIDs.weChat) } // 含 contentFlag // JAnalytics(极光) / XianliaoSDK(闲聊) / Agora(声网) 暂不集成(§14.2) // — 对应模块用 Noop 占位,首版不参与启动注册 group.addTask { AMapWrapper.configure() } group.addTask { crashReporter.start() } // SentryCrashReporter by default; Noop if SENTRY_ENABLED=0 } } } // SDK/WeChat/WeChatSDK.swift public enum WeChatSDK { /// 完整注册:WXApi + 文件类型支持 /// (registerAppSupportContentFlag 必须包含 DOC/DOCX/PPT/PPTX/XLS/XLSX/PDF 等 /// 否则微信分享对应文件类型的回包识别失败 — 此为微信 SDK 契约) public static func registerFull(appId: String) { WXApi.registerApp(appId, enableMTA: true) let typeFlag: UInt64 = MMAPP_SUPPORT_TEXT | MMAPP_SUPPORT_PICTURE | MMAPP_SUPPORT_LOCATION | MMAPP_SUPPORT_VIDEO | MMAPP_SUPPORT_AUDIO | MMAPP_SUPPORT_WEBPAGE | MMAPP_SUPPORT_DOC | MMAPP_SUPPORT_DOCX | MMAPP_SUPPORT_PPT | MMAPP_SUPPORT_PPTX | MMAPP_SUPPORT_XLS | MMAPP_SUPPORT_XLSX | MMAPP_SUPPORT_PDF WXApi.registerAppSupportContentFlag(typeFlag) } } ``` > ⚠️ 漏 `registerAppSupportContentFlag` 会导致微信分享 PDF/DOCX/PPT/XLS 等业务文件回包识别失败 — 这是微信 SDK 的契约要求,任何 iOS App 接入分享都必须设置。 ### 4.2.1 启动期容易遗漏的契约点 以下几项是 **H5 / SDK 契约要求**,在 Bootstrapper 或 SceneDelegate 内必须显式完成。容易遗漏是因为它们看起来是"散落小事",但任一漏掉都会破坏 H5 业务: | 配置项 | 为什么必须 | 新项目落地 | |--------|----------|-----------| | `UIApplication.shared.applicationSupportsShakeToEdit = true` | 不设则 `motionEnded:withEvent:` 收不到摇一摇 → H5 调 `startshake` 后没有 `shakeEnd` 回调,业务断链 | SceneDelegate `sceneDidBecomeActive` 一次性设置 | | Info.plist `UIStatusBarHidden = NO` + `UIViewControllerBasedStatusBarAppearance = YES` | H5 设计稿基于显示状态栏的可视区域绘制,隐藏会导致布局错位 | Info.plist 直接配,各 VC 重写 `preferredStatusBarStyle` 返回 `.default` | | 写入 NSUserDefaults: `everLaunched` / `getcompareCode=0` / `FirstBOOL=set0` / `SecondBOOL=set0` / `ThirdBOOL=set0` / `VersionInfo=1.0` | 这些 key 通过 app_data.js 暴露给 H5,key 名与初值是 H5 契约 | KVStore 在首次启动时写入;key 名直接 hardcode,不允许重命名 | | 首次启动写 `Library/Caches/{gamedir}/{gamestart}/app_gamesname.js`,内容 `var app_gamesname=new Array('{gamestart}');` (注意 `var` 后两个空格) | H5 启动时静态 `` 等同步引入。新外壳如果不写这三个文件,H5 启动时变量未定义直接崩。 ### 20.6 NSUserDefaults Key 清单(契约 §10 持久化) | Key 名 | 类型 | 写入时机 | 说明 | |--------|------|---------|------| | `everLaunched` | Bool | 首次启动设 YES | 用于判断是否要执行首次初始化逻辑 | | `getcompareCode` | String | 首次启动设 "0",H5 业务过程会改写 | H5 通过 app_data.js 读 | | `FirstBOOL` | String | 首次启动设 "set0" | H5 业务标记 | | `SecondBOOL` | String | 首次启动设 "set0" | H5 业务标记 | | `ThirdBOOL` | String | 首次启动设 "set0" | H5 业务标记 | | `VersionInfo` | String | 每次启动设 "1.0" | H5 读取版本号 | **禁止重命名 key**,否则升级覆盖安装时 H5 业务状态全丢。 ### 20.7 首次启动写 `app_gamesname.js` ```swift // 仅 everLaunched=false 时执行一次 if !UserDefaults.standard.bool(forKey: DefaultsKey.everLaunched) { let dir = SandboxPaths.caches .appendingPathComponent(BundleConfig.shared.gameDir) .appendingPathComponent(BundleConfig.shared.gameStart) let js = "var app_gamesname=new Array('\(BundleConfig.shared.gameStart)');" try? js.write(to: dir.appendingPathComponent("app_gamesname.js"), atomically: true, encoding: .utf8) UserDefaults.standard.set(true, forKey: DefaultsKey.everLaunched) } ``` > 注意 `var` 后**两个空格**:H5 静态引用此文件,字符级必须一致(契约边界,从现网 H5 代码反推得到)。 --- 文档完成日期:2026-06-21