How to get the download progress with the new try await URLSession.shared.download(...)

Viewed 2041

Apple just introduced async/await and a bunch of Foundation functions that use them. I'm downloading a file using the new async/await pattern, but i cannot seem to get the download progress.

(downloadedURL, response) = try await URLSession.shared.download(for: dataRequest, delegate: self) as (URL, URLResponse)

As you can see, there is a delegate, and i have tried conforming my class to the URLSessionDownloadDelegate and implementing the urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:) function, but it never gets called.

I also tried creating a new URLSession and setting it's delegate to the same class, in hopes that URLSession would call this function instead, but it never gets called and the file still happily downloads. But i need the progress, how do i get it please?

2 Answers

You can use URLSession.shared.bytes(from: imageURL) and for await in to loop over.

URLSession.shared.bytes returns (URLSession.AsyncBytes, URLResponse). AsyncBytes is async a sequence that can be looped over using for await in.

func fetchImageInProgress(imageURL: URL) async -> UIImage? {
    do {
        let (asyncBytes, urlResponse) = try await URLSession.shared.bytes(from: imageURL)
        let length = (urlResponse.expectedContentLength)
        var data = Data()
        data.reserveCapacity(Int(length))

        for try await byte in asyncBytes {
            data.append(byte)
            let progress = Double(data.count) / Double(length)
            print(progress)
        }
        return UIImage(data: data)
    } catch {
        return nil
    }
}

It shows like below progressive fetching image.

enter image description here

A few observations:

  • The delegate in download(for:delegate:) is a URLSessionTaskDelegate, not a URLSessionDownloadDelegate, so there are not any assurances that download-specific delegate methods would be called.

  • FWIW, in Use async/await with URLSession, they illustrate the delegate as being used for authentication challenges, not for download progress.

  • With traditional URLSessionTask methods, the download progress is not called if you call downloadTask(with:completionHandler:), but rather only if you call the rendition without the completion handler, downloadTask(with:). As Downloading Files from Websites says:

    If you want to receive progress updates as the download proceeds, you must use a delegate.

    If the new download(for:delegate:) uses downloadTask(with:completionHandler:) behind the scenes, one can easily imagine why one might not see the download progress being reported.

But all of that is academic. Bottom line, you do not see progress reported with download(for:delegate:) or download(from:delegate:). So, if you want to see progress as the download proceeds, you have a few options:

  1. Implement bytes(from:) as suggested by Won and update your progress as bytes come in.

    As an aside, I might advise streaming it to a file (e.g., an OutputStream) rather than appending it to a Data, to mirror the memory characteristics of a download task. But, his answer illustrates the basic idea.

  2. Fall back to the delegate-based downloadTask(with:) solution.


If you want to write your own version that reports progress, you could do something like:

extension URLSession {
    func download(from url: URL, delegate: URLSessionTaskDelegate? = nil, progress parent: Progress) async throws -> (URL, URLResponse) {
        try await download(for: URLRequest(url: url), progress: parent)
    }

    func download(for request: URLRequest, delegate: URLSessionTaskDelegate? = nil, progress parent: Progress) async throws -> (URL, URLResponse) {
        let progress = Progress()
        parent.addChild(progress, withPendingUnitCount: 1)

        let bufferSize = 65_536
        let estimatedSize: Int64 = 1_000_000

        let (asyncBytes, response) = try await bytes(for: request, delegate: delegate)
        let expectedLength = response.expectedContentLength                             // note, if server cannot provide expectedContentLength, this will be -1
        progress.totalUnitCount = expectedLength > 0 ? expectedLength : estimatedSize

        let fileURL = URL(fileURLWithPath: NSTemporaryDirectory())
            .appendingPathComponent(UUID().uuidString)
        guard let output = OutputStream(url: fileURL, append: false) else {
            throw URLError(.cannotOpenFile)
        }
        output.open()

        var buffer = Data()
        if expectedLength > 0 {
            buffer.reserveCapacity(min(bufferSize, Int(expectedLength)))
        } else {
            buffer.reserveCapacity(bufferSize)
        }

        var count: Int64 = 0
        for try await byte in asyncBytes {
            try Task.checkCancellation()

            count += 1
            buffer.append(byte)

            if buffer.count >= bufferSize {
                try output.write(buffer)
                buffer.removeAll(keepingCapacity: true)

                if expectedLength < 0 || count > expectedLength {
                    progress.totalUnitCount = count + estimatedSize
                }
                progress.completedUnitCount = count
            }
        }

        if !buffer.isEmpty {
            try output.write(buffer)
        }

        output.close()

        progress.totalUnitCount = count
        progress.completedUnitCount = count

        return (fileURL, response)
    }
}

With:

extension OutputStream {

    /// Write `Data` to `OutputStream`
    ///
    /// - parameter data:                  The `Data` to write.

    func write(_ data: Data) throws {
        try data.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) throws in
            guard var pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
                throw OutputStreamError.bufferFailure
            }

            var bytesRemaining = buffer.count

            while bytesRemaining > 0 {
                let bytesWritten = write(pointer, maxLength: bytesRemaining)
                if bytesWritten < 0 {
                    throw OutputStreamError.writeFailure
                }

                bytesRemaining -= bytesWritten
                pointer += bytesWritten
            }
        }
    }
}

Note:

  1. This uses a small buffer to avoid trying to load the entire asset into memory at one time. It writes the results to a file as it goes along.

    This is important if the assets might be large (which, generally, is why we use download rather than data).

  2. Note that expectedContentLength can sometimes be -1, in which case we do not know the size of the file being downloaded. The above handles that scenario.

    The logic for guestimating the progress when the size of the asset is unknown is a matter of personal preference. Above I use an estimated asset size and adjust the progress. It won't be terribly accurate but at least it reflects some progress as the download proceeds.

  3. I include a try Task.checkCancellation() so that the download task is cancellable.

  4. I use Progress to report the progress back to the parent. You can hook in and display this however you want, but is especially simple if you are using a UIProgressView.

Anyway, you can then do things like:

func startDownloads(_ urls: [URL]) async throws {
    let cachesFolder = try! FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)

    let progress = Progress()
    progressView.observedProgress = progress    // assuming you are just updating a `UIProgressView` with the overall progress of all the downloads

    try await withThrowingTaskGroup(of: Void.self) { group in
        progress.totalUnitCount = Int64(urls.count)
        for url in urls {
            group.addTask {
                let destination = cachesFolder.appendingPathComponent(url.lastPathComponent)  // obviously, put the resulting file wherever you want
                let (url, _) = try await URLSession.shared.download(from: url, progress: progress)
                try? FileManager.default.removeItem(at: destination)
                try FileManager.default.moveItem(at: url, to: destination)
            }
        }

        try await group.waitForAll()
    }
}
Related