Files
youle_app_ios_v2/docs/H5-Native-Implementation-Design.md
T
joywayer 17a8b96cf8 docs:依赖管理改为纯 SPM + Vendor,移除 CocoaPods(ADR-006)
调研发现 Qiniu SDK 已官方支持 SPM(https://github.com/qiniu/objc-sdk
v8.9.x),AMap 仍仅支持 CocoaPods 或手动 XCFramework;CocoaPods 1.15.2
不兼容 Xcode 26 的 PBXFileSystemSynchronizedRootGroup,需绕道 Bundler
才能升到 1.16+。综合性价比决定不引入 CocoaPods 工具链,所有闭源 SDK
(含 AMap)统一走 Vendor .xcframework 手动接入。

- Design §14.1 三层策略改写为两层(SPM + Vendor),新增 Vendor 接入
  标准流程 6 步法
- Design §14.2 AMap 接入方式从 CocoaPods 改为 Vendor,Qiniu 标注 SPM
  URL 与版本
- Plan §2.3 / §5 Phase 5.1 / §6.1 / §8 同步 AMap Vendor 化
- Plan 新增 ADR-006 完整决策记录
- CLAUDE.md 新增「依赖管理约定」节,禁止引入 CocoaPods
2026-06-21 21:46:19 +08:00

2503 lines
107 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
bridge?.call("appservice", data: .string(state == .background ? "1" : "2"), callback: nil)
}
battery.start(); network.start(); lifecycle.start()
// 立即派发一次,补齐暂停期间的状态
battery.emitNow(); network.emitNow()
}
func suspend() {
battery.stop(); network.stop(); lifecycle.stop()
// 解绑回调,避免在子游戏 push 上来后大厅仍收到事件
}
}
```
#### 2.4.3 栈深约束 ≤ 2 — 防止内存堆栈
旧架构隐患:H5 连续调 SwitchOverGameData 时,navigation stack 不断增长(`[Lobby, SubA, SubB, SubC, ...]`),内存爆炸。
新架构方案:Coordinator 在路由时显式判断:
```swift
@MainActor
final class AppCoordinator {
private let nav: UINavigationController
/// 进入子游戏:保证 nav stack 最多两层(Lobby + 当前 SubGame)
func showSubGame(_ params: SwitchGameParams) {
if nav.topViewController is SubGameViewController {
nav.popViewController(animated: false) // 先退出当前子游戏
}
let vc = SubGameViewController(params: params, coordinator: self)
nav.pushViewController(vc, animated: true)
}
/// 打开弹层(Overlay 是浮层,允许 [Lobby, SubGame, Overlay] 三层)
func showOverlay(_ params: OverlayParams) {
let vc = OverlayViewController(params: params, coordinator: self)
nav.pushViewController(vc, animated: true)
}
func popToLobby() { nav.popToRootViewController(animated: true) }
}
```
→ 任意 H5 操作下,navigation stack 最多 3 层(Lobby + SubGame + Overlay),内存占用可预测。
#### 2.4.4 WebView 数据隔离策略
新架构主张大厅 / 子游戏 / 弹层共用同一个 `WKProcessPool` 加速启动,但**默认共享 Cookie 和 LocalStorage**(因为它们都属于同一个 dataStore)。这在不同源 H5 间可能产生意外串扰。
策略:
- **大厅 + 子游戏** 共用 `WKWebsiteDataStore.default()`(都是项目自家 H5,共享业务态合理 — 比如登录态、用户偏好等)
- **弹层 Overlay**(加载第三方外链时)使用 `WKWebsiteDataStore.nonPersistent()` 私有 store,Cookie / LocalStorage 不写盘,**不污染主业务态**
```swift
extension WKWebsiteDataStore {
static let businessStore = WKWebsiteDataStore.default() // 大厅 + 子游戏
static let overlayStore = WKWebsiteDataStore.nonPersistent() // 弹层外链
}
```
OverlayViewController 在 WKWebViewConfiguration 上设置 `websiteDataStore = .overlayStore` 即可隔离。
#### 2.4.5 副作用清单:进入 / 退出子游戏的强制动作
无论 H5 端逻辑如何变化,以下副作用由 Coordinator + Handler 在路由时**强制完成**,不允许 H5 端遗漏:
| 时机 | 强制副作用 |
|------|----------|
| 大厅 → 子游戏(SwitchOverGameData) | ① `audio.stopBackground()``WXApiManager` delegate 切上下文(详见 §8.4) |
| 子游戏 → 大厅(backgameData) | ① `audio.stopBackground()``videoRoom.leave()` ③ 通过 `.subGameDidReturn` 通知 LobbyHandlers 回调 `getWebdata` |
| 进入 Overlay(OpenurlTitleData) | ① 节流(3 秒内重复打开吞掉) |
| 退出 Overlay(settings.finishweb / settings.backgameData) | ① 弹层 backgameData 同样发 `.subGameDidReturn` 通知,LobbyHandlers / SubGameHandlers 监听并 callback `getWebdata` 给当前 active 容器的 H5 |
---
## 3. H5 桥接核心实现
### 3.1 设计要点
WVJB 的 JS 端协议很简单:页面加载完成后,JS 会执行 `_setupWebViewJavascriptBridge`,把队列 message 通过 `iframe src``console.log` 通知原生。新外壳**自己实现 WVJB-JS 端的协议**,而不是引用 OC 版 WebViewJavascriptBridge 库:
- 优点:纯 Swift、无 OC 依赖、可严格并发安全、单元测试容易
- 落地路径:把 WebViewJavascriptBridge 的 JS 端代码(`WebViewJavascriptBridge.js.txt`)作为 WKUserScript 注入到 WKWebView,原生侧自研 `WKScriptMessageHandler` 接收并分发消息
### 3.2 协议骨架
```swift
// BridgeCore/BridgeProtocol.swift
public protocol BridgeProtocol: AnyObject, Sendable {
/// 注册一个 H5 → Native handler。重复名覆盖。
func register(_ name: String, handler: @escaping BridgeHandler)
/// 主动调用 H5 端的 handler。callback 可选。
func call(_ name: String, data: BridgeData?, callback: BridgeCallback?)
}
public typealias BridgeHandler = @Sendable (BridgeData?, BridgeCallback?) async -> Void
public typealias BridgeCallback = @Sendable (BridgeData?) -> Void
public enum BridgeData: Sendable {
case string(String)
case number(Double)
case bool(Bool)
case null
case array([BridgeData])
case object([String: BridgeData])
// 字段访问语法糖
public subscript(key: String) -> BridgeData? { ... }
public var asString: String? { ... }
public var asInt: Int? { ... }
}
```
### 3.3 关键实现
```swift
// WebViewKit/BridgedWebView.swift
@MainActor
public final class BridgedWebView: UIView {
public let webView: WKWebView
public let bridge: BridgeBus
public init(config: WebViewConfig) {
let cfg = WKWebViewConfiguration()
cfg.processPool = SharedProcessPool.shared // 进程池复用
cfg.preferences.javaScriptEnabled = true
cfg.preferences.javaScriptCanOpenWindowsAutomatically = false
cfg.preferences.minimumFontSize = 10
cfg.allowsInlineMediaPlayback = true
// 注入 WVJB-JS 协议
let userScript = WKUserScript(
source: JSInjector.wvjbProtocol,
injectionTime: .atDocumentStart,
forMainFrameOnly: true)
cfg.userContentController.addUserScript(userScript)
self.webView = WKWebView(frame: .zero, configuration: cfg)
self.bridge = BridgeBus(webView: webView, controller: cfg.userContentController)
super.init(frame: .zero)
layout()
// 契约 §4.1:与现网保持的 ScrollView 配置
webView.scrollView.contentInsetAdjustmentBehavior = .never // iOS 11+
webView.scrollView.bounces = false
webView.scrollView.showsVerticalScrollIndicator = false
webView.scrollView.showsHorizontalScrollIndicator = false
webView.scrollView.isScrollEnabled = false // ⚠️ 契约要求,漏则 H5 上下滑动失控
}
}
// BridgeCore/BridgeBus.swift
@MainActor
public final class BridgeBus: BridgeProtocol {
private var handlers: [String: BridgeHandler] = [:]
private weak var webView: WKWebView?
private var pendingCallbacks: [String: BridgeCallback] = [:]
private var callbackCounter = 0
public func register(_ name: String, handler: @escaping BridgeHandler) {
handlers[name] = handler
}
public func call(_ name: String, data: BridgeData?, callback: BridgeCallback?) {
var payload: [String: Any] = ["handlerName": name]
if let data = data { payload["data"] = data.jsonObject }
if let callback = callback {
callbackCounter += 1
let cbId = "objc_cb_\(callbackCounter)"
pendingCallbacks[cbId] = callback
payload["callbackId"] = cbId
}
let json = try! JSONSerialization.data(withJSONObject: payload)
let js = "WebViewJavascriptBridge._handleMessageFromObjC('\(json.base64)')"
webView?.evaluateJavaScript(js)
}
// 接收 H5 → Native
func didReceive(_ message: [String: Any]) async {
guard let name = message["handlerName"] as? String else { return }
let data = (message["data"]).map(BridgeData.from)
let cbId = message["callbackId"] as? String
let cb: BridgeCallback? = cbId.map { id in
{ [weak self] resp in self?.call(id, data: resp, callback: nil) }
}
if let handler = handlers[name] {
await handler(data, cb)
} else {
Log.warn("Bridge: no handler for \(name)")
cb?(nil)
}
}
}
```
### 3.4 弹层桥(window.settings polyfill)
H5 弹层页约定使用 `window.settings.xxx(...)` 同步调用形式(契约边界)。新项目弹层 WebView 用 WKWebView,通过注入一段 polyfill 把 `window.settings` 转换为 `WKScriptMessageHandler` 消息:
```swift
// WebViewKit/OverlayBridge.swift
public enum OverlayBridge {
public static let polyfill = """
window.settings = {
backgameData: function(data) {
window.webkit.messageHandlers.overlayBackgameData.postMessage(String(data));
},
browser: function(url) {
window.webkit.messageHandlers.overlayBrowser.postMessage(String(url));
},
finishweb: function() {
window.webkit.messageHandlers.overlayFinishweb.postMessage("");
}
};
"""
}
```
3 个 `WKScriptMessageHandler` 在 OverlayViewController 内注册即可。
### 3.5 性能优化
- **ProcessPool 复用**:大厅、子游戏、弹层共用同一个 `WKProcessPool`,Cookie/Cache 共享,避免重复初始化(800 ms → 50 ms)
- **JS 注入用 `WKUserScript`**:而不是 `evaluateJavaScript`,在 document load 之前就完成,避免 race
- **桥消息批处理**:H5 端 message queue 累积一帧再 flush,减少 evaluateJavaScript 次数
- **结构化日志**:Bridge 全链路用 `os_log` 类型化,Release 自动去 `.debug` 级别
### 3.6 WebView 进程崩溃恢复
iOS 系统在内存压力下会 kill `WKWebView` 的 Web Content Process,UI 上表现为**整个 WebView 变成白屏**,JS 全停,但 native 这边的 `WKWebView` 对象本身还在 — 系统不会自动回调 navigation delegate,如果不主动处理,用户看到的就是一个永久白屏的页面。
WebContainerViewController 必须实现 `webViewWebContentProcessDidTerminate:`:
```swift
extension WebContainerViewController: WKNavigationDelegate {
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
Log.warn("WKWebView content process terminated, attempting reload")
crashReporter.captureMessage("WebContentTerminated:\(self.containerRole)", level: .warning)
let now = Date()
recentTerminations.append(now)
recentTerminations = recentTerminations.filter { now.timeIntervalSince($0) < 60 }
if recentTerminations.count >= 3 {
// 1 分钟内连续 3 次崩溃 → 不再自动 reload,弹错误页让用户主动重试
showFatalErrorView()
return
}
// 退避:第 1 次立即,第 2 次 1s,第 3 次 3s
let delay = TimeInterval(recentTerminations.count - 1) * (recentTerminations.count - 1)
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak webView] in
webView?.reload()
}
}
}
```
要点:
- **必须 reload**,不能依赖系统自动恢复(WebKit 故意不自动 reload,让 App 自己决定策略)
- **崩溃次数退避**:连续多次崩溃通常意味着真实 bug 或低内存,无限 reload 会陷入循环。1 分钟 ≥ 3 次切到 fallback UI
- **Sentry 上报**:每次崩溃都打 breadcrumb,持续高崩溃率应该触发告警
- **大厅 / 子游戏 / 弹层都受这条覆盖**,因为它们都继承自 WebContainerViewController
---
## 4. 启动流程重新设计
### 4.1 整体时序(目标 < 1.5 s 进 H5)
```
SceneDelegate.scene(_:willConnectTo:options:) T=0
├─► (并行) BundleConfig.preload() T=0 ← 渠道注入读取
│ ↓ 同步读 11 个目录名,纯内存,~5 ms
├─► (并行) SDK 注册 T=0
│ • WeChat ~10 ms
│ • AMap (privacy + key) ~15 ms
│ • crashReporter.start() ~20 ms(Sentry 默认启用)
│ (JAnalytics 极光 / Xianliao 闲聊 / Agora 声网 / Bugly 当前未集成,见 §14.2)
│ 并发 Task,await 全部完成
├─► Window 创建 + LobbyViewController push T=50ms
LobbyViewController.viewDidLoad
├─► (并行) ResourceUnzipper.ensureReady() T=50ms
│ • 检查 Library/Caches/{gamedir}/{gamestart}/version.xml
│ • 不存在 → 异步解压 gamehall.zip(后台队列)
│ • 存在 → 直接通过
├─► (并行) BridgedWebView 创建 + 注册 20 handler T=50ms
│ • 同步,~30 ms
▼ ▼
await Both T=300ms (假设首次解压 250ms,缓存命中 < 50ms)
├─► JSInjector.writeAppData(...) T=300ms
│ • app_data.js / app_battery.js / app_network.js
├─► webView.loadFileURL(...) T=320ms
│ • file:// 加载,WebKit 内部 ~500 ms 到 DOMContentLoaded
DOMContentLoaded → JS 桥握手完成 T < 1.5s
```
### 4.2 关键代码
```swift
// App/SceneDelegate.swift
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options: UIScene.ConnectionOptions) {
guard let scene = scene as? UIWindowScene else { return }
Task { @MainActor in
await Bootstrapper.shared.run() // 并发 SDK 注册
let lobby = LobbyViewController()
let nav = NavigationController(rootViewController: lobby)
window = UIWindow(windowScene: scene)
window?.rootViewController = nav
window?.makeKeyAndVisible()
}
}
}
// App/Bootstrapper.swift
@MainActor
final class Bootstrapper {
static let shared = Bootstrapper()
private(set) var isReady = false
func run() async {
async let cfg: Void = BundleConfig.preload()
async let sdks: Void = registerSDKs()
_ = await (cfg, sdks)
isReady = true
}
private func registerSDKs() async {
await withTaskGroup(of: Void.self) { group in
group.addTask { WeChatSDK.registerFull(appId: AppIDs.weChat) } // 含 contentFlag
// JAnalytics(极光) / XianliaoSDK(闲聊) / Agora(声网) 暂不集成(§14.2)
// — 对应模块用 Noop 占位,首版不参与启动注册
group.addTask { AMapWrapper.configure() }
group.addTask { crashReporter.start() } // SentryCrashReporter by default; Noop if SENTRY_ENABLED=0
}
}
}
// SDK/WeChat/WeChatSDK.swift
public enum WeChatSDK {
/// 完整注册:WXApi + 文件类型支持
/// (registerAppSupportContentFlag 必须包含 DOC/DOCX/PPT/PPTX/XLS/XLSX/PDF 等
/// 否则微信分享对应文件类型的回包识别失败 — 此为微信 SDK 契约)
public static func registerFull(appId: String) {
WXApi.registerApp(appId, enableMTA: true)
let typeFlag: UInt64 =
MMAPP_SUPPORT_TEXT | MMAPP_SUPPORT_PICTURE | MMAPP_SUPPORT_LOCATION
| MMAPP_SUPPORT_VIDEO | MMAPP_SUPPORT_AUDIO | MMAPP_SUPPORT_WEBPAGE
| MMAPP_SUPPORT_DOC | MMAPP_SUPPORT_DOCX | MMAPP_SUPPORT_PPT
| MMAPP_SUPPORT_PPTX | MMAPP_SUPPORT_XLS | MMAPP_SUPPORT_XLSX
| MMAPP_SUPPORT_PDF
WXApi.registerAppSupportContentFlag(typeFlag)
}
}
```
> ⚠️ 漏 `registerAppSupportContentFlag` 会导致微信分享 PDF/DOCX/PPT/XLS 等业务文件回包识别失败 — 这是微信 SDK 的契约要求,任何 iOS App 接入分享都必须设置。
### 4.2.1 启动期容易遗漏的契约点
以下几项是 **H5 / SDK 契约要求**,在 Bootstrapper 或 SceneDelegate 内必须显式完成。容易遗漏是因为它们看起来是"散落小事",但任一漏掉都会破坏 H5 业务:
| 配置项 | 为什么必须 | 新项目落地 |
|--------|----------|-----------|
| `UIApplication.shared.applicationSupportsShakeToEdit = true` | 不设则 `motionEnded:withEvent:` 收不到摇一摇 → H5 调 `startshake` 后没有 `shakeEnd` 回调,业务断链 | SceneDelegate `sceneDidBecomeActive` 一次性设置 |
| Info.plist `UIStatusBarHidden = NO` + `UIViewControllerBasedStatusBarAppearance = YES` | H5 设计稿基于显示状态栏的可视区域绘制,隐藏会导致布局错位 | Info.plist 直接配,各 VC 重写 `preferredStatusBarStyle` 返回 `.default` |
| 写入 NSUserDefaults: `everLaunched` / `getcompareCode=0` / `FirstBOOL=set0` / `SecondBOOL=set0` / `ThirdBOOL=set0` / `VersionInfo=1.0` | 这些 key 通过 app_data.js 暴露给 H5,key 名与初值是 H5 契约 | KVStore 在首次启动时写入;key 名直接 hardcode,不允许重命名 |
| 首次启动写 `Library/Caches/{gamedir}/{gamestart}/app_gamesname.js`,内容 `var app_gamesname=new Array('{gamestart}');` (注意 `var` 后两个空格) | H5 启动时静态 `<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 内监听并转桥
notifications.observe(.appDidEnterBackground) { [bridge] _ in
bridge.call("appservice", data: .string("1"), callback: nil) // 契约:"1"=后台
}
notifications.observe(.appDidBecomeActive) { [bridge] _ in
bridge.call("appservice", data: .string("2"), callback: nil) // 契约:"2"=前台
}
```
**0.3s 节流决策**:旧外壳在 `applicationDidBecomeActive` 里设了 `flag=NO` + 0.3s 后恢复,防止双重触发。新外壳**默认不要这个节流**,理由:
1. 节流的根因是旧代码在 `applicationDidBecomeActive` 内被多处触发,新外壳只有 SceneDelegate 一处发通知
2. 测试如果证实双触发,加节流是 1 行代码的事(`DispatchQueue.asyncAfter`),不是架构改动
3. H5 端通常对重复 `appservice("2")` 是幂等的(就是刷新 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) {
call("appservice", data: .string(state == .background ? "1" : "2"), 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 下载层
```swift
// ResourceKit/ConfigService.swift
public actor ConfigService {
private let client: HTTPClient
private let unzipper: ResourceUnzipper
public func syncIfNeeded() async throws {
let config = try await fetchRemoteConfig()
let local = try LocalVersion.read()
if local.gameVersion < config.gameVersion {
try await downloadAndUnzip(config.gameZipURL)
}
// 同样比较 agent / channel / market / agentlist / gamelist
}
}
```
---
## 7. 资源 & 渠道注入
### 7.0 仓库根 `Resources/` 目录现状(2026-06-21
新外壳运行所需的项目方资源统一存放在仓库根 `Resources/`**不**在 Xcode 工程内的 `ylgamehall/` 源码目录里)。这与 §2.2 的 SPM 包结构里 `Resources/` 是同一份物理目录,后续打包脚本通过 Build Phase 引用。
当前实际内容(可直接展开 Phase 1 开发):
```
Resources/
├── gamehall.zip ← 大厅 H5 资源包 (≈ 11 MB, 2023-12 旧版)
│ 启动时 ResourceUnzipper 解压到
│ Library/Caches/{gamedir}/
│ ⚠️ 上线前必须由 H5 团队提供最新版替换
├── Images.xcassets/ ← 原生 Asset Catalog (AppIcon /
│ LaunchImage / 微信 / QQ / 抖音
│ 分享平台图标)
└── Res/ ← 散落原生资源 (未走 Asset Catalog 的旧文件):
├── sharelogo.png ← 分享缩略图 (友圈/微信链接分享用)
├── shake_sound_male.mp3 ← 摇一摇音效 (SwitchShake 开关控制)
├── BackBT.png ← msext 兼容图,新外壳是否引用待 Phase 1 验证
├── Sistem_back.png ← 同上
├── Icon180.png ← 同上
└── Default-568h@2x~iphone.png ← 同上
```
> **Phase 1 含义**:`gamehall.zip` 实物已就位,虽是旧版但足以跑通"渠道注入 → 解压 → 加载 H5 → 桥消息"全链路,不必等 H5 团队最新版才开始。版本差异不影响契约边界(handler 名 / 参数 / 数据结构)。
后续 11 个**渠道注入目录**(`qiniudomain` / `gameid` / `channel` / `gamedir` / `gamestart` / `gameconfig` / `market` / `agent` / `appversion` / `other` / `appleconfig`)将由 `Scripts/inject_channel.sh` 在打包前于 `Resources/ChannelInjection/` 下动态生成,运行时通过 `BundleConfig.readInjected(_:)` 扫描 Bundle 子目录读取(§7.2)。
### 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
public final class BundleConfig: @unchecked Sendable {
public static let shared = BundleConfig()
public private(set) var qiniuDomain = ""
public private(set) var gameId = ""
public private(set) var channel = ""
public private(set) var market = ""
public private(set) var agent = ""
public private(set) var appVersion = ""
public private(set) var gameDir = ""
public private(set) var gameStart = ""
public private(set) var gameConfig = ""
public static func preload() async {
// 异步包装,实际是同步 IO,但放在后台 queue
await Task.detached(priority: .userInitiated) {
BundleConfig.shared.qiniuDomain = readInjected("qiniudomain")
BundleConfig.shared.gameId = readInjected("gameid")
BundleConfig.shared.channel = readInjected("channel")
// ...
}.value
}
/// 扫描 Bundle 中指定名称的目录,返回其第一个非隐藏子项的名字
/// (打包脚本约定:每个 key 对应一个 Bundle 目录,目录下唯一子目录名即为配置值)
private static func readInjected(_ key: String) -> String {
let dir = Bundle.main.bundleURL.appendingPathComponent(key)
guard let items = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else {
return ""
}
return items.first { !$0.hasPrefix(".") && $0 != ".DS_Store" } ?? ""
}
}
```
> 七牛 CDN 域名(用于录音上传后的公网 URL 组装)在新项目中统一从 `BundleConfig.shared.qiniuDomain` 读取,不暴露任何全局变量。所有 AudioKit / RecordUploader 等模块通过依赖注入获取 BundleConfig。
### 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
```
---
## 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.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.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`,代码大厅 / 子游戏完全一致。
---
## 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
```swift
func register() {
// 登录 / 分享
bridge.register("accreditlogin", handler: accreditLogin)
bridge.register("friendsSharetypeUrlToptitleDescript", handler: friendsShare)
// 音频 / 录音
bridge.register("srcIsloop", handler: srcIsLoop)
bridge.register("prepareaudio", handler: prepareAudio)
bridge.register("mediaTypeAudio", handler: mediaTypeAudio)
bridge.register("voicePlaying", handler: voicePlaying)
// 摇一摇 / 振动
bridge.register("startshake", handler: startShake)
bridge.register("stopshake", handler: stopShake)
bridge.register("SwitchShake", handler: switchShake)
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("OpenurlTitleData", handler: openUrlTitleData)
bridge.register("browser", handler: browser)
bridge.register("SwitchOverGameData", handler: switchOverGameData)
// 定位 / 设备 / 扫码
bridge.register("startlocation", handler: startLocation)
bridge.register("getphoneInfo", handler: getPhoneInfo)
bridge.register("opensaoma", handler: openSaoma) // ⚠️ 空实现仍要注册
}
```
### 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 ? "1" : "2"), callback: nil) }
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