URLSession downloadTask much slower than internet connection

2.3k views Asked by At

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.

1

There are 1 answers

0
luis On BEST ANSWER

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()
  }
}