Files
youle_app_ios_v2/ylgamehall/Source/Network/RemoteConfigClient.swift
T
joywayer 1bc287bafc 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 路径是热路径
2026-06-22 02:14:57 +08:00

279 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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
}
}