From 5f032342e7e55dc389b5d7b0f0f3c1fccff5bc4f Mon Sep 17 00:00:00 2001 From: joywayer Date: Mon, 22 Jun 2026 00:42:30 +0800 Subject: [PATCH] =?UTF-8?q?docs=EF=BC=9APlan=20/=20Design=20=E8=A1=A5=20Ph?= =?UTF-8?q?ase=201=20=E8=BF=9C=E7=A8=8B=E9=85=8D=E7=BD=AE=20+=20zip=20?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E6=B5=81=E6=B0=B4=E7=BA=BF=EF=BC=88ADR-008?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调研 daoqi/msext 原项目 NewRootVC.m:1551-1621 viewWillAppear、1372-1492 chulishengji、1789-1869 downFileFromServer 三段核心代码后发现 Plan 初版 Phase 1 漏掉了启动流程里最关键的一环——从 gameconfig 拼远端 .txt 拉 真实配置、做 agent → channel → market 4 级覆盖、对比本地版本、必要时 下载并替换 H5 zip。不做的代价是:上线后用户永远停留在 IPA 内打包瞬间 的 H5 旧版本,H5 团队任何更新都到达不了。 - Plan Phase 1 任务清单从 13 项扩为 17 项,插入 4 个新子项: - 1.10 RemoteConfigClient(actor,URLSession async + 指数退避) - 1.11 VersionResolver(纯函数 + 单测,reduce 实现 4 级覆盖) - 1.12 LocalVersionReader(解析 version.xml) - 1.13 LobbyZipUpgrader(actor,原子 rename) 原 1.10-1.13 后移为 1.14-1.17。已完成的 1.1-1.9 标记为 ✅ - Plan 新增 ADR-008 完整记录决策背景 / 触发事件 / 与 msext 实现差异 对照表 / 守护条款 / 子游戏复用规划 - Design §6.3 整节重写:从原"ConfigService 一锅端"扩为完整 4 模块流水线 (§6.3.1-6.3.7),含 Codable RemoteConfig 模型、纯函数 VersionResolver、 nonisolated LocalVersionReader、actor LobbyZipUpgrader、WebContainer 调用串、与 msext 差异表、子游戏 Phase 6 复用规划 - 关键设计纠正: - 远端 URL 不带 SERVERNew 前缀,仅 gameconfig.replace("-","/") + ".txt" - 4 级覆盖:顶层 → agent → channel → market 深层胜出 + game 子树覆盖 agent 子树 - 解压策略:staging-{uuid}/ 临时目录 + 原子 moveItem rename,不学 msext "目录名 +1" 累积 hack --- docs/Development-Plan.md | 141 +++++++++++----- docs/H5-Native-Implementation-Design.md | 204 ++++++++++++++++++++++-- 2 files changed, 298 insertions(+), 47 deletions(-) diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index f151899..08de5ff 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -212,15 +212,17 @@ Contract Design Plan(本文档) --- -### Phase 1: 最小垂直闭环(资源 + 桥 + 第一个 handler) +### Phase 1: 最小垂直闭环(资源 + 桥 + 远程配置 + 第一个 handler) #### 目标 -打通**从启动 → 渠道注入读取 → 解压 H5 → 加载 file:// → JS 与原生互调**的全链路,用最简单的 handler `vibrator` 作为验证标的。 +打通**从启动 → 渠道注入读取 → 远程配置拉取 → 版本对比 → 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 值) +- ⏳ 项目方提供 11 个渠道注入目录名值(缺则用临时 demo 值,含可访问的远端 `gameconfig`.txt) - 备选:若 zip 加载有问题,临时用一个最小 H5(`` 含一个 button 调 `bridge.callHandler('vibrator')`)做工程内测排查 #### 任务清单 @@ -228,59 +230,89 @@ Contract Design Plan(本文档) ##### 1.A 资源层 - [x] **1.1** 创建 `ylgamehall/Resources/ChannelConfig.plist` 含 11 个渠道键值对(沿用 msext 现网 demo 作为母包默认值;ADR-007 决策) -- [ ] **1.2** 实现 `ylgamehall/Source/Resource/BundleConfig.swift` +- [x] **1.2** 实现 `ylgamehall/Source/Resource/BundleConfig.swift` - `init(bundle: Bundle = .main)`:读 `bundle.url(forResource: "ChannelConfig", withExtension: "plist")`,用 `PropertyListSerialization` 反序列化为 `[String: String]` - 公开 11 个只读属性:`qiniuDomain` / `gameId` / `channel` / `gameDir` / `gameStart` / `gameConfig` / `market` / `agent` / `appVersion` / `other` / `appleConfig` - 单测 fixture:建立 mock bundle 含 fixture plist,验证 11 个 key 全部能读出且缺失 key 返回空串 -- [ ] **1.3** 实现 `ylgamehall/Source/Resource/SandboxPaths.swift` +- [x] **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) +- [x] **1.4** 加 ZIPFoundation SPM 依赖 +- [x] **1.5** 实现 `ylgamehall/Source/Resource/ResourceUnzipper.swift`(actor) - 从 `docs/res/gamehall.zip` 拷贝一份到 `ylgamehall/Resources/gamehall.zip` - `ensureReady() async throws`:检测 `version.xml` 不存在则解压 Bundle 内 `gamehall.zip` 到 caches - 单测:用 fixture zip 验证解压幂等性 ##### 1.B 桥核心 -- [ ] **1.6** 实现 `Source/Bridge/BridgeProtocol.swift` +- [x] **1.6** 实现 `Source/Bridge/BridgeProtocol.swift` - `BridgeProtocol` / `BridgeData` / `BridgeHandler` / `BridgeCallback` 类型 -- [ ] **1.7** 实现 `Source/Bridge/BridgeBus.swift`(@MainActor) +- [x] **1.7** 实现 `Source/Bridge/BridgeBus.swift`(@MainActor) - `register(_:handler:)` / `call(_:data:callback:)` / `didReceive(_:)` - 单测:mock WKWebView,验证 handler 注册 / 分发 / callback 配对 -- [ ] **1.8** 引入 WVJB JS 端协议源码 +- [x] **1.8** 引入 WVJB JS 端协议源码 - 从 [marcuswestin/WebViewJavascriptBridge](https://github.com/marcuswestin/WebViewJavascriptBridge) 取 `WebViewJavascriptBridge.js.txt`,作为 `Resources/JS/WebViewJavascriptBridge.js` - 注入方式:`WKUserScript`,`atDocumentStart` -##### 1.C WebView 容器 +##### 1.C WebView 容器(视图层 + 一个 handler) -- [ ] **1.9** 实现 `Source/WebView/BridgedWebView.swift` +- [x] **1.9** 实现 `Source/WebView/BridgedWebView.swift` - WKWebView 配置照 Contract §4.1(`javaScriptCanOpenWindowsAutomatically=NO`、`minimumFontSize=10`、`bounces=NO`、`scrollEnabled=NO` 等) - `WKProcessPool` 单例 `SharedProcessPool.shared` -- [ ] **1.10** 实现 `Source/WebView/WebContainerViewController.swift`(基类) + +##### 1.D 远程配置 + 版本对比 + zip 升级(**Phase 1 关键缺口,ADR-008**) + +- [ ] **1.10** 实现 `ylgamehall/Source/Network/RemoteConfigClient.swift`(actor) + - URL 构造:`http://` + `BundleConfig.shared.gameConfig.replacingOccurrences("-", "/")` + `.txt`(**注意**:不带 `SERVERNew` 前缀,原 `daoqi/NewRootVC.m:250` 就是这样构造) + - 拉远端 `.txt` 内容(实际是 JSON)→ `URLSession.async data(from:)`,10 s 超时 + - 指数退避重试(msext 用 4s 等间隔暴力 timer,新外壳用 1s / 2s / 4s 三次) + - 反 JSON → 强类型 `RemoteConfig` Codable 模型(嵌套:`agentlist[].channellist[].marketlist[]` + `agentlist[].gamelist[].channellist[].marketlist[]`) + - 单测:mock URLSession + fixture JSON 验证嵌套解析正确 +- [ ] **1.11** 实现 `ylgamehall/Source/Network/VersionResolver.swift`(纯函数 + 单测) + - 输入:`RemoteConfig + (agentId, channelId, marketId, gameId)` 四组 ID + - 输出:`ResolvedVersion { appVersion, appDownload, gameVersion, gameZip }` + - 算法:4 级覆盖合并 —— 顶层 → 当前 agent → 当前 channel → 当前 market(最深胜出);agent 子树和 game 子树分别合并,game 子树最终覆盖 agent 子树 + - 参考 `daoqi/NewRootVC.m:1372-1492` 的散落 if/else,新外壳实现成纯 reduce + - 单测:覆盖所有边界(顶层有/无、深层缺失、agent vs game 优先级 4 组场景至少 12 用例) +- [ ] **1.12** 实现 `ylgamehall/Source/Resource/LocalVersionReader.swift`(nonisolated namespace) + - `localAppVersion: Int`:从 `BundleConfig.shared.appVersion` 读,转 Int + - `localGameVersion: Int`:读 `Library/Caches/{gamedir}/{gamestart}/version.xml` 用 `XMLParser` 解析 `/game/version@value` 转 Int;缺失返回 0 + - 单测:fixture xml 验证解析;缺失 / 损坏 xml 返回 0 +- [ ] **1.13** 实现 `ylgamehall/Source/Resource/LobbyZipUpgrader.swift`(actor) + - `upgradeIfNeeded(remote: ResolvedVersion) async throws -> UpgradeOutcome` + - 比较 `remote.gameVersion > LocalVersionReader.localGameVersion` → 触发下载,否则直接返回 `.noop` + - 下载链路:`URLSession.download(from:)` → 临时文件 `tmp/lobbyzip-{uuid}.zip` → ZIPFoundation 解压到 `tmp/lobbyzip-{uuid}/` → **原子 rename** 到 `lobbyRoot`(解压前清空旧 lobbyRoot;不学 msext "目录名 +1" hack) + - 失败处理:超时 / 网络断 / hash 校验失败 / 解压失败 → throw 类型化错误;调用方决定是否回退用旧 H5 + - 单测:mock URLSession + fixture zip 验证升级路径与 noop 路径 + +##### 1.E 集成 + 烟雾测试 + +- [ ] **1.14** 实现 `Source/WebView/WebContainerViewController.swift`(基类) - 持有 `BridgedWebView` + `BridgeBus` - 16:9 比例布局:屏幕比 < 16:9 用宽度撑满上下黑边;屏幕比 > 16:9 用高度撑满左右黑边 + - `viewDidLoad` 内串:`ResourceUnzipper.ensureReady() → RemoteConfigClient.fetch → VersionResolver.resolve → LobbyZipUpgrader.upgradeIfNeeded → loadFileURL(lobbyIndex)` + - 无升级路径走快路(< 200 ms),有升级路径显示 progress UI - 实现 `webViewWebContentProcessDidTerminate:` 退避 reload(Design §3.6) -- [ ] **1.11** 实现 `Source/Bridge/Handlers/VibratorHandler.swift` +- [ ] **1.15** 实现 `Source/Bridge/Handlers/VibratorHandler.swift` - `register(_ bridge)` 注册 `vibrator` handler → `AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)` + responseCallback `"vibrator"` - -##### 1.D 集成 - -- [ ] **1.12** `SceneDelegate` → `LobbyViewController`(继承 `WebContainerViewController`) - - viewDidLoad: `ResourceUnzipper.ensureReady()` await 完成后 → `BridgedWebView.load(SandboxPaths.lobbyIndex())` -- [ ] **1.13** 把渠道目录里临时填入的 demo 值(如 `gamedir/demo` / `gamestart/gamehall`)+ 一个 demo H5 跑通 +- [ ] **1.16** `SceneDelegate` → `WebContainerViewController` 作 rootViewController + - 替换 M0 占位 `RootViewController` +- [ ] **1.17** 把渠道目录里临时填入的 demo 值 + 一个 demo H5 跑通 / 真机验证完整 1.10-1.16 链路 #### 验收 -- 真机启动后能看到 H5 demo 页 +- 真机启动后能看到 H5 demo 页(首次会先经历"远程配置拉取"流程) - H5 按钮调 `bridge.callHandler('vibrator')` 后真机震动 - H5 收到 `responseCallback("vibrator")` -- BuildProject 通过;契约测试 `VibratorContractTest` 通过 +- 模拟 `game_version` 远端 +1,重启 App 后能看到 zip 重新下载 + 解压 + 加载新版本 +- BuildProject 通过;契约测试 `VibratorContractTest` + 单测 `VersionResolverTests`、`RemoteConfigClientTests` 通过 #### 风险 - WVJB JS 端协议握手时序对加载顺序敏感,必须用 `WKUserScript` 在 documentStart 注入 - iPad letterbox 实现细节:用 `aspectRatio` constraint vs 手算 frame;推荐用 Auto Layout `aspectRatio=16/9` + centerX/centerY +- 远端 `.txt` 实际是 JSON 但被命名为 .txt(msext 历史包袱),解析时不要被 mime-type 误导 +- 远端配置 4 级覆盖逻辑历史代码 `daoqi/NewRootVC.m:1372-1492` 有几处可疑分支(`if(gameconfig.game_download && gameconfig.game_download)` 重复条件 / `if(result)` 包住整段 unzip 在首次时不进入),新外壳重写时**不要照抄分支结构**,按 ADR-008 描述的 reduce 算法重新表达 --- @@ -739,20 +771,24 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - [ ] 0.5 CI 流水线 - [ ] 0.6 SPM 包目录决策(暂单 target) -### Phase 1 最小垂直闭环 +### Phase 1 最小垂直闭环(含远程配置 + zip 升级,ADR-008) - [x] 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 WebContainerViewController(16:9 letterbox) -- [ ] 1.11 VibratorHandler -- [ ] 1.12 SceneDelegate → LobbyViewController -- [ ] 1.13 Demo H5 联调 +- [x] 1.2 BundleConfig +- [x] 1.3 SandboxPaths +- [x] 1.4 ZIPFoundation SPM +- [x] 1.5 ResourceUnzipper +- [x] 1.6 BridgeProtocol +- [x] 1.7 BridgeBus +- [x] 1.8 WVJB JS 协议 +- [x] 1.9 BridgedWebView +- [ ] 1.10 RemoteConfigClient(actor,URLSession async + 重试) +- [ ] 1.11 VersionResolver(纯函数 4 级覆盖 + 单测) +- [ ] 1.12 LocalVersionReader(version.xml 解析) +- [ ] 1.13 LobbyZipUpgrader(actor,原子 rename 升级) +- [ ] 1.14 WebContainerViewController(16:9 letterbox + 串接整链路) +- [ ] 1.15 VibratorHandler +- [ ] 1.16 SceneDelegate → WebContainerViewController +- [ ] 1.17 Demo H5 联调 + 真机验证升级路径 ### Phase 2 大厅简单 handler + 反向 callback - [ ] 2.1 剪贴板 @@ -966,7 +1002,40 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道) - **回滚条件**:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质 +### ADR-008:Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22) +- **背景**:Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5",**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置,做 agent → channel → market 4 级覆盖,对比本地版本,必要时下载并替换 H5 zip +- **触发事件**:2026-06-22 调研 `daoqi/msext` 原项目(subagent 详细分析 `NewRootVC.m:1551-1621` viewWillAppear、`NewRootVC.m:1372-1492` chulishengji、`NewRootVC.m:1789-1869` downFileFromServer 三段核心代码),发现: + - 远端配置 URL 不是 `SERVERNew + gameconfig` 拼接,而是 `"http://" + gameconfig.replacingOccurrences("-", "/") + ".txt"`(当前 demo 值即 `http://tsgames.daoqi88.cn/config_test/update_jsonv2_test.txt`) + - JSON 结构是 `agentlist[].channellist[].marketlist[]` 三级嵌套 + `agentlist[].gamelist[].channellist[].marketlist[]` 副路径,每层都可携带同名版本字段,深层覆盖浅层 + - 两条独立升级链:① 原生 IPA 升级(`app_version > 本地 appversion 注入值` → 弹窗 + Safari 外链)② H5 zip 升级(`game_version > 本地 version.xml /game/version@value` → 下载 + 解压 + reload) + - 不做这一步的代价:上线后用户永远停在 IPA 内打包瞬间的 H5 版本,H5 团队任何更新都到达不了用户 +- **决策**:在 Phase 1 视图层之前插入 4 个新子项 `1.10 RemoteConfigClient / 1.11 VersionResolver / 1.12 LocalVersionReader / 1.13 LobbyZipUpgrader`,原 `1.10-1.13` 后移为 `1.14-1.17` +- **理由**: + - **WebContainer 的 `loadFileURL` 在无升级链路前提下是有缺陷的**——只能加载 IPA 内静态 zip + - 推迟到 Phase 2 也行得通,但 Phase 1 验收"看到大厅 H5"的语义会被掩盖(看到的是 IPA 内的旧版,跟"上线后看到的"完全不同) + - 一次性补齐使 Phase 1 验收能模拟真实上线场景:模拟远端 `game_version +1` 重启 App 必须看到新版本下载 + 加载 +- **关键设计点(与 msext 实现的差异)**: + | 维度 | msext 现状 | 新外壳决策 | + |------|----------|----------| + | 网络库 | `[NSData dataWithContentsOfURL:]` 主线程阻塞 / ASIHTTPRequest 下载 | `URLSession async data(from:)` + `download(from:)`,actor 隔离 | + | 失败重试 | 4 秒等间隔暴力 `timer` | 指数退避 1/2/4 秒三次,可取消 | + | 4 级覆盖算法 | 散落 if/else 在 `getagentversion` / `getgameversion` / `chulishengji` 三方法手写 | 纯函数 reduce,单测覆盖所有边界 | + | 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`,半途崩溃留残骸 | 解压到临时目录 + 原子 rename,无半残状态 | + | 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11`,靠系统清 Caches) | 同样的原子 rename 策略,旧目录直接覆盖 | + | 解析 JSON | SBJSON 三方库 | Codable + JSONDecoder | + | 版本号类型 | NSString → intValue(自动当 0 处理) | 强类型 Int,缺失 throw 类型化错误 | +- **守护**: + - **H5 端契约不变**:H5 看到的仍是"启动一段时间后看到大厅",对 4 级覆盖 / 升级链路完全无感 + - `gameconfig` 注入值是远端 URL 的唯一驱动,**不允许把远端 URL 硬编码在 Swift 源码里** + - `version.xml` 文件名 / xpath `/game/version@value` 不允许改名(H5 自更新时会写这个文件,新外壳读它,双方协议) + - 4 级覆盖的优先级(agent > 顶层、channel > agent、market > channel、game 子树最终覆盖 agent 子树)必须由单测固化,避免回归 +- **回滚条件**:若远端 `.txt` 配置服后台被替换为 RESTful API(路径 / 字段名变化),重新评估并实现新协议;JSON 嵌套结构变化同理 +- **影响 Phase 2 及之后**: + - Phase 2 / 3 等不再需要重做远程配置;只在新加 handler 时调用现成的 `RemoteConfigClient.current` + - Phase 6 子游戏升级直接复用 `LobbyZipUpgrader` 的设计(仅参数化目录路径) + - Phase 10 多渠道打包前 `gameconfig` 注入值由 IPA 后处理工具修改(ADR-007 `plutil` 路径) + --- 文档完成日期:2026-06-21 -最后更新:2026-06-21(ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录) +最后更新:2026-06-22(ADR-008 Phase 1 补远程配置 + zip 升级 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录) diff --git a/docs/H5-Native-Implementation-Design.md b/docs/H5-Native-Implementation-Design.md index ad59560..58659cd 100644 --- a/docs/H5-Native-Implementation-Design.md +++ b/docs/H5-Native-Implementation-Design.md @@ -1022,25 +1022,207 @@ final class SigningContractTests: XCTestCase { } ``` -### 6.3 配置 / Zip 下载层 +### 6.3 远程配置 + 版本对比 + zip 升级流水线(**Phase 1 实施**,ADR-008 详细记录) + +完整链路由 4 个独立模块构成,全部 actor 隔离 + 单测覆盖: + +``` +RemoteConfigClient → VersionResolver → LocalVersionReader → LobbyZipUpgrader + (拉 .txt 配置) (4 级覆盖合并) (读 version.xml) (URLSession 下载 + 原子 rename) + ↓ ↓ ↓ ↓ + RemoteConfig ResolvedVersion (appVer, gameVer) UpgradeOutcome + {appVer, appDL, .noop / .upgraded + gameVer, gameZip} +``` + +#### 6.3.1 RemoteConfigClient ```swift -// ResourceKit/ConfigService.swift -public actor ConfigService { - private let client: HTTPClient - private let unzipper: ResourceUnzipper +// Source/Network/RemoteConfigClient.swift +public actor RemoteConfigClient { + private let session: URLSession + private let urlBuilder: () -> URL - public func syncIfNeeded() async throws { - let config = try await fetchRemoteConfig() - let local = try LocalVersion.read() - if local.gameVersion < config.gameVersion { - try await downloadAndUnzip(config.gameZipURL) + /// 默认根据 BundleConfig.shared.gameConfig 拼装: + /// http:// + gameConfig.replacingOccurrences("-", "/") + ".txt" + /// 注意:原 msext 没有 SERVERNew 前缀(NewRootVC.m:250),照搬。 + public init( + session: URLSession = .shared, + urlBuilder: @escaping () -> URL = Self.defaultURL + ) { ... } + + /// 拉远端 .txt(实际是 JSON),10s 超时。 + /// 指数退避 1/2/4 秒最多 3 次(msext 用 4s 等间隔 timer,新外壳更省电)。 + public func fetch() async throws -> RemoteConfig { + for attempt in 0..<3 { + do { return try await fetchOnce() } + catch where attempt < 2 { + try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000) + } } - // 同样比较 agent / channel / market / agentlist / gamelist + throw RemoteConfigError.allRetriesFailed + } +} + +public struct RemoteConfig: Codable, Sendable { + public let showmessage: String? + public let agentlist: [Agent]? +} +public struct Agent: Codable, Sendable { + public let agentid: String + public let showmessage: String? + public let app_version: String? + public let app_download: String? + public let game_version: String? + public let game_zip: String? + public let channellist: [Channel]? + public let gamelist: [Game]? +} +public struct Channel: Codable, Sendable { ... } +public struct Game: Codable, Sendable { ... } +public struct Market: Codable, Sendable { ... } +``` + +#### 6.3.2 VersionResolver(纯函数) + +```swift +// Source/Network/VersionResolver.swift +public enum VersionResolver { + + public struct Resolved: Sendable { + public let appVersion: Int // 0 表示远端未指定 + public let appDownload: String? + public let gameVersion: Int // 0 表示远端未指定 + public let gameZip: String? + } + + /// 4 级覆盖:顶层 → agent → channel → market,后者覆盖前者。 + /// agent 子树和 game 子树各自合并,game 子树最终优先(msext 'chulishengji' 中的语义)。 + public static func resolve( + config: RemoteConfig, + agentId: String, + channelId: String, + marketId: String, + gameId: String + ) -> Resolved { + // pure function: 不依赖任何 actor / global + // 实现:reduce over [top, agent, channel, market] + ... } } ``` +#### 6.3.3 LocalVersionReader + +```swift +// Source/Resource/LocalVersionReader.swift +public enum LocalVersionReader { + + /// 原生 IPA 版本,从 ChannelConfig.plist 的 appversion 字段(BundleConfig)。 + nonisolated public static var localAppVersion: Int { + Int(BundleConfig.shared.appVersion) ?? 0 + } + + /// 大厅 H5 包版本:从 Library/Caches/{gamedir}/{gamestart}/version.xml 解析 + /// /game/version 节点的 value 属性。XMLParser 解析,缺失 / 损坏返回 0。 + nonisolated public static var localGameVersion: Int { ... } +} +``` + +#### 6.3.4 LobbyZipUpgrader + +```swift +// Source/Resource/LobbyZipUpgrader.swift +public actor LobbyZipUpgrader { + + public enum UpgradeOutcome { + case noop // 远端版本 ≤ 本地,无需升级 + case upgraded(from: Int, to: Int) + } + + public func upgradeIfNeeded( + remoteGameVersion: Int, + remoteGameZip: String? + ) async throws -> UpgradeOutcome { + let local = LocalVersionReader.localGameVersion + guard remoteGameVersion > local, let zipURL = remoteGameZip.flatMap(URL.init) else { + return .noop + } + + // 1. 下载到 tmp(断点续传由 URLSessionDownloadTask 默认支持) + let (tmpZip, _) = try await session.download(from: zipURL) + + // 2. 解压到隔离目录 + let stagingDir = SandboxPaths.caches.appendingPathComponent("staging-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true) + try FileManager.default.unzipItem(at: tmpZip, to: stagingDir) + + // 3. 原子 rename:清旧 lobbyRoot,把 stagingDir 移到位 + let lobbyRoot = SandboxPaths.lobbyRoot + try? FileManager.default.removeItem(at: lobbyRoot) + try FileManager.default.moveItem(at: stagingDir, to: lobbyRoot) + + // 4. 清 tmp + try? FileManager.default.removeItem(at: tmpZip) + + return .upgraded(from: local, to: remoteGameVersion) + } +} +``` + +#### 6.3.5 WebContainer 调用串 + +```swift +// Source/WebView/WebContainerViewController.swift(Phase 1.14) +override func viewDidLoad() { + super.viewDidLoad() + Task { @MainActor in + try await ResourceUnzipper.shared.ensureReady() // 首装解压 Bundle 内 zip + let config = try await RemoteConfigClient.shared.fetch() + let resolved = VersionResolver.resolve( + config: config, + agentId: BundleConfig.shared.agent, + channelId: BundleConfig.shared.channel, + marketId: BundleConfig.shared.market, + gameId: BundleConfig.shared.gameId + ) + if resolved.appVersion > LocalVersionReader.localAppVersion { + // 弹窗 + Safari 外链 → resolved.appDownload + return showAppUpgradeAlert(resolved.appDownload) + } + _ = try await LobbyZipUpgrader.shared.upgradeIfNeeded( + remoteGameVersion: resolved.gameVersion, + remoteGameZip: resolved.gameZip + ) + bridgedWebView.webView.loadFileURL( + SandboxPaths.lobbyIndex, + allowingReadAccessTo: SandboxPaths.lobbyRoot + ) + } +} +``` + +#### 6.3.6 与 msext 历史实现的差异(不要照抄的部分) + +| 维度 | msext 现状(不要照抄) | 新外壳决策 | +|------|--------------------|----------| +| 网络模型 | `[NSData dataWithContentsOfURL:]` 主线程同步阻塞 + ASIHTTPRequest 后台下载 | URLSession `async data(from:)` / `download(from:)`,actor 隔离 | +| 失败重试 | `viewDidLoad` 起 4s 等间隔 `timer`,至无穷直到成功 | 指数退避 1/2/4 秒最多 3 次,超出 throw 由 UI 决定回退路径 | +| 4 级覆盖 | `getagentversion` / `getgameversion` / `chulishengji` 三段散落 if/else | 纯函数 `VersionResolver.resolve`,单测覆盖所有边界 12+ 用例 | +| 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`,半途崩溃留半残 | 解压到 `staging-{uuid}/` 临时目录 + 原子 `moveItem` rename,半途崩溃只留 tmp(下次启动可清) | +| 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11`) | 同款原子 rename,旧目录直接覆盖 | +| 配置解析 | SBJSON 三方库 | Codable + JSONDecoder | +| 版本号 | NSString → intValue(自动 0) | 强类型 Int,缺失明示 | + +#### 6.3.7 子游戏升级复用 + +Phase 6 子游戏(`SwitchOverGameData` 入参的 `Gamedirectory` / `gamedownloadurl`)的升级逻辑**复用** `LobbyZipUpgrader` 的设计,差异仅在: +- 目标路径不同(`SandboxPaths.subGameRoot(dir)` 而非 `lobbyRoot`) +- 版本号 source 不同(子游戏 `version.xml` 而非大厅) +- 不重新拉远端配置(继承大厅的 `RemoteConfig`) + +具体接口扩展到 Phase 6 设计时定。 + --- ## 7. 资源 & 渠道注入