修复下载进度瞬间跳 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:
joywayer
2026-06-24 22:40:59 +08:00
co-authored by Claude Opus 4.7
parent 800497fd18
commit 522087f398
2 changed files with 130 additions and 21 deletions
@@ -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"]
))
}
} }
} }