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:
co-authored by
Claude Opus 4.7
parent
90a523736e
commit
8125dd4d11
@@ -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<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?) { ... }
|
||||
|
||||
/// 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<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 子树有,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 |
|
||||
|
||||
Reference in New Issue
Block a user