Files
youle_app_ios_v2/docs/Development-Plan.md
T
joywayerandClaude Opus 4.7 c95e80df9c Phase 1.16:SceneDelegate 接 WebContainer + 退役 RootViewController
- SceneDelegate.scene(_:willConnectTo:options:) 中 rootViewController
  从 M0 占位 RootViewController() 改为 WebContainerViewController()
- 删除 ylgamehall/RootViewController.swift(M0 + Phase 1.2–1.13 烟雾测试
  整体退役);所有 print 验证记录在 git log,留着是死代码
- File System Synchronized Group 自动适应文件删除,无需改 pbxproj
- BuildProject 通过

至此 Phase 1.16 完成;启动链路:
  SceneDelegate → WebContainerViewController.viewDidLoad
    → setupBridgedWebView + setupSplash + registerBridgeHandlers
    → runBootPipeline (ensureReady → fetch → resolve → upgrade → loadFileURL)
    → didFinish 淡出 splash

Plan 进度已勾选(§5 Phase 1.16 + §8)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 04:23:52 +08:00

72 KiB
Raw Blame History

进贤聚友棋牌 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 GroupobjectVersion 77Xcode 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 代码启动 → RootViewControllerM0 占位)
  • 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.zip2023-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 拷贝)
微信 AppIDwx586a9b321e56efb7+ Universal Links 配置 微信回调 Phase 4 项目方
QQ OpenSDK .framework + AppID QQ 分享 Phase 4 项目方
后台 /wechat/login 中转接口 secret 不入 IPA 的前提 Phase 4 后台团队
高德地图 APIKey + SDK XCFramework 定位 Phase 5 已有 key b0d4a8e3fcbbcc0dd96283b7df6a4494(但绑死 msext Bundle ID,需用新 Bundle ID 重新申请);SDK 走 Vendor 手动接入(ADR-006),下载页 https://lbs.amap.com/api/ios-location-sdk/download
七牛上传 token 颁发接口 录音上传 Phase 3 后台团队
极光 AppKey 用户统计 已决策暂不集成(ADR-005),未来启用时再协调
Sentry DSN 崩溃监控 Phase 9 项目方(需开通账号)
Agora AppID(视频房间) 子游戏视频 Phase 8(默认 stub,可延后) 项目方
闲聊 SDK 闲聊分享 Phase 9(默认 stub 项目方
开发 / 企业签名证书 + 描述文件 真机调试 / 分发 Phase 1 起持续 项目方/iOS 账号管理员

协调原则:当某 Phase 阻塞在外部资源时,先做不依赖该资源的 Phase(并行机会见 §3 依赖图)。

2.4 已确定的项目约束

H5 设计分辨率 1280 × 72016:9
WebView 适配策略 保持 16:9 比例(设备比例不匹配时上下/左右 letterbox)
Bundle ID com.skyapp.ylgamehall(与 msext 分离)
Team ID NX5W3B4QP3
设备 iPhone + iPad(不去 iPad
支持方向 仅横屏(LandscapeLeft / LandscapeRight
Swift 版本 6.0
最低 iOS 15.6

3. 全局依赖图

┌─────────────────────────────────────────────────────────────┐
│ Phase 0  工程化基线(剩余项:SwiftLint / 测试 target / CI) │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 1  资源层 + 桥核心 + 第一个 handler                    │
│   ResourceKit / BundleConfig / BridgeCore / BridgedWebView   │
│   验收:H5 加载 + 一个简单 handler 双向通                    │
└─────────────────────────────────────────────────────────────┘
                            │
              ┌─────────────┼─────────────┐
              ▼             ▼             ▼
        ┌──────────┐  ┌──────────┐  ┌──────────┐
        │ Phase 2  │  │ Phase 3  │  │ Phase 5  │
        │ 简单     │  │ 音频体系 │  │ 定位      │
        │ handler  │  │          │  │          │
        │ + 反向cb │  │          │  │          │
        └────┬─────┘  └────┬─────┘  └────┬─────┘
             │             │             │
             └─────────────┴─────────────┘
                           │  并行可行
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 4  分享 + 登录(前置:项目方提供 SDK / 后台接口)        │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 6  子游戏容器(SwitchOverGameData / backgameData      │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 7  弹层(Overlay + window.settings polyfill          │
│   完成后契约 §10 A/B/C/E 节可全跑通                          │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 8  视频房间(默认 NoopVideoRoomAgora 启用是开关)     │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 9  SDK 真实化(Sentry / 极光)+ Stub 替换为真实 SDK     │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 10  多渠道打包 + 真机回归 + 灰度切流                    │
└─────────────────────────────────────────────────────────────┘

关键并行机会Phase 2 / 3 / 5 互不依赖,可按当下手头的资源到位情况自由调度。


4. Phase 总览

Phase 目标 关键产出 验收来源 相对成本
P0 工程化基线 SwiftLint、单测 target、CI 流水线 本文档 §5.0
P1 最小垂直闭环 BundleConfig / ResourceUnzipper / BridgeCore / BridgedWebView + vibrator handler 契约 §10 中 vibrator 项;H5 file:// 加载成功
P2 大厅简单 handler + 反向 callback DeviceKit / Pasteboard / 振动 / 摇一摇 / 网络 / 电池 / 前后台 契约 §10 B 节 + C 节
P3 音频体系 AudioKit(播放 / 录音 / AMR↔WAV / 七牛上传) 契约 §10 中 srcIsloop / mediaTypeAudio / prepareaudio
P4 分享 + 登录 LoginKitWeChat OAuth/ ShareKitWeChat + Noop 闲聊) 契约 §10 B 节 accreditlogin / friendsShare...
P5 定位 LocationKit(高德 SDK 封装) 契约 §10 B 节 startlocation
P6 子游戏容器 SubGameViewController + Coordinator + SwitchOverGameData / backgameData 契约 §10 中 SwitchOverGameData
P7 弹层 OverlayViewController + window.settings polyfill + 节流 契约 §10 中 OpenurlTitleData / settings.*
P8 视频房间 stub 3 个 stub handler + Native→H5 getVideoinfo / phonestate / recordSuccess callback 契约 §10 D 节(NoopVideoRoom 路径)
P9 SDK 真实化 + 监控 Sentry / 七牛 SPM 接入;极光 / 闲聊 / Bugly 决策落地为 Noop / 不集成 契约 §10 全 26 项
P10 多渠道 + 灰度 渠道注入脚本 / CI 多渠道出包 / 真机回归 / 灰度切流 真机验收 + 灰度无回退

相对成本说明

  • 小:1 个工作日内可独立完成
  • 中:24 个工作日,含联调
  • 大:5–10 个工作日,跨多个模块或需对外联调

5. 各 Phase 详细计划

Phase 0: 工程化基线(剩余项)

目标

建立项目级别的代码质量与回归基线,使后续 Phase 每个 commit 都可被自动验证。

前置

无。当前 M0 基础配置已完成。

任务清单

  • 0.1 加入 SwiftLint(SPM 插件方式,避免全局安装依赖)
    • Package.swiftSwiftLintPlugin;规则文件 .swiftlint.yml 按 Design §13.4 配(行长 120、文件 ≤400、函数 ≤40)
    • 验收:xcodebuild 时 lint 自动跑,违规 warning 出现
  • 0.2 新建 ylgamehallTests 单测 targetTesting framework,不引 XCTest 旧 API
    • 第一个测试 BootstrapTests.testAppDelegateRespondsToShake,确认 applicationSupportsShakeToEdit=true 已生效
    • 验收:xcodebuild test 通过
  • 0.3 新建 ylgamehallContractTests 契约测试 target(与 unit test 分离,跑得久也无所谓)
    • 暂只放空骨架,Phase 1 起逐项填入
  • 0.4 新建 ylgamehallUITests UI 测试 targetXCUI
    • 暂只放空骨架,Phase 10 真机回归用
  • 0.5 加 CI 配置(GitHub Actions 或本地 Jenkinsfile,按项目方实际 CI 平台)
    • 跑:xcodebuild test -scheme ylgamehall + lint
    • 验收:push 后 CI 绿
  • 0.6 准备 SPM 包目录结构(先建空 Package.swift 但暂不切 target
    • 决策:先单 target 内按目录组织,等 P3 之后代码量 > 5K 行再切 SPM
    • 这样早期开发不被 SPM 切分成本拖慢

验收

  • xcodebuild test 通过;CI 绿;SwiftLint 报告 0 违规

风险

  • 单测 / UI 测 target 的 signing 需配置(自动签 + 不同 Bundle ID 后缀)

Phase 1: 最小垂直闭环(资源 + 桥 + 远程配置 + 第一个 handler

目标

打通从启动 → 渠道注入读取 → 远程配置拉取 → 版本对比 → zip 升级(若需要)→ 加载 H5 → JS 与原生互调的全链路,用最简单的 handler vibrator 作为验证标的。

Phase 1 范围扩展(ADR-008:原 Plan 漏掉了"远程配置 + 版本对比 + zip 升级"这一关键环节——若不做,上线后用户永远看不到 H5 团队的最新版本,永远停留在 IPA 内打包那一刻的 H5 旧版。2026-06-22 调研 msext NewRootVC.m 发现完整流程后插入新子项 1.10-1.13,原 1.10-1.13 后移为 1.14-1.17。

前置

  • Phase 0 基线
  • docs/res/gamehall.zip2023-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.swift
    • init(bundle: Bundle = .main):读 bundle.url(forResource: "ChannelConfig", withExtension: "plist"),用 PropertyListSerialization 反序列化为 [String: String]
    • 公开 11 个只读属性:qiniuDomain / gameId / channel / gameDir / gameStart / gameConfig / market / agent / appVersion / other / appleConfig
    • 单测 fixture:建立 mock bundle 含 fixture plist,验证 11 个 key 全部能读出且缺失 key 返回空串
  • 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.swiftactor
    • 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.swift
    • BridgeProtocol / BridgeData / BridgeHandler / BridgeCallback 类型
  • 1.7 实现 Source/Bridge/BridgeBus.swift@MainActor
    • register(_:handler:) / call(_:data:callback:) / didReceive(_:)
    • 单测:mock WKWebView,验证 handler 注册 / 分发 / callback 配对
  • 1.8 引入 WVJB JS 端协议源码
1.C WebView 容器(视图层 + 一个 handler)
  • 1.9 实现 Source/WebView/BridgedWebView.swift
    • WKWebView 配置照 Contract §4.1javaScriptCanOpenWindowsAutomatically=NOminimumFontSize=10bounces=NOscrollEnabled=NO 等)
    • WKProcessPool 单例 SharedProcessPool.shared
1.D 远程配置 + 版本对比 + zip 升级(Phase 1 关键缺口,ADR-008
  • 1.10 实现 ylgamehall/Source/Network/RemoteConfigClient.swiftactor
    • URL 构造:http:// + BundleConfig.shared.gameConfig.replacingOccurrences("-", "/") + .txt注意:不带 SERVERNew 前缀,原 daoqi/NewRootVC.m:250 就是这样构造)
    • 拉远端 .txt 内容(实际是 JSON)→ URLSession.async data(from:)10 s 超时
    • 指数退避重试(msext 用 4s 等间隔暴力 timer,新外壳用 1s / 2s / 4s 三次)
    • 反 JSON → 强类型 RemoteConfig Codable 模型(嵌套:agentlist[].channellist[].marketlist[] + agentlist[].gamelist[].channellist[].marketlist[]
    • 单测:mock URLSession + fixture JSON 验证嵌套解析正确
  • 1.11 实现 ylgamehall/Source/Network/VersionResolver.swift(纯函数 + 单测)
    • 输入:RemoteConfig + (agentId, channelId, marketId, gameId) 四组 ID
    • 输出:ResolvedVersion { appVersion, appDownload, gameVersion, gameZip }
    • 算法:4 级覆盖合并 —— 顶层 → 当前 agent → 当前 channel → 当前 market(最深胜出);agent 子树和 game 子树分别合并,game 子树最终覆盖 agent 子树
    • 参考 daoqi/NewRootVC.m:1372-1492 的散落 if/else,新外壳实现成纯 reduce
    • 单测:覆盖所有边界(顶层有/无、深层缺失、agent vs game 优先级 4 组场景至少 12 用例)
  • 1.12 实现 ylgamehall/Source/Resource/LocalVersionReader.swiftnonisolated namespace
    • localAppVersion: Int:从 BundleConfig.shared.appVersion 读,转 Int
    • localGameVersion: Int:读 Library/Caches/{gamedir}/{gamestart}/version.xmlXMLParser 解析 /game/version@value 转 Int;缺失返回 0
    • 单测:fixture xml 验证解析;缺失 / 损坏 xml 返回 0
  • 1.13 实现 ylgamehall/Source/Resource/LobbyZipUpgrader.swiftactor
    • upgradeIfNeeded(remote: ResolvedVersion) async throws -> UpgradeOutcome
      • 比较 remote.gameVersion > LocalVersionReader.localGameVersion → 触发下载,否则直接返回 .noop
    • 下载链路:URLSession.download(from:) → 临时文件 tmp/lobbyzip-{uuid}.zip → ZIPFoundation 解压到 tmp/lobbyzip-{uuid}/原子 renamelobbyRoot(解压前清空旧 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 个 warningiPad 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.pnguniversal 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 -> OutcomeonProgress: @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 letterboxaspectRatio(16:9) + widthMax/heightMax required + widthFill/heightFill .defaultLow + centerXY 居中。屏幕比 < 16:9 → 上下黑边;> 16:9 → 左右黑边
    • SplashOverlayUIImageView(SplashImage, scaleAspectFill) + UIProgressView + UILabelupdate(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 VibratorHandlerstatic func register(on bridge: any BridgeProtocol)
    • 入参忽略(参考 msext RootVC.m:1816-1818time 参数实际不读取)
    • 副作用:AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)responseCallback.string("vibrator")
    • WebContainerViewController.viewDidLoad 新增 registerBridgeHandlers() 单一聚合点,Phase 2+ 在此追加。BuildProject 通过
  • 1.16 SceneDelegateWebContainerViewController 作 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 + 单测 VersionResolverTestsRemoteConfigClientTests 通过

风险

  • WVJB JS 端协议握手时序对加载顺序敏感,必须用 WKUserScript 在 documentStart 注入
  • iPad letterbox 实现细节:用 aspectRatio constraint vs 手算 frame;推荐用 Auto Layout aspectRatio=16/9 + centerX/centerY
  • 远端 .txt 实际是 JSON 但被命名为 .txt(msext 历史包袱),解析时不要被 mime-type 误导
  • 远端配置 4 级覆盖逻辑历史代码 daoqi/NewRootVC.m:1372-1492 有几处可疑分支(if(gameconfig.game_download && gameconfig.game_download) 重复条件 / if(result) 包住整段 unzip 在首次时不进入),新外壳重写时不要照抄分支结构,按 ADR-008 描述的 reduce 算法重新表达

Phase 2: 大厅简单 handler + 反向 callback

目标

把不依赖外部 SDK 的 11 个 handler 全部实现,使大厅 H5 在不接微信/七牛/高德的前提下能完整跑大部分业务。

前置

  • Phase 1 完成

任务清单

2.A H5→Native handler(无外部依赖)
  • 2.1 gameCopytext / gamepastetext(剪贴板)
  • 2.2 vibrator / repeatvibrator / canclevibrator(已有,扩展)
  • 2.3 startshake / stopshake / SwitchShake(摇一摇开关 + 音效开关)
  • 2.4 voicePlaying(语音播放总开关)
  • 2.5 getphoneInfo → 触发反向 callback getphoneinfo(注意小写 i
  • 2.6 browser → 系统 Safari 打开 URL
  • 2.7 opensaoma 空实现注册(契约 §3.1【22】要求)
2.B Native→H5 反向 callback
  • 2.8 Source/Device/DeviceInfo.swift6 字段 snapshot
    • getphoneInfo 收到调用后 → bridge.call("getphoneinfo", data: ...) 反向
  • 2.9 Source/Device/BatteryMonitor.swift:监听 UIDeviceBatteryLevelDidChangeNotification
    • 触发 → bridge.call("getBattery", data: .string("%.2f"))
  • 2.10 Source/Device/NetworkMonitor.swiftNWPathMonitor 包装
    • 状态变化 → bridge.call("getnetwork", data: .string("1"/"2"/"3"))
  • 2.11 Source/Device/AppLifecycleObserver.swift:监听 SceneDelegate 前后台通知
    • bridge.call("appservice", data: .string("1"=后台 / "2"=前台))
  • 2.12 Source/WebView/ShakeDetector.swift:在 WebContainerViewController 重写 motionEnded:
    • bridge.call("shakeEnd", data: nil)(仅 canshake=YES 时触发)
2.C 外部订阅生命周期
  • 2.13 实现 Design §2.4.2 的 ExternalSubscriptions 模式
    • viewWillAppear resume / viewWillDisappear suspend
    • 防双发:栈深 ≥ 2 时下层不发桥事件

验收

  • 契约 §10 B 节中以下项目通过:
    • getphoneInfo 6 字段完整
    • gameCopytext + gamepastetext
    • vibrator / repeatvibrator
  • 契约 §10 C 节全部通过:
    • 前后台切换 appservice("1"/"2")
    • 电量变化 getBattery("0.XX")
    • 飞行模式切换 getnetwork("1"/"2"/"3")
    • 摇一摇 shakeEnd

风险

  • motionEnded: 需要 become first responder,在 viewDidAppear 内调用 becomeFirstResponder()
  • iOS 14+ WKWebView.scrollView.bounces 在某些版本被重置,需 viewWillAppear 内重新设置
  • NWPathMonitor 必须保持引用,否则会被释放停止监听

Phase 3: 音频体系

目标

打通本地音频播放 / 远程语音播放 / 录音 → AMR 转码 → 七牛上传的完整链路。

前置

  • Phase 1 完成
  • 项目方提供:opencore-amr 静态库(可从 msext 拷贝)+ 七牛上传 token 颁发接口

任务清单

3.A 本地音频
  • 3.1 Source/Audio/AudioPlayer.swift@MainActor
    • background / button / voice 三类 AVAudioPlayer
    • srcIsloop handlerisloop=0 单次 / isloop=1 循环背景 / isloop=-1 停同名背景
  • 3.2 真机验证:放一个 .wav 进 ResourcesH5 调 srcIsloop({src:"test.wav", isloop:0}) 出声
3.B AMR 转码
  • 3.3 从 msext 拷贝 libopencore-amrnb.a / libopencore-amrwb.a(已 segalign 8 修复版)到 Vendor/
  • 3.4 拷贝 VoiceConverter 头文件 + 实现 → 改写为 Source/Audio/VoiceCoder.swift Swift wrapper
    • amrToWav(_:dest:) / wavToAmr(_:dest:)
    • 单测:fixture amr → 转 wav → 转回 amr,比较前后 hash
3.C 录音 + 上传
  • 3.5 Source/Audio/AudioRecorder.swiftactor
    • record() async throws -> AudioFileAVAudioRecorder 录 WAV
    • 麦克风权限:Info.plist 加 NSMicrophoneUsageDescription = "{gamehallname}需要访问您的麦克风录制语音消息"
  • 3.6 七牛 SPM 依赖(v8++ Source/Network/QiniuUploader.swift
    • upload(_:token:) async throws -> UploadedFile
    • 单测:mock token,模拟上传成功 / 失败
  • 3.7 prepareaudio handler:拉起录音 → 停止录音 → AMR 转码 → 上传 → 反向 callback getaudiourl + recordSuccess(仅子游戏触发)
3.D 远程语音回放
  • 3.8 mediaTypeAudio handler:下载 AMR → 转 WAV → AVAudioPlayer 播放
    • 开始播放 → bridge.call("gameui_play_voice", user)
    • 播放结束 → bridge.call("gameui_stop_voice", user)
    • voicePlaying 开关控制(=1 才播)

验收

  • 契约 §10 B 节中:
    • srcIsloop 背景音循环 / 停止
    • mediaTypeAudio 远端播放 + play/stop callback
    • prepareaudio → 录音 → 上传 → getaudiourl({audiourl, time})

风险

  • 麦克风权限被拒后的 UI 兜底(H5 alert 提示,契约 §3.1【4】)
  • 七牛 token 时效性:每次录音前后端动态颁发 vs 长 token 缓存
  • AVAudioSession 与背景音 / 通话音的混音规则(.playAndRecord mode

Phase 4: 分享 + 登录

目标

微信授权登录 + 微信好友/朋友圈分享 + 闲聊分享(Noop stub)。

前置

  • Phase 1 完成
  • 微信 OpenSDK .framework + AppID + Universal Links
  • QQ OpenSDK .framework + AppID
  • 后台 /wechat/login 接口(或先用客户端直拼 fallback)

任务清单

4.A SDK 接入
  • 4.1 Vendor/WechatSDK/ 拷贝微信 SDK
    • Info.plist 加 URL Schemewx586a9b321e56efb7+ LSApplicationQueriesSchemesweixin / weixinULAPI / weixinURLParamsAPI
    • Entitlement 加 Associated DomainsUniversal Links
  • 4.2 Vendor/QQShare/ 拷贝 QQ SDK
    • Info.plist 加 QQ URL Scheme + LSApplicationQueriesSchemesmqq*
  • 4.3 Source/SDK/WeChat/WeChatSDK.swift 启动注册(Design §4.2 registerFull + 全部 12 个 MMAPP_SUPPORT_* flag
  • 4.4 Source/SDK/WeChat/WeChatManager.swift@MainActor
    • 持久 delegate
    • authorize() async throws -> WXAuthCodestate UUID 配对)
    • share(_:scene:) async throwsFIFO 串行)
    • 详见 Design §8.5
  • 4.5 SceneDelegate.openURLContexts 接入 QQ → WXApi 顺序
4.B 授权登录
  • 4.6 Source/Login/WeChatAuth.swiftactor
    • authorize() async throws -> WeChatUser:内部调 WeChatManager.authorize() 拿 code → 转给后台 /wechat/login(或客户端 fallback)→ 拿到 7 字段 user
    • 后台未就绪时的 fallback:客户端直拼 api.weixin.qq.com/sns/oauth2/access_token —— 文件头标 // FIXME: 待后台 /wechat/login 就绪后切换为后台中转
  • 4.7 accreditlogin handler:触发 OAuth → 反向 callback sharelogin7 字段,注意 Province 大写 P
    • 关键契约测试sharelogin payload 字段名严格 1:1
4.C 分享
  • 4.8 Source/Share/SharePlatform.swift 协议 + WeChatShare / NoopSharePlatform(name:"xianliao")
  • 4.9 Source/Share/ShareCenter.swift:策略分发(type=1/2/3 × sharetype=1/2/3,共 9 种组合)
  • 4.10 friendsSharetypeUrlToptitleDescript handler:参数解析 → ShareCenter.dispatch → 反向 callback sharesuccess({success:"2", type:<原 sharefriend>})
  • 4.11 截图分享:getImageWithFullScreenshot Swift 等价实现(UIGraphicsImageRenderer
  • 4.12 远程图片分享:URLSession 下载 → 重打包

验收

  • 契约 §10 B 节:
    • accreditlogin 收到 7 字段 shareloginProvince 大写)
    • friendsSharetypeUrlToptitleDescript type=1/2/3 各跑一次,收到 sharesuccess
  • 契约 §10 E 节:微信 / QQ 回调命中(QQ 必须先判)

风险

  • 微信审核:Universal Links 配置错误 → 授权回不来
  • AppSecret 客户端泄露 = 严重安全问题;fallback 路径上线前必须切到后台
  • 截图分享在 Liquid Glass / 新版 UIKit 下 drawHierarchy API 行为变化,需查 DocumentationSearch 验证

Phase 5: 定位

目标

高德定位 + 逆地理 → 反向 callback getlocationinfo 9 字段。

前置

  • Phase 1 完成
  • 高德 SDK XCFrameworkVendor 手动,ADR-006+ 新 Bundle ID 重新申请 APIKey

任务清单

  • 5.1高德官方下载页 拉取最新 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.swiftactor
    • requestOnce() async throws -> LocationPayload
    • startContinuous(onUpdate:) / stop()
  • 5.5 startlocation handlerdata=1 持续 / 其他一次性 → 反向 callback getlocationinfo 9 字段
    • 关键契约latitude / longitudestringstringWithFormat:%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.swiftpath 节流 + 栈深约束(Design §2.4.3
  • 6.2 Source/Containers/SubGameViewController.swift:继承 WebContainer,注入 SubGameHandlers
  • 6.3 Source/Bridge/Handlers/SubGameHandlers.swift19 共享 + backgameData + 3 个视频房间 stub
  • 6.4 SwitchOverGameData handler(仅大厅注册):解参 → AppCoordinator.showSubGame → 节流 2s
  • 6.5 backgameData handler(仅子游戏注册):停 audio → 发 .subGameDidReturn 通知 → coordinator.popSubGame
  • 6.6 大厅监听 .subGameDidReturnbridge.call("getWebdata", data)
  • 6.7 子游戏 zip 下载 / 解压(H5 端通过 SwitchOverGameData 传 gamedownloadurl
    • Source/Resource/SubGameDownloader.swiftURLSession + ZIPFoundation
    • 缓存策略:Library/Caches/{Gamedirectory}/ 已存在则跳过

验收

  • 契约 §10 B 节 SwitchOverGameData → push 子游戏 → 子游戏调 backgameData → 回大厅 → 大厅收 getWebdata
  • 栈深永远 ≤ 3Lobby + SubGame + Overlay

风险

  • 子游戏 zip 下载失败兜底:超时 / 网络断 / hash 校验失败 → 弹错误页且 pop 回大厅
  • 大厅 + 子游戏并存时双发桥事件(已由 Phase 2 ExternalSubscriptions 解决)

Phase 7: 弹层(Overlay

目标

H5 调 OpenurlTitleData 打开弹层 WebView,弹层内 H5 用 window.settings.* 三接口操作,关闭后大厅收 getWebdata

前置

  • Phase 1 完成

任务清单

  • 7.1 Source/Containers/OverlayViewController.swift:继承 WebContainer,但 WKWebsiteDataStore 用 .nonPersistent() 隔离
  • 7.2 Source/WebView/OverlayBridge.swift polyfill JSwindow.settings = {backgameData, browser, finishweb}webkit.messageHandlers.*
  • 7.3 WKScriptMessageHandler 注册三 handleroverlayBackgameData / overlayBrowser / overlayFinishweb
  • 7.4 OpenurlTitleData handler(大厅 + 子游戏都注册):解参(注意 "title " 末尾空格契约) → AppCoordinator.showOverlay → 3 秒节流
  • 7.5 Overlay backgameData → 发 .subGameDidReturn → 上层 H5 收 getWebdata
  • 7.6 Overlay finishweb → coordinator.popOverlay
  • 7.7 Overlay browser → 系统 Safari 打开

验收

  • 契约 §10 B 节 OpenurlTitleData 完整链路 + settings.finishweb() 关闭 + settings.backgameData(data) 回传

风险

  • 弹层第三方外链 Cookie 不污染主业务态(.nonPersistent() 已隔离)
  • 3 秒节流时间戳由谁持有(OpenUrlHandler 内部 actor 状态)

Phase 8: 视频房间 stub

目标

注册 3 个视频房间 handler 的 stub 实现 + 子游戏独有 3 个反向 callback,以满足契约边界 —— 即使业务暂不上视频,H5 也不会报错。

前置

  • Phase 6 完成(子游戏容器)

任务清单

  • 8.1 Source/VideoRoom/VideoRoom.swift 协议
  • 8.2 Source/VideoRoom/NoopVideoRoom.swift 实现(log + 不动)
  • 8.3 SubGameHandlers 注入 NoopVideoRoom,注册:
    • createRoom stub → cb("createRoom")
    • getVideoinfo stub → cb("getVideoinfo")
    • exitRoom stub → cb("exitRoom")
  • 8.4 Source/Telephony/CallCenterMonitor.swiftCXCallObserver 监听通话状态
    • 来电 → bridge.call("phonestate", "2")
    • 挂断 → bridge.call("phonestate", "0")
  • 8.5 RecordUploader.onUploaded 已在 Phase 3,需要在 SubGameHandlers 里额外触发 recordSuccess({fileUrl, fileName, fileKey})(契约 §3.2 表 [15]
  • 8.6 AgoraVideoRoom.swift 留蓝图骨架(#if AGORA_ENABLED 包裹,默认 OFF

验收

  • 契约 §10 D 节中:
    • createRoom / exitRoom H5 调用不报 "bridge not found"
    • 子游戏接电话 → H5 收 phonestate("2") / phonestate("0")
    • 子游戏录音上传 → H5 收 getaudiourl AND recordSuccess(双 callback

风险

  • CXCallObserver 在模拟器无效,必须真机测
  • 视频房间真要启用时再走 §8.6 的 AgoraVideoRoom 实现路径

Phase 9: SDK 真实化 + 监控接入

目标

首版 SDK 集成定型:接入 Sentry 崩溃监控;极光 / 闲聊 / Agora 维持 Noop 占位(编译开关 OFF);Bugly 不集成。

前置

  • Phase 18 完成

任务清单

  • 9.1 Sentry-Cocoa SPM 依赖 + Source/Analytics/SentryCrashReporter.swift
    • 编译开关 SENTRY_ENABLED(默认 ON+ NoopCrashReporter fallback
    • DSN 通过 xcconfig 注入,不入 git
  • 9.2 Source/Bridge/Handlers/H5ErrorRelay.swift:注入 window.onerror / unhandledrejection polyfill + reportH5Error 桥 handlerDesign §11.4,新增 handler 不破坏契约)
  • 9.3 极光(JAnalytics):保持 NoopAnalytics stubADR-005 决策
    • Source/Analytics/Tracker.swift 协议 + NoopAnalytics 实现
    • JAnalyticsTracker.swift 留蓝图骨架(#if JANALYTICS_ENABLED 包裹,默认 OFF
    • 上线后业务真要做用户激活 / 留存分析时再启用:放 Vendor、切换实现、加 AppKey、Bootstrapper 注册
  • 9.4 闲聊:保持 NoopSharePlatform(业务方未启用闲聊分享,stub 已满足契约)
  • 9.5 Agora:保持 NoopVideoRoom + 编译开关 OFF
  • 9.6 Bugly:不集成(Sentry 已覆盖)

验收

  • 强制崩溃 → Sentry 后台收到 issue
  • H5 内 throw new Error() → Sentry 看到 H5 错误 + container_role 标签
  • Tracker 协议存在且默认绑到 NoopAnalytics,业务调用 Tracker.event(...) 不崩、不阻塞

风险

  • Sentry 账号必须 ≥ 2 人持有访问凭证(避免重蹈 Bugly 覆辙)
  • 首版无用户统计,业务侧若想看激活 / 留存只能等启用 JAnalytics 或自建埋点

Phase 10: 多渠道 + 真机回归 + 灰度

目标

完成打包脚本 + CI 多渠道流水线 + 真机覆盖回归 + 内部灰度切流。

前置

  • Phase 19 完成

任务清单

10.A 多渠道打包
  • 10.1 Scripts/inject_channel.sh:按 Design §7.4 实现
  • 10.2 Scripts/archive.shxcodebuild 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.xhttps://github.com/qiniu/objc-sdk Phase 1 / 9 / 3
Vendor .xcframework 微信 / QQ / 高德 AMapADR-006);闲聊 / Agora / JAnalytics 均暂不集成(Noop 占位) Phase 4 / 5
Vendor .a opencore-amr Phase 3
CocoaPods 不引入ADR-006,纯 SPM + Vendor 两层管理)

每集成一个 SDK 立即提交一个独立 commitCLAUDE.md "及时提交" 规则)。

6.2 测试节奏

  • Phase 1 起每 Phase 必须有契约测试用例纳入 ylgamehallContractTests
  • 单元测试覆盖率目标:BridgeCore ≥ 90%、ResourceKit ≥ 85%、其余 Kit ≥ 70%Design §12.1
  • UI 集成测在 Phase 10 完成 26 项验收清单的 XCUI 自动化

6.3 监控接入

  • Sentry 默认 ON 但可关(编译开关 SENTRY_ENABLED
  • DSN / 七牛 token 接口 URL / 微信 AppID 等敏感配置走 xcconfig + .gitignore
  • H5 错误回流通道(reportH5Error 桥 handler)在 Phase 9 接入

6.4 文档同步

  • 每个 Phase 结束更新本文档 §8 进度追踪
  • 桥接 handler 任何变动必须同步更新 ContractDesign §18.6
  • Phase 完成时如发现 Contract 描述与实测不符 → 优先修 Contract(事实为准)

7. 真实风险与缓解(项目独有)

风险 触发条件 影响 缓解
已就位的 gamehall.zip 是 2023-12 旧版,与现网 H5 有契约漂移 Phase 19 联调 联调期发现 handler 名 / 字段被旧版掩盖、真上线时反而暴露 上线前阶段(Phase 10 灰度前)强制由 H5 团队提供最新版替换并复跑契约 §10 全部 26 项;联调期发现的契约疑点立即对照现网 msext 与 H5 团队对齐
微信 SDK 审核失败 / 改 Bundle ID 引起 Universal Links 失效 Phase 4 真机调试 微信回调收不到 Bundle ID 注册微信开放平台 + 配置 Universal Links 流程独立做一次(项目方协调)
后台 /wechat/login 接口延期 Phase 4 上线时 secret 仍驻 IPA 接受临时 fallback(标 TODO),上线前必须切到后台
高德 APIKey 与 Bundle ID 绑定失败 Phase 5 真机定位 定位全失败 项目方控制台重新申请 key(本项目独立 key)
Swift 6 严格并发产生大量 warning/error Phase 2 起 进度延迟 已启用 Approachable Concurrency(渐进模式),逐 Phase 修复,不一开始就 Complete 模式
WKWebView 内 evaluateJavaScript 在 Liquid Glass 时代 API 变化 iOS 26+ 真机 桥消息派发失败 DocumentationSearch 优先查最新 API,不假设知识截止前的写法
真机回归人力不足(单人 + AI Phase 10 漏测 → 上线翻车 把 26 项验收清单写成 XCUI 自动化(可重复跑),手测只覆盖体验类
Sentry 账号长期无人维护变 Bugly 翻版 Phase 9 上线后 崩溃监控形同虚设 接入时同步把账号责任写入团队 onboarding,季度回顾会上检查存活
灰度期发现 P0 但无热修通道 Phase 10 灰度 用户体验受损 设计期就要预留远程配置开关(如:Sentry breadcrumbs 中查 root cause + 一个 plist 控制的 feature flag

8. 进度追踪

每完成一项就把 [ ][x],并在 commit message 里附 Phase X.Y 编号方便 git log 检索。

Phase 0 工程化基线

  • 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 RemoteConfigClientactorURLSession async + 重试 + cache-busting + 短文本 + FlexibleString
  • 1.11 VersionResolverchulishengji 双子树合并算法,纯函数)
  • 1.12 LocalVersionReaderversion.xml 解析 + ATS 全局放行)
  • 1.13 LobbyZipUpgraderactor,原子 rename 升级;端到端实测 260→261)
  • 1.14.a AppIcon 替换 Xcode 默认空模板(iPad / 1024 缺失留 Phase 10 polish
  • 1.14.b LaunchScreen 改启动图(素材逆时针旋转 90° → 物理 1136×640scaleAspectFit + 黑底左右补边)
  • 1.14.c LobbyZipUpgrader 加 progress 回调(URLSessionDownloadDelegate0.0…1.0
  • 1.14.d WebContainerViewControllerSplashOverlay + 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
  • 4.3 WeChatSDK registerFull
  • 4.4 WeChatManagerstate map + FIFO
  • 4.5 SceneDelegate openURL 顺序
  • 4.6 WeChatAuth(后台中转或 fallback
  • 4.7 accreditlogin → sharelogin
  • 4.8 SharePlatform 协议
  • 4.9 ShareCenter 9 种组合
  • 4.10 friendsShare... handler
  • 4.11 截图分享
  • 4.12 远程图片分享

Phase 5 定位

  • 5.1 AMap Vendor 接入(XCFramework
  • 5.2 定位权限文案
  • 5.3 AMapWrapper 启动注册
  • 5.4 LocationService
  • 5.5 startlocation handlerlatitude/longitude stringprovince 小写)
  • 5.6 失败回包 errorCode 12

Phase 6 子游戏

  • 6.1 AppCoordinator 栈深节流
  • 6.2 SubGameViewController
  • 6.3 SubGameHandlers
  • 6.4 SwitchOverGameData
  • 6.5 backgameData
  • 6.6 getWebdata 通知链
  • 6.7 SubGameDownloader

Phase 7 弹层

  • 7.1 OverlayViewController + 私有 dataStore
  • 7.2 window.settings polyfill
  • 7.3 3 个 WKScriptMessageHandler
  • 7.4 OpenurlTitleData handler"title " 末尾空格)
  • 7.5 Overlay backgameData
  • 7.6 Overlay finishweb
  • 7.7 Overlay browser

Phase 8 视频房间 stub

  • 8.1 VideoRoom 协议
  • 8.2 NoopVideoRoom
  • 8.3 3 个 stub handler
  • 8.4 CallCenterMonitor → phonestate
  • 8.5 recordSuccess 双 callback
  • 8.6 AgoraVideoRoom 蓝图(#if AGORA_ENABLED OFF

Phase 9 SDK 真实化 + 监控

  • 9.1 Sentry SPM + CrashReporter
  • 9.2 H5ErrorRelay
  • 9.3 极光保持 NoopAnalyticsJANALYTICS_ENABLED OFF
  • 9.4 闲聊保持 NoopSharePlatform
  • 9.5 Agora 保持 NoopVideoRoomAGORA_ENABLED OFF
  • 9.6 不集成 Bugly

Phase 10 多渠道 + 回归 + 灰度

  • 10.1 inject_channel.sh
  • 10.2 archive.sh
  • 10.3 release.sh
  • 10.4 渠道环境文件
  • 10.5 CI archive smoke
  • 10.6 契约测试入 CI
  • 10.7 26 项验收清单真机过
  • 10.8 设备矩阵覆盖
  • 10.9 内部 50 人灰度
  • 10.10 Sentry 监控
  • 10.11 全量切流

9. 决策记录(ADR-lite

关键技术 / 工程决策的简短记录,便于回溯"为什么这样选"。新增决策追加到本节末尾。

ADR-001:单 target 起步,暂不切 SPM2026-06-21

  • 决策Phase 02 阶段保持单 target,按目录组织(Source/BridgeSource/Resource 等);待代码量 > 5K 行(约 Phase 3 完成时)再评估切 SPM
  • 理由:早期 SPM 切分成本(target 间循环依赖排查、增量编译配置、测试 target 接入)会拖慢核心垂直闭环验证;Design §2.2 也注明"目标稳态 8-10 个 target,以日常开发体验为先"
  • 回滚条件:单 target 编译时间 > 30s 时切 SPM

ADR-002:使用已就位的 2023-12 旧版 gamehall.zip 起步(2026-06-21,原方案"H5 demo 先行"已作废)

  • 背景:私人素材池 docs/res/gamehall.zip 已存在(旧版,2023-12,11.5 MB),无需等 H5 团队最新版即可启动 Phase 1
  • 决策Phase 1 实施时从 docs/res/gamehall.zip 拷贝到 ylgamehall/Resources/gamehall.zip 入 Bundle,跑通"渠道注入 → 解压 → 加载 H5 → 桥消息"全链路;旧 H5 业务代码与新版的契约差异不影响"桥本身是否工作"的验证
  • 理由
    • 桥接口名 / 参数字段名 / JS 协议属于契约范畴,按 Contract 1:1 实现即可,不依赖 H5 业务代码新旧
    • 提早用真 H5 跑通比 demo H5 更能暴露真实问题(如 WVJB 握手时序、JS 注入文件路径等)
  • 风险:联调期遇到的契约疑点可能来自旧版 H5 已废弃接口,需对照现网 msext + H5 团队双确认
  • 切换条件Phase 10 灰度前必须由 H5 团队提供最新版替换 ylgamehall/Resources/gamehall.zip 并复跑契约 §10 全部 26 项

ADR-003:微信登录走后台中转 vs 客户端 fallback2026-06-21

  • 决策:默认走后台 /wechat/login 中转;后台未就绪时临时客户端直拼,但代码标 // FIXME禁止以 fallback 状态上线
  • 理由:客户端 secret 一旦进 IPA 永久泄露 + AppSecret 不可重置(Contract §0.4 / Design §17);上线前必须闭环
  • 复盘节点:Phase 4 完成时检查后台接口状态

ADR-004:项目资源目录结构由各 Phase 按需落地,不预先建仓库根 Resources/2026-06-21 修订)

  • 背景:原 ADR-0042026-06-21 初版)决议"项目资源统一放仓库根 Resources/ + 打包脚本 Build Phase 引入",并将 gamehall.zip / Images.xcassets / Res/ 入库到 Resources/。但 docs/res/ 后被维护者重新定义为私人原始素材池(不被项目感知,详见 CLAUDE.md),原 Resources/ 入库的素材被搬走,使该 ADR 的语义崩塌
  • 修订决策:废止"仓库根 Resources/"的固定结构假设,项目内资源目录由各 Phase 实施时按需落地
    • 静态 Bundle 资源 → ylgamehall/Resources/synchronized group 自动收集,含 gamehall.zip / ChannelConfig.plist 等)
    • 原生 Asset Catalog → ylgamehall/Assets.xcassets/
    • 渠道注入值 → ylgamehall/Resources/ChannelConfig.plist11 个 string key,母包默认值;ADR-007
    • 闭源 SDK 二进制 → Vendor/<SDK>/<SDK>.xcframework
    • 私人原始素材池 → docs/res/(项目不感知,需要时拷贝到上述工程内目录)
  • 理由
    • docs/res/ 既已剥离为私人素材池,原"项目方资源统一放仓库根 Resources/"的前提不存在
    • synchronized group 模式下,把资源直接放 ylgamehall/Resources/ 而非仓库根 Resources/ 更省一步 Build Phase 配置
    • "不预先建 Resources/ 目录"避免空壳目录污染仓库;目录在该 Phase 真正需要时再建
  • 影响
    • 仓库根 Resources/ 不再存在(已删除)
    • Plan §2.2 / Phase 1 任务 / ADR-002 同步调整为 docs/res/ylgamehall/Resources/ 的拷贝模型
    • Design §7.0 同步重写

ADR-005JAnalytics(极光)首版降为 NoopAnalytics 占位(2026-06-21

  • 背景Design §14.2 原标注极光" 启用,启动注册",但项目方在 2026-06-21 评审时确认首版无用户激活 / 留存分析需求
  • 决策:与 Xianliao / Agora 同款渐进集成策略——首版不集成极光 SDK,AnalyticsKit.tracker 默认绑到 NoopAnalytics,业务调用 Tracker.event(...) 立即返回不做任何事;编译开关 JANALYTICS_ENABLED 默认 OFF,留蓝图 JAnalyticsTracker.swift 待启用
  • 理由
    • 减少首版外部依赖(首版 SDK 集成清单:微信 / QQ / 高德 / 七牛 / opencore-amr / Sentry / ZIPFoundation,共 7 个;不集成 4 个:极光 / 闲聊 / Agora / Bugly
    • 现网 msext 的极光后端运维状态不可知,与 Bugly 同样有"账号断档监控失效"风险(CLAUDE.md / 父级 msext 已有先例),新外壳起步不重蹈
    • Sentry 的 breadcrumb / transaction 可兜一部分用户行为路径,初期定位够用
    • 接入成本(AppKey 申请 / Vendor 二进制 / Info.plist 隐私文案)推迟到真有业务需求时
  • 后续启用路径(保持契约不变,业务无感):
    1. Vendor/ 放入 JAnalytics.framework
    2. AnalyticsKit.trackerNoopAnalytics() 切换为 JAnalyticsTracker()
    3. xcconfig 注入 JANALYTICS_ENABLED=1
    4. Bootstrapper.registerSDKs 加 JAnalytics.setup(appKey:)
    5. Info.plist 加 AppKey + 隐私授权文案
  • 复盘节点:上线 3 个月后业务方根据数据需求决定是否启用

ADR-006:纯 SPM + Vendor .xcframework 两层依赖管理,不引入 CocoaPods2026-06-21

  • 背景Design 原方案是"SPM 优先 + CocoaPods 兜底 + Vendor 手动"三层策略,CocoaPods 仅服务于高德 AMap 定位 SDK 一个依赖
  • 触发事件:尝试 pod init 时,CocoaPods 1.15.2Homebrew 上的最新版)自带的 xcodeproj gem 1.24.0 不识别 Xcode 26.5 默认的 PBXFileSystemSynchronizedRootGrouppod init 直接抛 unknown ISA 异常
  • 调研结论
    • 七牛 SDK 已官方支持 SPMhttps://github.com/qiniu/objc-sdkv8.9.x),原计划的"七牛走 CocoaPods"完全无必要
    • 高德 AMap 定位 SDK 官方未提供 SPM(截至 2026-06),但提供 XCFramework 直接下载
    • CocoaPods 1.16+ 才支持新 ISA,但 Homebrew 未跟进;需绕道 Bundler + Gemfile / brew --HEAD / rbenv 自管 Ruby,均增加工具链复杂度
  • 决策:依赖管理简化为两层 — SPM 优先 + Vendor .xcframework 手动完全不引入 CocoaPods
    • SPMZIPFoundation / Sentry / Qiniu / WVJB JS(资源文件)
    • Vendor:微信 / QQ / 高德 AMap / opencore-amr;闲聊 / Agora / JAnalytics 暂不集成(Noop 占位)
  • 理由
    • 项目实际只有高德 AMap 1 个 SDK 让 CocoaPods 必要;为 1 个依赖引入整套 Ruby + Bundler + CocoaPods + workspace 工具链 + Xcode 26 兼容性维护,性价比低
    • 闭源 SDK 本就是 Vendor 性质(微信 / QQ / 闲聊 / JAnalytics / opencore-amr),AMap 走同款流程不增加心智负担
    • 生态趋势:Google 已宣布 2026 Q2 后 iOS SDK 全部停止 CocoaPods 支持;CocoaPods 维护活跃度下降
    • 工程入口仍是 .xcodeproj(不需要 .xcworkspace),团队 / CI 配置不需要切换
  • 接入标准流程Vendor,写入 Design §14.1):
    1. 二进制放 Vendor/<SDKName>/<SDKName>.xcframework
    2. Target → General → Frameworks → +,动态库选 "Embed & Sign",静态库 "Do Not Embed"
    3. Library / Header Search Paths 用 $(PROJECT_DIR)/Vendor/<SDKName> 相对路径
    4. Vendor/<SDKName>/README.md 记录版本号 / 下载日期 / 官方更新页 URL
    5. 二进制入 git(项目规模可控,暂不强制 git-lfs)
  • 回滚条件:未来某天出现 ≥ 3 个仅有 podspec 而无 SPM / XCFramework 的 SDK 必接需求时,重新评估是否引入 CocoaPods
  • 维护责任:AMap 升级(年度级)→ 下载新 XCFramework 覆盖 Vendor/AMap/ + 更新 README + 跑契约测试

ADR-007:渠道注入用 ChannelConfig.plist 替代 msext 的"空目录名注入"机制(2026-06-21

  • 背景Contract §0.3 描述 msext 把 11 个渠道值编码为 Bundle 根的 11 个目录子文件夹名(如 channel/FtJf0.../,目录名本身就是值)。新项目首版尝试照搬此机制
  • 触发事件:与 Xcode 26.5 默认的 PBXFileSystemSynchronizedRootGroup 不兼容——
    • 子目录被同步组当作 group 处理,11 个 .gitkeep 在 Bundle 根撞名 → build 失败
    • 尝试 folder reference(蓝色文件夹):Xcode 26.5 拖入对话框流程对此不友好,pbxproj 不会写入正确条目
    • 尝试 Run Script Build Phase
      1. 默认开启的 User Script Sandboxing 拒绝读 Scripts/copy_channel_injection.shChannelInjection/
      2. 即使关掉 sandboxRun Script 在没有 Input/Output 声明 + 默认勾选"Based on dependency analysis"时,被 Xcode 视为"无依赖故无需运行"clean build 也不跑
    • 综合工程成本估算:为保留"目录名编码"机制,需引入额外 Build Phase / 关闭 sandbox / 维护 Input/Output 列表,全是 Xcode 行为兼容性维护,与项目目标无关
  • 决策:渠道注入存储改用单一 ylgamehall/Resources/ChannelConfig.plist 含 11 个 string key,与 msext 11 个目录一一对应
    • 存储介质:plistiOS 原生)
    • Bundle 加载:synchronized group 自动收集,零配置
    • 运行时读取:BundleConfig.init(bundle:)PropertyListSerialization 反序列化
  • 理由
    • 契约 100% 等价H5 端通过 app_data.js 看到的 11 个 JS 全局变量行为完全不变(契约边界在 BundleConfig.shared.xxx,与底层存储无关)
    • CLAUDE.md 原则 B 落地:原生内部自由重构,不要照搬旧项目;msext 那套是 iOS 9 / Xcode 14 时代的 hackXcode 26 + Swift 6 应当用更现代的存储
    • IPA 后处理多渠道分发完全等价
      plutil -replace channel -string "<新渠道 ID>" ChannelConfig.plist
      plutil -replace market  -string "<新市场 ID>" ChannelConfig.plist
      codesign --force --sign "$IDENTITY" --entitlements "$ENT" <app>.app
      zip -r <channel>.ipa Payload/
      
      工作量与 msext 的 mv 目录名 + 重签完全相同,且 plutil 比 mv 一组路径更可读 / 可自动化
    • 维护成本显著降低:单文件、可读、单测可注入 mock bundle、不依赖 Xcode 任何特殊配置
  • 影响
    • Contract §0.3 描述的 msext 实现仅作为历史参考,新项目不照搬
    • Design §7.0 / §7.2 重写为 plist 方案
    • Plan Phase 1.1 简化为"建 plist"单步任务(取代原 1.1.a~d 四步 inject_channel.sh 方案)
    • BundleConfig.swift 接口(11 个公开属性)保持不变,仅 init 实现切换
  • 守护
    • ChannelConfig.plist 必须保持 11 个 key 完整且类型为 string;新增 key 视同契约边界变更,需更新 Design / Plan / Contract(如该值被 H5 通过 app_data.js 暴露)
    • 后处理工具必须修改 plist 后立即重签,否则 iOS 拒绝安装
    • 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道)
  • 回滚条件:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质

ADR-008Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22,含 2 次调研 revise

  • 背景Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5"漏掉了 msext 启动流程里最关键的一环:从 gameconfig 拼接出的远端 .txt 拉真实配置 → chulishengji 双子树算法 → 与本地版本比较 → 必要时下载并替换 H5 zip
  • 触发事件2026-06-22 两轮 subagent 调研 daoqi/msext 原项目,逐行核对核心方法:
    • 第一轮(粗粒度):发现远端配置流程整体形态、IPA / H5 zip 两条升级链
    • 第二轮(精确算法):核对 NewRootVC.m 全文,识别出线上热路径是 chulishengji 双子树合并,非 onnet 简单 4 层
  • 核心修正:线上热路径算法
    • 不是 onnetif(self.gamelist==nil) 分支(NewRootVC.m:689-810)的"简单线性 4 层覆盖"
    • onnetelse 分支(NewRootVC.m:811-877)→ gonetconfig1 / gonetconfigone1chulishengjiNewRootVC.m:1372-1538)双子树合并
    • 触发条件:viewWillAppearNewRootVC.m:1609-1616)与 gonetNewRootVC.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.17VersionResolver 必须按 chulishengji 双子树算法实现,不照搬 onnet 简单 4 层

ADR-008-A 远端 URL 构造(RemoteConfigClient

  • 基础 URL"https://" + BundleConfig.gameConfig.replacingOccurrences("-", "/") + ".txt"
    • 新外壳改 HTTPSmsext 用 HTTPNewRootVC.m:250)。后台已经支持 HTTPS。
  • cache-busting querymsext NewRootVC.m:1226, 1585):每次拉取时拼 ?vXXXXXXXXYYYYYYYY(两个 arc4random() 各 8 位 hex 连写,=)。新外壳保留此约定——服务端可能根据这段 query 做无 cache 处理。

ADR-008-B 网络层

  • APIURLSession.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 子树(getagentversionNewRootVC.m:885-1013
//    agent → channel → market → gamegame 嵌在 market 命中后才遍历)
agentConfig = extractAgentSubtree(agent)
//    → (app_version, app_download, game_version, game_download, showmessage)

// 2. 提取 game 子树(getgameversionNewRootVC.m:1015-1180
//    game → game-self → channel → market
//    (第二层 "game-self" 是把 gamedata 自身当 infotwo 再嵌一遍 channellist
//      msext 那段曾经的 agentid 判断被注释掉了)
gameConfig = extractGameSubtree(game)
//    → (app_version, app_download, game_version, game_download, showmessage)

// 3. 合并 IPA 字段(NewRootVC.m:1408-1447
if both have app_download:
    result.appVersion  = agentConfig.appVersion   // 默认 agent 赢
    result.appDownload = agentConfig.appDownload
    if gameConfig.appVersion > agentConfig.appVersion:   // 版本号大的赢(line 1416
        result.appVersion  = gameConfig.appVersion
        result.appDownload = gameConfig.appDownload
elif only one has: 用那一份
else: nil

// 4. 合并 zip 字段(NewRootVC.m:1450-1492
if both have game_download:
    result.gameVersion = gameConfig.gameVersion   // 默认 game 赢
    result.gameZip     = gameConfig.gameZip
    if agentConfig.gameVersion > gameConfig.gameVersion:   // 版本号大的赢(line 1456
        result.gameVersion = agentConfig.gameVersion
        result.gameZip     = agentConfig.gameZip
elif only one has: 用那一份
else: nil

// 5. showmessage:在两条子树 extract 内部多次覆盖(agent 子树 :897/:910/:924/:948
//    game 子树 :1027/:1058/:1087/:1114),加上 onnet else 的两次 for 循环 (:826/:852)
//    与 gonetconfigone1 :1319。算法上等同于"任意一处非空就覆盖",最后写赢。
result.showmessage = mergeShowmessage(top, agent, game, channel, market)

agent 子树 4 层结构getagentversion:):

  • 第 1 层 agent 自己:NewRootVC.m:889-915 提取顶层 4 字段 + showmessage
  • 第 2 层 channelNewRootVC.m:903-940 嵌 channellist 找匹配
  • 第 3 层 marketNewRootVC.m:921-997 嵌 marketlist 找匹配
  • 第 4 层 gameNewRootVC.m:944-996 嵌在 market 命中后才遍历 game(注意是 market.gamelist,不是 channel.gamelist

game 子树 4 层结构getgameversion:):

  • 第 1 层 game 自己:NewRootVC.m:1019-1045
  • 第 2 层 game-selfinfotwo=gamedata 自身再嵌 channellist):NewRootVC.m:1048-1073
  • 第 3 层 channelNewRootVC.m:1075-1100
  • 第 4 层 marketNewRootVC.m:1100-1163

字段名差异(必须留意)

  • agent 子树用 game_downloadNewRootVC.m:952
  • game 子树用 game_zipNewRootVC.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_downloadreturn
3 否则 uplevel:download: 内(NewRootVC.m:1874-1885):gamevers > [versioninfo intValue]downFileFromServer:;否则 initView 直接加载本地 H5

ADR-008-F LocalVersionReader1.12

  • iOS 本地版本BundleConfig.shared.appVersion 转 Int(来自 ChannelConfig.plist appversion 字段,等价 msext [FuncPublic filename:@"appversion"]
  • H5 本地版本Library/Caches/{gamedir}/{gamestart}/version.xml 解析 /game/version@value
    • msext 用 GDataXMLNewRootVC.m:664-682);新外壳用 Foundation XMLParser(无外部依赖)
    • 缺失 / 损坏返回 0(必须保留,是首装后首次升级的兜底机制;msext [nil intValue] = 0

ADR-008-G LobbyZipUpgrader1.13

  • 下载链路:URLSession.download(from: gameZip) async + 进度回调
  • 解压:临时目录 staging-{uuid}/ + 原子 moveItem rename(不学 msext 先删后压的中断风险)
  • 失败处理msext requestFailed: 只打 NSLog 静默;新外壳应当弹错误页 + 回退用 Bundle 内 H5(旧 zip 仍可用)

ADR-008-H 5 字段的他用(必须保留)

  • showmessage:仅 alert,不传 H5 / 不存 UserDefaults
  • version_ios:① 决策;② initJSdata 间接通过 app_data.js 注入 app_appversion 字段传给 H5(值 0 = gameconfig / 1 = appleconfig,控制 H5 走两条不同分发路径)
  • app_download:仅本次启动用
  • game_version_ / game_zip_:决策 + downFileFromServer 拼下载 URL
  • 全部不存 UserDefaultsmsext 无缓存层)

ADR-008-I 与 msext 差异(实施层面,H5 契约不变)

维度 msext 现状 新外壳决策
网络库 主线程同步 dataWithContentsOfURL: + ASIHTTPRequest 下载 URLSession.data(from:) + download(from:) asyncactor 隔离
重试 4s timer 无上限 1.10 初版用 3 次退避;可改为 msext 风格无上限
URL HTTP HTTPS(后台已切)
cache-busting ?vXXXXXXXX_YYYYYYYY 保留
JSON 解析 SBJSON Codable + JSONDecoder + 自定义 decodeFlexibleStringIfPresent(兼容 number-as-string
算法 chulishengji 双子树合并 复刻 chulishengji,纯函数 + 单测
version.xml GDataXML Foundation XMLParser
升级解压 removeItemAtPath + ZipArchive 临时目录 + 原子 rename
失败 UI 静默 / NSLog 弹错误页 + 重试按钮(不留卡死黑洞)
  • 回滚条件:若远端 .txt 配置服后台被替换为 RESTful API(路径 / 字段名变化),重新评估并实现新协议
  • 影响 Phase 2 及之后
    • Phase 6 子游戏升级直接复用 LobbyZipUpgrader 的设计(仅参数化目录路径)
    • Phase 10 多渠道打包前 gameconfig 注入值由 IPA 后处理工具修改(ADR-007 plutil 路径)

文档完成日期:2026-06-21 最后更新:2026-06-22ADR-008 二次精确化 chulishengji 双子树算法 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录)