Files
youle_app_ios_v2/docs/Development-Plan.md
T
joywayerandClaude Opus 4.7 09f071e38f Phase 2.C:ExternalSubscriptions 生命周期管理(防双发预备)
按 Design §2.4.2 把 battery / network / appservice 三组外部事件源的订阅
挂钩与 VC 生命周期配对:viewWillAppear → setup,viewWillDisappear →
teardown。配套约束:未来 Phase 6 子游戏 push 上来时大厅自动 teardown,
避免桥事件双发(栈深 ≤ 2,§2.4.3)。

WebContainerViewController 改造:
  - 新增 setupExternalSubscriptions():
    * 幂等 start 3 个 monitor(NetworkMonitor / BatteryMonitor /
      AppLifecycleObserver)
    * 设 BatteryMonitor.onChange → evaluateJavaScript("window.app_getbattery=N")
      + bridge.call("getBattery", "%.2f")
    * 设 NetworkMonitor.onChange → evaluateJavaScript("window.app_getnetwork=N")
      + bridge.call("getnetwork", "1"/"2"/"3")
    * 设 AppLifecycleObserver.onBackground/Foreground → bridge.call("appservice",
      "1"/"2")
  - 新增 teardownExternalSubscriptions():
    * 解绑 onChange / on{Background,Foreground} = nil
    * 释放对 bridge/webView 的 closure 引用避免子游戏 push 后双发
    * monitor 不停(保持后台 currentXxx 状态新鲜),下次 setup 直接 resume
  - viewWillAppear / viewWillDisappear override 调 setup / teardown 配对
  - writeAppDataFiles 精简:只写首次值 + 启动 NetworkMonitor/BatteryMonitor
    (需要 currentXxx 读首次值);AppLifecycleObserver 启动挪到 setup

至此 Phase 2 全部完成(2.A 8 个 handler + 2.B 5 项反向 callback + 2.C
生命周期管理)。

BuildProject 通过

Plan §5 Phase 2.13 勾选

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

1199 lines
78 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**
- [x] **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 验证嵌套解析正确
- [x] **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 用例)
- [x] **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
- [x] **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 集成 + 烟雾测试
- [x] **1.14.a** AppIcon:从 `docs/res/Images.xcassets/AppIcon-1.appiconset` 拷贝 9 个 png + Contents.json 到 `ylgamehall/Assets.xcassets/AppIcon.appiconset`,替换 Xcode 默认空模板。已实测 BuildProject 通过。actool 3 个 warningiPad 76 / 83.5 / 1024 缺失)暂不处理,iPad 会自动用 iPhone 缩放,1024 是 App Store 上架要求(企业签不强制);待 Phase 10 上线前 polish 时补齐
- [x] **1.14.b** LaunchScreen:源素材 `docs/res/Res/Default-568h@2x~iphone.png` 是 CgBI PNG,物理 640×1136 但画面内容是"横躺"在竖向容器里(msext 旧 LaunchImage 系统依赖 device orientation 自动旋转);现代 LaunchScreen.storyboard 没有这个魔法,直接显示会看到躺倒画面。处理:用 `sips -r -90` 一次性把素材逆时针旋转 90° 输出为标准 PNG(去 CgBI),物理像素 1136×640,存到 `ylgamehall/Assets.xcassets/SplashImage.imageset/SplashImage@2x.png`universal idiom)。storyboard 用单一 UIImageView 全屏铺满,`contentMode = scaleAspectFit`,背景 `.black`,四边约束到 superview。aspectFit 在比 16:9 更宽的现代横屏 iPhone 上左右补黑边(黑底无违和),保画面完整不裁切 logo。WebContainer 的 SplashOverlay 用同款 contentMode,避免启动 → 容器视觉过渡时跳变。BuildProject 通过
- [x] **1.14.c** `LobbyZipUpgrader` 加 progress 回调
- `upgradeIfNeeded(resolved:onProgress:) async throws -> Outcome``onProgress: @escaping @Sendable (Double) -> Void` 默认 no-op 报告 0.0…1.0
- 文件内私有 `DownloadProgressDelegate: URLSessionDownloadDelegate, @unchecked Sendable`,实现 `didWriteData:totalBytesWritten:totalBytesExpectedToWrite:`;通过 `session.download(from:delegate:)` 注入
- 完成时兜底报 1.0(部分 server 不发 Content-Length 或末尾片段晚到,避免进度条停在 99%)
- RootViewController 烟雾测试用 `ProgressTicker` 节流到 10% 阶梯打印;BuildProject 通过
- [x] **1.14.d** 实现 `Source/WebView/WebContainerViewController.swift`(基类)
- 持有 `BridgedWebView`(通过其再持有 `BridgeBus`+ 文件内私有 `SplashOverlay`
- 16:9 letterboxaspectRatio(16:9) + widthMax/heightMax required + widthFill/heightFill .defaultLow + centerXY 居中。屏幕比 < 16:9 → 上下黑边;> 16:9 → 左右黑边
- **SplashOverlay**UIImageView(SplashImage, scaleAspectFill) + UIProgressView + UILabel`update(text:progress:)` 切换状态机
- 启动流水线 `runBootPipeline()`ensureReady → "拉取配置中..." → RemoteConfigClient.fetch → 分支(.shortText / .parsed → showmessage / IPA 升级 / LobbyZipUpgrader.upgradeIfNeeded with onProgress hop 到 MainActor)→ "加载大厅..." → `loadFileURL(SandboxPaths.lobbyIndex, allowingReadAccessTo: lobbyRoot)`
- 终态弹窗:`showBlockingAlert`(短文本 / showmessage,永停)/ `showIPAUpgradeAlert`(确定 → openURL,永停)/ `showFatalAlert`(重试 → 重跑 pipeline
- 横屏锁定 + statusBar 显示,与契约 §4.2 一致
- **暂不实现**`webViewWebContentProcessDidTerminate:` 退避 reload(移到 Phase 3 网络层时一并处理)
- BuildProject 通过
- [x] **1.14.e** WebView 加载完成后淡出 splash
- `webView(_:didFinish:)``UIView.animate(withDuration: 0.3)` splash.alpha = 0 → completion 内 `splash.removeFromSuperview()`
- 期间 webView 已渲染好 H5 大厅,无白屏闪烁;splash 出 view 层级避免空持有
- BuildProject 通过
- [x] **1.15** 实现 `Source/Bridge/Handlers/VibratorHandler.swift`
- 无状态 `public enum VibratorHandler``static func register(on bridge: any BridgeProtocol)`
- 入参忽略(参考 msext `RootVC.m:1816-1818``time` 参数实际不读取)
- 副作用:`AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)`responseCallback`.string("vibrator")`
- `WebContainerViewController.viewDidLoad` 新增 `registerBridgeHandlers()` 单一聚合点,Phase 2+ 在此追加。BuildProject 通过
- [x] **1.16** `SceneDelegate``WebContainerViewController` 作 rootViewController
- 替换 M0 占位 `RootViewController` + 删除 `ylgamehall/RootViewController.swift`(M0 烟雾测试堆栈整体退役,所有 1.2–1.13 print 验证记录在 git log,留着是死代码)
- File System Synchronized Group 自动适应文件删除,无需改 pbxproj
- BuildProject 通过
- [ ] **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(无外部依赖)
- [x] **2.1** `gameCopytext` / `gamepastetext`(剪贴板)— `Source/Bridge/Handlers/ClipboardHandler.swift`UIPasteboard.general
- [x] **2.2** `vibrator` / `repeatvibrator` / `canclevibrator`VibratorHandler.swift 扩展 2 项)
- [x] **2.3** `startshake` / `stopshake` / `SwitchShake`ShakeHandler.swiftcanShake / canVoice 状态 + WebContainer motionEnded 转发 + shakeEnd 反向)
- [x] **2.4** `voicePlaying`VoicePlayingHandler.swiftactor VoiceCenter.shared.isEnabled 开关位,Phase 3 mediaTypeAudio 读此)
- [x] **2.5** `getphoneInfo` → 触发反向 callback `getphoneinfo`DeviceInfoHandler.swift,注意大小写:大写 I 入、小写 i 出;DeviceInfoSnapshot 6 字段,IDFA 用 idfv 兜底因为 CLAUDE.md 不接 ATT
- [x] **2.6** `browser` → 系统 Safari 打开 URLBrowserHandler.swiftUIApplication.open async
- [x] **2.7** `opensaoma` 空实现注册(OpenSaomaHandler.swift,契约 §3.1【22】要求)
- [x] **Bonus** `startlocation` stubStartLocationHandler.swift,让 H5 启动不报 "no handler"Phase 5 接 LocationKit 完整实现)
- [x] **Bridge 重构** `BridgeData.asXxx` 全部加 `nonisolated`,让 handler 闭包(@Sendable async)可以自由访问数据值;契约不变
##### 2.B Native→H5 反向 callback
- [x] **2.8** `Source/Bridge/Handlers/DeviceInfoHandler.swift`DeviceInfoSnapshot 6 字段(Phase 2.5 一并完成)
- `getphoneInfo` 收到 → `bridge.call("getphoneinfo", data: 表 A 6 字段)` 反向
- [x] **2.9** `Source/Resource/BatteryMonitor.swift`:监听 `UIDevice.batteryLevelDidChangeNotification`
- 触发 → 重写 `app_battery.js` + `bridge.call("getBattery", data: .string("%.2f", level))`
- [x] **2.10** `Source/Resource/NetworkMonitor.swift`NWPathMonitor 包装(Phase 1 已建)
- 状态变化 → 重写 `app_network.js` + `bridge.call("getnetwork", data: .string("1"/"2"/"3"))`
- [x] **2.11** `Source/Resource/AppLifecycleObserver.swift`:监听 `UIApplication.didEnterBackgroundNotification` / `willEnterForegroundNotification`
- 后台 → `bridge.call("appservice", data: .string("1"))`;前台 → `"2"`
- [x] **2.12** ShakeDetector 集成在 `Source/Bridge/Handlers/ShakeHandler.swift`Phase 2.3 一并完成)
- `WebContainerViewController.motionEnded(_:with:)` 转发 → `bridge.call("shakeEnd", data: nil)`(仅 `canShake=YES` 时触发)
##### 2.C 外部订阅生命周期
- [x] **2.13** 实现 Design §2.4.2 的外部订阅生命周期管理
- `setupExternalSubscriptions()`viewWillAppear 调):幂等启动 3 个 monitor + 设 onChange 闭包(battery / network 走 evaluateJavaScript + bridge.call 双轨;appservice 仅 bridge.call
- `teardownExternalSubscriptions()`viewWillDisappear 调):解绑 onChange / on{Background,Foreground} = nil,释放 bridge/webView 引用避免子游戏 push 后双发
- `writeAppDataFiles` 精简为只写首次值 + 启动 NetworkMonitor/BatteryMonitor(读 currentXxx 用),AppLifecycleObserver 启动挪到 setup
- 单例 monitor 全局只有一个 closure 引用,未来 Phase 6 子游戏 setup 时会覆盖(大厅已 teardown 不会冲突,符合 §2.4.3 栈深 ≤ 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(事实为准)
#### Design 接口骨架覆盖里程碑
| 日期 | 完成 | 涉及 commit |
|------|------|-------------|
| 2026-06-22 | Phase 1 闭环(1.101.16)完整启动链路打通:SceneDelegate → WebContainer → ensureReady → fetch → resolve → upgrade → loadFileURL → fade out splash | `c95e80d` 及之前 |
| 2026-06-22 | Design 蓝图按 Contract 全 51 项接口补完实现骨架(H5↔Native 5 条通讯路径):22 项 §3.1 异步 handler / 15 项 §3.2 反向 callback / 3 项 §3.4 弹层 polyfill / 15 项 §7.5 `app_*.js` 预注入全局变量 / 2 项 §3.7 WKUIDelegate alert/confirm | `92298b6` / `c6221d3` / `d48ecc0` / `a06a18c` / `5f5f6a9` / `33574a1` / `f68b0db` |
| 2026-06-22 | Contract §4.2 按 daoqi 原项目代码(grep `var app_` 字面字符串)全面修订:删 `app_gameid` / `app_compareCode`(原项目不写)/ 修正 `app_battery``app_getbattery``app_network``app_getnetwork`(文件名不带 get、变量名带 get)/ 补 6 项遗漏(version / Launchtype / getwifisignalLevel / gamename / invitationcode / gamesname | `47a89aa` |
| 2026-06-22 | CLAUDE.md 新增第 2 个典型案例「H5 与原生通讯接口的真实路径」记录"凭语义猜测会一字之差犯错、必须 grep 字面字符串验证"的教训 | `fae7b3d` |
| 2026-06-22 | **§7.5 重大路径调整**:从「原生 `writeToFile` 写 4 个 `app_*.js` 文件」改为「H5 团队 zip 自带 + 原生 `WKUserScript(.atDocumentEnd)` / `evaluateJavaScript` 覆盖 `window.app_xxx` 全局变量」 | `246f215` |
| 2026-06-22 | **CLAUDE.md 原则 A 升级为第一准则**:H5 端零修改不可妥协,禁止任何"H5 改一行 / 改一个文件 / 加 polyfill / 调时机"的妥协方案。加典型案例 3 复盘 `app_*` 注入时序问题 | `3039daf` |
| 2026-06-22 | **§7.5 路径再次回退(最终)**:撤销 `246f215`,回到 msext 原写文件路径 `AppDataWriter`。原因:WKUserScript 时序(documentStart 被 H5 自带 var 覆盖、documentEnd 又太晚)无法 1:1 等价 msext,任何要求 H5 配合改动的方案违反原则 A 第一准则。Design §7.5 + Contract §4.2 + §10 全面同步回退 | `13cc24b` |
| 2026-06-22 | **AppDataWriter 代码落地**:新增 `Source/WebView/AppDataWriter.swift` + `Source/Resource/NetworkMonitor.swift`WebContainer.runBootPipelineSteps 在 step 5 后、step 6 loadFileURL 前调 `writeAppDataFiles(resolved:)` 写 4 个 app_*.js 文件 + 挂 battery/network 变化重写监听。BuildProject 通过 | `829cc8f` |
| 2026-06-22 | **`app_appversion` / `app_gameconfig` 业务语义纠正**grep `daoqi/msext/NewRootVC.m:1190-1208` 发现 `app_appversion` 不是版本号、是审核切换标志(`'0'` 正常 / `'1'` 审核期);`app_gameconfig` 也跟着 result 在 `BundleConfig.gameConfig` / `appleConfig` 切换。AppDataWriter.writeAppData 加 result 计算逻辑;Contract §4.2 + Design §7.5.1 同步纠正;Contract §4.2 修订记录加一行 | `0a313af` |
| 2026-06-22 | **Phase 2.A 8 个大厅 handler 落地** + **Phase 2.B 5 项反向 callback 落地** + BridgeData 加 nonisolated 解 Swift 6 actor 隔离 | `d38ff0f` + `23b3a1f` |
| 2026-06-22 | **业务期 `app_getbattery` / `app_getnetwork` 改为 evaluateJavaScript 重新赋值(不重写文件)**:启动期保持写文件,业务期变化时改为 `webView.evaluateJavaScript("window.app_xxx=N;")` 直接重新赋值(H5 已加载完 `<script src>` 不会再 fetch,重写文件无效)。与 §3.2 反向 callback 双轨同时执行。Design §7.5.2 / §7.5.4 + Contract §4.2 同步 | 本次 commit(用户指示) |
> **§3.4.1 撤销说明**:早期 commit `82bad8a` 实现的 `window.settings.getXxx()` polyfill 已撤销 — Contract §附录 A 自身明示「iOS<9 路径,新外壳如最低系统 ≥ iOS 14 可不实现」,本项目最低 iOS 15.6 → polyfill 路径未启用,H5 用 §7.5 的 `app_*.js` 文件路径作为唯一主路径。详细缘由见 Design §3.4.1(撤销说明)和 CLAUDE.md「典型案例」段。
---
## 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
- [x] 1.10 RemoteConfigClientactorURLSession async + 重试 + cache-busting + 短文本 + FlexibleString
- [x] 1.11 VersionResolverchulishengji 双子树合并算法,纯函数)
- [x] 1.12 LocalVersionReaderversion.xml 解析 + ATS 全局放行)
- [x] 1.13 LobbyZipUpgraderactor,原子 rename 升级;端到端实测 260→261)
- [x] 1.14.a AppIcon 替换 Xcode 默认空模板(iPad / 1024 缺失留 Phase 10 polish
- [x] 1.14.b LaunchScreen 改启动图(素材逆时针旋转 90° → 物理 1136×640scaleAspectFit + 黑底左右补边)
- [x] 1.14.c LobbyZipUpgrader 加 progress 回调(URLSessionDownloadDelegate0.0…1.0
- [x] 1.14.d WebContainerViewControllerSplashOverlay + 16:9 letterbox + 启动流水线 + 三种 alert
- [x] 1.14.e WebView didFinish 后 0.3s 淡出 splash + removeFromSuperview
- [x] 1.15 VibratorHandler(单次振动,AudioServicesPlaySystemSound + responseCallback "vibrator"
- [x] 1.16 SceneDelegate → WebContainerViewController(删除 M0 占位 RootViewController.swift
- [ ] 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,含 2 次调研 revise
- **背景**Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5"**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置 → chulishengji 双子树算法 → 与本地版本比较 → 必要时下载并替换 H5 zip
- **触发事件**2026-06-22 两轮 subagent 调研 `daoqi/msext` 原项目,逐行核对核心方法:
- 第一轮(粗粒度):发现远端配置流程整体形态、IPA / H5 zip 两条升级链
- 第二轮(精确算法):核对 `NewRootVC.m` 全文,识别出**线上热路径是 chulishengji 双子树合并**,非 onnet 简单 4 层
- **核心修正:线上热路径算法**
- **不是** `onnet` 的 `if(self.gamelist==nil)` 分支(`NewRootVC.m:689-810`)的"简单线性 4 层覆盖"
- **是** `onnet` 的 `else` 分支(`NewRootVC.m:811-877`)→ `gonetconfig1` / `gonetconfigone1` → **`chulishengji`**`NewRootVC.m:1372-1538`)双子树合并
- 触发条件:`viewWillAppear``NewRootVC.m:1609-1616`)与 `gonet``NewRootVC.m:1252-1258`)拉完 JSON 都强制把当前 agent 节点下 `gamelist` 提到 `self.gamelist`;只要服务器 JSON 里 agent 节点带 `gamelist`(线上 100% 都有),`self.gamelist` 就非 nil → 走 chulishengji 路径
- **决策**:在 Phase 1 视图层之前插入 4 个新子项 `1.10 RemoteConfigClient / 1.11 VersionResolver / 1.12 LocalVersionReader / 1.13 LobbyZipUpgrader`,原 `1.10-1.13` 后移为 `1.14-1.17`。**`VersionResolver` 必须按 chulishengji 双子树算法实现,不照搬 onnet 简单 4 层**
#### ADR-008-A 远端 URL 构造(`RemoteConfigClient`
- 基础 URL`"https://" + BundleConfig.gameConfig.replacingOccurrences("-", "/") + ".txt"`
- 新外壳改 HTTPSmsext 用 HTTP`NewRootVC.m:250`)。后台已经支持 HTTPS。
- **cache-busting query**msext `NewRootVC.m:1226, 1585`):每次拉取时拼 `?vXXXXXXXXYYYYYYYY`(两个 `arc4random()` 各 8 位 hex 连写,**无 `=`**)。**新外壳保留此约定**——服务端可能根据这段 query 做无 cache 处理。
#### ADR-008-B 网络层
- API`URLSession.data(from:)` async(替代 msext `[NSData dataWithContentsOfURL:]` 主线程同步阻塞)。10s 请求超时 + 30s 资源超时。
- 重试策略:**最初取 3 次 1/2/4 秒指数退避**(已在 1.10 落地);上线前评估改为 **msext 风格 4s 等间隔无次数上限**——因为 msext 那套"无网静默重试 + 网络恢复立刻进"的体验在地铁场景是合理的,新外壳不应当回退。
- 静默重试:网络错误不弹窗、不打 log,等下次 tick;只在第一次失败时 UI 显示状态(启动图 + "重新连接中")。这是契约。
- 短文本响应(`NewRootVC.m:1239-1243`):trim 后 `length ≤ 100` 时**当 alert 内容直接弹窗**,并停止重试。运营杀手锏 #1。**新外壳用 `utf16.count` 实现 1:1 复刻**msext 用 NSString length 即 UTF-16 unit 计数)。
#### ADR-008-C JSON 解析(`RemoteConfigClient`
- 服务端响应是 UTF-8 编码的 JSON(扩展名 `.txt` 仅历史包袱)
- 顶层结构:`{ showmessage, agentlist[], scrollmsg, Ads, managerUrl, isclose, menunotice, ... 20+ H5 业务字段 }`
- 原生**只读** `agentlist + showmessage`,**其余字段 H5 同 URL 再拉一次**自己读
- Codable 忽略未识别字段(默认行为),新外壳自动满足
- **字段类型混乱(必须兼容)**:`marketid` / `game_version` / `app_version` 等本应为 string 的字段,服务端会发 number。msext 用 NSNumber 静默吞下;**新外壳用 `decodeFlexibleStringIfPresent` 扩展(String / Int / Double / Bool 全兼容)**——已在 1.10 实现
- **解析失败行为**msext 静默 nil → 后续 for 循环 0 次跑空 → App 静默卡死(缺陷)。新外壳应当**显式抛 `ConfigParseFailed` + UI 弹错误页 + 提供重试按钮**
#### ADR-008-D chulishengji 双子树合并算法(`VersionResolver`
新外壳 `VersionResolver.resolve()` 必须 1:1 复刻 `NewRootVC.m:1372-1538` 的算法,输出 5 个目标字段:`(appVersion, appDownload, gameVersion, gameZip, showmessage)`。
**步骤**
```swift
// 1. 提取 agent 子树(getagentversionNewRootVC.m:885-1013
// agent → channel → market → gamegame 嵌在 market 命中后才遍历)
agentConfig = extractAgentSubtree(agent)
// → (app_version, app_download, game_version, game_download, showmessage)
// 2. 提取 game 子树(getgameversionNewRootVC.m:1015-1180
// game → game-self → channel → market
// (第二层 "game-self" 是把 gamedata 自身当 infotwo 再嵌一遍 channellist
// msext 那段曾经的 agentid 判断被注释掉了)
gameConfig = extractGameSubtree(game)
// → (app_version, app_download, game_version, game_download, showmessage)
// 3. 合并 IPA 字段(NewRootVC.m:1408-1447
if both have app_download:
result.appVersion = agentConfig.appVersion // 默认 agent 赢
result.appDownload = agentConfig.appDownload
if gameConfig.appVersion > agentConfig.appVersion: // 版本号大的赢(line 1416
result.appVersion = gameConfig.appVersion
result.appDownload = gameConfig.appDownload
elif only one has: 用那一份
else: nil
// 4. 合并 zip 字段(NewRootVC.m:1450-1492
if both have game_download:
result.gameVersion = gameConfig.gameVersion // 默认 game 赢
result.gameZip = gameConfig.gameZip
if agentConfig.gameVersion > gameConfig.gameVersion: // 版本号大的赢(line 1456
result.gameVersion = agentConfig.gameVersion
result.gameZip = agentConfig.gameZip
elif only one has: 用那一份
else: nil
// 5. showmessage:在两条子树 extract 内部多次覆盖(agent 子树 :897/:910/:924/:948
// game 子树 :1027/:1058/:1087/:1114),加上 onnet else 的两次 for 循环 (:826/:852)
// 与 gonetconfigone1 :1319。算法上等同于"任意一处非空就覆盖",最后写赢。
result.showmessage = mergeShowmessage(top, agent, game, channel, market)
```
**agent 子树 4 层结构**`getagentversion:`):
- 第 1 层 agent 自己:`NewRootVC.m:889-915` 提取顶层 4 字段 + showmessage
- 第 2 层 channel`NewRootVC.m:903-940` 嵌 channellist 找匹配
- 第 3 层 market`NewRootVC.m:921-997` 嵌 marketlist 找匹配
- 第 4 层 game`NewRootVC.m:944-996` 嵌在 market 命中后才遍历 game(注意是 market.gamelist,不是 channel.gamelist
**game 子树 4 层结构**`getgameversion:`):
- 第 1 层 game 自己:`NewRootVC.m:1019-1045`
- 第 2 层 game-self`infotwo=gamedata` 自身再嵌 channellist):`NewRootVC.m:1048-1073`
- 第 3 层 channel`NewRootVC.m:1075-1100`
- 第 4 层 market`NewRootVC.m:1100-1163`
**字段名差异(必须留意)**
- agent 子树用 `game_download``NewRootVC.m:952`
- game 子树用 `game_zip``NewRootVC.m:1037`
- 两者本质同一字段,msext 历史命名不一致。新外壳 Codable 模型用 `gameZip`,但需要在两个 extract 函数里都查这两个 key(择一非空)
#### ADR-008-E 决策顺序(`WebContainer` 编排)
5 字段拿到后**固定顺序**判断(chulishengji 路径在 `NewRootVC.m:1501-1527`):
| 优先级 | 条件 | 动作 |
|---|---|---|
| 1 | `showmessage != ""` 且非 nil | 弹 alert(标题 `gamehallname + 提醒`,单按钮"确定"),**完全阻塞**return |
| 2 | `[version_ios intValue] > [iosNumber intValue]` | 弹 IPA 升级 alert(标题"有新的版本更新,点击前往下载!更新过程中可能会白屏...",单按钮"确定"tag PublicTagfive → Safari 外链 `app_download`return |
| 3 | 否则 | `uplevel:download:` 内(`NewRootVC.m:1874-1885`):`gamevers > [versioninfo intValue]` → `downFileFromServer:`;否则 `initView` 直接加载本地 H5 |
#### ADR-008-F LocalVersionReader1.12
- **iOS 本地版本**`BundleConfig.shared.appVersion` 转 Int(来自 ChannelConfig.plist `appversion` 字段,等价 msext `[FuncPublic filename:@"appversion"]`
- **H5 本地版本**`Library/Caches/{gamedir}/{gamestart}/version.xml` 解析 `/game/version@value`
- msext 用 GDataXML`NewRootVC.m:664-682`);新外壳用 Foundation `XMLParser`(无外部依赖)
- **缺失 / 损坏返回 0**(必须保留,是首装后首次升级的兜底机制;msext `[nil intValue] = 0`
#### ADR-008-G LobbyZipUpgrader1.13
- 下载链路:`URLSession.download(from: gameZip)` async + 进度回调
- 解压:临时目录 `staging-{uuid}/` + 原子 `moveItem` rename(不学 msext 先删后压的中断风险)
- **失败处理**msext `requestFailed:` 只打 NSLog 静默;新外壳应当**弹错误页 + 回退用 Bundle 内 H5**(旧 zip 仍可用)
#### ADR-008-H 5 字段的他用(必须保留)
- `showmessage`:仅 alert,不传 H5 / 不存 UserDefaults
- `version_ios`:① 决策;② `initJSdata` 间接通过 `app_data.js` 注入 `app_appversion` 字段传给 H5(值 0 = gameconfig / 1 = appleconfig,控制 H5 走两条不同分发路径)
- `app_download`:仅本次启动用
- `game_version_` / `game_zip_`:决策 + downFileFromServer 拼下载 URL
- 全部**不存 UserDefaults**msext 无缓存层)
#### ADR-008-I 与 msext 差异(实施层面,H5 契约不变)
| 维度 | msext 现状 | 新外壳决策 |
|------|----------|----------|
| 网络库 | 主线程同步 `dataWithContentsOfURL:` + ASIHTTPRequest 下载 | `URLSession.data(from:)` + `download(from:)` asyncactor 隔离 |
| 重试 | 4s timer 无上限 | 1.10 初版用 3 次退避;可改为 msext 风格无上限 |
| URL | HTTP | HTTPS(后台已切) |
| cache-busting | `?vXXXXXXXX_YYYYYYYY` | **保留** |
| JSON 解析 | SBJSON | Codable + JSONDecoder + 自定义 `decodeFlexibleStringIfPresent`(兼容 number-as-string |
| 算法 | chulishengji 双子树合并 | **复刻 chulishengji**,纯函数 + 单测 |
| version.xml | GDataXML | Foundation XMLParser |
| 升级解压 | `removeItemAtPath` + ZipArchive | 临时目录 + 原子 rename |
| 失败 UI | 静默 / NSLog | 弹错误页 + 重试按钮(不留卡死黑洞) |
- **回滚条件**:若远端 `.txt` 配置服后台被替换为 RESTful API(路径 / 字段名变化),重新评估并实现新协议
- **影响 Phase 2 及之后**
- Phase 6 子游戏升级直接复用 `LobbyZipUpgrader` 的设计(仅参数化目录路径)
- Phase 10 多渠道打包前 `gameconfig` 注入值由 IPA 后处理工具修改(ADR-007 `plutil` 路径)
---
文档完成日期:2026-06-21
最后更新:2026-06-22ADR-008 二次精确化 chulishengji 双子树算法 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录)