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:
@@ -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(实际是 JSON),10s 超时。
|
||||
/// 指数退避 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.swift(Phase 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. 资源 & 渠道注入
|
||||
|
||||
Reference in New Issue
Block a user