# 进贤聚友棋牌 iOS 新外壳开发计划 > 配套文档: > - `docs/H5-Native-Implementation-Design.md` — 架构蓝图(**WHAT to build / HOW to design**) > - `docs/H5-Native-Contract.md` — H5↔原生契约(**外部可观察行为,验收以此为准**) > > 本文档:执行图(**WHEN / IN WHAT ORDER / ACCEPTANCE CRITERIA**),随实施持续更新。 > > 起点:M0 部分完成(项目基础配置就绪);终点:通过契约 §10 全部 26 项验收清单后灰度上线。 --- ## 1. 文档三角关系 ``` Contract Design Plan(本文档) ↓ ↓ ↓ 契约边界 架构蓝图 执行路线 "必须长这样" "推荐怎么做" "按这个顺序做" 不可越界 可自由重构 随进度更新 ``` - **Contract 与 Design 是稳定文档**,除非契约或架构本身要变更,否则不动 - **Plan 是活文档**,每完成一个 Phase 就在 §8 进度追踪里勾掉、把后续 Phase 调整为新现实 - **冲突时优先级**:Contract > Design > Plan(不能为了赶进度违背契约) --- ## 2. 起点:当前项目状态快照(2026-06-21) ### 2.1 已完成 - [x] Xcode 工程骨架(File System Synchronized Group,objectVersion 77,Xcode 26.5) - [x] iOS Deployment Target 15.6 - [x] Swift 6.0 + `SWIFT_APPROACHABLE_CONCURRENCY=YES` + `SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor` - [x] iPhone + iPad 仅横屏,`UIRequiresFullScreen=true` - [x] 删除默认 Storyboard,改用 `SceneDelegate` 代码启动 → `RootViewController`(M0 占位) - [x] Info.plist 契约前置项:`UIStatusBarHidden=NO` / `UIViewControllerBasedStatusBarAppearance=YES` - [x] `AppDelegate` 设置 `applicationSupportsShakeToEdit=true` - [x] `.gitignore`(标准 iOS/Swift 模板,含签名 / 敏感配置 / xcuserdata) - [x] CLAUDE.md 加入"及时提交"规则 - [x] BuildProject 验证通过 ### 2.2 已就位的原始素材 项目维护者的私人素材池 `docs/res/` 已包含以下可按需取用的素材(详见 CLAUDE.md「docs/res/ 与项目资源的关系」与 Design §7.0): - `docs/res/gamehall.zip`(2023-12 旧版,11.5 MB;用时拷贝到 `ylgamehall/Resources/`) - `docs/res/Images.xcassets/`(AppIcon / LaunchImage / 分享平台图标;用时迁移 / 拷贝条目到 `ylgamehall/Assets.xcassets/`) - `docs/res/Res/`(`sharelogo.png` / `shake_sound_male.mp3` 等散落原生资源;用时拷贝到 `ylgamehall/Resources/Sounds/` 等子目录) > `docs/res/` 不进 Bundle 也不被工程引用。Phase 1 / 3 / 4 等实施时按需从中拷贝到 `ylgamehall/Resources/` 或 `ylgamehall/Assets.xcassets/`。 → Phase 1 不再阻塞于 H5 团队(gamehall.zip 旧版可用于打通管道),可立即展开。 ### 2.3 待项目方协调的外部资源(阻塞项) | 资源 | 用途 | 阻塞起始 Phase | 协调对象 | |------|------|--------------|---------| | `gamehall.zip` **最新版**(上线前替换) | 真实 H5 业务代码 | Phase 10 灰度前 | H5/前端团队 | | 11 个渠道注入目录的目录名值 | 渠道 / 游戏 ID / 七牛域名 / market 等 | Phase 1 | 项目方/运营 | | 微信 OpenSDK `.framework` | 登录 / 分享 | Phase 4 | 项目方(可从 msext/Vendor 拷贝) | | 微信 AppID(`wx586a9b321e56efb7`)+ Universal Links 配置 | 微信回调 | Phase 4 | 项目方 | | QQ OpenSDK `.framework` + AppID | QQ 分享 | Phase 4 | 项目方 | | 后台 `/wechat/login` 中转接口 | secret 不入 IPA 的前提 | Phase 4 | 后台团队 | | 高德地图 `APIKey` + SDK XCFramework | 定位 | Phase 5 | 已有 key `b0d4a8e3fcbbcc0dd96283b7df6a4494`(但绑死 msext Bundle ID,需用新 Bundle ID 重新申请);SDK 走 Vendor 手动接入(ADR-006),下载页 https://lbs.amap.com/api/ios-location-sdk/download | | 七牛上传 token 颁发接口 | 录音上传 | Phase 3 | 后台团队 | | ~~极光 AppKey~~ | ~~用户统计~~ | **已决策暂不集成(ADR-005),未来启用时再协调** | — | | Sentry DSN | 崩溃监控 | Phase 9 | 项目方(需开通账号) | | Agora AppID(视频房间) | 子游戏视频 | Phase 8(默认 stub,可延后) | 项目方 | | 闲聊 SDK | 闲聊分享 | Phase 9(默认 stub) | 项目方 | | 开发 / 企业签名证书 + 描述文件 | 真机调试 / 分发 | Phase 1 起持续 | 项目方/iOS 账号管理员 | > **协调原则**:当某 Phase 阻塞在外部资源时,**先做不依赖该资源的 Phase**(并行机会见 §3 依赖图)。 ### 2.4 已确定的项目约束 | 项 | 值 | |---|---| | H5 设计分辨率 | **1280 × 720(16:9)** | | WebView 适配策略 | 保持 16:9 比例(设备比例不匹配时上下/左右 letterbox) | | Bundle ID | `com.skyapp.ylgamehall`(与 msext 分离) | | Team ID | NX5W3B4QP3 | | 设备 | iPhone + iPad(不去 iPad) | | 支持方向 | 仅横屏(LandscapeLeft / LandscapeRight) | | Swift 版本 | 6.0 | | 最低 iOS | 15.6 | --- ## 3. 全局依赖图 ``` ┌─────────────────────────────────────────────────────────────┐ │ Phase 0 工程化基线(剩余项:SwiftLint / 测试 target / CI) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 1 资源层 + 桥核心 + 第一个 handler │ │ ResourceKit / BundleConfig / BridgeCore / BridgedWebView │ │ 验收:H5 加载 + 一个简单 handler 双向通 │ └─────────────────────────────────────────────────────────────┘ │ ┌─────────────┼─────────────┐ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Phase 2 │ │ Phase 3 │ │ Phase 5 │ │ 简单 │ │ 音频体系 │ │ 定位 │ │ handler │ │ │ │ │ │ + 反向cb │ │ │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ └─────────────┴─────────────┘ │ 并行可行 ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 4 分享 + 登录(前置:项目方提供 SDK / 后台接口) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 6 子游戏容器(SwitchOverGameData / backgameData) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 7 弹层(Overlay + window.settings polyfill) │ │ 完成后契约 §10 A/B/C/E 节可全跑通 │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 8 视频房间(默认 NoopVideoRoom;Agora 启用是开关) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 9 SDK 真实化(Sentry / 极光)+ Stub 替换为真实 SDK │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 10 多渠道打包 + 真机回归 + 灰度切流 │ └─────────────────────────────────────────────────────────────┘ ``` **关键并行机会**:Phase 2 / 3 / 5 互不依赖,可按当下手头的资源到位情况自由调度。 --- ## 4. Phase 总览 | Phase | 目标 | 关键产出 | 验收来源 | 相对成本 | |-------|------|---------|---------|---------| | **P0** | 工程化基线 | SwiftLint、单测 target、CI 流水线 | 本文档 §5.0 | 小 | | **P1** | 最小垂直闭环 | `BundleConfig` / `ResourceUnzipper` / `BridgeCore` / `BridgedWebView` + `vibrator` handler | 契约 §10 中 `vibrator` 项;H5 file:// 加载成功 | 大 | | **P2** | 大厅简单 handler + 反向 callback | `DeviceKit` / `Pasteboard` / 振动 / 摇一摇 / 网络 / 电池 / 前后台 | 契约 §10 B 节 + C 节 | 中 | | **P3** | 音频体系 | `AudioKit`(播放 / 录音 / AMR↔WAV / 七牛上传) | 契约 §10 中 `srcIsloop` / `mediaTypeAudio` / `prepareaudio` 项 | 大 | | **P4** | 分享 + 登录 | `LoginKit`(WeChat OAuth)/ `ShareKit`(WeChat + Noop 闲聊) | 契约 §10 B 节 `accreditlogin` / `friendsShare...` 项 | 大 | | **P5** | 定位 | `LocationKit`(高德 SDK 封装) | 契约 §10 B 节 `startlocation` 项 | 中 | | **P6** | 子游戏容器 | `SubGameViewController` + `Coordinator` + `SwitchOverGameData` / `backgameData` | 契约 §10 中 `SwitchOverGameData` 项 | 中 | | **P7** | 弹层 | `OverlayViewController` + `window.settings` polyfill + 节流 | 契约 §10 中 `OpenurlTitleData` / `settings.*` 项 | 中 | | **P8** | 视频房间 stub | 3 个 stub handler + Native→H5 `getVideoinfo` / `phonestate` / `recordSuccess` callback | 契约 §10 D 节(NoopVideoRoom 路径) | 小 | | **P9** | SDK 真实化 + 监控 | Sentry / 七牛 SPM 接入;极光 / 闲聊 / Bugly 决策落地为 Noop / 不集成 | 契约 §10 全 26 项 | 中 | | **P10** | 多渠道 + 灰度 | 渠道注入脚本 / CI 多渠道出包 / 真机回归 / 灰度切流 | 真机验收 + 灰度无回退 | 大 | **相对成本说明**: - 小:1 个工作日内可独立完成 - 中:2–4 个工作日,含联调 - 大:5–10 个工作日,跨多个模块或需对外联调 --- ## 5. 各 Phase 详细计划 ### Phase 0: 工程化基线(剩余项) #### 目标 建立项目级别的代码质量与回归基线,使后续 Phase 每个 commit 都可被自动验证。 #### 前置 无。当前 M0 基础配置已完成。 #### 任务清单 - [ ] **0.1** 加入 SwiftLint(SPM 插件方式,避免全局安装依赖) - `Package.swift` 加 `SwiftLintPlugin`;规则文件 `.swiftlint.yml` 按 Design §13.4 配(行长 120、文件 ≤400、函数 ≤40) - 验收:`xcodebuild` 时 lint 自动跑,违规 warning 出现 - [ ] **0.2** 新建 `ylgamehallTests` 单测 target(Testing framework,不引 XCTest 旧 API) - 第一个测试 `BootstrapTests.testAppDelegateRespondsToShake`,确认 `applicationSupportsShakeToEdit=true` 已生效 - 验收:`xcodebuild test` 通过 - [ ] **0.3** 新建 `ylgamehallContractTests` 契约测试 target(与 unit test 分离,跑得久也无所谓) - 暂只放空骨架,Phase 1 起逐项填入 - [ ] **0.4** 新建 `ylgamehallUITests` UI 测试 target(XCUI) - 暂只放空骨架,Phase 10 真机回归用 - [ ] **0.5** 加 CI 配置(GitHub Actions 或本地 Jenkinsfile,按项目方实际 CI 平台) - 跑:`xcodebuild test -scheme ylgamehall` + lint - 验收:push 后 CI 绿 - [ ] **0.6** 准备 SPM 包目录结构(**先建空 `Package.swift` 但暂不切 target**) - 决策:先单 target 内按目录组织,等 P3 之后代码量 > 5K 行再切 SPM - 这样早期开发不被 SPM 切分成本拖慢 #### 验收 - `xcodebuild test` 通过;CI 绿;SwiftLint 报告 0 违规 #### 风险 - 单测 / UI 测 target 的 signing 需配置(自动签 + 不同 Bundle ID 后缀) --- ### Phase 1: 最小垂直闭环(资源 + 桥 + 远程配置 + 第一个 handler) #### 目标 打通**从启动 → 渠道注入读取 → 远程配置拉取 → 版本对比 → zip 升级(若需要)→ 加载 H5 → JS 与原生互调**的全链路,用最简单的 handler `vibrator` 作为验证标的。 > **Phase 1 范围扩展(ADR-008)**:原 Plan 漏掉了"远程配置 + 版本对比 + zip 升级"这一关键环节——若不做,上线后用户永远看不到 H5 团队的最新版本,永远停留在 IPA 内打包那一刻的 H5 旧版。2026-06-22 调研 msext `NewRootVC.m` 发现完整流程后插入新子项 1.10-1.13,原 1.10-1.13 后移为 1.14-1.17。 #### 前置 - ✅ Phase 0 基线 - ✅ `docs/res/gamehall.zip`(2023-12 旧版,足以验收 Phase 1;实施时拷贝到 `ylgamehall/Resources/gamehall.zip`) - ⏳ 项目方提供 11 个渠道注入目录名值(缺则用临时 demo 值,含可访问的远端 `gameconfig`.txt) - 备选:若 zip 加载有问题,临时用一个最小 H5(`` 含一个 button 调 `bridge.callHandler('vibrator')`)做工程内测排查 #### 任务清单 ##### 1.A 资源层 - [x] **1.1** 创建 `ylgamehall/Resources/ChannelConfig.plist` 含 11 个渠道键值对(沿用 msext 现网 demo 作为母包默认值;ADR-007 决策) - [x] **1.2** 实现 `ylgamehall/Source/Resource/BundleConfig.swift` - `init(bundle: Bundle = .main)`:读 `bundle.url(forResource: "ChannelConfig", withExtension: "plist")`,用 `PropertyListSerialization` 反序列化为 `[String: String]` - 公开 11 个只读属性:`qiniuDomain` / `gameId` / `channel` / `gameDir` / `gameStart` / `gameConfig` / `market` / `agent` / `appVersion` / `other` / `appleConfig` - 单测 fixture:建立 mock bundle 含 fixture plist,验证 11 个 key 全部能读出且缺失 key 返回空串 - [x] **1.3** 实现 `ylgamehall/Source/Resource/SandboxPaths.swift` - 常量:`caches` / `documents` / `bundle` - `lobbyIndex() -> URL` 拼出 `{Caches}/{gamedir}/{gamestart}/index.html` - [x] **1.4** 加 ZIPFoundation SPM 依赖 - [x] **1.5** 实现 `ylgamehall/Source/Resource/ResourceUnzipper.swift`(actor) - 从 `docs/res/gamehall.zip` 拷贝一份到 `ylgamehall/Resources/gamehall.zip` - `ensureReady() async throws`:检测 `version.xml` 不存在则解压 Bundle 内 `gamehall.zip` 到 caches - 单测:用 fixture zip 验证解压幂等性 ##### 1.B 桥核心 - [x] **1.6** 实现 `Source/Bridge/BridgeProtocol.swift` - `BridgeProtocol` / `BridgeData` / `BridgeHandler` / `BridgeCallback` 类型 - [x] **1.7** 实现 `Source/Bridge/BridgeBus.swift`(@MainActor) - `register(_:handler:)` / `call(_:data:callback:)` / `didReceive(_:)` - 单测:mock WKWebView,验证 handler 注册 / 分发 / callback 配对 - [x] **1.8** 引入 WVJB JS 端协议源码 - 从 [marcuswestin/WebViewJavascriptBridge](https://github.com/marcuswestin/WebViewJavascriptBridge) 取 `WebViewJavascriptBridge.js.txt`,作为 `Resources/JS/WebViewJavascriptBridge.js` - 注入方式:`WKUserScript`,`atDocumentStart` ##### 1.C WebView 容器(视图层 + 一个 handler) - [x] **1.9** 实现 `Source/WebView/BridgedWebView.swift` - WKWebView 配置照 Contract §4.1(`javaScriptCanOpenWindowsAutomatically=NO`、`minimumFontSize=10`、`bounces=NO`、`scrollEnabled=NO` 等) - `WKProcessPool` 单例 `SharedProcessPool.shared` ##### 1.D 远程配置 + 版本对比 + zip 升级(**Phase 1 关键缺口,ADR-008**) - [ ] **1.10** 实现 `ylgamehall/Source/Network/RemoteConfigClient.swift`(actor) - URL 构造:`http://` + `BundleConfig.shared.gameConfig.replacingOccurrences("-", "/")` + `.txt`(**注意**:不带 `SERVERNew` 前缀,原 `daoqi/NewRootVC.m:250` 就是这样构造) - 拉远端 `.txt` 内容(实际是 JSON)→ `URLSession.async data(from:)`,10 s 超时 - 指数退避重试(msext 用 4s 等间隔暴力 timer,新外壳用 1s / 2s / 4s 三次) - 反 JSON → 强类型 `RemoteConfig` Codable 模型(嵌套:`agentlist[].channellist[].marketlist[]` + `agentlist[].gamelist[].channellist[].marketlist[]`) - 单测:mock URLSession + fixture JSON 验证嵌套解析正确 - [ ] **1.11** 实现 `ylgamehall/Source/Network/VersionResolver.swift`(纯函数 + 单测) - 输入:`RemoteConfig + (agentId, channelId, marketId, gameId)` 四组 ID - 输出:`ResolvedVersion { appVersion, appDownload, gameVersion, gameZip }` - 算法:4 级覆盖合并 —— 顶层 → 当前 agent → 当前 channel → 当前 market(最深胜出);agent 子树和 game 子树分别合并,game 子树最终覆盖 agent 子树 - 参考 `daoqi/NewRootVC.m:1372-1492` 的散落 if/else,新外壳实现成纯 reduce - 单测:覆盖所有边界(顶层有/无、深层缺失、agent vs game 优先级 4 组场景至少 12 用例) - [ ] **1.12** 实现 `ylgamehall/Source/Resource/LocalVersionReader.swift`(nonisolated namespace) - `localAppVersion: Int`:从 `BundleConfig.shared.appVersion` 读,转 Int - `localGameVersion: Int`:读 `Library/Caches/{gamedir}/{gamestart}/version.xml` 用 `XMLParser` 解析 `/game/version@value` 转 Int;缺失返回 0 - 单测:fixture xml 验证解析;缺失 / 损坏 xml 返回 0 - [ ] **1.13** 实现 `ylgamehall/Source/Resource/LobbyZipUpgrader.swift`(actor) - `upgradeIfNeeded(remote: ResolvedVersion) async throws -> UpgradeOutcome` - 比较 `remote.gameVersion > LocalVersionReader.localGameVersion` → 触发下载,否则直接返回 `.noop` - 下载链路:`URLSession.download(from:)` → 临时文件 `tmp/lobbyzip-{uuid}.zip` → ZIPFoundation 解压到 `tmp/lobbyzip-{uuid}/` → **原子 rename** 到 `lobbyRoot`(解压前清空旧 lobbyRoot;不学 msext "目录名 +1" hack) - 失败处理:超时 / 网络断 / hash 校验失败 / 解压失败 → throw 类型化错误;调用方决定是否回退用旧 H5 - 单测:mock URLSession + fixture zip 验证升级路径与 noop 路径 ##### 1.E 集成 + 烟雾测试 - [ ] **1.14** 实现 `Source/WebView/WebContainerViewController.swift`(基类) - 持有 `BridgedWebView` + `BridgeBus` - 16:9 比例布局:屏幕比 < 16:9 用宽度撑满上下黑边;屏幕比 > 16:9 用高度撑满左右黑边 - `viewDidLoad` 内串:`ResourceUnzipper.ensureReady() → RemoteConfigClient.fetch → VersionResolver.resolve → LobbyZipUpgrader.upgradeIfNeeded → loadFileURL(lobbyIndex)` - 无升级路径走快路(< 200 ms),有升级路径显示 progress UI - 实现 `webViewWebContentProcessDidTerminate:` 退避 reload(Design §3.6) - [ ] **1.15** 实现 `Source/Bridge/Handlers/VibratorHandler.swift` - `register(_ bridge)` 注册 `vibrator` handler → `AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)` + responseCallback `"vibrator"` - [ ] **1.16** `SceneDelegate` → `WebContainerViewController` 作 rootViewController - 替换 M0 占位 `RootViewController` - [ ] **1.17** 把渠道目录里临时填入的 demo 值 + 一个 demo H5 跑通 / 真机验证完整 1.10-1.16 链路 #### 验收 - 真机启动后能看到 H5 demo 页(首次会先经历"远程配置拉取"流程) - H5 按钮调 `bridge.callHandler('vibrator')` 后真机震动 - H5 收到 `responseCallback("vibrator")` - 模拟 `game_version` 远端 +1,重启 App 后能看到 zip 重新下载 + 解压 + 加载新版本 - BuildProject 通过;契约测试 `VibratorContractTest` + 单测 `VersionResolverTests`、`RemoteConfigClientTests` 通过 #### 风险 - WVJB JS 端协议握手时序对加载顺序敏感,必须用 `WKUserScript` 在 documentStart 注入 - iPad letterbox 实现细节:用 `aspectRatio` constraint vs 手算 frame;推荐用 Auto Layout `aspectRatio=16/9` + centerX/centerY - 远端 `.txt` 实际是 JSON 但被命名为 .txt(msext 历史包袱),解析时不要被 mime-type 误导 - 远端配置 4 级覆盖逻辑历史代码 `daoqi/NewRootVC.m:1372-1492` 有几处可疑分支(`if(gameconfig.game_download && gameconfig.game_download)` 重复条件 / `if(result)` 包住整段 unzip 在首次时不进入),新外壳重写时**不要照抄分支结构**,按 ADR-008 描述的 reduce 算法重新表达 --- ### Phase 2: 大厅简单 handler + 反向 callback #### 目标 把不依赖外部 SDK 的 11 个 handler 全部实现,使大厅 H5 在不接微信/七牛/高德的前提下能完整跑大部分业务。 #### 前置 - Phase 1 完成 #### 任务清单 ##### 2.A H5→Native handler(无外部依赖) - [ ] **2.1** `gameCopytext` / `gamepastetext`(剪贴板) - [ ] **2.2** `vibrator` / `repeatvibrator` / `canclevibrator`(已有,扩展) - [ ] **2.3** `startshake` / `stopshake` / `SwitchShake`(摇一摇开关 + 音效开关) - [ ] **2.4** `voicePlaying`(语音播放总开关) - [ ] **2.5** `getphoneInfo` → 触发反向 callback `getphoneinfo`(注意小写 i) - [ ] **2.6** `browser` → 系统 Safari 打开 URL - [ ] **2.7** `opensaoma` 空实现注册(契约 §3.1【22】要求) ##### 2.B Native→H5 反向 callback - [ ] **2.8** `Source/Device/DeviceInfo.swift`:6 字段 snapshot - `getphoneInfo` 收到调用后 → `bridge.call("getphoneinfo", data: ...)` 反向 - [ ] **2.9** `Source/Device/BatteryMonitor.swift`:监听 `UIDeviceBatteryLevelDidChangeNotification` - 触发 → `bridge.call("getBattery", data: .string("%.2f"))` - [ ] **2.10** `Source/Device/NetworkMonitor.swift`:`NWPathMonitor` 包装 - 状态变化 → `bridge.call("getnetwork", data: .string("1"/"2"/"3"))` - [ ] **2.11** `Source/Device/AppLifecycleObserver.swift`:监听 SceneDelegate 前后台通知 - → `bridge.call("appservice", data: .string("1"=后台 / "2"=前台))` - [ ] **2.12** `Source/WebView/ShakeDetector.swift`:在 `WebContainerViewController` 重写 `motionEnded:` - → `bridge.call("shakeEnd", data: nil)`(仅 `canshake=YES` 时触发) ##### 2.C 外部订阅生命周期 - [ ] **2.13** 实现 Design §2.4.2 的 `ExternalSubscriptions` 模式 - `viewWillAppear` resume / `viewWillDisappear` suspend - 防双发:栈深 ≥ 2 时下层不发桥事件 #### 验收 - 契约 §10 B 节中以下项目通过: - `getphoneInfo` 6 字段完整 - `gameCopytext` + `gamepastetext` - `vibrator` / `repeatvibrator` - 契约 §10 C 节全部通过: - 前后台切换 `appservice("1"/"2")` - 电量变化 `getBattery("0.XX")` - 飞行模式切换 `getnetwork("1"/"2"/"3")` - 摇一摇 `shakeEnd` #### 风险 - `motionEnded:` 需要 `become first responder`,在 `viewDidAppear` 内调用 `becomeFirstResponder()` - iOS 14+ `WKWebView.scrollView.bounces` 在某些版本被重置,需 viewWillAppear 内重新设置 - `NWPathMonitor` 必须保持引用,否则会被释放停止监听 --- ### Phase 3: 音频体系 #### 目标 打通本地音频播放 / 远程语音播放 / 录音 → AMR 转码 → 七牛上传的完整链路。 #### 前置 - Phase 1 完成 - ⏳ 项目方提供:opencore-amr 静态库(可从 msext 拷贝)+ 七牛上传 token 颁发接口 #### 任务清单 ##### 3.A 本地音频 - [ ] **3.1** `Source/Audio/AudioPlayer.swift`(@MainActor) - `background` / `button` / `voice` 三类 AVAudioPlayer - `srcIsloop` handler:`isloop=0` 单次 / `isloop=1` 循环背景 / `isloop=-1` 停同名背景 - [ ] **3.2** 真机验证:放一个 `.wav` 进 Resources,H5 调 `srcIsloop({src:"test.wav", isloop:0})` 出声 ##### 3.B AMR 转码 - [ ] **3.3** 从 msext 拷贝 `libopencore-amrnb.a` / `libopencore-amrwb.a`(已 segalign 8 修复版)到 `Vendor/` - [ ] **3.4** 拷贝 `VoiceConverter` 头文件 + 实现 → 改写为 `Source/Audio/VoiceCoder.swift` Swift wrapper - `amrToWav(_:dest:)` / `wavToAmr(_:dest:)` - 单测:fixture amr → 转 wav → 转回 amr,比较前后 hash ##### 3.C 录音 + 上传 - [ ] **3.5** `Source/Audio/AudioRecorder.swift`(actor) - `record() async throws -> AudioFile`:`AVAudioRecorder` 录 WAV - 麦克风权限:Info.plist 加 `NSMicrophoneUsageDescription = "{gamehallname}需要访问您的麦克风录制语音消息"` - [ ] **3.6** 七牛 SPM 依赖(v8+)+ `Source/Network/QiniuUploader.swift` - `upload(_:token:) async throws -> UploadedFile` - 单测:mock token,模拟上传成功 / 失败 - [ ] **3.7** `prepareaudio` handler:拉起录音 → 停止录音 → AMR 转码 → 上传 → 反向 callback `getaudiourl` + `recordSuccess`(仅子游戏触发) ##### 3.D 远程语音回放 - [ ] **3.8** `mediaTypeAudio` handler:下载 AMR → 转 WAV → `AVAudioPlayer` 播放 - 开始播放 → `bridge.call("gameui_play_voice", user)` - 播放结束 → `bridge.call("gameui_stop_voice", user)` - 受 `voicePlaying` 开关控制(=1 才播) #### 验收 - 契约 §10 B 节中: - `srcIsloop` 背景音循环 / 停止 - `mediaTypeAudio` 远端播放 + play/stop callback - `prepareaudio` → 录音 → 上传 → `getaudiourl({audiourl, time})` #### 风险 - 麦克风权限被拒后的 UI 兜底(H5 alert 提示,契约 §3.1【4】) - 七牛 token 时效性:每次录音前后端动态颁发 vs 长 token 缓存 - AVAudioSession 与背景音 / 通话音的混音规则(`.playAndRecord` mode) --- ### Phase 4: 分享 + 登录 #### 目标 微信授权登录 + 微信好友/朋友圈分享 + 闲聊分享(Noop stub)。 #### 前置 - Phase 1 完成 - ⏳ 微信 OpenSDK `.framework` + AppID + Universal Links - ⏳ QQ OpenSDK `.framework` + AppID - ⏳ 后台 `/wechat/login` 接口(或先用客户端直拼 fallback) #### 任务清单 ##### 4.A SDK 接入 - [ ] **4.1** `Vendor/WechatSDK/` 拷贝微信 SDK - Info.plist 加 URL Scheme(`wx586a9b321e56efb7`)+ `LSApplicationQueriesSchemes`(`weixin` / `weixinULAPI` / `weixinURLParamsAPI`) - Entitlement 加 Associated Domains(Universal Links) - [ ] **4.2** `Vendor/QQShare/` 拷贝 QQ SDK - Info.plist 加 QQ URL Scheme + `LSApplicationQueriesSchemes`(`mqq*`) - [ ] **4.3** `Source/SDK/WeChat/WeChatSDK.swift` 启动注册(Design §4.2 `registerFull` + 全部 12 个 `MMAPP_SUPPORT_*` flag) - [ ] **4.4** `Source/SDK/WeChat/WeChatManager.swift`(@MainActor) - 持久 delegate - `authorize() async throws -> WXAuthCode`(state UUID 配对) - `share(_:scene:) async throws`(FIFO 串行) - 详见 Design §8.5 - [ ] **4.5** `SceneDelegate.openURLContexts` 接入 QQ → WXApi 顺序 ##### 4.B 授权登录 - [ ] **4.6** `Source/Login/WeChatAuth.swift`(actor) - `authorize() async throws -> WeChatUser`:内部调 `WeChatManager.authorize()` 拿 code → 转给后台 `/wechat/login`(或客户端 fallback)→ 拿到 7 字段 user - 后台未就绪时的 fallback:客户端直拼 `api.weixin.qq.com/sns/oauth2/access_token` —— 文件头标 `// FIXME: 待后台 /wechat/login 就绪后切换为后台中转` - [ ] **4.7** `accreditlogin` handler:触发 OAuth → 反向 callback `sharelogin`(7 字段,注意 `Province` 大写 P) - **关键契约测试**:`sharelogin` payload 字段名严格 1:1 ##### 4.C 分享 - [ ] **4.8** `Source/Share/SharePlatform.swift` 协议 + `WeChatShare` / `NoopSharePlatform(name:"xianliao")` - [ ] **4.9** `Source/Share/ShareCenter.swift`:策略分发(type=1/2/3 × sharetype=1/2/3,共 9 种组合) - [ ] **4.10** `friendsSharetypeUrlToptitleDescript` handler:参数解析 → ShareCenter.dispatch → 反向 callback `sharesuccess({success:"2", type:<原 sharefriend>})` - [ ] **4.11** 截图分享:`getImageWithFullScreenshot` Swift 等价实现(`UIGraphicsImageRenderer`) - [ ] **4.12** 远程图片分享:URLSession 下载 → 重打包 #### 验收 - 契约 §10 B 节: - `accreditlogin` 收到 7 字段 `sharelogin`(Province 大写) - `friendsSharetypeUrlToptitleDescript` type=1/2/3 各跑一次,收到 `sharesuccess` - 契约 §10 E 节:微信 / QQ 回调命中(QQ 必须先判) #### 风险 - 微信审核:Universal Links 配置错误 → 授权回不来 - AppSecret 客户端泄露 = 严重安全问题;fallback 路径上线前必须切到后台 - 截图分享在 Liquid Glass / 新版 UIKit 下 `drawHierarchy` API 行为变化,需查 DocumentationSearch 验证 --- ### Phase 5: 定位 #### 目标 高德定位 + 逆地理 → 反向 callback `getlocationinfo` 9 字段。 #### 前置 - Phase 1 完成 - ⏳ 高德 SDK XCFramework(Vendor 手动,ADR-006)+ 新 Bundle ID 重新申请 APIKey #### 任务清单 - [ ] **5.1** 从 [高德官方下载页](https://lbs.amap.com/api/ios-location-sdk/download) 拉取最新 `AMapLocationKit.xcframework` + `AMapFoundationKit.xcframework`,放 `Vendor/AMap/`;Target → General → Embed Frameworks 加入;按 Design §14.1 Vendor 接入标准流程操作;`Vendor/AMap/README.md` 记录版本号 / 下载日期 - [ ] **5.2** Info.plist 加 `NSLocationWhenInUseUsageDescription = "{gamehallname}需要访问您的位置以提供本地化服务"` - [ ] **5.3** `Source/SDK/AMap/AMapWrapper.swift`:启动期 `updatePrivacyShow` + `updatePrivacyAgree` + `apiKey` 设置 - [ ] **5.4** `Source/Location/LocationService.swift`(actor) - `requestOnce() async throws -> LocationPayload` - `startContinuous(onUpdate:)` / `stop()` - [ ] **5.5** `startlocation` handler:`data=1` 持续 / 其他一次性 → 反向 callback `getlocationinfo` 9 字段 - **关键契约**:`latitude` / `longitude` 是 **string**(`stringWithFormat:%f` 等价产物) - **关键契约**:`province` 是小写 p(与 `sharelogin.Province` 大写不同) - [ ] **5.6** 失败路径:`getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})` #### 验收 - 契约 §10 B 节 `startlocation` → 9 字段完整 - 拒绝权限后 H5 收到 errorCode 12 #### 风险 - 高德 Bundle ID 绑定:APIKey 是绑死 Bundle ID 的,必须用项目方控制台重新申请(不能复用 msext 的 key) --- ### Phase 6: 子游戏容器 #### 目标 大厅 push 子游戏,子游戏返回大厅,`getWebdata` 链路打通。 #### 前置 - Phase 1 完成(大厅可加载) - Phase 2/3/4/5 不强制,但子游戏页同样需要 19 个共享 handler #### 任务清单 - [ ] **6.1** `Source/Coordinator/AppCoordinator.swift`:path 节流 + 栈深约束(Design §2.4.3) - [ ] **6.2** `Source/Containers/SubGameViewController.swift`:继承 WebContainer,注入 SubGameHandlers - [ ] **6.3** `Source/Bridge/Handlers/SubGameHandlers.swift`:19 共享 + `backgameData` + 3 个视频房间 stub - [ ] **6.4** `SwitchOverGameData` handler(仅大厅注册):解参 → AppCoordinator.showSubGame → 节流 2s - [ ] **6.5** `backgameData` handler(仅子游戏注册):停 audio → 发 `.subGameDidReturn` 通知 → coordinator.popSubGame - [ ] **6.6** 大厅监听 `.subGameDidReturn` → `bridge.call("getWebdata", data)` - [ ] **6.7** 子游戏 zip 下载 / 解压(H5 端通过 SwitchOverGameData 传 `gamedownloadurl`) - `Source/Resource/SubGameDownloader.swift`:URLSession + ZIPFoundation - 缓存策略:`Library/Caches/{Gamedirectory}/` 已存在则跳过 #### 验收 - 契约 §10 B 节 `SwitchOverGameData` → push 子游戏 → 子游戏调 `backgameData` → 回大厅 → 大厅收 `getWebdata` - 栈深永远 ≤ 3(Lobby + SubGame + Overlay) #### 风险 - 子游戏 zip 下载失败兜底:超时 / 网络断 / hash 校验失败 → 弹错误页且 pop 回大厅 - 大厅 + 子游戏并存时双发桥事件(已由 Phase 2 ExternalSubscriptions 解决) --- ### Phase 7: 弹层(Overlay) #### 目标 H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settings.*` 三接口操作,关闭后大厅收 `getWebdata`。 #### 前置 - Phase 1 完成 #### 任务清单 - [ ] **7.1** `Source/Containers/OverlayViewController.swift`:继承 WebContainer,但 WKWebsiteDataStore 用 `.nonPersistent()` 隔离 - [ ] **7.2** `Source/WebView/OverlayBridge.swift` polyfill JS:`window.settings = {backgameData, browser, finishweb}` → `webkit.messageHandlers.*` - [ ] **7.3** `WKScriptMessageHandler` 注册三 handler:`overlayBackgameData` / `overlayBrowser` / `overlayFinishweb` - [ ] **7.4** `OpenurlTitleData` handler(大厅 + 子游戏都注册):解参(注意 `"title "` 末尾空格契约) → AppCoordinator.showOverlay → 3 秒节流 - [ ] **7.5** Overlay backgameData → 发 `.subGameDidReturn` → 上层 H5 收 `getWebdata` - [ ] **7.6** Overlay `finishweb` → coordinator.popOverlay - [ ] **7.7** Overlay `browser` → 系统 Safari 打开 #### 验收 - 契约 §10 B 节 `OpenurlTitleData` 完整链路 + `settings.finishweb()` 关闭 + `settings.backgameData(data)` 回传 #### 风险 - 弹层第三方外链 Cookie 不污染主业务态(`.nonPersistent()` 已隔离) - 3 秒节流时间戳由谁持有(`OpenUrlHandler` 内部 actor 状态) --- ### Phase 8: 视频房间 stub #### 目标 注册 3 个视频房间 handler 的 stub 实现 + 子游戏独有 3 个反向 callback,以满足契约边界 —— 即使业务暂不上视频,H5 也不会报错。 #### 前置 - Phase 6 完成(子游戏容器) #### 任务清单 - [ ] **8.1** `Source/VideoRoom/VideoRoom.swift` 协议 - [ ] **8.2** `Source/VideoRoom/NoopVideoRoom.swift` 实现(log + 不动) - [ ] **8.3** `SubGameHandlers` 注入 NoopVideoRoom,注册: - `createRoom` stub → `cb("createRoom")` - `getVideoinfo` stub → `cb("getVideoinfo")` - `exitRoom` stub → `cb("exitRoom")` - [ ] **8.4** `Source/Telephony/CallCenterMonitor.swift`:CXCallObserver 监听通话状态 - 来电 → `bridge.call("phonestate", "2")` - 挂断 → `bridge.call("phonestate", "0")` - [ ] **8.5** `RecordUploader.onUploaded` 已在 Phase 3,需要在 SubGameHandlers 里**额外**触发 `recordSuccess({fileUrl, fileName, fileKey})`(契约 §3.2 表 [15]) - [ ] **8.6** `AgoraVideoRoom.swift` 留蓝图骨架(`#if AGORA_ENABLED` 包裹,默认 OFF) #### 验收 - 契约 §10 D 节中: - `createRoom` / `exitRoom` H5 调用不报 "bridge not found" - 子游戏接电话 → H5 收 `phonestate("2")` / `phonestate("0")` - 子游戏录音上传 → H5 收 `getaudiourl` **AND** `recordSuccess`(双 callback) #### 风险 - `CXCallObserver` 在模拟器无效,必须真机测 - 视频房间真要启用时再走 §8.6 的 AgoraVideoRoom 实现路径 --- ### Phase 9: SDK 真实化 + 监控接入 #### 目标 首版 SDK 集成定型:接入 Sentry 崩溃监控;极光 / 闲聊 / Agora 维持 Noop 占位(编译开关 OFF);Bugly 不集成。 #### 前置 - Phase 1–8 完成 #### 任务清单 - [ ] **9.1** Sentry-Cocoa SPM 依赖 + `Source/Analytics/SentryCrashReporter.swift` - 编译开关 `SENTRY_ENABLED`(默认 ON)+ `NoopCrashReporter` fallback - DSN 通过 xcconfig 注入,不入 git - [ ] **9.2** `Source/Bridge/Handlers/H5ErrorRelay.swift`:注入 `window.onerror` / `unhandledrejection` polyfill + `reportH5Error` 桥 handler(Design §11.4,新增 handler 不破坏契约) - [ ] **9.3** 极光(JAnalytics):保持 `NoopAnalytics` stub(**ADR-005 决策**) - `Source/Analytics/Tracker.swift` 协议 + `NoopAnalytics` 实现 - `JAnalyticsTracker.swift` 留蓝图骨架(`#if JANALYTICS_ENABLED` 包裹,默认 OFF) - 上线后业务真要做用户激活 / 留存分析时再启用:放 Vendor、切换实现、加 AppKey、Bootstrapper 注册 - [ ] **9.4** 闲聊:保持 NoopSharePlatform(业务方未启用闲聊分享,stub 已满足契约) - [ ] **9.5** Agora:保持 NoopVideoRoom + 编译开关 OFF - [ ] **9.6** Bugly:不集成(Sentry 已覆盖) #### 验收 - 强制崩溃 → Sentry 后台收到 issue - H5 内 `throw new Error()` → Sentry 看到 H5 错误 + `container_role` 标签 - `Tracker` 协议存在且默认绑到 `NoopAnalytics`,业务调用 `Tracker.event(...)` 不崩、不阻塞 #### 风险 - Sentry 账号必须 ≥ 2 人持有访问凭证(避免重蹈 Bugly 覆辙) - 首版无用户统计,业务侧若想看激活 / 留存只能等启用 JAnalytics 或自建埋点 --- ### Phase 10: 多渠道 + 真机回归 + 灰度 #### 目标 完成打包脚本 + CI 多渠道流水线 + 真机覆盖回归 + 内部灰度切流。 #### 前置 - Phase 1–9 完成 #### 任务清单 ##### 10.A 多渠道打包 - [ ] **10.1** `Scripts/inject_channel.sh`:按 Design §7.4 实现 - [ ] **10.2** `Scripts/archive.sh`:xcodebuild archive + exportArchive 流水线 - [ ] **10.3** `Scripts/release.sh`:循环渠道列表,逐个出包到 `dist/` - [ ] **10.4** 渠道环境文件 `channels/*.env` 模板 ##### 10.B CI - [ ] **10.5** CI 加 archive smoke test(至少一个渠道能出 ipa) - [ ] **10.6** 契约测试纳入 CI 必跑 ##### 10.C 真机回归 - [ ] **10.7** 按契约 §10 26 项清单逐项真机过: - A 节 启动(4 项) - B 节 桥接(12 项) - C 节 系统事件(4 项) - D 节 视频房间(4 项,stub 模式下只验 phonestate / recordSuccess / createRoom 不报错) - E 节 回归(3 项 + 截图分享) - [ ] **10.8** 覆盖设备矩阵:iPhone(最新 + 一台老款)/ iPad / iOS 15.6 / iOS 26.x ##### 10.D 灰度 - [ ] **10.9** 与 msext 旧外壳并存(Bundle ID 已分离),内部 50 人手动覆盖装 - [ ] **10.10** 监控 Sentry crash-free rate ≥ 99.5%、用户反馈 - [ ] **10.11** 50 → 100 → 全量切流(按渠道,小渠道先切) #### 验收 - 契约 §10 全 26 项真机通过 - 全量灰度内零回退要求 - CI 多渠道出包稳定 #### 风险 - 真机覆盖不全:单人测试时间有限,必要时项目方协调测试人力 - 灰度期间发现 P0 → 必须能 24h 内热修(设计 Sentry breadcrumb + 远程开关) --- ## 6. 横向工作流(贯穿全程) ### 6.1 SDK 集成节奏 | 集成方式 | SDK | 集成 Phase | |---------|-----|----------| | **SPM 优先** | ZIPFoundation / Sentry / 七牛 v8.9.x(`https://github.com/qiniu/objc-sdk`) | Phase 1 / 9 / 3 | | **Vendor `.xcframework`** | 微信 / QQ / **高德 AMap**(ADR-006);闲聊 / Agora / JAnalytics 均暂不集成(Noop 占位) | Phase 4 / 5 | | **Vendor `.a`** | opencore-amr | Phase 3 | | ~~CocoaPods~~ | **不引入**(ADR-006,纯 SPM + Vendor 两层管理) | — | 每集成一个 SDK 立即提交一个独立 commit(CLAUDE.md "及时提交" 规则)。 ### 6.2 测试节奏 - **Phase 1 起每 Phase 必须有契约测试用例**纳入 `ylgamehallContractTests` - 单元测试覆盖率目标:BridgeCore ≥ 90%、ResourceKit ≥ 85%、其余 Kit ≥ 70%(Design §12.1) - UI 集成测在 Phase 10 完成 26 项验收清单的 XCUI 自动化 ### 6.3 监控接入 - Sentry 默认 ON 但可关(编译开关 `SENTRY_ENABLED`) - DSN / 七牛 token 接口 URL / 微信 AppID 等敏感配置走 xcconfig + `.gitignore` - H5 错误回流通道(`reportH5Error` 桥 handler)在 Phase 9 接入 ### 6.4 文档同步 - 每个 Phase 结束更新本文档 §8 进度追踪 - 桥接 handler 任何变动必须同步更新 Contract(Design §18.6) - Phase 完成时如发现 Contract 描述与实测不符 → 优先修 Contract(事实为准) --- ## 7. 真实风险与缓解(项目独有) | 风险 | 触发条件 | 影响 | 缓解 | |------|---------|------|------| | 已就位的 `gamehall.zip` 是 2023-12 旧版,与现网 H5 有契约漂移 | Phase 1–9 联调 | 联调期发现 handler 名 / 字段被旧版掩盖、真上线时反而暴露 | 上线前阶段(Phase 10 灰度前)强制由 H5 团队提供最新版替换并复跑契约 §10 全部 26 项;联调期发现的契约疑点立即对照现网 msext 与 H5 团队对齐 | | 微信 SDK 审核失败 / 改 Bundle ID 引起 Universal Links 失效 | Phase 4 真机调试 | 微信回调收不到 | Bundle ID 注册微信开放平台 + 配置 Universal Links 流程独立做一次(项目方协调) | | 后台 `/wechat/login` 接口延期 | Phase 4 上线时 | secret 仍驻 IPA | 接受临时 fallback(标 TODO),上线前必须切到后台 | | 高德 APIKey 与 Bundle ID 绑定失败 | Phase 5 真机定位 | 定位全失败 | 项目方控制台重新申请 key(本项目独立 key) | | Swift 6 严格并发产生大量 warning/error | Phase 2 起 | 进度延迟 | 已启用 Approachable Concurrency(渐进模式),逐 Phase 修复,不一开始就 Complete 模式 | | WKWebView 内 `evaluateJavaScript` 在 Liquid Glass 时代 API 变化 | iOS 26+ 真机 | 桥消息派发失败 | DocumentationSearch 优先查最新 API,不假设知识截止前的写法 | | 真机回归人力不足(单人 + AI) | Phase 10 | 漏测 → 上线翻车 | 把 26 项验收清单写成 XCUI 自动化(可重复跑),手测只覆盖体验类 | | Sentry 账号长期无人维护变 Bugly 翻版 | Phase 9 上线后 | 崩溃监控形同虚设 | 接入时同步把账号责任写入团队 onboarding,季度回顾会上检查存活 | | 灰度期发现 P0 但无热修通道 | Phase 10 灰度 | 用户体验受损 | 设计期就要预留远程配置开关(如:`Sentry breadcrumbs` 中查 root cause + 一个 plist 控制的 feature flag)| --- ## 8. 进度追踪 > 每完成一项就把 `[ ]` 改 `[x]`,并在 commit message 里附 Phase X.Y 编号方便 git log 检索。 ### Phase 0 工程化基线 - [x] 0.0 M0 基础配置(横屏 / Swift 6 / 代码启动) - [x] 0.0.1 `.gitignore` + xcuserdata 清理 - [ ] 0.1 SwiftLint - [ ] 0.2 单测 target - [ ] 0.3 契约测试 target - [ ] 0.4 UI 测试 target - [ ] 0.5 CI 流水线 - [ ] 0.6 SPM 包目录决策(暂单 target) ### Phase 1 最小垂直闭环(含远程配置 + zip 升级,ADR-008) - [x] 1.1 ChannelConfig.plist 母包默认值(ADR-007) - [x] 1.2 BundleConfig - [x] 1.3 SandboxPaths - [x] 1.4 ZIPFoundation SPM - [x] 1.5 ResourceUnzipper - [x] 1.6 BridgeProtocol - [x] 1.7 BridgeBus - [x] 1.8 WVJB JS 协议 - [x] 1.9 BridgedWebView - [ ] 1.10 RemoteConfigClient(actor,URLSession async + 重试) - [ ] 1.11 VersionResolver(纯函数 4 级覆盖 + 单测) - [ ] 1.12 LocalVersionReader(version.xml 解析) - [ ] 1.13 LobbyZipUpgrader(actor,原子 rename 升级) - [ ] 1.14 WebContainerViewController(16:9 letterbox + 串接整链路) - [ ] 1.15 VibratorHandler - [ ] 1.16 SceneDelegate → WebContainerViewController - [ ] 1.17 Demo H5 联调 + 真机验证升级路径 ### Phase 2 大厅简单 handler + 反向 callback - [ ] 2.1 剪贴板 - [ ] 2.2 振动扩展 - [ ] 2.3 摇一摇开关 - [ ] 2.4 voicePlaying - [ ] 2.5 getphoneInfo - [ ] 2.6 browser - [ ] 2.7 opensaoma 空注册 - [ ] 2.8 DeviceInfo - [ ] 2.9 BatteryMonitor - [ ] 2.10 NetworkMonitor - [ ] 2.11 AppLifecycleObserver - [ ] 2.12 ShakeDetector - [ ] 2.13 ExternalSubscriptions ### Phase 3 音频体系 - [ ] 3.1 AudioPlayer - [ ] 3.2 本地音频真机验证 - [ ] 3.3 opencore-amr Vendor 接入 - [ ] 3.4 VoiceCoder Swift wrapper - [ ] 3.5 AudioRecorder + 麦克风权限 - [ ] 3.6 七牛 SPM + QiniuUploader - [ ] 3.7 prepareaudio handler - [ ] 3.8 mediaTypeAudio handler ### Phase 4 分享 + 登录 - [ ] 4.1 微信 SDK Vendor + URL Scheme - [ ] 4.2 QQ SDK Vendor - [ ] 4.3 WeChatSDK registerFull - [ ] 4.4 WeChatManager(state map + FIFO) - [ ] 4.5 SceneDelegate openURL 顺序 - [ ] 4.6 WeChatAuth(后台中转或 fallback) - [ ] 4.7 accreditlogin → sharelogin - [ ] 4.8 SharePlatform 协议 - [ ] 4.9 ShareCenter 9 种组合 - [ ] 4.10 friendsShare... handler - [ ] 4.11 截图分享 - [ ] 4.12 远程图片分享 ### Phase 5 定位 - [ ] 5.1 AMap Vendor 接入(XCFramework) - [ ] 5.2 定位权限文案 - [ ] 5.3 AMapWrapper 启动注册 - [ ] 5.4 LocationService - [ ] 5.5 startlocation handler(latitude/longitude string,province 小写) - [ ] 5.6 失败回包 errorCode 12 ### Phase 6 子游戏 - [ ] 6.1 AppCoordinator 栈深节流 - [ ] 6.2 SubGameViewController - [ ] 6.3 SubGameHandlers - [ ] 6.4 SwitchOverGameData - [ ] 6.5 backgameData - [ ] 6.6 getWebdata 通知链 - [ ] 6.7 SubGameDownloader ### Phase 7 弹层 - [ ] 7.1 OverlayViewController + 私有 dataStore - [ ] 7.2 window.settings polyfill - [ ] 7.3 3 个 WKScriptMessageHandler - [ ] 7.4 OpenurlTitleData handler("title " 末尾空格) - [ ] 7.5 Overlay backgameData - [ ] 7.6 Overlay finishweb - [ ] 7.7 Overlay browser ### Phase 8 视频房间 stub - [ ] 8.1 VideoRoom 协议 - [ ] 8.2 NoopVideoRoom - [ ] 8.3 3 个 stub handler - [ ] 8.4 CallCenterMonitor → phonestate - [ ] 8.5 recordSuccess 双 callback - [ ] 8.6 AgoraVideoRoom 蓝图(#if AGORA_ENABLED OFF) ### Phase 9 SDK 真实化 + 监控 - [ ] 9.1 Sentry SPM + CrashReporter - [ ] 9.2 H5ErrorRelay - [ ] 9.3 极光保持 NoopAnalytics(JANALYTICS_ENABLED OFF) - [ ] 9.4 闲聊保持 NoopSharePlatform - [ ] 9.5 Agora 保持 NoopVideoRoom(AGORA_ENABLED OFF) - [ ] 9.6 不集成 Bugly ### Phase 10 多渠道 + 回归 + 灰度 - [ ] 10.1 inject_channel.sh - [ ] 10.2 archive.sh - [ ] 10.3 release.sh - [ ] 10.4 渠道环境文件 - [ ] 10.5 CI archive smoke - [ ] 10.6 契约测试入 CI - [ ] 10.7 26 项验收清单真机过 - [ ] 10.8 设备矩阵覆盖 - [ ] 10.9 内部 50 人灰度 - [ ] 10.10 Sentry 监控 - [ ] 10.11 全量切流 --- ## 9. 决策记录(ADR-lite) > 关键技术 / 工程决策的简短记录,便于回溯"为什么这样选"。新增决策追加到本节末尾。 ### ADR-001:单 target 起步,暂不切 SPM(2026-06-21) - **决策**:Phase 0–2 阶段保持单 target,按目录组织(`Source/Bridge`、`Source/Resource` 等);待代码量 > 5K 行(约 Phase 3 完成时)再评估切 SPM - **理由**:早期 SPM 切分成本(target 间循环依赖排查、增量编译配置、测试 target 接入)会拖慢核心垂直闭环验证;Design §2.2 也注明"目标稳态 8-10 个 target,以日常开发体验为先" - **回滚条件**:单 target 编译时间 > 30s 时切 SPM ### ADR-002:使用已就位的 2023-12 旧版 gamehall.zip 起步(2026-06-21,原方案"H5 demo 先行"已作废) - **背景**:私人素材池 `docs/res/gamehall.zip` 已存在(旧版,2023-12,11.5 MB),无需等 H5 团队最新版即可启动 Phase 1 - **决策**:Phase 1 实施时从 `docs/res/gamehall.zip` 拷贝到 `ylgamehall/Resources/gamehall.zip` 入 Bundle,跑通"渠道注入 → 解压 → 加载 H5 → 桥消息"全链路;旧 H5 业务代码与新版的契约差异不影响"桥本身是否工作"的验证 - **理由**: - 桥接口名 / 参数字段名 / JS 协议属于契约范畴,按 Contract 1:1 实现即可,不依赖 H5 业务代码新旧 - 提早用真 H5 跑通比 demo H5 更能暴露真实问题(如 WVJB 握手时序、JS 注入文件路径等) - **风险**:联调期遇到的契约疑点可能来自旧版 H5 已废弃接口,需对照现网 msext + H5 团队双确认 - **切换条件**:Phase 10 灰度前必须由 H5 团队提供最新版替换 `ylgamehall/Resources/gamehall.zip` 并复跑契约 §10 全部 26 项 ### ADR-003:微信登录走后台中转 vs 客户端 fallback(2026-06-21) - **决策**:默认走后台 `/wechat/login` 中转;后台未就绪时临时客户端直拼,但代码标 `// FIXME` 且**禁止以 fallback 状态上线** - **理由**:客户端 secret 一旦进 IPA 永久泄露 + AppSecret 不可重置(Contract §0.4 / Design §17);上线前必须闭环 - **复盘节点**:Phase 4 完成时检查后台接口状态 ### ADR-004:项目资源目录结构由各 Phase 按需落地,不预先建仓库根 Resources/(2026-06-21 修订) - **背景**:原 ADR-004(2026-06-21 初版)决议"项目资源统一放仓库根 `Resources/` + 打包脚本 Build Phase 引入",并将 `gamehall.zip` / `Images.xcassets` / `Res/` 入库到 `Resources/`。但 `docs/res/` 后被维护者重新定义为**私人原始素材池**(不被项目感知,详见 CLAUDE.md),原 `Resources/` 入库的素材被搬走,使该 ADR 的语义崩塌 - **修订决策**:废止"仓库根 `Resources/`"的固定结构假设,**项目内资源目录由各 Phase 实施时按需落地**: - 静态 Bundle 资源 → `ylgamehall/Resources/`(synchronized group 自动收集,含 `gamehall.zip` / `ChannelConfig.plist` 等) - 原生 Asset Catalog → `ylgamehall/Assets.xcassets/` - 渠道注入值 → `ylgamehall/Resources/ChannelConfig.plist`(11 个 string key,母包默认值;ADR-007) - 闭源 SDK 二进制 → `Vendor//.xcframework` - 私人原始素材池 → `docs/res/`(项目不感知,需要时拷贝到上述工程内目录) - **理由**: - `docs/res/` 既已剥离为私人素材池,原"项目方资源统一放仓库根 Resources/"的前提不存在 - synchronized group 模式下,把资源直接放 `ylgamehall/Resources/` 而非仓库根 `Resources/` 更省一步 Build Phase 配置 - "不预先建 Resources/ 目录"避免空壳目录污染仓库;目录在该 Phase 真正需要时再建 - **影响**: - 仓库根 `Resources/` 不再存在(已删除) - Plan §2.2 / Phase 1 任务 / ADR-002 同步调整为 `docs/res/` → `ylgamehall/Resources/` 的拷贝模型 - Design §7.0 同步重写 ### ADR-005:JAnalytics(极光)首版降为 NoopAnalytics 占位(2026-06-21) - **背景**:Design §14.2 原标注极光"✅ 启用,启动注册",但项目方在 2026-06-21 评审时确认**首版无用户激活 / 留存分析需求** - **决策**:与 Xianliao / Agora 同款渐进集成策略——首版不集成极光 SDK,`AnalyticsKit.tracker` 默认绑到 `NoopAnalytics`,业务调用 `Tracker.event(...)` 立即返回不做任何事;编译开关 `JANALYTICS_ENABLED` 默认 OFF,留蓝图 `JAnalyticsTracker.swift` 待启用 - **理由**: - 减少首版外部依赖(首版 SDK 集成清单:微信 / QQ / 高德 / 七牛 / opencore-amr / Sentry / ZIPFoundation,共 7 个;不集成 4 个:极光 / 闲聊 / Agora / Bugly) - 现网 msext 的极光后端运维状态不可知,与 Bugly 同样有"账号断档监控失效"风险(CLAUDE.md / 父级 msext 已有先例),新外壳起步不重蹈 - Sentry 的 breadcrumb / transaction 可兜一部分用户行为路径,初期定位够用 - 接入成本(AppKey 申请 / Vendor 二进制 / Info.plist 隐私文案)推迟到真有业务需求时 - **后续启用路径**(保持契约不变,业务无感): 1. 在 `Vendor/` 放入 `JAnalytics.framework` 2. 把 `AnalyticsKit.tracker` 从 `NoopAnalytics()` 切换为 `JAnalyticsTracker()` 3. xcconfig 注入 `JANALYTICS_ENABLED=1` 4. Bootstrapper.registerSDKs 加 `JAnalytics.setup(appKey:)` 5. Info.plist 加 AppKey + 隐私授权文案 - **复盘节点**:上线 3 个月后业务方根据数据需求决定是否启用 ### ADR-006:纯 SPM + Vendor `.xcframework` 两层依赖管理,不引入 CocoaPods(2026-06-21) - **背景**:Design 原方案是"SPM 优先 + CocoaPods 兜底 + Vendor 手动"三层策略,CocoaPods 仅服务于高德 AMap 定位 SDK 一个依赖 - **触发事件**:尝试 `pod init` 时,CocoaPods 1.15.2(Homebrew 上的最新版)自带的 `xcodeproj` gem 1.24.0 不识别 Xcode 26.5 默认的 `PBXFileSystemSynchronizedRootGroup`,pod init 直接抛 `unknown ISA` 异常 - **调研结论**: - 七牛 SDK 已官方支持 SPM(`https://github.com/qiniu/objc-sdk`,v8.9.x),原计划的"七牛走 CocoaPods"完全无必要 - 高德 AMap 定位 SDK 官方未提供 SPM(截至 2026-06),但提供 XCFramework 直接下载 - CocoaPods 1.16+ 才支持新 ISA,但 Homebrew 未跟进;需绕道 Bundler + Gemfile / brew --HEAD / rbenv 自管 Ruby,均增加工具链复杂度 - **决策**:依赖管理简化为**两层 — SPM 优先 + Vendor `.xcframework` 手动**,**完全不引入 CocoaPods** - SPM:ZIPFoundation / Sentry / Qiniu / WVJB JS(资源文件) - Vendor:微信 / QQ / **高德 AMap** / opencore-amr;闲聊 / Agora / JAnalytics 暂不集成(Noop 占位) - **理由**: - 项目实际只有高德 AMap 1 个 SDK 让 CocoaPods 必要;为 1 个依赖引入整套 Ruby + Bundler + CocoaPods + workspace 工具链 + Xcode 26 兼容性维护,性价比低 - 闭源 SDK 本就是 Vendor 性质(微信 / QQ / 闲聊 / JAnalytics / opencore-amr),AMap 走同款流程不增加心智负担 - 生态趋势:Google 已宣布 2026 Q2 后 iOS SDK 全部停止 CocoaPods 支持;CocoaPods 维护活跃度下降 - 工程入口仍是 `.xcodeproj`(不需要 `.xcworkspace`),团队 / CI 配置不需要切换 - **接入标准流程**(Vendor,写入 Design §14.1): 1. 二进制放 `Vendor//.xcframework` 2. Target → General → Frameworks → `+`,动态库选 "Embed & Sign",静态库 "Do Not Embed" 3. Library / Header Search Paths 用 `$(PROJECT_DIR)/Vendor/` 相对路径 4. `Vendor//README.md` 记录版本号 / 下载日期 / 官方更新页 URL 5. 二进制入 git(项目规模可控,暂不强制 git-lfs) - **回滚条件**:未来某天出现 ≥ 3 个仅有 podspec 而无 SPM / XCFramework 的 SDK 必接需求时,重新评估是否引入 CocoaPods - **维护责任**:AMap 升级(年度级)→ 下载新 XCFramework 覆盖 `Vendor/AMap/` + 更新 README + 跑契约测试 ### ADR-007:渠道注入用 `ChannelConfig.plist` 替代 msext 的"空目录名注入"机制(2026-06-21) - **背景**:Contract §0.3 描述 msext 把 11 个渠道值编码为 Bundle 根的 11 个目录子文件夹名(如 `channel/FtJf0.../`,目录名本身就是值)。新项目首版尝试照搬此机制 - **触发事件**:与 Xcode 26.5 默认的 `PBXFileSystemSynchronizedRootGroup` 不兼容—— - 子目录被同步组当作 group 处理,11 个 `.gitkeep` 在 Bundle 根撞名 → build 失败 - 尝试 folder reference(蓝色文件夹):Xcode 26.5 拖入对话框流程对此不友好,pbxproj 不会写入正确条目 - 尝试 Run Script Build Phase: 1. 默认开启的 User Script Sandboxing 拒绝读 `Scripts/copy_channel_injection.sh` 与 `ChannelInjection/` 2. 即使关掉 sandbox,Run Script 在没有 Input/Output 声明 + 默认勾选"Based on dependency analysis"时,被 Xcode 视为"无依赖故无需运行",clean build 也不跑 - 综合工程成本估算:为保留"目录名编码"机制,需引入额外 Build Phase / 关闭 sandbox / 维护 Input/Output 列表,**全是 Xcode 行为兼容性维护,与项目目标无关** - **决策**:渠道注入存储改用**单一 `ylgamehall/Resources/ChannelConfig.plist`** 含 11 个 string key,与 msext 11 个目录一一对应 - 存储介质:plist(iOS 原生) - Bundle 加载:synchronized group 自动收集,零配置 - 运行时读取:`BundleConfig.init(bundle:)` 用 `PropertyListSerialization` 反序列化 - **理由**: - **契约 100% 等价**:H5 端通过 `app_data.js` 看到的 11 个 JS 全局变量行为完全不变(契约边界在 `BundleConfig.shared.xxx`,与底层存储无关) - **CLAUDE.md 原则 B 落地**:原生内部自由重构,不要照搬旧项目;msext 那套是 iOS 9 / Xcode 14 时代的 hack,Xcode 26 + Swift 6 应当用更现代的存储 - **IPA 后处理多渠道分发完全等价**: ```bash plutil -replace channel -string "<新渠道 ID>" ChannelConfig.plist plutil -replace market -string "<新市场 ID>" ChannelConfig.plist codesign --force --sign "$IDENTITY" --entitlements "$ENT" .app zip -r .ipa Payload/ ``` 工作量与 msext 的 `mv` 目录名 + 重签完全相同,且 plutil 比 mv 一组路径更可读 / 可自动化 - **维护成本显著降低**:单文件、可读、单测可注入 mock bundle、不依赖 Xcode 任何特殊配置 - **影响**: - Contract §0.3 描述的 msext 实现仅作为历史参考,新项目不照搬 - Design §7.0 / §7.2 重写为 plist 方案 - Plan Phase 1.1 简化为"建 plist"单步任务(取代原 1.1.a~d 四步 inject_channel.sh 方案) - `BundleConfig.swift` 接口(11 个公开属性)保持不变,仅 init 实现切换 - **守护**: - **`ChannelConfig.plist` 必须保持 11 个 key 完整且类型为 string**;新增 key 视同契约边界变更,需更新 Design / Plan / Contract(如该值被 H5 通过 app_data.js 暴露) - 后处理工具必须**修改 plist 后立即重签**,否则 iOS 拒绝安装 - 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道) - **回滚条件**:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质 ### ADR-008:Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22) - **背景**:Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5",**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置,做 agent → channel → market 4 级覆盖,对比本地版本,必要时下载并替换 H5 zip - **触发事件**:2026-06-22 调研 `daoqi/msext` 原项目(subagent 详细分析 `NewRootVC.m:1551-1621` viewWillAppear、`NewRootVC.m:1372-1492` chulishengji、`NewRootVC.m:1789-1869` downFileFromServer 三段核心代码),发现: - 远端配置 URL 不是 `SERVERNew + gameconfig` 拼接,而是 `"http://" + gameconfig.replacingOccurrences("-", "/") + ".txt"`(当前 demo 值即 `http://tsgames.daoqi88.cn/config_test/update_jsonv2_test.txt`) - JSON 结构是 `agentlist[].channellist[].marketlist[]` 三级嵌套 + `agentlist[].gamelist[].channellist[].marketlist[]` 副路径,每层都可携带同名版本字段,深层覆盖浅层 - 两条独立升级链:① 原生 IPA 升级(`app_version > 本地 appversion 注入值` → 弹窗 + Safari 外链)② H5 zip 升级(`game_version > 本地 version.xml /game/version@value` → 下载 + 解压 + reload) - 不做这一步的代价:上线后用户永远停在 IPA 内打包瞬间的 H5 版本,H5 团队任何更新都到达不了用户 - **决策**:在 Phase 1 视图层之前插入 4 个新子项 `1.10 RemoteConfigClient / 1.11 VersionResolver / 1.12 LocalVersionReader / 1.13 LobbyZipUpgrader`,原 `1.10-1.13` 后移为 `1.14-1.17` - **理由**: - **WebContainer 的 `loadFileURL` 在无升级链路前提下是有缺陷的**——只能加载 IPA 内静态 zip - 推迟到 Phase 2 也行得通,但 Phase 1 验收"看到大厅 H5"的语义会被掩盖(看到的是 IPA 内的旧版,跟"上线后看到的"完全不同) - 一次性补齐使 Phase 1 验收能模拟真实上线场景:模拟远端 `game_version +1` 重启 App 必须看到新版本下载 + 加载 - **关键设计点(与 msext 实现的差异)**: | 维度 | msext 现状 | 新外壳决策 | |------|----------|----------| | 网络库 | `[NSData dataWithContentsOfURL:]` 主线程阻塞 / ASIHTTPRequest 下载 | `URLSession async data(from:)` + `download(from:)`,actor 隔离 | | 失败重试 | 4 秒等间隔暴力 `timer` | 指数退避 1/2/4 秒三次,可取消 | | 4 级覆盖算法 | 散落 if/else 在 `getagentversion` / `getgameversion` / `chulishengji` 三方法手写 | 纯函数 reduce,单测覆盖所有边界 | | 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`,半途崩溃留残骸 | 解压到临时目录 + 原子 rename,无半残状态 | | 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11`,靠系统清 Caches) | 同样的原子 rename 策略,旧目录直接覆盖 | | 解析 JSON | SBJSON 三方库 | Codable + JSONDecoder | | 版本号类型 | NSString → intValue(自动当 0 处理) | 强类型 Int,缺失 throw 类型化错误 | - **守护**: - **H5 端契约不变**:H5 看到的仍是"启动一段时间后看到大厅",对 4 级覆盖 / 升级链路完全无感 - `gameconfig` 注入值是远端 URL 的唯一驱动,**不允许把远端 URL 硬编码在 Swift 源码里** - `version.xml` 文件名 / xpath `/game/version@value` 不允许改名(H5 自更新时会写这个文件,新外壳读它,双方协议) - 4 级覆盖的优先级(agent > 顶层、channel > agent、market > channel、game 子树最终覆盖 agent 子树)必须由单测固化,避免回归 - **回滚条件**:若远端 `.txt` 配置服后台被替换为 RESTful API(路径 / 字段名变化),重新评估并实现新协议;JSON 嵌套结构变化同理 - **影响 Phase 2 及之后**: - Phase 2 / 3 等不再需要重做远程配置;只在新加 handler 时调用现成的 `RemoteConfigClient.current` - Phase 6 子游戏升级直接复用 `LobbyZipUpgrader` 的设计(仅参数化目录路径) - Phase 10 多渠道打包前 `gameconfig` 注入值由 IPA 后处理工具修改(ADR-007 `plutil` 路径) --- 文档完成日期:2026-06-21 最后更新:2026-06-22(ADR-008 Phase 1 补远程配置 + zip 升级 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录)