docs:Plan / Design 补 Phase 1 远程配置 + zip 升级流水线(ADR-008)
调研 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
This commit is contained in:
+105
-36
@@ -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(`<html>` 含一个 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/ 目录记录)
|
||||
|
||||
@@ -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. 资源 & 渠道注入
|
||||
|
||||
Reference in New Issue
Block a user