docs:Plan / Design 补 Phase 1 远程配置 + zip 升级流水线(ADR-008)

调研 daoqi/msext 原项目 NewRootVC.m:1551-1621 viewWillAppear、1372-1492
chulishengji、1789-1869 downFileFromServer 三段核心代码后发现 Plan 初版
Phase 1 漏掉了启动流程里最关键的一环——从 gameconfig 拼远端 .txt 拉
真实配置、做 agent → channel → market 4 级覆盖、对比本地版本、必要时
下载并替换 H5 zip。不做的代价是:上线后用户永远停留在 IPA 内打包瞬间
的 H5 旧版本,H5 团队任何更新都到达不了。

- Plan Phase 1 任务清单从 13 项扩为 17 项,插入 4 个新子项:
  - 1.10 RemoteConfigClient(actor,URLSession async + 指数退避)
  - 1.11 VersionResolver(纯函数 + 单测,reduce 实现 4 级覆盖)
  - 1.12 LocalVersionReader(解析 version.xml)
  - 1.13 LobbyZipUpgrader(actor,原子 rename)
  原 1.10-1.13 后移为 1.14-1.17。已完成的 1.1-1.9 标记为 
- Plan 新增 ADR-008 完整记录决策背景 / 触发事件 / 与 msext 实现差异
  对照表 / 守护条款 / 子游戏复用规划
- Design §6.3 整节重写:从原"ConfigService 一锅端"扩为完整 4 模块流水线
  (§6.3.1-6.3.7),含 Codable RemoteConfig 模型、纯函数 VersionResolver、
  nonisolated LocalVersionReader、actor LobbyZipUpgrader、WebContainer
  调用串、与 msext 差异表、子游戏 Phase 6 复用规划
- 关键设计纠正:
  - 远端 URL 不带 SERVERNew 前缀,仅 gameconfig.replace("-","/") + ".txt"
  - 4 级覆盖:顶层 → agent → channel → market 深层胜出 + game 子树覆盖
    agent 子树
  - 解压策略:staging-{uuid}/ 临时目录 + 原子 moveItem rename,不学
    msext "目录名 +1" 累积 hack
This commit is contained in:
joywayer
2026-06-22 00:42:30 +08:00
parent 65505b0a79
commit 5f032342e7
2 changed files with 298 additions and 47 deletions
+193 -11
View File
@@ -1022,25 +1022,207 @@ final class SigningContractTests: XCTestCase {
}
```
### 6.3 配置 / Zip 下载层
### 6.3 远程配置 + 版本对比 + zip 升级流水线(**Phase 1 实施**ADR-008 详细记录)
完整链路由 4 个独立模块构成,全部 actor 隔离 + 单测覆盖:
```
RemoteConfigClient → VersionResolver → LocalVersionReader → LobbyZipUpgrader
(拉 .txt 配置) (4 级覆盖合并) (读 version.xml) (URLSession 下载 + 原子 rename)
↓ ↓ ↓ ↓
RemoteConfig ResolvedVersion (appVer, gameVer) UpgradeOutcome
{appVer, appDL, .noop / .upgraded
gameVer, gameZip}
```
#### 6.3.1 RemoteConfigClient
```swift
// ResourceKit/ConfigService.swift
public actor ConfigService {
private let client: HTTPClient
private let unzipper: ResourceUnzipper
// Source/Network/RemoteConfigClient.swift
public actor RemoteConfigClient {
private let session: URLSession
private let urlBuilder: () -> URL
public func syncIfNeeded() async throws {
let config = try await fetchRemoteConfig()
let local = try LocalVersion.read()
if local.gameVersion < config.gameVersion {
try await downloadAndUnzip(config.gameZipURL)
/// BundleConfig.shared.gameConfig
/// http:// + gameConfig.replacingOccurrences("-", "/") + ".txt"
/// msext SERVERNew NewRootVC.m:250
public init(
session: URLSession = .shared,
urlBuilder: @escaping () -> URL = Self.defaultURL
) { ... }
/// .txt JSON10s
/// 退 1/2/4 3 msext 4s timer
public func fetch() async throws -> RemoteConfig {
for attempt in 0..<3 {
do { return try await fetchOnce() }
catch where attempt < 2 {
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000)
}
}
// agent / channel / market / agentlist / gamelist
throw RemoteConfigError.allRetriesFailed
}
}
public struct RemoteConfig: Codable, Sendable {
public let showmessage: String?
public let agentlist: [Agent]?
}
public struct Agent: Codable, Sendable {
public let agentid: String
public let showmessage: String?
public let app_version: String?
public let app_download: String?
public let game_version: String?
public let game_zip: String?
public let channellist: [Channel]?
public let gamelist: [Game]?
}
public struct Channel: Codable, Sendable { ... }
public struct Game: Codable, Sendable { ... }
public struct Market: Codable, Sendable { ... }
```
#### 6.3.2 VersionResolver(纯函数)
```swift
// Source/Network/VersionResolver.swift
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?
}
/// 4 agent channel market
/// agent game game msext 'chulishengji'
public static func resolve(
config: RemoteConfig,
agentId: String,
channelId: String,
marketId: String,
gameId: String
) -> Resolved {
// pure function: actor / global
// reduce over [top, agent, channel, market]
...
}
}
```
#### 6.3.3 LocalVersionReader
```swift
// Source/Resource/LocalVersionReader.swift
public enum LocalVersionReader {
/// IPA ChannelConfig.plist appversion BundleConfig
nonisolated public static var localAppVersion: Int {
Int(BundleConfig.shared.appVersion) ?? 0
}
/// H5 Library/Caches/{gamedir}/{gamestart}/version.xml
/// /game/version value XMLParser / 0
nonisolated public static var localGameVersion: Int { ... }
}
```
#### 6.3.4 LobbyZipUpgrader
```swift
// Source/Resource/LobbyZipUpgrader.swift
public actor LobbyZipUpgrader {
public enum UpgradeOutcome {
case noop //
case upgraded(from: Int, to: Int)
}
public func upgradeIfNeeded(
remoteGameVersion: Int,
remoteGameZip: String?
) async throws -> UpgradeOutcome {
let local = LocalVersionReader.localGameVersion
guard remoteGameVersion > local, let zipURL = remoteGameZip.flatMap(URL.init) else {
return .noop
}
// 1. tmp URLSessionDownloadTask
let (tmpZip, _) = try await session.download(from: zipURL)
// 2.
let stagingDir = SandboxPaths.caches.appendingPathComponent("staging-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true)
try FileManager.default.unzipItem(at: tmpZip, to: stagingDir)
// 3. rename lobbyRoot stagingDir
let lobbyRoot = SandboxPaths.lobbyRoot
try? FileManager.default.removeItem(at: lobbyRoot)
try FileManager.default.moveItem(at: stagingDir, to: lobbyRoot)
// 4. tmp
try? FileManager.default.removeItem(at: tmpZip)
return .upgraded(from: local, to: remoteGameVersion)
}
}
```
#### 6.3.5 WebContainer 调用串
```swift
// Source/WebView/WebContainerViewController.swiftPhase 1.14
override func viewDidLoad() {
super.viewDidLoad()
Task { @MainActor in
try await ResourceUnzipper.shared.ensureReady() // Bundle zip
let config = try await RemoteConfigClient.shared.fetch()
let resolved = VersionResolver.resolve(
config: config,
agentId: BundleConfig.shared.agent,
channelId: BundleConfig.shared.channel,
marketId: BundleConfig.shared.market,
gameId: BundleConfig.shared.gameId
)
if resolved.appVersion > LocalVersionReader.localAppVersion {
// + Safari resolved.appDownload
return showAppUpgradeAlert(resolved.appDownload)
}
_ = try await LobbyZipUpgrader.shared.upgradeIfNeeded(
remoteGameVersion: resolved.gameVersion,
remoteGameZip: resolved.gameZip
)
bridgedWebView.webView.loadFileURL(
SandboxPaths.lobbyIndex,
allowingReadAccessTo: SandboxPaths.lobbyRoot
)
}
}
```
#### 6.3.6 与 msext 历史实现的差异(不要照抄的部分)
| 维度 | msext 现状(不要照抄) | 新外壳决策 |
|------|--------------------|----------|
| 网络模型 | `[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+ 用例 |
| 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`,半途崩溃留半残 | 解压到 `staging-{uuid}/` 临时目录 + 原子 `moveItem` rename,半途崩溃只留 tmp(下次启动可清) |
| 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11`) | 同款原子 rename,旧目录直接覆盖 |
| 配置解析 | SBJSON 三方库 | Codable + JSONDecoder |
| 版本号 | NSString → intValue(自动 0 | 强类型 Int,缺失明示 |
#### 6.3.7 子游戏升级复用
Phase 6 子游戏(`SwitchOverGameData` 入参的 `Gamedirectory` / `gamedownloadurl`)的升级逻辑**复用** `LobbyZipUpgrader` 的设计,差异仅在:
- 目标路径不同(`SandboxPaths.subGameRoot(dir)` 而非 `lobbyRoot`
- 版本号 source 不同(子游戏 `version.xml` 而非大厅)
- 不重新拉远端配置(继承大厅的 `RemoteConfig`
具体接口扩展到 Phase 6 设计时定。
---
## 7. 资源 & 渠道注入