From 522087f39885127670f4198f7c51f41f8713dae7 Mon Sep 17 00:00:00 2001 From: joywayer Date: Wed, 24 Jun 2026 22:40:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8B=E8=BD=BD=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E7=9E=AC=E9=97=B4=E8=B7=B3=20100%=EF=BC=9A=E6=8D=A2?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=20downloadTask=20+=20=E7=BB=AD=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit async 版 await session.download(from:) 内部走 completion-handler 路径, session 级 delegate 的 didWriteData 全部被跳过,结果只有兜底 onProgress(1.0) 在下载完成后才触发——用户看到的"瞬间 100%"就是这个唯一一次回调。 换成手动 session.downloadTask(with:).resume() + CheckedContinuation: - didWriteData 正常按 URLSession 节奏在 opQueue 上派发,进度真实推进 - didFinishDownloadingTo 内同步把临时文件 move 到沙盒 Caches 的已知位置 (否则 delegate 返回后系统立即清掉那个临时位置) - didCompleteWithError 里 resume 续作 - continuation/moveError 跨线程(actor → opQueue)用 NSLock 保护 LobbyZipUpgrader / SubGameDownloader 同款修复(大厅 zip 下载 + 子游戏 zip 下载)。 Co-Authored-By: Claude Opus 4.7 --- .../Source/Resource/LobbyZipUpgrader.swift | 80 ++++++++++++++++--- .../Source/Resource/SubGameDownloader.swift | 71 +++++++++++++--- 2 files changed, 130 insertions(+), 21 deletions(-) diff --git a/ylgamehall/Source/Resource/LobbyZipUpgrader.swift b/ylgamehall/Source/Resource/LobbyZipUpgrader.swift index 4a215eb..23890a4 100644 --- a/ylgamehall/Source/Resource/LobbyZipUpgrader.swift +++ b/ylgamehall/Source/Resource/LobbyZipUpgrader.swift @@ -60,10 +60,20 @@ public actor LobbyZipUpgrader { return .noop } - // 1. 下载到 tmp(URLSession 默认管理临时位置),delegate 上报 0…1 进度。 - // delegate 必须在 URLSession init 时绑定,否则 didWriteData 不会被调用 - // (async API `download(from:delegate:)` 的 ad-hoc delegate 只收 task-level 回调)。 - let progressDelegate = DownloadProgressDelegate(onProgress: onProgress) + // 1. 下载到 tmp。**必须用手动 downloadTask + 续作**,不能用 async 版 + // `await session.download(from:)`:后者内部走 completion-handler 路径, + // session 级 delegate 的 didWriteData 全部被跳过,结果只有下载完成才补一次 + // onProgress(1.0),进度条瞬间从 0% 跳到 100%(Apple Guide "Downloading files + // from websites" 明确:要进度必须 delegate + manual task.resume())。 + // 手动 task 路径下,didWriteData 由 URLSession 在 opQueue 上正常派发; + // didFinishDownloadingTo 内同步把临时文件 move 到 savedURL(否则 delegate + // 返回后系统会立即清掉那个临时位置);didCompleteWithError 里 resume 续作。 + let savedURL = SandboxPaths.caches + .appendingPathComponent("lobby-download-\(UUID().uuidString).zip") + let progressDelegate = DownloadProgressDelegate( + onProgress: onProgress, + moveDownloadedTo: savedURL + ) let opQueue = OperationQueue() opQueue.maxConcurrentOperationCount = 1 let session = URLSession( @@ -73,17 +83,22 @@ public actor LobbyZipUpgrader { ) defer { session.invalidateAndCancel() } - let downloadedURL: URL let response: URLResponse do { - (downloadedURL, response) = try await session.download(from: zipURL) + response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + progressDelegate.setContinuation(cont) + session.downloadTask(with: zipURL).resume() + } } catch { + try? FileManager.default.removeItem(at: savedURL) throw UpgradeError.downloadFailed(error) } if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + try? FileManager.default.removeItem(at: savedURL) throw UpgradeError.httpStatus(http.statusCode) } + let downloadedURL = savedURL // 下载完成兜底报 1.0:部分 server 不发 Content-Length 或末尾片段晚到, // 不显式补这一下,进度条可能停在 99% 直到解压结束才跳走 onProgress(1.0) @@ -120,21 +135,34 @@ public actor LobbyZipUpgrader { } } -/// URLSession 下载进度桥接:把 `didWriteData` 的 byte 计数换算成 0…1,回调上层。 +/// URLSession 下载进度桥接:把 `didWriteData` 的 byte 计数换算成 0…1,回调上层; +/// 同时驱动下载完成的 CheckedContinuation。 /// /// 之所以放在文件内私有:仅 LobbyZipUpgrader 用,不构成对外 API。 /// `@unchecked Sendable`:URLSessionDownloadDelegate 协议本身不带 Sendable; -/// 我们的状态仅是一个 `@Sendable` 闭包,无可变共享,标记安全。 +/// 内部可变状态(continuation / moveError)跨线程(actor → opQueue),用 NSLock 保护。 private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { private let onProgress: @Sendable (Double) -> Void + private let moveDownloadedTo: URL + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var moveError: (any Error)? - init(onProgress: @escaping @Sendable (Double) -> Void) { + init(onProgress: @escaping @Sendable (Double) -> Void, moveDownloadedTo: URL) { self.onProgress = onProgress + self.moveDownloadedTo = moveDownloadedTo super.init() } + /// 由 actor 在启动 downloadTask 前调一次。必须在 `task.resume()` 之前调, + /// 否则 didCompleteWithError 可能先于 setContinuation 发生(极端情况下短下载)。 + func setContinuation(_ c: CheckedContinuation) { + lock.lock(); defer { lock.unlock() } + continuation = c + } + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, @@ -145,11 +173,39 @@ private final class DownloadProgressDelegate: NSObject, onProgress(min(max(p, 0), 1)) } - /// 必须实现的协议方法。async download API 内部已接管下载文件落地, - /// 这里无需把文件搬到任何位置。 + /// 必须在 delegate 方法返回前同步把临时文件 move 到 `moveDownloadedTo`: + /// 系统会在本方法返回后立即清理 `location` 处的临时文件。 + /// move 出错记录到 moveError,由 didCompleteWithError 统一抛回 continuation。 func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - // no-op + do { + try? FileManager.default.removeItem(at: moveDownloadedTo) + try FileManager.default.moveItem(at: location, to: moveDownloadedTo) + } catch { + lock.lock(); defer { lock.unlock() } + moveError = error + } + } + + func urlSession(_ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: (any Error)?) { + lock.lock(); defer { lock.unlock() } + guard let c = continuation else { return } + continuation = nil + if let error { + c.resume(throwing: error) + } else if let me = moveError { + c.resume(throwing: me) + } else if let response = task.response { + c.resume(returning: response) + } else { + c.resume(throwing: NSError( + domain: "LobbyZipUpgrader.DownloadProgressDelegate", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "download finished without response"] + )) + } } } diff --git a/ylgamehall/Source/Resource/SubGameDownloader.swift b/ylgamehall/Source/Resource/SubGameDownloader.swift index 8ab3952..d7682e9 100644 --- a/ylgamehall/Source/Resource/SubGameDownloader.swift +++ b/ylgamehall/Source/Resource/SubGameDownloader.swift @@ -141,10 +141,16 @@ public actor SubGameDownloader { throw DownloadError.badDownloadURL(zipURLString) } - // 1. 下载到 tmp。delegate 必须在 URLSession init 时绑定,否则 - // URLSessionDownloadDelegate.didWriteData 不会被调用(async API - // `download(from:delegate:)` 的 ad-hoc delegate 只收 task-level 回调)。 - let progressDelegate = DownloadProgressDelegate(onProgress: onProgress) + // 1. 下载到 tmp。**必须用手动 downloadTask + 续作**,不能用 async 版 + // `await session.download(from:)`:后者内部走 completion-handler 路径, + // session 级 delegate 的 didWriteData 全部被跳过,结果只有下载完成才补一次 + // onProgress(1.0),进度条瞬间从 0% 跳到 100%。详见 LobbyZipUpgrader 同款修复注释。 + let savedURL = SandboxPaths.caches + .appendingPathComponent("subgame-download-\(UUID().uuidString).zip") + let progressDelegate = DownloadProgressDelegate( + onProgress: onProgress, + moveDownloadedTo: savedURL + ) let opQueue = OperationQueue() opQueue.maxConcurrentOperationCount = 1 let session = URLSession( @@ -154,17 +160,22 @@ public actor SubGameDownloader { ) defer { session.invalidateAndCancel() } - let downloadedURL: URL let response: URLResponse do { - (downloadedURL, response) = try await session.download(from: zipURL) + response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + progressDelegate.setContinuation(cont) + session.downloadTask(with: zipURL).resume() + } } catch { + try? FileManager.default.removeItem(at: savedURL) throw DownloadError.downloadFailed(error) } if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + try? FileManager.default.removeItem(at: savedURL) throw DownloadError.httpStatus(http.statusCode) } + let downloadedURL = savedURL onProgress(1.0) let fm = FileManager.default @@ -201,19 +212,32 @@ public actor SubGameDownloader { } } -/// URLSession 下载进度桥接。 +/// URLSession 下载进度桥接:把 didWriteData 的 byte 计数换算成 0…1,回调上层; +/// 同时驱动下载完成的 CheckedContinuation。 /// /// 与 LobbyZipUpgrader 同款实现就地复制(两处使用,不抽公共 — 三处出现时再合并)。 private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { private let onProgress: @Sendable (Double) -> Void + private let moveDownloadedTo: URL + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var moveError: (any Error)? - init(onProgress: @escaping @Sendable (Double) -> Void) { + init(onProgress: @escaping @Sendable (Double) -> Void, moveDownloadedTo: URL) { self.onProgress = onProgress + self.moveDownloadedTo = moveDownloadedTo super.init() } + /// 由 actor 在启动 downloadTask 前调一次。必须在 `task.resume()` 之前调, + /// 否则 didCompleteWithError 可能先于 setContinuation 发生(极端情况下短下载)。 + func setContinuation(_ c: CheckedContinuation) { + lock.lock(); defer { lock.unlock() } + continuation = c + } + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, @@ -224,9 +248,38 @@ private final class DownloadProgressDelegate: NSObject, onProgress(min(max(p, 0), 1)) } + /// 必须在 delegate 方法返回前同步把临时文件 move 到 `moveDownloadedTo`: + /// 系统会在本方法返回后立即清理 `location` 处的临时文件。 func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - // no-op + do { + try? FileManager.default.removeItem(at: moveDownloadedTo) + try FileManager.default.moveItem(at: location, to: moveDownloadedTo) + } catch { + lock.lock(); defer { lock.unlock() } + moveError = error + } + } + + func urlSession(_ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: (any Error)?) { + lock.lock(); defer { lock.unlock() } + guard let c = continuation else { return } + continuation = nil + if let error { + c.resume(throwing: error) + } else if let me = moveError { + c.resume(throwing: me) + } else if let response = task.response { + c.resume(returning: response) + } else { + c.resume(throwing: NSError( + domain: "SubGameDownloader.DownloadProgressDelegate", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "download finished without response"] + )) + } } }