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