Files
youle_app_ios_v2/docs/H5-Native-Implementation-Design.md
T
joywayerandClaude Opus 4.7 8125dd4d11 1.11 修订:VersionResolver 改单链 4 层 fallback,删除双子树合并算法(契约影响)
【契约影响】
- RemoteConfig 模型:Agent 节点移除 channellist 字段(线上 JSON 实际只挂 gamelist;此前误读 chulishengji 引入的 channellist 是空 schema)
- VersionResolver 解析算法:从"双子树 + 字段级合并"改为"agentlist → gamelist → channellist → marketlist 单链 4 层 fallback",行为按 daoqi NewRootVC.m:689-810 主路径
- 对外 API ResolvedVersion / resolve(config:agentId:channelId:marketId:gameId:) 签名零变化,WebContainerViewController / SubGameViewController 调用点未动

【设计要点】
- 4 节点类型 conform 同一 private protocol RemoteConfigNode
- 5 字段共用 pickString / pickInt 两个倒序 fallback 工具,不允许为某字段单独写 if 链
- buildChain 集中处理"任一层 id 不匹配立刻截断"语义

【文档同步】
- Design §6.3.2 整段重写
- Plan ADR-008 顶部追加"第三轮修订(2026-06-27)"小节 + 第二轮误读复盘
- Plan ADR-008-D 替换为单链 4 层算法说明 + 保留修订史
- Plan §1.11 / ADR-008-I / 最后更新日期 / Verification-Checklist L73 同步

参考契约章节:docs/H5-Native-Implementation-Design.md §6.3.2、docs/Development-Plan.md ADR-008-D
BuildProject 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-27 20:04:19 +08:00

4426 lines
201 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
// 值错位沿用历史 msext WKWebView 路径:背景="2" / 前台="1"
bridge?.call("appservice", data: .string(state == .background ? "2" : "1"), 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.getXxx()` polyfill(无需实现)
> 本子节早期版本(commit `82bad8a`)曾设计 9 项同步 getter polyfill`getchannelName` / `getmarketname` / `getothername` / `getbattery` / `getnetwork` / `getcompareCode` / `getGameinstall` / `getGameplay` / `getOther`),把 §附录 A 旧桥 JSExport 方法重新实现为 `window.settings.getXxx()` JS 函数。**经原项目深度审查后撤销**,理由:
>
> 1. **契约自身已明示可不实现**Contract §3.3 标题"关于 iOS<9 旧桥的精确签名(**附录用,新外壳可不实现**)",§附录 A 同样声明"这是 iOS<9 路径使用的旧协议,新外壳如最低系统 ≥ iOS 14 可不实现"。本项目最低 iOS 15.6 → 不需要
> 2. **H5 端真实路径是 §4.2 的 `app_*.js` 文件预注入**H5 业务代码通过 `<script src="app_data.js">` 同步引入预生成的 15 个 `app_xxx` 全局变量直接读值,**不调** `window.settings.getXxx()`。详见 **§7.5 H5 `app_*.js` 预注入文件机制**
> 3. **过度设计**:原 polyfill 把数据快照塞进 `window.__nativeSnapshot` 全局对象 + 写一遍 getter 函数,实际工作量比直接写 `var app_xxx = ...` 文件大;同时引入了"两条路径都存在但 H5 只读其中一条"的混淆
>
> **保留的部分**:§3.4 头部的 `OverlayBridge.polyfill`3 项 `backgameData` / `browser` / `finishweb`)是**弹层 threeView 内**的契约硬约束(Contract §3.4 明示"与系统版本无关,iOS 9+ 也走这条"),与本子节撤销的旧桥 getter polyfill 不是同一回事,继续保留。
>
> Phase 2+ 实施时直接按 §7.5 落地 `app_*.js` 文件预写,本子节内容**不要照搬**。
#### 3.4.1.legacy 9 项 getter 的等价数据来源(仅供 §7.5 映射参考)
**背景**:契约 §附录 A 列出 28 项旧桥 JSExport 方法(iOS<9 路径),与 §3.1 异步 callback 主表去重后多出 **9 项 H5 同步只读 getter**
| getter | 返回类型 | 业务语义 | 数据源 |
|---|---|---|---|
| `getchannelName()` | string | 渠道 ID11 项渠道注入之一)| `BundleConfig.shared.channel` |
| `getmarketname()` | string | 市场 ID11 项渠道注入之一)| `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.01.0 | `UIDevice.current.batteryLevel`(启动期 snapshot 一次) |
| `getnetwork()` | int | 当前网络类型(0 无 / 1 WiFi / 2 蜂窝) | `NWPathMonitor` 当前 pathloadFileURL 前 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,作用是**触发**一个新的 OverlayViewControllerthreeView 等价容器)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 待加载 URLHTTP
// 入参 "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 polyfillbackgameData/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
### 3.7 WKUIDelegate alert / confirm(契约 §4.3
H5 用 `alert(msg)` / `confirm(msg)` 触发原生弹窗,WKWebView 默认行为是**直接吞掉不弹**——必须在容器 VC 上实现 `WKUIDelegate` 才能让 H5 业务的提示信息正常显示。这是契约 §4.3 的硬约束,**漏掉直接导致 H5 任何 alert/confirm 无响应**。
涉及 2 项接口(不在 §3.1 主表,单列):
1. `runJavaScriptAlertPanelWithMessage` — H5 调 `alert(msg)`
2. `runJavaScriptConfirmPanelWithMessage` — H5 调 `confirm(msg)` 期待返回 true/false
```swift
// Source/WebView/WebContainerViewController.swift(追加 WKUIDelegate 扩展)
extension WebContainerViewController: WKUIDelegate {
// MARK: - 【契约 §4.3】 H5 alert(msg)
//
// 标题:BundleConfig.shared.appDisplayNamemsext 用 gamehallname 常量)
// 按钮:单"确定"
// completionHandler 必须调一次(否则 WKWebView 永久阻塞同一帧的 JS 执行)
public func webView(_ webView: WKWebView,
runJavaScriptAlertPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping () -> Void) {
let alert = UIAlertController(
title: appDisplayName,
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
completionHandler()
})
present(alert, animated: true)
}
// MARK: - 【契约 §4.3】 H5 confirm(msg)
//
// 标题:同 alert
// 按钮:确定 / 取消,分别 completionHandler(true) / completionHandler(false)
// completionHandler 必须以 bool 调一次
public func webView(_ webView: WKWebView,
runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping (Bool) -> Void) {
let alert = UIAlertController(
title: appDisplayName,
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { _ in
completionHandler(false)
})
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
completionHandler(true)
})
present(alert, animated: true)
}
}
```
接入:`bridgedWebView.webView.uiDelegate = self``viewDidLoad``navigationDelegate` 一起设置。
#### 3.7.1 与原 msext 的差异
| 维度 | msext 现状 | 新外壳决策 |
|------|----------|---------|
| 标题文案 | `gamehallname` PCH 常量 | `appDisplayName`CFBundleDisplayName / CFBundleName,自包含) |
| confirm 按钮顺序 | "取消" 在左 / "确定" 在右 | 严格保持(iOS UIAlertController 默认 cancel 在左 + default 在右) |
| 弹窗样式 | UIAlertController + .alert | 同款 |
| 多帧并发 | msext 偶发"alert 串"叠加,体验略乱 | 当前同时只允许一个 alert(present 队列);如未来 H5 业务有真并发,再加 pendingAlertQueue |
> ⚠️ 不实现这两个方法的代价:H5 业务里 `alert("登录失败")` 之类的提示**用户完全看不见**,业务流程哑火无可视化报错。是 Phase 1.17 真机联调时最容易暴露的缺漏点,因此 §10 验收清单单列。
---
## 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 启动时静态 `<script>` 引入,缺则全局变量未定义 → H5 黑屏 | ResourceUnzipper 解压完成后,检测 `everLaunched=NO` 即生成此文件 |
### 4.3 URL Scheme 回调
iOS 13+ 用 `SceneDelegate`,处理顺序保持 QQ → 微信(契约 §1.2 要求,QQ 先判否则被 WXApi 吞):
```swift
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for ctx in URLContexts {
if QQShareSDK.handle(url: ctx.url) { continue }
WeChatSDK.handle(url: ctx.url)
}
}
```
### 4.4 前后台通知命名变更
`AppDelegate` 用了**反人类命名**:
- 通知名 `enterForeground` ⇒ 实际触发于"进入后台"(`applicationDidEnterBackground`)
- 通知名 `applicationWillResignActive` ⇒ 实际触发于"回到前台"(`applicationDidBecomeActive`,且节流 0.3s)
新外壳**采用正确命名**(NSNotification 是模块内通讯,不是契约边界,可以自由命名),但**桥事件不变**:
```swift
extension Notification.Name {
static let appDidEnterBackground = Notification.Name("Daoqi.appDidEnterBackground")
static let appDidBecomeActive = Notification.Name("Daoqi.appDidBecomeActive")
}
// SceneDelegate
func sceneDidEnterBackground(_ scene: UIScene) {
NotificationCenter.default.post(name: .appDidEnterBackground, object: nil)
}
func sceneDidBecomeActive(_ scene: UIScene) {
NotificationCenter.default.post(name: .appDidBecomeActive, object: nil)
}
// LobbyHandlers / SubGameHandlers 内监听并转桥
// 注:契约值与 NSNotification 字面语义相反,沿用 msext WKWebView 路径历史。
// 真·进入后台 → "2";真·回到前台 → "1"。详见 Contract.md §3.2 `appservice` 值错位说明。
notifications.observe(.appDidEnterBackground) { [bridge] _ in
bridge.call("appservice", data: .string("2"), callback: nil) // 契约:进入后台="2"
}
notifications.observe(.appDidBecomeActive) { [bridge] _ in
bridge.call("appservice", data: .string("1"), callback: nil) // 契约:回到前台="1"
}
```
**0.3s 节流决策**:旧外壳在 `applicationDidBecomeActive` 里设了 `flag=NO` + 0.3s 后恢复,防止双重触发。新外壳**默认不要这个节流**,理由:
1. 节流的根因是旧代码在 `applicationDidBecomeActive` 内被多处触发,新外壳只有 SceneDelegate 一处发通知
2. 测试如果证实双触发,加节流是 1 行代码的事(`DispatchQueue.asyncAfter`),不是架构改动
3. H5 端通常对重复 `appservice("1")`(回前台)是幂等的(就是刷新 UI 状态)
如真机回归发现 H5 双触发产生问题,在 LobbyHandlers 内加 throttle 即可,不污染 SceneDelegate。
### 4.5 子游戏 → 大厅数据回传通知
旧外壳:子游戏 `backgameData` handler 发 `NSNotification "backgameDatatwo"`,大厅监听后转 `bridge.call("getWebdata", data)`
新外壳同样是模块内通讯,**可以自由命名**。推荐用专属 `Notification.Name`:
```swift
extension Notification.Name {
static let subGameDidReturn = Notification.Name("Daoqi.subGameDidReturn")
}
// SubGameHandlers 内
private func backgameData(_ data: BridgeData?, _ cb: BridgeCallback?) async {
defer { cb?(.string("backgameData")) }
audio.stopBackground()
videoRoom.leave()
NotificationCenter.default.post(name: .subGameDidReturn,
object: data?.asString ?? "")
coordinator.popSubGame()
}
// LobbyHandlers 内
notifications.observe(.subGameDidReturn) { [bridge] note in
let payload = note.object as? String ?? ""
bridge.call("getWebdata", data: .string(payload), callback: nil)
}
```
→ 新通知名只在 `Daoqi.*` 内部模块间使用,与契约的 `backgameDatatwo` 字符串无关。但**测试用例必须验证**:子游戏调 `backgameData` → 大厅 H5 收到 `getWebdata`
---
## 5. 桥 Handler 注册组织
### 5.1 一个 handler 一个文件
避免上千行的 VC,每个 handler 拆成纯函数:
```swift
// BridgeHandlers/Lobby/LobbyHandlers.swift
struct LobbyHandlers {
let bridge: BridgeProtocol
let audio: AudioKit
let share: ShareCenter
let login: WeChatAuth
let location: LocationService
let device: DeviceKit
let coordinator: LobbyCoordinator
func register() {
bridge.register("accreditlogin", handler: accreditLogin)
bridge.register("srcIsloop", handler: srcIsLoop)
bridge.register("prepareaudio", handler: prepareAudio)
bridge.register("mediaTypeAudio",handler: mediaTypeAudio)
bridge.register("startshake", handler: startShake)
bridge.register("stopshake", handler: stopShake)
// ... 20 个全列出
}
private func accreditLogin(_ data: BridgeData?, _ cb: BridgeCallback?) async {
defer { cb?(.string("Response from accreditlogin")) }
do {
let user = try await login.authorize()
bridge.call("sharelogin", data: .object([
"openid": .string(user.openid),
"headimgurl": .string(user.headimgurl),
"nickname": .string(user.nickname),
"sex": .string(String(user.sex)),
"city": .string(user.city),
"Province": .string(user.province), // ⚠️ 大写 P,契约要求
"unionid": .string(user.unionid)
]), callback: nil)
} catch {
Log.error("WeChat auth failed: \(error)")
}
}
// ...
}
```
### 5.2 子游戏额外 handler
```swift
// BridgeHandlers/SubGame/SubGameHandlers.swift
struct SubGameHandlers {
let lobbyShared: LobbyHandlers // 共享 19 个
let videoRoom: VideoRoom // 当前注入 NoopVideoRoom(见 §8.3)
let bridge: BridgeProtocol
let callCenter: CallCenterMonitor
let recordUploader: RecordUploader
func register() {
lobbyShared.registerCommon() // 19 个共享(SwitchOverGameData 除外)
bridge.register("backgameData", handler: backgameData)
// 视频房间 3 个 handler 当前业务暂未使用,stub 实现保契约
bridge.register("createRoom", handler: createRoomStub)
bridge.register("getVideoinfo", handler: getVideoInfoStub)
bridge.register("exitRoom", handler: exitRoomStub)
// SwitchOverGameData 不注册:子游戏内不再跳子游戏
bindSubGameOnlyCallbacks()
}
// MARK: - 视频房间 stub(契约要求注册,业务暂未启用)
private func createRoomStub(_ data: BridgeData?, _ cb: BridgeCallback?) async {
Log.info("createRoom called (stub, video room not enabled)")
cb?(.string("createRoom"))
// 故意不做任何业务;真实现见 §8.3 AgoraVideoRoom,启用时把构造器注入换掉即可
}
private func getVideoInfoStub(_ data: BridgeData?, _ cb: BridgeCallback?) async {
Log.info("getVideoinfo called (stub)")
cb?(.string("getVideoinfo"))
}
private func exitRoomStub(_ data: BridgeData?, _ cb: BridgeCallback?) async {
Log.info("exitRoom called (stub)")
cb?(.string("exitRoom"))
}
/// 子游戏独有的 3 个反向 callback(契约 §3.2 表 [13]/[14]/[15])
/// 这是大厅页**没有**的事件源,必须在 SubGame 容器内独立挂钩
private func bindSubGameOnlyCallbacks() {
// 1) Native→H5 getVideoinfo:远端用户首帧解码后
// NoopVideoRoom 永远不会触发此回调,业务启用后自动生效,H5 无感
videoRoom.didReceiveRemoteVideo = { [weak bridge] uid in
bridge?.call("getVideoinfo", data: .string(String(uid)), callback: nil)
}
// 2) Native→H5 phonestate:CTCallCenter 状态变化(独立于视频房间,正常启用)
callCenter.onStateChange = { [weak bridge] state in
bridge?.call("phonestate", data: .string(state == .incoming ? "2" : "0"),
callback: nil)
}
// 3) Native→H5 recordSuccess:七牛上传成功(独立于视频房间,正常启用)
recordUploader.onUploaded = { [weak bridge] file in
bridge?.call("getaudiourl", data: .object([
"audiourl": .string(file.publicUrl),
"time": .string(String(file.durationSeconds))
]), callback: nil)
bridge?.call("recordSuccess", data: .object([
"fileUrl": .string(file.publicUrl),
"fileName": .string(file.fileName),
"fileKey": .string(file.qiniuKey)
]), callback: nil)
}
}
}
```
> ⚠️ 大厅页(LobbyHandlers)**不应**注册 phonestate / recordSuccess / getVideoinfo 这 3 个 callback,否则 H5 在大厅就收到会触发业务分支错误。**只有进入 SubGame 容器才能挂钩**。
>
> ✅ 视频房间业务启用时,只需把 `videoRoom` 依赖从 `NoopVideoRoom` 换为 `AgoraVideoRoom`(§8.3),并把 3 个 stub handler 换成真实现 — H5 端无感,契约不变。
### 5.3 反向 callback 全列在 §3.2 文档
```swift
// 反向通知 H5 的辅助 facade
extension BridgeProtocol {
func callShareSuccess(type: String) {
call("sharesuccess", data: .object([
"success": .string("2"),
"type": .string(type)
]), callback: nil)
}
func callAppService(_ state: AppState) {
// 值错位沿用历史 msext WKWebView 路径:背景="2" / 前台="1"
call("appservice", data: .string(state == .background ? "2" : "1"), callback: nil)
}
// ... 15 个全列出
}
```
---
## 6. 网络层
### 6.1 现代 HTTPClient
```swift
// NetworkKit/HTTPClient.swift
public actor HTTPClient {
private let session: URLSession
public init(config: URLSessionConfiguration = .default) {
config.timeoutIntervalForRequest = 20
config.urlCache = URLCache(memoryCapacity: 8 * 1024 * 1024,
diskCapacity: 64 * 1024 * 1024)
self.session = URLSession(configuration: config)
}
public func send<T: Decodable>(_ endpoint: Endpoint<T>) async throws -> T {
var req = URLRequest(url: endpoint.url)
req.httpMethod = endpoint.method.rawValue
req.allHTTPHeaderFields = endpoint.headers
req.httpBody = endpoint.body
let (data, resp) = try await session.data(for: req)
guard let http = resp as? HTTPURLResponse, http.isSuccess else {
throw NetworkError.badStatus
}
return try endpoint.decoder.decode(T.self, from: data)
}
public func download(_ url: URL, to dest: URL,
progress: ((Double) -> Void)? = nil) async throws {
// ... 下载 zip 用,带进度
}
}
```
### 6.2 GameAPIClient(业务接口客户端)
业务接口客户端按 Swift 现代设计,使用 actor 隔离 + Endpoint 协议化 + Codable 解码。
#### 服务端契约:签名算法
服务端在校验请求签名,因此**签名算法与字段顺序是网络对外契约**,必须按现网约定实现:
- 算法:`MD5(按业务字段固定顺序拼接的 value 字符串 + salt)`
- salt:`"WERTY#$&(HJKfghjWERTYUIFGJFGHadf2222"`(项目方协调更换需先与服务端对齐)
- 字段顺序:**按业务接口约定**(每个接口有自己的字段序列),`code` 字段不参与签名,签名结果写回 `code`
Swift `Dictionary` 是无序的,直接遍历 `values` 会产生不稳定的签名 → 用有序结构 `OrderedParams` 显式持有字段顺序。
#### 全新设计:OrderedParams + GameAPIClient
```swift
// NetworkKit/OrderedParams.swift
public struct OrderedParams: Sendable, ExpressibleByDictionaryLiteral {
public private(set) var items: [(key: String, value: String)] = []
public init(dictionaryLiteral elements: (String, String)...) {
items = elements
}
public mutating func set(_ key: String, _ value: String) {
if let i = items.firstIndex(where: { $0.key == key }) {
items[i] = (key, value)
} else {
items.append((key, value))
}
}
}
// NetworkKit/GameAPIClient.swift
public actor GameAPIClient {
public static let shared = GameAPIClient()
private let client: HTTPClient
private static let salt = "WERTY#$&(HJKfghjWERTYUIFGJFGHadf2222"
public init(client: HTTPClient = .init()) { self.client = client }
public func send<Response: Decodable>(
_ params: OrderedParams,
to path: String,
method: APIMethod = .post,
as type: Response.Type = Response.self
) async throws -> Response {
var p = params
if p.items.contains(where: { $0.key == "code" }) {
p.set("code", Self.sign(p))
}
let baseURL: URL = (method == .postTwo) ? Endpoints.serverTwo : Endpoints.server
let endpoint = Endpoint<Response>(
url: baseURL.appendingPathComponent(path),
method: method.httpMethod,
body: URLFormEncoder.encode(p),
headers: ["Content-Type": "application/x-www-form-urlencoded"])
return try await client.send(endpoint)
}
/// 服务端契约:按 items 顺序拼 value,追加 salt,MD5
static func sign(_ params: OrderedParams) -> String {
let payload = params.items
.filter { $0.key != "code" }
.map { $0.value }
.joined()
return (payload + salt).md5
}
}
// 业务接口定义:每个接口的字段顺序写在业务层
public enum BizAPI {
public static func getGroupPic(userId: String) async throws -> GroupPicResponse {
let p: OrderedParams = [
("t", "get_group_pic"),
("i", userId),
("code", "")
]
return try await GameAPIClient.shared.send(p, to: "")
}
}
```
#### 契约测试
```swift
// Tests/Contract/SigningContractTests.swift
final class SigningContractTests: XCTestCase {
func test_sign_isStableAcrossRuns() {
let p: OrderedParams = [("t","x"), ("i","100"), ("code","")]
XCTAssertEqual(GameAPIClient.sign(p), GameAPIClient.sign(p)) // 同输入同输出
}
func test_sign_matchesServerSpec() {
// 固定 fixture,与服务端工程师对齐过的 (input → expected hash)
let p: OrderedParams = [("t","get_group_pic"), ("i","100"), ("code","")]
XCTAssertEqual(GameAPIClient.sign(p), "<expected MD5>")
}
}
```
### 6.3 远程配置 + 版本对比 + zip 升级流水线(**Phase 1 实施**ADR-008 详细记录)
完整链路由 4 个独立模块构成,全部 actor 隔离 + 单测覆盖:
```
RemoteConfigClient → VersionResolver → LocalVersionReader → LobbyZipUpgrader
(拉 .txt 配置) (4 级覆盖合并) (读 version.xml) (URLSession 下载 + 原子 rename)
↓ ↓ ↓ ↓
RemoteConfig ResolvedVersion (appVer, gameVer) UpgradeOutcome
{appVer, appDL, .noop / .upgraded
gameVer, gameZip}
```
#### 6.3.1 RemoteConfigClient
```swift
// Source/Network/RemoteConfigClient.swift
public actor RemoteConfigClient {
private let session: URLSession
private let urlBuilder: () -> URL
/// 默认根据 BundleConfig.shared.gameConfig 拼装:
/// http:// + gameConfig.replacingOccurrences("-", "/") + ".txt"
/// 注意:原 msext 没有 SERVERNew 前缀(NewRootVC.m:250),照搬。
public init(
session: URLSession = .shared,
urlBuilder: @escaping () -> URL = Self.defaultURL
) { ... }
/// 拉远端 .txt(实际是 JSON),10s 超时。
/// 指数退避 1/2/4 秒最多 3 次(msext 用 4s 等间隔 timer,新外壳更省电)。
public func fetch() async throws -> RemoteConfig {
for attempt in 0..<3 {
do { return try await fetchOnce() }
catch where attempt < 2 {
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000)
}
}
throw RemoteConfigError.allRetriesFailed
}
}
public struct RemoteConfig: Codable, Sendable {
public let showmessage: String?
public let agentlist: [Agent]?
}
public struct Agent: Codable, Sendable {
public let agentid: String
public let showmessage: String?
public let app_version: String?
public let app_download: String?
public let game_version: String?
public let game_zip: String?
public let channellist: [Channel]?
public let gamelist: [Game]?
}
public struct Channel: Codable, Sendable { ... }
public struct Game: Codable, Sendable { ... }
public struct Market: Codable, Sendable { ... }
```
#### 6.3.2 VersionResolver(单链 4 层 fallback,纯函数)
> **修订史**2026-06-22 初版误指为 chulishengji 双子树合并;2026-06-27 按项目维护者澄清推翻,改为本节描述的单链 4 层 fallback。复盘见 Plan ADR-008 顶部"第二轮误读复盘"。
**契约结构**(参 daoqi `NewRootVC.m:689-810` 主路径):
```
agentlist[agentid]
└─ gamelist[gameid]
└─ channellist[channelid]
└─ marketlist[marketid]
```
每层节点都可独立携带 `app_version / app_download / game_version / game_zip / showmessage`;越深层覆盖越浅层;越深层缺省时回退上一层。算法上等价于"从最深层向根回退取第一个非空"。
**统一接口(关键设计点)**:4 个节点类型 conform 同一 `RemoteConfigNode` 协议,5 个字段全部走 `pickString` / `pickInt` 两个倒序查找工具 —— **不允许为某个字段单独写 if 链**
```swift
// Source/Network/VersionResolver.swift
private protocol RemoteConfigNode {
var appVersion: String? { get }
var appDownload: String? { get }
var gameVersion: String? { get }
var gameZip: String? { get }
var showmessage: String? { get }
}
extension Agent: RemoteConfigNode {}
extension Game: RemoteConfigNode {}
extension Channel: RemoteConfigNode {}
extension Market: RemoteConfigNode {}
public struct ResolvedVersion: Sendable, Equatable {
public let appVersion: Int // 0 表示远端未指定
public let appDownload: String?
public let gameVersion: Int // 0 表示远端未指定
public let gameZip: String?
public let showmessage: String? // 非空时阻塞,运营杀手锏
}
public enum VersionResolver {
public static func resolve(
config: RemoteConfig,
agentId: String,
channelId: String,
marketId: String,
gameId: String
) -> ResolvedVersion {
let chain = buildChain(
config: config,
agentId: agentId, gameId: gameId,
channelId: channelId, marketId: marketId
)
return ResolvedVersion(
appVersion: pickInt(chain, \.appVersion),
appDownload: pickString(chain, \.appDownload),
gameVersion: pickInt(chain, \.gameVersion),
gameZip: pickString(chain, \.gameZip),
showmessage: pickString(chain, \.showmessage) ?? config.showmessage
)
}
/// 顺 agent → game → channel → market 匹配,任一层失败立刻截断。
/// 返回 0..4 个有序节点(从根到叶)。
private static func buildChain(
config: RemoteConfig,
agentId: String, gameId: String,
channelId: String, marketId: String
) -> [RemoteConfigNode] {
var chain: [RemoteConfigNode] = []
guard let agent = config.agentlist?.first(where: { $0.agentid == agentId })
else { return chain }
chain.append(agent)
guard let game = agent.gamelist?.first(where: { $0.gameid == gameId })
else { return chain }
chain.append(game)
guard let channel = game.channellist?.first(where: { $0.channelid == channelId })
else { return chain }
chain.append(channel)
guard let market = channel.marketlist?.first(where: { $0.marketid == marketId })
else { return chain }
chain.append(market)
return chain
}
/// 倒序找第一个非空字符串
private static func pickString(
_ chain: [RemoteConfigNode],
_ keyPath: KeyPath<RemoteConfigNode, String?>
) -> String? {
for node in chain.reversed() {
if let s = node[keyPath: keyPath], !s.isEmpty { return s }
}
return nil
}
/// 倒序找第一个能解析为非零整数的字段值
/// JSON 里该字段可能是 string / int / doubleCodable 已经 decodeFlexibleStringIfPresent
/// 归一化为 String?;0 视同未提供继续向上找。
private static func pickInt(
_ chain: [RemoteConfigNode],
_ keyPath: KeyPath<RemoteConfigNode, String?>
) -> Int {
for node in chain.reversed() {
if let s = node[keyPath: keyPath], let v = Int(s), v != 0 { return v }
}
return 0
}
}
```
**关键测试用例**(单测必须覆盖):
| 用例 | 验证 |
|------|------|
| 仅 agent 层有 app_version,其余空 | 取 agent |
| agent / game / channel / market 都有 app_version | 取 market(最深层赢) |
| agent / game / market 有,channel 缺 | 取 market(最深层赢) |
| channelid 不匹配 | chain 截断 = [agent, game];取 game |
| gameid 不匹配 | chain = [agent];取 agent |
| agentid 不匹配 | chain = [];返回全 0/nil + 顶层 showmessage |
| showmessage 仅顶层有 | fallback 到 `config.showmessage` |
| showmessage 仅 market 有 | 取 market(最深层赢) |
| showmessage market 与 agent 都有 | 取 market |
| 字段值 = 0 / "" / nil | 视同未提供,继续向上找 |
| JSON 把 app_version 发成 number 而非 string | `decodeFlexibleStringIfPresent` 兜底为 String`Int(s)` 解析正常 |
#### 6.3.3 LocalVersionReader
```swift
// Source/Resource/LocalVersionReader.swift
public enum LocalVersionReader {
/// 原生 IPA 版本,从 ChannelConfig.plist 的 appversion 字段(BundleConfig)。
nonisolated public static var localAppVersion: Int {
Int(BundleConfig.shared.appVersion) ?? 0
}
/// 大厅 H5 包版本:从 Library/Caches/{gamedir}/{gamestart}/version.xml 解析
/// /game/version 节点的 value 属性。XMLParser 解析,缺失 / 损坏返回 0。
nonisolated public static var localGameVersion: Int { ... }
}
```
#### 6.3.4 LobbyZipUpgrader
```swift
// Source/Resource/LobbyZipUpgrader.swift
public actor LobbyZipUpgrader {
public enum UpgradeOutcome {
case noop // 远端版本 ≤ 本地,无需升级
case upgraded(from: Int, to: Int)
}
public func upgradeIfNeeded(
remoteGameVersion: Int,
remoteGameZip: String?
) async throws -> UpgradeOutcome {
let local = LocalVersionReader.localGameVersion
guard remoteGameVersion > local, let zipURL = remoteGameZip.flatMap(URL.init) else {
return .noop
}
// 1. 下载到 tmp(断点续传由 URLSessionDownloadTask 默认支持)
let (tmpZip, _) = try await session.download(from: zipURL)
// 2. 解压到隔离目录
let stagingDir = SandboxPaths.caches.appendingPathComponent("staging-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true)
try FileManager.default.unzipItem(at: tmpZip, to: stagingDir)
// 3. 原子 rename:清旧 lobbyRoot,把 stagingDir 移到位
let lobbyRoot = SandboxPaths.lobbyRoot
try? FileManager.default.removeItem(at: lobbyRoot)
try FileManager.default.moveItem(at: stagingDir, to: lobbyRoot)
// 4. 清 tmp
try? FileManager.default.removeItem(at: tmpZip)
return .upgraded(from: local, to: remoteGameVersion)
}
}
```
#### 6.3.5 WebContainer 调用串 + Splash UI 状态机(Phase 1.14
WebContainerViewController 同时承担两层:
- **下层**BridgedWebView 嵌入 + 16:9 letterbox(屏幕比 < 16:9 上下黑边;> 16:9 左右黑边)
- **上层(Splash 覆盖)**:与 LaunchScreen.storyboard 同款启动图(横屏铺满)+ UIProgressView + 状态文字标签
- 启动瞬间:LaunchScreen 静态图(iOS 系统级,0 延迟)
- viewDidLoadWebContainer 把自己的 Splash 覆盖在 BridgedWebView 之上(两者 alpha 0/1 由状态切换)
- 启动流水线推进时:updateSplash(text: ..., progress: ...) 更新文字 + 进度条
- WKWebView didFinish 后:UIView.animate(duration: 0.3) splash.alpha = 0 → removeFromSuperview
```swift
// Source/WebView/WebContainerViewController.swiftPhase 1.14
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
setupBridgedWebView() // 16:9 letterbox 嵌入
setupSplashOverlay() // 启动图 + 进度条 + 文字标签
bridgedWebView.webView.navigationDelegate = self
Task { @MainActor in
await runBootPipeline()
}
}
private func runBootPipeline() async {
do {
updateSplash(text: "拼命启动中...", progress: nil)
try await ResourceUnzipper.shared.ensureReady()
updateSplash(text: "拉取配置中...", progress: nil)
let outcome = try await RemoteConfigClient.shared.fetch()
switch outcome {
case .shortText(let msg):
// 运营杀手锏 #1:弹 alertApp 永停(msext NewRootVC.m:1239-1243 契约)
showBlockingAlert(msg)
return
case .parsed(let cfg):
let bc = BundleConfig.shared
let resolved = VersionResolver.resolve(
config: cfg,
agentId: bc.agent,
channelId: bc.channel,
marketId: bc.market,
gameId: bc.gameId
)
// 运营杀手锏 #2showmessage 非空 → 弹 alert + 完全阻塞
if let msg = resolved.showmessage, !msg.isEmpty {
showBlockingAlert(msg)
return
}
// IPA 升级:弹窗 + Safari 外链(msext NewRootVC.m:1510-1520
if resolved.appVersion > LocalVersionReader.localAppVersion,
let dl = resolved.appDownload {
showIPAUpgradeAlert(dl)
return
}
// H5 zip 升级:进度条实时更新
_ = try await LobbyZipUpgrader.shared.upgradeIfNeeded(
resolved: resolved,
onProgress: { @MainActor [weak self] p in
self?.updateSplash(
text: String(format: "下载更新中 %d%%", Int(p * 100)),
progress: p
)
}
)
}
updateSplash(text: "加载大厅...", progress: nil)
bridgedWebView.webView.loadFileURL(
SandboxPaths.lobbyIndex,
allowingReadAccessTo: SandboxPaths.lobbyRoot
)
// didFinish 回调里淡出 splash(见 WKNavigationDelegate 扩展)
} catch {
showFatalAlert(error)
}
}
// MARK: - WKNavigationDelegate
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
UIView.animate(withDuration: 0.3, animations: { [weak self] in
self?.splash.alpha = 0
}, completion: { [weak self] _ in
self?.splash.removeFromSuperview()
})
}
```
**Splash UI 状态切换表**
| 阶段 | 文字 | progress | 持续时长(典型) |
|------|------|----------|---------------|
| viewDidLoad 起 | "拼命启动中..." | 隐藏 | ~50 ms |
| 首装解压 zip 中 | "拼命启动中..." | 隐藏 | ~50-500 ms |
| 拉远端配置中 | "拉取配置中..." | 隐藏 | ~500-2000 ms |
| H5 zip 下载中 | "下载更新中 XX%" | 0.0-1.0 | ~1000-3000 ms |
| WebView 加载 H5 中 | "加载大厅..." | 隐藏 | ~500-1000 ms |
| WebView didFinish | (淡出消失)| - | 300 ms 动画 |
**所有失败 / 阻塞终态都用 UIAlertController(非 splash 文字)**
- 短文本响应:弹 alert,标题 `gamehallname + 提醒`,单按钮"确定",无 tap 处理(永停)
- showmessage 非空:同上
- IPA 升级:弹 alert,单按钮"确定"→ openURL(appDownload),永停
- 网络错误 / 解析失败:弹 alert,单按钮"重试",点击重新 runBootPipeline()
- zip 下载失败:弹 alert,单按钮"重试" + 单按钮"使用旧版本"(回退用 IPA 内 Bundle zip
```
#### 6.3.6 与 msext 历史实现的差异(不要照抄的部分)
| 维度 | msext 现状(不要照抄) | 新外壳决策 |
|------|--------------------|----------|
| 网络模型 | `[NSData dataWithContentsOfURL:]` 主线程同步阻塞 + ASIHTTPRequest 后台下载 | URLSession `async data(from:)` / `download(from:)`actor 隔离 |
| 失败重试 | `viewDidLoad` 起 4s 等间隔 `timer`,至无穷直到成功 | 指数退避 1/2/4 秒最多 3 次,超出 throw 由 UI 决定回退路径 |
| 4 级覆盖 | `onnet` 单链 4 层 if/嵌 for`NewRootVC.m:689-810` | 纯函数 `VersionResolver.resolve`,协议化 + 5 字段共用 `pickString` / `pickInt` 倒序 fallback,单测覆盖所有边界 |
| 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`,半途崩溃留半残 | 解压到 `staging-{uuid}/` 临时目录 + 原子 `moveItem` rename,半途崩溃只留 tmp(下次启动可清) |
| 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11` | 同款原子 rename,旧目录直接覆盖 |
| 配置解析 | SBJSON 三方库 | Codable + JSONDecoder |
| 版本号 | NSString → intValue(自动 0 | 强类型 Int,缺失明示 |
#### 6.3.7 子游戏升级复用
Phase 6 子游戏(`SwitchOverGameData` 入参的 `Gamedirectory` / `gamedownloadurl`)的升级逻辑**复用** `LobbyZipUpgrader` 的设计,差异仅在:
- 目标路径不同(`SandboxPaths.subGameRoot(dir)` 而非 `lobbyRoot`
- 版本号 source 不同(子游戏 `version.xml` 而非大厅)
- 不重新拉远端配置(继承大厅的 `RemoteConfig`
具体接口扩展到 Phase 6 设计时定。
---
## 7. 资源 & 渠道注入
### 7.0 项目资源目录约定
项目资源目录的最终结构**由各 Phase 实施时按需定义**,本节列出固定规则,具体目录形态在落地时决定。
#### 7.0.1 仓库内素材池 `docs/res/` 与项目无关
`docs/res/` 是项目维护者的**私人原始素材池**,**与项目架构无关**(详见 `CLAUDE.md` 「docs/res/ 与项目资源的关系」):
- 项目代码 / 构建脚本 / Xcode 工程都**不感知**它的存在,不引用任何 `docs/res/xxx` 路径
- 它的目录结构 / 命名 / 内容可由维护者任意调整,不影响构建
- git 跟踪只是为了多机同步素材,而非项目需要
需要某素材时,**从 `docs/res/` 拷一份**到项目内规划好的位置,后续维护与 `docs/res/` 不再有任何关联。
#### 7.0.2 项目内资源目录(Phase 实施时落地)
按现代 iOS 单 target + synchronized group 实践,推荐落点如下,**但具体目录在该 Phase 真正需要时再创建**(避免预先空目录):
| 落点 | 用途 | 入 Bundle 方式 |
|------|------|--------------|
| `ylgamehall/Resources/` | 静态打入 Bundle 的项目资源(`gamehall.zip` / `WebViewJavascriptBridge.js` / 本地音效 mp3 / `ChannelConfig.plist` 等) | synchronized group 自动收集 |
| `ylgamehall/Assets.xcassets/` | 原生 Asset Catalog(AppIcon / LaunchImage / 分享平台图标) | Xcode 默认 |
| `Vendor/<SDK>/<SDK>.xcframework` | 闭源 SDK 二进制(微信 / QQ / 高德 / opencore-amr) | Target Build Phase「Frameworks」手动加入 |
#### 7.0.3 渠道注入:ChannelConfig.plist 母包模式
**契约 §0.3 描述 msext 用"空目录名注入"机制存储 11 个渠道值,本项目按 ADR-007 改用 `ChannelConfig.plist` 等价实现** —— 契约边界(H5 通过 `app_data.js` 看到的 11 个 JS 全局变量)完全不变,实现内部更简洁。
**存储**:`ylgamehall/Resources/ChannelConfig.plist`,11 个 string key 一一对应渠道值:
```xml
<dict>
<key>qiniudomain</key> <string>iosaudio.daoqi88.cn</string>
<key>gameid</key> <string>G2hw0u...</string>
<key>channel</key> <string>FtJf07...</string>
<key>gamedir</key> <string>FtJf07...</string>
<key>gamestart</key> <string>gamehall</string>
<key>gameconfig</key> <string>tsgames.daoqi88.cn-config_test-update_jsonv2_test</string>
<key>market</key> <string>2</string>
<key>agent</key> <string>veRa0qrBf0df2K1G4de2tgfmVxB2jxpv</string>
<key>appversion</key> <string>43</string>
<key>other</key> <string></string>
<key>appleconfig</key> <string></string>
</dict>
```
**运行时读取路径**:`Bundle.main.bundleURL.appendingPathComponent("ChannelConfig.plist")`,由 `BundleConfig.swift`(§7.2)用 `PropertyListSerialization` 反序列化。
**多渠道分发**(IPA 后处理,不重新 Xcode build):
```bash
unzip ylgamehall.ipa -d tmp/
APP="tmp/Payload/ylgamehall.app"
plutil -replace channel -string "<new_channel_id>" "$APP/ChannelConfig.plist"
plutil -replace market -string "<new_market_id>" "$APP/ChannelConfig.plist"
codesign --force --sign "$IDENTITY" --entitlements "$ENT" "$APP"
cd tmp && zip -r ../ylgamehall_<channel>.ipa Payload/
```
→ 修改 plist 与 msext 的"`mv` 目录名"机制工作量相当,但**完全规避 Xcode 26 synchronized group 与目录树的兼容问题**(详见 Plan ADR-007)。
> **为什么不照搬 msext 的目录树**:
> - msext 那套在 Xcode 14 / 老 group 模型下能工作;但 Xcode 26 默认 synchronized group 把子目录扁平化,适配代价高(尝试过 folder reference / Run Script + sandboxing 均有阻碍)
> - plist 是 iOS 原生最简单的配置存储,加入 `Resources/` 后 Xcode 自动入 Bundle,零配置
> - 修改成本 / 重签流程 / 维护成本 / IPA 后处理脚本 / H5 业务可观察行为,**与目录树方案完全等价**
> - CLAUDE.md 原则 B:"原生内部自由重构,不要照搬旧项目"——这是落地
### 7.1 SandboxPaths 集中管理
```swift
// ResourceKit/SandboxPaths.swift
public enum SandboxPaths {
public static var caches: URL {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
}
public static var documents: URL {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
public static var bundle: URL { Bundle.main.bundleURL }
/// {Caches}/{gamedir}/{gamestart}/index.html
public static func lobbyIndex() -> URL {
let cfg = BundleConfig.shared
return caches
.appendingPathComponent(cfg.gameDir)
.appendingPathComponent(cfg.gameStart)
.appendingPathComponent("index.html")
}
public static func subGameIndex(_ dir: String, _ start: String) -> URL {
caches.appendingPathComponent(dir)
.appendingPathComponent(start)
.appendingPathComponent("index.html")
}
}
```
### 7.2 渠道注入读取
```swift
// ResourceKit/BundleConfig.swift
//
// 从 Bundle 内的 ChannelConfig.plist 读取 11 个渠道注入值。
// 详见 §7.0.3 ChannelConfig.plist 母包模式 / ADR-007。
public final class BundleConfig: @unchecked Sendable {
public static let shared = BundleConfig()
public let qiniuDomain: String
public let gameId: String
public let channel: String
public let gameDir: String
public let gameStart: String
public let gameConfig: String
public let market: String
public let agent: String
public let appVersion: String
public let other: String
public let appleConfig: String
public init(bundle: Bundle = .main) {
let dict = Self.loadPlist(bundle: bundle)
qiniuDomain = dict["qiniudomain"] ?? ""
gameId = dict["gameid"] ?? ""
channel = dict["channel"] ?? ""
gameDir = dict["gamedir"] ?? ""
gameStart = dict["gamestart"] ?? ""
gameConfig = dict["gameconfig"] ?? ""
market = dict["market"] ?? ""
agent = dict["agent"] ?? ""
appVersion = dict["appversion"] ?? ""
other = dict["other"] ?? ""
appleConfig = dict["appleconfig"] ?? ""
}
private static func loadPlist(bundle: Bundle) -> [String: String] {
guard let url = bundle.url(forResource: "ChannelConfig", withExtension: "plist"),
let data = try? Data(contentsOf: url),
let plist = try? PropertyListSerialization.propertyList(
from: data, format: nil) as? [String: String]
else { return [:] }
return plist
}
}
```
> 七牛 CDN 域名(用于录音上传后的公网 URL 组装)在新项目中统一从 `BundleConfig.shared.qiniuDomain` 读取,不暴露任何全局变量。所有 AudioKit / RecordUploader 等模块通过依赖注入获取 BundleConfig。
>
> **测试性**:`init(bundle:)` 接受任意 Bundle,单测可注入 mock bundle 验证不同 plist fixture。
### 7.3 Zip 解压(异步,非阻塞)
```swift
// ResourceKit/ResourceUnzipper.swift
public actor ResourceUnzipper {
public func ensureReady() async throws {
let dest = SandboxPaths.caches
.appendingPathComponent(BundleConfig.shared.gameDir)
if FileManager.default.fileExists(atPath:
dest.appendingPathComponent(BundleConfig.shared.gameStart)
.appendingPathComponent("version.xml").path) {
return // 已存在
}
let zip = Bundle.main.url(forResource: "gamehall", withExtension: "zip")!
try await Task.detached(priority: .userInitiated) {
try FileManager.default.createDirectory(at: dest,
withIntermediateDirectories: true)
try Zip.unzipFile(zip, destination: dest, overwrite: true, password: nil)
}.value
}
}
```
依赖 [`ZIPFoundation`](https://github.com/weichsel/ZIPFoundation) (SPM),替代旧 ZipArchive。
### 7.4 打包脚本(渠道动态切换)
```bash
#!/bin/bash
# Scripts/inject_channel.sh
# Usage: ./inject_channel.sh <channel_id> <gamedir> <gameid> <market> ...
set -e
INJECT_ROOT="Resources/ChannelInjection"
inject() {
local key=$1 value=$2
rm -rf "$INJECT_ROOT/$key"
mkdir -p "$INJECT_ROOT/$key/$value"
touch "$INJECT_ROOT/$key/$value/.gitkeep"
}
inject "channel" "$1"
inject "gamedir" "$2"
inject "gameid" "$3"
inject "market" "$4"
inject "agent" "$5"
inject "appversion" "$6"
inject "qiniudomain" "$7"
inject "gamestart" "$8"
inject "gameconfig" "$9"
echo "Channel injected: channel=$1 gamedir=$2 market=$4"
```
CI 一键多渠道出包:
```bash
for channel in xianliao 360 qq tencent; do
./Scripts/inject_channel.sh "$channel" "..." "..."
xcodebuild archive -scheme Daoqi -archivePath build/$channel.xcarchive
xcodebuild -exportArchive ...
done
```
### 7.5 H5 `app_*.js` 预注入文件机制(**最终路径:原生 `writeToFile` 写文件**
**用途**H5 业务代码通过 `<script src="app_data.js">` / `<script src="app_battery.js">` / `<script src="app_network.js">` / `<script src="app_gamesname.js">` 等同步 `<script>` 引入 4 个 `.js` 文件。**原生在 `loadFileURL` 之前用 `String.write(to:)` 把这 4 个文件写到 `{gamedir}/{gamestart}/`**,文件内是 `var app_xxx = "<实际渠道/启动/设备值>"` 形式的变量声明,H5 启动时 `<script src>` 同步引入直接读到正确值。**这是 H5 在 iOS 9+ 路径下读渠道 / 设备 / 启动信息的唯一真实路径**(§3.4.1 旧桥同步 getter 已撤销)。
> ⚠️ **2026-06-22 路径决策最终落定(中间走过两次弯路,复盘见 CLAUDE.md 典型案例 3**
>
> 1. **早期 commit `f68b0db`**:设计为"原生 `writeToFile` 写 4 个 `.js` 文件"路径(与原 msext `NewRootVC.initJSdata` 一致)→ **正确方向**
> 2. **中间 commit `246f215`**:误以为"H5 自带 `app_*.js`,原生用 `WKUserScript(.atDocumentEnd)` + `evaluateJavaScript` 覆盖 `window.app_xxx`"是更现代化的做法 → **错误调整**,撤销
> 3. **本次回退**:再次回到"原生 `writeToFile` 写文件"路径,与原 msext 行为 1:1 等价
>
> **为什么 `evaluateJavaScript` 路径不可行**CLAUDE.md 典型案例 3 详记):
> - `WKUserScript(.atDocumentStart)` 注入会被 H5 自带 `app_*.js` 内的 `var app_xxx = "默认值"` 声明覆盖回退
> - `WKUserScript(.atDocumentEnd)` 又太晚(H5 业务顶层 `<script>` 在 documentEnd 之前已跑过 `var x = app_xxx`,读到的是默认占位)
> - WKUserScript 没有"在 `<script src="app_data.js">` 之后、业务 `<script>` 之前"的精确时机
> - 任何"让 H5 改一行 / 改一个文件"的妥协方案都违反 CLAUDE.md **原则 A 第一准则**(H5 端零修改不可妥协),立即否决
>
> **正确解法**:回到 msext 老路 — 原生在 `loadFileURL` **之前**写 `app_*.js` 文件,H5 启动时 `<script src>` 同步引入即可读到实际值,时序 100% 等价 msext。哪怕这是"现代 WKWebView 不推荐"的写法,契约优先于现代化(CLAUDE.md 原则 A.2)。
#### 7.5.1 15 个 `app_*` 变量完整映射(按 daoqi/msext `NewRootVC.initJSdata`、`gameController.initJSdata`、`AppDelegate.applicationDidFinishLaunching` 审查得出)
| # | 变量名 | 大厅值(NewRootVC| 子游戏值(gameController)| 数据源(新外壳) | 写入文件 |
|---|---|---|---|---|---|
| 1 | `app_version` | `"1"` 硬编码 | `"1"` 硬编码 | 硬编码 string `"1"`(沿用历史,H5 不会读出不同值) | `app_data.js` |
| 2 | `app_gameconfig` | `[FuncPublic filename:@"gameconfig"]``[FuncPublic filename:@"appleconfig"]`(动态切换,见下方)| 同左 | **动态切换**`result==0``BundleConfig.gameConfig` / `result==1``BundleConfig.appleConfig`(详见 #6 app_appversion 计算)| `app_data.js` |
| 3 | `app_gamedir` | `self.gamedir` | `self.gamefilepath` | 大厅 `BundleConfig.shared.gameDir` / 子游戏 `subGameDir` | `app_data.js` |
| 4 | `app_gamestart` | `self.gamestart` | `self.game_name` | 大厅 `BundleConfig.shared.gameStart` / 子游戏 `subGameName` | `app_data.js` |
| 5 | `app_agent` | `self.agentinfo` | `self.agentinfo` | `BundleConfig.shared.agent` | `app_data.js` |
| 6 | `app_appversion` | `result`(0/1 二值标志,**不是版本号**) | `self.result_state`(同上) | **审核切换标志**msext NewRootVC.m:1190-1208):`远端 ResolvedVersion.appVersion >= 本地 BundleConfig.appVersion``result=0`(正常 gameconfig),否则 `result=1`(审核期 appleconfig)。写入字面 `'0'` / `'1'` 单引号包裹数字(与 msext `'%d'` 等价)| `app_data.js` |
| 7 | `app_market` | `self.market` | `self.market` | `BundleConfig.shared.market` | `app_data.js` |
| 8 | `app_channel` | `self.channel_id` | `self.channel_id` | `BundleConfig.shared.channel` | `app_data.js` |
| 9 | `app_Launchtype` | `"0"` 硬编码 | `"1"` 硬编码 | 大厅 `"0"` / 子游戏 `"1"`(⚠️ L 大写) | `app_data.js` |
| 10 | `app_getwifisignalLevel` | `"1"` 硬编码 | `"1"` 硬编码 | 硬编码 string `"1"`(⚠️ wifi 小写、signal/Level 区分大小写) | `app_data.js` |
| 11 | `app_gamename` | `self.gamestart` | `self.game_name` | 大厅 = `gameStart` / 子游戏 = 子游戏名(与 `app_gamestart` 同值) | `app_data.js` |
| 12 | `app_invitationcode` | `self.tuiguang_id` | `self.tuiguang_id` | `BundleConfig.shared.other`(推广码 / 邀请码,沿用 other 字段;如未来独立字段再改)| `app_data.js` |
| 13 | `app_gamesname` | 已安装子游戏 JS Array | 同上 + 新增本子游戏 | `SandboxPaths.installedSubGameNames()` JS Array literal | `app_gamesname.js` |
| 14 | `app_getbattery` | `String(format: "%.2f", UIDevice.batteryLevel)` | 同 | `UIDevice.current.batteryLevel`,每次 viewWillAppear + battery 变化通知 | `app_battery.js` |
| 15 | `app_getnetwork` | `"1"`/`"2"`/`"3"` | 同 | `NWPathMonitor` 状态映射(1 无网 / 2 WiFi / 3 蜂窝),每次 viewWillAppear + network 变化通知 | `app_network.js` |
#### 7.5.2 写入时机与机制(**两阶段:启动期写文件 + 业务期 evaluateJavaScript**
```
启动期(loadFileURL 之前,原生写文件到沙盒,供 H5 同步 <script src> 引入):
{gamedir}/{gamestart}/app_data.js 一次性写(#1-#12 共 12 个 var
{gamedir}/{gamestart}/app_gamesname.js 一次性写(#13
{gamedir}/{gamestart}/app_battery.js loadFileURL 前补一次(首次值)
{gamedir}/{gamestart}/app_network.js loadFileURL 前补一次(首次值)
业务期(H5 已加载完后,原生 evaluateJavaScript 直接重新赋值 window.app_*):
battery 变化 → webView.evaluateJavaScript("window.app_getbattery=N;") (不重写文件)
network 变化 → webView.evaluateJavaScript("window.app_getnetwork=N;") (不重写文件)
子游戏装新 → 重新进入大厅时调 writer.writeGamesName 一次(增量更新文件)
```
**为什么启动期写文件**(保留与 msext 等价的时序):H5 启动时用 `<script src="app_data.js">` **同步**引入,必须在 `loadFileURL` 之前文件已存在于沙盒;H5 业务代码顶层 `var x = app_xxx` 直接拿到实际值。
**为什么业务期不重写文件,改用 evaluateJavaScript**H5 加载完成后,`<script src>` 不会再 fetch,所以重写文件对当前页面无影响(仅影响下次 reload)。直接 `evaluateJavaScript("window.app_xxx = N;")` 重新赋值全局变量,H5 业务期同步读 `app_xxx` 立即拿到最新值,效率更高、无文件 IO 开销。
> **与 §3.2 反向 callback 的关系**:两条路径并存、互为补充:
> - **`window.app_xxx` 重新赋值**H5 业务代码任何时刻同步读 `app_getbattery` 都拿到最新值
> - **`bridge.call("getBattery", ...)` 反向推送**H5 注册的 callback 收到事件主动响应(业务期变化感知)
> 两路同时执行(onChange 闭包内一次完成),与原 msext changebattery + js eval 双轨等价。
> **不要照搬 `WKUserScript` 注入路径**(曾在 commit `246f215` 尝试过,已撤回):
> - `.atDocumentStart` 注入会被 H5 自带 `app_*.js` 内的 `var app_xxx = "默认值"` 声明覆盖
> - `.atDocumentEnd` 又太晚,H5 业务顶层 `<script>` 在 documentEnd 之前已读过 `app_xxx`(默认值)
> - 没有"在 `<script src>` 之后、业务 `<script>` 之前"的精确时机
> - 任何要求 H5 配合改动的方案都违反 CLAUDE.md 原则 A 第一准则
> - 详细分析见 CLAUDE.md「典型案例:`app_*` 注入时序问题」
#### 7.5.3 Swift 骨架(`AppDataWriter`,写文件路径)
设计带可观察性:每次写文件都在 Debug 模式下把"写了哪个文件 + 内容预览 + 路径"打到控制台,便于联调时一眼对账 H5 端读到的值。Release 自动去 `.debug` 级别避免性能开销 + 防泄漏。
```swift
// Source/WebView/AppDataWriter.swift
import os.log
@MainActor
public struct AppDataWriter {
let bundleConfig: BundleConfig
let resolvedVersion: ResolvedVersion? // 拉到远端后传入;首次 fallback nil
let containerRole: ContainerRole // .lobby / .subGame(name:)
public enum ContainerRole: CustomStringConvertible {
case lobby
case subGame(name: String, dir: String)
public var description: String {
switch self {
case .lobby: return "lobby"
case .subGame(let n, _): return "subGame(\(n))"
}
}
}
/// 集中日志入口:可被单测重定向;Debug 输出,Release 自动归档
private static let log = Logger(subsystem: "ylgamehall", category: "AppDataWriter")
/// 一次性写 #1-#12 到 app_data.js + #13 app_gamesname.jsloadFileURL 前调一次)
public func writeInitial() throws {
try writeAppData()
try writeGamesName()
}
/// 写 #14 app_battery.js(电池变化 / viewWillAppear 触发)
public func writeBattery(_ level: Float) throws {
let value = String(format: "%.2f", level)
let line = "var app_getbattery=\(value);\n"
let url = filePath("app_battery.js")
try line.write(to: url, atomically: true, encoding: .utf8)
Self.log.debug("[\(containerRole.description, privacy: .public)] write app_battery.js: app_getbattery=\(value, privacy: .public)\(url.path, privacy: .public)")
}
/// 写 #15 app_network.jsNWPath 变化 / viewWillAppear 触发)
public func writeNetwork(_ code: Int) throws {
let line = "var app_getnetwork=\(code);\n"
let url = filePath("app_network.js")
try line.write(to: url, atomically: true, encoding: .utf8)
Self.log.debug("[\(containerRole.description, privacy: .public)] write app_network.js: app_getnetwork=\(code, privacy: .public)\(url.path, privacy: .public)")
}
private func writeAppData() throws {
let bc = bundleConfig
let launchtype: String
let gameName: String
let gameDir: String
let gameStart: String
switch containerRole {
case .lobby:
launchtype = "0"
gameName = bc.gameStart
gameDir = bc.gameDir
gameStart = bc.gameStart
case .subGame(let name, let dir):
launchtype = "1"
gameName = name
gameDir = dir
gameStart = name
}
let appVersion = resolvedVersion?.appVersion.description ?? bc.appVersion
// ⚠️ 严格保持:单引号包裹字符串、msext 沿用形式;大小写不能改
// 数值字段(app_version / app_Launchtype / app_getwifisignalLevel / app_appversion
// 是字面数字,无引号;msext "var app_version=1;" / "var app_appversion='%d';"
let lines = """
var app_version=1;\
var app_gameconfig='\(escape(bc.gameConfig))';\
var app_gamedir='\(escape(gameDir))';\
var app_gamestart='\(escape(gameStart))';\
var app_agent='\(escape(bc.agent))';\
var app_appversion='\(escape(appVersion))';\
var app_market='\(escape(bc.market))';\
var app_channel='\(escape(bc.channel))';\
var app_Launchtype=\(launchtype);\
var app_getwifisignalLevel=1;\
var app_gamename='\(escape(gameName))';\
var app_invitationcode='\(escape(bc.other))';
"""
let url = filePath("app_data.js")
try lines.write(to: url, atomically: true, encoding: .utf8)
// Debug 打印每个 key=value 一行,便于和 H5 console.log(app_xxx) 逐项对账
Self.log.debug("""
[\(containerRole.description, privacy: .public)] write app_data.js → \(url.path, privacy: .public)
app_version = 1
app_gameconfig = '\(bc.gameConfig, privacy: .public)'
app_gamedir = '\(gameDir, privacy: .public)'
app_gamestart = '\(gameStart, privacy: .public)'
app_agent = '\(bc.agent, privacy: .public)'
app_appversion = '\(appVersion, privacy: .public)'
app_market = '\(bc.market, privacy: .public)'
app_channel = '\(bc.channel, privacy: .public)'
app_Launchtype = \(launchtype, privacy: .public)
app_getwifisignalLevel = 1
app_gamename = '\(gameName, privacy: .public)'
app_invitationcode = '\(bc.other, privacy: .public)'
""")
}
private func writeGamesName() throws {
// JS Array literal: new Array('game1','game2',...),与 msext AppDelegate.m:259 完全一致
let installed = SandboxPaths.installedSubGameNames()
let escaped = installed.map { "'\(escape($0))'" }.joined(separator: ",")
let line = "var app_gamesname=new Array(\(escaped));\n"
// ^ 注意 var 后两个空格,msext 字面沿用
let url = filePath("app_gamesname.js")
try line.write(to: url, atomically: true, encoding: .utf8)
Self.log.debug("[\(containerRole.description, privacy: .public)] write app_gamesname.js: \(installed.count, privacy: .public) games → \(url.path, privacy: .public)\n games = \(installed, privacy: .public)")
}
private func filePath(_ name: String) -> URL {
// 与 msext NewRootVC 一致:{gamedir}/{gamestart}/app_*.js
let base: URL
switch containerRole {
case .lobby:
base = SandboxPaths.lobbyAssets // {Caches}/{gamedir}/{gamestart}/
case .subGame(_, let dir):
base = SandboxPaths.subGameAssets(dir: dir)
}
return base.appendingPathComponent(name)
}
/// JS string 转义:单引号、反斜杠、换行(注意 msext 用单引号包裹字符串,
/// 转义对象是单引号不是双引号)
private func escape(_ s: String) -> String {
s.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
}
}
```
#### 7.5.4 WebContainerViewController 接入点
```swift
private func runBootPipelineSteps() async throws {
// ... ensureReady / fetch / resolve / upgrade ...
let writer = AppDataWriter(
bundleConfig: .shared,
resolvedVersion: resolved,
containerRole: .lobby
)
// 1. 启动期:loadFileURL 前一次性写 4 个 app_*.js 文件
// H5 通过 <script src> 同步引入即可读到实际值
NetworkMonitor.shared.start()
BatteryMonitor.shared.start()
AppLifecycleObserver.shared.start()
try writer.writeInitial() // app_data.js + app_gamesname.js
try writer.writeBattery(BatteryMonitor.shared.currentLevel) // app_battery.js
try writer.writeNetwork(NetworkMonitor.shared.currentCode) // app_network.js
splash.update(text: "加载大厅...", progress: nil)
bridgedWebView.webView.loadFileURL(
SandboxPaths.lobbyIndex,
allowingReadAccessTo: SandboxPaths.lobbyRoot
)
// 2. 业务期:变化时**不重写文件**,改为 evaluateJavaScript 重新赋值 window.app_*
// + bridge.call 反向通知(双轨同时进行)
let bridge = bridgedWebView.bridge
let webView = bridgedWebView.webView
BatteryMonitor.shared.onChange = { level in
let value = String(format: "%.2f", level)
Task { @MainActor in
_ = try? await webView.evaluateJavaScript("window.app_getbattery=\(value);")
}
bridge.call("getBattery", data: .string(value), callback: nil)
}
NetworkMonitor.shared.onChange = { code in
Task { @MainActor in
_ = try? await webView.evaluateJavaScript("window.app_getnetwork=\(code);")
}
bridge.call("getnetwork", data: .string("\(code)"), callback: nil)
}
// appservice 无对应 app_* 全局变量,只走 bridge.call
// 值错位沿用历史 msext WKWebView 路径:背景="2" / 前台="1"
AppLifecycleObserver.shared.onBackground = {
bridge.call("appservice", data: .string("2"), callback: nil)
}
AppLifecycleObserver.shared.onForeground = {
bridge.call("appservice", data: .string("1"), callback: nil)
}
}
```
> ⚠️ 持续重写文件**不会** trigger H5 端 `<script src>` 重新加载(浏览器只在页面 load 时引入一次)。这条路径只服务于"刷新 → reload → 读到最新值"的场景,与 §3.2 2][3]的 `bridge.call("getBattery"|"getnetwork", ...)` 事件 callback **互为补充**
> - 事件 callback = 业务期 H5 主动收到变化推送
> - 文件重写 = 下次 reload 时 H5 读到的初始值是最新的
>
> 两条路径并存,与原 msext 行为等价。
#### 7.5.5 大厅 / 子游戏 / 弹层差异
| 容器 | `app_data.js` 写入 | `app_gamesname.js` | `app_battery.js` | `app_network.js` | 备注 |
|------|---|---|---|---|---|
| 大厅(WebContainer.lobby | `app_Launchtype=0` + `gamedir = BundleConfig.gameDir` + `gamename = gameStart` | 写当前已安装子游戏列表 | ✓ | ✓ | `app_appversion` 用远端 ResolvedVersion |
| 子游戏(WebContainer.subGame | `app_Launchtype=1` + `gamedir = 子游戏目录` + `gamename = 子游戏名` | 同样写(与大厅同份内容) | ✓ | ✓ | 子游戏专属 `result_state` 字段,新外壳暂用同 appVersion |
| 弹层(OverlayViewController / threeView | ✗ 不写 | ✗ | ✗ | ✗ | 弹层 H5 走 `window.settings.{backgameData/browser/finishweb}`(§3.4),不读 `app_*` 全局变量 |
#### 7.5.6 与原 msext 的差异
| 维度 | msext 现状 | 新外壳决策 |
|------|---|---|
| 写入主体 | `NewRootVC.initJSdata` / `gameController.initJSdata` 散落在 VC 内 | 抽象为 `AppDataWriter` struct,三个容器复用同一份代码 |
| 写入机制 | `[NSString writeToFile:atomically:YES encoding:UTF8 error:nil]``app_*.js` 到沙盒 | `String.write(to:atomically:encoding:)`(等价) |
| 数据源 | `self.gameconfig` 等实例属性,跨多处赋值 | 统一从 `BundleConfig.shared` + `ResolvedVersion` 拉,单一真相 |
| battery / network 时机 | `viewWillAppear` + 前台通知双重保险 | `addObserver` 注册一次 + `loadFileURL` 前补一次首次值(避免双重写入) |
| 字符串包裹符 | `var x='%@'` 单引号 | `var x='...'` 单引号一致(与 msext 字面对齐) |
| 转义处理 | 直接 `stringWithFormat:@"var x='%@'"` 不转义 | 完整转义 4 种特殊字符(避免渠道字段含单引号导致 H5 JS 解析错) |
| 大小写 | `app_Launchtype` / `app_getwifisignalLevel` 严格保持 | 同款,注释提示 |
| 文件位置 | `{gamedir}/{gamestart}/app_*.js`(沙盒 Caches)| 同款(统一走 `SandboxPaths.lobbyAssets` / `subGameAssets`|
#### 7.5.7 与 §3.4.1 撤销 polyfill 的关系
| 路径 | 状态 | 用途 |
|---|---|---|
| §7.5 `app_*.js` 文件预写 | ✓ **主路径** | iOS 9+ 全部 H5 业务读 `app_xxx` 全局变量;原生 `writeToFile``loadFileURL` 前写入沙盒,H5 `<script src>` 同步引入即可(与 msext 1:1 等价)|
| §3.4.1 polyfill | ✗ **已撤销** | 仅 iOS<9 旧桥 `window.settings.getXxx()`,本项目最低 iOS 15.6 不需要 |
| §3.4 OverlayBridge polyfill | ✓ **保留** | 弹层 `window.settings.{backgameData/browser/finishweb}`,与系统版本无关 |
| §3.2 事件 callback `getBattery` / `getnetwork` / `appservice` | ✓ **保留** | 业务期 H5 主动收到的变化推送,与 §7.5 文件重写互为补充 |
| `WKUserScript` + `evaluateJavaScript` 覆盖 `window.app_xxx` | ✗ **探索后撤回** | 时序无法 1:1 等价 msext,详细分析见 §7.5 头部 + CLAUDE.md「典型案例 3」|
#### 7.5.8 联调时的可观察性(Phase 1.17 调试手段)
H5 端读到错误值时,第一步是确认"原生写了什么、H5 读到什么"。四条对账路径:
**1. Xcode 控制台 — 原生写入 log(§7.5.3 已埋点)**
启动后 Xcode console 应出现:
```
[lobby] write app_data.js → /Users/.../Caches/<gamedir>/<gamestart>/app_data.js
app_version = 1
app_gameconfig = 'tsgames.daoqi88.cn-config_test-update_jsonv2_test'
app_gamedir = 'FtJf07...'
app_gamestart = 'gamehall'
app_agent = 'veRa0qrBf0df...'
app_appversion = '43'
app_market = '2'
app_channel = 'FtJf07...'
app_Launchtype = 0
app_getwifisignalLevel = 1
app_gamename = 'gamehall'
app_invitationcode = ''
[lobby] write app_gamesname.js: 0 games → ...
[lobby] write app_battery.js: app_getbattery=0.85 → ...
[lobby] write app_network.js: app_getnetwork=2 → ...
```
若没出现 → AppDataWriter 没接入 / log subsystem 过滤掉了,先按 §7.5.4 接入点检查。
**2. 沙盒文件直接读 — `cat`(确认文件落盘正确)**
```bash
xcrun simctl get_app_container booted com.skyapp.ylgamehall data
# 拿到容器路径后
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_data.js
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_battery.js
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_network.js
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_gamesname.js
```
文件应包含 `var app_xxx=...;` 字面字符串。若文件不存在 / 为空 → AppDataWriter 没跑到 / 路径错;若文件正确但 H5 读 undefined → H5 端 `<script src>` 路径不对或加载时序问题。
**3. H5 console 对账 — Safari Web Inspector**
模拟器跑时 macOS Safari → 开发菜单 → Simulator → 当前 H5 页面 → Web Inspector → Console
```js
console.log("app_version =", app_version); // 期望 1
console.log("app_channel =", app_channel); // 期望 ChannelConfig.plist channel 值
console.log("app_Launchtype =", app_Launchtype); // 大厅期望 0,⚠️ L 大写
console.log("app_getwifisignalLevel =", app_getwifisignalLevel); // ⚠️ wifi 小写
console.log("app_getbattery =", app_getbattery); // ⚠️ 带 get 前缀
console.log("app_getnetwork =", app_getnetwork);
console.log("app_gamesname =", app_gamesname); // 期望 Array
```
| 现象 | 可能原因 |
|---|---|
| 全部为 H5 zip 包内默认占位(不是实际渠道值)| AppDataWriter 没在 loadFileURL 前调到 / 写入路径与 H5 `<script src>` 引入路径不一致 / writeAppData 抛错被吞 |
| 某项为 `undefined` | 拼写错误(`app_launchtype` 小写 l / `app_battery` 少 get / `app_getNetwork` 驼峰错) |
| `app_getbattery` = 默认值不变 | BatteryMonitor 没启动 / 模拟器 batteryLevel = -1(模拟器无电池硬件) |
| `app_gamesname` 是空数组但实际已装子游戏 | `SandboxPaths.installedSubGameNames()` 实现错 / 子游戏目录路径不对 |
**4. 真机调试**iPhone 上 Safari → 设置 → 高级 → 网页检查器开启,然后 macOS Safari 同样路径。
切换后 Xcode console 应出现 `inject battery: ...` / `inject network: ...` 日志,同时 H5 端 console.log 再读应反映新值。
> ⚠️ 上面 H5 控制台和原生 log 调试**仅在 Debug 配置生效**。Release 版本 os.log `.debug` 级别会被系统过滤掉,避免性能开销和敏感字段(agent / channel)外泄。Phase 9 Release 监控走 Sentry breadcrumb(§11.3),不依赖 console。
> ⚠️ 上面 H5 控制台和文件路径调试**仅在 Debug 配置生效**。Release 版本 os.log `.debug` 级别会被系统过滤掉,避免性能开销和敏感字段(agent / channel)外泄。Phase 9 Release 监控走 Sentry breadcrumb(§11.3),不依赖 console。
---
## 8. 能力模块详细设计
### 8.1 AudioKit
```swift
// AudioKit/AudioPlayer.swift
@MainActor
public final class AudioPlayer {
private var background: AVAudioPlayer?
private var button: AVAudioPlayer?
private var voice: AVAudioPlayer?
public func playOnce(_ url: URL) { ... }
public func loopBackground(_ url: URL, type: String) { ... }
public func stopBackground(type: String) { ... }
public func playVoice(_ url: URL,
onStart: @escaping () -> Void,
onEnd: @escaping () -> Void) { ... }
}
// AudioKit/VoiceCoder.swift
/// 全新 Swift wrapper,直接调用 libopencore-amr 静态库的 C 接口
/// (opencore-amr 是闭源静态库,业界标准 AMR 编解码实现,无 Swift 替代品)
public enum VoiceCoder {
public static func amrToWav(_ src: URL, dest: URL) throws { ... }
public static func wavToAmr(_ src: URL, dest: URL) throws { ... }
}
// AudioKit/AudioRecorder.swift
public actor AudioRecorder {
public func record() async throws -> AudioFile { ... }
/// 录音 → WAV → AMR → 上传七牛 → 返回 URL + 时长
public func recordAndUpload() async throws -> (audioUrl: String, time: Int) { ... }
}
```
#### 8.1.1 H5 audio handlers 实现骨架
把契约 §3.1 §B 的 4 项 audio handler(【3】–【6】)粘到 AudioKit 上的胶水代码。注册集中到 `AudioHandlers` 一个 struct,便于发现 / 单测。
```swift
// Source/Bridge/Handlers/AudioHandlers.swift
import Foundation
public struct AudioHandlers {
let bridge: BridgeProtocol
let player: AudioPlayer
let recorder: AudioRecorder
let voiceCenter: VoiceCenter // 8.1.1 引入,控制远端语音的播放总开关
public func register() {
bridge.register("srcIsloop", handler: srcIsloop)
bridge.register("prepareaudio", handler: prepareAudio)
bridge.register("mediaTypeAudio", handler: mediaTypeAudio)
bridge.register("voicePlaying", handler: voicePlaying)
}
// MARK: - 【3】 srcIsloop — 本地音频播放
//
// 契约 §3.1 3]:
// 入参 src : string 音频文件名,路径 = Library/Caches/{gamedir}/{gamestart}/assets/wav/{src}
// 入参 isloop : int 0 单次 / 1 循环 / -1 停同类循环
// responseCallback: "Response from srcIsloop"
//
// 与原 msext 的差异(不破契约):
// - 原项目用 NSURL fileURLWithPath: 拼接 + AVAudioPlayer 同步初始化;
// 新外壳通过 SandboxPaths 统一管理,所有路径在一处可校验
// - 原项目 backgroundType 用全局 NSString *;新外壳把状态封装在
// @MainActor 的 AudioPlayer 内部,避免跨线程访问竞态
private func srcIsloop(_ data: BridgeData?, _ cb: BridgeCallback?) async {
defer { cb?(.string("Response from srcIsloop")) }
guard let obj = data?.asObject,
let src = obj["src"]?.asString,
let isloop = obj["isloop"]?.asInt
else {
Log.warn("srcIsloop: bad payload \(String(describing: data))")
return
}
let url = SandboxPaths.lobbyAssets
.appendingPathComponent("wav")
.appendingPathComponent(src)
await MainActor.run {
switch isloop {
case 0: player.playOnce(url)
case 1: player.loopBackground(url, type: src)
case -1: player.stopBackground(type: src)
default: Log.warn("srcIsloop: unexpected isloop=\(isloop)")
}
}
}
// MARK: - 【4】 prepareaudio — 启动麦克风录音
//
// 契约 §3.1 4]:
// 入参 : 忽略
// 副作用 : 权限检查 → recordAndUpload → getaudiourl callback
// responseCallback: "Response from prepareaudio"
//
// 权限态参考契约 §6.2「ifauth」三态语义:
// 0 denied/restricted → 弹 alert 并放弃
// 1 not determined → AudioRecorder 内部触发系统询问
// 2 authorized → 直接录
//
// 与原 msext 的差异:
// - 原项目 VoiceRecorderBaseVC 是 UI 录音控件,含 cancel/redo 按钮,
// 与 H5 的录音 UI 重复。新外壳走"无 UI 录音"路径:H5 自己画按钮,
// 原生只负责拿到 AVAudioRecorder 数据 → 转码 → 上传,不弹任何原生界面
// - 上传走七牛 SDK(已在 ADR-006 选型);旧 PostFile 接口不再支持
private func prepareAudio(_ data: BridgeData?, _ cb: BridgeCallback?) async {
cb?(.string("Response from prepareaudio"))
switch await Permissions.microphone() {
case .denied, .restricted:
// 契约要求:通过 H5 alert 提示(不是原生弹窗),文案 = "{gamehallname}需要访问您的麦克风"
bridge.call("alertMessage",
data: .string("\(BundleConfig.shared.appDisplayName)需要访问您的麦克风"),
callback: nil)
return
case .notDetermined, .authorized:
break
}
do {
let result = try await recorder.recordAndUpload()
// 契约 §3.2 9]:getaudiourl({audiourl, time})
bridge.call("getaudiourl",
data: .object([
"audiourl": .string(result.audioUrl),
"time": .string(String(result.time)) // ⚠️ 字符串,非 number
]),
callback: nil)
} catch {
Log.error("prepareaudio failed: \(error)")
}
}
// MARK: - 【5】 mediaTypeAudio — 远程语音回放
//
// 契约 §3.1 5]:
// 入参 audiourl: string 远端 AMR URL
// 入参 user : string 发声方用户 ID
// responseCallback: "Response from mediaTypeAudio"
// 开始播放: gameui_play_voice(user) (§3.2 7])
// 播放结束: gameui_stop_voice(user) (§3.2 8])
//
// 总开关由 voicePlaying 控制(【6】),关闭态直接静默不播
private func mediaTypeAudio(_ data: BridgeData?, _ cb: BridgeCallback?) async {
defer { cb?(.string("Response from mediaTypeAudio")) }
guard let obj = data?.asObject,
let urlStr = obj["audiourl"]?.asString,
let user = obj["user"]?.asString,
let remote = URL(string: urlStr)
else {
Log.warn("mediaTypeAudio: bad payload")
return
}
guard await voiceCenter.isEnabled else {
Log.info("mediaTypeAudio: voicePlaying off, skip user=\(user)")
return
}
do {
let amr = try await AudioDownloader.fetchToCache(remote)
let wav = try await VoiceCoder.amrToWavAsync(amr)
await MainActor.run {
player.playVoice(wav,
onStart: { [weak bridge] in
bridge?.call("gameui_play_voice", data: .string(user), callback: nil)
},
onEnd: { [weak bridge] in
bridge?.call("gameui_stop_voice", data: .string(user), callback: nil)
})
}
} catch {
Log.error("mediaTypeAudio failed for user=\(user): \(error)")
// 失败也要补 stop_voice,避免 H5 端 UI 永远停在"播放中"状态
bridge.call("gameui_stop_voice", data: .string(user), callback: nil)
}
}
// MARK: - 【6】 voicePlaying — 语音播放总开关
//
// 契约 §3.1 6]:
// 入参: int 1=允许 / 其他=静默
// responseCallback: "Response from voicePlaying"
//
// VoiceCenter 持有 actor 隔离的 bool 状态,mediaTypeAudio 每次读它
// 决定要不要走完整的下载→转码→播放链路
private func voicePlaying(_ data: BridgeData?, _ cb: BridgeCallback?) async {
defer { cb?(.string("Response from voicePlaying")) }
let on = (data?.asInt ?? 0) == 1
await voiceCenter.setEnabled(on)
}
}
/// 远端语音播放总开关(actor 隔离,跨 bridge handler 共享)
public actor VoiceCenter {
public private(set) var isEnabled: Bool = true
public func setEnabled(_ on: Bool) { isEnabled = on }
}
```
#### 8.1.2 与原 msext 的差异(不要照抄的部分)
| 维度 | msext 现状(不要照抄) | 新外壳决策 |
|------|--------------------|----------|
| 录音 UI | `VoiceRecorderBaseVC` 原生录音控件 + cancel/redo 按钮 | 无原生 UI;H5 自己画按钮,原生只录 + 转 + 传 |
| 上传 | 旧 `gameapi.0791ts.cn/api/UpLoad/PostFile` 直传后台 | 七牛 SDKADR-006 已选型) |
| 状态共享 | `static NSString *backgroundType` 全局变量 | `@MainActor` AudioPlayer 内部状态 |
| 总开关 | `static BOOL voicePlaying` | `actor VoiceCenter` 隔离 |
| 路径拼接 | 各处 `NSURL fileURLWithPath:` 散落 | 统一走 `SandboxPaths.lobbyAssets` |
### 8.2 ShareKit
策略模式分发,新增平台只加一个 conformance:
```swift
// ShareKit/SharePlatform.swift
public protocol SharePlatform: Sendable {
func shareLink(_ link: ShareLink) async throws -> ShareResult
func shareImage(_ image: ShareImage) async throws -> ShareResult
}
public enum ShareResult { case success, cancelled, failed(Error) }
public final class ShareCenter {
private let weChat: SharePlatform = WeChatShare()
private let xianliao: SharePlatform = NoopSharePlatform(name: "xianliao") // 暂未启用
public func dispatch(_ req: ShareRequest) async throws -> ShareResult {
let platform: SharePlatform = req.sharetype == 3 ? xianliao : weChat
switch req.type {
case 1: return try await platform.shareLink(req.asLink())
case 2: return try await platform.shareImage(req.asScreenshot())
default: return try await platform.shareImage(try await req.asRemoteImage())
}
}
}
// ShareKit/NoopSharePlatform.swift
/// 暂未集成的分享平台占位实现:返回 success 让 H5 业务继续(契约要求 sharesuccess 回包)
/// 启用时,把 ShareCenter.xianliao 的初始化换成 XianliaoShare() 即可
public struct NoopSharePlatform: SharePlatform {
let name: String
public func shareLink(_ link: ShareLink) async throws -> ShareResult {
Log.info("Share to \(name) is not enabled, returning success stub")
return .success
}
public func shareImage(_ image: ShareImage) async throws -> ShareResult {
Log.info("Share to \(name) is not enabled, returning success stub")
return .success
}
}
```
> 说明:闲聊 SDK 暂不集成,但 H5 调 `friendsSharetypeUrlToptitleDescript({sharetype:"3", ...})` 时仍然会收到 `sharesuccess({success:"2", type:...})`,业务流程不中断。启用闲聊 SDK 后,`NoopSharePlatform(name:"xianliao")` 替换为 `XianliaoShare()`,H5 端无感切换。
### 8.3 VideoRoomKit
抽象 protocol,当前默认注入 NoopVideoRoom(Agora 暂不集成),业务启用时换成 AgoraVideoRoom:
```swift
// VideoRoomKit/VideoRoom.swift
public protocol VideoRoom: AnyObject {
func join(roomId: String, playerId: String, localView: UIView) async throws
func setupRemote(uid: UInt, view: UIView)
func leave()
var didReceiveRemoteVideo: ((UInt) -> Void)? { get set }
}
// VideoRoomKit/NoopVideoRoom.swift
/// 暂未集成 Agora 时使用:所有方法立即返回,不做任何动作
/// 上层 SubGameHandlers 已经把 createRoom/exitRoom/getVideoinfo 改成 stub responseCallback
/// 此 Noop 实现仅用于满足依赖注入,使 SubGameHandlers 不需要 nil-check
public final class NoopVideoRoom: VideoRoom {
public var didReceiveRemoteVideo: ((UInt) -> Void)?
public init() {}
public func join(roomId: String, playerId: String, localView: UIView) async throws {
Log.info("VideoRoom.join called (NoopVideoRoom, Agora not integrated)")
}
public func setupRemote(uid: UInt, view: UIView) {}
public func leave() {}
}
// VideoRoomKit/AgoraVideoRoom.swift
/// 真实现:Agora SDK 接入后启用。
/// 当前不在工程的 SPM 依赖里,业务启用时再:
/// ① 在 Package.swift 加 .package(url: "...AgoraRtcEngine_iOS"...)
/// ② 用此类替换 NoopVideoRoom 注入 SubGameHandlers
/// ③ 文件保留在 VideoRoomKit/ 模块中作为蓝图(以下为业务启用时的实现轮廓,而非当前已编译代码)
#if AGORA_ENABLED // 编译开关,当前默认 OFF
import AgoraRtcKit
final class AgoraVideoRoom: NSObject, VideoRoom, AgoraRtcEngineDelegate {
private let kit: AgoraRtcEngineKit
var didReceiveRemoteVideo: ((UInt) -> Void)?
init() {
self.kit = AgoraRtcEngineKit.sharedEngine(withAppId: AppIDs.agora, delegate: nil)
super.init()
self.kit.delegate = self
}
func join(roomId: String, playerId: String, localView: UIView) async throws {
let channel = md5("\(BundleConfig.shared.agent)\(BundleConfig.shared.gameId)\(roomId)")
let canvas = AgoraRtcVideoCanvas()
canvas.uid = UInt(playerId) ?? 0
canvas.view = localView
kit.setupLocalVideo(canvas)
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
kit.joinChannel(byKey: nil, channelName: channel, info: nil,
uid: canvas.uid) { _, _, _ in cont.resume() }
}
}
func setupRemote(uid: UInt, view: UIView) { /* setupRemoteVideo */ }
func leave() { kit.leaveChannel(nil) }
func rtcEngine(_ engine: AgoraRtcEngineKit,
firstRemoteVideoDecodedOfUid uid: UInt, size: CGSize, elapsed: Int) {
didReceiveRemoteVideo?(uid)
}
}
#endif
```
> **业务启用 Agora 的步骤**(未来某天需要时):
> 1. 在 `Package.swift` 加 Agora SPM 依赖,工程加 `AGORA_ENABLED` 编译开关
> 2. SubGameHandlers 的 `videoRoom` 依赖从 `NoopVideoRoom()` 改为 `AgoraVideoRoom()`
> 3. `SubGameHandlers.createRoomStub` / `getVideoInfoStub` / `exitRoomStub` 换成真实业务逻辑(调 `videoRoom.join` / `videoRoom.leave` 等)
> 4. H5 端代码不动 — 它仍然 `bridge.callHandler('createRoom', {...})`,看到的 `getVideoinfo(uid)` 反向回包等契约不变
### 8.4 DeviceKit
```swift
// DeviceKit/DeviceInfo.swift
public enum DeviceInfo {
public static var snapshot: [String: String] {
[
"PhoneAdresseMAC": UIDevice.current.identifierForVendor?.uuidString ?? "",
"PhoneDeviceBrand": UIDevice.current.model,
"PhoneIMEI": ASIdentifierManager.shared().advertisingIdentifier.uuidString,
"PhoneModel": UIDevice.current.localizedModel,
"PhoneProvidersName": CarrierName.current ?? "",
"PhoneVersion": UIDevice.current.systemVersion
]
}
}
// DeviceKit/NetworkMonitor.swift
@MainActor
public final class NetworkMonitor {
public enum State: String { case none = "1", wifi = "2", cellular = "3" }
private let monitor = NWPathMonitor()
public var stateDidChange: ((State) -> Void)?
public func start() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in
let state: State =
path.status != .satisfied ? .none :
path.usesInterfaceType(.wifi) ? .wifi : .cellular
self?.stateDidChange?(state)
}
}
monitor.start(queue: DispatchQueue.global(qos: .utility))
}
}
```
`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.swiftPhase 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 前后台切换
// 数据:字符串 "2"=进入后台 / "1"=回到前台(值错位沿用 msext WKWebView 路径历史,
// 参 Contract.md §3.2 `appservice` 值错位说明,以及原 NewRootVC.m:1773/1782
appLifecycle.onBackground = { [weak bridge] in
bridge?.call("appservice", data: .string("2"), callback: nil)
}
appLifecycle.onForeground = { [weak bridge] in
bridge?.call("appservice", data: .string("1"), 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 多发起方派发(关键设计)
#### 问题背景
微信 SDK 是**全局单例**(`WXApi`),只接受**一个全局 delegate**。所有授权回包 (`SendAuthResp`) 和分享回包 (`SendMessageToWXResp`) 都通过这个唯一 delegate 派发。
新架构里大厅和子游戏的 H5 **都会调用** `accreditlogin``friendsSharetypeUrlToptitleDescript`:
```
H5 大厅 H5 子游戏
│ │
▼ ▼
LobbyHandlers SubGameHandlers
│ │
└─────────┬───────┘
WXApi.send(...)
(微信 App)
▼ 回包(只有 1 个 delegate 接收)
WXApiDelegate
❓ 派发给大厅还是子游戏?
```
**旧架构的错误做法**:让大厅和子游戏轮流持有 delegate(`WXApiManager.delegate = self / nil`),靠"谁活着谁注册"传球。这在以下场景必然出错:
- 大厅发起授权后**用户立刻切去子游戏**,授权回包来时 delegate 已经换给子游戏 → 大厅 H5 永远收不到 `sharelogin`,卡死
- 子游戏发起分享后回到大厅,分享回包来时归属错乱
- VC 之间忘记同步 delegate → 回包静默丢失
#### 新架构方案:Continuation Map by state
`WXApi.delegate` **永久绑定**到 `WeChatManager.shared`,**永不切换**。所有回包统一进入这个单例,再通过**请求-响应配对**派发给正确的发起方。
微信 OAuth2 协议天然提供 `state` 字段作为配对 key —— 发起请求时客户端塞一个 UUID,微信回包字面回传,**这就是天生的请求 ID**:
```swift
// SDK/WeChat/WeChatManager.swift
@MainActor
public final class WeChatManager: NSObject, WXApiDelegate {
public static let shared = WeChatManager()
/// in-flight 授权请求:state → continuation
/// 谁 await,谁拿到回包,与"哪个 VC 在前台"无关
private var pendingAuth: [String: CheckedContinuation<WXAuthCode, WeChatError>] = [:]
/// in-flight 分享请求(SendMessageToWXResp 不带 state,只能用 FIFO 队列 + 串行约束)
private var pendingShare: CheckedContinuation<Void, WeChatError>?
private override init() {
super.init()
// 启动时一次性注册,delegate 永不切换
WeChatSDK.registerFull(appId: AppIDs.weChat)
// WXApi.delegate(handleOpenURL 回调路径)由 SceneDelegate.openURLContexts 转给 self
}
/// 发起授权(任何 handler 都可以调,内部用 continuation 唯一对应本次调用)
public func authorize(scope: String = kAuthScope) async throws -> WXAuthCode {
let state = "wx_" + UUID().uuidString
try await Task.checkCancellation()
return try await withCheckedThrowingContinuation { cont in
pendingAuth[state] = cont
let req = SendAuthReq()
req.scope = scope
req.state = state // 配对 key,会被微信原样回传
WXApi.send(req)
}
}
/// 发起分享(串行化 — 同时只能有一个 pending,新请求等上一个完成)
public func share(_ message: WXMediaMessage, scene: WXScene) async throws {
// 如已有 pending,抛 .busy 让上层 H5 自行重试;不允许并发避免回包错配
guard pendingShare == nil else { throw WeChatError.busy }
return try await withCheckedThrowingContinuation { cont in
pendingShare = cont
let req = SendMessageToWXReq()
req.message = message
req.bText = false
req.scene = Int32(scene.rawValue)
WXApi.send(req)
}
}
// MARK: - WXApiDelegate(全局唯一 delegate)
public func onResp(_ resp: BaseResp) {
switch resp {
case let auth as SendAuthResp:
handleAuthResponse(auth)
case let share as SendMessageToWXResp:
handleShareResponse(share)
default:
Log.warn("Unhandled WeChat response: \(type(of: resp))")
}
}
private func handleAuthResponse(_ resp: SendAuthResp) {
guard let state = resp.state,
let cont = pendingAuth.removeValue(forKey: state) else {
Log.warn("Stale or unknown WeChat auth response: state=\(resp.state ?? "nil")")
return
}
if resp.errCode == 0, let code = resp.code {
cont.resume(returning: WXAuthCode(code: code))
} else if resp.errCode == -2 {
cont.resume(throwing: .userCancelled)
} else {
cont.resume(throwing: .authFailed(code: Int(resp.errCode)))
}
}
private func handleShareResponse(_ resp: SendMessageToWXResp) {
guard let cont = pendingShare else {
Log.warn("Stale WeChat share response with no pending continuation")
return
}
pendingShare = nil
if resp.errCode == 0 {
cont.resume()
} else if resp.errCode == -2 {
cont.resume(throwing: .userCancelled)
} else {
cont.resume(throwing: .shareFailed(code: Int(resp.errCode)))
}
}
}
```
#### 调用方代码 — 大厅 / 子游戏完全一致
**契约要求**:同一个 H5 容器内发起的回调,必须回到同一个 H5 容器:
- 大厅 H5 调 `accreditlogin``sharelogin` 必须回大厅 H5,**不能跨派到子游戏**
- 大厅 H5 调 `friendsShare...``sharesuccess` 必须回大厅 H5
- 子游戏 H5 调 `accreditlogin``sharelogin` 必须回子游戏 H5
- 子游戏 H5 调 `friendsShare...``sharesuccess` 必须回子游戏 H5
实现层面,**每个 Handler 持有自己的 `bridge`**(LobbyHandlers 持有 LobbyBridge,SubGameHandlers 持有 SubGameBridge)。回调时调用的是**自己的** bridge,与全局状态无关:
```swift
// LobbyHandlers.accreditLogin —— 持有大厅自己的 bridge
private let bridge: BridgeProtocol // = LobbyBridge
private func accreditLogin(_ data: BridgeData?, _ cb: BridgeCallback?) async {
defer { cb?(.string("Response from accreditlogin")) }
do {
let authCode = try await WeChatManager.shared.authorize()
let user = try await loginAPI.exchangeForUser(code: authCode.code)
bridge.call("sharelogin", data: user.bridgePayload, callback: nil)
// ↑ self.bridge 是 LobbyBridge,sharelogin 派到大厅 H5
} catch WeChatError.userCancelled {
Log.info("User cancelled WeChat auth in Lobby")
} catch {
Log.error("Lobby WeChat auth failed: \(error)")
}
}
// LobbyHandlers.friendsShare —— 同样持有大厅 bridge
private func friendsShare(_ data: BridgeData?, _ cb: BridgeCallback?) async {
defer { cb?(.string("sharefriend")) }
guard let req = ShareRequest(data: data) else { return }
do {
try await ShareCenter.shared.dispatch(req)
bridge.call("sharesuccess", data: .object([
"success": .string("2"),
"type": .string(req.sharefriend)
]), callback: nil)
// ↑ 大厅发起,大厅 H5 收到 sharesuccess
} catch WeChatError.userCancelled {
Log.info("User cancelled WeChat share in Lobby")
} catch {
Log.error("Lobby share failed: \(error)")
}
}
// SubGameHandlers.accreditLogin —— 代码一字不差,但 self.bridge 是 SubGameBridge
private let bridge: BridgeProtocol // = SubGameBridge
private func accreditLogin(_ data: BridgeData?, _ cb: BridgeCallback?) async {
defer { cb?(.string("Response from accreditlogin")) }
do {
let authCode = try await WeChatManager.shared.authorize()
let user = try await loginAPI.exchangeForUser(code: authCode.code)
bridge.call("sharelogin", data: user.bridgePayload, callback: nil)
// ↑ 子游戏发起,sharelogin 派到子游戏 H5
} catch {
Log.error("SubGame WeChat auth failed: \(error)")
}
}
// SubGameHandlers.friendsShare —— 同样
private func friendsShare(_ data: BridgeData?, _ cb: BridgeCallback?) async {
defer { cb?(.string("sharefriend")) }
guard let req = ShareRequest(data: data) else { return }
do {
try await ShareCenter.shared.dispatch(req)
bridge.call("sharesuccess", data: .object([
"success": .string("2"),
"type": .string(req.sharefriend)
]), callback: nil)
// ↑ self.bridge 是 SubGameBridge,sharesuccess 派到子游戏 H5
} catch {
Log.error("SubGame share failed: \(error)")
}
}
```
#### 为什么这套机制天然满足契约
回调归属的关键是 **"谁 await,谁拿到结果,然后谁 call 自己的 bridge"**,链路上没有任何"全局状态判断当前是谁"的环节:
```
H5 大厅 → bridge.callHandler('accreditlogin')
LobbyHandlers.accreditLogin (持有 LobbyBridge)
try await WeChatManager.shared.authorize() ← 进入 pendingAuth[state]
← 大厅 await 在这里挂起
... 用户在微信里授权,可能切去子游戏再切回...
← 微信回包 SendAuthResp(state=同一个 UUID)
WeChatManager.onResp → handleAuthResponse
pendingAuth[state] 取出 continuation → resume
LobbyHandlers.accreditLogin 函数从 await 处恢复执行 ← 仍在大厅的 handler 内
self.bridge.call("sharelogin", ...) ← self.bridge 是 LobbyBridge
H5 大厅收到 sharelogin ← 契约满足 ✓
```
**关键事实**:Swift 的 async/await 是基于 continuation 的,await 恢复后**回到的是同一个调用栈**(同一个 LobbyHandlers 实例的同一个方法),所以 `self.bridge` 自然还是 LobbyBridge,不存在"派错"的可能。
**大厅发起大厅回调、子游戏发起子游戏回调** 是这套设计的**结构保证**(structural guarantee),而不是靠运行时判断,所以不会因为 VC 切换、网络延迟、并发顺序而失效。
#### 边界场景验证(分享)
| 场景 | 行为 |
|------|------|
| 大厅 H5 调分享,等待中切去子游戏再切回 | WeChat 回包 → WeChatManager.pendingShare 是大厅的 continuation → resume → LobbyHandlers.friendsShare 从 await 恢复 → LobbyBridge.call(sharesuccess) → 大厅 H5 收到 ✓ |
| 大厅 H5 调分享后,子游戏 H5 立刻也调分享 | 子游戏的 ShareCenter.dispatch → WeChatManager.share 检测 pendingShare ≠ nil → 抛 `WeChatError.busy` → SubGameHandlers.friendsShare 捕获后选择重试或忽略;**不会**让子游戏的 continuation 覆盖大厅的 |
| 大厅 H5 调分享 ,然后调 backgameData(返回大厅) | 大厅本就在,无变化;continuation 仍归大厅 |
| 子游戏 H5 调分享后立刻 backgameData | 子游戏 VC pop 但 SubGameHandlers 实例仍被 `pendingShare.cont` 持有,直到回包到来 resume 后才会释放;回包派到 SubGameBridge,但子游戏 H5 已经销毁,bridge 的 evaluateJavaScript 在已销毁的 WebView 上 no-op(不崩溃,只是回包丢失) |
最后一条是**唯一可能丢回包的场景**(子游戏发起分享后立刻退出),实务上极少发生。新架构的对策:在 `SubGameHandlers.backgameData` 内主动检查 `WeChatManager.shared.hasPendingShare`,若有 pending 则延迟 pop 或弹提示"还有分享待完成"。这是可选优化,默认实现不强制。
#### SceneDelegate 接入 WXApi 入口
```swift
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for ctx in URLContexts {
if QQShareManager.shared.handle(url: ctx.url) { continue }
// WXApi 的 handleOpenURL: 会调用其 delegate 的 onResp(也就是 WeChatManager.shared)
WXApi.handleOpenURL(ctx.url, delegate: WeChatManager.shared)
}
}
```
#### QQ SDK 同样模式
QQ SDK 也是全局 delegate,设计完全镜像:
```swift
@MainActor
public final class QQShareManager: NSObject {
public static let shared = QQShareManager()
private var pendingShare: CheckedContinuation<Void, QQError>?
// 与 WeChatManager 相同的 continuation 模式
}
```
#### 为什么这套机制是对的
| 维度 | 旧架构(delegate 传球) | 新架构(continuation map by state) |
|------|---------------------|-----------------------------------|
| 发起方记录 | 隐式 — 当前 delegate 持有者 | 显式 — pendingAuth map 的 key,pendingShare 的 continuation |
| 大厅发起回包归属 | 取决于回包时 delegate 是谁 → 易错 | 结构保证:LobbyHandlers.bridge = LobbyBridge,sharelogin / sharesuccess 必派大厅 |
| 子游戏发起回包归属 | 同上,易错 | 结构保证:SubGameHandlers.bridge = SubGameBridge,sharelogin / sharesuccess 必派子游戏 |
| 切换 VC 后是否丢回包 | 丢(delegate 已换) | 不丢(map / continuation 不受 VC 切换影响) |
| 并发发起 | 串扰(后发起的覆盖前 delegate) | 授权按 state 自然隔离;分享串行化(后发起者抛 `.busy`,不覆盖) |
| 测试 | 难(全局状态) | 易(WeChatManager / ShareCenter 可注入 mock) |
| 代码可读性 | 散落 `delegate = self / nil` | 集中在 WeChatManager,调用方只看到 `try await` |
#### 微信 / QQ 派发机制清单
| 操作 | 配对方式 | 并发约束 |
|------|----------|---------|
| 微信授权 `authorize()` | `state` UUID(微信回传) | 允许多并发(每个 state 独立) |
| 微信分享 `share()` | 全局 FIFO | 串行(同时只能一个 pending,新请求抛 `.busy`) |
| QQ 登录 / 分享 | 全局 FIFO + 操作类型标识 | 同上 |
→ 上层 `accreditLogin` / `friendsShare...` handler 看不到这些细节,只看到 `try await`,代码大厅 / 子游戏完全一致。
### 8.6 LocationKit
覆盖契约 §F §3.1 20`startlocation` + §3.2 6`getlocationinfo` 反向 callback 共 2 项接口。
#### 8.6.1 Module 骨架
依赖高德 `AMapLocationKit.xcframework`ADR-006 走 Vendor 手动接入),用 Swift wrapper 隔离 Objective-C SDK,避免业务层接触 AMap 类型。
```swift
// Source/LocationKit/LocationService.swift
import AMapLocationKit
@MainActor
public final class LocationService: NSObject, AMapLocationManagerDelegate {
/// 启动期完成 AMap privacy + key 配置(详见 §4.2 启动并行任务)
public static func bootstrap() {
AMapServices.shared().enableHTTPS = true
AMapServices.shared().apiKey = BundleConfig.shared.amapKey
AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapLocationManager.updatePrivacyAgree(.didAgree)
}
private let manager: AMapLocationManager = {
let m = AMapLocationManager()
m.desiredAccuracy = kCLLocationAccuracyHundredMeters
m.locationTimeout = 6
m.reGeocodeTimeout = 4
m.locatingWithReGeocode = true
return m
}()
private var continuousActive = false
public override init() {
super.init()
manager.delegate = self
}
// ─── 一次性定位(startlocation 入参 ≠ 1)──────────────
public func locateOnce() async throws -> LocationInfo {
try await withCheckedThrowingContinuation { cont in
manager.requestLocation(withReGeocode: true) { loc, reGeo, error in
if let error = error {
cont.resume(throwing: error)
return
}
guard let loc = loc, let reGeo = reGeo else {
cont.resume(throwing: LocationError.empty)
return
}
cont.resume(returning: LocationInfo(coord: loc, reGeo: reGeo))
}
}
}
// ─── 持续定位(startlocation 入参 == 1)─────────────────
public var onContinuousUpdate: ((LocationInfo) -> Void)?
public func startContinuous() {
guard !continuousActive else { return }
continuousActive = true
manager.startUpdatingLocation()
}
public func stopContinuous() {
guard continuousActive else { return }
continuousActive = false
manager.stopUpdatingLocation()
}
public func amapLocationManager(_ manager: AMapLocationManager!,
didUpdate location: CLLocation!,
reGeocode: AMapLocationReGeocode!) {
guard let loc = location, let reGeo = reGeocode else { return }
onContinuousUpdate?(LocationInfo(coord: loc, reGeo: reGeo))
}
}
public struct LocationInfo: Sendable {
public let address: String
public let city: String
public let cityCode: String
public let country: String
public let district: String
public let latitude: String // ⚠️ string,契约硬约束(stringWithFormat:@"%f"
public let longitude: String // ⚠️ string
public let province: String // ⚠️ 小写 p,与 sharelogin 大写 P 不一致,沿用历史
public let street: String
init(coord: CLLocation, reGeo: AMapLocationReGeocode) {
address = reGeo.formattedAddress ?? ""
city = reGeo.city ?? ""
cityCode = reGeo.citycode ?? ""
country = reGeo.country ?? ""
district = reGeo.district ?? ""
latitude = String(format: "%f", coord.coordinate.latitude)
longitude = String(format: "%f", coord.coordinate.longitude)
province = reGeo.province ?? ""
street = reGeo.street ?? ""
}
}
public enum LocationError: Error, Sendable {
case empty
case permissionDenied(code: Int = 12)
}
```
#### 8.6.2 H5 location handler 实现骨架
```swift
// Source/Bridge/Handlers/LocationHandlers.swift
@MainActor
public struct LocationHandlers {
let bridge: BridgeProtocol
let service: LocationService
public func register() {
bridge.register("startlocation", handler: startLocation)
}
// MARK: - 【20】 startlocation
//
// 契约 §3.1 20]:
// 入参 data : int 1=持续 startUpdatingLocation
// 其他=一次性 reGeocodeAction
// responseCallback: "startlocation"
// 反向 callback : getlocationinfo(§3.2 6])
// 成功: 表 B 9 字段(latitude/longitude 是 string
// province 小写 p
// 失败: { errorCode: 12, errorMsg: "缺少定位权限" }
// ⚠️ errorCode 是数字(NSNumber)不是 string
//
// 持续模式下,service.onContinuousUpdate 在 register() 时挂钩;
// 一次性模式直接 await + 推一次
private func startLocation(_ data: BridgeData?, _ cb: BridgeCallback?) async {
defer { cb?(.string("startlocation")) }
let continuous = (data?.asInt ?? 0) == 1
// 持续模式:挂钩 + 启动 → 每次位置变化都推 getlocationinfo
if continuous {
service.onContinuousUpdate = { [weak bridge] info in
bridge?.call("getlocationinfo", data: info.bridgeData, callback: nil)
}
service.startContinuous()
return
}
// 一次性:await + 单次推 getlocationinfo
do {
let info = try await service.locateOnce()
bridge.call("getlocationinfo", data: info.bridgeData, callback: nil)
} catch {
// 契约 §3.2 表 B 失败结构:errorCode 是数字(不是 string
bridge.call("getlocationinfo",
data: .object([
"errorCode": .number(12),
"errorMsg": .string("缺少定位权限")
]),
callback: nil)
}
}
}
extension LocationInfo {
/// 转成契约 §3.2 表 B 的 BridgeData 结构(所有字段 string
var bridgeData: BridgeData {
.object([
"address": .string(address),
"city": .string(city),
"cityCode": .string(cityCode),
"country": .string(country),
"district": .string(district),
"latitude": .string(latitude), // stringified
"longitude": .string(longitude), // stringified
"province": .string(province), // 小写 p
"street": .string(street)
])
}
}
```
#### 8.6.3 与原 msext 的差异
| 维度 | msext 现状(不要照抄) | 新外壳决策 |
|------|--------------------|----------|
| SDK 接入 | CocoaPods `AMapLocation` 包含的 framework + Reachability headers 多重耦合 | Vendor 手动 `AMapLocationKit.xcframework`ADR-006),SPM/CocoaPods 双关 |
| 隐私合规 | `updatePrivacyShow:.didShow` / `updatePrivacyAgree:.didAgree` 在 AppDelegate 散落 | `LocationService.bootstrap()` 集中调用 |
| 回调风格 | `delegate amapLocationManager:didUpdateLocation:reGeocode:` 散落赋值给 self | `async/await` + `onContinuousUpdate` 闭包,actor 安全 |
| 持续/一次性入参 | 入参 string 类型,`[data intValue]` 转 int 后判断 | BridgeData.asInt 直接拿 |
| latitude 字段类型 | `stringWithFormat:@"%f"` 字符串 | `String(format: "%f", ...)`(等价) |
| province 大小写 | 小写 p(与 sharelogin 大写 P 不一致)| 严格保持 |
### 8.7 ShakeKit
覆盖契约 §C §3.1 7][8] [9] 摇一摇 3 项 + §3.2 5`shakeEnd` 反向 callback,共 4 项接口。
设备摇动的真正事件源是 `UIResponder.motionEnded(_:with:)`(系统检测晃动后回调),ShakeKit 把它包装为:开关位(canshake / canvoice+ 摇一摇音效播放 + 触发 shakeEnd callback。
#### 8.7.1 Module 骨架
```swift
// Source/ShakeKit/ShakeKit.swift
@MainActor
public final class ShakeKit {
/// 摇一摇总开关(startshake / stopshake 控制)
public private(set) var canShake: Bool = false
/// 摇一摇音效开关(SwitchShake 控制)
public private(set) var canVoice: Bool = true
public var onShakeEnded: (() -> Void)?
private let audioPlayer: AudioPlayer
private let shakeSoundURL: URL // 拷贝自 docs/res/Res/shake_sound_male.mp3 → Bundle
public init(audioPlayer: AudioPlayer, shakeSoundURL: URL) {
self.audioPlayer = audioPlayer
self.shakeSoundURL = shakeSoundURL
}
public func setShakeEnabled(_ on: Bool) { canShake = on }
public func setVoiceEnabled(_ on: Bool) { canVoice = on }
/// 由 WebContainerViewController.motionEnded(_:with:) 转发调用
public func handleMotionEnded(_ motion: UIEvent.EventSubtype) {
guard motion == .motionShake, canShake else { return }
if canVoice { audioPlayer.playOnce(shakeSoundURL) }
onShakeEnded?()
}
}
```
**接入 VC 层**(在 §4.2.1「启动期容易遗漏的契约点」已声明 `UIApplication.shared.applicationSupportsShakeToEdit = true`,本节补 motionEnded 转发):
```swift
extension WebContainerViewController {
public override var canBecomeFirstResponder: Bool { true }
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// 必须主动 becomeFirstResponder,否则 motionEnded: 不会触发
becomeFirstResponder()
}
public override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
shakeKit.handleMotionEnded(motion)
}
}
```
#### 8.7.2 H5 shake handlers + 反向 callback 骨架
```swift
// Source/Bridge/Handlers/ShakeHandlers.swift
@MainActor
public struct ShakeHandlers {
let bridge: BridgeProtocol
let shakeKit: ShakeKit
public func register() {
bridge.register("startshake", handler: startShake)
bridge.register("stopshake", handler: stopShake)
bridge.register("SwitchShake", handler: switchShake)
bindReverseCallback()
}
// MARK: - 【7】 startshake
//
// 契约 §3.1 7]:
// 入参 : 忽略 → canshake = YES
// responseCallback: "startshake from accreditlogin"
// (字面字符串带 from accreditlogin 后缀,沿用历史;
// H5 不依赖此值,但严格拷贝保留契约等价)
private func startShake(_ data: BridgeData?, _ cb: BridgeCallback?) async {
shakeKit.setShakeEnabled(true)
cb?(.string("startshake from accreditlogin"))
}
// MARK: - 【8】 stopshake
//
// 契约 §3.1 8]:
// 入参 : 忽略 → canshake = NO
// responseCallback: 同 startshake"startshake from accreditlogin"
private func stopShake(_ data: BridgeData?, _ cb: BridgeCallback?) async {
shakeKit.setShakeEnabled(false)
cb?(.string("startshake from accreditlogin"))
}
// MARK: - 【9】 SwitchShake — 摇一摇音效开关
//
// 契约 §3.1 9]:
// 入参 : int 1 → canvoice = YES,其他 → canvoice = NO
// responseCallback: "SwitchShake"
private func switchShake(_ data: BridgeData?, _ cb: BridgeCallback?) async {
defer { cb?(.string("SwitchShake")) }
let on = (data?.asInt ?? 0) == 1
shakeKit.setVoiceEnabled(on)
}
// MARK: - §3.2 5 shakeEnd 反向 callback
//
// 触发时机:UIResponder.motionEnded(.motionShake, with:) 且 canshake == YES
// 数据:nil(契约规定 data 为 nil,不传额外字段)
private func bindReverseCallback() {
shakeKit.onShakeEnded = { [weak bridge] in
bridge?.call("shakeEnd", data: nil, callback: nil)
}
}
}
```
#### 8.7.3 与原 msext 的差异
| 维度 | msext 现状(不要照抄) | 新外壳决策 |
|------|--------------------|----------|
| 开关状态 | `static BOOL canshake` / `static BOOL canvoice` 全局变量 | `@MainActor ShakeKit` 实例属性,VC 注入 |
| 摇一摇音效 | `[FuncPublic playWithFileName:@"shake_sound_male.mp3"]` 散落 | 复用 AudioKit 的 `AudioPlayer.playOnce`,统一路径 |
| 摇动事件 | 大厅 / 子游戏 VC 各自实现 `motionEnded:` 各自判断 | ShakeKit 集中判断 + 单点 callback 触发 |
| canBecomeFirstResponder | 大厅 VC override 为 YES,子游戏沿用默认 NO(潜在漏点) | 统一在 WebContainerViewController 基类内 override = true |
| responseCallback 字面 | `"startshake from accreditlogin"` 字面奇怪但沿用 | 严格保留(H5 不读,仅保契约不破) |
| shakeEnd 数据 | `nil` | 严格保留(Bridge.call data: nil |
---
## 9. 并发模型
### 9.1 Actor 隔离
- `BridgeBus``@MainActor`(WKWebView 必须主线程)
- `HTTPClient` / `SGGateway` / `ConfigService` / `ResourceUnzipper` / `AudioRecorder``actor`(各自串行)
- 数据模型 — `Sendable struct`(值类型,自由跨 actor)
- 跨 actor 通信 — 全用 `await`,**不允许 GCD 直接 `dispatch_async`**
### 9.2 严禁的反模式
```swift
// ❌ 不要
DispatchQueue.main.async { self.bridge.call("foo", data: ...) }
// ✅ 要
Task { @MainActor in bridge.call("foo", data: ...) }
```
### 9.3 启用 Swift 6 严格并发
`Package.swift`:
```swift
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("ConciseMagicFile")
]
```
---
## 10. 持久化
只用两种存储,**不引入数据库**:
| 数据 | 存储 | 备注 |
|------|------|------|
| 用户偏好(everLaunched / VersionInfo / getcompareCode 等) | `UserDefaults` | key 名是 H5 契约(通过 app_data.js 暴露给 H5),不允许变更 |
| 游戏 zip 解压物 | `Library/Caches/{gamedir}/` | 系统空间不足会清,需重新解压 |
| 录音临时文件 | `Library/Caches/recording/` | 上传完即删 |
| 下载中间产物 | `tmp/` | 系统自动清 |
```swift
// Foundation+/UserDefaults+TypedKey.swift
public enum DefaultsKey {
static let everLaunched = "everLaunched" // ⚠️ 旧 key,不改名
static let versionInfo = "VersionInfo"
static let compareCode = "getcompareCode"
static let firstBool = "FirstBOOL"
static let secondBool = "SecondBOOL"
static let thirdBool = "ThirdBOOL"
}
```
---
## 11. 错误处理与日志
### 11.1 错误分层
```swift
// Foundation+/AppError.swift
public enum AppError: Error {
case bridge(BridgeError)
case network(NetworkError)
case resource(ResourceError)
case audio(AudioError)
case auth(AuthError)
case sdk(String, underlying: Error)
}
```
### 11.2 统一日志
```swift
// Logger/Log.swift
import os
public enum Log {
private static let bridge = Logger(subsystem: "cn.daoqi", category: "Bridge")
private static let net = Logger(subsystem: "cn.daoqi", category: "Net")
private static let game = Logger(subsystem: "cn.daoqi", category: "Game")
public static func debug(_ msg: String, file: String = #file, line: Int = #line) {
#if DEBUG
bridge.debug("\(msg) [\(file):\(line)]")
#endif
}
public static func info(_ msg: String) { bridge.info("\(msg)") }
public static func warn(_ msg: String) { bridge.warning("\(msg)") }
public static func error(_ msg: String) {
bridge.error("\(msg)")
crashReporter.captureMessage(msg, level: .error) // Sentry by default
}
}
```
### 11.3 崩溃监控(默认接入 Sentry)
新项目自第一天起接入崩溃 / 性能监控。**默认选择:Sentry**(SaaS 版或自建实例均可)。
Sentry 选型理由:
- 社区主流、长期维护、Swift SDK 一等支持(SPM 直接装)
- 单一入口同时覆盖 Crash / 性能 transaction / breadcrumb / 用户行为
- 支持自建实例,可满足数据不出境的合规要求
- 提供编译开关 `SENTRY_ENABLED`,如未来某次决定退回无监控,关闭开关即可,业务代码不动
监控账号运维责任写入团队 onboarding 文档,避免后续运维断档导致监控形同虚设。
```swift
// AnalyticsKit/CrashReporter.swift
public protocol CrashReporter: Sendable {
func start()
func captureMessage(_ msg: String, level: Severity)
func captureError(_ error: Error, context: [String: Any]?)
func setUserContext(channel: String, gameId: String)
}
#if SENTRY_ENABLED // 编译开关,默认 ON
public struct SentryCrashReporter: CrashReporter {
public func start() {
SentrySDK.start { options in
options.dsn = AppIDs.sentryDSN
options.enableAutoPerformanceTracing = true
options.tracesSampleRate = 0.1
options.releaseName = "Daoqi@\(Bundle.main.shortVersion)+\(BundleConfig.shared.appVersion)"
options.environment = AppEnvironment.current.rawValue
options.attachScreenshot = false // 不上传截图,避免误传 H5 业务内容
}
}
public func setUserContext(channel: String, gameId: String) {
SentrySDK.setUser(User(userId: channel))
SentrySDK.setContext(value: ["gameId": gameId], key: "game")
}
public func captureMessage(_ msg: String, level: Severity) {
SentrySDK.capture(message: msg)
}
public func captureError(_ error: Error, context: [String: Any]?) {
SentrySDK.capture(error: error)
}
}
#else
public struct NoopCrashReporter: CrashReporter {
public func start() {}
public func setUserContext(channel: String, gameId: String) {}
public func captureMessage(_ msg: String, level: Severity) {}
public func captureError(_ error: Error, context: [String: Any]?) {}
}
#endif
```
**运维责任**:Sentry 后端账号必须有团队中至少 2 人持有访问凭证、季度检查存活性,避免重蹈 Bugly 覆辙(账号断档 = 监控失效)。
### 11.4 H5 错误回流通道
原生 Sentry 接住的是原生崩溃,但 **H5 端的 JS 错误、unhandled promise rejection、网络请求失败**,原生默认无感知。线上一旦 H5 业务出 bug,只能靠用户反馈,等同盲飞。
新外壳作为 H5 容器,**必须主动提供 JS 错误回流通道**。这是新外壳额外提供的能力,**不修改 H5 契约**(H5 端可选用,不强制),不影响原契约的任何行为。
#### 设计
注入一段 polyfill,在 H5 端劫持 `window.onerror``window.onunhandledrejection`,通过新桥事件 `reportH5Error` 回流到原生 Sentry:
```swift
// WebViewKit/H5ErrorRelay.swift
public enum H5ErrorRelay {
public static let jsPolyfill = """
(function() {
function relay(payload) {
try {
if (window.WebViewJavascriptBridge) {
window.WebViewJavascriptBridge.callHandler('reportH5Error', payload, null);
}
} catch (e) { /* swallow */ }
}
window.addEventListener('error', function(e) {
relay({
kind: 'error',
message: String(e.message || ''),
source: String(e.filename || ''),
line: e.lineno || 0,
col: e.colno || 0,
stack: (e.error && e.error.stack) ? String(e.error.stack) : ''
});
}, true);
window.addEventListener('unhandledrejection', function(e) {
relay({
kind: 'unhandledrejection',
message: String((e.reason && e.reason.message) || e.reason || ''),
stack: (e.reason && e.reason.stack) ? String(e.reason.stack) : ''
});
});
})();
"""
}
```
WebContainerViewController 用 `WKUserScript``atDocumentStart` 注入,确保在 H5 业务代码执行之前生效。
#### Handler 注册(共享 handler,大厅 / 子游戏 / 弹层都注册)
```swift
// BridgeHandlers/Common/H5ErrorHandler.swift
extension BridgeProtocol {
func registerH5ErrorRelay(role: String, crashReporter: CrashReporter) {
self.register("reportH5Error") { data, cb in
defer { cb?(.string("ok")) }
guard let payload = data,
let message = payload["message"]?.asString else { return }
let kind = payload["kind"]?.asString ?? "unknown"
let stack = payload["stack"]?.asString ?? ""
let source = payload["source"]?.asString ?? ""
crashReporter.captureMessage(
"[H5/\(role)/\(kind)] \(message)",
level: .error,
context: [
"h5_source": source,
"h5_stack": String(stack.prefix(2000)), // 截断,避免 payload 过大
"container_role": role
]
)
}
}
}
```
#### 上下文标签
每条 H5 错误上报时携带:
- `container_role` — lobby / subgame / overlay,定位错误归属容器
- `h5_source` — 出错的 JS 文件 URL(file:// 路径)
- `h5_stack` — 截断后的调用栈(2000 字符上限,避免 Sentry payload 过大)
- `channel` / `gameId`(从 `BundleConfig` 全局上下文自动带上)
#### 与契约的关系
`reportH5Error` 是**新增**的桥 handler,**不在 39 个契约接口列表里**:
- 现有 H5 业务代码不调用它 → 行为完全不变
- 未来 H5 团队若主动接入(在 H5 端加几行调用即可),则错误自动上报
- 即使原生注册了但 H5 永不调用,也不影响任何业务
→ 这是**契约兼容的纯增强**,可独立上线、可独立回滚。
---
## 12. 测试策略
### 12.1 单元测试矩阵
| 模块 | 关键用例 | 覆盖率目标 |
|------|---------|-----------|
| `BridgeCore` | handler 注册/分发/callback、错误隔离、JSON 序列化双向 | ≥ 90% |
| `ResourceKit` | filename: 等价行为、解压幂等、PathType 路径正确 | ≥ 85% |
| `NetworkKit` | SGGateway 签名算法、特殊接口包装、超时 | ≥ 80% |
| `AudioKit` | AMR↔WAV 转换正确、录音边界、播放生命周期 | ≥ 70% |
| `ShareKit` | type=1/2/3 与 sharetype=1/2/3 9 种组合路由 | 100% |
| 其余 Kit | 路径覆盖 | ≥ 60% |
| Coordinator | 不写单测(全集成测) | — |
### 12.2 契约测试(关键)
写一组黑盒测试,**直接模拟 H5 发起 bridge 消息**,验证 native 行为与 `H5-Native-Contract.md` 一致:
```swift
// Tests/Contract/SharingContractTests.swift
final class SharingContractTests: XCTestCase {
func test_friendsShare_dispatchesToWeChat_whenSharetype1() async {
let mockWeChat = MockSharePlatform()
let center = ShareCenter(weChat: mockWeChat, xianliao: MockSharePlatform())
let req = BridgeData.object([
"sharefriend": .string("1"),
"sharetype": .string("1"),
"type": .string("1"),
"webpageUrl": .string("https://test"),
"title": .string("T"), // ⚠️ 不是 "title "
"description": .string("D")
])
// simulate H5 → native bridge message
let bridge = TestBridge(handlers: [
"friendsSharetypeUrlToptitleDescript": center.handle
])
await bridge.receive(handler: "friendsSharetypeUrlToptitleDescript", data: req)
XCTAssertEqual(mockWeChat.calls.count, 1)
XCTAssertEqual(mockWeChat.calls[0].title, "T")
}
}
```
### 12.3 UI 集成测
用 XCUI 跑 §10 验收清单 26 项,在 CI 上每次 commit 自动执行。
---
## 13. 工程化
### 13.1 Xcode 配置
| 设置 | 值 |
|------|-----|
| Deployment Target | iOS 14.0 |
| Architectures | arm64(去掉 armv7) |
| Swift Language Version | 6 |
| Strict Concurrency Checking | Complete |
| Enable Modules | YES |
| Bitcode | NO(已废弃) |
| Code Signing | 自动 + 手动 provisioning(企业证书) |
| LLVM Optimization | -O(Release)、-Onone(Debug) |
| Dead Code Stripping | YES |
| Whole Module Optimization | YES(Release) |
### 13.2 CI 流水线(GitHub Actions / Jenkins)
```yaml
# .github/workflows/build.yml
name: Build & Test
on: [push, pull_request]
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Resolve SPM
run: xcodebuild -resolvePackageDependencies
- name: Lint
run: swiftlint
- name: Unit Tests
run: xcodebuild test -scheme Daoqi -destination 'platform=iOS Simulator,name=iPhone 16'
- name: Contract Tests
run: xcodebuild test -scheme DaoqiContract -destination '...'
- name: Archive (smoke)
run: xcodebuild archive -scheme Daoqi -archivePath build/Daoqi.xcarchive
```
### 13.3 多渠道发包
```bash
# Scripts/release.sh
CHANNELS="xianliao_001 douyin_002 inhouse_003"
for ch in $CHANNELS; do
source channels/$ch.env
./Scripts/inject_channel.sh "$CHANNEL" "$GAMEDIR" "$GAMEID" \
"$MARKET" "$AGENT" "$APPVERSION" "$QINIUDOMAIN" \
"$GAMESTART" "$GAMECONFIG"
xcodebuild archive ...
xcodebuild -exportArchive ...
mv build/Daoqi.ipa dist/Daoqi-$ch.ipa
done
```
### 13.4 SwiftLint / SwiftFormat
强制规则:
- 行长 ≤ 120
- 文件长度 ≤ 400 行(超出即模块/类拆分)
- 类型长度 ≤ 200 行
- 函数长度 ≤ 40 行
- 禁止 `force_unwrapping`(只允许在 `@testable` 中)
- 禁止 `force_cast`
---
## 14. 第三方 SDK 集成清单
### 14.1 依赖管理策略:SPM 优先 + Vendor `.xcframework` 手动
新项目采用**两层混合策略**,**完全不引入 CocoaPods 工具链**:
1. **首选 SPM** —— Apple 官方,Xcode 原生集成,无第三方工具依赖,无衍生工程文件污染,新人 clone 即用。适用于:Sentry、ZIPFoundation、七牛 v8.9+ 等**已发布官方 Swift Package** 的库
2. **Vendor 手动** —— `Vendor/` 目录直接放闭源 `.xcframework` / `.framework` / `.a`,target Build Phase 加入 `Link Binary With Libraries` + `Embed Frameworks`(动态库需要)。适用于:微信 OpenSDK、QQ OpenSDK、AMap 高德定位、opencore-amr 等**官方不提供 Swift Package** 的二进制
#### 为什么不引入 CocoaPods
ADR-006 决策记录(见 `docs/Development-Plan.md` §9)详细背景。简短理由:
- **唯一会触发 Pods 的 SDK 是 AMap 高德定位**(其它 SDK 要么 SPM,要么本就是 Vendor 手动);为 1 个 SDK 引入完整 Ruby + Bundler + CocoaPods 工具链性价比低
- **Xcode 26 与 CocoaPods 的兼容性问题**:Xcode 26 默认 `PBXFileSystemSynchronizedRootGroup`,Homebrew 的 CocoaPods 1.15.2 自带 `xcodeproj` gem 1.24.0 不识别,需绕道 Bundler + Gemfile 锁定 1.16+
- **生态趋势**:Google 已宣布 2026 Q2 后停止 iOS SDK 的 CocoaPods 支持,Firebase / GoogleMaps 转 SPM
- **AMap 手动接入成本可控**:闭源 XCFramework 升级频率年度级,每次 30 分钟手动操作,对比 CocoaPods 工具链长期维护更低成本
#### 为什么不选纯 SPM
微信 / QQ / 闲聊 / JAnalytics / AMap 等闭源 SDK 官方至今(2026)未提供 Swift Package,强行纯 SPM 化需要自己包一层私有 Wrapper Package,维护成本反而上升。
#### 决策方法
新增第三方依赖时,**按 SPM → Vendor 顺序尝试**,前一项无法满足才退到下一项。任何团队成员把已 SPM 化的 SDK 改成 Vendor,需在 PR 描述里写明理由。**禁止引入 CocoaPods**(违反 ADR-006)。
#### Vendor 接入标准流程
闭源 SDK Vendor 化按以下顺序操作,保持工程一致性:
1. SDK 二进制放 `Vendor/<SDKName>/<SDKName>.xcframework`(动态库)或 `Vendor/<SDKName>/lib<name>.a` + 头文件目录(静态库)
2. Xcode → Target → General → Frameworks, Libraries, and Embedded Content → `+` 加入,动态库选 "Embed & Sign",静态库选 "Do Not Embed"
3. 静态库 `Library Search Paths` / 头文件 `Header Search Paths` 写到 `$(PROJECT_DIR)/Vendor/<SDKName>` 相对路径,**不写绝对路径**
4. 新增 module bridging header(若是 Swift 调 OC SDK)
5. `Vendor/<SDKName>/README.md` 写明版本号 / 下载日期 / 官方更新页 URL
6. SDK 二进制入 git(配合 git-lfs 处理大文件;当前项目不强制 lfs,因为 SDK 体积可控)
### 14.2 SDK 详细清单
| SDK | 接入方式 | 版本 | 状态 / 启动时机 |
|-----|---------|------|--------------|
| WeChat OpenSDK | Vendor `.framework` | latest 2.x | ✅ 启用,启动注册 |
| QQShare | Vendor | latest | ✅ 启用,启动注册 |
| **Xianliao** | — | — | ⏸️ **暂不集成**(`ShareCenter.xianliao = NoopSharePlatform`,§8.2)。当 H5 调 `friendsShare...({sharetype:"3"})` 时,Noop 立即回 `sharesuccess`,业务流不中断。启用时把 Vendor `.framework` 放入 + 把 `NoopSharePlatform` 换为 `XianliaoShare` |
| **Agora RTC** | — | — | ⏸️ **暂不集成**(`VideoRoom = NoopVideoRoom`,§8.3)。子游戏视频房间 3 个 handler 当前 stub 实现。启用时按 §8.3 末尾的 4 步说明切换 |
| AMap Location | Vendor `.xcframework`(官方暂无 SPM,ADR-006 决策不引 CocoaPods) | latest 2.x | ✅ 启用,启动注册;下载页 `https://lbs.amap.com/api/ios-location-sdk/download` |
| Qiniu SDK | **SPM** `https://github.com/qiniu/objc-sdk` | **8.9.x** | ✅ 启用,录音上传时初始化;import `QiniuSDK` |
| opencore-amr | 静态 `.a`(直接接入 — H5 端通过此库收发的 AMR 音频是契约边界) | 2014 版本 | ✅ 启用 |
| ZIPFoundation | **SPM** | latest | ✅ 启用 |
| Sentry-Cocoa | **SPM**(默认 ON,编译开关 `SENTRY_ENABLED` 提供逃生口) | latest | ✅ 启用,启动并发注册 |
| **JAnalytics(极光)** | — | — | ⏸️ **暂不集成**(`AnalyticsKit.tracker = NoopAnalytics`)。新外壳首版不接用户统计 — 现网 msext 的极光后端运维状态不可知,且 Sentry 的 breadcrumb / transaction 可兜一部分行为指标。启用时:① Vendor `.framework` 放入 ② `NoopAnalytics` 换为 `JAnalyticsTracker` ③ Info.plist 加 AppKey ④ Bootstrapper 注册 |
| **Bugly** | — | — | ⏸️ **不集成**。Sentry 已覆盖崩溃 / 性能监控,无需 Bugly。未来若有强需求(如旧外壳数据迁移),再单独评估 |
> **微信 OAuth 架构决策**:新项目走**后台中转** —— 客户端把 `code` 发给后台 `/wechat/login` 接口,由后台完成 `access_token` + `userinfo` 拉取,再回传 7 个字段给客户端,客户端最终 `callHandler:@"sharelogin"` 给 H5。
> - 优势:secret 不进 IPA,无客户端泄露风险
> - 前置依赖:后台需提供 `/wechat/login` 接口(项目方协调)
> - Fallback:若后台暂未就绪,临时由客户端完成 OAuth,但 `LoginKit/WeChatAuth.swift` 内必须标 `// FIXME: 待后台 /wechat/login 就绪后切换` 注释,作为 P0 技术债项跟踪
> - 无论实现路径如何切换,`sharelogin` 7 字段(H5 契约)保持不变
---
## 15. 性能指标 & SLO
| 指标 | 目标 | 测量方式 |
|------|------|---------|
| 冷启动 → 大厅 H5 可点击 | < 1.5 s(P90) | Sentry transaction + Xcode Instruments + os_signpost |
| 桥接消息 RTT(简单 handler) | < 16 ms(P95) | 单测 + 真机 profiling |
| 进入子游戏 → 视频房间建立 | < 800 ms | Sentry transaction |
| AMR → WAV 转换 | < 100 ms(P95) | 单测 baseline |
| 内存峰值 | < 200 MB | Memory gauge |
| 录音上传 30s 文件 | < 3 s(P95) | Sentry transaction |
| Crash-free session rate | ≥ 99.5% | Sentry |
---
## 16. 里程碑 / 排期(假设 2-3 人团队)
| 里程碑 | 内容 | 估时 |
|-------|------|------|
| **M0 立项** | Xcode 工程骨架、SPM 切分、CI 跑通、Lint 落地 | 1 周 |
| **M1 桥核心** | BridgeCore / WebViewKit / 所有桥协议、Contract 单测 | 2 周 |
| **M2 基础能力** | ResourceKit / NetworkKit / DeviceKit / Logger / Sentry | 2 周 |
| **M3 大厅 happy path** | Lobby + 20 个 handler 全联通,跑 §10 A/B/C 节验收 | 3 周 |
| **M4 子游戏 + 视频房间** | SubGame + Agora 包装 + 视频 4 handler,跑 §10 D 验收 | 3 周 |
| **M5 弹层 + 分享 + 登录** | Overlay + ShareKit + LoginKit,§10 全部通过 | 2 周 |
| **M6 真机回归 + 多渠道打包** | 真机覆盖 iOS 14-26,5 个渠道出包,UAT | 2 周 |
| **M7 灰度 + 上线** | 内部 50 人 → 100 人 → 全量切流 | 2 周 |
| **合计** | | **17 周(约 4 个月)** |
### 16.1 切流策略
-`msext` IPA 与新 `Daoqi` IPA 共存(不同 Bundle ID),用户手动覆盖安装
- 灰度按渠道:小渠道先切,大渠道(主市场)最后切
- 灰度期内,旧 msext 外壳保持线上不动,避免双线推送破坏现网用户(防御性约束)
---
## 17. 风险与缓解
| 风险 | 概率 | 影响 | 缓解 |
|------|------|------|------|
| H5 端隐藏依赖未在契约中描述 | 中 | 高 | M3 起开始真机回归,用旧外壳的全部 H5 通路对比;§10 验收清单逐项过 |
| WVJB JS 协议不完全兼容 | 中 | 高 | 直接复用 marcuswestin/WebViewJavascriptBridge 的 JS 端原文,不重写;Bridge 单元测试 ≥ 90% |
| Agora SDK 版本升级破坏视频房间 | 低 | 中 | 锁版本,Agora 用 protocol 抽象,后续升级一处替换 |
| opencore-amr fat 库后续 Xcode 版本无法链接 | 低 | 高 | 已知方案:`lipo -thin` 拆分各 slice 后用 `lipo -create -segalign <arch> 8` 重新打包(根因是 2014 年 fat header 的 architecture slice `align=2^2`,新链接器 `ld_prime` 要求 ≥2^3);最坏从源码重编 |
| Sentry DSN 泄露 | 低 | 低 | 通过 Xcode build setting 注入,不入 Git |
| 微信 Appsecret 客户端泄露 | 高 | 高 | 走后台 `/wechat/login` 中转,secret 永不进 IPA;若后台未就绪,临时由客户端完成 OAuth 并标 P0 TODO,后台到位后立刻切换 |
---
## 18. 开发规约
1. **commit message** 中文短句,主题+原因,参考 `git log` 现有风格
2. **PR 单一职责**:重构 PR 不混 bugfix;桥接 handler 修改单独 PR,要附契约验收截图
3. **新增 H5 handler 流程**:① 在 `H5-Native-Contract.md` 加描述 ② 写 contract 单测 ③ 实现 handler ④ 真机 H5 端联调
4. **删除/重命名任何已存在的 handler 名禁止**(契约违反),除非 H5 同步发版
5. **代码评审强制**:任何接触 `BridgeCore` / `BundleConfig` / `SandboxPaths` 的 PR 必须 2 人 +1
6. **文档与代码同步**:`BridgeProtocol` 协议变动必须同步更新本文档与 Contract 文档
---
## 19. 与既有项目的关系
- 本项目独立目录(`/Daoqi/`),不与 `/msext/` 共用任何文件
- `gamehall.zip` 直接复用现网产物,放进 `Resources/`
- `Vendor/` 下的 SDK 二进制可参照 msext 的 `Pods/` / `Frameworks/` 找到对应版本,但**不要走 CocoaPods 流程**(ADR-006);从官方下载页或 msext 仓库直接拷贝 `.xcframework` / `.framework` / `.a``Vendor/`
- 渠道配置脚本可与现 `msext` 共享同一 Jenkins job 参数表
- 灰度期间旧 msext 外壳保持线上不动(避免双线推送);新外壳全量切流稳定后再评估是否归档 msext target
---
## 附录:启动文件最小骨架
```swift
// App/AppDelegate.swift
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions opts: ...) -> Bool { true }
func application(_ app: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
UISceneConfiguration(name: "Main", sessionRole: connectingSceneSession.role)
}
}
// App/SceneDelegate.swift
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var coordinator: AppCoordinator?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options: UIScene.ConnectionOptions) {
guard let scene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: scene)
coordinator = AppCoordinator(window: window!)
Task { @MainActor in
await coordinator?.start()
}
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for ctx in URLContexts {
if QQShareSDK.handle(url: ctx.url) { continue }
WeChatSDK.handle(url: ctx.url)
}
}
func sceneDidEnterBackground(_ scene: UIScene) {
NotificationCenter.default.post(name: .appDidEnterBackground, object: nil)
}
func sceneDidBecomeActive(_ scene: UIScene) {
NotificationCenter.default.post(name: .appDidBecomeActive, object: nil)
}
}
// AppCoordinator/AppCoordinator.swift
@MainActor
final class AppCoordinator {
private let window: UIWindow
private let nav = NavigationController()
init(window: UIWindow) {
self.window = window
window.rootViewController = nav
window.makeKeyAndVisible()
}
func start() async {
await Bootstrapper.shared.run()
let lobby = LobbyAssembly.assemble(coordinator: self)
nav.setViewControllers([lobby], animated: false)
}
func showSubGame(_ params: SwitchGameParams) {
let vc = SubGameAssembly.assemble(params: params, coordinator: self)
nav.pushViewController(vc, animated: true)
}
func showOverlay(_ params: OverlayParams) {
let vc = OverlayAssembly.assemble(params: params, coordinator: self)
nav.pushViewController(vc, animated: true)
}
}
```
---
## 20. 附录:完整 handler / callback / JS 注入清单
> 本附录是实施时的"清单核对表",必须 1:1 落地。任何字段差异都视为契约违反。
> 与 `H5-Native-Contract.md` §3 / §4.2 一一对应,**以契约为准**;本附录是契约的 Swift 落地骨架。
### 20.1 LobbyHandlers — 大厅注册 20 个 handler
> **架构说明**(与 §8.x 各 module 章节同步):本节早期版本把 20 项 handler 全列在单一 `LobbyHandlers struct.register()` 内,便于一眼看全。实际实施时(§8.1.1 / §8.4.1 / §8.6 / §8.7 / §3.4.2)按 module 维度拆为多个 sub-struct`AudioHandlers` / `DeviceHandlers` / `LocationHandlers` / `ShakeHandlers` / `OpenurlTitleDataHandler` 等),`LobbyHandlers` 成为聚合点:
>
> ```swift
> public struct LobbyHandlers {
> let bridge: BridgeProtocol
> let audioHandlers: AudioHandlers // §8.1.1
> let deviceHandlers: DeviceHandlers // §8.4.1
> let locationHandlers: LocationHandlers // §8.6
> let shakeHandlers: ShakeHandlers // §8.7
> let openurlHandler: OpenurlTitleDataHandler // §3.4.2
> // 其它单点:accreditLogin / friendsShare / browser / SwitchOverGameData /
> // opensaoma stub 留在 LobbyHandlers 本体
>
> public func register() {
> audioHandlers.register()
> deviceHandlers.register()
> locationHandlers.register()
> shakeHandlers.register()
> openurlHandler.register()
> registerLobbyOnly() // accreditLogin / friendsShare / browser / SwitchOverGameData / opensaoma
> }
> }
> ```
>
> 下方 monolithic register 调用清单**作为 20 项接口的速查表保留**(与 Contract §3.1 编号对账用),实际实施请按上述 sub-struct 拆分。
```swift
// LobbyHandlers 聚合视角的 20 项接口速查表(与 Contract §3.1 编号一一对应)
func register() {
// 登录 / 分享 (留在 LobbyHandlers 本体)
bridge.register("accreditlogin", handler: accreditLogin) // §3.1 1
bridge.register("friendsSharetypeUrlToptitleDescript", handler: friendsShare) // §3.1 2
// 音频 / 录音 AudioHandlers, §8.1.1
bridge.register("srcIsloop", handler: srcIsLoop) // §3.1 3
bridge.register("prepareaudio", handler: prepareAudio) // §3.1 4
bridge.register("mediaTypeAudio", handler: mediaTypeAudio) // §3.1 5
bridge.register("voicePlaying", handler: voicePlaying) // §3.1 6
// 摇一摇 ShakeHandlers, §8.7
bridge.register("startshake", handler: startShake) // §3.1 7
bridge.register("stopshake", handler: stopShake) // §3.1 8
bridge.register("SwitchShake", handler: switchShake) // §3.1 9
// 振动 + 剪贴板 + 设备 DeviceHandlers, §8.4.1
bridge.register("vibrator", handler: vibrator) // §3.1 10
bridge.register("repeatvibrator", handler: repeatVibrator) // §3.1 11
bridge.register("canclevibrator", handler: cancelVibrator) // §3.1 12
bridge.register("gamepastetext", handler: gamePasteText) // §3.1 13
bridge.register("gameCopytext", handler: gameCopyText) // §3.1 14
bridge.register("getphoneInfo", handler: getPhoneInfo) // §3.1 21
// 网页 / 浏览器 / 跳子游戏 OpenurlTitleDataHandler §3.4.2 + LobbyHandlers 本体)
bridge.register("OpenurlTitleData", handler: openUrlTitleData) // §3.1 15
bridge.register("browser", handler: browser) // §3.1 16
bridge.register("SwitchOverGameData", handler: switchOverGameData) // §3.1 17
// 定位 LocationHandlers, §8.6
bridge.register("startlocation", handler: startLocation) // §3.1 20
// 扫码 stub LobbyHandlers 本体)
bridge.register("opensaoma", handler: openSaoma) // §3.1 [22] ⚠️ 空实现仍要注册
}
```
### 20.2 SubGameHandlers — 子游戏注册 23 个 handler
```swift
func register() {
// 共享 19 个(SwitchOverGameData 除外)
registerLobbyCommon() // accreditlogin / friendsSharetypeUrlToptitleDescript / ...
// ... vibrator / repeatvibrator / canclevibrator /
// gamepastetext / gameCopytext / OpenurlTitleData /
// browser / startlocation / getphoneInfo / opensaoma /
// srcIsloop / prepareaudio / mediaTypeAudio / voicePlaying /
// startshake / stopshake / SwitchShake
// = 19 个
// 子游戏独有 4 个
bridge.register("backgameData", handler: backgameData)
bridge.register("createRoom", handler: createRoom)
bridge.register("getVideoinfo", handler: getVideoInfo) // 注意:同名反向也有
bridge.register("exitRoom", handler: exitRoom)
}
```
### 20.3 OverlayHandlers — 弹层 3 个(走 settings.* polyfill)
```swift
// WKScriptMessageHandler 注册到 userContentController
let names = ["overlayBackgameData", "overlayBrowser", "overlayFinishweb"]
for name in names {
cfg.userContentController.add(self, name: name)
}
func userContentController(_: WKUserContentController,
didReceive msg: WKScriptMessage) {
switch msg.name {
case "overlayBackgameData":
NotificationCenter.default.post(name: .overlayBackgameData,
object: msg.body as? String ?? "")
coordinator.popOverlay()
case "overlayBrowser":
if let url = URL(string: msg.body as? String ?? "") {
UIApplication.shared.open(url)
}
case "overlayFinishweb":
coordinator.popOverlay()
default: break
}
}
```
### 20.4 反向 callback(LobbyBridge 共 12 + SubGame 额外 3 = 15 个)
```swift
extension BridgeProtocol {
// 大厅 + 子游戏共享 12 个
func callGetPhoneInfo(_ info: [String: String]) { call("getphoneinfo", data: .object(info.mapValues(BridgeData.string)), callback: nil) }
func callGetBattery(_ level: Float) { call("getBattery", data: .string(String(format: "%.2f", level)), callback: nil) }
func callGetNetwork(_ state: NetworkState) { call("getnetwork", data: .string(state.rawValue), callback: nil) } // "1"/"2"/"3"
func callAppService(_ s: AppLifecycleState) { call("appservice", data: .string(s == .background ? "2" : "1"), callback: nil) } // 值错位沿用 msext WKWebView 路径
func callShakeEnd() { call("shakeEnd", data: nil, callback: nil) }
func callGetLocationInfo(_ d: LocationPayload) { call("getlocationinfo", data: .object(d.dict), callback: nil) } // 字段见契约 §3.2 表B
func callGameUIPlayVoice(_ user: String) { call("gameui_play_voice", data: .string(user), callback: nil) }
func callGameUIStopVoice(_ user: String) { call("gameui_stop_voice", data: .string(user), callback: nil) }
func callGetAudioUrl(_ url: String, time: Int) { call("getaudiourl", data: .object([
"audiourl": .string(url),
"time": .string(String(time))
]), callback: nil) }
func callShareLogin(_ user: WeChatUser) { call("sharelogin", data: .object([
"openid": .string(user.openid),
"headimgurl": .string(user.headimgurl),
"nickname": .string(user.nickname),
"sex": .string(String(user.sex)),
"city": .string(user.city),
"Province": .string(user.province), // ⚠️ 大写 P
"unionid": .string(user.unionid)
]), callback: nil) }
func callShareSuccess(type: String) { call("sharesuccess", data: .object([
"success": .string("2"),
"type": .string(type)
]), callback: nil) }
func callGetWebData(_ data: String) { call("getWebdata", data: .string(data), callback: nil) }
// 子游戏独有 3 个(LobbyBridge 不要注册)
func callGetVideoInfo(_ uid: UInt) { call("getVideoinfo", data: .string(String(uid)), callback: nil) }
func callPhoneState(_ state: CallState) { call("phonestate", data: .string(state == .incoming ? "2" : "0"), callback: nil) }
func callRecordSuccess(_ file: UploadedFile) { call("recordSuccess", data: .object([
"fileUrl": .string(file.publicUrl),
"fileName": .string(file.fileName),
"fileKey": .string(file.qiniuKey)
]), callback: nil) }
}
```
### 20.5 JS 注入文件(契约 §4.2)
**每次 loadFileURL 之前**写入 3 个 JS 文件到 `Library/Caches/{gamedir}/{gamestart}/` 下,内容由 BundleConfig + DeviceKit + NetworkMonitor 提供:
```swift
// WebViewKit/JSInjector.swift
public enum JSInjector {
public static func writeStartupJS() throws {
let dir = SandboxPaths.caches
.appendingPathComponent(BundleConfig.shared.gameDir)
.appendingPathComponent(BundleConfig.shared.gameStart)
try writeAppData(into: dir)
try writeAppBattery(into: dir)
try writeAppNetwork(into: dir)
}
private static func writeAppData(into dir: URL) throws {
let cfg = BundleConfig.shared
let compareCode = UserDefaults.standard.string(forKey: DefaultsKey.compareCode) ?? "0"
let js = """
var app_gameconfig = "\(cfg.gameConfig.escapedForJS)";
var app_gamedir = "\(cfg.gameDir)";
var app_gamestart = "\(cfg.gameStart)";
var app_agent = "\(cfg.agent)";
var app_appversion = "\(cfg.appVersion)";
var app_market = "\(cfg.market)";
var app_channel = "\(cfg.channel)";
var app_gameid = "\(cfg.gameId)";
var app_compareCode = "\(compareCode)";
"""
try js.write(to: dir.appendingPathComponent("app_data.js"),
atomically: true, encoding: .utf8)
}
private static func writeAppBattery(into dir: URL) throws {
UIDevice.current.isBatteryMonitoringEnabled = true
let js = "var app_battery = \"\(UIDevice.current.batteryLevel)\";"
try js.write(to: dir.appendingPathComponent("app_battery.js"),
atomically: true, encoding: .utf8)
}
private static func writeAppNetwork(into dir: URL) throws {
let state = NetworkMonitor.shared.currentState.rawValue // "1"/"2"/"3"
let js = "var app_network = \"\(state)\";"
try js.write(to: dir.appendingPathComponent("app_network.js"),
atomically: true, encoding: .utf8)
}
}
```
H5 端 `index.html` 通过 `<script src="app_data.js"></script>` 等同步引入。新外壳如果不写这三个文件,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