【bug】 ADR-009 初版只从 RemoteConfig 顶层读 audio_domain / audio_bucket,但渠道方 配置时习惯把这俩字段放在 agent 节点(与 app_version / game_zip 等版本字段同 款层级),导致客户端读到空值后误报 BootError.audioConfigMissing 启动期致命。 【方案】 对齐版本字段的 4 层 fallback 算法: - Agent / Game / Channel / Market 4 个节点 struct 各加 audioDomain / audioBucket - RemoteConfigNode 协议加这俩 getter - VersionResolver 新增 resolveAudio(...) → (domain:String?, bucket:String?) 逻辑:agent → game → channel → market 倒序找第一个非空,整链空时 fallback 顶层 - WebContainerViewController parsed 分支改用 resolveAudio + print 诊断日志 【兼容性】 顶层 audio_domain / audio_bucket 仍然支持(作为整链兜底),后台不需要改配置 位置;放节点上也能读到——任意一种 layout 都工作。 【文档】 Plan ADR-009 决策表 + 注入时序段 同步更新 + 加 2026-06-27 修订说明记录此次踩坑。 BuildProject 通过,Xcode 诊断 clean。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1281 lines
97 KiB
Markdown
1281 lines
97 KiB
Markdown
# 进贤聚友棋牌 iOS 新外壳开发计划
|
||
|
||
> 配套文档:
|
||
> - `docs/H5-Native-Implementation-Design.md` — 架构蓝图(**WHAT to build / HOW to design**)
|
||
> - `docs/H5-Native-Contract.md` — H5↔原生契约(**外部可观察行为,验收以此为准**)
|
||
>
|
||
> 本文档:执行图(**WHEN / IN WHAT ORDER / ACCEPTANCE CRITERIA**),随实施持续更新。
|
||
>
|
||
> 起点:M0 部分完成(项目基础配置就绪);终点:通过契约 §10 全部 26 项验收清单后灰度上线。
|
||
|
||
---
|
||
|
||
## 1. 文档三角关系
|
||
|
||
```
|
||
Contract Design Plan(本文档)
|
||
↓ ↓ ↓
|
||
契约边界 架构蓝图 执行路线
|
||
"必须长这样" "推荐怎么做" "按这个顺序做"
|
||
|
||
不可越界 可自由重构 随进度更新
|
||
```
|
||
|
||
- **Contract 与 Design 是稳定文档**,除非契约或架构本身要变更,否则不动
|
||
- **Plan 是活文档**,每完成一个 Phase 就在 §8 进度追踪里勾掉、把后续 Phase 调整为新现实
|
||
- **冲突时优先级**:Contract > Design > Plan(不能为了赶进度违背契约)
|
||
|
||
---
|
||
|
||
## 2. 起点:当前项目状态快照(2026-06-21)
|
||
|
||
### 2.1 已完成
|
||
|
||
- [x] Xcode 工程骨架(File System Synchronized Group,objectVersion 77,Xcode 26.5)
|
||
- [x] iOS Deployment Target 15.6
|
||
- [x] Swift 6.0 + `SWIFT_APPROACHABLE_CONCURRENCY=YES` + `SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor`
|
||
- [x] iPhone + iPad 仅横屏,`UIRequiresFullScreen=true`
|
||
- [x] 删除默认 Storyboard,改用 `SceneDelegate` 代码启动 → `RootViewController`(M0 占位)
|
||
- [x] Info.plist 契约前置项:`UIStatusBarHidden=NO` / `UIViewControllerBasedStatusBarAppearance=YES`
|
||
- [x] `AppDelegate` 设置 `applicationSupportsShakeToEdit=true`
|
||
- [x] `.gitignore`(标准 iOS/Swift 模板,含签名 / 敏感配置 / xcuserdata)
|
||
- [x] CLAUDE.md 加入"及时提交"规则
|
||
- [x] BuildProject 验证通过
|
||
|
||
### 2.2 已就位的原始素材
|
||
|
||
项目维护者的私人素材池 `docs/res/` 已包含以下可按需取用的素材(详见 CLAUDE.md「docs/res/ 与项目资源的关系」与 Design §7.0):
|
||
|
||
- `docs/res/gamehall.zip`(2023-12 旧版,11.5 MB;用时拷贝到 `ylgamehall/Resources/`)
|
||
- `docs/res/Images.xcassets/`(AppIcon / LaunchImage / 分享平台图标;用时迁移 / 拷贝条目到 `ylgamehall/Assets.xcassets/`)
|
||
- `docs/res/Res/`(`sharelogo.png` / `shake_sound_male.mp3` 等散落原生资源;用时拷贝到 `ylgamehall/Resources/Sounds/` 等子目录)
|
||
|
||
> `docs/res/` 不进 Bundle 也不被工程引用。Phase 1 / 3 / 4 等实施时按需从中拷贝到 `ylgamehall/Resources/` 或 `ylgamehall/Assets.xcassets/`。
|
||
|
||
→ Phase 1 不再阻塞于 H5 团队(gamehall.zip 旧版可用于打通管道),可立即展开。
|
||
|
||
### 2.3 待项目方协调的外部资源(阻塞项)
|
||
|
||
| 资源 | 用途 | 阻塞起始 Phase | 协调对象 |
|
||
|------|------|--------------|---------|
|
||
| `gamehall.zip` **最新版**(上线前替换) | 真实 H5 业务代码 | Phase 10 灰度前 | H5/前端团队 |
|
||
| 11 个渠道注入目录的目录名值 | 渠道 / 游戏 ID / 七牛域名 / market 等 | Phase 1 | 项目方/运营 |
|
||
| 微信 OpenSDK `.framework` | 登录 / 分享 | Phase 4 | 项目方(可从 msext/Vendor 拷贝) |
|
||
| 微信 AppID(`wx586a9b321e56efb7`)+ Universal Links 配置 | 微信回调 | Phase 4 | 项目方 |
|
||
| ~~QQ OpenSDK `.framework`~~ | QQ 分享 | **不需要 SDK** — msext `QQShareManager.m:14` `__has_include` fallback 路径已有完整 URL Scheme 实现(`mqqapi://share/to_fri?...`),新外壳直接照搬即可。Info.plist 仅需加 `LSApplicationQueriesSchemes`(`mqq` / `mqqapi` / `mqqopensdkfriend` 等) | — |
|
||
| ~~抖音 OpenSDK `.framework`~~ | 抖音分享 | **不需要 SDK** — msext `DouyinShareManager.m` 全程 URL Scheme(`snssdk1128://share/video` / `snssdk1128://camera`)+ Photos 框架(保存图片到相册让用户在抖音里选)。Info.plist 仅需加 `LSApplicationQueriesSchemes`(`snssdk1128`)+ `NSPhotoLibraryAddUsageDescription` 写入相册权限文案 | — |
|
||
| 后台 `/wechat/login` 中转接口 | secret 不入 IPA 的前提 | Phase 4 | 后台团队 |
|
||
| 高德地图 `APIKey` + SDK XCFramework | 定位 | Phase 5 | 已有 key `b0d4a8e3fcbbcc0dd96283b7df6a4494`(但绑死 msext Bundle ID,需用新 Bundle ID 重新申请);SDK 走 Vendor 手动接入(ADR-006),下载页 https://lbs.amap.com/api/ios-location-sdk/download |
|
||
| 七牛上传 token 颁发接口 | 录音上传 | Phase 3 | 后台团队 |
|
||
| ~~极光 AppKey~~ | ~~用户统计~~ | **已决策暂不集成(ADR-005),未来启用时再协调** | — |
|
||
| Sentry DSN | 崩溃监控 | Phase 9 | 项目方(需开通账号) |
|
||
| Agora AppID(视频房间) | 子游戏视频 | Phase 8(默认 stub,可延后) | 项目方 |
|
||
| 闲聊 SDK | 闲聊分享 | Phase 9(默认 stub) | 项目方 |
|
||
| 开发 / 企业签名证书 + 描述文件 | 真机调试 / 分发 | Phase 1 起持续 | 项目方/iOS 账号管理员 |
|
||
|
||
> **协调原则**:当某 Phase 阻塞在外部资源时,**先做不依赖该资源的 Phase**(并行机会见 §3 依赖图)。
|
||
|
||
### 2.4 已确定的项目约束
|
||
|
||
| 项 | 值 |
|
||
|---|---|
|
||
| H5 设计分辨率 | **1280 × 720(16:9)** |
|
||
| WebView 适配策略 | 保持 16:9 比例(设备比例不匹配时上下/左右 letterbox) |
|
||
| Bundle ID | `com.skyapp.ylgamehall`(与 msext 分离) |
|
||
| Team ID | NX5W3B4QP3 |
|
||
| 设备 | iPhone + iPad(不去 iPad) |
|
||
| 支持方向 | 仅横屏(LandscapeLeft / LandscapeRight) |
|
||
| Swift 版本 | 6.0 |
|
||
| 最低 iOS | 15.6 |
|
||
|
||
---
|
||
|
||
## 3. 全局依赖图
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Phase 0 工程化基线(剩余项:SwiftLint / 测试 target / CI) │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Phase 1 资源层 + 桥核心 + 第一个 handler │
|
||
│ ResourceKit / BundleConfig / BridgeCore / BridgedWebView │
|
||
│ 验收:H5 加载 + 一个简单 handler 双向通 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
┌─────────────┼─────────────┐
|
||
▼ ▼ ▼
|
||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||
│ Phase 2 │ │ Phase 3 │ │ Phase 5 │
|
||
│ 简单 │ │ 音频体系 │ │ 定位 │
|
||
│ handler │ │ │ │ │
|
||
│ + 反向cb │ │ │ │ │
|
||
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
||
│ │ │
|
||
└─────────────┴─────────────┘
|
||
│ 并行可行
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Phase 4 分享 + 登录(前置:项目方提供 SDK / 后台接口) │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Phase 6 子游戏容器(SwitchOverGameData / backgameData) │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Phase 7 弹层(Overlay + window.settings polyfill) │
|
||
│ 完成后契约 §10 A/B/C/E 节可全跑通 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Phase 8 视频房间(默认 NoopVideoRoom;Agora 启用是开关) │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Phase 9 SDK 真实化(Sentry / 极光)+ Stub 替换为真实 SDK │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Phase 10 多渠道打包 + 真机回归 + 灰度切流 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**关键并行机会**:Phase 2 / 3 / 5 互不依赖,可按当下手头的资源到位情况自由调度。
|
||
|
||
---
|
||
|
||
## 4. Phase 总览
|
||
|
||
| Phase | 目标 | 关键产出 | 验收来源 | 相对成本 |
|
||
|-------|------|---------|---------|---------|
|
||
| **P0** | 工程化基线 | SwiftLint、单测 target、CI 流水线 | 本文档 §5.0 | 小 |
|
||
| **P1** | 最小垂直闭环 | `BundleConfig` / `ResourceUnzipper` / `BridgeCore` / `BridgedWebView` + `vibrator` handler | 契约 §10 中 `vibrator` 项;H5 file:// 加载成功 | 大 |
|
||
| **P2** | 大厅简单 handler + 反向 callback | `DeviceKit` / `Pasteboard` / 振动 / 摇一摇 / 网络 / 电池 / 前后台 | 契约 §10 B 节 + C 节 | 中 |
|
||
| **P3** | 音频体系 | `AudioKit`(播放 / 录音 / AMR↔WAV / 七牛上传) | 契约 §10 中 `srcIsloop` / `mediaTypeAudio` / `prepareaudio` 项 | 大 |
|
||
| **P4** | 分享 + 登录 | `LoginKit`(WeChat OAuth)/ `ShareKit`(WeChat + Noop 闲聊) | 契约 §10 B 节 `accreditlogin` / `friendsShare...` 项 | 大 |
|
||
| **P5** | 定位 | `LocationKit`(高德 SDK 封装) | 契约 §10 B 节 `startlocation` 项 | 中 |
|
||
| **P6** | 子游戏容器 | `SubGameViewController` + `Coordinator` + `SwitchOverGameData` / `backgameData` | 契约 §10 中 `SwitchOverGameData` 项 | 中 |
|
||
| **P7** | 弹层 | `OverlayViewController` + `window.settings` polyfill + 节流 | 契约 §10 中 `OpenurlTitleData` / `settings.*` 项 | 中 |
|
||
| **P8** | 视频房间 stub | 3 个 stub handler + Native→H5 `getVideoinfo` / `phonestate` / `recordSuccess` callback | 契约 §10 D 节(NoopVideoRoom 路径) | 小 |
|
||
| **P9** | SDK 真实化 + 监控 | Sentry / 七牛 SPM 接入;极光 / 闲聊 / Bugly 决策落地为 Noop / 不集成 | 契约 §10 全 26 项 | 中 |
|
||
| **P10** | 多渠道 + 灰度 | 渠道注入脚本 / CI 多渠道出包 / 真机回归 / 灰度切流 | 真机验收 + 灰度无回退 | 大 |
|
||
|
||
**相对成本说明**:
|
||
- 小:1 个工作日内可独立完成
|
||
- 中:2–4 个工作日,含联调
|
||
- 大:5–10 个工作日,跨多个模块或需对外联调
|
||
|
||
---
|
||
|
||
## 5. 各 Phase 详细计划
|
||
|
||
### Phase 0: 工程化基线(剩余项)
|
||
|
||
#### 目标
|
||
建立项目级别的代码质量与回归基线,使后续 Phase 每个 commit 都可被自动验证。
|
||
|
||
#### 前置
|
||
无。当前 M0 基础配置已完成。
|
||
|
||
#### 任务清单
|
||
|
||
- [ ] **0.1** 加入 SwiftLint(SPM 插件方式,避免全局安装依赖)
|
||
- `Package.swift` 加 `SwiftLintPlugin`;规则文件 `.swiftlint.yml` 按 Design §13.4 配(行长 120、文件 ≤400、函数 ≤40)
|
||
- 验收:`xcodebuild` 时 lint 自动跑,违规 warning 出现
|
||
- [ ] **0.2** 新建 `ylgamehallTests` 单测 target(Testing framework,不引 XCTest 旧 API)
|
||
- 第一个测试 `BootstrapTests.testAppDelegateRespondsToShake`,确认 `applicationSupportsShakeToEdit=true` 已生效
|
||
- 验收:`xcodebuild test` 通过
|
||
- [ ] **0.3** 新建 `ylgamehallContractTests` 契约测试 target(与 unit test 分离,跑得久也无所谓)
|
||
- 暂只放空骨架,Phase 1 起逐项填入
|
||
- [ ] **0.4** 新建 `ylgamehallUITests` UI 测试 target(XCUI)
|
||
- 暂只放空骨架,Phase 10 真机回归用
|
||
- [ ] **0.5** 加 CI 配置(GitHub Actions 或本地 Jenkinsfile,按项目方实际 CI 平台)
|
||
- 跑:`xcodebuild test -scheme ylgamehall` + lint
|
||
- 验收:push 后 CI 绿
|
||
- [ ] **0.6** 准备 SPM 包目录结构(**先建空 `Package.swift` 但暂不切 target**)
|
||
- 决策:先单 target 内按目录组织,等 P3 之后代码量 > 5K 行再切 SPM
|
||
- 这样早期开发不被 SPM 切分成本拖慢
|
||
|
||
#### 验收
|
||
|
||
- `xcodebuild test` 通过;CI 绿;SwiftLint 报告 0 违规
|
||
|
||
#### 风险
|
||
|
||
- 单测 / UI 测 target 的 signing 需配置(自动签 + 不同 Bundle ID 后缀)
|
||
|
||
---
|
||
|
||
### Phase 1: 最小垂直闭环(资源 + 桥 + 远程配置 + 第一个 handler)
|
||
|
||
#### 目标
|
||
打通**从启动 → 渠道注入读取 → 远程配置拉取 → 版本对比 → zip 升级(若需要)→ 加载 H5 → JS 与原生互调**的全链路,用最简单的 handler `vibrator` 作为验证标的。
|
||
|
||
> **Phase 1 范围扩展(ADR-008)**:原 Plan 漏掉了"远程配置 + 版本对比 + zip 升级"这一关键环节——若不做,上线后用户永远看不到 H5 团队的最新版本,永远停留在 IPA 内打包那一刻的 H5 旧版。2026-06-22 调研 msext `NewRootVC.m` 发现完整流程后插入新子项 1.10-1.13,原 1.10-1.13 后移为 1.14-1.17。
|
||
|
||
#### 前置
|
||
- ✅ Phase 0 基线
|
||
- ✅ `docs/res/gamehall.zip`(2023-12 旧版,足以验收 Phase 1;实施时拷贝到 `ylgamehall/Resources/gamehall.zip`)
|
||
- ⏳ 项目方提供 11 个渠道注入目录名值(缺则用临时 demo 值,含可访问的远端 `gameconfig`.txt)
|
||
- 备选:若 zip 加载有问题,临时用一个最小 H5(`<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]`
|
||
- 公开 10 个只读属性:`gameId` / `channel` / `gameDir` / `gameStart` / `gameConfig` / `market` / `agent` / `appVersion` / `other` / `appleConfig`(`qiniuDomain` 已于 2026-06-27 移除,七牛 CDN 改 RemoteConfig.audio_domain 远端注入,详见 ADR-009)
|
||
- 单测 fixture:建立 mock bundle 含 fixture plist,验证 10 个 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 个 warning(iPad 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 letterbox:aspectRatio(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.swift,canShake / canVoice 状态 + WebContainer motionEnded 转发 + shakeEnd 反向)
|
||
- [x] **2.4** `voicePlaying`(VoicePlayingHandler.swift,actor 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 打开 URL(BrowserHandler.swift,UIApplication.open async)
|
||
- [x] **2.7** `opensaoma` 空实现注册(OpenSaomaHandler.swift,契约 §3.1【22】要求)
|
||
- [x] **Bonus** `startlocation` stub(StartLocationHandler.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("2"))`;前台 → `"1"`(值错位沿用 msext WKWebView 路径历史,参 Contract.md §3.2 `appservice` 值错位说明)
|
||
- [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 本地音频
|
||
|
||
- [x] **3.1** `Source/Audio/AudioPlayer.swift`(@MainActor)
|
||
- `playOnce(url)` 单次按钮音(msext `buttunPlayer` 等价,多次点击不互打断 — 用 buttonPlayers 数组保活池)
|
||
- `loopBackground(url, type:)` 循环背景音(msext `backgroundPlayer numberOfLoops = -1`,记录 backgroundType)
|
||
- `stopBackground(type:)` 停同名背景音(type 与当前 backgroundType 匹配才停)
|
||
- `Source/Bridge/Handlers/LocalAudioHandler.swift` 注册 `srcIsloop` handler,按 isloop=0/1/-1 分支调用 AudioPlayer
|
||
- 音频文件路径:`{Caches}/{gamedir}/{gamestart}/assets/wav/{src}`(H5 zip 包内自带)
|
||
- `Source/Bridge/Handlers/RemoteAudioHandler.swift` 注册 `prepareaudio` / `mediaTypeAudio` stub(仅 cb 维持契约,等 Phase 3.B/3.C/3.D 升级)
|
||
- [ ] **3.2** 真机验证:H5 调 `srcIsloop({src:"xxx.wav", isloop:0/1/-1})` 听到对应音效 — 等 H5 团队提供测试音频后做(Verification-Checklist Phase 3 E.14-E.16)
|
||
|
||
##### 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}需要访问您的麦克风录制语音消息"`
|
||
- [x] **3.6** 七牛 SPM 依赖(`https://github.com/qiniu/objc-sdk` v8.9.2,传递依赖 happy-dns 1.0.4 自动解析)+ 客户端自签 token 路径
|
||
- `Source/Network/QiniuConfig.swift`:4 项常量(AccessKey / SecretKey / BucketName / cdnDomain)+ publicURL,沿用 msext,整体 nonisolated
|
||
- `Source/Network/QiniuTokenSigner.swift`:CryptoKit HMAC-SHA1 + Base64URL 自签,等价 msext QiniuManager.m:200-230
|
||
- `Source/Network/QiniuUploader.swift`:actor,`upload(_:timeSec:) async throws -> UploadedFile`,`QNUploadManager(configuration: defaultConfigurationV2)` 路径
|
||
- 真机上传单测待 Phase 3.C 完成(依赖 AudioRecorder 录音输入)
|
||
- [ ] **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~~ **不需要**:URL Scheme(参 msext `QQShareManager.m:14` `__has_include` fallback)
|
||
- ✗ ~~抖音 OpenSDK~~ **不需要**:URL Scheme + Photos 框架(参 msext `DouyinShareManager.m` 全程无 SDK)
|
||
- ⏳ 后台 `/wechat/login` 接口(或先用客户端直拼 fallback)
|
||
|
||
#### 任务清单
|
||
|
||
##### 4.A SDK 接入
|
||
|
||
- [x] **4.1** `Vendor/WechatSDK/` 入库微信 SDK(`WechatOpenSDK-NoPay.xcframework` 2.0.5,入 git)
|
||
- Info.plist URL Scheme `wx586a9b321e56efb7` + LSApplicationQueriesSchemes `weixin` / `weixinULAPI` / `weixinURLParamsAPI` 三项已就位
|
||
- Universal Links / Associated Domains 暂不接入:企业签分发,不上 App Store,无审核合规需求;`WXApi.registerApp(_, universalLink: "")` 空串即可
|
||
- [x] **4.2** **SharePanel 三选一面板 + SharePlatform 框架**(4.B 完成)
|
||
- `Source/Share/SharePlatform.swift`:SharePlatform 协议(name / isInstalled / share async)+ ShareContent struct(nonisolated Sendable)+ ShareResult enum(success / cancelled / notInstalled / failed)+ ShareScene enum(friend / timeline)
|
||
- `Source/Share/SharePanel.swift`:底部弹出三按钮(微信 / QQ / 抖音)+ 半透明背景点击关闭 + 0.25s 滑入/滑出动画;与 msext SharePanel.m 等价行为
|
||
- `Source/Share/ShareCenter.swift`:sharefriend == "1" 弹 SharePanel;"2" 直接 WechatShare.timeline;与 msext gameController.m:585 行为 1:1
|
||
- `Source/Share/{Wechat,QQ,Douyin}Share.swift`:三个平台 stub(仅返回 success),Phase 4.C/4.D/4.E 升级
|
||
- `FriendsShareHandler` 升级:调 ShareCenter.dispatch + 完成后触发 sharesuccess 反向 callback(success/cancelled 都发,notInstalled/failed 不发,沿用 msext 乐观策略)
|
||
- BuildProject 通过
|
||
- [x] **4.3** **QQ 分享走 URL Scheme,不依赖 SDK**(4.C 完成)
|
||
- 移植 msext `QQShareManager.m:716-771` `simpleShareToQQFriend` 简化版
|
||
- URL 构造:`mqqapi://share/to_fri?` 或 `mqqapi://share/to_qzone?`(scene == .timeline 走 qzone)+ `version=1&cflag=0&req_type=1&url=...&title=...&description=...`
|
||
- `encode` 与 msext line 1262-1268 等价:仅保留 RFC 3986 unreserved(alphanumeric + `-._~`)其它 percent encode
|
||
- `canOpenURL` 检查 + `await UIApplication.open` 异步打开;调起即视为 success
|
||
- **当前仅链接分享**(type == "1");type == "2" 截图 / 远端图 留 Phase 4.F
|
||
- Info.plist 加 `LSApplicationQueriesSchemes`:`mqq` / `mqqapi` / `mqqopensdkfriend` / `mqqopensdkapiV2/V3/V4`
|
||
- [x] **4.4** **抖音分享走 URL Scheme + Photos,不依赖 SDK**(4.D 完成)
|
||
- 新增 `Source/Share/ImageProvider.swift`:`captureScreenshot()` 截 keyWindow + `downloadRemote(_:)` 异步下载远端图(共用基础设施)
|
||
- 新增 `Source/Share/PhotoLibrarySaver.swift`:保存到相册(PHAccessLevel.addOnly 权限,iOS 14+ 推荐)
|
||
- `Source/Share/DouyinShare.swift` 升级移植 msext `DouyinShareManager.m:201-269` `shareImageToDouyin`:
|
||
1. 拿图片源:type=2 / type=1 都退化为截屏(抖音不支持纯链接,与 msext 行为一致);type=其它=远端图 URL → downloadRemote
|
||
2. PhotoLibrarySaver.save 保存相册
|
||
3. `UIPasteboard.general.image = image` 兜底
|
||
4. 按 msext 顺序尝试 `snssdk1128://camera` / `publish` / `share/image` / 基础 scheme
|
||
5. 调起即视为 success
|
||
- Info.plist 加 `NSPhotoLibraryAddUsageDescription` 文案
|
||
- BuildProject 通过
|
||
- [x] **4.3** `Source/SDK/WeChat/WeChatSDK.swift` 启动注册(`WXApi.registerApp("wx586a9b321e56efb7", universalLink: "")`;新 SDK 2.x 无 `MMAPP_SUPPORT_*` flag 概念,简单注册即可)
|
||
- [x] **4.4** `Source/SDK/WeChat/WeChatManager.swift`(@MainActor)
|
||
- 持久 delegate(WXApiDelegate,onResp nonisolated 跳 MainActor)
|
||
- `authorize() async throws -> WXAuthCodePayload`(state UUID 配对)
|
||
- `shareLink(_:scene:) async throws`(FIFO 串行;type=2/3 截图/远端图待 4.11 / 4.12 Phase 4.F)
|
||
- [x] **4.5** `SceneDelegate.openURLContexts` 仅 `WXApi.handleOpen`(QQ 走 URL Scheme 不依赖 SDK,无 SDK 顺序问题)
|
||
|
||
##### 4.B 授权登录
|
||
|
||
- [x] **4.6** `Source/Login/WeChatAuth.swift`(客户端直拼 sns/oauth2/access_token + sns/userinfo)
|
||
- 接受 msext 同款客户端持 secret 路径(父项目 CLAUDE.md 已识别但接受;后台无 `/wechat/login` 中转)
|
||
- 7 字段 user 包含 `openid` / `headimgurl` / `nickname` / `sex` / `city` / `Province`(大写 P)/ `unionid`
|
||
- [x] **4.7** `accreditlogin` handler:触发 OAuth → 反向 callback `sharelogin`(7 字段,Province 大写 P)
|
||
|
||
##### 4.C 分享
|
||
|
||
- [x] **4.8** `Source/Share/SharePlatform.swift` 协议 + `WechatShare` / `QQShare` / `DouyinShare`(闲聊不接,参 ADR-006)
|
||
- [x] **4.9** `Source/Share/ShareCenter.swift`:sharefriend=1 弹 SharePanel;=2 直接微信朋友圈(msext gameController.m:585 等价)
|
||
- [x] **4.10** `FriendsShareHandler.swift` → ShareCenter.dispatch → 反向 callback `sharesuccess({success:"2", type:<原 sharefriend>})`
|
||
- [x] **4.11** 截图分享 type=2(commit 8e4c36d):captureScreenshot → JPEG 0.6 + ImageProvider.scaledThumb 0.4x → WeChatManager.shareImage(WXImageObject + setThumbImage)
|
||
- [x] **4.12** 远程图片分享 type=其它(commit 8e4c36d):downloadRemote → JPEG 0.9 + scaledThumb 0.4x → shareImage 同一路径
|
||
|
||
#### 验收
|
||
|
||
- 契约 §10 B 节:
|
||
- `accreditlogin` 收到 7 字段 `sharelogin`(Province 大写)
|
||
- `friendsSharetypeUrlToptitleDescript` type=1/2/3 各跑一次,收到 `sharesuccess`
|
||
- 契约 §10 E 节:微信 / QQ 回调命中(QQ 必须先判)
|
||
|
||
#### 风险
|
||
|
||
- 微信审核:Universal Links 配置错误 → 授权回不来
|
||
- AppSecret 客户端泄露 = 严重安全问题;fallback 路径上线前必须切到后台
|
||
- 截图分享在 Liquid Glass / 新版 UIKit 下 `drawHierarchy` API 行为变化,需查 DocumentationSearch 验证
|
||
|
||
---
|
||
|
||
### Phase 5: 定位
|
||
|
||
#### 目标
|
||
高德定位 + 逆地理 → 反向 callback `getlocationinfo` 9 字段。
|
||
|
||
#### 前置
|
||
- Phase 1 完成
|
||
- ⏳ 高德 SDK XCFramework(Vendor 手动,ADR-006)+ 新 Bundle ID 重新申请 APIKey
|
||
|
||
#### 任务清单
|
||
|
||
- [x] **5.1** 高德官方至今**不提供 xcframework**,回退为 fat `.framework` 入 `Vendor/AMap/`(AMapFoundationKit 1.9.0 + AMapLocationKit 2.12.0);Target Do Not Embed(静态库);M Mac 模拟器链接失败是接受的现状,开发期跑真机,详见 `Vendor/AMap/README.md` 「已知限制」
|
||
- [x] **5.2** Info.plist `NSLocationWhenInUseUsageDescription = "进贤聚友棋牌需要访问您的位置以提供本地化服务"`
|
||
- [x] **5.3** `Source/SDK/AMap/AMapWrapper.swift`:`AMapLocationManager.updatePrivacyShow` + `updatePrivacyAgree` + `AMapServices.shared().apiKey`(注意:隐私 API 在 AMapLocationManager 类方法上,不在 AMapServices)
|
||
- [x] **5.4** `Source/Location/LocationService.swift`(actor)
|
||
- `requestOnce() async throws -> LocationPayload` ✅
|
||
- `stop()` 清理钩子 ✅
|
||
- 持续模式 `startContinuous(onUpdate:)` 未实现(msext 实际只有 H5 一次性 startlocation;如未来 H5 用 data=1 持续,再补)
|
||
- [x] **5.5** `startlocation` handler:单次定位 → 反向 callback `getlocationinfo` 9 字段
|
||
- latitude/longitude 是 string(`String(format: "%f", ...)`)
|
||
- province 是小写 p(与 sharelogin.Province 大写不同)
|
||
- [x] **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
|
||
|
||
#### 任务清单
|
||
|
||
- [x] **6.1** `Source/Coordinator/AppCoordinator.swift`:2s 节流(`lastSwitchAt`)+ 栈深约束(`viewControllers.count < 2`)+ `.subGameDidReturn` 通知名(Design §2.4.3)
|
||
- [x] **6.2** `Source/WebView/SubGameViewController.swift`:与大厅同款 BridgedWebView + Splash + 16:9 letterbox + ExternalSubscriptions;`AppDataWriter(containerRole: .subGame)` 写 4 个 app_*.js;H5 未安装则抛 `SubGameBootError.subGameNotInstalled`(待 6.7 接入下载)
|
||
- [x] **6.3** 子游戏 handler 注册(拆散到独立 enum,不抽 `SubGameHandlers` 聚合类):
|
||
- 14 个与大厅共享 handler 直接复用各自 enum.register
|
||
- 新增 `Source/Bridge/Handlers/BackGameDataHandler.swift`,仅 SubGameViewController 注册
|
||
- 新增 `Source/Bridge/Handlers/VideoRoomHandlers.swift` stub(createRoom / getVideoinfo / exitRoom):
|
||
业务暂未启用视频功能,仅维持桥契约不让 H5 报 "no handler";
|
||
未来接 Agora 时把 3 个 stub 展开实现,注册点不变
|
||
- [x] **6.4** `SwitchOverGameData` handler(大厅 + 子游戏共用):解参 `Gamedirectory` / `gamedownloadurl` / `data` → `AppCoordinator.shared.showSubGame(...)`;节流 / 栈深守门移到 Coordinator,handler 始终回 cb `"SwitchOverGameData"`(msext NewRootVC.m:541-566 等价)
|
||
- [x] **SceneDelegate**:以 `UINavigationController` 承载大厅,`AppCoordinator.shared.navigationController` 持引;导航栏隐藏 + 禁用边缘 pop 手势
|
||
- [x] **6.5** `backgameData` handler(仅子游戏 `BackGameDataHandler.swift` 注册):
|
||
- `AudioPlayer.shared.stopAllBackground()`(新增方法,msext 无条件 stop+nil 行为等价)
|
||
- `AppCoordinator.shared.popSubGame(returningData:)` → 内部 popViewController + 发 `.subGameDidReturn`
|
||
- callback 字面 `"backgameData"`
|
||
- data 入参兼容 string / object(非字符串走 JSONSerialization 透传)
|
||
- [x] **6.6** 大厅监听 `.subGameDidReturn` → `bridge.call("getWebdata", data)`
|
||
- 挂钩点:`WebContainerViewController.setupExternalSubscriptions`(与 battery/network/appservice
|
||
同一生命周期,避免双发);`teardownExternalSubscriptions` removeObserver
|
||
- [x] **6.7** 子游戏 zip 下载 / 解压(H5 端通过 SwitchOverGameData 传 `gamedownloadurl`)
|
||
- `Source/Resource/SubGameDownloader.swift`:actor 单例,URLSession + ZIPFoundation
|
||
- 缓存策略:`{Caches}/{gameDir}/{gameStart}/index.html` 已存在 → `.alreadyExists` 直接复用;
|
||
未命中 → 下载 → staging 解压 → 原子 rename(与 LobbyZipUpgrader 同思路;
|
||
msext gameController.m:1340-1401 行为等价,本项目以 staging 替代 msext 直接覆盖避免半残留)
|
||
- SubGameViewController.runBootPipelineSteps 接入:splash 实时更新下载进度
|
||
|
||
#### 验收
|
||
|
||
- 契约 §10 B 节 `SwitchOverGameData` → push 子游戏 → 子游戏调 `backgameData` → 回大厅 → 大厅收 `getWebdata`
|
||
- 栈深永远 ≤ 3(Lobby + SubGame + Overlay)
|
||
|
||
#### 风险
|
||
|
||
- 子游戏 zip 下载失败兜底:超时 / 网络断 / hash 校验失败 → 弹错误页且 pop 回大厅
|
||
- 大厅 + 子游戏并存时双发桥事件(已由 Phase 2 ExternalSubscriptions 解决)
|
||
|
||
---
|
||
|
||
### Phase 7: 弹层(Overlay)
|
||
|
||
#### 目标
|
||
H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settings.*` 三接口操作,关闭后大厅收 `getWebdata`。
|
||
|
||
#### 前置
|
||
- Phase 1 完成
|
||
|
||
#### 任务清单
|
||
|
||
- [x] **7.1** `Source/WebView/OverlayViewController.swift`:独立 WKWebView,`WKWebViewConfiguration.websiteDataStore = .nonPersistent()`
|
||
隔离弹层 Cookie / 缓存(不污染大厅 / 子游戏会话)
|
||
- [x] **7.2** polyfill JS 内联(不抽独立 .js 文件):`.atDocumentEnd` 注入
|
||
`window.settings={browser,finishweb,backgameData}` → `webkit.messageHandlers.*`
|
||
(msext threeView.m:223-241 在 webViewDidFinishLoad 注入 `context[@"settings"]=jo` 等价时机)
|
||
- [x] **7.3** `WKScriptMessageHandler` 通过 `ScriptMessageProxy`(weak target 切断 retain cycle)
|
||
注册 3 handler:`browser` / `finishweb` / `backgameData`(messageHandler 与 WVJB 不同 channel,
|
||
可与大厅 WVJB 同名共存不冲突)
|
||
- [x] **7.4** `OpenurlTitleData` handler(大厅 + 子游戏都注册):解参(`"title "` 末尾空格契约硬约束) →
|
||
`AppCoordinator.showOverlay` → 3 秒节流由 Coordinator 守门(msext first_Time 等价)
|
||
- [x] **7.5** Overlay backgameData → `AppCoordinator.popOverlay(returningData:)` → 发 `.subGameDidReturn`
|
||
→ 父层(大厅 / 子游戏)观察后 callback H5 `getWebdata`
|
||
- [x] **7.6** Overlay `finishweb` → `popOverlay(returningData: nil)` 仅 pop 不发通知
|
||
- [x] **7.7** Overlay `browser` → `UIApplication.shared.open` Safari 外链
|
||
- [x] **额外**:SubGameViewController.setupExternalSubscriptions 也订阅 `.subGameDidReturn`
|
||
(overlay 可从子游戏 push,pop 回子游戏时需要 callback 子游戏 H5 的 getWebdata;
|
||
与大厅 / 子游戏的 viewWillAppear/Disappear 生命周期对齐,栈顶才挂钩避免双发)
|
||
|
||
#### 验收
|
||
|
||
- 契约 §10 B 节 `OpenurlTitleData` 完整链路 + `settings.finishweb()` 关闭 + `settings.backgameData(data)` 回传
|
||
|
||
#### 风险
|
||
|
||
- 弹层第三方外链 Cookie 不污染主业务态(`.nonPersistent()` 已隔离)
|
||
- 3 秒节流时间戳由谁持有(`OpenUrlHandler` 内部 actor 状态)
|
||
|
||
---
|
||
|
||
### Phase 8: 视频房间 stub
|
||
|
||
#### 目标
|
||
注册 3 个视频房间 handler 的 stub 实现 + 子游戏独有 3 个反向 callback,以满足契约边界 —— 即使业务暂不上视频,H5 也不会报错。
|
||
|
||
#### 前置
|
||
- Phase 6 完成(子游戏容器)
|
||
|
||
#### 任务清单
|
||
|
||
- [x] **8.1/8.2/8.3** 视频房间 3 件套 stub 已在 Phase 6 commit C 同期落地:
|
||
- `Source/Bridge/Handlers/VideoRoomHandlers.swift` 直接 enum.register(不抽 protocol/Noop 类,
|
||
业务暂未启用 Agora,未来接入时把 3 个 stub 展开即可,注册点不变)
|
||
- `createRoom` stub → `cb("createRoom")`
|
||
- `getVideoinfo` stub → `cb("getVideoinfo")`
|
||
- `exitRoom` stub → `cb("exitRoom")`
|
||
- [x] **8.4** `Source/Resource/PhoneStateMonitor.swift`:CXCallObserver 监听通话状态(CallKit;不用废弃的 CTCallCenter)
|
||
- 来电响铃中(hasConnected=false, isOutgoing=false, hasEnded=false)或 已连接(hasConnected=true)→ "2"
|
||
- 挂断(hasEnded=true)→ "0"
|
||
- 仅 SubGameViewController.setupExternalSubscriptions 挂载 + teardown 时 stop(大厅不订阅)
|
||
- 反向 callback `bridge.call("phonestate", data:.string(state))`
|
||
- [ ] **8.5** `RecordUploader.onUploaded` 已在 Phase 3,需要在 SubGameHandlers 里**额外**触发 `recordSuccess({fileUrl, fileName, fileKey})`(契约 §3.2 表 [15])
|
||
- [ ] **8.6** `AgoraVideoRoom.swift` 留蓝图骨架(`#if AGORA_ENABLED` 包裹,默认 OFF)
|
||
|
||
#### 验收
|
||
|
||
- 契约 §10 D 节中:
|
||
- `createRoom` / `exitRoom` H5 调用不报 "bridge not found"
|
||
- 子游戏接电话 → H5 收 `phonestate("2")` / `phonestate("0")`
|
||
- 子游戏录音上传 → H5 收 `getaudiourl` **AND** `recordSuccess`(双 callback)
|
||
|
||
#### 风险
|
||
|
||
- `CXCallObserver` 在模拟器无效,必须真机测
|
||
- 视频房间真要启用时再走 §8.6 的 AgoraVideoRoom 实现路径
|
||
|
||
---
|
||
|
||
### Phase 9: SDK 真实化 + 监控接入
|
||
|
||
#### 目标
|
||
首版 SDK 集成定型:接入 Sentry 崩溃监控;极光 / 闲聊 / Agora 维持 Noop 占位(编译开关 OFF);Bugly 不集成。
|
||
|
||
#### 前置
|
||
- Phase 1–8 完成
|
||
|
||
#### 任务清单
|
||
|
||
- [ ] **9.1** Sentry-Cocoa SPM 依赖 + `Source/Analytics/SentryCrashReporter.swift`
|
||
- 编译开关 `SENTRY_ENABLED`(默认 ON)+ `NoopCrashReporter` fallback
|
||
- DSN 通过 xcconfig 注入,不入 git
|
||
- [x] **9.2** `Source/Bridge/H5ErrorRelay.swift`:WKUserScript .atDocumentStart 注入 hook,拦截 `console.error` / `window.onerror` / `unhandledrejection`,通过独立的 `webkit.messageHandlers.h5error` 桥(与 WVJB 不同 channel)转发到原生 print。仅 BridgedWebView 安装(大厅+子游戏),Overlay 第三方外链不挂。零修改 H5。
|
||
- [x] **9.3** 极光(JAnalytics):**不集成**(ADR-005 决策 + msext CLAUDE.md 同款实践:不主动现代化、Bugly 都已废弃没人看数据;极光同样无业务诉求)。`Source/Analytics/Tracker.swift` 协议 / `NoopAnalytics` 当前**未建**(YAGNI:H5 业务不调埋点 handler,建空协议反而 misleading;未来真要启用时再走 Vendor → 抽 Tracker 协议 → Noop fallback 的标准路径)
|
||
- [x] **9.4** 闲聊:**不集成 SDK**(msext 时代闲聊 SDK 已停更,业务方未启用)。SharePanel 三按钮仅微信 / QQ / 抖音,不含闲聊;DouyinShare / QQShare / WechatShare 已实现,闲聊连 stub 都不需要
|
||
- [x] **9.5** Agora:**不接入**(用户明确视频房暂不需要)。VideoRoomHandlers stub 已满足契约(createRoom / getVideoinfo / exitRoom 三 handler 不报 "not found");未来重启视频时按 Phase 8.6 蓝图展开
|
||
- [x] **9.6** Bugly:**不集成**(msext CLAUDE.md 已明示「Bugly 后台账号已废弃 / 不再续费 / 没人看数据」;新外壳沿用此决策,避免重蹈覆辙)。崩溃监控完全依赖 Phase 9.1 Sentry(如启用)+ 用户/运营反馈链
|
||
|
||
#### 验收
|
||
|
||
- 强制崩溃 → Sentry 后台收到 issue
|
||
- H5 内 `throw new Error()` → Sentry 看到 H5 错误 + `container_role` 标签
|
||
- `Tracker` 协议存在且默认绑到 `NoopAnalytics`,业务调用 `Tracker.event(...)` 不崩、不阻塞
|
||
|
||
#### 风险
|
||
|
||
- Sentry 账号必须 ≥ 2 人持有访问凭证(避免重蹈 Bugly 覆辙)
|
||
- 首版无用户统计,业务侧若想看激活 / 留存只能等启用 JAnalytics 或自建埋点
|
||
|
||
---
|
||
|
||
### Phase 10: 多渠道 + 真机回归 + 灰度
|
||
|
||
#### 目标
|
||
完成打包脚本 + CI 多渠道流水线 + 真机覆盖回归 + 内部灰度切流。
|
||
|
||
#### 前置
|
||
- Phase 1–9 完成
|
||
|
||
#### 任务清单
|
||
|
||
##### 10.A 多渠道打包
|
||
|
||
- [ ] **10.1** `Scripts/inject_channel.sh`:按 Design §7.4 实现
|
||
- [ ] **10.2** `Scripts/archive.sh`:xcodebuild archive + exportArchive 流水线
|
||
- [ ] **10.3** `Scripts/release.sh`:循环渠道列表,逐个出包到 `dist/`
|
||
- [ ] **10.4** 渠道环境文件 `channels/*.env` 模板
|
||
|
||
##### 10.B CI
|
||
|
||
- [ ] **10.5** CI 加 archive smoke test(至少一个渠道能出 ipa)
|
||
- [ ] **10.6** 契约测试纳入 CI 必跑
|
||
|
||
##### 10.C 真机回归
|
||
|
||
- [ ] **10.7** 按契约 §10 26 项清单逐项真机过:
|
||
- A 节 启动(4 项)
|
||
- B 节 桥接(12 项)
|
||
- C 节 系统事件(4 项)
|
||
- D 节 视频房间(4 项,stub 模式下只验 phonestate / recordSuccess / createRoom 不报错)
|
||
- E 节 回归(3 项 + 截图分享)
|
||
- [ ] **10.8** 覆盖设备矩阵:iPhone(最新 + 一台老款)/ iPad / iOS 15.6 / iOS 26.x
|
||
|
||
##### 10.D 灰度
|
||
|
||
- [ ] **10.9** 与 msext 旧外壳并存(Bundle ID 已分离),内部 50 人手动覆盖装
|
||
- [ ] **10.10** 监控 Sentry crash-free rate ≥ 99.5%、用户反馈
|
||
- [ ] **10.11** 50 → 100 → 全量切流(按渠道,小渠道先切)
|
||
|
||
#### 验收
|
||
|
||
- 契约 §10 全 26 项真机通过
|
||
- 全量灰度内零回退要求
|
||
- CI 多渠道出包稳定
|
||
|
||
#### 风险
|
||
|
||
- 真机覆盖不全:单人测试时间有限,必要时项目方协调测试人力
|
||
- 灰度期间发现 P0 → 必须能 24h 内热修(设计 Sentry breadcrumb + 远程开关)
|
||
|
||
---
|
||
|
||
## 6. 横向工作流(贯穿全程)
|
||
|
||
### 6.1 SDK 集成节奏
|
||
|
||
| 集成方式 | SDK | 集成 Phase |
|
||
|---------|-----|----------|
|
||
| **SPM 优先** | ZIPFoundation / Sentry / 七牛 v8.9.x(`https://github.com/qiniu/objc-sdk`) | Phase 1 / 9 / 3 |
|
||
| **Vendor `.xcframework`** | 微信 / QQ / **高德 AMap**(ADR-006);闲聊 / Agora / JAnalytics 均暂不集成(Noop 占位) | Phase 4 / 5 |
|
||
| **Vendor `.a`** | opencore-amr | Phase 3 |
|
||
| ~~CocoaPods~~ | **不引入**(ADR-006,纯 SPM + Vendor 两层管理) | — |
|
||
|
||
每集成一个 SDK 立即提交一个独立 commit(CLAUDE.md "及时提交" 规则)。
|
||
|
||
### 6.2 测试节奏
|
||
|
||
- **Phase 1 起每 Phase 必须有契约测试用例**纳入 `ylgamehallContractTests`
|
||
- 单元测试覆盖率目标:BridgeCore ≥ 90%、ResourceKit ≥ 85%、其余 Kit ≥ 70%(Design §12.1)
|
||
- UI 集成测在 Phase 10 完成 26 项验收清单的 XCUI 自动化
|
||
|
||
### 6.3 监控接入
|
||
|
||
- Sentry 默认 ON 但可关(编译开关 `SENTRY_ENABLED`)
|
||
- DSN / 七牛 token 接口 URL / 微信 AppID 等敏感配置走 xcconfig + `.gitignore`
|
||
- H5 错误回流通道(`reportH5Error` 桥 handler)在 Phase 9 接入
|
||
|
||
### 6.4 文档同步
|
||
|
||
- 每个 Phase 结束更新本文档 §8 进度追踪
|
||
- 桥接 handler 任何变动必须同步更新 Contract(Design §18.6)
|
||
- Phase 完成时如发现 Contract 描述与实测不符 → 优先修 Contract(事实为准)
|
||
|
||
#### Design 接口骨架覆盖里程碑
|
||
|
||
| 日期 | 完成 | 涉及 commit |
|
||
|------|------|-------------|
|
||
| 2026-06-22 | Phase 1 闭环(1.10–1.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 同步 | `4986f36` |
|
||
| 2026-06-22 | **Phase 2.C ExternalSubscriptions 生命周期管理**:setup/teardown 配对挂 viewWillAppear/viewWillDisappear,Phase 6 子游戏 push 时防双发 | `09f071e` |
|
||
| 2026-06-22 | 新增 `docs/Verification-Checklist.md` 功能验证清单:累积各 Phase 验证步骤、不在 Phase 进行中频繁验证、完成后统一跑。与 Contract §10 契约边界保持独立 | `b2ee1b3` |
|
||
| 2026-06-22 | Phase 3.A srcIsloop 本地音频 + 3.B/C/D RemoteAudio stub。AudioPlayer @MainActor 实现 playOnce/loopBackground/stopBackground 三方法与 msext 等价 | `0602b9d` |
|
||
| 2026-06-22 | **Phase 4.A 登录+分享 stub + QQ 改走 URL Scheme 不依 SDK**:AccreditLoginHandler + FriendsShareHandler stub;Plan §2.3 阻塞表去掉 QQ SDK | `aa80aec` |
|
||
| 2026-06-22 | **Phase 4 方向最终决策(A)**:grep msext NewRootVC.m / gameController.m 未发现 QQShareManager 引用 — Contract §3.1 [2] `sharetype` 取值仅"1"/"2"=微信 / "3"=闲聊无 QQ;msext 的 QQShareManager 只接原生 SharePanel(独立功能不接 H5 桥)。新外壳契约不变 → 不实现 QQ 分享 | `a6095ef`(**已被下一行撤销**) |
|
||
| 2026-06-22 | **Phase 4 方向再次纠正**:用户提示"原项目还有 QQ 分享、抖音分享"。深度调研发现 grep 范围之前不够 — msext `gameController.m:575-597`(不是 NewRootVC)显示 H5 调 `friendsShare(sharefriend=1)` 时原生**弹 SharePanel 三选一面板**(微信好友 / QQ / 抖音);Contract §3.1 [2]旧描述(sharetype=3 闲聊)已被 msext 新代码覆盖(sharetype 字段读了但不用)。QQ + 抖音都不依赖 SDK:QQ 走 URL Scheme(`QQShareManager.m:14` `__has_include` fallback),抖音走 URL Scheme + Photos(`DouyinShareManager.m` 全程无 SDK)。撤销 A 决策,新方向:Phase 4.2 SharePanel + 4.3 QQ URL Scheme + 4.4 抖音 URL Scheme | 本次 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 1–9 联调 | 联调期发现 handler 名 / 字段被旧版掩盖、真上线时反而暴露 | 上线前阶段(Phase 10 灰度前)强制由 H5 团队提供最新版替换并复跑契约 §10 全部 26 项;联调期发现的契约疑点立即对照现网 msext 与 H5 团队对齐 |
|
||
| 微信 SDK 审核失败 / 改 Bundle ID 引起 Universal Links 失效 | Phase 4 真机调试 | 微信回调收不到 | Bundle ID 注册微信开放平台 + 配置 Universal Links 流程独立做一次(项目方协调) |
|
||
| 后台 `/wechat/login` 接口延期 | Phase 4 上线时 | secret 仍驻 IPA | 接受临时 fallback(标 TODO),上线前必须切到后台 |
|
||
| 高德 APIKey 与 Bundle ID 绑定失败 | Phase 5 真机定位 | 定位全失败 | 项目方控制台重新申请 key(本项目独立 key) |
|
||
| Swift 6 严格并发产生大量 warning/error | Phase 2 起 | 进度延迟 | 已启用 Approachable Concurrency(渐进模式),逐 Phase 修复,不一开始就 Complete 模式 |
|
||
| WKWebView 内 `evaluateJavaScript` 在 Liquid Glass 时代 API 变化 | iOS 26+ 真机 | 桥消息派发失败 | DocumentationSearch 优先查最新 API,不假设知识截止前的写法 |
|
||
| 真机回归人力不足(单人 + AI) | Phase 10 | 漏测 → 上线翻车 | 把 26 项验收清单写成 XCUI 自动化(可重复跑),手测只覆盖体验类 |
|
||
| Sentry 账号长期无人维护变 Bugly 翻版 | Phase 9 上线后 | 崩溃监控形同虚设 | 接入时同步把账号责任写入团队 onboarding,季度回顾会上检查存活 |
|
||
| 灰度期发现 P0 但无热修通道 | Phase 10 灰度 | 用户体验受损 | 设计期就要预留远程配置开关(如:`Sentry breadcrumbs` 中查 root cause + 一个 plist 控制的 feature flag)|
|
||
|
||
---
|
||
|
||
## 8. 进度追踪
|
||
|
||
> 每完成一项就把 `[ ]` 改 `[x]`,并在 commit message 里附 Phase X.Y 编号方便 git log 检索。
|
||
|
||
### Phase 0 工程化基线
|
||
- [x] 0.0 M0 基础配置(横屏 / Swift 6 / 代码启动)
|
||
- [x] 0.0.1 `.gitignore` + xcuserdata 清理
|
||
- [ ] 0.1 SwiftLint
|
||
- [ ] 0.2 单测 target
|
||
- [ ] 0.3 契约测试 target
|
||
- [ ] 0.4 UI 测试 target
|
||
- [ ] 0.5 CI 流水线
|
||
- [ ] 0.6 SPM 包目录决策(暂单 target)
|
||
|
||
### Phase 1 最小垂直闭环(含远程配置 + zip 升级,ADR-008)
|
||
- [x] 1.1 ChannelConfig.plist 母包默认值(ADR-007)
|
||
- [x] 1.2 BundleConfig
|
||
- [x] 1.3 SandboxPaths
|
||
- [x] 1.4 ZIPFoundation SPM
|
||
- [x] 1.5 ResourceUnzipper
|
||
- [x] 1.6 BridgeProtocol
|
||
- [x] 1.7 BridgeBus
|
||
- [x] 1.8 WVJB JS 协议
|
||
- [x] 1.9 BridgedWebView
|
||
- [x] 1.10 RemoteConfigClient(actor,URLSession async + 重试 + cache-busting + 短文本 + FlexibleString)
|
||
- [x] 1.11 VersionResolver(单链 4 层 fallback:agent→game→channel→market,纯函数)
|
||
- [x] 1.12 LocalVersionReader(version.xml 解析 + ATS 全局放行)
|
||
- [x] 1.13 LobbyZipUpgrader(actor,原子 rename 升级;端到端实测 260→261)
|
||
- [x] 1.14.a AppIcon 替换 Xcode 默认空模板(iPad / 1024 缺失留 Phase 10 polish)
|
||
- [x] 1.14.b LaunchScreen 改启动图(素材逆时针旋转 90° → 物理 1136×640,scaleAspectFit + 黑底左右补边)
|
||
- [x] 1.14.c LobbyZipUpgrader 加 progress 回调(URLSessionDownloadDelegate,0.0…1.0)
|
||
- [x] 1.14.d WebContainerViewController(SplashOverlay + 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
|
||
- [x] 2.1 剪贴板(ClipboardHandler.swift)
|
||
- [x] 2.2 振动扩展(VibratorHandler.swift,Phase 1.15 顺带做完)
|
||
- [x] 2.3 摇一摇开关(ShakeHandler.swift + ShakeDetector)
|
||
- [x] 2.4 voicePlaying(VoicePlayingHandler.swift,全局开关由 RemoteAudioHandler 读)
|
||
- [x] 2.5 getphoneInfo(DeviceInfoHandler.swift,注意契约小写 i)
|
||
- [x] 2.6 browser(BrowserHandler.swift,UIApplication.open)
|
||
- [x] 2.7 opensaoma 空注册(OpenSaomaHandler.swift,cb-only stub 维持契约)
|
||
- [x] 2.8 DeviceInfo(DeviceInfoHandler.swift 与 2.5 合并实现)
|
||
- [x] 2.9 BatteryMonitor(Source/Resource/BatteryMonitor.swift,UIDevice batteryStateDidChange)
|
||
- [x] 2.10 NetworkMonitor(Source/Resource/NetworkMonitor.swift,NWPathMonitor)
|
||
- [x] 2.11 AppLifecycleObserver(Source/Resource/AppLifecycleObserver.swift,appservice "1"/"2")
|
||
- [x] 2.12 ShakeDetector(与 2.3 合并:UIResponder motionEnded → ShakeHandler)
|
||
- [x] 2.13 ExternalSubscriptions(WebContainerViewController + SubGameViewController 各自挂载/卸载)
|
||
|
||
### Phase 3 音频体系
|
||
- [x] 3.1 AudioPlayer(Source/Audio/AudioPlayer.swift,srcIsloop / stopBackground / volume 全路径)
|
||
- [ ] 3.2 本地音频真机验证
|
||
- [ ] 3.3 opencore-amr Vendor 接入(详见 docs/SDK-Integration-Guide.md §C,需手动)
|
||
- [ ] 3.4 VoiceCoder Swift wrapper
|
||
- [ ] 3.5 AudioRecorder + 麦克风权限(NSMicrophoneUsageDescription 已就位)
|
||
- [x] 3.6 七牛 SPM(objc-sdk 8.9.2)+ QiniuConfig + QiniuTokenSigner(CryptoKit HMAC-SHA1 自签)+ QiniuUploader actor(initWithConfiguration + defaultConfigurationV2)
|
||
- [ ] 3.7 prepareaudio handler
|
||
- [ ] 3.8 mediaTypeAudio handler
|
||
|
||
### Phase 4 分享 + 登录
|
||
- [x] 4.1 微信 SDK Vendor + URL Scheme(WechatOpenSDK-NoPay.xcframework 2.0.5 入库 Vendor/WechatSDK/;Info.plist CFBundleURLTypes + LSApplicationQueriesSchemes 3 项)
|
||
- ✗ ~~4.2 QQ SDK Vendor~~ **不需要**:QQ 走 URL Scheme,详见 §5 Phase 4.A 任务清单
|
||
- [x] 4.3 WeChatSDK register(Source/SDK/WeChat/WeChatSDK.swift;新 SDK 2.x 无 MMAPP flag 概念,registerApp + universalLink 即可)
|
||
- [x] 4.4 WeChatManager(state map + FIFO;Source/SDK/WeChat/WeChatManager.swift)
|
||
- [x] 4.5 SceneDelegate openURL(QQ 走 URL Scheme 不依赖 SDK,仅微信 WXApi.handleOpen)
|
||
- [x] 4.6 WeChatAuth(客户端直拼 sns/oauth2/access_token + sns/userinfo,沿用 msext 安全模型)
|
||
- [x] 4.7 accreditlogin → sharelogin(7 字段,Province 大写 P)
|
||
- [x] 4.8 SharePlatform 协议(Phase 4.B 完成)+ WechatShare(type=1 链接 done;type=2/3 见 4.11/4.12)
|
||
- [x] 4.9 ShareCenter(sharefriend=1 弹 SharePanel;=2 直接微信朋友圈;Phase 4.B 完成)
|
||
- [x] 4.10 friendsShare... handler(FriendsShareHandler.swift,sharesuccess 反向 callback)
|
||
- [ ] 4.11 截图分享(Phase 4.F,type=2)
|
||
- [ ] 4.12 远程图片分享(Phase 4.F,type=3)
|
||
|
||
### Phase 5 定位
|
||
- [x] 5.1 AMap Vendor 接入(高德官方不提供 XCFramework,回退为 fat `.framework`;M Mac 模拟器链接失败是接受的现状,详见 Vendor/AMap/README.md)
|
||
- [x] 5.2 定位权限文案(NSLocationWhenInUseUsageDescription)
|
||
- [x] 5.3 AMapWrapper 启动注册(updatePrivacyShow / updatePrivacyAgree 是 AMapLocationManager 类方法,apiKey 在 AMapServices.shared)
|
||
- [x] 5.4 LocationService actor(requestOnce 异步 + stop 清理钩子)
|
||
- [x] 5.5 startlocation handler(latitude/longitude string,province 小写 p)
|
||
- [x] 5.6 失败回包 errorCode 12(缺少定位权限)
|
||
|
||
### Phase 6 子游戏
|
||
- [x] 6.1 AppCoordinator 栈深节流(2s + viewControllers.count < 2)
|
||
- [x] 6.2 SubGameViewController 框架(commit B 接入 SubGameDownloader 后真实下载/缓存命中)
|
||
- [x] 6.3 子游戏 handler 拆散注册(含 BackGameDataHandler;视频房间留 Phase 8)
|
||
- [x] 6.4 SwitchOverGameData → AppCoordinator.showSubGame
|
||
- [x] 6.5 backgameData(停 audio + popSubGame + JSON 序列化兼容)
|
||
- [x] 6.6 getWebdata 通知链(.subGameDidReturn 观察者挂在 ExternalSubscriptions 生命周期)
|
||
- [x] 6.7 SubGameDownloader(actor,URLSession + ZIPFoundation + staging + 原子 rename)
|
||
|
||
### Phase 7 弹层
|
||
- [x] 7.1 OverlayViewController + 私有 dataStore(.nonPersistent)
|
||
- [x] 7.2 window.settings polyfill(.atDocumentEnd 注入)
|
||
- [x] 7.3 3 个 WKScriptMessageHandler(ScriptMessageProxy 切断 retain cycle)
|
||
- [x] 7.4 OpenurlTitleData handler("title " 末尾空格 + 3s 节流由 Coordinator 守门)
|
||
- [x] 7.5 Overlay backgameData(→ popOverlay 发 .subGameDidReturn → 父层 getWebdata)
|
||
- [x] 额外 SubGameViewController 也订阅 .subGameDidReturn(覆盖 overlay→sub-game 返回)
|
||
- [x] 7.6 Overlay finishweb(OverlayViewController.handleMessage `.finishweb` → popOverlay nil)
|
||
- [x] 7.7 Overlay browser(OverlayViewController.handleMessage `.browser` → UIApplication.open,URL 容错带 percent encoding)
|
||
|
||
### Phase 8 视频房间 stub
|
||
- [x] 8.1/8.2/8.3 视频房间 3 件套 stub(VideoRoomHandlers.swift,业务暂未启用 Agora,未来重启时把 3 个 stub 展开即可,注册点不变;不单独抽 protocol/Noop 类避免过度设计)
|
||
- [x] 8.4 PhoneStateMonitor → phonestate(CXCallObserver;仅子游戏订阅)
|
||
- [ ] 8.5 recordSuccess 双 callback(依赖 Phase 3.C 录音上传完成;当前 prepareaudio 是 stub)
|
||
- [ ] 8.6 AgoraVideoRoom 蓝图(业务暂不需要视频,缓做;未来重启再写 #if AGORA_ENABLED 路径)
|
||
|
||
### Phase 9 SDK 真实化 + 监控
|
||
- [ ] 9.1 Sentry SPM + CrashReporter
|
||
- [x] 9.2 H5ErrorRelay(独立 webkit.messageHandlers.h5error,BridgedWebView 安装,Overlay 不挂)
|
||
- [x] 9.3 极光 JAnalytics 不集成(YAGNI;未来启用时按 Vendor → 抽协议 → Noop 流程)
|
||
- [x] 9.4 闲聊不集成 SDK(SharePanel 已只含微信/QQ/抖音三按钮)
|
||
- [x] 9.5 Agora 不接入(VideoRoomHandlers stub 已满足契约;未来重启按 8.6 蓝图)
|
||
- [x] 9.6 Bugly 不集成(沿用 msext CLAUDE.md「账号已废弃」决策)
|
||
|
||
### Phase 10 多渠道 + 回归 + 灰度
|
||
- [ ] 10.1 inject_channel.sh
|
||
- [ ] 10.2 archive.sh
|
||
- [ ] 10.3 release.sh
|
||
- [ ] 10.4 渠道环境文件
|
||
- [ ] 10.5 CI archive smoke
|
||
- [ ] 10.6 契约测试入 CI
|
||
- [ ] 10.7 26 项验收清单真机过
|
||
- [ ] 10.8 设备矩阵覆盖
|
||
- [ ] 10.9 内部 50 人灰度
|
||
- [ ] 10.10 Sentry 监控
|
||
- [ ] 10.11 全量切流
|
||
|
||
---
|
||
|
||
## 9. 决策记录(ADR-lite)
|
||
|
||
> 关键技术 / 工程决策的简短记录,便于回溯"为什么这样选"。新增决策追加到本节末尾。
|
||
|
||
### ADR-001:单 target 起步,暂不切 SPM(2026-06-21)
|
||
- **决策**:Phase 0–2 阶段保持单 target,按目录组织(`Source/Bridge`、`Source/Resource` 等);待代码量 > 5K 行(约 Phase 3 完成时)再评估切 SPM
|
||
- **理由**:早期 SPM 切分成本(target 间循环依赖排查、增量编译配置、测试 target 接入)会拖慢核心垂直闭环验证;Design §2.2 也注明"目标稳态 8-10 个 target,以日常开发体验为先"
|
||
- **回滚条件**:单 target 编译时间 > 30s 时切 SPM
|
||
|
||
### ADR-002:使用已就位的 2023-12 旧版 gamehall.zip 起步(2026-06-21,原方案"H5 demo 先行"已作废)
|
||
- **背景**:私人素材池 `docs/res/gamehall.zip` 已存在(旧版,2023-12,11.5 MB),无需等 H5 团队最新版即可启动 Phase 1
|
||
- **决策**:Phase 1 实施时从 `docs/res/gamehall.zip` 拷贝到 `ylgamehall/Resources/gamehall.zip` 入 Bundle,跑通"渠道注入 → 解压 → 加载 H5 → 桥消息"全链路;旧 H5 业务代码与新版的契约差异不影响"桥本身是否工作"的验证
|
||
- **理由**:
|
||
- 桥接口名 / 参数字段名 / JS 协议属于契约范畴,按 Contract 1:1 实现即可,不依赖 H5 业务代码新旧
|
||
- 提早用真 H5 跑通比 demo H5 更能暴露真实问题(如 WVJB 握手时序、JS 注入文件路径等)
|
||
- **风险**:联调期遇到的契约疑点可能来自旧版 H5 已废弃接口,需对照现网 msext + H5 团队双确认
|
||
- **切换条件**:Phase 10 灰度前必须由 H5 团队提供最新版替换 `ylgamehall/Resources/gamehall.zip` 并复跑契约 §10 全部 26 项
|
||
|
||
### ADR-003:微信登录走后台中转 vs 客户端 fallback(2026-06-21)
|
||
- **决策**:默认走后台 `/wechat/login` 中转;后台未就绪时临时客户端直拼,但代码标 `// FIXME` 且**禁止以 fallback 状态上线**
|
||
- **理由**:客户端 secret 一旦进 IPA 永久泄露 + AppSecret 不可重置(Contract §0.4 / Design §17);上线前必须闭环
|
||
- **复盘节点**:Phase 4 完成时检查后台接口状态
|
||
|
||
### ADR-004:项目资源目录结构由各 Phase 按需落地,不预先建仓库根 Resources/(2026-06-21 修订)
|
||
- **背景**:原 ADR-004(2026-06-21 初版)决议"项目资源统一放仓库根 `Resources/` + 打包脚本 Build Phase 引入",并将 `gamehall.zip` / `Images.xcassets` / `Res/` 入库到 `Resources/`。但 `docs/res/` 后被维护者重新定义为**私人原始素材池**(不被项目感知,详见 CLAUDE.md),原 `Resources/` 入库的素材被搬走,使该 ADR 的语义崩塌
|
||
- **修订决策**:废止"仓库根 `Resources/`"的固定结构假设,**项目内资源目录由各 Phase 实施时按需落地**:
|
||
- 静态 Bundle 资源 → `ylgamehall/Resources/`(synchronized group 自动收集,含 `gamehall.zip` / `ChannelConfig.plist` 等)
|
||
- 原生 Asset Catalog → `ylgamehall/Assets.xcassets/`
|
||
- 渠道注入值 → `ylgamehall/Resources/ChannelConfig.plist`(10 个 string key,母包默认值;ADR-007、ADR-009)
|
||
- 应用级凭证 → `ylgamehall/Resources/AppSecrets.plist`(3 个 string key:wxAppSecret / qiniuAccessKey / qiniuSecretKey;ADR-009)
|
||
- 闭源 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-005:JAnalytics(极光)首版降为 NoopAnalytics 占位(2026-06-21)
|
||
- **背景**:Design §14.2 原标注极光"✅ 启用,启动注册",但项目方在 2026-06-21 评审时确认**首版无用户激活 / 留存分析需求**
|
||
- **决策**:与 Xianliao / Agora 同款渐进集成策略——首版不集成极光 SDK,`AnalyticsKit.tracker` 默认绑到 `NoopAnalytics`,业务调用 `Tracker.event(...)` 立即返回不做任何事;编译开关 `JANALYTICS_ENABLED` 默认 OFF,留蓝图 `JAnalyticsTracker.swift` 待启用
|
||
- **理由**:
|
||
- 减少首版外部依赖(首版 SDK 集成清单:微信 / QQ / 高德 / 七牛 / opencore-amr / Sentry / ZIPFoundation,共 7 个;不集成 4 个:极光 / 闲聊 / Agora / Bugly)
|
||
- 现网 msext 的极光后端运维状态不可知,与 Bugly 同样有"账号断档监控失效"风险(CLAUDE.md / 父级 msext 已有先例),新外壳起步不重蹈
|
||
- Sentry 的 breadcrumb / transaction 可兜一部分用户行为路径,初期定位够用
|
||
- 接入成本(AppKey 申请 / Vendor 二进制 / Info.plist 隐私文案)推迟到真有业务需求时
|
||
- **后续启用路径**(保持契约不变,业务无感):
|
||
1. 在 `Vendor/` 放入 `JAnalytics.framework`
|
||
2. 把 `AnalyticsKit.tracker` 从 `NoopAnalytics()` 切换为 `JAnalyticsTracker()`
|
||
3. xcconfig 注入 `JANALYTICS_ENABLED=1`
|
||
4. Bootstrapper.registerSDKs 加 `JAnalytics.setup(appKey:)`
|
||
5. Info.plist 加 AppKey + 隐私授权文案
|
||
- **复盘节点**:上线 3 个月后业务方根据数据需求决定是否启用
|
||
|
||
### ADR-006:纯 SPM + Vendor `.xcframework` 两层依赖管理,不引入 CocoaPods(2026-06-21)
|
||
- **背景**:Design 原方案是"SPM 优先 + CocoaPods 兜底 + Vendor 手动"三层策略,CocoaPods 仅服务于高德 AMap 定位 SDK 一个依赖
|
||
- **触发事件**:尝试 `pod init` 时,CocoaPods 1.15.2(Homebrew 上的最新版)自带的 `xcodeproj` gem 1.24.0 不识别 Xcode 26.5 默认的 `PBXFileSystemSynchronizedRootGroup`,pod init 直接抛 `unknown ISA` 异常
|
||
- **调研结论**:
|
||
- 七牛 SDK 已官方支持 SPM(`https://github.com/qiniu/objc-sdk`,v8.9.x),原计划的"七牛走 CocoaPods"完全无必要
|
||
- 高德 AMap 定位 SDK 官方未提供 SPM(截至 2026-06),但提供 XCFramework 直接下载
|
||
- CocoaPods 1.16+ 才支持新 ISA,但 Homebrew 未跟进;需绕道 Bundler + Gemfile / brew --HEAD / rbenv 自管 Ruby,均增加工具链复杂度
|
||
- **决策**:依赖管理简化为**两层 — SPM 优先 + Vendor `.xcframework` 手动**,**完全不引入 CocoaPods**
|
||
- SPM:ZIPFoundation / Sentry / Qiniu / WVJB JS(资源文件)
|
||
- Vendor:微信 / QQ / **高德 AMap** / opencore-amr;闲聊 / Agora / JAnalytics 暂不集成(Noop 占位)
|
||
- **理由**:
|
||
- 项目实际只有高德 AMap 1 个 SDK 让 CocoaPods 必要;为 1 个依赖引入整套 Ruby + Bundler + CocoaPods + workspace 工具链 + Xcode 26 兼容性维护,性价比低
|
||
- 闭源 SDK 本就是 Vendor 性质(微信 / QQ / 闲聊 / JAnalytics / opencore-amr),AMap 走同款流程不增加心智负担
|
||
- 生态趋势:Google 已宣布 2026 Q2 后 iOS SDK 全部停止 CocoaPods 支持;CocoaPods 维护活跃度下降
|
||
- 工程入口仍是 `.xcodeproj`(不需要 `.xcworkspace`),团队 / CI 配置不需要切换
|
||
- **接入标准流程**(Vendor,写入 Design §14.1):
|
||
1. 二进制放 `Vendor/<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. 即使关掉 sandbox,Run Script 在没有 Input/Output 声明 + 默认勾选"Based on dependency analysis"时,被 Xcode 视为"无依赖故无需运行",clean build 也不跑
|
||
- 综合工程成本估算:为保留"目录名编码"机制,需引入额外 Build Phase / 关闭 sandbox / 维护 Input/Output 列表,**全是 Xcode 行为兼容性维护,与项目目标无关**
|
||
- **决策**:渠道注入存储改用**单一 `ylgamehall/Resources/ChannelConfig.plist`** 含 10 个 string key(2026-06-22 初版 11 个,2026-06-27 移除 `qiniudomain`,详见 ADR-009)
|
||
- 存储介质:plist(iOS 原生)
|
||
- Bundle 加载:synchronized group 自动收集,零配置
|
||
- 运行时读取:`BundleConfig.init(bundle:)` 用 `PropertyListSerialization` 反序列化
|
||
- **理由**:
|
||
- **契约 100% 等价**:H5 端通过 `app_data.js` 看到的 JS 全局变量行为完全不变(契约边界在 `BundleConfig.shared.xxx`,与底层存储无关)
|
||
- **CLAUDE.md 原则 B 落地**:原生内部自由重构,不要照搬旧项目;msext 那套是 iOS 9 / Xcode 14 时代的 hack,Xcode 26 + Swift 6 应当用更现代的存储
|
||
- **IPA 后处理多渠道分发完全等价**:
|
||
```bash
|
||
plutil -replace channel -string "<新渠道 ID>" ChannelConfig.plist
|
||
plutil -replace market -string "<新市场 ID>" ChannelConfig.plist
|
||
codesign --force --sign "$IDENTITY" --entitlements "$ENT" <app>.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` 接口(公开属性,当前 10 个)保持稳定,仅 init 实现切换
|
||
- **守护**:
|
||
- **`ChannelConfig.plist` 必须保持 10 个 key 完整且类型为 string**(gameid/channel/gamedir/gamestart/gameconfig/market/agent/appversion/other/appleconfig);新增 / 移除 key 视同契约边界变更,需更新 Design / Plan / Contract(如该值被 H5 通过 app_data.js 暴露)
|
||
- 跨渠道相同的应用凭证(微信 AppSecret、七牛 AccessKey/SecretKey)放 `AppSecrets.plist`,不进 ChannelConfig(ADR-009)
|
||
- 七牛 CDN 域名、bucket 名走 RemoteConfig 顶层 `audio_domain` / `audio_bucket` 远端注入,不在任何本地 plist(ADR-009)
|
||
- 后处理工具必须**修改 plist 后立即重签**,否则 iOS 拒绝安装
|
||
- 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道)
|
||
- **回滚条件**:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质
|
||
|
||
### ADR-008:Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22 起,含 3 次调研 revise)
|
||
- **背景**:Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5",**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置 → 单链 4 层 fallback 解析 → 与本地版本比较 → 必要时下载并替换 H5 zip
|
||
- **触发事件**:2026-06-22 起多轮调研 `daoqi/msext` 原项目,逐行核对核心方法:
|
||
- 第一轮(粗粒度,2026-06-22):发现远端配置流程整体形态、IPA / H5 zip 两条升级链
|
||
- 第二轮(2026-06-22,**已废弃**):误读 `onnet` 的 `else` 分支 + `chulishengji`(`NewRootVC.m:1372-1538`),错误地把双子树合并当作线上热路径
|
||
- **第三轮(2026-06-27,最终采用)**:项目维护者直接指出契约真实结构是单链 4 层 `agentlist → gamelist → channellist → marketlist`。重新核对 `NewRootVC.m`,确认 `onnet` 的 `if(self.gamelist==nil)` **顶层分支**(`NewRootVC.m:689-810`)= 线上热路径就是这条单链;`else` 分支的 chulishengji 是历史遗留代码、主路径不走
|
||
- **核心算法(第三轮最终版)**:
|
||
- 单链 4 层节点匹配:`agentlist[agentid] → gamelist[gameid] → channellist[channelid] → marketlist[marketid]`
|
||
- 任一层 id 匹配失败立刻截断(短链);最长 0..4 节点
|
||
- 字段语义:每个字段从最深层向根回退取第一个非空值(等价 msext `if(xxx!="" && xxx!=nil) self.xxx=xxx` 逐层覆盖的最终态)
|
||
- 5 字段(appVersion / appDownload / gameVersion / gameZip / showmessage)**共用同一套** `pickString` / `pickInt` 倒序查找接口,不再有"双子树 / 多分支合并"
|
||
- **第二轮误读复盘(保留以防再被翻出来)**:
|
||
- 误读点:`viewWillAppear`(`NewRootVC.m:1609-1616`)与 `gonet`(`NewRootVC.m:1252-1258`)的 `self.gamelist` 同样取自 agent 节点下 `gamelist`,**但流程结构上先走 `onnet` 的 `if(self.gamelist==nil)` 分支命中后立即 return**,不会再进 else 的 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` 按单链 4 层 fallback 实现(第三轮)**
|
||
|
||
#### ADR-008-A 远端 URL 构造(`RemoteConfigClient`)
|
||
|
||
- 基础 URL:`"https://" + BundleConfig.gameConfig.replacingOccurrences("-", "/") + ".txt"`
|
||
- 新外壳改 HTTPS(msext 用 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 单链 4 层 fallback 算法(`VersionResolver`)
|
||
|
||
> **修订史**:2026-06-22 初版误指为 chulishengji 双子树合并;2026-06-27 第三轮按项目维护者澄清推翻,改为本节描述的单链 4 层 fallback。下文以最终版本为准;第二轮内容已删除,复盘说明见 ADR-008 顶部"第二轮误读复盘"。
|
||
|
||
新外壳 `VersionResolver.resolve()` 1:1 复刻 `NewRootVC.m:689-810` 主路径,输出 5 个目标字段:`(appVersion, appDownload, gameVersion, gameZip, showmessage)`。
|
||
|
||
**契约结构**:
|
||
|
||
```
|
||
agentlist[agentid]
|
||
└─ gamelist[gameid]
|
||
└─ channellist[channelid]
|
||
└─ marketlist[marketid]
|
||
```
|
||
|
||
每一层节点都可独立携带 `app_version / app_download / game_version / game_zip / showmessage` 字段(缺省视为空);越深的层覆盖越浅的层;越深的层缺省时回退到上一层。
|
||
|
||
**步骤**:
|
||
|
||
```swift
|
||
// 1. 顺链匹配,返回 0..4 个有序节点
|
||
// 任一层匹配失败立刻截断(之后不再尝试更深层)
|
||
let chain: [RemoteConfigNode] = buildChain(
|
||
config, agentId, gameId, channelId, marketId
|
||
)
|
||
// 例:agent 命中、game 未命中 → chain = [agent]
|
||
|
||
// 2. 5 字段共用同一套倒序 fallback 接口
|
||
// 从最深层向根回退取第一个非空(等价 msext "下层非空即覆盖上层"的最终态)
|
||
ResolvedVersion(
|
||
appVersion: pickInt(chain, \.appVersion),
|
||
appDownload: pickString(chain, \.appDownload),
|
||
gameVersion: pickInt(chain, \.gameVersion),
|
||
gameZip: pickString(chain, \.gameZip),
|
||
showmessage: pickString(chain, \.showmessage) ?? config.showmessage
|
||
)
|
||
```
|
||
|
||
**统一接口要求(必须保持)**:
|
||
- 4 层节点(Agent / Game / Channel / Market)conform 同一 `RemoteConfigNode` 协议,提供相同字段 getter
|
||
- 字段查找只有 `pickString` / `pickInt` 两个工具,5 字段全走它们 —— 不允许出现"为某个字段写专门 if 链"的多处实现
|
||
- `pickString` 跳过 nil / 空字符串;`pickInt` 跳过 nil / 解析失败 / 0(msext `[nil intValue] = 0` 行为兼容)
|
||
|
||
**与 msext 实际代码的关系**:
|
||
- msext 顺嵌套 for 循环里"非空即覆盖"`self.xxx = xxx` 的写法 —— **最终态等价**于从根向叶遍历后取最深一处非空值,再等价于从叶向根 fallback 取第一个非空值。三种写法语义一致,新外壳选第三种因为:(1) 5 字段共用同一接口最简洁;(2) `buildChain` 集中处理"任一层失败截断"语义;(3) 单测易写
|
||
- 不复刻 msext 那段把 `showmessage` 散在 5 处覆盖的写法 —— 用 `pickString(\.showmessage)` 一处搞定,最终态相同
|
||
|
||
#### ADR-008-E 决策顺序(`WebContainer` 编排)
|
||
|
||
5 字段拿到后**固定顺序**判断(msext 单链主路径在 `NewRootVC.m:768-793`):
|
||
|
||
| 优先级 | 条件 | 动作 |
|
||
|---|---|---|
|
||
| 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 LocalVersionReader(1.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 LobbyZipUpgrader(1.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:)` async,actor 隔离 |
|
||
| 重试 | 4s timer 无上限 | 1.10 初版用 3 次退避;可改为 msext 风格无上限 |
|
||
| URL | HTTP | HTTPS(后台已切) |
|
||
| cache-busting | `?vXXXXXXXX_YYYYYYYY` | **保留** |
|
||
| JSON 解析 | SBJSON | Codable + JSONDecoder + 自定义 `decodeFlexibleStringIfPresent`(兼容 number-as-string) |
|
||
| 算法 | onnet 单链 4 层(`NewRootVC.m:689-810`) | 单链 4 层 + 协议化 fallback,5 字段共用同一查找接口,纯函数 + 单测 |
|
||
| 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` 路径)
|
||
|
||
---
|
||
|
||
### ADR-009:凭证 / 七牛运行参数三分制(2026-06-27)
|
||
|
||
- **背景**:本次修订前,微信 AppID/Secret + 七牛 AccessKey/SecretKey/bucket/CDN 域名分散在 3 处:
|
||
- Swift 源码硬编码:`WeChatSDK.swift`(AppID) / `WeChatAuth.swift`(AppSecret) / `QiniuConfig.swift`(AccessKey/SecretKey/bucket/CDN)
|
||
- `Info.plist` 的 CFBundleURLTypes:微信 AppID 又出现一次(iOS 系统级 URL Scheme,必须在 Info.plist)
|
||
- `ChannelConfig.plist` 的 `qiniudomain`:CDN 域名又一份,但 Swift 代码完全没读取(双源僵尸字段)
|
||
- **问题**:无单一真相源 → 多渠道分发 / 后台改后台时容易遗漏某处;CDN 域名出现"plist 改了但代码用硬编码"的潜在 bug
|
||
- **决策(三分制)**:
|
||
| 数据维度 | 唯一权威源 | 理由 |
|
||
|---|---|---|
|
||
| 微信 AppID | `Info.plist` 的 CFBundleURLTypes (URLName=weixin 首个 scheme) | iOS 系统级 URL Scheme 注册,运行期不可注入,Info.plist 是事实唯一可写位置 |
|
||
| 应用级凭证(微信 AppSecret、七牛 AccessKey、七牛 SecretKey) | `AppSecrets.plist` | 跨渠道相同的应用全局凭证;与渠道差异化字段语义分离 |
|
||
| 七牛 CDN 域名、bucket 名 | RemoteConfig 顶层 / 4 层节点任一处 `audio_domain` / `audio_bucket`(4 层 fallback + 顶层兜底) | 后台运维管理,按渠道差异化下发 |
|
||
- **远端注入时序**:`WebContainerViewController` 在 `parsed` 分支(即 RemoteConfig 拉到、IPA 校验前)调 `VersionResolver.resolveAudio(...)` 解析(与版本字段同款 4 层 fallback:agent → game → channel → market 倒序找第一个非空,整链空时 fallback 到顶层 `audio_domain` / `audio_bucket`),再 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 注入;缺失抛 `BootError.audioConfigMissing`,与 showmessage 同等致命,弹 modal 永停
|
||
- **2026-06-27 修订**:初版 `audio_domain` / `audio_bucket` 只从 RemoteConfig 顶层读取,实测渠道方习惯把这俩字段放 agent 节点下 → 客户端读不到误报"音频服务暂不可用"。改为 4 层 fallback + 顶层兜底(同 `app_version` / `game_zip` 等版本字段的 fallback 算法),节点与顶层任一处声明即可
|
||
- **类型设计**:
|
||
- `AppSecrets`:与 `BundleConfig` 同款 `nonisolated public final class Sendable`,3 个不可变 String 属性
|
||
- `QiniuConfig`:从 `enum` 改为 `actor`,`accessKey`/`secretKey` 仍 nonisolated(直接读 `AppSecrets.shared`),`cdnDomain`/`bucketName` 进 actor 状态;`update(...)` / `publicURL(...)` async
|
||
- `QiniuTokenSigner.uploadToken()` 改 async(因 bucketName 来自 actor)
|
||
- `QiniuUploader.upload()` 预取 cdnDomain 一次(避免 SDK 同步 callback 内再 await)
|
||
- **删除清单**:
|
||
- `WeChatSDK.swift` `static let appID = "..."` 硬编码 → 改读 Info.plist
|
||
- `WeChatAuth.swift` `static let appSecret = "..."` → 改读 `AppSecrets.shared.wxAppSecret`
|
||
- `QiniuConfig.swift` `static let accessKey/secretKey/bucketName/cdnDomain = "..."` 四处硬编码
|
||
- `ChannelConfig.plist` `<key>qiniudomain</key>` 字段
|
||
- `BundleConfig.swift` `qiniuDomain` 属性(10 key 守护规则同步)
|
||
- **回滚条件**:若未来 `Info.plist` 不再允许动态读取 CFBundleURLTypes(极不可能),或 RemoteConfig 接口被替换为按渠道差异化下发(需要进 4 层 fallback),重新评估
|
||
|
||
---
|
||
|
||
文档完成日期:2026-06-21
|
||
最后更新:2026-06-27(ADR-009 凭证集中化 + AppSecrets.plist + 七牛运行参数远端注入 + ChannelConfig 11→10 key;同日 ADR-008 第三轮修订:VersionResolver 单链 4 层 fallback;前置历史 2026-06-22 ADR-008 二次精确化 / ADR-007 渠道注入改 ChannelConfig.plist / ADR-006 纯 SPM + Vendor / ADR-005 极光降级 / Resources 目录记录)
|