修复下载进度瞬间跳 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
}
// 1. tmpURLSession delegate 01
// 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<URLResponse, any Error>) 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 01
/// URLSession `didWriteData` byte 01
/// 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<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.moveDownloadedTo = moveDownloadedTo
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,
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"]
))
}
}
}
@@ -141,10 +141,16 @@ public actor SubGameDownloader {
throw DownloadError.badDownloadURL(zipURLString)
}
// 1. tmpdelegate 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<URLResponse, any Error>) 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 01
/// 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<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.moveDownloadedTo = moveDownloadedTo
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,
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"]
))
}
}
}