1.11 修订:VersionResolver 改单链 4 层 fallback,删除双子树合并算法(契约影响)

【契约影响】
- RemoteConfig 模型:Agent 节点移除 channellist 字段(线上 JSON 实际只挂 gamelist;此前误读 chulishengji 引入的 channellist 是空 schema)
- VersionResolver 解析算法:从"双子树 + 字段级合并"改为"agentlist → gamelist → channellist → marketlist 单链 4 层 fallback",行为按 daoqi NewRootVC.m:689-810 主路径
- 对外 API ResolvedVersion / resolve(config:agentId:channelId:marketId:gameId:) 签名零变化,WebContainerViewController / SubGameViewController 调用点未动

【设计要点】
- 4 节点类型 conform 同一 private protocol RemoteConfigNode
- 5 字段共用 pickString / pickInt 两个倒序 fallback 工具,不允许为某字段单独写 if 链
- buildChain 集中处理"任一层 id 不匹配立刻截断"语义

【文档同步】
- Design §6.3.2 整段重写
- Plan ADR-008 顶部追加"第三轮修订(2026-06-27)"小节 + 第二轮误读复盘
- Plan ADR-008-D 替换为单链 4 层算法说明 + 保留修订史
- Plan §1.11 / ADR-008-I / 最后更新日期 / Verification-Checklist L73 同步

参考契约章节:docs/H5-Native-Implementation-Design.md §6.3.2、docs/Development-Plan.md ADR-008-D
BuildProject 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-27 20:04:19 +08:00
co-authored by Claude Opus 4.7
parent 90a523736e
commit 8125dd4d11
6 changed files with 249 additions and 325 deletions
+56 -66
View File
@@ -883,7 +883,7 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
- [x] 1.8 WVJB JS 协议 - [x] 1.8 WVJB JS 协议
- [x] 1.9 BridgedWebView - [x] 1.9 BridgedWebView
- [x] 1.10 RemoteConfigClientactorURLSession async + 重试 + cache-busting + 短文本 + FlexibleString - [x] 1.10 RemoteConfigClientactorURLSession async + 重试 + cache-busting + 短文本 + FlexibleString
- [x] 1.11 VersionResolverchulishengji 双子树合并算法,纯函数) - [x] 1.11 VersionResolver单链 4 层 fallbackagent→game→channel→market,纯函数)
- [x] 1.12 LocalVersionReaderversion.xml 解析 + ATS 全局放行) - [x] 1.12 LocalVersionReaderversion.xml 解析 + ATS 全局放行)
- [x] 1.13 LobbyZipUpgraderactor,原子 rename 升级;端到端实测 260→261) - [x] 1.13 LobbyZipUpgraderactor,原子 rename 升级;端到端实测 260→261)
- [x] 1.14.a AppIcon 替换 Xcode 默认空模板(iPad / 1024 缺失留 Phase 10 polish - [x] 1.14.a AppIcon 替换 Xcode 默认空模板(iPad / 1024 缺失留 Phase 10 polish
@@ -1106,16 +1106,21 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
- 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道) - 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道)
- **回滚条件**:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质 - **回滚条件**:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质
### ADR-008Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22,含 2 次调研 revise ### ADR-008Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22,含 3 次调研 revise
- **背景**Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5"**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置 → chulishengji 双子树算法 → 与本地版本比较 → 必要时下载并替换 H5 zip - **背景**Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5"**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置 → 单链 4 层 fallback 解析 → 与本地版本比较 → 必要时下载并替换 H5 zip
- **触发事件**2026-06-22 两轮 subagent 调研 `daoqi/msext` 原项目,逐行核对核心方法: - **触发事件**2026-06-22 起多轮调研 `daoqi/msext` 原项目,逐行核对核心方法:
- 第一轮(粗粒度):发现远端配置流程整体形态、IPA / H5 zip 两条升级链 - 第一轮(粗粒度2026-06-22):发现远端配置流程整体形态、IPA / H5 zip 两条升级链
- 第二轮(精确算法):核对 `NewRootVC.m` 全文,识别出**线上热路径是 chulishengji 双子树合并**,非 onnet 简单 4 层 - 第二轮(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 是历史遗留代码、主路径不走
- **不是** `onnet` 的 `if(self.gamelist==nil)` 分支(`NewRootVC.m:689-810`)的"简单线性 4 层覆盖" - **核心算法(第三轮最终版)**
- **是** `onnet` 的 `else` 分支(`NewRootVC.m:811-877`)→ `gonetconfig1` / `gonetconfigone1` → **`chulishengji`**`NewRootVC.m:1372-1538`)双子树合并 - 单链 4 层节点匹配:`agentlist[agentid] → gamelist[gameid] → channellist[channelid] → marketlist[marketid]`
- 触发条件:`viewWillAppear``NewRootVC.m:1609-1616`)与 `gonet``NewRootVC.m:1252-1258`)拉完 JSON 都强制把当前 agent 节点下 `gamelist` 提到 `self.gamelist`;只要服务器 JSON 里 agent 节点带 `gamelist`(线上 100% 都有),`self.gamelist` 就非 nil → 走 chulishengji 路径 - 任一层 id 匹配失败立刻截断(短链);最长 0..4 节点
- **决策**:在 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 层** - 字段语义:每个字段从最深层向根回退取第一个非空值(等价 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` #### 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 实现 - **字段类型混乱(必须兼容)**:`marketid` / `game_version` / `app_version` 等本应为 string 的字段,服务端会发 number。msext 用 NSNumber 静默吞下;**新外壳用 `decodeFlexibleStringIfPresent` 扩展(String / Int / Double / Bool 全兼容)**——已在 1.10 实现
- **解析失败行为**msext 静默 nil → 后续 for 循环 0 次跑空 → App 静默卡死(缺陷)。新外壳应当**显式抛 `ConfigParseFailed` + UI 弹错误页 + 提供重试按钮** - **解析失败行为**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 ```swift
// 1. 提取 agent 子树(getagentversionNewRootVC.m:885-1013 // 1. 顺链匹配,返回 0..4 个有序节点
// agent → channel → market → gamegame 嵌在 market 命中后才遍历 // 任一层匹配失败立刻截断(之后不再尝试更深层
agentConfig = extractAgentSubtree(agent) let chain: [RemoteConfigNode] = buildChain(
// → (app_version, app_download, game_version, game_download, showmessage) config, agentId, gameId, channelId, marketId
)
// 例:agent 命中、game 未命中 → chain = [agent]
// 2. 提取 game 子树(getgameversionNewRootVC.m:1015-1180 // 2. 5 字段共用同一套倒序 fallback 接口
// game → game-self → channel → market // 从最深层向根回退取第一个非空(等价 msext "下层非空即覆盖上层"的最终态)
// (第二层 "game-self" 是把 gamedata 自身当 infotwo 再嵌一遍 channellist ResolvedVersion(
// msext 那段曾经的 agentid 判断被注释掉了) appVersion: pickInt(chain, \.appVersion),
gameConfig = extractGameSubtree(game) appDownload: pickString(chain, \.appDownload),
// → (app_version, app_download, game_version, game_download, showmessage) gameVersion: pickInt(chain, \.gameVersion),
gameZip: pickString(chain, \.gameZip),
// 3. 合并 IPA 字段(NewRootVC.m:1408-1447 showmessage: pickString(chain, \.showmessage) ?? config.showmessage
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 - 4 层节点(Agent / Game / Channel / Marketconform 同一 `RemoteConfigNode` 协议,提供相同字段 getter
- 第 2 层 channel`NewRootVC.m:903-940` 嵌 channellist 找匹配 - 字段查找只有 `pickString` / `pickInt` 两个工具,5 字段全走它们 —— 不允许出现"为某个字段写专门 if 链"的多处实现
- 第 3 层 market`NewRootVC.m:921-997` 嵌 marketlist 找匹配 - `pickString` 跳过 nil / 空字符串;`pickInt` 跳过 nil / 解析失败 / 0msext `[nil intValue] = 0` 行为兼容)
- 第 4 层 game`NewRootVC.m:944-996` 嵌在 market 命中后才遍历 game(注意是 market.gamelist,不是 channel.gamelist
**game 子树 4 层结构**`getgameversion:` **与 msext 实际代码的关系**
- 第 1 层 game 自己:`NewRootVC.m:1019-1045` - msext 顺嵌套 for 循环里"非空即覆盖"`self.xxx = xxx` 的写法 —— **最终态等价**于从根向叶遍历后取最深一处非空值,再等价于从叶向根 fallback 取第一个非空值。三种写法语义一致,新外壳选第三种因为:(1) 5 字段共用同一接口最简洁;(2) `buildChain` 集中处理"任一层失败截断"语义;(3) 单测易写
- 第 2 层 game-self`infotwo=gamedata` 自身再嵌 channellist):`NewRootVC.m:1048-1073` - 不复刻 msext 那段把 `showmessage` 散在 5 处覆盖的写法 —— 用 `pickString(\.showmessage)` 一处搞定,最终态相同
- 第 3 层 channel`NewRootVC.m:1075-1100`
- 第 4 层 market`NewRootVC.m:1100-1163`
**字段名差异(必须留意)**
- agent 子树用 `game_download``NewRootVC.m:952`
- game 子树用 `game_zip``NewRootVC.m:1037`
- 两者本质同一字段,msext 历史命名不一致。新外壳 Codable 模型用 `gameZip`,但需要在两个 extract 函数里都查这两个 key(择一非空)
#### ADR-008-E 决策顺序(`WebContainer` 编排) #### 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(后台已切) | | URL | HTTP | HTTPS(后台已切) |
| cache-busting | `?vXXXXXXXX_YYYYYYYY` | **保留** | | cache-busting | `?vXXXXXXXX_YYYYYYYY` | **保留** |
| JSON 解析 | SBJSON | Codable + JSONDecoder + 自定义 `decodeFlexibleStringIfPresent`(兼容 number-as-string | | JSON 解析 | SBJSON | Codable + JSONDecoder + 自定义 `decodeFlexibleStringIfPresent`(兼容 number-as-string |
| 算法 | chulishengji 双子树合并 | **复刻 chulishengji**,纯函数 + 单测 | | 算法 | onnet 单链 4 层(`NewRootVC.m:689-810`) | 单链 4 层 + 协议化 fallback,5 字段共用同一查找接口,纯函数 + 单测 |
| version.xml | GDataXML | Foundation XMLParser | | version.xml | GDataXML | Foundation XMLParser |
| 升级解压 | `removeItemAtPath` + ZipArchive | 临时目录 + 原子 rename | | 升级解压 | `removeItemAtPath` + ZipArchive | 临时目录 + 原子 rename |
| 失败 UI | 静默 / NSLog | 弹错误页 + 重试按钮(不留卡死黑洞) | | 失败 UI | 静默 / NSLog | 弹错误页 + 重试按钮(不留卡死黑洞) |
@@ -1254,4 +1244,4 @@ result.showmessage = mergeShowmessage(top, agent, game, channel, market)
--- ---
文档完成日期:2026-06-21 文档完成日期:2026-06-21
最后更新:2026-06-22ADR-008 二次精确化 chulishengji 双子树算法 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录) 最后更新:2026-06-27ADR-008 第三轮修订:VersionResolver 改单链 4 层 fallback + 5 字段共用同一查找接口;前置历史 2026-06-22 ADR-008 二次精确化 / ADR-007 渠道注入改 ChannelConfig.plist / ADR-006 纯 SPM + Vendor / ADR-005 极光降级 / Resources 目录记录)
+98 -64
View File
@@ -1464,15 +1464,39 @@ public struct Game: Codable, Sendable { ... }
public struct Market: Codable, Sendable { ... } public struct Market: Codable, Sendable { ... }
``` ```
#### 6.3.2 VersionResolverchulishengji 双子树合并,纯函数) #### 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 ```swift
// Source/Network/VersionResolver.swift // Source/Network/VersionResolver.swift
public enum VersionResolver {
public struct Resolved: Sendable { 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 appVersion: Int // 0
public let appDownload: String? public let appDownload: String?
public let gameVersion: Int // 0 public let gameVersion: Int // 0
@@ -1480,64 +1504,76 @@ public enum VersionResolver {
public let showmessage: String? // public let showmessage: String? //
} }
/// chulishengji NewRootVC.m:1372-1538 public enum VersionResolver {
/// 1. agent agent channel market gamegame market
/// 2. game game game-self channel market
/// 3.
/// - IPAappVersion / appDownload agent game game line 1416
/// - zipgameVersion / gameZip game agent agent line 1456
/// 4. showmessage
public static func resolve( public static func resolve(
config: RemoteConfig, config: RemoteConfig,
agentId: String, agentId: String,
channelId: String, channelId: String,
marketId: String, marketId: String,
gameId: String gameId: String
) -> Resolved { ) -> ResolvedVersion {
// 1. agent / channel / market / game let chain = buildChain(
guard let agent = config.agentlist?.first(where: { $0.agentid == agentId }), config: config,
let game = agent.gamelist?.first(where: { $0.gameid == gameId }) else { agentId: agentId, gameId: gameId,
// 0 / nil WebContainer "" channelId: channelId, marketId: marketId
return Resolved(appVersion: 0, appDownload: nil, )
gameVersion: 0, gameZip: nil,
showmessage: config.showmessage)
}
let agentExtract = extractAgentSubtree( return ResolvedVersion(
agent: agent, channelId: channelId, marketId: marketId, gameId: gameId) appVersion: pickInt(chain, \.appVersion),
let gameExtract = extractGameSubtree( appDownload: pickString(chain, \.appDownload),
game: game, channelId: channelId, marketId: marketId) gameVersion: pickInt(chain, \.gameVersion),
gameZip: pickString(chain, \.gameZip),
// IPA NewRootVC.m:1408-1447 showmessage: pickString(chain, \.showmessage) ?? config.showmessage
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
) )
} }
/// agent 4 getagentversionNewRootVC.m:885-1013 /// agent game channel market
/// agent channel market gamegame market /// 0..4
/// agent game_download game game_zip private static func buildChain(
private static func extractAgentSubtree(...) -> SubtreeResult { ... } 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 getgameversionNewRootVC.m:1015-1180 ///
/// game game-selfgamedata channellist channel market private static func pickString(
private static func extractGameSubtree(...) -> SubtreeResult { ... } _ chain: [RemoteConfigNode],
_ keyPath: KeyPath<RemoteConfigNode, String?>
) -> 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?) { ... } /// JSON string / int / doubleCodable decodeFlexibleStringIfPresent
/// String?0
/// zip game agent.gameVersion > game.gameVersion agent private static func pickInt(
private static func mergeZip(...) -> (Int, String?) { ... } _ chain: [RemoteConfigNode],
_ keyPath: KeyPath<RemoteConfigNode, String?>
) -> 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 有 app_version,其余空 | agent |
| agent 子树有,game 子树无 app_version | 取 agent | | agent / game / channel / market 都有 app_version | 取 market(最深层赢) |
| 仅 game 子树有,agent 子树无 app_version | 取 game | | agent / game / market 有,channel 缺 | 取 market(最深层赢) |
| game.appVersion = 10 vs agent.appVersion = 5 | game | | channelid 不匹配 | chain 截断 = [agent, game];取 game |
| game.appVersion = 5 vs agent.appVersion = 10 | agent | | gameid 不匹配 | chain = [agent];取 agent |
| game.gameVersion = 10 vs agent.gameVersion = 5 | game 赢(默认) | | agentid 不匹配 | chain = [];返回全 0/nil + 顶层 showmessage |
| game.gameVersion = 5 vs agent.gameVersion = 10 | agent 赢 | | showmessage 仅顶层有 | fallback 到 `config.showmessage` |
| showmessage 仅顶层有 | 取顶层 | | showmessage 仅 market 有 | 取 market(最深层赢) |
| showmessage agent 层有 + 顶层有 | 取 agent(深层覆盖浅层) | | showmessage market 与 agent 都有 | 取 market |
| showmessage market 层有 + 全部层都有 | 取 market(最深) | | 字段值 = 0 / "" / nil | 视同未提供,继续向上找 |
| agentid 找不到匹配 | 返回 default0/nil),不抛 | | JSON 把 app_version 发成 number 而非 string | `decodeFlexibleStringIfPresent` 兜底为 String`Int(s)` 解析正常 |
| gameid 找不到匹配 | 返回 default,不抛 |
| 字段名 game_download vs game_zip 混用 | 两个 key 都查,择一非空 |
#### 6.3.3 LocalVersionReader #### 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 隔离 | | 网络模型 | `[NSData dataWithContentsOfURL:]` 主线程同步阻塞 + ASIHTTPRequest 后台下载 | URLSession `async data(from:)` / `download(from:)`actor 隔离 |
| 失败重试 | `viewDidLoad` 起 4s 等间隔 `timer`,至无穷直到成功 | 指数退避 1/2/4 秒最多 3 次,超出 throw 由 UI 决定回退路径 | | 失败重试 | `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(下次启动可清) | | 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`,半途崩溃留半残 | 解压到 `staging-{uuid}/` 临时目录 + 原子 `moveItem` rename,半途崩溃只留 tmp(下次启动可清) |
| 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11` | 同款原子 rename,旧目录直接覆盖 | | 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11` | 同款原子 rename,旧目录直接覆盖 |
| 配置解析 | SBJSON 三方库 | Codable + JSONDecoder | | 配置解析 | SBJSON 三方库 | Codable + JSONDecoder |
+1 -1
View File
@@ -70,7 +70,7 @@ xcrun simctl get_app_container booted com.skyapp.ylgamehall data
[SandboxPaths] 路径计算结果 [SandboxPaths] 路径计算结果
[ResourceUnzipper] ensureReady 完成 [ResourceUnzipper] ensureReady 完成
[RemoteConfigClient] 拉取成功 .parsed [RemoteConfigClient] 拉取成功 .parsed
[VersionResolver] chulishengji 双子树合并结果 [VersionResolver] 单链 4 层 fallback 结果
[LocalVersionReader] 本地 vs 远端对比 [LocalVersionReader] 本地 vs 远端对比
[LobbyZipUpgrader] 无需升级 / 升级完成 N → M [LobbyZipUpgrader] 无需升级 / 升级完成 N → M
[lobby] write app_data.js → ... ← Phase 1.7 落地(AppDataWriter [lobby] write app_data.js → ... ← Phase 1.7 落地(AppDataWriter
@@ -8,7 +8,7 @@
import Foundation import Foundation
// MARK: - Codable agent channel marketagent gamelist // MARK: - Codable 4 agent game channel market
nonisolated public struct RemoteConfig: Codable, Sendable { nonisolated public struct RemoteConfig: Codable, Sendable {
public let showmessage: String? public let showmessage: String?
@@ -22,7 +22,6 @@ nonisolated public struct Agent: Codable, Sendable {
public let appDownload: String? public let appDownload: String?
public let gameVersion: String? public let gameVersion: String?
public let gameZip: String? public let gameZip: String?
public let channellist: [Channel]?
public let gamelist: [Game]? public let gamelist: [Game]?
public init(from decoder: any Decoder) throws { public init(from decoder: any Decoder) throws {
@@ -33,12 +32,11 @@ nonisolated public struct Agent: Codable, Sendable {
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload) appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion) gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip) gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
channellist = try c.decodeIfPresent([Channel].self, forKey: .channellist)
gamelist = try c.decodeIfPresent([Game].self, forKey: .gamelist) gamelist = try c.decodeIfPresent([Game].self, forKey: .gamelist)
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case agentid, showmessage, channellist, gamelist case agentid, showmessage, gamelist
case appVersion, appDownload, gameVersion, gameZip case appVersion, appDownload, gameVersion, gameZip
} }
} }
+84 -182
View File
@@ -3,15 +3,23 @@
// ylgamehall // ylgamehall
// //
// RemoteConfig 5 appVersion/appDownload/gameVersion/ // RemoteConfig 5 appVersion/appDownload/gameVersion/
// gameZip/showmessage msext chulishengji // gameZip/showmessage
// docs/Development-Plan.md ADR-008-D / Design §6.3.2 / msext NewRootVC.m:1372-1538 //
// daoqi `NewRootVC.m:689-810`
// agentlist[agentid] gamelist[gameid] channellist[channelid] marketlist[marketid]
//
// market退
// msext "" if
//
// 5 `pickString` / `pickInt`
// " / chulishengji"
// //
import Foundation import Foundation
// MARK: - // MARK: -
/// chulishengji 5 /// 4 fallback 5
public struct ResolvedVersion: Sendable, Equatable { public struct ResolvedVersion: Sendable, Equatable {
/// IPA BundleConfig.appVersion 0 /// IPA BundleConfig.appVersion 0
public let appVersion: Int public let appVersion: Int
@@ -25,39 +33,32 @@ public struct ResolvedVersion: Sendable, Equatable {
public let showmessage: String? public let showmessage: String?
} }
// MARK: - // MARK: - 4
/// agent game 4 5 /// `Agent` / `Game` / `Channel` / `Market`
private struct SubtreeExtract { /// VersionResolver fallback
var appVersion: Int = 0 private protocol RemoteConfigNode {
var appDownload: String? var appVersion: String? { get }
var gameVersion: Int = 0 var appDownload: String? { get }
/// agent `game_download`game `game_zip` var gameVersion: String? { get }
/// key var gameZip: String? { get }
var gameZip: String? var showmessage: String? { get }
var showmessage: String?
} }
extension Agent: RemoteConfigNode {}
extension Game: RemoteConfigNode {}
extension Channel: RemoteConfigNode {}
extension Market: RemoteConfigNode {}
// MARK: - VersionResolver // MARK: - VersionResolver
public enum VersionResolver { public enum VersionResolver {
/// chulishengji msext `NewRootVC.m:1372-1538` /// 4 fallback
///
/// 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`
/// ///
/// - Parameters: /// - Parameters:
/// - config: RemoteConfigCodable /// - config: RemoteConfig
/// - agentId: BundleConfig.agent /// - agentId/channelId/marketId/gameId: BundleConfig
/// - channelId: BundleConfig.channel
/// - marketId: BundleConfig.market
/// - gameId: BundleConfig.gameId
/// - Returns: 5 0 / nil /// - Returns: 5 0 / nil
public static func resolve( public static func resolve(
config: RemoteConfig, config: RemoteConfig,
@@ -66,184 +67,85 @@ public enum VersionResolver {
marketId: String, marketId: String,
gameId: String gameId: String
) -> ResolvedVersion { ) -> ResolvedVersion {
// agent let chain = buildChain(
guard let agent = config.agentlist?.first(where: { $0.agentid == agentId }) else { config: config,
// agent 0 / nil WebContainer "" agentId: agentId,
return ResolvedVersion( gameId: gameId,
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,
channelId: channelId, channelId: channelId,
marketId: marketId, marketId: marketId
gameId: gameId
) )
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)
// showmessagegame > agent >
let msg = gameExtract.showmessage
?? agentExtract.showmessage
?? config.showmessage
return ResolvedVersion( return ResolvedVersion(
appVersion: appVer, appVersion: pickInt(chain, \.appVersion),
appDownload: appDL, appDownload: pickString(chain, \.appDownload),
gameVersion: gameVer, gameVersion: pickInt(chain, \.gameVersion),
gameZip: gameZip, gameZip: pickString(chain, \.gameZip),
showmessage: msg // showmessage: 退 config.showmessage
showmessage: pickString(chain, \.showmessage) ?? config.showmessage
) )
} }
// MARK: - agent getagentversionNewRootVC.m:885-1013 // MARK: -
/// agent 4 agent channel market game /// 沿 agent game channel market
/// game market market.gamelist game ///
private static func extractAgentSubtree( /// 0..4
agent: Agent, /// - `[]` agent
channelId: String, /// - `[agent]` agent game
marketId: String, /// - `[agent, game]`
gameId: String /// - ...
) -> SubtreeExtract { /// - `[agent, game, channel, market]`
var r = SubtreeExtract() private static func buildChain(
config: RemoteConfig,
// 1agent agentId: String,
applyOnto(&r, from: agent) gameId: String,
// 2channel
guard let channel = agent.channellist?.first(where: { $0.channelid == channelId })
else { return r }
applyOnto(&r, from: channel)
// 3market
guard let market = channel.marketlist?.first(where: { $0.marketid == marketId })
else { return r }
applyOnto(&r, from: market)
// 4game market msext
// Market gamelist channel marketlist
// gamelist Market.gamelist
// RemoteConfig 线
return r
}
// MARK: - game getgameversionNewRootVC.m:1015-1180
/// game 4 game game-self channel market
/// 2 game infotwo channellistmsext
/// agentid game.channellist
private static func extractGameSubtree(
game: Game,
channelId: String, channelId: String,
marketId: String marketId: String
) -> SubtreeExtract { ) -> [RemoteConfigNode] {
var r = SubtreeExtract() var chain: [RemoteConfigNode] = []
// 1game guard let agent = config.agentlist?.first(where: { $0.agentid == agentId })
applyOnto(&r, from: game) else { return chain }
chain.append(agent)
// 2game-self game.channellist 3 guard let game = agent.gamelist?.first(where: { $0.gameid == gameId })
else { return chain }
chain.append(game)
// 3channel
guard let channel = game.channellist?.first(where: { $0.channelid == channelId }) guard let channel = game.channellist?.first(where: { $0.channelid == channelId })
else { return r } else { return chain }
applyOnto(&r, from: channel) chain.append(channel)
// 4market
guard let market = channel.marketlist?.first(where: { $0.marketid == marketId }) guard let market = channel.marketlist?.first(where: { $0.marketid == marketId })
else { return r } else { return chain }
applyOnto(&r, from: market) chain.append(market)
return r return chain
} }
// MARK: - // MARK: - fallback
/// IPA agent game game NewRootVC.m:1408-1447 /// marketagent退
private static func mergeIPA( private static func pickString(
agent: SubtreeExtract, _ chain: [RemoteConfigNode],
game: SubtreeExtract _ keyPath: KeyPath<RemoteConfigNode, String?>
) -> (Int, String?) { ) -> String? {
// for node in chain.reversed() {
if agent.appDownload != nil && game.appDownload != nil { if let s = node[keyPath: keyPath], !s.isEmpty { return s }
if game.appVersion > agent.appVersion {
return (game.appVersion, game.appDownload)
} }
return (agent.appVersion, agent.appDownload) return nil
}
//
if let dl = agent.appDownload {
return (agent.appVersion, dl)
}
if let dl = game.appDownload {
return (game.appVersion, dl)
}
return (0, nil)
} }
/// zip game agent agent NewRootVC.m:1450-1492 /// 退
private static func mergeZip( /// JSON string / int / double `decodeFlexibleStringIfPresent`
agent: SubtreeExtract, /// / 0
game: SubtreeExtract private static func pickInt(
) -> (Int, String?) { _ chain: [RemoteConfigNode],
if agent.gameZip != nil && game.gameZip != nil { _ keyPath: KeyPath<RemoteConfigNode, String?>
if agent.gameVersion > game.gameVersion { ) -> Int {
return (agent.gameVersion, agent.gameZip) for node in chain.reversed() {
if let s = node[keyPath: keyPath], let v = Int(s), v != 0 { return v }
} }
return (game.gameVersion, game.gameZip) return 0
}
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 }
} }
} }
@@ -195,8 +195,8 @@ public final class SubGameViewController: UIViewController {
private func runBootPipelineSteps() async throws { private func runBootPipelineSteps() async throws {
// 0. H5 SwitchOverGameData.gamedownloadurl ** url gameid** // 0. H5 SwitchOverGameData.gamedownloadurl ** url gameid**
// msext gameController.m:544 "ID" zip // msext gameController.m:544 "ID" zip
// gameid RemoteConfig `game_zip` msext uplevel: // gameid RemoteConfig `game_zip` VersionResolver
// chulishengji VersionResolver // 4 fallbackagent game channel market
// //
// resolve // resolve
// - lobbyResolvedgameId = bc.gameId AppDataWriter app_appversion // - lobbyResolvedgameId = bc.gameId AppDataWriter app_appversion