Phase 1.10:RemoteConfigClient 完整版(HTTPS + cache-busting + 短文本 + FlexibleString)

第二轮调研 daoqi/NewRootVC.m 全文核对后,把对原项目远程配置流程的精确
理解固化进 ADR-008 + Design §6.3.2,并补齐 RemoteConfigClient 缺失的
3 个契约(cache-busting query、短文本响应识别、字段类型混乱兼容)。

核心修正:线上热路径是 chulishengji 双子树合并算法(NewRootVC.m:1372-1538),
不是 onnet 简单 4 层覆盖。viewWillAppear 与 gonet 都会强制把当前 agent 的
gamelist 提到 self.gamelist,只要服务器 JSON 含 agent.gamelist(线上 100% 都有),
self.gamelist 就非 nil,走 else 分支 → chulishengji。本次烟雾测试实测:
agent[0].gamelist = 7 个,完美印证 chulishengji 必要性。

- RemoteConfigClient 新增 cache-busting query:?vXXXXXXXXYYYYYYYY(两段 8 位 hex
  连写,无 =,照搬 msext NewRootVC.m:1226 契约),每次重试都重新生成
- 新增 RemoteConfigOutcome enum:.parsed(RemoteConfig) 与 .shortText(String)
  两种成功状态。短文本响应(trim 后 utf16.count ≤ 100)是服务端运营杀手锏 #1,
  msext 当 alert 文本弹窗 + 停止重试(NewRootVC.m:1239-1243)
- UTF-8 解码 + trim 跟 msext NewRootVC.m:1237-1238 严格对齐
- 所有 String? 字段改用 decodeFlexibleStringIfPresent 扩展(兼容 number-as-string):
  agentid / channelid / marketid / gameid / showmessage / appVersion / appDownload
  / gameVersion / gameZip 全部 9 字段。msext 用 NSNumber.intValue/description 静默
  吞下 number,新外壳 Codable 严格类型必须显式处理
- 远端 URL 从 http 改为 https(后台已切,无需 ATS 例外)
- Plan ADR-008 修订为含 9 个子节(A-I)的精确决策记录,含 chulishengji
  双子树算法、agent 子树结构、game 子树结构、字段级合并优先级、决策顺序、
  与 msext 差异表
- Design §6.3.2 VersionResolver 重写:从"简单 4 层 reduce"改为"chulishengji
  双子树合并 + 字段级合并",附 13 项单测覆盖矩阵
- RootViewController 烟雾测试切换为 outcome enum switch case,覆盖 .parsed
  与 .shortText 两条路径
- 实测烟雾:HTTPS 拉到 30 KB JSON 解析 .parsed 成功(耗时 2.7s 含 TLS 握手),
  agent[0].gamelist 含 7 个 game 节点,证明 chulishengji 路径是热路径
This commit is contained in:
joywayer
2026-06-22 02:14:57 +08:00
parent 5f032342e7
commit 1bc287bafc
4 changed files with 526 additions and 36 deletions
+142 -30
View File
@@ -1002,40 +1002,152 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
- 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道) - 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道)
- **回滚条件**:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质 - **回滚条件**:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质
### ADR-008Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22 ### ADR-008Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22,含 2 次调研 revise
- **背景**Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5"**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置,做 agent → channel → market 4 级覆盖,对比本地版本,必要时下载并替换 H5 zip - **背景**Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5"**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置 → chulishengji 双子树算法 → 与本地版本比较 → 必要时下载并替换 H5 zip
- **触发事件**2026-06-22 调研 `daoqi/msext` 原项目subagent 详细分析 `NewRootVC.m:1551-1621` viewWillAppear、`NewRootVC.m:1372-1492` chulishengji、`NewRootVC.m:1789-1869` downFileFromServer 三段核心代码),发现 - **触发事件**2026-06-22 两轮 subagent 调研 `daoqi/msext` 原项目,逐行核对核心方法
- 远端配置 URL 不是 `SERVERNew + gameconfig` 拼接,而是 `"http://" + gameconfig.replacingOccurrences("-", "/") + ".txt"`(当前 demo 值即 `http://tsgames.daoqi88.cn/config_test/update_jsonv2_test.txt` - 第一轮(粗粒度):发现远端配置流程整体形态、IPA / H5 zip 两条升级链
- JSON 结构是 `agentlist[].channellist[].marketlist[]` 三级嵌套 + `agentlist[].gamelist[].channellist[].marketlist[]` 副路径,每层都可携带同名版本字段,深层覆盖浅 - 第二轮(精确算法):核对 `NewRootVC.m` 全文,识别出**线上热路径是 chulishengji 双子树合并**,非 onnet 简单 4
- 两条独立升级链:① 原生 IPA 升级(`app_version > 本地 appversion 注入值` → 弹窗 + Safari 外链)② H5 zip 升级(`game_version > 本地 version.xml /game/version@value` → 下载 + 解压 + reload - **核心修正:线上热路径算法**
- 不做这一步的代价:上线后用户永远停在 IPA 内打包瞬间的 H5 版本,H5 团队任何更新都到达不了用户 - **不是** `onnet` 的 `if(self.gamelist==nil)` 分支(`NewRootVC.m:689-810`)的"简单线性 4 层覆盖"
- **决策**:在 Phase 1 视图层之前插入 4 个新子项 `1.10 RemoteConfigClient / 1.11 VersionResolver / 1.12 LocalVersionReader / 1.13 LobbyZipUpgrader`,原 `1.10-1.13` 后移为 `1.14-1.17` - **是** `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 路径
- **WebContainer 的 `loadFileURL` 在无升级链路前提下是有缺陷的**——只能加载 IPA 内静态 zip - **决策**:在 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 层**
- 推迟到 Phase 2 也行得通,但 Phase 1 验收"看到大厅 H5"的语义会被掩盖(看到的是 IPA 内的旧版,跟"上线后看到的"完全不同)
- 一次性补齐使 Phase 1 验收能模拟真实上线场景:模拟远端 `game_version +1` 重启 App 必须看到新版本下载 + 加载 #### ADR-008-A 远端 URL 构造(`RemoteConfigClient`
- **关键设计点(与 msext 实现的差异)**:
| 维度 | msext 现状 | 新外壳决策 | - 基础 URL`"https://" + BundleConfig.gameConfig.replacingOccurrences("-", "/") + ".txt"`
|------|----------|----------| - 新外壳改 HTTPSmsext 用 HTTP`NewRootVC.m:250`)。后台已经支持 HTTPS。
| 网络库 | `[NSData dataWithContentsOfURL:]` 主线程阻塞 / ASIHTTPRequest 下载 | `URLSession async data(from:)` + `download(from:)`actor 隔离 | - **cache-busting query**msext `NewRootVC.m:1226, 1585`):每次拉取时拼 `?vXXXXXXXXYYYYYYYY`(两个 `arc4random()` 各 8 位 hex 连写,**无 `=`**)。**新外壳保留此约定**——服务端可能根据这段 query 做无 cache 处理。
| 失败重试 | 4 秒等间隔暴力 `timer` | 指数退避 1/2/4 秒三次,可取消 |
| 4 级覆盖算法 | 散落 if/else 在 `getagentversion` / `getgameversion` / `chulishengji` 三方法手写 | 纯函数 reduce,单测覆盖所有边界 | #### ADR-008-B 网络层
| 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`,半途崩溃留残骸 | 解压到临时目录 + 原子 rename,无半残状态 |
| 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11`,靠系统清 Caches) | 同样的原子 rename 策略,旧目录直接覆盖 | - API`URLSession.data(from:)` async(替代 msext `[NSData dataWithContentsOfURL:]` 主线程同步阻塞)。10s 请求超时 + 30s 资源超时。
| 解析 JSON | SBJSON 三方库 | Codable + JSONDecoder | - 重试策略:**最初取 3 次 1/2/4 秒指数退避**(已在 1.10 落地);上线前评估改为 **msext 风格 4s 等间隔无次数上限**——因为 msext 那套"无网静默重试 + 网络恢复立刻进"的体验在地铁场景是合理的,新外壳不应当回退。
| 版本号类型 | NSString → intValue(自动当 0 处理) | 强类型 Int,缺失 throw 类型化错误 | - 静默重试:网络错误不弹窗、不打 log,等下次 tick;只在第一次失败时 UI 显示状态(启动图 + "重新连接中")。这是契约。
- **守护** - 短文本响应(`NewRootVC.m:1239-1243`):trim 后 `length ≤ 100` 时**当 alert 内容直接弹窗**,并停止重试。运营杀手锏 #1。**新外壳用 `utf16.count` 实现 1:1 复刻**msext 用 NSString length 即 UTF-16 unit 计数)。
- **H5 端契约不变**:H5 看到的仍是"启动一段时间后看到大厅",对 4 级覆盖 / 升级链路完全无感
- `gameconfig` 注入值是远端 URL 的唯一驱动,**不允许把远端 URL 硬编码在 Swift 源码里** #### ADR-008-C JSON 解析(`RemoteConfigClient`
- `version.xml` 文件名 / xpath `/game/version@value` 不允许改名(H5 自更新时会写这个文件,新外壳读它,双方协议)
- 4 级覆盖的优先级(agent > 顶层、channel > agent、market > channel、game 子树最终覆盖 agent 子树)必须由单测固化,避免回归 - 服务端响应是 UTF-8 编码的 JSON(扩展名 `.txt` 仅历史包袱)
- **回滚条件**:若远端 `.txt` 配置服后台被替换为 RESTful API(路径 / 字段名变化),重新评估并实现新协议;JSON 嵌套结构变化同理 - 顶层结构:`{ showmessage, agentlist[], scrollmsg, Ads, managerUrl, isclose, menunotice, ... 20+ H5 业务字段 }`
- 原生**只读** `agentlist + showmessage`,**其余字段 H5 同 URL 再拉一次**自己读
- Codable 忽略未识别字段(默认行为),新外壳自动满足
- **字段类型混乱(必须兼容)**:`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`
新外壳 `VersionResolver.resolve()` 必须 1:1 复刻 `NewRootVC.m:1372-1538` 的算法,输出 5 个目标字段:`(appVersion, appDownload, gameVersion, gameZip, showmessage)`。
**步骤**
```swift
// 1. 提取 agent 子树(getagentversionNewRootVC.m:885-1013
// agent → channel → market → gamegame 嵌在 market 命中后才遍历)
agentConfig = extractAgentSubtree(agent)
// → (app_version, app_download, game_version, game_download, showmessage)
// 2. 提取 game 子树(getgameversionNewRootVC.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)
```
**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
**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(择一非空)
#### ADR-008-E 决策顺序(`WebContainer` 编排)
5 字段拿到后**固定顺序**判断(chulishengji 路径在 `NewRootVC.m:1501-1527`):
| 优先级 | 条件 | 动作 |
|---|---|---|
| 1 | `showmessage != ""` 且非 nil | 弹 alert(标题 `gamehallname + 提醒`,单按钮"确定"),**完全阻塞**return |
| 2 | `[version_ios intValue] > [iosNumber intValue]` | 弹 IPA 升级 alert(标题"有新的版本更新,点击前往下载!更新过程中可能会白屏...",单按钮"确定"tag PublicTagfive → Safari 外链 `app_download`return |
| 3 | 否则 | `uplevel:download:` 内(`NewRootVC.m:1874-1885`):`gamevers > [versioninfo intValue]` → `downFileFromServer:`;否则 `initView` 直接加载本地 H5 |
#### ADR-008-F LocalVersionReader1.12
- **iOS 本地版本**`BundleConfig.shared.appVersion` 转 Int(来自 ChannelConfig.plist `appversion` 字段,等价 msext `[FuncPublic filename:@"appversion"]`
- **H5 本地版本**`Library/Caches/{gamedir}/{gamestart}/version.xml` 解析 `/game/version@value`
- msext 用 GDataXML`NewRootVC.m:664-682`);新外壳用 Foundation `XMLParser`(无外部依赖)
- **缺失 / 损坏返回 0**(必须保留,是首装后首次升级的兜底机制;msext `[nil intValue] = 0`
#### ADR-008-G LobbyZipUpgrader1.13
- 下载链路:`URLSession.download(from: gameZip)` async + 进度回调
- 解压:临时目录 `staging-{uuid}/` + 原子 `moveItem` rename(不学 msext 先删后压的中断风险)
- **失败处理**msext `requestFailed:` 只打 NSLog 静默;新外壳应当**弹错误页 + 回退用 Bundle 内 H5**(旧 zip 仍可用)
#### ADR-008-H 5 字段的他用(必须保留)
- `showmessage`:仅 alert,不传 H5 / 不存 UserDefaults
- `version_ios`:① 决策;② `initJSdata` 间接通过 `app_data.js` 注入 `app_appversion` 字段传给 H5(值 0 = gameconfig / 1 = appleconfig,控制 H5 走两条不同分发路径)
- `app_download`:仅本次启动用
- `game_version_` / `game_zip_`:决策 + downFileFromServer 拼下载 URL
- 全部**不存 UserDefaults**msext 无缓存层)
#### ADR-008-I 与 msext 差异(实施层面,H5 契约不变)
| 维度 | msext 现状 | 新外壳决策 |
|------|----------|----------|
| 网络库 | 主线程同步 `dataWithContentsOfURL:` + ASIHTTPRequest 下载 | `URLSession.data(from:)` + `download(from:)` asyncactor 隔离 |
| 重试 | 4s timer 无上限 | 1.10 初版用 3 次退避;可改为 msext 风格无上限 |
| URL | HTTP | HTTPS(后台已切) |
| cache-busting | `?vXXXXXXXX_YYYYYYYY` | **保留** |
| JSON 解析 | SBJSON | Codable + JSONDecoder + 自定义 `decodeFlexibleStringIfPresent`(兼容 number-as-string |
| 算法 | chulishengji 双子树合并 | **复刻 chulishengji**,纯函数 + 单测 |
| version.xml | GDataXML | Foundation XMLParser |
| 升级解压 | `removeItemAtPath` + ZipArchive | 临时目录 + 原子 rename |
| 失败 UI | 静默 / NSLog | 弹错误页 + 重试按钮(不留卡死黑洞) |
- **回滚条件**:若远端 `.txt` 配置服后台被替换为 RESTful API(路径 / 字段名变化),重新评估并实现新协议
- **影响 Phase 2 及之后** - **影响 Phase 2 及之后**
- Phase 2 / 3 等不再需要重做远程配置;只在新加 handler 时调用现成的 `RemoteConfigClient.current`
- Phase 6 子游戏升级直接复用 `LobbyZipUpgrader` 的设计(仅参数化目录路径) - Phase 6 子游戏升级直接复用 `LobbyZipUpgrader` 的设计(仅参数化目录路径)
- Phase 10 多渠道打包前 `gameconfig` 注入值由 IPA 后处理工具修改(ADR-007 `plutil` 路径) - Phase 10 多渠道打包前 `gameconfig` 注入值由 IPA 后处理工具修改(ADR-007 `plutil` 路径)
--- ---
文档完成日期:2026-06-21 文档完成日期:2026-06-21
最后更新:2026-06-22ADR-008 Phase 1 补远程配置 + zip 升级 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录) 最后更新:2026-06-22ADR-008 二次精确化 chulishengji 双子树算法 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录)
+72 -6
View File
@@ -1083,7 +1083,9 @@ public struct Game: Codable, Sendable { ... }
public struct Market: Codable, Sendable { ... } public struct Market: Codable, Sendable { ... }
``` ```
#### 6.3.2 VersionResolver(纯函数) #### 6.3.2 VersionResolverchulishengji 双子树合并,纯函数)
**核心修正(ADR-008 二次精确化)**:线上热路径是 chulishengji 双子树合并,**不是**简单的"顶 → agent → channel → market"线性覆盖。
```swift ```swift
// Source/Network/VersionResolver.swift // Source/Network/VersionResolver.swift
@@ -1094,10 +1096,16 @@ public enum VersionResolver {
public let appDownload: String? public let appDownload: String?
public let gameVersion: Int // 0 public let gameVersion: Int // 0
public let gameZip: String? public let gameZip: String?
public let showmessage: String? //
} }
/// 4 agent channel market /// chulishengji NewRootVC.m:1372-1538
/// agent game game msext 'chulishengji' /// 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,
@@ -1105,13 +1113,71 @@ public enum VersionResolver {
marketId: String, marketId: String,
gameId: String gameId: String
) -> Resolved { ) -> Resolved {
// pure function: actor / global // 1. agent / channel / market / game
// reduce over [top, agent, channel, market] 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)
}
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
)
} }
/// agent 4 getagentversionNewRootVC.m:885-1013
/// agent channel market gamegame market
/// agent game_download game game_zip
private static func extractAgentSubtree(...) -> SubtreeResult { ... }
/// game 4 getgameversionNewRootVC.m:1015-1180
/// game game-selfgamedata channellist channel market
private static func extractGameSubtree(...) -> SubtreeResult { ... }
/// 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?) { ... }
} }
``` ```
**关键测试用例**(单测必须覆盖):
| 用例 | 验证 |
|------|------|
| 顶层 / 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 找不到匹配 | 返回 default0/nil),不抛 |
| gameid 找不到匹配 | 返回 default,不抛 |
| 字段名 game_download vs game_zip 混用 | 两个 key 都查,择一非空 |
#### 6.3.3 LocalVersionReader #### 6.3.3 LocalVersionReader
```swift ```swift
+34
View File
@@ -72,6 +72,40 @@ final class RootViewController: UIViewController {
} else { } else {
print("[WVJB.js] ERROR: 找不到 Bundle 内 JS/WebViewJavascriptBridge.js") print("[WVJB.js] ERROR: 找不到 Bundle 内 JS/WebViewJavascriptBridge.js")
} }
// Phase 1.10 .txt cache-busting +
Task {
let start = Date()
do {
let outcome = try await RemoteConfigClient.shared.fetch()
let elapsed = Date().timeIntervalSince(start)
switch outcome {
case .parsed(let cfg):
print("""
[RemoteConfigClient] .parsed \(String(format: "%.3f", elapsed))s
showmessage = \(cfg.showmessage ?? "<nil>")
agentlist count = \(cfg.agentlist?.count ?? 0)
""")
if let first = cfg.agentlist?.first {
print("""
agent[0].agentid = \(first.agentid ?? "<nil>")
agent[0].appVersion = \(first.appVersion ?? "<nil>")
agent[0].gameVersion = \(first.gameVersion ?? "<nil>")
agent[0].channellist = \(first.channellist?.count ?? 0)
agent[0].gamelist = \(first.gamelist?.count ?? 0)
""")
}
case .shortText(let msg):
print("""
[RemoteConfigClient] \(String(format: "%.3f", elapsed))s
= "\(msg)"
""")
}
} catch {
print("[RemoteConfigClient] ERROR: \(error)")
}
}
} }
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
@@ -0,0 +1,278 @@
//
// RemoteConfigClient.swift
// ylgamehall
//
// BundleConfig.gameConfig .txt JSON
// docs/H5-Native-Implementation-Design.md §6.3.1 / Plan ADR-008
//
import Foundation
// MARK: - Codable agent channel marketagent gamelist
nonisolated public struct RemoteConfig: Codable, Sendable {
public let showmessage: String?
public let agentlist: [Agent]?
}
nonisolated public struct Agent: Codable, Sendable {
public let agentid: String?
public let showmessage: String?
public let appVersion: String?
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 {
let c = try decoder.container(keyedBy: CodingKeys.self)
agentid = try c.decodeFlexibleStringIfPresent(forKey: .agentid)
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion)
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 appVersion, appDownload, gameVersion, gameZip
}
}
nonisolated public struct Channel: Codable, Sendable {
public let channelid: String?
public let showmessage: String?
public let appVersion: String?
public let appDownload: String?
public let gameVersion: String?
public let gameZip: String?
public let marketlist: [Market]?
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
channelid = try c.decodeFlexibleStringIfPresent(forKey: .channelid)
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion)
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
marketlist = try c.decodeIfPresent([Market].self, forKey: .marketlist)
}
private enum CodingKeys: String, CodingKey {
case channelid, showmessage, marketlist
case appVersion, appDownload, gameVersion, gameZip
}
}
nonisolated public struct Market: Codable, Sendable {
public let marketid: String?
public let showmessage: String?
public let appVersion: String?
public let appDownload: String?
public let gameVersion: String?
public let gameZip: String?
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
marketid = try c.decodeFlexibleStringIfPresent(forKey: .marketid)
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion)
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
}
private enum CodingKeys: String, CodingKey {
case marketid, showmessage
case appVersion, appDownload, gameVersion, gameZip
}
}
nonisolated public struct Game: Codable, Sendable {
public let gameid: String?
public let showmessage: String?
public let appVersion: String?
public let appDownload: String?
public let gameVersion: String?
public let gameZip: String?
public let channellist: [Channel]?
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
gameid = try c.decodeFlexibleStringIfPresent(forKey: .gameid)
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion)
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)
}
private enum CodingKeys: String, CodingKey {
case gameid, showmessage, channellist
case appVersion, appDownload, gameVersion, gameZip
}
}
/// `KeyedDecodingContainer` String app_version/game_version
/// String / Int / Double msext NSString Codable
/// Bool 0
nonisolated extension KeyedDecodingContainer {
func decodeFlexibleStringIfPresent(forKey key: Key) throws -> String? {
// key nil decodeNil(forKey:) key throw keyNotFound
guard contains(key) else { return nil }
if (try? decodeNil(forKey: key)) == true { return nil }
if let s = try? decode(String.self, forKey: key) { return s }
if let i = try? decode(Int.self, forKey: key) { return String(i) }
if let d = try? decode(Double.self, forKey: key) {
// Double .0 "43.0" "43"
return d.truncatingRemainder(dividingBy: 1) == 0
? String(Int(d))
: String(d)
}
if let b = try? decode(Bool.self, forKey: key) { return String(b) }
return nil
}
}
// MARK: -
/// outcomemsext `NewRootVC.m:1239-1244`
public enum RemoteConfigOutcome: Sendable {
/// JSON
case parsed(RemoteConfig)
/// trim utf16.count 100 #1
///
/// ADR-008-B / msext NewRootVC.m:1239-1243
case shortText(String)
}
// MARK: -
public enum RemoteConfigError: Error, Sendable {
/// `BundleConfig.gameConfig` URL
case urlConstructionFailed
/// HTTP 2xx
case httpStatus(Int)
/// JSON
case decodingFailed(any Error)
///
case allRetriesFailed(underlying: any Error)
}
// MARK: -
/// actor URLSession async + 退
public actor RemoteConfigClient {
public static let shared = RemoteConfigClient()
private let session: URLSession
private let urlBuilder: @Sendable () -> URL?
private let maxRetries: Int
public init(
session: URLSession = RemoteConfigClient.defaultSession,
urlBuilder: @escaping @Sendable () -> URL? = RemoteConfigClient.defaultURLBuilder,
maxRetries: Int = 3
) {
self.session = session
self.urlBuilder = urlBuilder
self.maxRetries = maxRetries
}
/// URLSession 10 s
nonisolated public static var defaultSession: URLSession {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 10
cfg.timeoutIntervalForResource = 30
return URLSession(configuration: cfg)
}
/// URL `https://` + `BundleConfig.gameConfig.replacingOccurrences("-", "/")` + `.txt`
/// demo https://tsgames.daoqi88.cn/config_test/update_jsonv2_test.txt
///
/// msext `NewRootVC.m:250` http HTTPS
/// NSAllowsArbitraryLoads ATS SERVERNew
nonisolated public static let defaultURLBuilder: @Sendable () -> URL? = {
let gameConfig = BundleConfig.shared.gameConfig
guard !gameConfig.isEmpty else { return nil }
let path = gameConfig.replacingOccurrences(of: "-", with: "/")
return URL(string: "https://\(path).txt")
}
/// 退 1/2/4 maxRetries
///
/// - Returns: `.parsed(RemoteConfig)` JSON `.shortText(String)`
/// trim utf16.count 100msext
public func fetch() async throws -> RemoteConfigOutcome {
guard let baseURL = urlBuilder() else {
throw RemoteConfigError.urlConstructionFailed
}
var lastError: any Error = RemoteConfigError.urlConstructionFailed
for attempt in 0..<maxRetries {
do {
// cache-busting query CDN / Proxy
// ?vXXXXXXXXYYYYYYYY 8 hex `=`msext NewRootVC.m:1226
let url = appendCacheBuster(to: baseURL)
return try await fetchOnce(url: url)
} catch {
lastError = error
if attempt < maxRetries - 1 {
let delaySeconds = pow(2.0, Double(attempt))
try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000))
}
}
}
throw RemoteConfigError.allRetriesFailed(underlying: lastError)
}
private func fetchOnce(url: URL) async throws -> RemoteConfigOutcome {
let (data, response) = try await session.data(from: url)
if let http = response as? HTTPURLResponse,
!(200..<300).contains(http.statusCode) {
throw RemoteConfigError.httpStatus(http.statusCode)
}
// UTF-8 + trimmsext NewRootVC.m:1237-1238
guard let raw = String(data: data, encoding: .utf8) else {
throw RemoteConfigError.decodingFailed(
NSError(domain: "RemoteConfigClient", code: -1,
userInfo: [NSLocalizedDescriptionKey: "response is not UTF-8"])
)
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
// 100 utf16 unit alert
// msext NewRootVC.m:1239-1243 NSString length UTF-16 unit count utf16.count 1:1
if trimmed.utf16.count <= 100 {
return .shortText(trimmed)
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
let parsed = try decoder.decode(RemoteConfig.self, from: data)
return .parsed(parsed)
} catch {
throw RemoteConfigError.decodingFailed(error)
}
}
/// URL cache-busting query `?vXXXXXXXXYYYYYYYY`msext NewRootVC.m:1226
/// `?v=XXXX` query `?v<16hex>`
nonisolated private func appendCacheBuster(to url: URL) -> URL {
let token = String(format: "%08X%08X",
UInt32.random(in: 0...UInt32.max),
UInt32.random(in: 0...UInt32.max))
// URLComponents query
let absolute = url.absoluteString
let separator = absolute.contains("?") ? "&" : "?"
return URL(string: "\(absolute)\(separator)v\(token)") ?? url
}
}