修复下载进度瞬间跳 100%:换手动 downloadTask + 续作
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
800497fd18
commit
522087f398
@@ -60,10 +60,20 @@ public actor LobbyZipUpgrader {
|
|||||||
return .noop
|
return .noop
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 下载到 tmp(URLSession 默认管理临时位置),delegate 上报 0…1 进度。
|
// 1. 下载到 tmp。**必须用手动 downloadTask + 续作**,不能用 async 版
|
||||||
// delegate 必须在 URLSession init 时绑定,否则 didWriteData 不会被调用
|
// `await session.download(from:)`:后者内部走 completion-handler 路径,
|
||||||
// (async API `download(from:delegate:)` 的 ad-hoc delegate 只收 task-level 回调)。
|
// session 级 delegate 的 didWriteData 全部被跳过,结果只有下载完成才补一次
|
||||||
let progressDelegate = DownloadProgressDelegate(onProgress: onProgress)
|
// 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()
|
let opQueue = OperationQueue()
|
||||||
opQueue.maxConcurrentOperationCount = 1
|
opQueue.maxConcurrentOperationCount = 1
|
||||||
let session = URLSession(
|
let session = URLSession(
|
||||||
@@ -73,17 +83,22 @@ public actor LobbyZipUpgrader {
|
|||||||
)
|
)
|
||||||
defer { session.invalidateAndCancel() }
|
defer { session.invalidateAndCancel() }
|
||||||
|
|
||||||
let downloadedURL: URL
|
|
||||||
let response: URLResponse
|
let response: URLResponse
|
||||||
do {
|
do {
|
||||||
(downloadedURL, response) = try await session.download(from: zipURL)
|
response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<URLResponse, any Error>) in
|
||||||
|
progressDelegate.setContinuation(cont)
|
||||||
|
session.downloadTask(with: zipURL).resume()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
try? FileManager.default.removeItem(at: savedURL)
|
||||||
throw UpgradeError.downloadFailed(error)
|
throw UpgradeError.downloadFailed(error)
|
||||||
}
|
}
|
||||||
if let http = response as? HTTPURLResponse,
|
if let http = response as? HTTPURLResponse,
|
||||||
!(200..<300).contains(http.statusCode) {
|
!(200..<300).contains(http.statusCode) {
|
||||||
|
try? FileManager.default.removeItem(at: savedURL)
|
||||||
throw UpgradeError.httpStatus(http.statusCode)
|
throw UpgradeError.httpStatus(http.statusCode)
|
||||||
}
|
}
|
||||||
|
let downloadedURL = savedURL
|
||||||
// 下载完成兜底报 1.0:部分 server 不发 Content-Length 或末尾片段晚到,
|
// 下载完成兜底报 1.0:部分 server 不发 Content-Length 或末尾片段晚到,
|
||||||
// 不显式补这一下,进度条可能停在 99% 直到解压结束才跳走
|
// 不显式补这一下,进度条可能停在 99% 直到解压结束才跳走
|
||||||
onProgress(1.0)
|
onProgress(1.0)
|
||||||
@@ -120,21 +135,34 @@ public actor LobbyZipUpgrader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URLSession 下载进度桥接:把 `didWriteData` 的 byte 计数换算成 0…1,回调上层。
|
/// URLSession 下载进度桥接:把 `didWriteData` 的 byte 计数换算成 0…1,回调上层;
|
||||||
|
/// 同时驱动下载完成的 CheckedContinuation。
|
||||||
///
|
///
|
||||||
/// 之所以放在文件内私有:仅 LobbyZipUpgrader 用,不构成对外 API。
|
/// 之所以放在文件内私有:仅 LobbyZipUpgrader 用,不构成对外 API。
|
||||||
/// `@unchecked Sendable`:URLSessionDownloadDelegate 协议本身不带 Sendable;
|
/// `@unchecked Sendable`:URLSessionDownloadDelegate 协议本身不带 Sendable;
|
||||||
/// 我们的状态仅是一个 `@Sendable` 闭包,无可变共享,标记安全。
|
/// 内部可变状态(continuation / moveError)跨线程(actor → opQueue),用 NSLock 保护。
|
||||||
private final class DownloadProgressDelegate: NSObject,
|
private final class DownloadProgressDelegate: NSObject,
|
||||||
URLSessionDownloadDelegate,
|
URLSessionDownloadDelegate,
|
||||||
@unchecked Sendable {
|
@unchecked Sendable {
|
||||||
private let onProgress: @Sendable (Double) -> Void
|
private let onProgress: @Sendable (Double) -> Void
|
||||||
|
private let moveDownloadedTo: URL
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var continuation: CheckedContinuation<URLResponse, any Error>?
|
||||||
|
private var moveError: (any Error)?
|
||||||
|
|
||||||
init(onProgress: @escaping @Sendable (Double) -> Void) {
|
init(onProgress: @escaping @Sendable (Double) -> Void, moveDownloadedTo: URL) {
|
||||||
self.onProgress = onProgress
|
self.onProgress = onProgress
|
||||||
|
self.moveDownloadedTo = moveDownloadedTo
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 由 actor 在启动 downloadTask 前调一次。必须在 `task.resume()` 之前调,
|
||||||
|
/// 否则 didCompleteWithError 可能先于 setContinuation 发生(极端情况下短下载)。
|
||||||
|
func setContinuation(_ c: CheckedContinuation<URLResponse, any Error>) {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
continuation = c
|
||||||
|
}
|
||||||
|
|
||||||
func urlSession(_ session: URLSession,
|
func urlSession(_ session: URLSession,
|
||||||
downloadTask: URLSessionDownloadTask,
|
downloadTask: URLSessionDownloadTask,
|
||||||
didWriteData bytesWritten: Int64,
|
didWriteData bytesWritten: Int64,
|
||||||
@@ -145,11 +173,39 @@ private final class DownloadProgressDelegate: NSObject,
|
|||||||
onProgress(min(max(p, 0), 1))
|
onProgress(min(max(p, 0), 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 必须实现的协议方法。async download API 内部已接管下载文件落地,
|
/// 必须在 delegate 方法返回前同步把临时文件 move 到 `moveDownloadedTo`:
|
||||||
/// 这里无需把文件搬到任何位置。
|
/// 系统会在本方法返回后立即清理 `location` 处的临时文件。
|
||||||
|
/// move 出错记录到 moveError,由 didCompleteWithError 统一抛回 continuation。
|
||||||
func urlSession(_ session: URLSession,
|
func urlSession(_ session: URLSession,
|
||||||
downloadTask: URLSessionDownloadTask,
|
downloadTask: URLSessionDownloadTask,
|
||||||
didFinishDownloadingTo location: URL) {
|
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"]
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,10 +141,16 @@ public actor SubGameDownloader {
|
|||||||
throw DownloadError.badDownloadURL(zipURLString)
|
throw DownloadError.badDownloadURL(zipURLString)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 下载到 tmp。delegate 必须在 URLSession init 时绑定,否则
|
// 1. 下载到 tmp。**必须用手动 downloadTask + 续作**,不能用 async 版
|
||||||
// URLSessionDownloadDelegate.didWriteData 不会被调用(async API
|
// `await session.download(from:)`:后者内部走 completion-handler 路径,
|
||||||
// `download(from:delegate:)` 的 ad-hoc delegate 只收 task-level 回调)。
|
// session 级 delegate 的 didWriteData 全部被跳过,结果只有下载完成才补一次
|
||||||
let progressDelegate = DownloadProgressDelegate(onProgress: onProgress)
|
// 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()
|
let opQueue = OperationQueue()
|
||||||
opQueue.maxConcurrentOperationCount = 1
|
opQueue.maxConcurrentOperationCount = 1
|
||||||
let session = URLSession(
|
let session = URLSession(
|
||||||
@@ -154,17 +160,22 @@ public actor SubGameDownloader {
|
|||||||
)
|
)
|
||||||
defer { session.invalidateAndCancel() }
|
defer { session.invalidateAndCancel() }
|
||||||
|
|
||||||
let downloadedURL: URL
|
|
||||||
let response: URLResponse
|
let response: URLResponse
|
||||||
do {
|
do {
|
||||||
(downloadedURL, response) = try await session.download(from: zipURL)
|
response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<URLResponse, any Error>) in
|
||||||
|
progressDelegate.setContinuation(cont)
|
||||||
|
session.downloadTask(with: zipURL).resume()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
try? FileManager.default.removeItem(at: savedURL)
|
||||||
throw DownloadError.downloadFailed(error)
|
throw DownloadError.downloadFailed(error)
|
||||||
}
|
}
|
||||||
if let http = response as? HTTPURLResponse,
|
if let http = response as? HTTPURLResponse,
|
||||||
!(200..<300).contains(http.statusCode) {
|
!(200..<300).contains(http.statusCode) {
|
||||||
|
try? FileManager.default.removeItem(at: savedURL)
|
||||||
throw DownloadError.httpStatus(http.statusCode)
|
throw DownloadError.httpStatus(http.statusCode)
|
||||||
}
|
}
|
||||||
|
let downloadedURL = savedURL
|
||||||
onProgress(1.0)
|
onProgress(1.0)
|
||||||
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
@@ -201,19 +212,32 @@ public actor SubGameDownloader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URLSession 下载进度桥接。
|
/// URLSession 下载进度桥接:把 didWriteData 的 byte 计数换算成 0…1,回调上层;
|
||||||
|
/// 同时驱动下载完成的 CheckedContinuation。
|
||||||
///
|
///
|
||||||
/// 与 LobbyZipUpgrader 同款实现就地复制(两处使用,不抽公共 — 三处出现时再合并)。
|
/// 与 LobbyZipUpgrader 同款实现就地复制(两处使用,不抽公共 — 三处出现时再合并)。
|
||||||
private final class DownloadProgressDelegate: NSObject,
|
private final class DownloadProgressDelegate: NSObject,
|
||||||
URLSessionDownloadDelegate,
|
URLSessionDownloadDelegate,
|
||||||
@unchecked Sendable {
|
@unchecked Sendable {
|
||||||
private let onProgress: @Sendable (Double) -> Void
|
private let onProgress: @Sendable (Double) -> Void
|
||||||
|
private let moveDownloadedTo: URL
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var continuation: CheckedContinuation<URLResponse, any Error>?
|
||||||
|
private var moveError: (any Error)?
|
||||||
|
|
||||||
init(onProgress: @escaping @Sendable (Double) -> Void) {
|
init(onProgress: @escaping @Sendable (Double) -> Void, moveDownloadedTo: URL) {
|
||||||
self.onProgress = onProgress
|
self.onProgress = onProgress
|
||||||
|
self.moveDownloadedTo = moveDownloadedTo
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 由 actor 在启动 downloadTask 前调一次。必须在 `task.resume()` 之前调,
|
||||||
|
/// 否则 didCompleteWithError 可能先于 setContinuation 发生(极端情况下短下载)。
|
||||||
|
func setContinuation(_ c: CheckedContinuation<URLResponse, any Error>) {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
continuation = c
|
||||||
|
}
|
||||||
|
|
||||||
func urlSession(_ session: URLSession,
|
func urlSession(_ session: URLSession,
|
||||||
downloadTask: URLSessionDownloadTask,
|
downloadTask: URLSessionDownloadTask,
|
||||||
didWriteData bytesWritten: Int64,
|
didWriteData bytesWritten: Int64,
|
||||||
@@ -224,9 +248,38 @@ private final class DownloadProgressDelegate: NSObject,
|
|||||||
onProgress(min(max(p, 0), 1))
|
onProgress(min(max(p, 0), 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 必须在 delegate 方法返回前同步把临时文件 move 到 `moveDownloadedTo`:
|
||||||
|
/// 系统会在本方法返回后立即清理 `location` 处的临时文件。
|
||||||
func urlSession(_ session: URLSession,
|
func urlSession(_ session: URLSession,
|
||||||
downloadTask: URLSessionDownloadTask,
|
downloadTask: URLSessionDownloadTask,
|
||||||
didFinishDownloadingTo location: URL) {
|
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"]
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user