Files
youle_app_ios_v2/docs/H5-Native-Implementation-Design.md
T
joywayer 2280462a22 docs:Plan + Design 扩展 Phase 1.14 为 5 子项(含 AppIcon / LaunchScreen / progress / splash UI)
原 Phase 1.14 仅含"WebContainer 16:9 letterbox + 串接整链路",
漏掉了用户实际能看到的三件事:AppIcon / LaunchScreen 启动图 / zip
下载进度条。msext 上线时这三件都有,新外壳上线必须对齐。按用户偏好
方案 A 合并到 Phase 1.14,避免做两遍 WebContainer。

- Plan §5 Phase 1.14 拆为 1.14.a–e 五个子项
  - 1.14.a AppIcon:拷贝 docs/res/Images.xcassets/AppIcon-1.appiconset
    (9 个 png + Contents.json) 到 ylgamehall/Assets.xcassets
  - 1.14.b LaunchScreen:Default-568h@2x~iphone.png → SplashImage
    imageset;改写 LaunchScreen.storyboard 横屏铺满 UIImageView
  - 1.14.c LobbyZipUpgrader 加 onProgress 回调(@Sendable (Double) -> Void)
  - 1.14.d WebContainerViewController:splash 覆盖层(启动图 +
    UIProgressView + 状态文字)+ 16:9 letterbox + 启动流水线串接
  - 1.14.e webView didFinish 淡出 splash(0.3s 动画 + removeFromSuperview)
- Plan §8 进度追踪同步:5 个子项独立勾选条目
- Design §6.3.5 WebContainer 调用串完整重写:
  - Splash UI 6 状态切换表(拼命启动中... / 拉取配置中... /
    下载更新中 XX% / 加载大厅... / 淡出)
  - runBootPipeline 完整 swift 骨架:含 .shortText 短文本响应处理 /
    showmessage 阻塞处理 / IPA 升级 alert / zip 升级 onProgress 回调
  - 所有失败 / 阻塞终态都用 UIAlertController(非 splash 文字):
    短文本 / showmessage / IPA 升级 / 网络错误 / zip 下载失败
    各场景的按钮文案 + tap 行为表
2026-06-22 02:35:37 +08:00

2883 lines
124 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 升级流水线(**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 VersionResolverchulishengji 双子树合并,纯函数)
**核心修正(ADR-008 二次精确化)**:线上热路径是 chulishengji 双子树合并,**不是**简单的"顶 → agent → channel → market"线性覆盖。
```swift
// Source/Network/VersionResolver.swift
public enum VersionResolver {
public struct Resolved: Sendable {
public let appVersion: Int // 0 表示远端未指定
public let appDownload: String?
public let gameVersion: Int // 0 表示远端未指定
public let gameZip: String?
public let showmessage: String? // 非空时阻塞,运营杀手锏
}
/// chulishengji 双子树合并(NewRootVC.m:1372-1538):
/// 1. 从 agent 子树提取(agent → channel → market → gamegame 嵌在 market 命中后才遍历)
/// 2. 从 game 子树提取(game → game-self → channel → market
/// 3. 字段级合并:
/// - IPAappVersion / appDownload)默认 agent 赢;game 版本号更大时 game 赢(line 1416
/// - zipgameVersion / gameZip)默认 game 赢;agent 版本号更大时 agent 赢(line 1456
/// 4. showmessage 两条子树多层多次覆盖,最后写赢
public static func resolve(
config: RemoteConfig,
agentId: String,
channelId: String,
marketId: String,
gameId: String
) -> Resolved {
// 1. 找到当前 agent / channel / market / game 节点
guard let agent = config.agentlist?.first(where: { $0.agentid == agentId }),
let game = agent.gamelist?.first(where: { $0.gameid == gameId }) else {
// 找不到匹配(极端情况):返回 0 / nil,让 WebContainer 走"无升级"路径
return Resolved(appVersion: 0, appDownload: nil,
gameVersion: 0, gameZip: nil,
showmessage: config.showmessage)
}
let agentExtract = extractAgentSubtree(
agent: agent, channelId: channelId, marketId: marketId, gameId: gameId)
let gameExtract = extractGameSubtree(
game: game, channelId: channelId, marketId: marketId)
// IPA 合并(NewRootVC.m:1408-1447
let (appVer, appDL) = mergeIPA(agent: agentExtract, game: gameExtract)
// zip 合并(NewRootVC.m:1450-1492
let (gameVer, gameZip) = mergeZip(agent: agentExtract, game: gameExtract)
// showmessage:任意子树非空就用(最后写赢)
let msg = gameExtract.showmessage ?? agentExtract.showmessage ?? config.showmessage
return Resolved(
appVersion: appVer,
appDownload: appDL,
gameVersion: gameVer,
gameZip: gameZip,
showmessage: msg
)
}
/// agent 子树 4 层提取(getagentversionNewRootVC.m:885-1013
/// agent → channel → market → gamegame 嵌在 market 命中后才遍历)
/// 注意:agent 子树用 game_download 字段名,game 子树用 game_zip——两个都要查
private static func extractAgentSubtree(...) -> SubtreeResult { ... }
/// game 子树 4 层提取(getgameversionNewRootVC.m:1015-1180
/// game → game-selfgamedata 自身再嵌 channellist)→ channel → market
private static func extractGameSubtree(...) -> SubtreeResult { ... }
/// IPA 合并:默认 agent 赢;game.appVersion > agent.appVersion 时 game 赢
private static func mergeIPA(...) -> (Int, String?) { ... }
/// zip 合并:默认 game 赢;agent.gameVersion > game.gameVersion 时 agent 赢
private static func mergeZip(...) -> (Int, String?) { ... }
}
```
**关键测试用例**(单测必须覆盖):
| 用例 | 验证 |
|------|------|
| 顶层 / agent / channel / market / game 都有 app_version | agent 子树最深层赢;与 game 子树合并时按版本号大者 |
| 仅 agent 子树有,game 子树无 app_version | 取 agent |
| 仅 game 子树有,agent 子树无 app_version | 取 game |
| game.appVersion = 10 vs agent.appVersion = 5 | game 赢 |
| game.appVersion = 5 vs agent.appVersion = 10 | agent 赢 |
| game.gameVersion = 10 vs agent.gameVersion = 5 | game 赢(默认) |
| game.gameVersion = 5 vs agent.gameVersion = 10 | agent 赢 |
| showmessage 仅顶层有 | 取顶层 |
| showmessage agent 层有 + 顶层有 | 取 agent(深层覆盖浅层) |
| showmessage market 层有 + 全部层都有 | 取 market(最深) |
| agentid 找不到匹配 | 返回 default0/nil),不抛 |
| gameid 找不到匹配 | 返回 default,不抛 |
| 字段名 game_download vs game_zip 混用 | 两个 key 都查,择一非空 |
#### 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 级覆盖 | `getagentversion` / `getgameversion` / `chulishengji` 三段散落 if/else | 纯函数 `VersionResolver.resolve`,单测覆盖所有边界 12+ 用例 |
| 解压策略 | `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
```
---
## 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