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
@@ -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 → market;agent 子树另有 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: - 拉取结果
/// 远端配置拉取的两种成功 outcome(msext `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 ≤ 100,msext 契约)
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 解码 + trim(msext 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
}
}