业务暂未启用视频功能,按运营确认仅维持桥契约,让 H5 子游戏调用不报 "no handler"。 - 新增 Source/Bridge/Handlers/VideoRoomHandlers.swift:单一 enum.register 3 个 stub, 各自 callback 字面回包名(与 OpenSaomaHandler 同款 stub 模式) - SubGameViewController.registerBridgeHandlers 挂上 VideoRoomHandlers - 不抽 VideoRoom protocol / NoopVideoRoom 类(违反"不为假设的未来需求设计"原则); 未来重新接入 Agora 时把 3 个 stub 展开实现即可,注册点不变 - Plan §5.6.3 / §5.8.1-8.3 进度已勾选 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
86 KiB
进贤聚友棋牌 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 已完成
- Xcode 工程骨架(File System Synchronized Group,objectVersion 77,Xcode 26.5)
- iOS Deployment Target 15.6
- Swift 6.0 +
SWIFT_APPROACHABLE_CONCURRENCY=YES+SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor - iPhone + iPad 仅横屏,
UIRequiresFullScreen=true - 删除默认 Storyboard,改用
SceneDelegate代码启动 →RootViewController(M0 占位) - Info.plist 契约前置项:
UIStatusBarHidden=NO/UIViewControllerBasedStatusBarAppearance=YES AppDelegate设置applicationSupportsShakeToEdit=true.gitignore(标准 iOS/Swift 模板,含签名 / 敏感配置 / xcuserdata)- CLAUDE.md 加入"及时提交"规则
- 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 | 项目方 |
.framework |
QQ 分享 | 不需要 SDK — msext QQShareManager.m:14 __has_include fallback 路径已有完整 URL Scheme 实现(mqqapi://share/to_fri?...),新外壳直接照搬即可。Info.plist 仅需加 LSApplicationQueriesSchemes(mqq / mqqapi / mqqopensdkfriend 等) |
— |
.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 | 后台团队 |
| 已决策暂不集成(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 新建
ylgamehallUITestsUI 测试 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 资源层
- 1.1 创建
ylgamehall/Resources/ChannelConfig.plist含 11 个渠道键值对(沿用 msext 现网 demo 作为母包默认值;ADR-007 决策) - 1.2 实现
ylgamehall/Source/Resource/BundleConfig.swiftinit(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 返回空串
- 1.3 实现
ylgamehall/Source/Resource/SandboxPaths.swift- 常量:
caches/documents/bundle lobbyIndex() -> URL拼出{Caches}/{gamedir}/{gamestart}/index.html
- 常量:
- 1.4 加 ZIPFoundation SPM 依赖
- 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 桥核心
- 1.6 实现
Source/Bridge/BridgeProtocol.swiftBridgeProtocol/BridgeData/BridgeHandler/BridgeCallback类型
- 1.7 实现
Source/Bridge/BridgeBus.swift(@MainActor)register(_:handler:)/call(_:data:callback:)/didReceive(_:)- 单测:mock WKWebView,验证 handler 注册 / 分发 / callback 配对
- 1.8 引入 WVJB JS 端协议源码
- 从 marcuswestin/WebViewJavascriptBridge 取
WebViewJavascriptBridge.js.txt,作为Resources/JS/WebViewJavascriptBridge.js - 注入方式:
WKUserScript,atDocumentStart
- 从 marcuswestin/WebViewJavascriptBridge 取
1.C WebView 容器(视图层 + 一个 handler)
- 1.9 实现
Source/WebView/BridgedWebView.swift- WKWebView 配置照 Contract §4.1(
javaScriptCanOpenWindowsAutomatically=NO、minimumFontSize=10、bounces=NO、scrollEnabled=NO等) WKProcessPool单例SharedProcessPool.shared
- WKWebView 配置照 Contract §4.1(
1.D 远程配置 + 版本对比 + zip 升级(Phase 1 关键缺口,ADR-008)
- 1.10 实现
ylgamehall/Source/Network/RemoteConfigClient.swift(actor)- URL 构造:
http://+BundleConfig.shared.gameConfig.replacingOccurrences("-", "/")+.txt(注意:不带SERVERNew前缀,原daoqi/NewRootVC.m:250就是这样构造) - 拉远端
.txt内容(实际是 JSON)→URLSession.async data(from:),10 s 超时 - 指数退避重试(msext 用 4s 等间隔暴力 timer,新外壳用 1s / 2s / 4s 三次)
- 反 JSON → 强类型
RemoteConfigCodable 模型(嵌套:agentlist[].channellist[].marketlist[]+agentlist[].gamelist[].channellist[].marketlist[]) - 单测:mock URLSession + fixture JSON 验证嵌套解析正确
- URL 构造:
- 1.11 实现
ylgamehall/Source/Network/VersionResolver.swift(纯函数 + 单测)- 输入:
RemoteConfig + (agentId, channelId, marketId, gameId)四组 ID - 输出:
ResolvedVersion { appVersion, appDownload, gameVersion, gameZip } - 算法:4 级覆盖合并 —— 顶层 → 当前 agent → 当前 channel → 当前 market(最深胜出);agent 子树和 game 子树分别合并,game 子树最终覆盖 agent 子树
- 参考
daoqi/NewRootVC.m:1372-1492的散落 if/else,新外壳实现成纯 reduce - 单测:覆盖所有边界(顶层有/无、深层缺失、agent vs game 优先级 4 组场景至少 12 用例)
- 输入:
- 1.12 实现
ylgamehall/Source/Resource/LocalVersionReader.swift(nonisolated namespace)localAppVersion: Int:从BundleConfig.shared.appVersion读,转 IntlocalGameVersion: Int:读Library/Caches/{gamedir}/{gamestart}/version.xml用XMLParser解析/game/version@value转 Int;缺失返回 0- 单测:fixture xml 验证解析;缺失 / 损坏 xml 返回 0
- 1.13 实现
ylgamehall/Source/Resource/LobbyZipUpgrader.swift(actor)upgradeIfNeeded(remote: ResolvedVersion) async throws -> UpgradeOutcome- 比较
remote.gameVersion > LocalVersionReader.localGameVersion→ 触发下载,否则直接返回.noop
- 比较
- 下载链路:
URLSession.download(from:)→ 临时文件tmp/lobbyzip-{uuid}.zip→ ZIPFoundation 解压到tmp/lobbyzip-{uuid}/→ 原子 rename 到lobbyRoot(解压前清空旧 lobbyRoot;不学 msext "目录名 +1" hack) - 失败处理:超时 / 网络断 / hash 校验失败 / 解压失败 → throw 类型化错误;调用方决定是否回退用旧 H5
- 单测:mock URLSession + fixture zip 验证升级路径与 noop 路径
1.E 集成 + 烟雾测试
- 1.14.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 时补齐 - 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 通过 - 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 通过
- 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 通过
- 持有
- 1.14.e WebView 加载完成后淡出 splash
webView(_:didFinish:)内UIView.animate(withDuration: 0.3)splash.alpha = 0 → completion 内splash.removeFromSuperview()- 期间 webView 已渲染好 H5 大厅,无白屏闪烁;splash 出 view 层级避免空持有
- BuildProject 通过
- 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 通过
- 无状态
- 1.16
SceneDelegate→WebContainerViewController作 rootViewController- 替换 M0 占位
RootViewController+ 删除ylgamehall/RootViewController.swift(M0 烟雾测试堆栈整体退役,所有 1.2–1.13 print 验证记录在 git log,留着是死代码) - File System Synchronized Group 自动适应文件删除,无需改 pbxproj
- BuildProject 通过
- 替换 M0 占位
- 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 实现细节:用
aspectRatioconstraint vs 手算 frame;推荐用 Auto LayoutaspectRatio=16/9+ centerX/centerY - 远端
.txt实际是 JSON 但被命名为 .txt(msext 历史包袱),解析时不要被 mime-type 误导 - 远端配置 4 级覆盖逻辑历史代码
daoqi/NewRootVC.m:1372-1492有几处可疑分支(if(gameconfig.game_download && gameconfig.game_download)重复条件 /if(result)包住整段 unzip 在首次时不进入),新外壳重写时不要照抄分支结构,按 ADR-008 描述的 reduce 算法重新表达
Phase 2: 大厅简单 handler + 反向 callback
目标
把不依赖外部 SDK 的 11 个 handler 全部实现,使大厅 H5 在不接微信/七牛/高德的前提下能完整跑大部分业务。
前置
- Phase 1 完成
任务清单
2.A H5→Native handler(无外部依赖)
- 2.1
gameCopytext/gamepastetext(剪贴板)—Source/Bridge/Handlers/ClipboardHandler.swift,UIPasteboard.general - 2.2
vibrator/repeatvibrator/canclevibrator(VibratorHandler.swift 扩展 2 项) - 2.3
startshake/stopshake/SwitchShake(ShakeHandler.swift,canShake / canVoice 状态 + WebContainer motionEnded 转发 + shakeEnd 反向) - 2.4
voicePlaying(VoicePlayingHandler.swift,actor VoiceCenter.shared.isEnabled 开关位,Phase 3 mediaTypeAudio 读此) - 2.5
getphoneInfo→ 触发反向 callbackgetphoneinfo(DeviceInfoHandler.swift,注意大小写:大写 I 入、小写 i 出;DeviceInfoSnapshot 6 字段,IDFA 用 idfv 兜底因为 CLAUDE.md 不接 ATT) - 2.6
browser→ 系统 Safari 打开 URL(BrowserHandler.swift,UIApplication.open async) - 2.7
opensaoma空实现注册(OpenSaomaHandler.swift,契约 §3.1【22】要求) - Bonus
startlocationstub(StartLocationHandler.swift,让 H5 启动不报 "no handler";Phase 5 接 LocationKit 完整实现) - Bridge 重构
BridgeData.asXxx全部加nonisolated,让 handler 闭包(@Sendable async)可以自由访问数据值;契约不变
2.B Native→H5 反向 callback
- 2.8
Source/Bridge/Handlers/DeviceInfoHandler.swift:DeviceInfoSnapshot 6 字段(Phase 2.5 一并完成)getphoneInfo收到 →bridge.call("getphoneinfo", data: 表 A 6 字段)反向
- 2.9
Source/Resource/BatteryMonitor.swift:监听UIDevice.batteryLevelDidChangeNotification- 触发 → 重写
app_battery.js+bridge.call("getBattery", data: .string("%.2f", level))
- 触发 → 重写
- 2.10
Source/Resource/NetworkMonitor.swift:NWPathMonitor 包装(Phase 1 已建)- 状态变化 → 重写
app_network.js+bridge.call("getnetwork", data: .string("1"/"2"/"3"))
- 状态变化 → 重写
- 2.11
Source/Resource/AppLifecycleObserver.swift:监听UIApplication.didEnterBackgroundNotification/willEnterForegroundNotification- 后台 →
bridge.call("appservice", data: .string("1"));前台 →"2"
- 后台 →
- 2.12 ShakeDetector 集成在
Source/Bridge/Handlers/ShakeHandler.swift(Phase 2.3 一并完成)WebContainerViewController.motionEnded(_:with:)转发 →bridge.call("shakeEnd", data: nil)(仅canShake=YES时触发)
2.C 外部订阅生命周期
- 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 节中以下项目通过:
getphoneInfo6 字段完整gameCopytext+gamepastetextvibrator/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)playOnce(url)单次按钮音(msextbuttunPlayer等价,多次点击不互打断 — 用 buttonPlayers 数组保活池)loopBackground(url, type:)循环背景音(msextbackgroundPlayer numberOfLoops = -1,记录 backgroundType)stopBackground(type:)停同名背景音(type 与当前 backgroundType 匹配才停)Source/Bridge/Handlers/LocalAudioHandler.swift注册srcIsloophandler,按 isloop=0/1/-1 分支调用 AudioPlayer- 音频文件路径:
{Caches}/{gamedir}/{gamestart}/assets/wav/{src}(H5 zip 包内自带) Source/Bridge/Handlers/RemoteAudioHandler.swift注册prepareaudio/mediaTypeAudiostub(仅 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.swiftSwift wrapperamrToWav(_: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.swiftupload(_:token:) async throws -> UploadedFile- 单测:mock token,模拟上传成功 / 失败
- 3.7
prepareaudiohandler:拉起录音 → 停止录音 → AMR 转码 → 上传 → 反向 callbackgetaudiourl+recordSuccess(仅子游戏触发)
3.D 远程语音回放
- 3.8
mediaTypeAudiohandler:下载 AMR → 转 WAV →AVAudioPlayer播放- 开始播放 →
bridge.call("gameui_play_voice", user) - 播放结束 →
bridge.call("gameui_stop_voice", user) - 受
voicePlaying开关控制(=1 才播)
- 开始播放 →
验收
- 契约 §10 B 节中:
srcIsloop背景音循环 / 停止mediaTypeAudio远端播放 + play/stop callbackprepareaudio→ 录音 → 上传 →getaudiourl({audiourl, time})
风险
- 麦克风权限被拒后的 UI 兜底(H5 alert 提示,契约 §3.1【4】)
- 七牛 token 时效性:每次录音前后端动态颁发 vs 长 token 缓存
- AVAudioSession 与背景音 / 通话音的混音规则(
.playAndRecordmode)
Phase 4: 分享 + 登录
目标
微信授权登录 + 微信好友/朋友圈分享 + 闲聊分享(Noop stub)。
前置
- Phase 1 完成
- ⏳ 微信 OpenSDK
.framework+ AppID + Universal Links - ✗
QQ OpenSDK不需要:URL Scheme(参 msextQQShareManager.m:14__has_includefallback) - ✗
抖音 OpenSDK不需要:URL Scheme + Photos 框架(参 msextDouyinShareManager.m全程无 SDK) - ⏳ 后台
/wechat/login接口(或先用客户端直拼 fallback)
任务清单
4.A SDK 接入
- 4.1
Vendor/WechatSDK/拷贝微信 SDK- Info.plist 加 URL Scheme(
wx586a9b321e56efb7)+LSApplicationQueriesSchemes(weixin/weixinULAPI/weixinURLParamsAPI) - Entitlement 加 Associated Domains(Universal Links)
- Info.plist 加 URL Scheme(
- 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:1Source/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 通过
- 4.3 QQ 分享走 URL Scheme,不依赖 SDK(4.C 完成)
- 移植 msext
QQShareManager.m:716-771simpleShareToQQFriend简化版 - 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 encodecanOpenURL检查 +await UIApplication.open异步打开;调起即视为 success- 当前仅链接分享(type == "1");type == "2" 截图 / 远端图 留 Phase 4.F
- Info.plist 加
LSApplicationQueriesSchemes:mqq/mqqapi/mqqopensdkfriend/mqqopensdkapiV2/V3/V4
- 移植 msext
- 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升级移植 msextDouyinShareManager.m:201-269shareImageToDouyin:- 拿图片源:type=2 / type=1 都退化为截屏(抖音不支持纯链接,与 msext 行为一致);type=其它=远端图 URL → downloadRemote
- PhotoLibrarySaver.save 保存相册
UIPasteboard.general.image = image兜底- 按 msext 顺序尝试
snssdk1128://camera/publish/share/image/ 基础 scheme - 调起即视为 success
- Info.plist 加
NSPhotoLibraryAddUsageDescription文案 - BuildProject 通过
- 新增
- 4.3
Source/SDK/WeChat/WeChatSDK.swift启动注册(Design §4.2registerFull+ 全部 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
accreditloginhandler:触发 OAuth → 反向 callbacksharelogin(7 字段,注意Province大写 P)- 关键契约测试:
shareloginpayload 字段名严格 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
friendsSharetypeUrlToptitleDescripthandler:参数解析 → ShareCenter.dispatch → 反向 callbacksharesuccess({success:"2", type:<原 sharefriend>}) - 4.11 截图分享:
getImageWithFullScreenshotSwift 等价实现(UIGraphicsImageRenderer) - 4.12 远程图片分享:URLSession 下载 → 重打包
验收
- 契约 §10 B 节:
accreditlogin收到 7 字段sharelogin(Province 大写)friendsSharetypeUrlToptitleDescripttype=1/2/3 各跑一次,收到sharesuccess
- 契约 §10 E 节:微信 / QQ 回调命中(QQ 必须先判)
风险
- 微信审核:Universal Links 配置错误 → 授权回不来
- AppSecret 客户端泄露 = 严重安全问题;fallback 路径上线前必须切到后台
- 截图分享在 Liquid Glass / 新版 UIKit 下
drawHierarchyAPI 行为变化,需查 DocumentationSearch 验证
Phase 5: 定位
目标
高德定位 + 逆地理 → 反向 callback getlocationinfo 9 字段。
前置
- Phase 1 完成
- ⏳ 高德 SDK XCFramework(Vendor 手动,ADR-006)+ 新 Bundle ID 重新申请 APIKey
任务清单
- 5.1 从 高德官方下载页 拉取最新
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 -> LocationPayloadstartContinuous(onUpdate:)/stop()
- 5.5
startlocationhandler:data=1持续 / 其他一次性 → 反向 callbackgetlocationinfo9 字段- 关键契约:
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:2s 节流(lastSwitchAt)+ 栈深约束(viewControllers.count < 2)+.subGameDidReturn通知名(Design §2.4.3) - 6.2
Source/WebView/SubGameViewController.swift:与大厅同款 BridgedWebView + Splash + 16:9 letterbox + ExternalSubscriptions;AppDataWriter(containerRole: .subGame)写 4 个 app_*.js;H5 未安装则抛SubGameBootError.subGameNotInstalled(待 6.7 接入下载) - 6.3 子游戏 handler 注册(拆散到独立 enum,不抽
SubGameHandlers聚合类):- 14 个与大厅共享 handler 直接复用各自 enum.register
- 新增
Source/Bridge/Handlers/BackGameDataHandler.swift,仅 SubGameViewController 注册 - 新增
Source/Bridge/Handlers/VideoRoomHandlers.swiftstub(createRoom / getVideoinfo / exitRoom): 业务暂未启用视频功能,仅维持桥契约不让 H5 报 "no handler"; 未来接 Agora 时把 3 个 stub 展开实现,注册点不变
- 6.4
SwitchOverGameDatahandler(大厅 + 子游戏共用):解参Gamedirectory/gamedownloadurl/data→AppCoordinator.shared.showSubGame(...);节流 / 栈深守门移到 Coordinator,handler 始终回 cb"SwitchOverGameData"(msext NewRootVC.m:541-566 等价) - SceneDelegate:以
UINavigationController承载大厅,AppCoordinator.shared.navigationController持引;导航栏隐藏 + 禁用边缘 pop 手势 - 6.5
backgameDatahandler(仅子游戏BackGameDataHandler.swift注册):AudioPlayer.shared.stopAllBackground()(新增方法,msext 无条件 stop+nil 行为等价)AppCoordinator.shared.popSubGame(returningData:)→ 内部 popViewController + 发.subGameDidReturn- callback 字面
"backgameData" - data 入参兼容 string / object(非字符串走 JSONSerialization 透传)
- 6.6 大厅监听
.subGameDidReturn→bridge.call("getWebdata", data)- 挂钩点:
WebContainerViewController.setupExternalSubscriptions(与 battery/network/appservice 同一生命周期,避免双发);teardownExternalSubscriptionsremoveObserver
- 挂钩点:
- 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 完成
任务清单
- 7.1
Source/Containers/OverlayViewController.swift:继承 WebContainer,但 WKWebsiteDataStore 用.nonPersistent()隔离 - 7.2
Source/WebView/OverlayBridge.swiftpolyfill JS:window.settings = {backgameData, browser, finishweb}→webkit.messageHandlers.* - 7.3
WKScriptMessageHandler注册三 handler:overlayBackgameData/overlayBrowser/overlayFinishweb - 7.4
OpenurlTitleDatahandler(大厅 + 子游戏都注册):解参(注意"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/8.2/8.3 视频房间 3 件套 stub 已在 Phase 6 commit C 同期落地:
Source/Bridge/Handlers/VideoRoomHandlers.swift直接 enum.register(不抽 protocol/Noop 类, 业务暂未启用 Agora,未来接入时把 3 个 stub 展开即可,注册点不变)createRoomstub →cb("createRoom")getVideoinfostub →cb("getVideoinfo")exitRoomstub →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/exitRoomH5 调用不报 "bridge not found"- 子游戏接电话 → H5 收
phonestate("2")/phonestate("0") - 子游戏录音上传 → H5 收
getaudiourlANDrecordSuccess(双 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)+NoopCrashReporterfallback - DSN 通过 xcconfig 注入,不入 git
- 编译开关
- 9.2
Source/Bridge/Handlers/H5ErrorRelay.swift:注入window.onerror/unhandledrejectionpolyfill +reportH5Error桥 handler(Design §11.4,新增 handler 不破坏契约) - 9.3 极光(JAnalytics):保持
NoopAnalyticsstub(ADR-005 决策)Source/Analytics/Tracker.swift协议 +NoopAnalytics实现JAnalyticsTracker.swift留蓝图骨架(#if JANALYTICS_ENABLED包裹,默认 OFF)- 上线后业务真要做用户激活 / 留存分析时再启用:放 Vendor、切换实现、加 AppKey、Bootstrapper 注册
- 9.4 闲聊:保持 NoopSharePlatform(业务方未启用闲聊分享,stub 已满足契约)
- 9.5 Agora:保持 NoopVideoRoom + 编译开关 OFF
- 9.6 Bugly:不集成(Sentry 已覆盖)
验收
- 强制崩溃 → Sentry 后台收到 issue
- H5 内
throw new Error()→ Sentry 看到 H5 错误 +container_role标签 Tracker协议存在且默认绑到NoopAnalytics,业务调用Tracker.event(...)不崩、不阻塞
风险
- Sentry 账号必须 ≥ 2 人持有访问凭证(避免重蹈 Bugly 覆辙)
- 首版无用户统计,业务侧若想看激活 / 留存只能等启用 JAnalytics 或自建埋点
Phase 10: 多渠道 + 真机回归 + 灰度
目标
完成打包脚本 + CI 多渠道流水线 + 真机覆盖回归 + 内部灰度切流。
前置
- Phase 1–9 完成
任务清单
10.A 多渠道打包
- 10.1
Scripts/inject_channel.sh:按 Design §7.4 实现 - 10.2
Scripts/archive.sh:xcodebuild archive + exportArchive 流水线 - 10.3
Scripts/release.sh:循环渠道列表,逐个出包到dist/ - 10.4 渠道环境文件
channels/*.env模板
10.B CI
- 10.5 CI 加 archive smoke test(至少一个渠道能出 ipa)
- 10.6 契约测试纳入 CI 必跑
10.C 真机回归
- 10.7 按契约 §10 26 项清单逐项真机过:
- A 节 启动(4 项)
- B 节 桥接(12 项)
- C 节 系统事件(4 项)
- D 节 视频房间(4 项,stub 模式下只验 phonestate / recordSuccess / createRoom 不报错)
- E 节 回归(3 项 + 截图分享)
- 10.8 覆盖设备矩阵:iPhone(最新 + 一台老款)/ iPad / iOS 15.6 / iOS 26.x
10.D 灰度
- 10.9 与 msext 旧外壳并存(Bundle ID 已分离),内部 50 人手动覆盖装
- 10.10 监控 Sentry crash-free rate ≥ 99.5%、用户反馈
- 10.11 50 → 100 → 全量切流(按渠道,小渠道先切)
验收
- 契约 §10 全 26 项真机通过
- 全量灰度内零回退要求
- CI 多渠道出包稳定
风险
- 真机覆盖不全:单人测试时间有限,必要时项目方协调测试人力
- 灰度期间发现 P0 → 必须能 24h 内热修(设计 Sentry breadcrumb + 远程开关)
6. 横向工作流(贯穿全程)
6.1 SDK 集成节奏
| 集成方式 | SDK | 集成 Phase |
|---|---|---|
| SPM 优先 | ZIPFoundation / Sentry / 七牛 v8.9.x(https://github.com/qiniu/objc-sdk) |
Phase 1 / 9 / 3 |
Vendor .xcframework |
微信 / QQ / 高德 AMap(ADR-006);闲聊 / Agora / JAnalytics 均暂不集成(Noop 占位) | Phase 4 / 5 |
Vendor .a |
opencore-amr | Phase 3 |
| 不引入(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 工程化基线
- 0.0 M0 基础配置(横屏 / Swift 6 / 代码启动)
- 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)
- 1.1 ChannelConfig.plist 母包默认值(ADR-007)
- 1.2 BundleConfig
- 1.3 SandboxPaths
- 1.4 ZIPFoundation SPM
- 1.5 ResourceUnzipper
- 1.6 BridgeProtocol
- 1.7 BridgeBus
- 1.8 WVJB JS 协议
- 1.9 BridgedWebView
- 1.10 RemoteConfigClient(actor,URLSession async + 重试 + cache-busting + 短文本 + FlexibleString)
- 1.11 VersionResolver(chulishengji 双子树合并算法,纯函数)
- 1.12 LocalVersionReader(version.xml 解析 + ATS 全局放行)
- 1.13 LobbyZipUpgrader(actor,原子 rename 升级;端到端实测 260→261)
- 1.14.a AppIcon 替换 Xcode 默认空模板(iPad / 1024 缺失留 Phase 10 polish)
- 1.14.b LaunchScreen 改启动图(素材逆时针旋转 90° → 物理 1136×640,scaleAspectFit + 黑底左右补边)
- 1.14.c LobbyZipUpgrader 加 progress 回调(URLSessionDownloadDelegate,0.0…1.0)
- 1.14.d WebContainerViewController(SplashOverlay + 16:9 letterbox + 启动流水线 + 三种 alert)
- 1.14.e WebView didFinish 后 0.3s 淡出 splash + removeFromSuperview
- 1.15 VibratorHandler(单次振动,AudioServicesPlaySystemSound + responseCallback "vibrator")
- 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不需要:QQ 走 URL Scheme,详见 Phase 4.2 任务清单 - 4.3 WeChatSDK registerFull
- 4.4 WeChatManager(state map + FIFO)
- 4.5 SceneDelegate openURL 顺序
- 4.6 WeChatAuth(后台中转或 fallback)
- 4.7 accreditlogin → sharelogin
- 4.8 SharePlatform 协议
- 4.9 ShareCenter 9 种组合
- 4.10 friendsShare... handler
- 4.11 截图分享
- 4.12 远程图片分享
Phase 5 定位
- 5.1 AMap Vendor 接入(XCFramework)
- 5.2 定位权限文案
- 5.3 AMapWrapper 启动注册
- 5.4 LocationService
- 5.5 startlocation handler(latitude/longitude string,province 小写)
- 5.6 失败回包 errorCode 12
Phase 6 子游戏
- 6.1 AppCoordinator 栈深节流(2s + viewControllers.count < 2)
- 6.2 SubGameViewController 框架(commit B 接入 SubGameDownloader 后真实下载/缓存命中)
- 6.3 子游戏 handler 拆散注册(含 BackGameDataHandler;视频房间留 Phase 8)
- 6.4 SwitchOverGameData → AppCoordinator.showSubGame
- 6.5 backgameData(停 audio + popSubGame + JSON 序列化兼容)
- 6.6 getWebdata 通知链(.subGameDidReturn 观察者挂在 ExternalSubscriptions 生命周期)
- 6.7 SubGameDownloader(actor,URLSession + ZIPFoundation + staging + 原子 rename)
Phase 7 弹层
- 7.1 OverlayViewController + 私有 dataStore
- 7.2 window.settings polyfill
- 7.3 3 个 WKScriptMessageHandler
- 7.4 OpenurlTitleData handler("title " 末尾空格)
- 7.5 Overlay backgameData
- 7.6 Overlay finishweb
- 7.7 Overlay browser
Phase 8 视频房间 stub
- 8.1 VideoRoom 协议
- 8.2 NoopVideoRoom
- 8.3 3 个 stub handler
- 8.4 CallCenterMonitor → phonestate
- 8.5 recordSuccess 双 callback
- 8.6 AgoraVideoRoom 蓝图(#if AGORA_ENABLED OFF)
Phase 9 SDK 真实化 + 监控
- 9.1 Sentry SPM + CrashReporter
- 9.2 H5ErrorRelay
- 9.3 极光保持 NoopAnalytics(JANALYTICS_ENABLED OFF)
- 9.4 闲聊保持 NoopSharePlatform
- 9.5 Agora 保持 NoopVideoRoom(AGORA_ENABLED OFF)
- 9.6 不集成 Bugly
Phase 10 多渠道 + 回归 + 灰度
- 10.1 inject_channel.sh
- 10.2 archive.sh
- 10.3 release.sh
- 10.4 渠道环境文件
- 10.5 CI archive smoke
- 10.6 契约测试入 CI
- 10.7 26 项验收清单真机过
- 10.8 设备矩阵覆盖
- 10.9 内部 50 人灰度
- 10.10 Sentry 监控
- 10.11 全量切流
9. 决策记录(ADR-lite)
关键技术 / 工程决策的简短记录,便于回溯"为什么这样选"。新增决策追加到本节末尾。
ADR-001:单 target 起步,暂不切 SPM(2026-06-21)
- 决策:Phase 0–2 阶段保持单 target,按目录组织(
Source/Bridge、Source/Resource等);待代码量 > 5K 行(约 Phase 3 完成时)再评估切 SPM - 理由:早期 SPM 切分成本(target 间循环依赖排查、增量编译配置、测试 target 接入)会拖慢核心垂直闭环验证;Design §2.2 也注明"目标稳态 8-10 个 target,以日常开发体验为先"
- 回滚条件:单 target 编译时间 > 30s 时切 SPM
ADR-002:使用已就位的 2023-12 旧版 gamehall.zip 起步(2026-06-21,原方案"H5 demo 先行"已作废)
- 背景:私人素材池
docs/res/gamehall.zip已存在(旧版,2023-12,11.5 MB),无需等 H5 团队最新版即可启动 Phase 1 - 决策:Phase 1 实施时从
docs/res/gamehall.zip拷贝到ylgamehall/Resources/gamehall.zip入 Bundle,跑通"渠道注入 → 解压 → 加载 H5 → 桥消息"全链路;旧 H5 业务代码与新版的契约差异不影响"桥本身是否工作"的验证 - 理由:
- 桥接口名 / 参数字段名 / JS 协议属于契约范畴,按 Contract 1:1 实现即可,不依赖 H5 业务代码新旧
- 提早用真 H5 跑通比 demo H5 更能暴露真实问题(如 WVJB 握手时序、JS 注入文件路径等)
- 风险:联调期遇到的契约疑点可能来自旧版 H5 已废弃接口,需对照现网 msext + H5 团队双确认
- 切换条件:Phase 10 灰度前必须由 H5 团队提供最新版替换
ylgamehall/Resources/gamehall.zip并复跑契约 §10 全部 26 项
ADR-003:微信登录走后台中转 vs 客户端 fallback(2026-06-21)
- 决策:默认走后台
/wechat/login中转;后台未就绪时临时客户端直拼,但代码标// FIXME且禁止以 fallback 状态上线 - 理由:客户端 secret 一旦进 IPA 永久泄露 + AppSecret 不可重置(Contract §0.4 / Design §17);上线前必须闭环
- 复盘节点:Phase 4 完成时检查后台接口状态
ADR-004:项目资源目录结构由各 Phase 按需落地,不预先建仓库根 Resources/(2026-06-21 修订)
- 背景:原 ADR-004(2026-06-21 初版)决议"项目资源统一放仓库根
Resources/+ 打包脚本 Build Phase 引入",并将gamehall.zip/Images.xcassets/Res/入库到Resources/。但docs/res/后被维护者重新定义为私人原始素材池(不被项目感知,详见 CLAUDE.md),原Resources/入库的素材被搬走,使该 ADR 的语义崩塌 - 修订决策:废止"仓库根
Resources/"的固定结构假设,项目内资源目录由各 Phase 实施时按需落地:- 静态 Bundle 资源 →
ylgamehall/Resources/(synchronized group 自动收集,含gamehall.zip/ChannelConfig.plist等) - 原生 Asset Catalog →
ylgamehall/Assets.xcassets/ - 渠道注入值 →
ylgamehall/Resources/ChannelConfig.plist(11 个 string key,母包默认值;ADR-007) - 闭源 SDK 二进制 →
Vendor/<SDK>/<SDK>.xcframework - 私人原始素材池 →
docs/res/(项目不感知,需要时拷贝到上述工程内目录)
- 静态 Bundle 资源 →
- 理由:
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 隐私文案)推迟到真有业务需求时
- 后续启用路径(保持契约不变,业务无感):
- 在
Vendor/放入JAnalytics.framework - 把
AnalyticsKit.tracker从NoopAnalytics()切换为JAnalyticsTracker() - xcconfig 注入
JANALYTICS_ENABLED=1 - Bootstrapper.registerSDKs 加
JAnalytics.setup(appKey:) - 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 上的最新版)自带的xcodeprojgem 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,均增加工具链复杂度
- 七牛 SDK 已官方支持 SPM(
- 决策:依赖管理简化为两层 — 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):
- 二进制放
Vendor/<SDKName>/<SDKName>.xcframework - Target → General → Frameworks →
+,动态库选 "Embed & Sign",静态库 "Do Not Embed" - Library / Header Search Paths 用
$(PROJECT_DIR)/Vendor/<SDKName>相对路径 Vendor/<SDKName>/README.md记录版本号 / 下载日期 / 官方更新页 URL- 二进制入 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:
- 默认开启的 User Script Sandboxing 拒绝读
Scripts/copy_channel_injection.sh与ChannelInjection/ - 即使关掉 sandbox,Run Script 在没有 Input/Output 声明 + 默认勾选"Based on dependency analysis"时,被 Xcode 视为"无依赖故无需运行",clean build 也不跑
- 默认开启的 User Script Sandboxing 拒绝读
- 综合工程成本估算:为保留"目录名编码"机制,需引入额外 Build Phase / 关闭 sandbox / 维护 Input/Output 列表,全是 Xcode 行为兼容性维护,与项目目标无关
- 子目录被同步组当作 group 处理,11 个
- 决策:渠道注入存储改用单一
ylgamehall/Resources/ChannelConfig.plist含 11 个 string key,与 msext 11 个目录一一对应- 存储介质:plist(iOS 原生)
- Bundle 加载:synchronized group 自动收集,零配置
- 运行时读取:
BundleConfig.init(bundle:)用PropertyListSerialization反序列化
- 理由:
- 契约 100% 等价:H5 端通过
app_data.js看到的 11 个 JS 全局变量行为完全不变(契约边界在BundleConfig.shared.xxx,与底层存储无关) - CLAUDE.md 原则 B 落地:原生内部自由重构,不要照搬旧项目;msext 那套是 iOS 9 / Xcode 14 时代的 hack,Xcode 26 + Swift 6 应当用更现代的存储
- IPA 后处理多渠道分发完全等价:
工作量与 msext 的
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/mv目录名 + 重签完全相同,且 plutil 比 mv 一组路径更可读 / 可自动化 - 维护成本显著降低:单文件、可读、单测可注入 mock bundle、不依赖 Xcode 任何特殊配置
- 契约 100% 等价:H5 端通过
- 影响:
- Contract §0.3 描述的 msext 实现仅作为历史参考,新项目不照搬
- Design §7.0 / §7.2 重写为 plist 方案
- Plan Phase 1.1 简化为"建 plist"单步任务(取代原 1.1.a~d 四步 inject_channel.sh 方案)
BundleConfig.swift接口(11 个公开属性)保持不变,仅 init 实现切换
- 守护:
ChannelConfig.plist必须保持 11 个 key 完整且类型为 string;新增 key 视同契约边界变更,需更新 Design / Plan / Contract(如该值被 H5 通过 app_data.js 暴露)- 后处理工具必须修改 plist 后立即重签,否则 iOS 拒绝安装
- 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道)
- 回滚条件:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质
ADR-008:Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22,含 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"- 新外壳改 HTTPS(msext 用 HTTP,
NewRootVC.m:250)。后台已经支持 HTTPS。
- 新外壳改 HTTPS(msext 用 HTTP,
- 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)。
步骤:
// 1. 提取 agent 子树(getagentversion,NewRootVC.m:885-1013)
// agent → channel → market → game(game 嵌在 market 命中后才遍历)
agentConfig = extractAgentSubtree(agent)
// → (app_version, app_download, game_version, game_download, showmessage)
// 2. 提取 game 子树(getgameversion,NewRootVC.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 LocalVersionReader(1.12)
- iOS 本地版本:
BundleConfig.shared.appVersion转 Int(来自 ChannelConfig.plistappversion字段,等价 msext[FuncPublic filename:@"appversion"]) - H5 本地版本:
Library/Caches/{gamedir}/{gamestart}/version.xml解析/game/version@value- msext 用 GDataXML(
NewRootVC.m:664-682);新外壳用 FoundationXMLParser(无外部依赖) - 缺失 / 损坏返回 0(必须保留,是首装后首次升级的兜底机制;msext
[nil intValue] = 0)
- msext 用 GDataXML(
ADR-008-G LobbyZipUpgrader(1.13)
- 下载链路:
URLSession.download(from: gameZip)async + 进度回调 - 解压:临时目录
staging-{uuid}/+ 原子moveItemrename(不学 msext 先删后压的中断风险) - 失败处理:msext
requestFailed:只打 NSLog 静默;新外壳应当弹错误页 + 回退用 Bundle 内 H5(旧 zip 仍可用)
ADR-008-H 5 字段的他用(必须保留)
showmessage:仅 alert,不传 H5 / 不存 UserDefaultsversion_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) |
| 算法 | 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-007plutil路径)
- Phase 6 子游戏升级直接复用
文档完成日期:2026-06-21 最后更新:2026-06-22(ADR-008 二次精确化 chulishengji 双子树算法 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录)