Files
youle_app_ios_v2/docs/H5-Native-Implementation-Design.md
T

105 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 优先,微信/QQ/声网/高德等无 SPM 的用 CocoaPods
架构 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
│   ├── Logger/                   # 统一日志
│   └── Foundation+/              # 通用扩展
├── Vendor/                       # 闭源 SDK(.framework / .a)
│   ├── WechatSDK.framework       # 启用
│   ├── AMapLocationKit.framework # 启用
│   ├── JAnalytics.framework      # 启用
│   └── libopencore-amr*.a        # 启用
│   # 暂不集成(对应代码模块用 Noop 占位):
│   # - 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 Analytics(协议) → SentryCrashReporter / JAnalytics 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.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
    │       • JAnalytics              ~10 ms
    │       • AMap (privacy + key)    ~15 ms
    │       • crashReporter.start()   ~20 ms(Sentry 默认启用)
    │       (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
            // XianliaoSDK 暂不集成(§14.2);Agora 同样,懒加载也无需启动注册
            group.addTask { AnalyticsKit.start() }
            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 下载层

// ResourceKit/ConfigService.swift
public actor ConfigService {
    private let client: HTTPClient
    private let unzipper: ResourceUnzipper

    public func syncIfNeeded() async throws {
        let config = try await fetchRemoteConfig()
        let local = try LocalVersion.read()
        if local.gameVersion < config.gameVersion {
            try await downloadAndUnzip(config.gameZipURL)
        }
        // 同样比较 agent / channel / market / agentlist / gamelist
    }
}

7. 资源 & 渠道注入

7.0 仓库根 Resources/ 目录现状(2026-06-21

新外壳运行所需的项目方资源统一存放在仓库根 Resources/在 Xcode 工程内的 ylgamehall/ 源码目录里)。这与 §2.2 的 SPM 包结构里 Resources/ 是同一份物理目录,后续打包脚本通过 Build Phase 引用。

当前实际内容(可直接展开 Phase 1 开发):

Resources/
├── gamehall.zip              ← 大厅 H5 资源包 (≈ 11 MB, 2023-12 旧版)
│                                启动时 ResourceUnzipper 解压到
│                                Library/Caches/{gamedir}/
│                                ⚠️ 上线前必须由 H5 团队提供最新版替换
├── Images.xcassets/          ← 原生 Asset Catalog (AppIcon /
│                                LaunchImage / 微信 / QQ / 抖音
│                                分享平台图标)
└── Res/                      ← 散落原生资源 (未走 Asset Catalog 的旧文件):
    ├── sharelogo.png            ← 分享缩略图 (友圈/微信链接分享用)
    ├── shake_sound_male.mp3     ← 摇一摇音效 (SwitchShake 开关控制)
    ├── BackBT.png               ← msext 兼容图,新外壳是否引用待 Phase 1 验证
    ├── Sistem_back.png          ← 同上
    ├── Icon180.png              ← 同上
    └── Default-568h@2x~iphone.png ← 同上

Phase 1 含义:gamehall.zip 实物已就位,虽是旧版但足以跑通"渠道注入 → 解压 → 加载 H5 → 桥消息"全链路,不必等 H5 团队最新版才开始。版本差异不影响契约边界(handler 名 / 参数 / 数据结构)。

后续 11 个渠道注入目录(qiniudomain / gameid / channel / gamedir / gamestart / gameconfig / market / agent / appversion / other / appleconfig)将由 Scripts/inject_channel.sh 在打包前于 Resources/ChannelInjection/ 下动态生成,运行时通过 BundleConfig.readInjected(_:) 扫描 Bundle 子目录读取(§7.2)。

7.1 SandboxPaths 集中管理

// 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
public final class BundleConfig: @unchecked Sendable {
    public static let shared = BundleConfig()

    public private(set) var qiniuDomain  = ""
    public private(set) var gameId       = ""
    public private(set) var channel      = ""
    public private(set) var market       = ""
    public private(set) var agent        = ""
    public private(set) var appVersion   = ""
    public private(set) var gameDir      = ""
    public private(set) var gameStart    = ""
    public private(set) var gameConfig   = ""

    public static func preload() async {
        // 异步包装,实际是同步 IO,但放在后台 queue
        await Task.detached(priority: .userInitiated) {
            BundleConfig.shared.qiniuDomain = readInjected("qiniudomain")
            BundleConfig.shared.gameId     = readInjected("gameid")
            BundleConfig.shared.channel    = readInjected("channel")
            // ...
        }.value
    }

    /// 扫描 Bundle 中指定名称的目录,返回其第一个非隐藏子项的名字
    /// (打包脚本约定:每个 key 对应一个 Bundle 目录,目录下唯一子目录名即为配置值)
    private static func readInjected(_ key: String) -> String {
        let dir = Bundle.main.bundleURL.appendingPathComponent(key)
        guard let items = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else {
            return ""
        }
        return items.first { !$0.hasPrefix(".") && $0 != ".DS_Store" } ?? ""
    }
}

七牛 CDN 域名(用于录音上传后的公网 URL 组装)在新项目中统一从 BundleConfig.shared.qiniuDomain 读取,不暴露任何全局变量。所有 AudioKit / RecordUploader 等模块通过依赖注入获取 BundleConfig。

7.3 Zip 解压(异步,非阻塞)

// ResourceKit/ResourceUnzipper.swift
public actor ResourceUnzipper {
    public func ensureReady() async throws {
        let dest = SandboxPaths.caches
            .appendingPathComponent(BundleConfig.shared.gameDir)

        if FileManager.default.fileExists(atPath:
            dest.appendingPathComponent(BundleConfig.shared.gameStart)
                .appendingPathComponent("version.xml").path) {
            return     // 已存在
        }

        let zip = Bundle.main.url(forResource: "gamehall", withExtension: "zip")!
        try await Task.detached(priority: .userInitiated) {
            try FileManager.default.createDirectory(at: dest,
                withIntermediateDirectories: true)
            try Zip.unzipFile(zip, destination: dest, overwrite: true, password: nil)
        }.value
    }
}

依赖 ZIPFoundation (SPM),替代旧 ZipArchive。

7.4 打包脚本(渠道动态切换)

#!/bin/bash
# Scripts/inject_channel.sh
# Usage: ./inject_channel.sh <channel_id> <gamedir> <gameid> <market> ...

set -e
INJECT_ROOT="Resources/ChannelInjection"

inject() {
    local key=$1 value=$2
    rm -rf "$INJECT_ROOT/$key"
    mkdir -p "$INJECT_ROOT/$key/$value"
    touch "$INJECT_ROOT/$key/$value/.gitkeep"
}

inject "channel"      "$1"
inject "gamedir"      "$2"
inject "gameid"       "$3"
inject "market"       "$4"
inject "agent"        "$5"
inject "appversion"   "$6"
inject "qiniudomain"  "$7"
inject "gamestart"    "$8"
inject "gameconfig"   "$9"

echo "Channel injected: channel=$1 gamedir=$2 market=$4"

CI 一键多渠道出包:

for channel in xianliao 360 qq tencent; do
  ./Scripts/inject_channel.sh "$channel" "..." "..."
  xcodebuild archive -scheme Daoqi -archivePath build/$channel.xcarchive
  xcodebuild -exportArchive ...
done

8. 能力模块详细设计

8.1 AudioKit

// AudioKit/AudioPlayer.swift
@MainActor
public final class AudioPlayer {
    private var background: AVAudioPlayer?
    private var button: AVAudioPlayer?
    private var voice: AVAudioPlayer?

    public func playOnce(_ url: URL) { ... }
    public func loopBackground(_ url: URL, type: String) { ... }
    public func stopBackground(type: String) { ... }
    public func playVoice(_ url: URL,
        onStart: @escaping () -> Void,
        onEnd: @escaping () -> Void) { ... }
}

// AudioKit/VoiceCoder.swift
/// 全新 Swift wrapper,直接调用 libopencore-amr 静态库的 C 接口
/// (opencore-amr 是闭源静态库,业界标准 AMR 编解码实现,无 Swift 替代品)
public enum VoiceCoder {
    public static func amrToWav(_ src: URL, dest: URL) throws { ... }
    public static func wavToAmr(_ src: URL, dest: URL) throws { ... }
}

// AudioKit/AudioRecorder.swift
public actor AudioRecorder {
    public func record() async throws -> AudioFile { ... }
    /// 录音 → WAV → AMR → 上传七牛 → 返回 URL + 时长
    public func recordAndUpload() async throws -> (audioUrl: String, time: Int) { ... }
}

8.2 ShareKit

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

// ShareKit/SharePlatform.swift
public protocol SharePlatform: Sendable {
    func shareLink(_ link: ShareLink) async throws -> ShareResult
    func shareImage(_ image: ShareImage) async throws -> ShareResult
}

public enum ShareResult { case success, cancelled, failed(Error) }

public final class ShareCenter {
    private let weChat: SharePlatform = WeChatShare()
    private let xianliao: SharePlatform = NoopSharePlatform(name: "xianliao")  // 暂未启用

    public func dispatch(_ req: ShareRequest) async throws -> ShareResult {
        let platform: SharePlatform = req.sharetype == 3 ? xianliao : weChat
        switch req.type {
        case 1: return try await platform.shareLink(req.asLink())
        case 2: return try await platform.shareImage(req.asScreenshot())
        default: return try await platform.shareImage(try await req.asRemoteImage())
        }
    }
}

// ShareKit/NoopSharePlatform.swift
/// 暂未集成的分享平台占位实现:返回 success 让 H5 业务继续(契约要求 sharesuccess 回包)
/// 启用时,把 ShareCenter.xianliao 的初始化换成 XianliaoShare() 即可
public struct NoopSharePlatform: SharePlatform {
    let name: String
    public func shareLink(_ link: ShareLink) async throws -> ShareResult {
        Log.info("Share to \(name) is not enabled, returning success stub")
        return .success
    }
    public func shareImage(_ image: ShareImage) async throws -> ShareResult {
        Log.info("Share to \(name) is not enabled, returning success stub")
        return .success
    }
}

说明:闲聊 SDK 暂不集成,但 H5 调 friendsSharetypeUrlToptitleDescript({sharetype:"3", ...}) 时仍然会收到 sharesuccess({success:"2", type:...}),业务流程不中断。启用闲聊 SDK 后,NoopSharePlatform(name:"xianliao") 替换为 XianliaoShare(),H5 端无感切换。

8.3 VideoRoomKit

抽象 protocol,当前默认注入 NoopVideoRoom(Agora 暂不集成),业务启用时换成 AgoraVideoRoom:

// VideoRoomKit/VideoRoom.swift
public protocol VideoRoom: AnyObject {
    func join(roomId: String, playerId: String, localView: UIView) async throws
    func setupRemote(uid: UInt, view: UIView)
    func leave()
    var didReceiveRemoteVideo: ((UInt) -> Void)? { get set }
}

// VideoRoomKit/NoopVideoRoom.swift
/// 暂未集成 Agora 时使用:所有方法立即返回,不做任何动作
/// 上层 SubGameHandlers 已经把 createRoom/exitRoom/getVideoinfo 改成 stub responseCallback
/// 此 Noop 实现仅用于满足依赖注入,使 SubGameHandlers 不需要 nil-check
public final class NoopVideoRoom: VideoRoom {
    public var didReceiveRemoteVideo: ((UInt) -> Void)?
    public init() {}
    public func join(roomId: String, playerId: String, localView: UIView) async throws {
        Log.info("VideoRoom.join called (NoopVideoRoom, Agora not integrated)")
    }
    public func setupRemote(uid: UInt, view: UIView) {}
    public func leave() {}
}

// VideoRoomKit/AgoraVideoRoom.swift
/// 真实现:Agora SDK 接入后启用。
/// 当前不在工程的 SPM 依赖里,业务启用时再:
///   ① 在 Package.swift 加 .package(url: "...AgoraRtcEngine_iOS"...)
///   ② 用此类替换 NoopVideoRoom 注入 SubGameHandlers
///   ③ 文件保留在 VideoRoomKit/ 模块中作为蓝图(以下为业务启用时的实现轮廓,而非当前已编译代码)
#if AGORA_ENABLED   // 编译开关,当前默认 OFF
import AgoraRtcKit
final class AgoraVideoRoom: NSObject, VideoRoom, AgoraRtcEngineDelegate {
    private let kit: AgoraRtcEngineKit
    var didReceiveRemoteVideo: ((UInt) -> Void)?

    init() {
        self.kit = AgoraRtcEngineKit.sharedEngine(withAppId: AppIDs.agora, delegate: nil)
        super.init()
        self.kit.delegate = self
    }

    func join(roomId: String, playerId: String, localView: UIView) async throws {
        let channel = md5("\(BundleConfig.shared.agent)\(BundleConfig.shared.gameId)\(roomId)")
        let canvas = AgoraRtcVideoCanvas()
        canvas.uid = UInt(playerId) ?? 0
        canvas.view = localView
        kit.setupLocalVideo(canvas)
        try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
            kit.joinChannel(byKey: nil, channelName: channel, info: nil,
                            uid: canvas.uid) { _, _, _ in cont.resume() }
        }
    }

    func setupRemote(uid: UInt, view: UIView) { /* setupRemoteVideo */ }
    func leave() { kit.leaveChannel(nil) }

    func rtcEngine(_ engine: AgoraRtcEngineKit,
                   firstRemoteVideoDecodedOfUid uid: UInt, size: CGSize, elapsed: Int) {
        didReceiveRemoteVideo?(uid)
    }
}
#endif

业务启用 Agora 的步骤(未来某天需要时):

  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.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,代码大厅 / 子游戏完全一致。


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 优先 + CocoaPods 兜底 + Vendor 手动

新项目采用三层混合策略,按优先级从高到低:

  1. 首选 SPM —— Apple 官方,Xcode 原生集成,无第三方工具依赖,无衍生工程文件污染,新人 clone 即用。适用于:Sentry、ZIPFoundation、Agora 4.x、七牛 v8+ 等已发布官方 Swift Package 的库
  2. CocoaPods 兜底 —— 用于"有 podspec 但暂无 SPM 包"的 SDK,主要是高德定位、部分 Agora 旧版兼容场景。Podfile 仅写必须的 Pod,不引入大规模 transitive 依赖
  3. Vendor 手动 —— Vendor/ 目录直接放闭源 .framework / .a,xcconfig 配置 link flag。适用于:微信 OpenSDK、QQ OpenSDK、闲聊 SDK、JAnalytics、opencore-amr 等官方不提供 SPM / Pod 的私有 framework

为什么不选纯 SPM

微信 / QQ / 闲聊 / JAnalytics 等闭源 SDK 官方至今(2026)未提供 Swift Package,强行纯 SPM 化需要自己包一层私有 Package,维护成本反而上升。

为什么不选纯 CocoaPods

Sentry / ZIPFoundation 等纯 Swift 包走 Pods 需绕一道 podspec,失去 SPM 的"Xcode 原生 resolve、增量缓存、零 Ruby 依赖"优势;且 CI 上每次 pod install 都拉远端,慢且不稳定。

决策方法

新增第三方依赖时,按 SPM → Pods → Vendor 顺序尝试,前一项无法满足才退到下一项。任何团队成员改变接入方式(如把某 SDK 从 SPM 迁到 Pods)需要在 PR 描述里写明降级理由。

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 CocoaPods(官方暂无 SPM) 2.9+ 启用,启动注册
Qiniu SDK SPM(官方 v8+ 支持 Swift Package) latest 启用,录音上传时初始化
opencore-amr 静态 .a(直接接入 — H5 端通过此库收发的 AMR 音频是契约边界) 2014 版本 启用
ZIPFoundation SPM latest 启用
Sentry-Cocoa SPM(默认 ON,编译开关 SENTRY_ENABLED 提供逃生口) latest 启用,启动并发注册
JAnalytics Vendor 现网版本 启用,启动注册
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/ / msext/Frameworks/ 拷贝
  • 渠道配置脚本可与现 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