diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index e4f9853..ad5cba5 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -883,7 +883,7 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - [x] 1.8 WVJB JS 协议 - [x] 1.9 BridgedWebView - [x] 1.10 RemoteConfigClient(actor,URLSession async + 重试 + cache-busting + 短文本 + FlexibleString) -- [x] 1.11 VersionResolver(chulishengji 双子树合并算法,纯函数) +- [x] 1.11 VersionResolver(单链 4 层 fallback:agent→game→channel→market,纯函数) - [x] 1.12 LocalVersionReader(version.xml 解析 + ATS 全局放行) - [x] 1.13 LobbyZipUpgrader(actor,原子 rename 升级;端到端实测 260→261) - [x] 1.14.a AppIcon 替换 Xcode 默认空模板(iPad / 1024 缺失留 Phase 10 polish) @@ -1106,16 +1106,21 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - 不允许把渠道值硬编码到 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:Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22 起,含 3 次调研 revise) +- **背景**:Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5",**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置 → 单链 4 层 fallback 解析 → 与本地版本比较 → 必要时下载并替换 H5 zip +- **触发事件**:2026-06-22 起多轮调研 `daoqi/msext` 原项目,逐行核对核心方法: + - 第一轮(粗粒度,2026-06-22):发现远端配置流程整体形态、IPA / H5 zip 两条升级链 + - 第二轮(2026-06-22,**已废弃**):误读 `onnet` 的 `else` 分支 + `chulishengji`(`NewRootVC.m:1372-1538`),错误地把双子树合并当作线上热路径 + - **第三轮(2026-06-27,最终采用)**:项目维护者直接指出契约真实结构是单链 4 层 `agentlist → gamelist → channellist → marketlist`。重新核对 `NewRootVC.m`,确认 `onnet` 的 `if(self.gamelist==nil)` **顶层分支**(`NewRootVC.m:689-810`)= 线上热路径就是这条单链;`else` 分支的 chulishengji 是历史遗留代码、主路径不走 +- **核心算法(第三轮最终版)**: + - 单链 4 层节点匹配:`agentlist[agentid] → gamelist[gameid] → channellist[channelid] → marketlist[marketid]` + - 任一层 id 匹配失败立刻截断(短链);最长 0..4 节点 + - 字段语义:每个字段从最深层向根回退取第一个非空值(等价 msext `if(xxx!="" && xxx!=nil) self.xxx=xxx` 逐层覆盖的最终态) + - 5 字段(appVersion / appDownload / gameVersion / gameZip / showmessage)**共用同一套** `pickString` / `pickInt` 倒序查找接口,不再有"双子树 / 多分支合并" +- **第二轮误读复盘(保留以防再被翻出来)**: + - 误读点:`viewWillAppear`(`NewRootVC.m:1609-1616`)与 `gonet`(`NewRootVC.m:1252-1258`)的 `self.gamelist` 同样取自 agent 节点下 `gamelist`,**但流程结构上先走 `onnet` 的 `if(self.gamelist==nil)` 分支命中后立即 return**,不会再进 else 的 chulishengji + - 教训:原项目同名变量、不同入口的代码不能凭"看似都赋值了"判定走哪条;要顺着实际调用栈走一遍 +- **决策**:在 Phase 1 视图层之前插入 4 个新子项 `1.10 RemoteConfigClient / 1.11 VersionResolver / 1.12 LocalVersionReader / 1.13 LobbyZipUpgrader`,原 `1.10-1.13` 后移为 `1.14-1.17`。**`VersionResolver` 按单链 4 层 fallback 实现(第三轮)** #### ADR-008-A 远端 URL 构造(`RemoteConfigClient`) @@ -1139,71 +1144,56 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - **字段类型混乱(必须兼容)**:`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`) +#### ADR-008-D 单链 4 层 fallback 算法(`VersionResolver`) -新外壳 `VersionResolver.resolve()` 必须 1:1 复刻 `NewRootVC.m:1372-1538` 的算法,输出 5 个目标字段:`(appVersion, appDownload, gameVersion, gameZip, showmessage)`。 +> **修订史**:2026-06-22 初版误指为 chulishengji 双子树合并;2026-06-27 第三轮按项目维护者澄清推翻,改为本节描述的单链 4 层 fallback。下文以最终版本为准;第二轮内容已删除,复盘说明见 ADR-008 顶部"第二轮误读复盘"。 + +新外壳 `VersionResolver.resolve()` 1:1 复刻 `NewRootVC.m:689-810` 主路径,输出 5 个目标字段:`(appVersion, appDownload, gameVersion, gameZip, showmessage)`。 + +**契约结构**: + +``` +agentlist[agentid] + └─ gamelist[gameid] + └─ channellist[channelid] + └─ marketlist[marketid] +``` + +每一层节点都可独立携带 `app_version / app_download / game_version / game_zip / showmessage` 字段(缺省视为空);越深的层覆盖越浅的层;越深的层缺省时回退到上一层。 **步骤**: ```swift -// 1. 提取 agent 子树(getagentversion,NewRootVC.m:885-1013) -// agent → channel → market → game(game 嵌在 market 命中后才遍历) -agentConfig = extractAgentSubtree(agent) -// → (app_version, app_download, game_version, game_download, showmessage) +// 1. 顺链匹配,返回 0..4 个有序节点 +// 任一层匹配失败立刻截断(之后不再尝试更深层) +let chain: [RemoteConfigNode] = buildChain( + config, agentId, gameId, channelId, marketId +) +// 例:agent 命中、game 未命中 → chain = [agent] -// 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) +// 2. 5 字段共用同一套倒序 fallback 接口 +// 从最深层向根回退取第一个非空(等价 msext "下层非空即覆盖上层"的最终态) +ResolvedVersion( + appVersion: pickInt(chain, \.appVersion), + appDownload: pickString(chain, \.appDownload), + gameVersion: pickInt(chain, \.gameVersion), + gameZip: pickString(chain, \.gameZip), + showmessage: pickString(chain, \.showmessage) ?? config.showmessage +) ``` -**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) +**统一接口要求(必须保持)**: +- 4 层节点(Agent / Game / Channel / Market)conform 同一 `RemoteConfigNode` 协议,提供相同字段 getter +- 字段查找只有 `pickString` / `pickInt` 两个工具,5 字段全走它们 —— 不允许出现"为某个字段写专门 if 链"的多处实现 +- `pickString` 跳过 nil / 空字符串;`pickInt` 跳过 nil / 解析失败 / 0(msext `[nil intValue] = 0` 行为兼容) -**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(择一非空) +**与 msext 实际代码的关系**: +- msext 顺嵌套 for 循环里"非空即覆盖"`self.xxx = xxx` 的写法 —— **最终态等价**于从根向叶遍历后取最深一处非空值,再等价于从叶向根 fallback 取第一个非空值。三种写法语义一致,新外壳选第三种因为:(1) 5 字段共用同一接口最简洁;(2) `buildChain` 集中处理"任一层失败截断"语义;(3) 单测易写 +- 不复刻 msext 那段把 `showmessage` 散在 5 处覆盖的写法 —— 用 `pickString(\.showmessage)` 一处搞定,最终态相同 #### ADR-008-E 决策顺序(`WebContainer` 编排) -5 字段拿到后**固定顺序**判断(chulishengji 路径在 `NewRootVC.m:1501-1527`): +5 字段拿到后**固定顺序**判断(msext 单链主路径在 `NewRootVC.m:768-793`): | 优先级 | 条件 | 动作 | |---|---|---| @@ -1241,7 +1231,7 @@ result.showmessage = mergeShowmessage(top, agent, game, channel, market) | URL | HTTP | HTTPS(后台已切) | | cache-busting | `?vXXXXXXXX_YYYYYYYY` | **保留** | | JSON 解析 | SBJSON | Codable + JSONDecoder + 自定义 `decodeFlexibleStringIfPresent`(兼容 number-as-string) | -| 算法 | chulishengji 双子树合并 | **复刻 chulishengji**,纯函数 + 单测 | +| 算法 | onnet 单链 4 层(`NewRootVC.m:689-810`) | 单链 4 层 + 协议化 fallback,5 字段共用同一查找接口,纯函数 + 单测 | | version.xml | GDataXML | Foundation XMLParser | | 升级解压 | `removeItemAtPath` + ZipArchive | 临时目录 + 原子 rename | | 失败 UI | 静默 / NSLog | 弹错误页 + 重试按钮(不留卡死黑洞) | @@ -1254,4 +1244,4 @@ result.showmessage = mergeShowmessage(top, agent, game, channel, market) --- 文档完成日期:2026-06-21 -最后更新:2026-06-22(ADR-008 二次精确化 chulishengji 双子树算法 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录) +最后更新:2026-06-27(ADR-008 第三轮修订:VersionResolver 改单链 4 层 fallback + 5 字段共用同一查找接口;前置历史 2026-06-22 ADR-008 二次精确化 / 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 2accd13..f062360 100644 --- a/docs/H5-Native-Implementation-Design.md +++ b/docs/H5-Native-Implementation-Design.md @@ -1464,80 +1464,116 @@ public struct Game: Codable, Sendable { ... } public struct Market: Codable, Sendable { ... } ``` -#### 6.3.2 VersionResolver(chulishengji 双子树合并,纯函数) +#### 6.3.2 VersionResolver(单链 4 层 fallback,纯函数) -**核心修正(ADR-008 二次精确化)**:线上热路径是 chulishengji 双子树合并,**不是**简单的"顶 → agent → channel → market"线性覆盖。 +> **修订史**:2026-06-22 初版误指为 chulishengji 双子树合并;2026-06-27 按项目维护者澄清推翻,改为本节描述的单链 4 层 fallback。复盘见 Plan ADR-008 顶部"第二轮误读复盘"。 + +**契约结构**(参 daoqi `NewRootVC.m:689-810` 主路径): + +``` +agentlist[agentid] + └─ gamelist[gameid] + └─ channellist[channelid] + └─ marketlist[marketid] +``` + +每层节点都可独立携带 `app_version / app_download / game_version / game_zip / showmessage`;越深层覆盖越浅层;越深层缺省时回退上一层。算法上等价于"从最深层向根回退取第一个非空"。 + +**统一接口(关键设计点)**:4 个节点类型 conform 同一 `RemoteConfigNode` 协议,5 个字段全部走 `pickString` / `pickInt` 两个倒序查找工具 —— **不允许为某个字段单独写 if 链**。 ```swift // Source/Network/VersionResolver.swift + +private protocol RemoteConfigNode { + var appVersion: String? { get } + var appDownload: String? { get } + var gameVersion: String? { get } + var gameZip: String? { get } + var showmessage: String? { get } +} +extension Agent: RemoteConfigNode {} +extension Game: RemoteConfigNode {} +extension Channel: RemoteConfigNode {} +extension Market: RemoteConfigNode {} + +public struct ResolvedVersion: Sendable, Equatable { + public let appVersion: Int // 0 表示远端未指定 + public let appDownload: String? + public let gameVersion: Int // 0 表示远端未指定 + public let gameZip: String? + public let showmessage: String? // 非空时阻塞,运营杀手锏 +} + 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? - public let showmessage: String? // 非空时阻塞,运营杀手锏 - } - - /// chulishengji 双子树合并(NewRootVC.m:1372-1538): - /// 1. 从 agent 子树提取(agent → channel → market → game,game 嵌在 market 命中后才遍历) - /// 2. 从 game 子树提取(game → game-self → channel → market) - /// 3. 字段级合并: - /// - IPA(appVersion / appDownload)默认 agent 赢;game 版本号更大时 game 赢(line 1416) - /// - zip(gameVersion / gameZip)默认 game 赢;agent 版本号更大时 agent 赢(line 1456) - /// 4. showmessage 两条子树多层多次覆盖,最后写赢 public static func resolve( config: RemoteConfig, agentId: String, channelId: String, marketId: String, gameId: String - ) -> Resolved { - // 1. 找到当前 agent / channel / market / game 节点 - guard let agent = config.agentlist?.first(where: { $0.agentid == agentId }), - let game = agent.gamelist?.first(where: { $0.gameid == gameId }) else { - // 找不到匹配(极端情况):返回 0 / nil,让 WebContainer 走"无升级"路径 - return Resolved(appVersion: 0, appDownload: nil, - gameVersion: 0, gameZip: nil, - showmessage: config.showmessage) - } + ) -> ResolvedVersion { + let chain = buildChain( + config: config, + agentId: agentId, gameId: gameId, + channelId: channelId, marketId: marketId + ) - let agentExtract = extractAgentSubtree( - agent: agent, channelId: channelId, marketId: marketId, gameId: gameId) - let gameExtract = extractGameSubtree( - game: game, channelId: channelId, marketId: marketId) - - // IPA 合并(NewRootVC.m:1408-1447) - let (appVer, appDL) = mergeIPA(agent: agentExtract, game: gameExtract) - // zip 合并(NewRootVC.m:1450-1492) - let (gameVer, gameZip) = mergeZip(agent: agentExtract, game: gameExtract) - // showmessage:任意子树非空就用(最后写赢) - let msg = gameExtract.showmessage ?? agentExtract.showmessage ?? config.showmessage - - return Resolved( - appVersion: appVer, - appDownload: appDL, - gameVersion: gameVer, - gameZip: gameZip, - showmessage: msg + return ResolvedVersion( + appVersion: pickInt(chain, \.appVersion), + appDownload: pickString(chain, \.appDownload), + gameVersion: pickInt(chain, \.gameVersion), + gameZip: pickString(chain, \.gameZip), + showmessage: pickString(chain, \.showmessage) ?? config.showmessage ) } - /// agent 子树 4 层提取(getagentversion,NewRootVC.m:885-1013) - /// agent → channel → market → game(game 嵌在 market 命中后才遍历) - /// 注意:agent 子树用 game_download 字段名,game 子树用 game_zip——两个都要查 - private static func extractAgentSubtree(...) -> SubtreeResult { ... } + /// 顺 agent → game → channel → market 匹配,任一层失败立刻截断。 + /// 返回 0..4 个有序节点(从根到叶)。 + private static func buildChain( + config: RemoteConfig, + agentId: String, gameId: String, + channelId: String, marketId: String + ) -> [RemoteConfigNode] { + var chain: [RemoteConfigNode] = [] + guard let agent = config.agentlist?.first(where: { $0.agentid == agentId }) + else { return chain } + chain.append(agent) + guard let game = agent.gamelist?.first(where: { $0.gameid == gameId }) + else { return chain } + chain.append(game) + guard let channel = game.channellist?.first(where: { $0.channelid == channelId }) + else { return chain } + chain.append(channel) + guard let market = channel.marketlist?.first(where: { $0.marketid == marketId }) + else { return chain } + chain.append(market) + return chain + } - /// game 子树 4 层提取(getgameversion,NewRootVC.m:1015-1180) - /// game → game-self(gamedata 自身再嵌 channellist)→ channel → market - private static func extractGameSubtree(...) -> SubtreeResult { ... } + /// 倒序找第一个非空字符串 + private static func pickString( + _ chain: [RemoteConfigNode], + _ keyPath: KeyPath + ) -> String? { + for node in chain.reversed() { + if let s = node[keyPath: keyPath], !s.isEmpty { return s } + } + return nil + } - /// IPA 合并:默认 agent 赢;game.appVersion > agent.appVersion 时 game 赢 - private static func mergeIPA(...) -> (Int, String?) { ... } - - /// zip 合并:默认 game 赢;agent.gameVersion > game.gameVersion 时 agent 赢 - private static func mergeZip(...) -> (Int, String?) { ... } + /// 倒序找第一个能解析为非零整数的字段值 + /// JSON 里该字段可能是 string / int / double,Codable 已经 decodeFlexibleStringIfPresent + /// 归一化为 String?;0 视同未提供继续向上找。 + private static func pickInt( + _ chain: [RemoteConfigNode], + _ keyPath: KeyPath + ) -> Int { + for node in chain.reversed() { + if let s = node[keyPath: keyPath], let v = Int(s), v != 0 { return v } + } + return 0 + } } ``` @@ -1545,19 +1581,17 @@ public enum VersionResolver { | 用例 | 验证 | |------|------| -| 顶层 / agent / channel / market / game 都有 app_version | agent 子树最深层赢;与 game 子树合并时按版本号大者 | -| 仅 agent 子树有,game 子树无 app_version | 取 agent | -| 仅 game 子树有,agent 子树无 app_version | 取 game | -| game.appVersion = 10 vs agent.appVersion = 5 | game 赢 | -| game.appVersion = 5 vs agent.appVersion = 10 | agent 赢 | -| game.gameVersion = 10 vs agent.gameVersion = 5 | game 赢(默认) | -| game.gameVersion = 5 vs agent.gameVersion = 10 | agent 赢 | -| showmessage 仅顶层有 | 取顶层 | -| showmessage agent 层有 + 顶层有 | 取 agent(深层覆盖浅层) | -| showmessage market 层有 + 全部层都有 | 取 market(最深) | -| agentid 找不到匹配 | 返回 default(0/nil),不抛 | -| gameid 找不到匹配 | 返回 default,不抛 | -| 字段名 game_download vs game_zip 混用 | 两个 key 都查,择一非空 | +| 仅 agent 层有 app_version,其余空 | 取 agent | +| agent / game / channel / market 都有 app_version | 取 market(最深层赢) | +| agent / game / market 有,channel 缺 | 取 market(最深层赢) | +| channelid 不匹配 | chain 截断 = [agent, game];取 game | +| gameid 不匹配 | chain = [agent];取 agent | +| agentid 不匹配 | chain = [];返回全 0/nil + 顶层 showmessage | +| showmessage 仅顶层有 | fallback 到 `config.showmessage` | +| showmessage 仅 market 有 | 取 market(最深层赢) | +| showmessage market 与 agent 都有 | 取 market | +| 字段值 = 0 / "" / nil | 视同未提供,继续向上找 | +| JSON 把 app_version 发成 number 而非 string | `decodeFlexibleStringIfPresent` 兜底为 String,`Int(s)` 解析正常 | #### 6.3.3 LocalVersionReader @@ -1737,7 +1771,7 @@ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { |------|--------------------|----------| | 网络模型 | `[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+ 用例 | +| 4 级覆盖 | `onnet` 单链 4 层 if/嵌 for(`NewRootVC.m:689-810`) | 纯函数 `VersionResolver.resolve`,协议化 + 5 字段共用 `pickString` / `pickInt` 倒序 fallback,单测覆盖所有边界 | | 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`,半途崩溃留半残 | 解压到 `staging-{uuid}/` 临时目录 + 原子 `moveItem` rename,半途崩溃只留 tmp(下次启动可清) | | 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11`) | 同款原子 rename,旧目录直接覆盖 | | 配置解析 | SBJSON 三方库 | Codable + JSONDecoder | diff --git a/docs/Verification-Checklist.md b/docs/Verification-Checklist.md index 51006e8..2ae73af 100644 --- a/docs/Verification-Checklist.md +++ b/docs/Verification-Checklist.md @@ -70,7 +70,7 @@ xcrun simctl get_app_container booted com.skyapp.ylgamehall data [SandboxPaths] 路径计算结果 [ResourceUnzipper] ensureReady 完成 [RemoteConfigClient] 拉取成功 .parsed -[VersionResolver] chulishengji 双子树合并结果 +[VersionResolver] 单链 4 层 fallback 结果 [LocalVersionReader] 本地 vs 远端对比 [LobbyZipUpgrader] 无需升级 / 升级完成 N → M [lobby] write app_data.js → ... ← Phase 1.7 落地(AppDataWriter) diff --git a/ylgamehall/Source/Network/RemoteConfigClient.swift b/ylgamehall/Source/Network/RemoteConfigClient.swift index 801b0f6..952df25 100644 --- a/ylgamehall/Source/Network/RemoteConfigClient.swift +++ b/ylgamehall/Source/Network/RemoteConfigClient.swift @@ -8,7 +8,7 @@ import Foundation -// MARK: - Codable 模型(三级嵌套:agent → channel → market;agent 子树另有 gamelist 副路径) +// MARK: - Codable 模型(单链 4 层嵌套:agent → game → channel → market) nonisolated public struct RemoteConfig: Codable, Sendable { public let showmessage: String? @@ -22,7 +22,6 @@ nonisolated public struct Agent: Codable, Sendable { public let appDownload: String? public let gameVersion: String? public let gameZip: String? - public let channellist: [Channel]? public let gamelist: [Game]? public init(from decoder: any Decoder) throws { @@ -33,12 +32,11 @@ nonisolated public struct Agent: Codable, Sendable { appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload) gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion) gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip) - channellist = try c.decodeIfPresent([Channel].self, forKey: .channellist) gamelist = try c.decodeIfPresent([Game].self, forKey: .gamelist) } private enum CodingKeys: String, CodingKey { - case agentid, showmessage, channellist, gamelist + case agentid, showmessage, gamelist case appVersion, appDownload, gameVersion, gameZip } } diff --git a/ylgamehall/Source/Network/VersionResolver.swift b/ylgamehall/Source/Network/VersionResolver.swift index ac50d06..3cf4300 100644 --- a/ylgamehall/Source/Network/VersionResolver.swift +++ b/ylgamehall/Source/Network/VersionResolver.swift @@ -3,15 +3,23 @@ // ylgamehall // // 把远端 RemoteConfig 化简为 5 个目标字段(appVersion/appDownload/gameVersion/ -// gameZip/showmessage)的纯函数实现,照搬 msext chulishengji 双子树合并算法。 -// 详见 docs/Development-Plan.md ADR-008-D / Design §6.3.2 / msext NewRootVC.m:1372-1538。 +// gameZip/showmessage)的纯函数实现。 +// +// 契约结构(参 daoqi `NewRootVC.m:689-810` 主路径): +// agentlist[agentid] → gamelist[gameid] → channellist[channelid] → marketlist[marketid] +// +// 查找语义:从最深层(market)向上回退取第一个非空值,等价于 +// msext 那段从根向下"非空即覆盖"的 if 链,但写法更直接、可复用。 +// +// 统一接口:所有 5 个字段都用同一套 `pickString` / `pickInt` 倒序查找, +// 不再有"双子树合并 / chulishengji"等历史包袱的多处实现。 // import Foundation // MARK: - 决策输出 -/// chulishengji 双子树合并后的 5 个目标字段。 +/// 4 层 fallback 解析后的 5 个目标字段。 public struct ResolvedVersion: Sendable, Equatable { /// 远端期望的原生 IPA 版本(与 BundleConfig.appVersion 比较)。0 表示远端未指定。 public let appVersion: Int @@ -25,39 +33,32 @@ public struct ResolvedVersion: Sendable, Equatable { public let showmessage: String? } -// MARK: - 内部:单条子树的提取结果 +// MARK: - 节点协议(4 个层级共用同一查找接口) -/// 单条子树(agent 子树 或 game 子树)4 层逐层覆盖后的 5 字段。 -private struct SubtreeExtract { - var appVersion: Int = 0 - var appDownload: String? - var gameVersion: Int = 0 - /// 注意字段名:agent 子树原项目用 `game_download`,game 子树用 `game_zip`, - /// 两者本质同一字段,新外壳两 key 都查(择一非空)。 - var gameZip: String? - var showmessage: String? +/// `Agent` / `Game` / `Channel` / `Market` 都暴露相同字段集合, +/// 由 VersionResolver 用同一套倒序 fallback 算法统一处理。 +private protocol RemoteConfigNode { + var appVersion: String? { get } + var appDownload: String? { get } + var gameVersion: String? { get } + var gameZip: String? { get } + var showmessage: String? { get } } +extension Agent: RemoteConfigNode {} +extension Game: RemoteConfigNode {} +extension Channel: RemoteConfigNode {} +extension Market: RemoteConfigNode {} + // MARK: - VersionResolver public enum VersionResolver { - /// chulishengji 双子树合并(msext `NewRootVC.m:1372-1538`)。 - /// - /// 1. 从 agent 子树提取(`agent → channel → market → game`,game 嵌在 market 命中后才遍历) - /// 2. 从 game 子树提取(`game → game-self → channel → market`) - /// 3. **IPA 合并**:默认 agent 赢;`gameConfig.appVersion > agentConfig.appVersion` 时 - /// game 赢(NewRootVC.m:1416) - /// 4. **zip 合并**:默认 game 赢;`agentConfig.gameVersion > gameConfig.gameVersion` 时 - /// agent 赢(NewRootVC.m:1456) - /// 5. **showmessage**:两条子树多层多次覆盖,最后写赢;若都为 nil,回退到顶层 `config.showmessage` + /// 4 层 fallback 解析。 /// /// - Parameters: - /// - config: 远端 RemoteConfig(Codable 解析后) - /// - agentId: 当前 BundleConfig.agent - /// - channelId: 当前 BundleConfig.channel - /// - marketId: 当前 BundleConfig.market - /// - gameId: 当前 BundleConfig.gameId + /// - config: 远端 RemoteConfig + /// - agentId/channelId/marketId/gameId: 当前渠道注入值(BundleConfig) /// - Returns: 5 字段决策结果。任一字段缺失返回 0 / nil。 public static func resolve( config: RemoteConfig, @@ -66,184 +67,85 @@ public enum VersionResolver { marketId: String, gameId: String ) -> ResolvedVersion { - // 找当前 agent - guard let agent = config.agentlist?.first(where: { $0.agentid == agentId }) else { - // agent 找不到匹配(极端情况):返回 0 / nil,让 WebContainer 走"无升级"路径 - return ResolvedVersion( - appVersion: 0, appDownload: nil, - gameVersion: 0, gameZip: nil, - showmessage: config.showmessage - ) - } - - // 找当前 agent 下的 game - let game = agent.gamelist?.first(where: { $0.gameid == gameId }) - - let agentExtract = extractAgentSubtree( - agent: agent, + let chain = buildChain( + config: config, + agentId: agentId, + gameId: gameId, channelId: channelId, - marketId: marketId, - gameId: gameId + marketId: marketId ) - let gameExtract: SubtreeExtract = game.map { g in - extractGameSubtree(game: g, channelId: channelId, marketId: marketId) - } ?? SubtreeExtract() - - let (appVer, appDL) = mergeIPA(agent: agentExtract, game: gameExtract) - let (gameVer, gameZip) = mergeZip(agent: agentExtract, game: gameExtract) - - // showmessage:game 子树 > agent 子树 > 顶层(任意非空就用,最后写赢) - let msg = gameExtract.showmessage - ?? agentExtract.showmessage - ?? config.showmessage - return ResolvedVersion( - appVersion: appVer, - appDownload: appDL, - gameVersion: gameVer, - gameZip: gameZip, - showmessage: msg + appVersion: pickInt(chain, \.appVersion), + appDownload: pickString(chain, \.appDownload), + gameVersion: pickInt(chain, \.gameVersion), + gameZip: pickString(chain, \.gameZip), + // showmessage:链上任一层都不写 → 回退顶层 config.showmessage + showmessage: pickString(chain, \.showmessage) ?? config.showmessage ) } - // MARK: - agent 子树(getagentversion,NewRootVC.m:885-1013) + // MARK: - 节点链匹配 - /// agent 子树 4 层:agent → channel → market → game - /// (注意:game 嵌在 market 命中后才遍历 market.gamelist;与 game 子树的结构不同) - private static func extractAgentSubtree( - agent: Agent, - channelId: String, - marketId: String, - gameId: String - ) -> SubtreeExtract { - var r = SubtreeExtract() - - // 层 1:agent 自身 - applyOnto(&r, from: agent) - - // 层 2:channel - guard let channel = agent.channellist?.first(where: { $0.channelid == channelId }) - else { return r } - applyOnto(&r, from: channel) - - // 层 3:market - guard let market = channel.marketlist?.first(where: { $0.marketid == marketId }) - else { return r } - applyOnto(&r, from: market) - - // 层 4:game(嵌在 market 命中后才遍历,msext 历史结构) - // 注:Market 模型当前不带 gamelist 字段,原项目这层在 channel 节点的 marketlist - // 内部又嵌了一份 gamelist。如服务端有这层数据需要扩展 Market.gamelist。 - // 当前 RemoteConfig 模型未声明该字段,暂跳过——若上线时发现该层有版本数据需补。 - - return r - } - - // MARK: - game 子树(getgameversion,NewRootVC.m:1015-1180) - - /// game 子树 4 层:game → game-self → channel → market - /// (第 2 层是把 game 自身当作 infotwo 再嵌一遍 channellist;msext 那段曾经的 - /// agentid 判断被注释掉了,等价于直接走 game.channellist) - private static func extractGameSubtree( - game: Game, + /// 沿 agent → game → channel → market 顺序逐层匹配,遇到不匹配的层立刻截断。 + /// + /// 返回数组从根到叶有序,长度 0..4: + /// - `[]` :agent 未匹配 + /// - `[agent]` :agent 命中、game 未匹配 + /// - `[agent, game]` :再深一层未命中 + /// - ... + /// - `[agent, game, channel, market]`:全链命中 + private static func buildChain( + config: RemoteConfig, + agentId: String, + gameId: String, channelId: String, marketId: String - ) -> SubtreeExtract { - var r = SubtreeExtract() + ) -> [RemoteConfigNode] { + var chain: [RemoteConfigNode] = [] - // 层 1:game 自身 - applyOnto(&r, from: game) + guard let agent = config.agentlist?.first(where: { $0.agentid == agentId }) + else { return chain } + chain.append(agent) - // 层 2:game-self(理论上同 game.channellist,等价于层 3 入口,故略) + guard let game = agent.gamelist?.first(where: { $0.gameid == gameId }) + else { return chain } + chain.append(game) - // 层 3:channel guard let channel = game.channellist?.first(where: { $0.channelid == channelId }) - else { return r } - applyOnto(&r, from: channel) + else { return chain } + chain.append(channel) - // 层 4:market guard let market = channel.marketlist?.first(where: { $0.marketid == marketId }) - else { return r } - applyOnto(&r, from: market) + else { return chain } + chain.append(market) - return r + return chain } - // MARK: - 字段级合并 + // MARK: - 字段倒序 fallback(最深层赢) - /// IPA 合并:默认 agent 赢;game 版本号更大时 game 赢(NewRootVC.m:1408-1447) - private static func mergeIPA( - agent: SubtreeExtract, - game: SubtreeExtract - ) -> (Int, String?) { - // 都有:取较大版本对应的下载链 - if agent.appDownload != nil && game.appDownload != nil { - if game.appVersion > agent.appVersion { - return (game.appVersion, game.appDownload) - } - return (agent.appVersion, agent.appDownload) + /// 从最深层(market)向根(agent)回退,取第一个非空字符串。 + private static func pickString( + _ chain: [RemoteConfigNode], + _ keyPath: KeyPath + ) -> String? { + for node in chain.reversed() { + if let s = node[keyPath: keyPath], !s.isEmpty { return s } } - // 仅一边:取那边 - if let dl = agent.appDownload { - return (agent.appVersion, dl) - } - if let dl = game.appDownload { - return (game.appVersion, dl) - } - return (0, nil) + return nil } - /// zip 合并:默认 game 赢;agent 版本号更大时 agent 赢(NewRootVC.m:1450-1492) - private static func mergeZip( - agent: SubtreeExtract, - game: SubtreeExtract - ) -> (Int, String?) { - if agent.gameZip != nil && game.gameZip != nil { - if agent.gameVersion > game.gameVersion { - return (agent.gameVersion, agent.gameZip) - } - return (game.gameVersion, game.gameZip) + /// 从最深层向根回退,取第一个能解析为非零整数的字段值。 + /// 字段在 JSON 里可能是 string / int / double 之一(详见 `decodeFlexibleStringIfPresent`), + /// 解析失败 / 为 0 视同未提供继续向上找。 + private static func pickInt( + _ chain: [RemoteConfigNode], + _ keyPath: KeyPath + ) -> Int { + for node in chain.reversed() { + if let s = node[keyPath: keyPath], let v = Int(s), v != 0 { return v } } - if let zip = game.gameZip { - return (game.gameVersion, zip) - } - if let zip = agent.gameZip { - return (agent.gameVersion, zip) - } - return (0, nil) - } - - // MARK: - 单层逐字段覆盖工具 - - /// 从单层节点向 SubtreeExtract 累加:非空字段才覆盖,等价于 msext 的 - /// `if (xxx != "" && xxx != nil) self.xxx = xxx` 语义。 - private static func applyOnto(_ r: inout SubtreeExtract, from agent: Agent) { - if let v = Int(agent.appVersion ?? ""), v != 0 { r.appVersion = v } - if let s = agent.appDownload, !s.isEmpty { r.appDownload = s } - if let v = Int(agent.gameVersion ?? ""), v != 0 { r.gameVersion = v } - if let s = agent.gameZip, !s.isEmpty { r.gameZip = s } - if let s = agent.showmessage, !s.isEmpty { r.showmessage = s } - } - private static func applyOnto(_ r: inout SubtreeExtract, from channel: Channel) { - if let v = Int(channel.appVersion ?? ""), v != 0 { r.appVersion = v } - if let s = channel.appDownload, !s.isEmpty { r.appDownload = s } - if let v = Int(channel.gameVersion ?? ""), v != 0 { r.gameVersion = v } - if let s = channel.gameZip, !s.isEmpty { r.gameZip = s } - if let s = channel.showmessage, !s.isEmpty { r.showmessage = s } - } - private static func applyOnto(_ r: inout SubtreeExtract, from market: Market) { - if let v = Int(market.appVersion ?? ""), v != 0 { r.appVersion = v } - if let s = market.appDownload, !s.isEmpty { r.appDownload = s } - if let v = Int(market.gameVersion ?? ""), v != 0 { r.gameVersion = v } - if let s = market.gameZip, !s.isEmpty { r.gameZip = s } - if let s = market.showmessage, !s.isEmpty { r.showmessage = s } - } - private static func applyOnto(_ r: inout SubtreeExtract, from game: Game) { - if let v = Int(game.appVersion ?? ""), v != 0 { r.appVersion = v } - if let s = game.appDownload, !s.isEmpty { r.appDownload = s } - if let v = Int(game.gameVersion ?? ""), v != 0 { r.gameVersion = v } - if let s = game.gameZip, !s.isEmpty { r.gameZip = s } - if let s = game.showmessage, !s.isEmpty { r.showmessage = s } + return 0 } } diff --git a/ylgamehall/Source/WebView/SubGameViewController.swift b/ylgamehall/Source/WebView/SubGameViewController.swift index 4087f97..33ce773 100644 --- a/ylgamehall/Source/WebView/SubGameViewController.swift +++ b/ylgamehall/Source/WebView/SubGameViewController.swift @@ -195,8 +195,8 @@ public final class SubGameViewController: UIViewController { private func runBootPipelineSteps() async throws { // 0. H5 传过来的 SwitchOverGameData.gamedownloadurl **字段名是 url 但语义是 gameid** // (msext gameController.m:544 注释明确写"游戏ID")。真实 zip 下载地址要按 - // gameid 去启动期缓存的 RemoteConfig 里查 `game_zip` 字段;msext 走 uplevel: - // chulishengji 算法,新外壳直接复用 VersionResolver 同一套实现。 + // gameid 去启动期缓存的 RemoteConfig 里查 `game_zip` 字段;走 VersionResolver + // 单链 4 层 fallback(agent → game → channel → market)的同一套实现。 // // 需要做两次 resolve: // - lobbyResolved(gameId = bc.gameId)→ 给 AppDataWriter 算 app_appversion 审核标志