Phase 1.14.c:LobbyZipUpgrader 加 0…1 progress 回调

- upgradeIfNeeded 新增 onProgress: @escaping @Sendable (Double) -> Void 参数,
  默认 no-op 兼容仅做版本对比的烟雾测试
- 新增文件内私有 DownloadProgressDelegate(URLSessionDownloadDelegate
  + @unchecked Sendable),在 didWriteData 回调里把 totalBytesWritten /
  totalBytesExpectedToWrite 换算成 0…1 上报,clamp 到 [0,1]
- 通过 session.download(from:delegate:) 注入;下载完成后兜底报 1.0
  (部分 server 不发 Content-Length 或末尾片段晚到,避免进度条停在 99%)
- RootViewController 烟雾测试用 ProgressTicker 把回调节流到 10% 阶梯打印,
  避免几百行刷屏(NSLock 保护单 Int 状态,@unchecked Sendable)
- BuildProject 通过

Plan 进度已勾选(§5 Phase 1.14.c + §8)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-22 02:46:11 +08:00
co-authored by Claude Opus 4.7
parent ba34854133
commit f3c66b670d
3 changed files with 86 additions and 10 deletions
+6 -4
View File
@@ -289,9 +289,11 @@ Contract Design Plan(本文档)
- [x] **1.14.a** AppIcon:从 `docs/res/Images.xcassets/AppIcon-1.appiconset` 拷贝 9 个 png + Contents.json 到 `ylgamehall/Assets.xcassets/AppIcon.appiconset`,替换 Xcode 默认空模板。已实测 BuildProject 通过。actool 3 个 warningiPad 76 / 83.5 / 1024 缺失)暂不处理,iPad 会自动用 iPhone 缩放,1024 是 App Store 上架要求(企业签不强制);待 Phase 10 上线前 polish 时补齐 - [x] **1.14.a** AppIcon:从 `docs/res/Images.xcassets/AppIcon-1.appiconset` 拷贝 9 个 png + Contents.json 到 `ylgamehall/Assets.xcassets/AppIcon.appiconset`,替换 Xcode 默认空模板。已实测 BuildProject 通过。actool 3 个 warningiPad 76 / 83.5 / 1024 缺失)暂不处理,iPad 会自动用 iPhone 缩放,1024 是 App Store 上架要求(企业签不强制);待 Phase 10 上线前 polish 时补齐
- [x] **1.14.b** LaunchScreen:把 `docs/res/Res/Default-568h@2x~iphone.png` 拷到 `ylgamehall/Assets.xcassets/SplashImage.imageset`universal idiom,仅 @2x 物理文件 29708 字节 640×1136 竖图);改写 `LaunchScreen.storyboard` 为单一 UIImageView 全屏铺满(contentMode = .scaleAspectFill,背景 .black,四边约束到 superview)。注:竖图在横屏设备上会被 aspectFill 裁剪上下,启动只显示 0.x s 接受此折衷,Phase 10 polish 时再考虑出一张专门的横屏素材。BuildProject 通过 - [x] **1.14.b** LaunchScreen:把 `docs/res/Res/Default-568h@2x~iphone.png` 拷到 `ylgamehall/Assets.xcassets/SplashImage.imageset`universal idiom,仅 @2x 物理文件 29708 字节 640×1136 竖图);改写 `LaunchScreen.storyboard` 为单一 UIImageView 全屏铺满(contentMode = .scaleAspectFill,背景 .black,四边约束到 superview)。注:竖图在横屏设备上会被 aspectFill 裁剪上下,启动只显示 0.x s 接受此折衷,Phase 10 polish 时再考虑出一张专门的横屏素材。BuildProject 通过
- [ ] **1.14.c** `LobbyZipUpgrader` 加 progress 回调 - [x] **1.14.c** `LobbyZipUpgrader` 加 progress 回调
- `upgradeIfNeeded(resolved:onProgress:) async throws -> Outcome``onProgress: @Sendable (Double) -> Void` 报告 0.0…1.0 - `upgradeIfNeeded(resolved:onProgress:) async throws -> Outcome``onProgress: @escaping @Sendable (Double) -> Void` 默认 no-op 报告 0.0…1.0
- `URLSession.download``URLSessionTaskDelegate.urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:)` 上报 - 文件内私有 `DownloadProgressDelegate: URLSessionDownloadDelegate, @unchecked Sendable`,实现 `didWriteData:totalBytesWritten:totalBytesExpectedToWrite:`;通过 `session.download(from:delegate:)` 注入
- 完成时兜底报 1.0(部分 server 不发 Content-Length 或末尾片段晚到,避免进度条停在 99%)
- RootViewController 烟雾测试用 `ProgressTicker` 节流到 10% 阶梯打印;BuildProject 通过
- [ ] **1.14.d** 实现 `Source/WebView/WebContainerViewController.swift`(基类) - [ ] **1.14.d** 实现 `Source/WebView/WebContainerViewController.swift`(基类)
- 持有 `BridgedWebView` + `BridgeBus` - 持有 `BridgedWebView` + `BridgeBus`
- 16:9 比例布局:屏幕比 < 16:9 用宽度撑满上下黑边;屏幕比 > 16:9 用高度撑满左右黑边 - 16:9 比例布局:屏幕比 < 16:9 用宽度撑满上下黑边;屏幕比 > 16:9 用高度撑满左右黑边
@@ -796,7 +798,7 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
- [x] 1.13 LobbyZipUpgraderactor,原子 rename 升级;端到端实测 260→261) - [x] 1.13 LobbyZipUpgraderactor,原子 rename 升级;端到端实测 260→261)
- [x] 1.14.a AppIcon 替换 Xcode 默认空模板(iPad / 1024 缺失留 Phase 10 polish - [x] 1.14.a AppIcon 替换 Xcode 默认空模板(iPad / 1024 缺失留 Phase 10 polish
- [x] 1.14.b LaunchScreen 改启动图(SplashImage 全屏 scaleAspectFill,竖图横屏裁剪折衷) - [x] 1.14.b LaunchScreen 改启动图(SplashImage 全屏 scaleAspectFill,竖图横屏裁剪折衷)
- [ ] 1.14.c LobbyZipUpgrader 加 progress 回调 - [x] 1.14.c LobbyZipUpgrader 加 progress 回调URLSessionDownloadDelegate0.0…1.0
- [ ] 1.14.d WebContainerViewController(含 splash UI 状态层 + 16:9 letterbox + 启动流水线串接) - [ ] 1.14.d WebContainerViewController(含 splash UI 状态层 + 16:9 letterbox + 启动流水线串接)
- [ ] 1.14.e WebView didFinish 后淡出 splash - [ ] 1.14.e WebView didFinish 后淡出 splash
- [ ] 1.15 VibratorHandler - [ ] 1.15 VibratorHandler
+29 -2
View File
@@ -129,10 +129,19 @@ final class RootViewController: UIViewController {
→ H5 zip 升级 = \(needZipUpgrade ? "需要" : "不需要") → H5 zip 升级 = \(needZipUpgrade ? "需要" : "不需要")
""") """)
// Phase 1.13 LobbyZipUpgrader // Phase 1.13 / 1.14.c LobbyZipUpgrader
// progress 10%
let upgradeStart = Date() let upgradeStart = Date()
do { do {
let outcome = try await LobbyZipUpgrader.shared.upgradeIfNeeded(resolved: resolved) let lastReported = ProgressTicker()
let outcome = try await LobbyZipUpgrader.shared.upgradeIfNeeded(
resolved: resolved,
onProgress: { p in
lastReported.tickIfPassed(p) { reported in
print(String(format: "[LobbyZipUpgrader] 下载进度 %3d%%", Int(reported * 100)))
}
}
)
let elapsed = Date().timeIntervalSince(upgradeStart) let elapsed = Date().timeIntervalSince(upgradeStart)
switch outcome { switch outcome {
case .noop: case .noop:
@@ -173,3 +182,21 @@ final class RootViewController: UIViewController {
override var prefersStatusBarHidden: Bool { false } override var prefersStatusBarHidden: Bool { false }
} }
/// Phase 1.14.c 使 10% progress
/// `@unchecked Sendable` Int NSLock
private final class ProgressTicker: @unchecked Sendable {
private let lock = NSLock()
private var lastBucket: Int = -1
func tickIfPassed(_ progress: Double, action: (Double) -> Void) {
let bucket = Int(progress * 10)
lock.lock()
let shouldFire = bucket > lastBucket
if shouldFire { lastBucket = bucket }
lock.unlock()
if shouldFire {
action(Double(bucket) / 10.0)
}
}
}
@@ -44,9 +44,15 @@ public actor LobbyZipUpgrader {
/// game_version version.xml + + rename /// game_version version.xml + + rename
/// ///
/// - Parameter resolved: VersionResolver.resolve(...) /// - Parameters:
/// - resolved: VersionResolver.resolve(...)
/// - onProgress: 0.01.0 URLSession delegate 线
/// UI hop MainActor no-op
/// - Returns: `.noop` `.upgraded(from:to:)` /// - Returns: `.noop` `.upgraded(from:to:)`
public func upgradeIfNeeded(resolved: ResolvedVersion) async throws -> Outcome { public func upgradeIfNeeded(
resolved: ResolvedVersion,
onProgress: @escaping @Sendable (Double) -> Void = { _ in }
) async throws -> Outcome {
let localGameVersion = LocalVersionReader.localGameVersion let localGameVersion = LocalVersionReader.localGameVersion
guard resolved.gameVersion > localGameVersion, guard resolved.gameVersion > localGameVersion,
let zipURLString = resolved.gameZip, let zipURLString = resolved.gameZip,
@@ -55,11 +61,15 @@ public actor LobbyZipUpgrader {
return .noop return .noop
} }
// 1. tmpURLSession // 1. tmpURLSession delegate 01
let progressDelegate = DownloadProgressDelegate(onProgress: onProgress)
let downloadedURL: URL let downloadedURL: URL
let response: URLResponse let response: URLResponse
do { do {
(downloadedURL, response) = try await session.download(from: zipURL) (downloadedURL, response) = try await session.download(
from: zipURL,
delegate: progressDelegate
)
} catch { } catch {
throw UpgradeError.downloadFailed(error) throw UpgradeError.downloadFailed(error)
} }
@@ -67,6 +77,9 @@ public actor LobbyZipUpgrader {
!(200..<300).contains(http.statusCode) { !(200..<300).contains(http.statusCode) {
throw UpgradeError.httpStatus(http.statusCode) throw UpgradeError.httpStatus(http.statusCode)
} }
// 1.0 server Content-Length
// 99%
onProgress(1.0)
// 2. staging // 2. staging
let staging = SandboxPaths.caches let staging = SandboxPaths.caches
@@ -99,3 +112,37 @@ public actor LobbyZipUpgrader {
return .upgraded(from: localGameVersion, to: resolved.gameVersion) return .upgraded(from: localGameVersion, to: resolved.gameVersion)
} }
} }
/// URLSession `didWriteData` byte 01
///
/// LobbyZipUpgrader API
/// `@unchecked Sendable`URLSessionDownloadDelegate Sendable
/// `@Sendable`
private final class DownloadProgressDelegate: NSObject,
URLSessionDownloadDelegate,
@unchecked Sendable {
private let onProgress: @Sendable (Double) -> Void
init(onProgress: @escaping @Sendable (Double) -> Void) {
self.onProgress = onProgress
super.init()
}
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64) {
guard totalBytesExpectedToWrite > 0 else { return }
let p = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
onProgress(min(max(p, 0), 1))
}
/// async download API
///
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
// no-op
}
}