Search code examples
iosdownloadnsurlsessionnsurlsessiondownloadtask

URLSession downloadTask much slower than internet connection


I'm using URLSession and downloadTask to download a file in the foreground. The download is much slower than expected. Other posts I found address the issue for background tasks.

let config = URLSessionConfiguration.default
config.httpMaximumConnectionsPerHost = 20
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)

let request = URLRequest(url: url)
let completion: ((URL?, Error?) -> Void) = { (tempLocalUrl, error) in
  print("Download over")
}
value.completion = completion
value.task = self.session.downloadTask(with: request)

I'm observing a network usage of ~150kb/s while a speed test on my device reports a connection of 5MB/s

=== Edit

I can confirm that coding a multipart download (which is a bit of a pain to do) speeds up things by a lot.


Solution

  • If that helps anyone, here is my code to speed up the download. It splits the file download in a number of file parts downloads, which uses the available bandwidth more efficiently. It still feels wrong to have to do that...

    The final usage is like:

    // task.pause is not implemented yet
    let task = FileDownloadManager.shared.download(from:someUrl)
    task.delegate = self
    task.resume()
    

    and here's the code:

    /// Holds a weak reverence
    class Weak<T: AnyObject> {
      weak var value : T?
      init (value: T) {
        self.value = value
      }
    }
    
    enum DownloadError: Error {
      case missingData
    }
    
    /// Represents the download of one part of the file
    fileprivate class DownloadTask {
      /// The position (included) of the first byte
      let startOffset: Int64
      /// The position (not included) of the last byte
      let endOffset: Int64
      /// The byte length of the part
      var size: Int64 { return endOffset - startOffset }
      /// The number of bytes currently written
      var bytesWritten: Int64 = 0
      /// The URL task corresponding to the download
      let request: URLSessionDownloadTask
      /// The disk location of the saved file
      var didWriteTo: URL?
    
      init(for url: URL, from start: Int64, to end: Int64, in session: URLSession) {
        startOffset = start
        endOffset = end
    
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.allHTTPHeaderFields?["Range"] = "bytes=\(start)-\(end - 1)"
    
        self.request = session.downloadTask(with: request)
      }
    }
    
    /// Represents the download of a file (that is done in multi parts)
    class MultiPartsDownloadTask {
    
      weak var delegate: MultiPartDownloadTaskDelegate?
      /// the current progress, from 0 to 1
      var progress: CGFloat {
        var total: Int64 = 0
        var written: Int64 = 0
        parts.forEach({ part in
          total += part.size
          written += part.bytesWritten
        })
        guard total > 0 else { return 0 }
        return CGFloat(written) / CGFloat(total)
      }
    
      fileprivate var parts = [DownloadTask]()
      fileprivate var contentLength: Int64?
      fileprivate let url: URL
      private var session: URLSession
      private var isStoped = false
      private var isResumed = false
      /// When the download started
      private var startedAt: Date
      /// An estimate on how long left before the download is over
      var remainingTimeEstimate: CGFloat {
        let progress = self.progress
        guard progress > 0 else { return CGFloat.greatestFiniteMagnitude }
        return CGFloat(Date().timeIntervalSince(startedAt)) / progress * (1 - progress)
      }
    
      fileprivate init(from url: URL, in session: URLSession) {
        self.url = url
        self.session = session
        startedAt = Date()
    
        getRemoteResourceSize().then { [weak self] size -> Void in
          guard let wself = self else { return }
          wself.contentLength = size
          wself.createDownloadParts()
    
          if wself.isResumed {
            wself.resume()
          }
        }.catch { [weak self] error in
          guard let wself = self else { return }
          wself.isStoped = true
        }
      }
    
      /// Start the download
      func resume() {
        guard !isStoped else { return }
        startedAt = Date()
        isResumed = true
        parts.forEach({ $0.request.resume() })
      }
    
      /// Cancels the download
      func cancel() {
        guard !isStoped else { return }
        parts.forEach({ $0.request.cancel() })
      }
    
      /// Fetch the file size of a remote resource
      private func getRemoteResourceSize(completion: @escaping (Int64?, Error?) -> Void) {
        var headRequest = URLRequest(url: url)
        headRequest.httpMethod = "HEAD"
        session.dataTask(with: headRequest, completionHandler: { (data, response, error) in
          if let error = error {
            completion(nil, error)
            return
          }
          guard let expectedContentLength = response?.expectedContentLength else {
            completion(nil, FileCacheError.sizeNotAvailableForRemoteResource)
            return
          }
          completion(expectedContentLength, nil)
        }).resume()
      }
    
      /// Split the download request into multiple request to use more bandwidth
      private func createDownloadParts() {
        guard let size = contentLength else { return }
    
        let numberOfRequests = 20
        for i in 0..<numberOfRequests {
          let start = Int64(ceil(CGFloat(Int64(i) * size) / CGFloat(numberOfRequests)))
          let end = Int64(ceil(CGFloat(Int64(i + 1) * size) / CGFloat(numberOfRequests)))
          parts.append(DownloadTask(for: url, from: start, to: end, in: session))
        }
      }
    
      fileprivate func didFail(_ error: Error) {
        cancel()
        delegate?.didFail(self, error: error)
      }
    
      fileprivate func didFinishOnePart() {
        if parts.filter({ $0.didWriteTo != nil }).count == parts.count {
          mergeFiles()
        }
      }
    
      /// Put together the download files
      private func mergeFiles() {
        let ext = self.url.pathExtension
        let destination = Constants.tempDirectory
          .appendingPathComponent("\(String.random(ofLength: 5))")
          .appendingPathExtension(ext)
    
        do {
          let partLocations = parts.flatMap({ $0.didWriteTo })
          try FileManager.default.merge(files: partLocations, to: destination)
          delegate?.didFinish(self, didFinishDownloadingTo: destination)
          for partLocation in partLocations {
            do {
              try FileManager.default.removeItem(at: partLocation)
            } catch {
              report(error)
            }
          }
        } catch {
          delegate?.didFail(self, error: error)
        }
      }
    
      deinit {
        FileDownloadManager.shared.tasks = FileDownloadManager.shared.tasks.filter({
          $0.value !== self
        })
      }
    }
    
    protocol MultiPartDownloadTaskDelegate: class {
      /// Called when the download progress changed
      func didProgress(
        _ downloadTask: MultiPartsDownloadTask
      )
    
      /// Called when the download finished succesfully
      func didFinish(
        _ downloadTask: MultiPartsDownloadTask,
        didFinishDownloadingTo location: URL
      )
    
      /// Called when the download failed
      func didFail(_ downloadTask: MultiPartsDownloadTask, error: Error)
    }
    
    /// Manage files downloads
    class FileDownloadManager: NSObject {
      static let shared = FileDownloadManager()
      private var session: URLSession!
      fileprivate var tasks = [Weak<MultiPartsDownloadTask>]()
    
      private override init() {
        super.init()
        let config = URLSessionConfiguration.default
        config.httpMaximumConnectionsPerHost = 50
        session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
      }
    
      /// Create a task to download a file
      func download(from url: URL) -> MultiPartsDownloadTask {
        let task = MultiPartsDownloadTask(from: url, in: session)
        tasks.append(Weak(value: task))
        return task
      }
    
      /// Returns the download task that correspond to the URL task
      fileprivate func match(request: URLSessionTask) -> (MultiPartsDownloadTask, DownloadTask)? {
        for wtask in tasks {
          if let task = wtask.value {
            for part in task.parts {
              if part.request == request {
                return (task, part)
              }
            }
          }
        }
        return nil
      }
    }
    
    extension FileDownloadManager: URLSessionDownloadDelegate {
      public func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didWriteData bytesWritten: Int64,
        totalBytesWritten: Int64,
        totalBytesExpectedToWrite: Int64
      ) {
        guard let x = match(request: downloadTask) else { return }
        let multiPart = x.0
        let part = x.1
    
        part.bytesWritten = totalBytesWritten
        multiPart.delegate?.didProgress(multiPart)
      }
    
      func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didFinishDownloadingTo location: URL
        ) {
        guard let x = match(request: downloadTask) else { return }
        let multiPart = x.0
        let part = x.1
    
        let ext = multiPart.url.pathExtension
        let destination = Constants.tempDirectory
          .appendingPathComponent("\(String.random(ofLength: 5))")
          .appendingPathExtension(ext)
    
        do {
          try FileManager.default.moveItem(at: location, to: destination)
        } catch {
          multiPart.didFail(error)
          return
        }
    
        part.didWriteTo = destination
        multiPart.didFinishOnePart()
      }
    
      func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        guard let error = error, let multipart = match(request: task)?.0 else { return }
        multipart.didFail(error)
      }
    }
    
    extension FileManager {
      /// Merge the files into one (without deleting the files)
      func merge(files: [URL], to destination: URL, chunkSize: Int = 1000000) throws {
        FileManager.default.createFile(atPath: destination.path, contents: nil, attributes: nil)
        let writer = try FileHandle(forWritingTo: destination)
        try files.forEach({ partLocation in
          let reader = try FileHandle(forReadingFrom: partLocation)
          var data = reader.readData(ofLength: chunkSize)
          while data.count > 0 {
            writer.write(data)
            data = reader.readData(ofLength: chunkSize)
          }
          reader.closeFile()
        })
        writer.closeFile()
      }
    }