From f3c66b670d6ec75e0c42387111b49b485df620a7 Mon Sep 17 00:00:00 2001 From: joywayer Date: Mon, 22 Jun 2026 02:46:11 +0800 Subject: [PATCH] =?UTF-8?q?Phase=201.14.c=EF=BC=9ALobbyZipUpgrader=20?= =?UTF-8?q?=E5=8A=A0=200=E2=80=A61=20progress=20=E5=9B=9E=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/Development-Plan.md | 10 ++-- ylgamehall/RootViewController.swift | 31 ++++++++++- .../Source/Resource/LobbyZipUpgrader.swift | 55 +++++++++++++++++-- 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index e75c1c0..c641a06 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -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 个 warning(iPad 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 通过 -- [ ] **1.14.c** `LobbyZipUpgrader` 加 progress 回调 - - `upgradeIfNeeded(resolved:onProgress:) async throws -> Outcome`,`onProgress: @Sendable (Double) -> Void` 报告 0.0…1.0 - - 用 `URLSession.download` 配 `URLSessionTaskDelegate.urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:)` 上报 +- [x] **1.14.c** `LobbyZipUpgrader` 加 progress 回调 + - `upgradeIfNeeded(resolved:onProgress:) async throws -> Outcome`,`onProgress: @escaping @Sendable (Double) -> Void` 默认 no-op 报告 0.0…1.0 + - 文件内私有 `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`(基类) - 持有 `BridgedWebView` + `BridgeBus` - 16:9 比例布局:屏幕比 < 16:9 用宽度撑满上下黑边;屏幕比 > 16:9 用高度撑满左右黑边 @@ -796,7 +798,7 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - [x] 1.13 LobbyZipUpgrader(actor,原子 rename 升级;端到端实测 260→261) - [x] 1.14.a AppIcon 替换 Xcode 默认空模板(iPad / 1024 缺失留 Phase 10 polish) - [x] 1.14.b LaunchScreen 改启动图(SplashImage 全屏 scaleAspectFill,竖图横屏裁剪折衷) -- [ ] 1.14.c LobbyZipUpgrader 加 progress 回调 +- [x] 1.14.c LobbyZipUpgrader 加 progress 回调(URLSessionDownloadDelegate,0.0…1.0) - [ ] 1.14.d WebContainerViewController(含 splash UI 状态层 + 16:9 letterbox + 启动流水线串接) - [ ] 1.14.e WebView didFinish 后淡出 splash - [ ] 1.15 VibratorHandler diff --git a/ylgamehall/RootViewController.swift b/ylgamehall/RootViewController.swift index c42f04c..ddcb93b 100644 --- a/ylgamehall/RootViewController.swift +++ b/ylgamehall/RootViewController.swift @@ -129,10 +129,19 @@ final class RootViewController: UIViewController { → H5 zip 升级 = \(needZipUpgrade ? "需要" : "不需要") """) - // Phase 1.13 烟雾测试:触发 LobbyZipUpgrader(如果需要升级) + // Phase 1.13 / 1.14.c 烟雾测试:触发 LobbyZipUpgrader(如果需要升级), + // 并打印 progress 回调(每 10% 报一次,避免刷屏) let upgradeStart = Date() 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) switch outcome { case .noop: @@ -173,3 +182,21 @@ final class RootViewController: UIViewController { 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) + } + } +} diff --git a/ylgamehall/Source/Resource/LobbyZipUpgrader.swift b/ylgamehall/Source/Resource/LobbyZipUpgrader.swift index ed81570..cefb417 100644 --- a/ylgamehall/Source/Resource/LobbyZipUpgrader.swift +++ b/ylgamehall/Source/Resource/LobbyZipUpgrader.swift @@ -44,9 +44,15 @@ public actor LobbyZipUpgrader { /// 比较远端 game_version 与本地 version.xml。需要升级则下载 + 解压 + 原子 rename。 /// - /// - Parameter resolved: VersionResolver.resolve(...) 的结果 + /// - Parameters: + /// - resolved: VersionResolver.resolve(...) 的结果 + /// - onProgress: 下载进度回调 0.0…1.0,由 URLSession delegate 在后台线程派发; + /// UI 层负责把闭包 hop 到 MainActor。默认 no-op 兼容仅做版本对比的烟雾测试。 /// - 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 guard resolved.gameVersion > localGameVersion, let zipURLString = resolved.gameZip, @@ -55,11 +61,15 @@ public actor LobbyZipUpgrader { return .noop } - // 1. 下载到 tmp(URLSession 默认管理临时位置) + // 1. 下载到 tmp(URLSession 默认管理临时位置),delegate 上报 0…1 进度 + let progressDelegate = DownloadProgressDelegate(onProgress: onProgress) let downloadedURL: URL let response: URLResponse do { - (downloadedURL, response) = try await session.download(from: zipURL) + (downloadedURL, response) = try await session.download( + from: zipURL, + delegate: progressDelegate + ) } catch { throw UpgradeError.downloadFailed(error) } @@ -67,6 +77,9 @@ public actor LobbyZipUpgrader { !(200..<300).contains(http.statusCode) { throw UpgradeError.httpStatus(http.statusCode) } + // 下载完成兜底报 1.0:部分 server 不发 Content-Length 或末尾片段晚到, + // 不显式补这一下,进度条可能停在 99% 直到解压结束才跳走 + onProgress(1.0) // 2. 解压到隔离 staging 目录 let staging = SandboxPaths.caches @@ -99,3 +112,37 @@ public actor LobbyZipUpgrader { return .upgraded(from: localGameVersion, to: resolved.gameVersion) } } + +/// URLSession 下载进度桥接:把 `didWriteData` 的 byte 计数换算成 0…1,回调上层。 +/// +/// 之所以放在文件内私有:仅 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 + } +}