Files
youle_app_ios_v2/docs/H5-Native-Implementation-Design.md
T
joywayerandClaude Opus 4.7 d48ecc0473 Design §8.6:新增 LocationKit + 2 项接口实现骨架
覆盖 §3.1 [20]startlocation + §3.2 [6]getlocationinfo 反向 callback。

§8.6.1 LocationService module 骨架:
  - AMapServices.bootstrap:privacy + key 启动期完成
  - locateOnce() async/await 包装 AMapLocationManager.requestLocation
  - startContinuous / stopContinuous + onContinuousUpdate 闭包钩子
  - LocationInfo struct 转契约 §3.2 表 B 9 字段(latitude/longitude string
    化用 String(format: "%f"),province 小写 p)
  - LocationError.permissionDenied(code: 12)

§8.6.2 H5 handler(LocationHandlers.startLocation):
  - 入参 data: int 1=持续 / 其他=一次性,与 msext RootVC 沿用
  - 持续模式:onContinuousUpdate 挂钩 → 每次位置变化推 getlocationinfo
  - 一次性:await locateOnce → 单次推
  - responseCallback: "startlocation"
  - 失败 callback 数据结构 errorCode 数字 12 不是 string(契约硬约束)

§8.6.3 与 msext 6 维度差异表(SDK 接入 / 隐私合规集中 / async wrapper /
入参解析 / latitude string / province 小写 p)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 07:30:06 +08:00

158 KiB
Raw Blame History

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 目标

  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+;AudioKitShareKit 看依赖深度可保持独立或合并。目标稳态 8-10 个 target,以日常开发体验为先。

2.3 模块职责矩阵

模块 暴露能力 内部依赖
BridgeCore BridgeProtocolBridgeMessageBridgeBus(actor) Foundation+
WebViewKit BridgedWebView(包装 WKWebView+消息桥), JSInjector BridgeCore, ResourceKit, Foundation+
BridgeHandlers LobbyHandlersSubGameHandlersOverlayHandlers 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 派发桥事件:

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 srcconsole.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.4.1 大厅 / 子游戏的 window.settings 同步 getter polyfill(契约 §附录 A 9 项 getter)

背景:契约 §附录 A 列出 28 项旧桥 JSExport 方法(iOS<9 路径),与 §3.1 异步 callback 主表去重后多出 9 项 H5 同步只读 getter

getter 返回类型 业务语义 数据源
getchannelName() string 渠道 ID11 项渠道注入之一) BundleConfig.shared.channel
getmarketname() string 市场 ID11 项渠道注入之一) BundleConfig.shared.market
getOther() string 渠道 other 字段 BundleConfig.shared.other
getothername(name) string 按 H5 传入 key 动态读 11 项渠道注入任意字段 BundleConfig.shared.value(forKey: name)
getcompareCode() int 业务校验码(msext RootVC.m 沿用 zip 版本号或固定值) 待原 msext 取值确认(Phase 2 实施时查 RootVC.m:1560 附近 getcompareCode 真实返回值,并对齐)
getbattery() double 当前电池电量 0.01.0 UIDevice.current.batteryLevel(启动期 snapshot 一次)
getnetwork() int 当前网络类型(0 无 / 1 WiFi / 2 蜂窝) NWPathMonitor 当前 pathloadFileURL 前 snapshot
getGameinstall(name) int 子游戏目录是否存在(0/1 SandboxPaths.subGameRoot(name) 后注入已安装列表
getGameplay(jsondata) void 契约 §3.1 19空实现H5 仍会调,原生 noop 即可

为什么不走 BridgeBus 异步 callbackH5 端代码形式是 var ch = window.settings.getchannelName()var b = window.settings.getbattery()同步表达式(取值后立即用于业务判断),WKWebView 时代 native 无法同步返回 JS 值(异步 evaluateJavaScript 改不了 H5 端代码 → 违反契约原则 A)。唯一不破契约的实现路径:WebView 加载前在 documentStart 注入完整数据快照 + 同步 JS getter polyfill,本地查询零延迟。

数据快照时机

  • 静态字段(渠道 / market / other / appVersion 等 11 项渠道注入):app 启动期读 ChannelConfig.plist 后即不变,全程一次即可
  • 动态字段(getbattery / getnetwork):每次 loadFileURL 前重新 snapshot 注入(精度足够,原 msext 自身也只在 viewDidLoad 取一次,H5 业务里"启动时刻电量值"被复用整个会话)
  • 已安装子游戏列表(getGameinstall):每次 loadFileURL 前扫描沙盒 + 注入 → SwitchOverGameData 解压新子游戏后自然在下次 loadFileURL 刷新

实现骨架

// Source/WebView/SettingsBridgePolyfill.swift
//
// 把契约 §附录 A 的 9 项 H5 同步 getter 实现为 documentStart 注入的 JS polyfill
// 数据全部本地查询、零延迟,与原 msext JSExport 同步行为等价。
//
// 注入流程:
//   WebContainerViewController.loadFileURL(lobbyIndex) 调用前
//     → SettingsBridgePolyfill.makeUserScript(BundleConfig + DeviceSnapshot + InstalledGames)
//     → BridgedWebView 把 WKUserScript 加到 WKUserContentController
//     → loadFileURL → H5 一加载即可同步读 window.settings.getXxx()

@MainActor
public enum SettingsBridgePolyfill {

    /// 构造一段 documentStart 注入的 JS。data 是 Native 端拼好的快照。
    public static func makeUserScript(snapshot: Snapshot) -> WKUserScript {
        let json = (try? JSONSerialization.data(withJSONObject: snapshot.jsonObject))
            .flatMap { String(data: $0, encoding: .utf8) }
            ?? "{}"
        let source = """
        (function() {
            window.__nativeSnapshot = \(json);
            window.settings = window.settings || {};

            // ── 11 项渠道注入(静态,启动期一次性快照)────────────
            window.settings.getchannelName  = function() { return window.__nativeSnapshot.channel || ""; };
            window.settings.getmarketname   = function() { return window.__nativeSnapshot.market || ""; };
            window.settings.getOther        = function() { return window.__nativeSnapshot.other || ""; };
            window.settings.getothername    = function(name) {
                if (!name) return "";
                return (window.__nativeSnapshot.channelConfig || {})[name] || "";
            };

            // ── 业务校验码 + 设备动态字段(每次 loadFileURL 前刷新)──
            window.settings.getcompareCode  = function() { return window.__nativeSnapshot.compareCode || 0; };
            window.settings.getbattery      = function() { return window.__nativeSnapshot.battery || 0.0; };
            window.settings.getnetwork      = function() { return window.__nativeSnapshot.network || 0; };

            // ── 子游戏安装查询(每次 loadFileURL 前快照已安装列表)─
            window.settings.getGameinstall  = function(name) {
                if (!name) return 0;
                var list = window.__nativeSnapshot.installedGames || [];
                return list.indexOf(name) >= 0 ? 1 : 0;
            };

            // ── 已知空实现(契约 §3.1 [19],H5 仍会调)────────────
            window.settings.getGameplay     = function(_jsondata) { /* no-op */ };
        })();
        """
        return WKUserScript(source: source,
                            injectionTime: .atDocumentStart,
                            forMainFrameOnly: true)
    }

    public struct Snapshot: Sendable {
        public let channelConfig: [String: String]   // 11 项渠道注入完整字典
        public let channel: String
        public let market: String
        public let other: String
        public let compareCode: Int
        public let battery: Double
        public let network: Int                       // 0/1/2
        public let installedGames: [String]

        public var jsonObject: [String: Any] {
            [
                "channelConfig":   channelConfig,
                "channel":         channel,
                "market":          market,
                "other":           other,
                "compareCode":     compareCode,
                "battery":         battery,
                "network":         network,
                "installedGames":  installedGames
            ]
        }

        /// 从 BundleConfig + DeviceKit + SandboxPaths 拼装当前快照
        @MainActor
        public static func make() -> Snapshot {
            let bc = BundleConfig.shared
            return Snapshot(
                channelConfig: bc.asDictionary,
                channel:       bc.channel,
                market:        bc.market,
                other:         bc.other,
                compareCode:   CompareCodeProvider.current(),    // 见下文
                battery:       DeviceKit.batteryLevel(),
                network:       NetworkMonitor.shared.currentTypeCode,
                installedGames: SandboxPaths.installedSubGames()
            )
        }
    }
}

WebContainerViewController 接入点

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.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 后恢复,防止双重触发。新外壳默认不要这个节流,理由:

  1. 节流的根因是旧代码在 applicationDidBecomeActive 内被多处触发,新外壳只有 SceneDelegate 一处发通知
  2. 测试如果证实双触发,加节流是 1 行代码的事(DispatchQueue.asyncAfter),不是架构改动
  3. H5 端通常对重复 appservice("2") 是幂等的(就是刷新 UI 状态)

如真机回归发现 H5 双触发产生问题,在 LobbyHandlers 内加 throttle 即可,不污染 SceneDelegate。

4.5 子游戏 → 大厅数据回传通知

旧外壳:子游戏 backgameData handler 发 NSNotification "backgameDatatwo",大厅监听后转 bridge.call("getWebdata", data)

新外壳同样是模块内通讯,可以自由命名。推荐用专属 Notification.Name:

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 升级流水线(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

// Source/Network/RemoteConfigClient.swift
public actor RemoteConfigClient {
    private let session: URLSession
    private let urlBuilder: () -> URL

    /// 默认根据 BundleConfig.shared.gameConfig 拼装:
    ///   http:// + gameConfig.replacingOccurrences("-", "/") + ".txt"
    /// 注意:原 msext 没有 SERVERNew 前缀(NewRootVC.m:250),照搬。
    public init(
        session: URLSession = .shared,
        urlBuilder: @escaping () -> URL = Self.defaultURL
    ) { ... }

    /// 拉远端 .txt(实际是 JSON),10s 超时。
    /// 指数退避 1/2/4 秒最多 3 次(msext 用 4s 等间隔 timer,新外壳更省电)。
    public func fetch() async throws -> RemoteConfig {
        for attempt in 0..<3 {
            do { return try await fetchOnce() }
            catch where attempt < 2 {
                try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000)
            }
        }
        throw RemoteConfigError.allRetriesFailed
    }
}

public struct RemoteConfig: Codable, Sendable {
    public let showmessage: String?
    public let agentlist: [Agent]?
}
public struct Agent: Codable, Sendable {
    public let agentid: String
    public let showmessage: String?
    public let app_version: String?
    public let app_download: String?
    public let game_version: String?
    public let game_zip: String?
    public let channellist: [Channel]?
    public let gamelist: [Game]?
}
public struct Channel: Codable, Sendable { ... }
public struct Game: Codable, Sendable { ... }
public struct Market: Codable, Sendable { ... }

6.3.2 VersionResolverchulishengji 双子树合并,纯函数)

核心修正(ADR-008 二次精确化):线上热路径是 chulishengji 双子树合并,不是简单的"顶 → agent → channel → market"线性覆盖。

// Source/Network/VersionResolver.swift
public enum VersionResolver {

    public struct Resolved: Sendable {
        public let appVersion: Int       // 0 表示远端未指定
        public let appDownload: String?
        public let gameVersion: Int      // 0 表示远端未指定
        public let gameZip: String?
        public let showmessage: String?  // 非空时阻塞,运营杀手锏
    }

    /// chulishengji 双子树合并(NewRootVC.m:1372-1538):
    /// 1. 从 agent 子树提取(agent → channel → market → gamegame 嵌在 market 命中后才遍历)
    /// 2. 从 game 子树提取(game → game-self → channel → market
    /// 3. 字段级合并:
    ///    - IPAappVersion / appDownload)默认 agent 赢;game 版本号更大时 game 赢(line 1416
    ///    - zipgameVersion / gameZip)默认 game 赢;agent 版本号更大时 agent 赢(line 1456
    /// 4. showmessage 两条子树多层多次覆盖,最后写赢
    public static func resolve(
        config: RemoteConfig,
        agentId: String,
        channelId: String,
        marketId: String,
        gameId: String
    ) -> Resolved {
        // 1. 找到当前 agent / channel / market / game 节点
        guard let agent = config.agentlist?.first(where: { $0.agentid == agentId }),
              let game  = agent.gamelist?.first(where: { $0.gameid == gameId }) else {
            // 找不到匹配(极端情况):返回 0 / nil,让 WebContainer 走"无升级"路径
            return Resolved(appVersion: 0, appDownload: nil,
                            gameVersion: 0, gameZip: nil,
                            showmessage: config.showmessage)
        }

        let agentExtract = extractAgentSubtree(
            agent: agent, channelId: channelId, marketId: marketId, gameId: gameId)
        let gameExtract = extractGameSubtree(
            game: game, channelId: channelId, marketId: marketId)

        // IPA 合并(NewRootVC.m:1408-1447
        let (appVer, appDL) = mergeIPA(agent: agentExtract, game: gameExtract)
        // zip 合并(NewRootVC.m:1450-1492
        let (gameVer, gameZip) = mergeZip(agent: agentExtract, game: gameExtract)
        // showmessage:任意子树非空就用(最后写赢)
        let msg = gameExtract.showmessage ?? agentExtract.showmessage ?? config.showmessage

        return Resolved(
            appVersion: appVer,
            appDownload: appDL,
            gameVersion: gameVer,
            gameZip: gameZip,
            showmessage: msg
        )
    }

    /// agent 子树 4 层提取(getagentversionNewRootVC.m:885-1013
    /// agent → channel → market → gamegame 嵌在 market 命中后才遍历)
    /// 注意:agent 子树用 game_download 字段名,game 子树用 game_zip——两个都要查
    private static func extractAgentSubtree(...) -> SubtreeResult { ... }

    /// game 子树 4 层提取(getgameversionNewRootVC.m:1015-1180
    /// game → game-selfgamedata 自身再嵌 channellist)→ channel → market
    private static func extractGameSubtree(...) -> SubtreeResult { ... }

    /// IPA 合并:默认 agent 赢;game.appVersion > agent.appVersion 时 game 赢
    private static func mergeIPA(...) -> (Int, String?) { ... }

    /// zip 合并:默认 game 赢;agent.gameVersion > game.gameVersion 时 agent 赢
    private static func mergeZip(...) -> (Int, String?) { ... }
}

关键测试用例(单测必须覆盖):

用例 验证
顶层 / agent / channel / market / game 都有 app_version agent 子树最深层赢;与 game 子树合并时按版本号大者
仅 agent 子树有,game 子树无 app_version 取 agent
仅 game 子树有,agent 子树无 app_version 取 game
game.appVersion = 10 vs agent.appVersion = 5 game 赢
game.appVersion = 5 vs agent.appVersion = 10 agent 赢
game.gameVersion = 10 vs agent.gameVersion = 5 game 赢(默认)
game.gameVersion = 5 vs agent.gameVersion = 10 agent 赢
showmessage 仅顶层有 取顶层
showmessage agent 层有 + 顶层有 取 agent(深层覆盖浅层)
showmessage market 层有 + 全部层都有 取 market(最深)
agentid 找不到匹配 返回 default0/nil),不抛
gameid 找不到匹配 返回 default,不抛
字段名 game_download vs game_zip 混用 两个 key 都查,择一非空

6.3.3 LocalVersionReader

// 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

// Source/Resource/LobbyZipUpgrader.swift
public actor LobbyZipUpgrader {

    public enum UpgradeOutcome {
        case noop                    // 远端版本 ≤ 本地,无需升级
        case upgraded(from: Int, to: Int)
    }

    public func upgradeIfNeeded(
        remoteGameVersion: Int,
        remoteGameZip: String?
    ) async throws -> UpgradeOutcome {
        let local = LocalVersionReader.localGameVersion
        guard remoteGameVersion > local, let zipURL = remoteGameZip.flatMap(URL.init) else {
            return .noop
        }

        // 1. 下载到 tmp(断点续传由 URLSessionDownloadTask 默认支持)
        let (tmpZip, _) = try await session.download(from: zipURL)

        // 2. 解压到隔离目录
        let stagingDir = SandboxPaths.caches.appendingPathComponent("staging-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true)
        try FileManager.default.unzipItem(at: tmpZip, to: stagingDir)

        // 3. 原子 rename:清旧 lobbyRoot,把 stagingDir 移到位
        let lobbyRoot = SandboxPaths.lobbyRoot
        try? FileManager.default.removeItem(at: lobbyRoot)
        try FileManager.default.moveItem(at: stagingDir, to: lobbyRoot)

        // 4. 清 tmp
        try? FileManager.default.removeItem(at: tmpZip)

        return .upgraded(from: local, to: remoteGameVersion)
    }
}

6.3.5 WebContainer 调用串 + Splash UI 状态机(Phase 1.14

WebContainerViewController 同时承担两层:

  • 下层BridgedWebView 嵌入 + 16:9 letterbox(屏幕比 < 16:9 上下黑边;> 16:9 左右黑边)
  • 上层(Splash 覆盖):与 LaunchScreen.storyboard 同款启动图(横屏铺满)+ UIProgressView + 状态文字标签
    • 启动瞬间:LaunchScreen 静态图(iOS 系统级,0 延迟)
    • viewDidLoadWebContainer 把自己的 Splash 覆盖在 BridgedWebView 之上(两者 alpha 0/1 由状态切换)
    • 启动流水线推进时:updateSplash(text: ..., progress: ...) 更新文字 + 进度条
    • WKWebView didFinish 后:UIView.animate(duration: 0.3) splash.alpha = 0 → removeFromSuperview
// Source/WebView/WebContainerViewController.swiftPhase 1.14
override func viewDidLoad() {
    super.viewDidLoad()
    view.backgroundColor = .black

    setupBridgedWebView()        // 16:9 letterbox 嵌入
    setupSplashOverlay()          // 启动图 + 进度条 + 文字标签

    bridgedWebView.webView.navigationDelegate = self

    Task { @MainActor in
        await runBootPipeline()
    }
}

private func runBootPipeline() async {
    do {
        updateSplash(text: "拼命启动中...", progress: nil)
        try await ResourceUnzipper.shared.ensureReady()

        updateSplash(text: "拉取配置中...", progress: nil)
        let outcome = try await RemoteConfigClient.shared.fetch()

        switch outcome {
        case .shortText(let msg):
            // 运营杀手锏 #1:弹 alertApp 永停(msext NewRootVC.m:1239-1243 契约)
            showBlockingAlert(msg)
            return
        case .parsed(let cfg):
            let bc = BundleConfig.shared
            let resolved = VersionResolver.resolve(
                config: cfg,
                agentId: bc.agent,
                channelId: bc.channel,
                marketId: bc.market,
                gameId: bc.gameId
            )

            // 运营杀手锏 #2showmessage 非空 → 弹 alert + 完全阻塞
            if let msg = resolved.showmessage, !msg.isEmpty {
                showBlockingAlert(msg)
                return
            }

            // IPA 升级:弹窗 + Safari 外链(msext NewRootVC.m:1510-1520
            if resolved.appVersion > LocalVersionReader.localAppVersion,
               let dl = resolved.appDownload {
                showIPAUpgradeAlert(dl)
                return
            }

            // H5 zip 升级:进度条实时更新
            _ = try await LobbyZipUpgrader.shared.upgradeIfNeeded(
                resolved: resolved,
                onProgress: { @MainActor [weak self] p in
                    self?.updateSplash(
                        text: String(format: "下载更新中 %d%%", Int(p * 100)),
                        progress: p
                    )
                }
            )
        }

        updateSplash(text: "加载大厅...", progress: nil)
        bridgedWebView.webView.loadFileURL(
            SandboxPaths.lobbyIndex,
            allowingReadAccessTo: SandboxPaths.lobbyRoot
        )
        // didFinish 回调里淡出 splash(见 WKNavigationDelegate 扩展)
    } catch {
        showFatalAlert(error)
    }
}

// MARK: - WKNavigationDelegate
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    UIView.animate(withDuration: 0.3, animations: { [weak self] in
        self?.splash.alpha = 0
    }, completion: { [weak self] _ in
        self?.splash.removeFromSuperview()
    })
}

Splash UI 状态切换表

阶段 文字 progress 持续时长(典型)
viewDidLoad 起 "拼命启动中..." 隐藏 ~50 ms
首装解压 zip 中 "拼命启动中..." 隐藏 ~50-500 ms
拉远端配置中 "拉取配置中..." 隐藏 ~500-2000 ms
H5 zip 下载中 "下载更新中 XX%" 0.0-1.0 ~1000-3000 ms
WebView 加载 H5 中 "加载大厅..." 隐藏 ~500-1000 ms
WebView didFinish (淡出消失) - 300 ms 动画

所有失败 / 阻塞终态都用 UIAlertController(非 splash 文字)

  • 短文本响应:弹 alert,标题 gamehallname + 提醒,单按钮"确定",无 tap 处理(永停)
  • showmessage 非空:同上
  • IPA 升级:弹 alert,单按钮"确定"→ openURL(appDownload),永停
  • 网络错误 / 解析失败:弹 alert,单按钮"重试",点击重新 runBootPipeline()
  • zip 下载失败:弹 alert,单按钮"重试" + 单按钮"使用旧版本"(回退用 IPA 内 Bundle zip

#### 6.3.6 与 msext 历史实现的差异(不要照抄的部分)

| 维度 | msext 现状(不要照抄) | 新外壳决策 |
|------|--------------------|----------|
| 网络模型 | `[NSData dataWithContentsOfURL:]` 主线程同步阻塞 + ASIHTTPRequest 后台下载 | URLSession `async data(from:)` / `download(from:)`actor 隔离 |
| 失败重试 | `viewDidLoad` 起 4s 等间隔 `timer`,至无穷直到成功 | 指数退避 1/2/4 秒最多 3 次,超出 throw 由 UI 决定回退路径 |
| 4 级覆盖 | `getagentversion` / `getgameversion` / `chulishengji` 三段散落 if/else | 纯函数 `VersionResolver.resolve`,单测覆盖所有边界 12+ 用例 |
| 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`,半途崩溃留半残 | 解压到 `staging-{uuid}/` 临时目录 + 原子 `moveItem` rename,半途崩溃只留 tmp(下次启动可清) |
| 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11` | 同款原子 rename,旧目录直接覆盖 |
| 配置解析 | SBJSON 三方库 | Codable + JSONDecoder |
| 版本号 | NSString → intValue(自动 0 | 强类型 Int,缺失明示 |

#### 6.3.7 子游戏升级复用

Phase 6 子游戏(`SwitchOverGameData` 入参的 `Gamedirectory` / `gamedownloadurl`)的升级逻辑**复用** `LobbyZipUpgrader` 的设计,差异仅在:
- 目标路径不同(`SandboxPaths.subGameRoot(dir)` 而非 `lobbyRoot`
- 版本号 source 不同(子游戏 `version.xml` 而非大厅)
- 不重新拉远端配置(继承大厅的 `RemoteConfig`

具体接口扩展到 Phase 6 设计时定。

---

## 7. 资源 & 渠道注入

### 7.0 项目资源目录约定

项目资源目录的最终结构**由各 Phase 实施时按需定义**,本节列出固定规则,具体目录形态在落地时决定。

#### 7.0.1 仓库内素材池 `docs/res/` 与项目无关

`docs/res/` 是项目维护者的**私人原始素材池**,**与项目架构无关**(详见 `CLAUDE.md` 「docs/res/ 与项目资源的关系」):

- 项目代码 / 构建脚本 / Xcode 工程都**不感知**它的存在,不引用任何 `docs/res/xxx` 路径
- 它的目录结构 / 命名 / 内容可由维护者任意调整,不影响构建
- git 跟踪只是为了多机同步素材,而非项目需要

需要某素材时,**从 `docs/res/` 拷一份**到项目内规划好的位置,后续维护与 `docs/res/` 不再有任何关联。

#### 7.0.2 项目内资源目录(Phase 实施时落地)

按现代 iOS 单 target + synchronized group 实践,推荐落点如下,**但具体目录在该 Phase 真正需要时再创建**(避免预先空目录):

| 落点 | 用途 | 入 Bundle 方式 |
|------|------|--------------|
| `ylgamehall/Resources/` | 静态打入 Bundle 的项目资源(`gamehall.zip` / `WebViewJavascriptBridge.js` / 本地音效 mp3 / `ChannelConfig.plist` 等) | synchronized group 自动收集 |
| `ylgamehall/Assets.xcassets/` | 原生 Asset Catalog(AppIcon / LaunchImage / 分享平台图标) | Xcode 默认 |
| `Vendor/<SDK>/<SDK>.xcframework` | 闭源 SDK 二进制(微信 / QQ / 高德 / opencore-amr) | Target Build Phase「Frameworks」手动加入 |

#### 7.0.3 渠道注入:ChannelConfig.plist 母包模式

**契约 §0.3 描述 msext 用"空目录名注入"机制存储 11 个渠道值,本项目按 ADR-007 改用 `ChannelConfig.plist` 等价实现** —— 契约边界(H5 通过 `app_data.js` 看到的 11 个 JS 全局变量)完全不变,实现内部更简洁。

**存储**:`ylgamehall/Resources/ChannelConfig.plist`,11 个 string key 一一对应渠道值:

```xml
<dict>
    <key>qiniudomain</key>  <string>iosaudio.daoqi88.cn</string>
    <key>gameid</key>       <string>G2hw0u...</string>
    <key>channel</key>      <string>FtJf07...</string>
    <key>gamedir</key>      <string>FtJf07...</string>
    <key>gamestart</key>    <string>gamehall</string>
    <key>gameconfig</key>   <string>tsgames.daoqi88.cn-config_test-update_jsonv2_test</string>
    <key>market</key>       <string>2</string>
    <key>agent</key>        <string>veRa0qrBf0df2K1G4de2tgfmVxB2jxpv</string>
    <key>appversion</key>   <string>43</string>
    <key>other</key>        <string></string>
    <key>appleconfig</key>  <string></string>
</dict>

运行时读取路径:Bundle.main.bundleURL.appendingPathComponent("ChannelConfig.plist"),由 BundleConfig.swift(§7.2)用 PropertyListSerialization 反序列化。

多渠道分发(IPA 后处理,不重新 Xcode build):

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.1.1 H5 audio handlers 实现骨架

把契约 §3.1 §B 的 4 项 audio handler(【3】–【6】)粘到 AudioKit 上的胶水代码。注册集中到 AudioHandlers 一个 struct,便于发现 / 单测。

// Source/Bridge/Handlers/AudioHandlers.swift
import Foundation

public struct AudioHandlers {

    let bridge: BridgeProtocol
    let player: AudioPlayer
    let recorder: AudioRecorder
    let voiceCenter: VoiceCenter    // 8.1.1 引入,控制远端语音的播放总开关

    public func register() {
        bridge.register("srcIsloop",      handler: srcIsloop)
        bridge.register("prepareaudio",   handler: prepareAudio)
        bridge.register("mediaTypeAudio", handler: mediaTypeAudio)
        bridge.register("voicePlaying",   handler: voicePlaying)
    }

    // MARK: - 【3】 srcIsloop — 本地音频播放
    //
    // 契约 §3.1 3]:
    //   入参 src    : string  音频文件名,路径 = Library/Caches/{gamedir}/{gamestart}/assets/wav/{src}
    //   入参 isloop : int     0 单次 / 1 循环 / -1 停同类循环
    //   responseCallback: "Response from srcIsloop"
    //
    // 与原 msext 的差异(不破契约):
    //   - 原项目用 NSURL fileURLWithPath: 拼接 + AVAudioPlayer 同步初始化;
    //     新外壳通过 SandboxPaths 统一管理,所有路径在一处可校验
    //   - 原项目 backgroundType 用全局 NSString *;新外壳把状态封装在
    //     @MainActor 的 AudioPlayer 内部,避免跨线程访问竞态

    private func srcIsloop(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        defer { cb?(.string("Response from srcIsloop")) }
        guard let obj = data?.asObject,
              let src = obj["src"]?.asString,
              let isloop = obj["isloop"]?.asInt
        else {
            Log.warn("srcIsloop: bad payload \(String(describing: data))")
            return
        }

        let url = SandboxPaths.lobbyAssets
            .appendingPathComponent("wav")
            .appendingPathComponent(src)

        await MainActor.run {
            switch isloop {
            case 0:  player.playOnce(url)
            case 1:  player.loopBackground(url, type: src)
            case -1: player.stopBackground(type: src)
            default: Log.warn("srcIsloop: unexpected isloop=\(isloop)")
            }
        }
    }

    // MARK: - 【4】 prepareaudio — 启动麦克风录音
    //
    // 契约 §3.1 4]:
    //   入参   : 忽略
    //   副作用 : 权限检查 → recordAndUpload → getaudiourl callback
    //   responseCallback: "Response from prepareaudio"
    //
    // 权限态参考契约 §6.2「ifauth」三态语义:
    //   0 denied/restricted → 弹 alert 并放弃
    //   1 not determined    → AudioRecorder 内部触发系统询问
    //   2 authorized        → 直接录
    //
    // 与原 msext 的差异:
    //   - 原项目 VoiceRecorderBaseVC 是 UI 录音控件,含 cancel/redo 按钮,
    //     与 H5 的录音 UI 重复。新外壳走"无 UI 录音"路径:H5 自己画按钮,
    //     原生只负责拿到 AVAudioRecorder 数据 → 转码 → 上传,不弹任何原生界面
    //   - 上传走七牛 SDK(已在 ADR-006 选型);旧 PostFile 接口不再支持

    private func prepareAudio(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        cb?(.string("Response from prepareaudio"))

        switch await Permissions.microphone() {
        case .denied, .restricted:
            // 契约要求:通过 H5 alert 提示(不是原生弹窗),文案 = "{gamehallname}需要访问您的麦克风"
            bridge.call("alertMessage",
                        data: .string("\(BundleConfig.shared.appDisplayName)需要访问您的麦克风"),
                        callback: nil)
            return
        case .notDetermined, .authorized:
            break
        }

        do {
            let result = try await recorder.recordAndUpload()
            // 契约 §3.2 9]:getaudiourl({audiourl, time})
            bridge.call("getaudiourl",
                        data: .object([
                            "audiourl": .string(result.audioUrl),
                            "time":     .string(String(result.time))    // ⚠️ 字符串,非 number
                        ]),
                        callback: nil)
        } catch {
            Log.error("prepareaudio failed: \(error)")
        }
    }

    // MARK: - 【5】 mediaTypeAudio — 远程语音回放
    //
    // 契约 §3.1 5]:
    //   入参 audiourl: string   远端 AMR URL
    //   入参 user    : string   发声方用户 ID
    //   responseCallback: "Response from mediaTypeAudio"
    //   开始播放: gameui_play_voice(user)  (§3.2 7])
    //   播放结束: gameui_stop_voice(user)  (§3.2 8])
    //
    // 总开关由 voicePlaying 控制(【6】),关闭态直接静默不播

    private func mediaTypeAudio(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        defer { cb?(.string("Response from mediaTypeAudio")) }

        guard let obj = data?.asObject,
              let urlStr = obj["audiourl"]?.asString,
              let user = obj["user"]?.asString,
              let remote = URL(string: urlStr)
        else {
            Log.warn("mediaTypeAudio: bad payload")
            return
        }

        guard await voiceCenter.isEnabled else {
            Log.info("mediaTypeAudio: voicePlaying off, skip user=\(user)")
            return
        }

        do {
            let amr = try await AudioDownloader.fetchToCache(remote)
            let wav = try await VoiceCoder.amrToWavAsync(amr)
            await MainActor.run {
                player.playVoice(wav,
                    onStart: { [weak bridge] in
                        bridge?.call("gameui_play_voice", data: .string(user), callback: nil)
                    },
                    onEnd: { [weak bridge] in
                        bridge?.call("gameui_stop_voice", data: .string(user), callback: nil)
                    })
            }
        } catch {
            Log.error("mediaTypeAudio failed for user=\(user): \(error)")
            // 失败也要补 stop_voice,避免 H5 端 UI 永远停在"播放中"状态
            bridge.call("gameui_stop_voice", data: .string(user), callback: nil)
        }
    }

    // MARK: - 【6】 voicePlaying — 语音播放总开关
    //
    // 契约 §3.1 6]:
    //   入参: int 1=允许 / 其他=静默
    //   responseCallback: "Response from voicePlaying"
    //
    // VoiceCenter 持有 actor 隔离的 bool 状态,mediaTypeAudio 每次读它
    // 决定要不要走完整的下载→转码→播放链路

    private func voicePlaying(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        defer { cb?(.string("Response from voicePlaying")) }
        let on = (data?.asInt ?? 0) == 1
        await voiceCenter.setEnabled(on)
    }
}

/// 远端语音播放总开关(actor 隔离,跨 bridge handler 共享)
public actor VoiceCenter {
    public private(set) var isEnabled: Bool = true
    public func setEnabled(_ on: Bool) { isEnabled = on }
}

8.1.2 与原 msext 的差异(不要照抄的部分)

维度 msext 现状(不要照抄) 新外壳决策
录音 UI VoiceRecorderBaseVC 原生录音控件 + cancel/redo 按钮 无原生 UI;H5 自己画按钮,原生只录 + 转 + 传
上传 gameapi.0791ts.cn/api/UpLoad/PostFile 直传后台 七牛 SDKADR-006 已选型)
状态共享 static NSString *backgroundType 全局变量 @MainActor AudioPlayer 内部状态
总开关 static BOOL voicePlaying actor VoiceCenter 隔离
路径拼接 各处 NSURL fileURLWithPath: 散落 统一走 SandboxPaths.lobbyAssets

8.2 ShareKit

策略模式分发,新增平台只加一个 conformance:

// 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 的步骤(未来某天需要时):

  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

// 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 根据使用场景选其一
// Source/Bridge/Handlers/DeviceHandlers.swift
import UIKit
import AudioToolbox

@MainActor
public struct DeviceHandlers {

    let bridge: BridgeProtocol
    let pasteboard: UIPasteboard = .general
    // BatteryMonitor / NetworkMonitor / AppLifecycleMonitor 在 register() 内启动
    // 并把回调钩到 bridge.call(...),构成"事件 → callback"的反向链路

    let batteryMonitor: BatteryMonitor
    let networkMonitor: NetworkMonitor
    let appLifecycle:   AppLifecycleMonitor

    public func register() {
        // ─── §3.1 异步 handler ──────────────────────────────
        bridge.register("vibrator",        handler: vibrator)
        bridge.register("repeatvibrator",  handler: repeatVibrator)
        bridge.register("canclevibrator",  handler: cancelVibrator)
        bridge.register("gamepastetext",   handler: gamePasteText)
        bridge.register("gameCopytext",    handler: gameCopyText)
        bridge.register("getphoneInfo",    handler: getPhoneInfo)

        // ─── §3.2 反向 callback 钩子 ────────────────────────
        bindReverseCallbacks()
    }

    // MARK: - 【10】 vibrator — 单次振动
    //
    // 契约 §3.1 10]:入参忽略 → AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
    //                  responseCallback: "vibrator"(注意:vibrator 系列的 callback 是
    //                                   handler 名字本身,不是 "Response from xxx"
    //                                   与 audio 系列约定不同;参考 msext Bridge.m 约定)
    //
    // 当前实现见 Source/Bridge/Handlers/VibratorHandler.swiftPhase 1.15 已落地,
    // 本节是把它放进 DeviceHandlers 聚合时的等价骨架)

    private func vibrator(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
        cb?(.string("vibrator"))
    }

    // MARK: - 【11】 repeatvibrator — 重复振动(实际同 vibrator)
    //
    // 契约 §3.1 [11]:入参 int 但被忽略,原 msext RootVC.m:1820 实现就是单次 vibrate
    // 沿用历史,不真的循环

    private func repeatVibrator(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
        cb?(.string("repeatvibrator"))
    }

    // MARK: - 【12】 canclevibrator — 释放系统振动音
    //
    // 契约 §3.1 12]:AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate)
    // 实际意义有限(kSystemSoundID_Vibrate 是系统常量,dispose 也不会真的"取消"),
    // 但 H5 仍会调用,必须 noop 实现 + callback 维持契约

    private func cancelVibrator(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate)
        cb?(.string("canclevibrator"))
    }

    // MARK: - 【13】 gamepastetext — 读剪贴板
    //
    // 契约 §3.1 13]:
    //   入参 : 忽略
    //   responseCallback: 剪贴板字符串内容(不是 "gamepastetext"),nil 时回空串
    //
    // 注意:iOS 14+ 读剪贴板会弹"已粘贴自 XX"系统提示,无法关闭,是平台行为
    // 不算契约违反;如未来 H5 业务希望静默读,需 H5 端配合改用 UIPasteboard 检测 API

    private func gamePasteText(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        let text = pasteboard.string ?? ""
        cb?(.string(text))
    }

    // MARK: - 【14】 gameCopytext — 写剪贴板
    //
    // 契约 §3.1 14]:
    //   入参 data : string  要写入剪贴板的文本
    //   responseCallback: "gameCopytext"

    private func gameCopyText(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        if let text = data?.asString {
            pasteboard.string = text
        }
        cb?(.string("gameCopytext"))
    }

    // MARK: - 【21】 getphoneInfo(大写 I)— 请求设备信息
    //
    // 契约 §3.1 21]:
    //   入参 : 忽略
    //   responseCallback: "getphoneInfo"(注意大写 I
    //   反向调用: bridge.call("getphoneinfo"(小写 i!), data: 表 A, callback: nil)
    //
    // 大小写 i 不一致是 msext 历史遗留契约,必须严格保持:
    //   - H5 调原生时用大写:bridge.callHandler("getphoneInfo", ...)
    //   - 原生回调 H5 时用小写:bridge.callHandler("getphoneinfo", ...)
    //
    // 表 A 字段名固定(PhoneAdresseMAC / PhoneDeviceBrand / PhoneIMEI / PhoneModel /
    // PhoneProvidersName / PhoneVersion),值统一为 string,详见 Contract §3.2 表 A

    private func getPhoneInfo(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        cb?(.string("getphoneInfo"))
        // 反向 push 设备信息快照(注意 handler 名小写 i)
        let snapshot = DeviceInfo.snapshot
        bridge.call("getphoneinfo",
                    data: .object(snapshot.mapValues { .string($0) }),
                    callback: nil)
    }

    // MARK: - §3.2 反向 callback 钩子(事件驱动)

    private func bindReverseCallbacks() {
        // 【2】 getBattery — UIDeviceBatteryLevelDidChangeNotification
        //   数据:字符串 "%.2f" 形式的小数电量(0~1
        UIDevice.current.isBatteryMonitoringEnabled = true
        batteryMonitor.onChange = { [weak bridge] level in
            let str = String(format: "%.2f", level)
            bridge?.call("getBattery", data: .string(str), callback: nil)
        }
        batteryMonitor.start()

        // 【3】 getnetwork — NWPath 变化
        //   数据:字符串 "1"=无网 / "2"=WiFi / "3"=蜂窝
        networkMonitor.stateDidChange = { [weak bridge] state in
            bridge?.call("getnetwork", data: .string(state.rawValue), callback: nil)
        }
        networkMonitor.start()

        // 【4】 appservice — App 前后台切换
        //   数据:字符串 "1"=进入后台 / "2"=回到前台(命名错位,沿用历史)
        appLifecycle.onBackground = { [weak bridge] in
            bridge?.call("appservice", data: .string("1"), callback: nil)
        }
        appLifecycle.onForeground = { [weak bridge] in
            bridge?.call("appservice", data: .string("2"), 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 NWPathMonitorADR-006,无 AF 依赖)
前后台 UIApplicationDidEnterBackgroundNotification + UIApplicationWillEnterForegroundNotification 各自挂大厅 VC AppLifecycleMonitor 集中处理,多 VC 复用
getphoneInfo 大小写 大写 I 入 / 小写 i 出 严格保持(契约硬约束,msext Bridge.m 沿用)

8.5 微信 / QQ 多发起方派发(关键设计)

问题背景

微信 SDK 是全局单例(WXApi),只接受一个全局 delegate。所有授权回包 (SendAuthResp) 和分享回包 (SendMessageToWXResp) 都通过这个唯一 delegate 派发。

新架构里大厅和子游戏的 H5 都会调用 accreditloginfriendsSharetypeUrlToptitleDescript:

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 调 accreditloginsharelogin 必须回大厅 H5,不能跨派到子游戏
  • 大厅 H5 调 friendsShare...sharesuccess 必须回大厅 H5
  • 子游戏 H5 调 accreditloginsharelogin 必须回子游戏 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,代码大厅 / 子游戏完全一致。

8.6 LocationKit

覆盖契约 §F §3.1 20startlocation + §3.2 6getlocationinfo 反向 callback 共 2 项接口。

8.6.1 Module 骨架

依赖高德 AMapLocationKit.xcframeworkADR-006 走 Vendor 手动接入),用 Swift wrapper 隔离 Objective-C SDK,避免业务层接触 AMap 类型。

// 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 实现骨架

// 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.xcframeworkADR-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 不一致) 严格保持

9. 并发模型

9.1 Actor 隔离

  • BridgeBus@MainActor(WKWebView 必须主线程)
  • HTTPClient / SGGateway / ConfigService / ResourceUnzipper / AudioRecorderactor(各自串行)
  • 数据模型 — 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.onerrorwindow.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 用 WKUserScriptatDocumentStart 注入,确保在 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 工具链:

  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 / .aVendor/
  • 渠道配置脚本可与现 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