Files
youle_app_ios_v2/docs/Development-Plan.md
T
joywayer 5f032342e7 docs:Plan / Design 补 Phase 1 远程配置 + zip 升级流水线(ADR-008)
调研 daoqi/msext 原项目 NewRootVC.m:1551-1621 viewWillAppear、1372-1492
chulishengji、1789-1869 downFileFromServer 三段核心代码后发现 Plan 初版
Phase 1 漏掉了启动流程里最关键的一环——从 gameconfig 拼远端 .txt 拉
真实配置、做 agent → channel → market 4 级覆盖、对比本地版本、必要时
下载并替换 H5 zip。不做的代价是:上线后用户永远停留在 IPA 内打包瞬间
的 H5 旧版本,H5 团队任何更新都到达不了。

- Plan Phase 1 任务清单从 13 项扩为 17 项,插入 4 个新子项:
  - 1.10 RemoteConfigClient(actor,URLSession async + 指数退避)
  - 1.11 VersionResolver(纯函数 + 单测,reduce 实现 4 级覆盖)
  - 1.12 LocalVersionReader(解析 version.xml)
  - 1.13 LobbyZipUpgrader(actor,原子 rename)
  原 1.10-1.13 后移为 1.14-1.17。已完成的 1.1-1.9 标记为 
- Plan 新增 ADR-008 完整记录决策背景 / 触发事件 / 与 msext 实现差异
  对照表 / 守护条款 / 子游戏复用规划
- Design §6.3 整节重写:从原"ConfigService 一锅端"扩为完整 4 模块流水线
  (§6.3.1-6.3.7),含 Codable RemoteConfig 模型、纯函数 VersionResolver、
  nonisolated LocalVersionReader、actor LobbyZipUpgrader、WebContainer
  调用串、与 msext 差异表、子游戏 Phase 6 复用规划
- 关键设计纠正:
  - 远端 URL 不带 SERVERNew 前缀,仅 gameconfig.replace("-","/") + ".txt"
  - 4 级覆盖:顶层 → agent → channel → market 深层胜出 + game 子树覆盖
    agent 子树
  - 解压策略:staging-{uuid}/ 临时目录 + 原子 moveItem rename,不学
    msext "目录名 +1" 累积 hack
2026-06-22 00:42:30 +08:00

1042 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 进贤聚友棋牌 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 GroupobjectVersion 77Xcode 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 × 72016: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 视频房间(默认 NoopVideoRoomAgora 启用是开关) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 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 个工作日内可独立完成
- 中:24 个工作日,含联调
- 大: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` 单测 targetTesting framework,不引 XCTest 旧 API
- 第一个测试 `BootstrapTests.testAppDelegateRespondsToShake`,确认 `applicationSupportsShakeToEdit=true` 已生效
- 验收:`xcodebuild test` 通过
- [ ] **0.3** 新建 `ylgamehallContractTests` 契约测试 target(与 unit test 分离,跑得久也无所谓)
- 暂只放空骨架,Phase 1 起逐项填入
- [ ] **0.4** 新建 `ylgamehallUITests` UI 测试 targetXCUI
- 暂只放空骨架,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(`<html>` 含一个 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:` 退避 reloadDesign §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` 进 ResourcesH5 调 `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 DomainsUniversal 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 XCFrameworkVendor 手动,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`
- 栈深永远 ≤ 3Lobby + 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 18 完成
#### 任务清单
- [ ] **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` 桥 handlerDesign §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 19 完成
#### 任务清单
##### 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 立即提交一个独立 commitCLAUDE.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 任何变动必须同步更新 ContractDesign §18.6
- Phase 完成时如发现 Contract 描述与实测不符 → 优先修 Contract(事实为准)
---
## 7. 真实风险与缓解(项目独有)
| 风险 | 触发条件 | 影响 | 缓解 |
|------|---------|------|------|
| 已就位的 `gamehall.zip` 是 2023-12 旧版,与现网 H5 有契约漂移 | Phase 19 联调 | 联调期发现 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 RemoteConfigClientactorURLSession async + 重试)
- [ ] 1.11 VersionResolver(纯函数 4 级覆盖 + 单测)
- [ ] 1.12 LocalVersionReaderversion.xml 解析)
- [ ] 1.13 LobbyZipUpgraderactor,原子 rename 升级)
- [ ] 1.14 WebContainerViewController16: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 WeChatManagerstate 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 handlerlatitude/longitude stringprovince 小写)
- [ ] 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 极光保持 NoopAnalyticsJANALYTICS_ENABLED OFF
- [ ] 9.4 闲聊保持 NoopSharePlatform
- [ ] 9.5 Agora 保持 NoopVideoRoomAGORA_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 起步,暂不切 SPM2026-06-21
- **决策**Phase 02 阶段保持单 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 客户端 fallback2026-06-21
- **决策**:默认走后台 `/wechat/login` 中转;后台未就绪时临时客户端直拼,但代码标 `// FIXME` 且**禁止以 fallback 状态上线**
- **理由**:客户端 secret 一旦进 IPA 永久泄露 + AppSecret 不可重置(Contract §0.4 / Design §17);上线前必须闭环
- **复盘节点**:Phase 4 完成时检查后台接口状态
### ADR-004:项目资源目录结构由各 Phase 按需落地,不预先建仓库根 Resources/2026-06-21 修订)
- **背景**:原 ADR-0042026-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/<SDK>/<SDK>.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-005JAnalytics(极光)首版降为 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` 两层依赖管理,不引入 CocoaPods2026-06-21
- **背景**Design 原方案是"SPM 优先 + CocoaPods 兜底 + Vendor 手动"三层策略,CocoaPods 仅服务于高德 AMap 定位 SDK 一个依赖
- **触发事件**:尝试 `pod init` 时,CocoaPods 1.15.2Homebrew 上的最新版)自带的 `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**
- SPMZIPFoundation / 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/<SDKName>/<SDKName>.xcframework`
2. Target → General → Frameworks → `+`,动态库选 "Embed & Sign",静态库 "Do Not Embed"
3. Library / Header Search Paths 用 `$(PROJECT_DIR)/Vendor/<SDKName>` 相对路径
4. `Vendor/<SDKName>/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. 即使关掉 sandboxRun 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 时代的 hackXcode 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>.app
zip -r <channel>.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-008Phase 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-22ADR-008 Phase 1 补远程配置 + zip 升级 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录)