契约 §0.3 描述 msext 把 11 个渠道值编码为 Bundle 根的 11 个空目录 子文件夹名(目录名本身就是值)。在 Xcode 26.5 默认的 synchronized group 下,此机制不兼容(子目录被扁平化、11 个 .gitkeep 撞名; folder reference 拖入流程失效;Run Script Phase 受 sandbox 阻碍且 默认 Based on dependency analysis 让 clean build 也不跑)。 CLAUDE.md 原则 B 落地——原生内部自由重构,不照搬 msext。改用单一 plist 存储等价语义,H5 端通过 app_data.js 看到的 11 个 JS 全局变量 行为完全不变(契约边界在 BundleConfig.shared.xxx,与底层无关)。 多渠道分发用 plutil -replace + 重签 + 重打包,工作量与 mv 目录名 重签完全相同。 - 新增 ylgamehall/Resources/ChannelConfig.plist:11 个 string key 含母包默认值(沿用 msext 现网值);synchronized group 自动入 Bundle - Design §7.0 / §7.2 重写为 plist 方案;BundleConfig 代码骨架改为 PropertyListSerialization + init(bundle:) 可注入式 - Plan §5 Phase 1.1 任务清单从 4 个子项简化为 1 项;ADR-004 文字 同步;新增 ADR-007 完整记录决策背景 / 触发事件 / 工程兼容性分析 / 理由 / 守护条款 - pbxproj:ENABLE_USER_SCRIPT_SANDBOXING 残留为 NO(前期 Run Script 方案探索时关闭,plist 方案下不再需要,但未恢复以避免再次 UI 操作; 无 Run Script 故无实际安全暴露面,未来可随时改回 YES) - BuildProject 验证:plist 已落在 .app 根,plutil -p 输出 11 个键值 完整
110 KiB
Daoqi iOS 外壳新项目实施设计
配套文档:
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 目标
- 契约零偏差 — H5 不改一行代码,在新外壳上行为与现网一致(以
H5-Native-Contract.md§10 验收清单为准) - 架构优雅 — 模块边界清晰,单一职责;新增桥接接口/SDK 只动一处
- 高性能 — 启动 < 1.5 s 进大厅 H5;桥接消息往返 < 16 ms(单帧);视频房间 1080p@30 稳定
- 专业成熟 — 包含完整的崩溃监控/日志/单测/集成测/CI 流程;团队随时可换人维护
- 可演进 — 新增渠道/功能不需要回改既有模块;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 buildbaseline 测量。若 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 端业务隐式依赖大厅状态(列表滚动、玩家余额、待办通知等)
getWebdatacallback 要求大厅 handler 一直注册着,新建大厅会丢数据- 返回瞬时(无 loadFileURL 黑屏)
2.4.2 外部订阅暂停 — 避免桥事件双发
旧架构隐患:大厅和子游戏都监听 enterForeground / 电池 / 网络 等通知,前后台切换时两个都响应,H5 收到双倍桥事件。
新架构方案:WebContainer 用 viewWillAppear / viewWillDisappear 管理外部订阅生命周期,任何时刻只有最顶层可见 VC 向 H5 派发桥事件:
public class WebContainerViewController: UIViewController {
private let externalSubs: ExternalSubscriptions // 注入
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
externalSubs.resume()
// resume 内部:bind battery/network/lifecycle 监听
// 立即派发一次当前状态(避免 H5 错过暂停期间的变化)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
externalSubs.suspend()
// suspend 内部:unbind 所有监听,WebView 不释放,JS 继续运行
}
}
@MainActor
final class ExternalSubscriptions {
weak var bridge: BridgeProtocol?
private let battery = BatteryMonitor()
private let network = NetworkMonitor()
private let lifecycle = AppLifecycleObserver()
func resume() {
battery.onChange = { [weak bridge] level in
bridge?.call("getBattery", data: .string(String(format: "%.2f", level)), callback: nil)
}
network.onChange = { [weak bridge] state in
bridge?.call("getnetwork", data: .string(state.rawValue), callback: nil)
}
lifecycle.onChange = { [weak bridge] state in
bridge?.call("appservice", data: .string(state == .background ? "1" : "2"), callback: nil)
}
battery.start(); network.start(); lifecycle.start()
// 立即派发一次,补齐暂停期间的状态
battery.emitNow(); network.emitNow()
}
func suspend() {
battery.stop(); network.stop(); lifecycle.stop()
// 解绑回调,避免在子游戏 push 上来后大厅仍收到事件
}
}
2.4.3 栈深约束 ≤ 2 — 防止内存堆栈
旧架构隐患:H5 连续调 SwitchOverGameData 时,navigation stack 不断增长([Lobby, SubA, SubB, SubC, ...]),内存爆炸。
新架构方案:Coordinator 在路由时显式判断:
@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 不写盘,不污染主业务态
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 协议骨架
// 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 关键实现
// 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 消息:
// WebViewKit/OverlayBridge.swift
public enum OverlayBridge {
public static let polyfill = """
window.settings = {
backgameData: function(data) {
window.webkit.messageHandlers.overlayBackgameData.postMessage(String(data));
},
browser: function(url) {
window.webkit.messageHandlers.overlayBrowser.postMessage(String(url));
},
finishweb: function() {
window.webkit.messageHandlers.overlayFinishweb.postMessage("");
}
};
"""
}
3 个 WKScriptMessageHandler 在 OverlayViewController 内注册即可。
3.5 性能优化
- ProcessPool 复用:大厅、子游戏、弹层共用同一个
WKProcessPool,Cookie/Cache 共享,避免重复初始化(800 ms → 50 ms) - JS 注入用
WKUserScript:而不是evaluateJavaScript,在 document load 之前就完成,避免 race - 桥消息批处理:H5 端 message queue 累积一帧再 flush,减少 evaluateJavaScript 次数
- 结构化日志:Bridge 全链路用
os_log类型化,Release 自动去.debug级别
3.6 WebView 进程崩溃恢复
iOS 系统在内存压力下会 kill WKWebView 的 Web Content Process,UI 上表现为整个 WebView 变成白屏,JS 全停,但 native 这边的 WKWebView 对象本身还在 — 系统不会自动回调 navigation delegate,如果不主动处理,用户看到的就是一个永久白屏的页面。
WebContainerViewController 必须实现 webViewWebContentProcessDidTerminate::
extension WebContainerViewController: WKNavigationDelegate {
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
Log.warn("WKWebView content process terminated, attempting reload")
crashReporter.captureMessage("WebContentTerminated:\(self.containerRole)", level: .warning)
let now = Date()
recentTerminations.append(now)
recentTerminations = recentTerminations.filter { now.timeIntervalSince($0) < 60 }
if recentTerminations.count >= 3 {
// 1 分钟内连续 3 次崩溃 → 不再自动 reload,弹错误页让用户主动重试
showFatalErrorView()
return
}
// 退避:第 1 次立即,第 2 次 1s,第 3 次 3s
let delay = TimeInterval(recentTerminations.count - 1) * (recentTerminations.count - 1)
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak webView] in
webView?.reload()
}
}
}
要点:
- 必须 reload,不能依赖系统自动恢复(WebKit 故意不自动 reload,让 App 自己决定策略)
- 崩溃次数退避:连续多次崩溃通常意味着真实 bug 或低内存,无限 reload 会陷入循环。1 分钟 ≥ 3 次切到 fallback UI
- Sentry 上报:每次崩溃都打 breadcrumb,持续高崩溃率应该触发告警
- 大厅 / 子游戏 / 弹层都受这条覆盖,因为它们都继承自 WebContainerViewController
4. 启动流程重新设计
4.1 整体时序(目标 < 1.5 s 进 H5)
SceneDelegate.scene(_:willConnectTo:options:) T=0
│
├─► (并行) BundleConfig.preload() T=0 ← 渠道注入读取
│ ↓ 同步读 11 个目录名,纯内存,~5 ms
│
├─► (并行) SDK 注册 T=0
│ • WeChat ~10 ms
│ • AMap (privacy + key) ~15 ms
│ • crashReporter.start() ~20 ms(Sentry 默认启用)
│ (JAnalytics 极光 / Xianliao 闲聊 / Agora 声网 / Bugly 当前未集成,见 §14.2)
│ 并发 Task,await 全部完成
│
├─► Window 创建 + LobbyViewController push T=50ms
│
▼
LobbyViewController.viewDidLoad
│
├─► (并行) ResourceUnzipper.ensureReady() T=50ms
│ • 检查 Library/Caches/{gamedir}/{gamestart}/version.xml
│ • 不存在 → 异步解压 gamehall.zip(后台队列)
│ • 存在 → 直接通过
│
├─► (并行) BridgedWebView 创建 + 注册 20 handler T=50ms
│ • 同步,~30 ms
│
▼ ▼
await Both T=300ms (假设首次解压 250ms,缓存命中 < 50ms)
│
├─► JSInjector.writeAppData(...) T=300ms
│ • app_data.js / app_battery.js / app_network.js
│
├─► webView.loadFileURL(...) T=320ms
│ • file:// 加载,WebKit 内部 ~500 ms 到 DOMContentLoaded
│
▼
DOMContentLoaded → JS 桥握手完成 T < 1.5s
4.2 关键代码
// 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 吞):
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 是模块内通讯,不是契约边界,可以自由命名),但桥事件不变:
extension Notification.Name {
static let appDidEnterBackground = Notification.Name("Daoqi.appDidEnterBackground")
static let appDidBecomeActive = Notification.Name("Daoqi.appDidBecomeActive")
}
// SceneDelegate
func sceneDidEnterBackground(_ scene: UIScene) {
NotificationCenter.default.post(name: .appDidEnterBackground, object: nil)
}
func sceneDidBecomeActive(_ scene: UIScene) {
NotificationCenter.default.post(name: .appDidBecomeActive, object: nil)
}
// LobbyHandlers / SubGameHandlers 内监听并转桥
notifications.observe(.appDidEnterBackground) { [bridge] _ in
bridge.call("appservice", data: .string("1"), callback: nil) // 契约:"1"=后台
}
notifications.observe(.appDidBecomeActive) { [bridge] _ in
bridge.call("appservice", data: .string("2"), callback: nil) // 契约:"2"=前台
}
0.3s 节流决策:旧外壳在 applicationDidBecomeActive 里设了 flag=NO + 0.3s 后恢复,防止双重触发。新外壳默认不要这个节流,理由:
- 节流的根因是旧代码在
applicationDidBecomeActive内被多处触发,新外壳只有 SceneDelegate 一处发通知 - 测试如果证实双触发,加节流是 1 行代码的事(
DispatchQueue.asyncAfter),不是架构改动 - H5 端通常对重复
appservice("2")是幂等的(就是刷新 UI 状态)
如真机回归发现 H5 双触发产生问题,在 LobbyHandlers 内加 throttle 即可,不污染 SceneDelegate。
4.5 子游戏 → 大厅数据回传通知
旧外壳:子游戏 backgameData handler 发 NSNotification "backgameDatatwo",大厅监听后转 bridge.call("getWebdata", data)。
新外壳同样是模块内通讯,可以自由命名。推荐用专属 Notification.Name:
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 拆成纯函数:
// 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
// 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 文档
// 反向通知 H5 的辅助 facade
extension BridgeProtocol {
func callShareSuccess(type: String) {
call("sharesuccess", data: .object([
"success": .string("2"),
"type": .string(type)
]), callback: nil)
}
func callAppService(_ state: AppState) {
call("appservice", data: .string(state == .background ? "1" : "2"), callback: nil)
}
// ... 15 个全列出
}
6. 网络层
6.1 现代 HTTPClient
// 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
// 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: "")
}
}
契约测试
// 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 下载层
// ResourceKit/ConfigService.swift
public actor ConfigService {
private let client: HTTPClient
private let unzipper: ResourceUnzipper
public func syncIfNeeded() async throws {
let config = try await fetchRemoteConfig()
let local = try LocalVersion.read()
if local.gameVersion < config.gameVersion {
try await downloadAndUnzip(config.gameZipURL)
}
// 同样比较 agent / channel / market / agentlist / gamelist
}
}
7. 资源 & 渠道注入
7.0 项目资源目录约定
项目资源目录的最终结构由各 Phase 实施时按需定义,本节列出固定规则,具体目录形态在落地时决定。
7.0.1 仓库内素材池 docs/res/ 与项目无关
docs/res/ 是项目维护者的私人原始素材池,与项目架构无关(详见 CLAUDE.md 「docs/res/ 与项目资源的关系」):
- 项目代码 / 构建脚本 / Xcode 工程都不感知它的存在,不引用任何
docs/res/xxx路径 - 它的目录结构 / 命名 / 内容可由维护者任意调整,不影响构建
- git 跟踪只是为了多机同步素材,而非项目需要
需要某素材时,从 docs/res/ 拷一份到项目内规划好的位置,后续维护与 docs/res/ 不再有任何关联。
7.0.2 项目内资源目录(Phase 实施时落地)
按现代 iOS 单 target + synchronized group 实践,推荐落点如下,但具体目录在该 Phase 真正需要时再创建(避免预先空目录):
| 落点 | 用途 | 入 Bundle 方式 |
|---|---|---|
ylgamehall/Resources/ |
静态打入 Bundle 的项目资源(gamehall.zip / WebViewJavascriptBridge.js / 本地音效 mp3 / ChannelConfig.plist 等) |
synchronized group 自动收集 |
ylgamehall/Assets.xcassets/ |
原生 Asset Catalog(AppIcon / LaunchImage / 分享平台图标) | Xcode 默认 |
Vendor/<SDK>/<SDK>.xcframework |
闭源 SDK 二进制(微信 / QQ / 高德 / opencore-amr) | Target Build Phase「Frameworks」手动加入 |
7.0.3 渠道注入:ChannelConfig.plist 母包模式
契约 §0.3 描述 msext 用"空目录名注入"机制存储 11 个渠道值,本项目按 ADR-007 改用 ChannelConfig.plist 等价实现 —— 契约边界(H5 通过 app_data.js 看到的 11 个 JS 全局变量)完全不变,实现内部更简洁。
存储:ylgamehall/Resources/ChannelConfig.plist,11 个 string key 一一对应渠道值:
<dict>
<key>qiniudomain</key> <string>iosaudio.daoqi88.cn</string>
<key>gameid</key> <string>G2hw0u...</string>
<key>channel</key> <string>FtJf07...</string>
<key>gamedir</key> <string>FtJf07...</string>
<key>gamestart</key> <string>gamehall</string>
<key>gameconfig</key> <string>tsgames.daoqi88.cn-config_test-update_jsonv2_test</string>
<key>market</key> <string>2</string>
<key>agent</key> <string>veRa0qrBf0df2K1G4de2tgfmVxB2jxpv</string>
<key>appversion</key> <string>43</string>
<key>other</key> <string></string>
<key>appleconfig</key> <string></string>
</dict>
运行时读取路径:Bundle.main.bundleURL.appendingPathComponent("ChannelConfig.plist"),由 BundleConfig.swift(§7.2)用 PropertyListSerialization 反序列化。
多渠道分发(IPA 后处理,不重新 Xcode build):
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 集中管理
// 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 渠道注入读取
// ResourceKit/BundleConfig.swift
//
// 从 Bundle 内的 ChannelConfig.plist 读取 11 个渠道注入值。
// 详见 §7.0.3 ChannelConfig.plist 母包模式 / ADR-007。
public final class BundleConfig: @unchecked Sendable {
public static let shared = BundleConfig()
public let qiniuDomain: String
public let gameId: String
public let channel: String
public let gameDir: String
public let gameStart: String
public let gameConfig: String
public let market: String
public let agent: String
public let appVersion: String
public let other: String
public let appleConfig: String
public init(bundle: Bundle = .main) {
let dict = Self.loadPlist(bundle: bundle)
qiniuDomain = dict["qiniudomain"] ?? ""
gameId = dict["gameid"] ?? ""
channel = dict["channel"] ?? ""
gameDir = dict["gamedir"] ?? ""
gameStart = dict["gamestart"] ?? ""
gameConfig = dict["gameconfig"] ?? ""
market = dict["market"] ?? ""
agent = dict["agent"] ?? ""
appVersion = dict["appversion"] ?? ""
other = dict["other"] ?? ""
appleConfig = dict["appleconfig"] ?? ""
}
private static func loadPlist(bundle: Bundle) -> [String: String] {
guard let url = bundle.url(forResource: "ChannelConfig", withExtension: "plist"),
let data = try? Data(contentsOf: url),
let plist = try? PropertyListSerialization.propertyList(
from: data, format: nil) as? [String: String]
else { return [:] }
return plist
}
}
七牛 CDN 域名(用于录音上传后的公网 URL 组装)在新项目中统一从
BundleConfig.shared.qiniuDomain读取,不暴露任何全局变量。所有 AudioKit / RecordUploader 等模块通过依赖注入获取 BundleConfig。测试性:
init(bundle:)接受任意 Bundle,单测可注入 mock bundle 验证不同 plist fixture。
7.3 Zip 解压(异步,非阻塞)
// 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 (SPM),替代旧 ZipArchive。
7.4 打包脚本(渠道动态切换)
#!/bin/bash
# Scripts/inject_channel.sh
# Usage: ./inject_channel.sh <channel_id> <gamedir> <gameid> <market> ...
set -e
INJECT_ROOT="Resources/ChannelInjection"
inject() {
local key=$1 value=$2
rm -rf "$INJECT_ROOT/$key"
mkdir -p "$INJECT_ROOT/$key/$value"
touch "$INJECT_ROOT/$key/$value/.gitkeep"
}
inject "channel" "$1"
inject "gamedir" "$2"
inject "gameid" "$3"
inject "market" "$4"
inject "agent" "$5"
inject "appversion" "$6"
inject "qiniudomain" "$7"
inject "gamestart" "$8"
inject "gameconfig" "$9"
echo "Channel injected: channel=$1 gamedir=$2 market=$4"
CI 一键多渠道出包:
for channel in xianliao 360 qq tencent; do
./Scripts/inject_channel.sh "$channel" "..." "..."
xcodebuild archive -scheme Daoqi -archivePath build/$channel.xcarchive
xcodebuild -exportArchive ...
done
8. 能力模块详细设计
8.1 AudioKit
// AudioKit/AudioPlayer.swift
@MainActor
public final class AudioPlayer {
private var background: AVAudioPlayer?
private var button: AVAudioPlayer?
private var voice: AVAudioPlayer?
public func playOnce(_ url: URL) { ... }
public func loopBackground(_ url: URL, type: String) { ... }
public func stopBackground(type: String) { ... }
public func playVoice(_ url: URL,
onStart: @escaping () -> Void,
onEnd: @escaping () -> Void) { ... }
}
// AudioKit/VoiceCoder.swift
/// 全新 Swift wrapper,直接调用 libopencore-amr 静态库的 C 接口
/// (opencore-amr 是闭源静态库,业界标准 AMR 编解码实现,无 Swift 替代品)
public enum VoiceCoder {
public static func amrToWav(_ src: URL, dest: URL) throws { ... }
public static func wavToAmr(_ src: URL, dest: URL) throws { ... }
}
// AudioKit/AudioRecorder.swift
public actor AudioRecorder {
public func record() async throws -> AudioFile { ... }
/// 录音 → WAV → AMR → 上传七牛 → 返回 URL + 时长
public func recordAndUpload() async throws -> (audioUrl: String, time: Int) { ... }
}
8.2 ShareKit
策略模式分发,新增平台只加一个 conformance:
// 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:
// 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 的步骤(未来某天需要时):
- 在
Package.swift加 Agora SPM 依赖,工程加AGORA_ENABLED编译开关- SubGameHandlers 的
videoRoom依赖从NoopVideoRoom()改为AgoraVideoRoom()SubGameHandlers.createRoomStub/getVideoInfoStub/exitRoomStub换成真实业务逻辑(调videoRoom.join/videoRoom.leave等)- H5 端代码不动 — 它仍然
bridge.callHandler('createRoom', {...}),看到的getVideoinfo(uid)反向回包等契约不变
8.4 DeviceKit
// DeviceKit/DeviceInfo.swift
public enum DeviceInfo {
public static var snapshot: [String: String] {
[
"PhoneAdresseMAC": UIDevice.current.identifierForVendor?.uuidString ?? "",
"PhoneDeviceBrand": UIDevice.current.model,
"PhoneIMEI": ASIdentifierManager.shared().advertisingIdentifier.uuidString,
"PhoneModel": UIDevice.current.localizedModel,
"PhoneProvidersName": CarrierName.current ?? "",
"PhoneVersion": UIDevice.current.systemVersion
]
}
}
// DeviceKit/NetworkMonitor.swift
@MainActor
public final class NetworkMonitor {
public enum State: String { case none = "1", wifi = "2", cellular = "3" }
private let monitor = NWPathMonitor()
public var stateDidChange: ((State) -> Void)?
public func start() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in
let state: State =
path.status != .satisfied ? .none :
path.usesInterfaceType(.wifi) ? .wifi : .cellular
self?.stateDidChange?(state)
}
}
monitor.start(queue: DispatchQueue.global(qos: .utility))
}
}
用 Network.framework 替换 AFNetworkReachabilityManager。
8.5 微信 / QQ 多发起方派发(关键设计)
问题背景
微信 SDK 是全局单例(WXApi),只接受一个全局 delegate。所有授权回包 (SendAuthResp) 和分享回包 (SendMessageToWXResp) 都通过这个唯一 delegate 派发。
新架构里大厅和子游戏的 H5 都会调用 accreditlogin 和 friendsSharetypeUrlToptitleDescript:
H5 大厅 H5 子游戏
│ │
▼ ▼
LobbyHandlers SubGameHandlers
│ │
└─────────┬───────┘
▼
WXApi.send(...)
│
▼
(微信 App)
│
▼ 回包(只有 1 个 delegate 接收)
WXApiDelegate
│
❓ 派发给大厅还是子游戏?
旧架构的错误做法:让大厅和子游戏轮流持有 delegate(WXApiManager.delegate = self / nil),靠"谁活着谁注册"传球。这在以下场景必然出错:
- 大厅发起授权后用户立刻切去子游戏,授权回包来时 delegate 已经换给子游戏 → 大厅 H5 永远收不到
sharelogin,卡死 - 子游戏发起分享后回到大厅,分享回包来时归属错乱
- VC 之间忘记同步 delegate → 回包静默丢失
新架构方案:Continuation Map by state
WXApi.delegate 永久绑定到 WeChatManager.shared,永不切换。所有回包统一进入这个单例,再通过请求-响应配对派发给正确的发起方。
微信 OAuth2 协议天然提供 state 字段作为配对 key —— 发起请求时客户端塞一个 UUID,微信回包字面回传,这就是天生的请求 ID:
// 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,与全局状态无关:
// 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 入口
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,设计完全镜像:
@MainActor
public final class QQShareManager: NSObject {
public static let shared = QQShareManager()
private var pendingShare: CheckedContinuation<Void, QQError>?
// 与 WeChatManager 相同的 continuation 模式
}
为什么这套机制是对的
| 维度 | 旧架构(delegate 传球) | 新架构(continuation map by state) |
|---|---|---|
| 发起方记录 | 隐式 — 当前 delegate 持有者 | 显式 — pendingAuth map 的 key,pendingShare 的 continuation |
| 大厅发起回包归属 | 取决于回包时 delegate 是谁 → 易错 | 结构保证:LobbyHandlers.bridge = LobbyBridge,sharelogin / sharesuccess 必派大厅 |
| 子游戏发起回包归属 | 同上,易错 | 结构保证:SubGameHandlers.bridge = SubGameBridge,sharelogin / sharesuccess 必派子游戏 |
| 切换 VC 后是否丢回包 | 丢(delegate 已换) | 不丢(map / continuation 不受 VC 切换影响) |
| 并发发起 | 串扰(后发起的覆盖前 delegate) | 授权按 state 自然隔离;分享串行化(后发起者抛 .busy,不覆盖) |
| 测试 | 难(全局状态) | 易(WeChatManager / ShareCenter 可注入 mock) |
| 代码可读性 | 散落 delegate = self / nil |
集中在 WeChatManager,调用方只看到 try await |
微信 / QQ 派发机制清单
| 操作 | 配对方式 | 并发约束 |
|---|---|---|
微信授权 authorize() |
state UUID(微信回传) |
允许多并发(每个 state 独立) |
微信分享 share() |
全局 FIFO | 串行(同时只能一个 pending,新请求抛 .busy) |
| QQ 登录 / 分享 | 全局 FIFO + 操作类型标识 | 同上 |
→ 上层 accreditLogin / friendsShare... handler 看不到这些细节,只看到 try await,代码大厅 / 子游戏完全一致。
9. 并发模型
9.1 Actor 隔离
BridgeBus—@MainActor(WKWebView 必须主线程)HTTPClient/SGGateway/ConfigService/ResourceUnzipper/AudioRecorder—actor(各自串行)- 数据模型 —
Sendable struct(值类型,自由跨 actor) - 跨 actor 通信 — 全用
await,不允许 GCD 直接dispatch_async
9.2 严禁的反模式
// ❌ 不要
DispatchQueue.main.async { self.bridge.call("foo", data: ...) }
// ✅ 要
Task { @MainActor in bridge.call("foo", data: ...) }
9.3 启用 Swift 6 严格并发
Package.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/ |
系统自动清 |
// 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 错误分层
// 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 统一日志
// 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 文档,避免后续运维断档导致监控形同虚设。
// 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:
// 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,大厅 / 子游戏 / 弹层都注册)
// 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 一致:
// 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)
# .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 多渠道发包
# 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 工具链:
- 首选 SPM —— Apple 官方,Xcode 原生集成,无第三方工具依赖,无衍生工程文件污染,新人 clone 即用。适用于:Sentry、ZIPFoundation、七牛 v8.9+ 等已发布官方 Swift Package 的库
- 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 自带xcodeprojgem 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 化按以下顺序操作,保持工程一致性:
- SDK 二进制放
Vendor/<SDKName>/<SDKName>.xcframework(动态库)或Vendor/<SDKName>/lib<name>.a+ 头文件目录(静态库) - Xcode → Target → General → Frameworks, Libraries, and Embedded Content →
+加入,动态库选 "Embed & Sign",静态库选 "Do Not Embed" - 静态库
Library Search Paths/ 头文件Header Search Paths写到$(PROJECT_DIR)/Vendor/<SDKName>相对路径,不写绝对路径 - 新增 module bridging header(若是 Swift 调 OC SDK)
Vendor/<SDKName>/README.md写明版本号 / 下载日期 / 官方更新页 URL- 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 技术债项跟踪- 无论实现路径如何切换,
sharelogin7 字段(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 切流策略
- 旧
msextIPA 与新DaoqiIPA 共存(不同 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. 开发规约
- commit message 中文短句,主题+原因,参考
git log现有风格 - PR 单一职责:重构 PR 不混 bugfix;桥接 handler 修改单独 PR,要附契约验收截图
- 新增 H5 handler 流程:① 在
H5-Native-Contract.md加描述 ② 写 contract 单测 ③ 实现 handler ④ 真机 H5 端联调 - 删除/重命名任何已存在的 handler 名禁止(契约违反),除非 H5 同步发版
- 代码评审强制:任何接触
BridgeCore/BundleConfig/SandboxPaths的 PR 必须 2 人 +1 - 文档与代码同步:
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
附录:启动文件最小骨架
// 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
func register() {
// 登录 / 分享
bridge.register("accreditlogin", handler: accreditLogin)
bridge.register("friendsSharetypeUrlToptitleDescript", handler: friendsShare)
// 音频 / 录音
bridge.register("srcIsloop", handler: srcIsLoop)
bridge.register("prepareaudio", handler: prepareAudio)
bridge.register("mediaTypeAudio", handler: mediaTypeAudio)
bridge.register("voicePlaying", handler: voicePlaying)
// 摇一摇 / 振动
bridge.register("startshake", handler: startShake)
bridge.register("stopshake", handler: stopShake)
bridge.register("SwitchShake", handler: switchShake)
bridge.register("vibrator", handler: vibrator)
bridge.register("repeatvibrator", handler: repeatVibrator)
bridge.register("canclevibrator", handler: cancelVibrator)
// 剪贴板
bridge.register("gamepastetext", handler: gamePasteText)
bridge.register("gameCopytext", handler: gameCopyText)
// 网页 / 浏览器 / 跳子游戏
bridge.register("OpenurlTitleData", handler: openUrlTitleData)
bridge.register("browser", handler: browser)
bridge.register("SwitchOverGameData", handler: switchOverGameData)
// 定位 / 设备 / 扫码
bridge.register("startlocation", handler: startLocation)
bridge.register("getphoneInfo", handler: getPhoneInfo)
bridge.register("opensaoma", handler: openSaoma) // ⚠️ 空实现仍要注册
}
20.2 SubGameHandlers — 子游戏注册 23 个 handler
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)
// 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 个)
extension BridgeProtocol {
// 大厅 + 子游戏共享 12 个
func callGetPhoneInfo(_ info: [String: String]) { call("getphoneinfo", data: .object(info.mapValues(BridgeData.string)), callback: nil) }
func callGetBattery(_ level: Float) { call("getBattery", data: .string(String(format: "%.2f", level)), callback: nil) }
func callGetNetwork(_ state: NetworkState) { call("getnetwork", data: .string(state.rawValue), callback: nil) } // "1"/"2"/"3"
func callAppService(_ s: AppLifecycleState) { call("appservice", data: .string(s == .background ? "1" : "2"), callback: nil) }
func callShakeEnd() { call("shakeEnd", data: nil, callback: nil) }
func callGetLocationInfo(_ d: LocationPayload) { call("getlocationinfo", data: .object(d.dict), callback: nil) } // 字段见契约 §3.2 表B
func callGameUIPlayVoice(_ user: String) { call("gameui_play_voice", data: .string(user), callback: nil) }
func callGameUIStopVoice(_ user: String) { call("gameui_stop_voice", data: .string(user), callback: nil) }
func callGetAudioUrl(_ url: String, time: Int) { call("getaudiourl", data: .object([
"audiourl": .string(url),
"time": .string(String(time))
]), callback: nil) }
func callShareLogin(_ user: WeChatUser) { call("sharelogin", data: .object([
"openid": .string(user.openid),
"headimgurl": .string(user.headimgurl),
"nickname": .string(user.nickname),
"sex": .string(String(user.sex)),
"city": .string(user.city),
"Province": .string(user.province), // ⚠️ 大写 P
"unionid": .string(user.unionid)
]), callback: nil) }
func callShareSuccess(type: String) { call("sharesuccess", data: .object([
"success": .string("2"),
"type": .string(type)
]), callback: nil) }
func callGetWebData(_ data: String) { call("getWebdata", data: .string(data), callback: nil) }
// 子游戏独有 3 个(LobbyBridge 不要注册)
func callGetVideoInfo(_ uid: UInt) { call("getVideoinfo", data: .string(String(uid)), callback: nil) }
func callPhoneState(_ state: CallState) { call("phonestate", data: .string(state == .incoming ? "2" : "0"), callback: nil) }
func callRecordSuccess(_ file: UploadedFile) { call("recordSuccess", data: .object([
"fileUrl": .string(file.publicUrl),
"fileName": .string(file.fileName),
"fileKey": .string(file.qiniuKey)
]), callback: nil) }
}
20.5 JS 注入文件(契约 §4.2)
每次 loadFileURL 之前写入 3 个 JS 文件到 Library/Caches/{gamedir}/{gamestart}/ 下,内容由 BundleConfig + DeviceKit + NetworkMonitor 提供:
// 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
// 仅 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