首页 > 解决方案 > Background mp3 download on iOS 14 in a swift ui app

问题描述

I'm creating a swift UI radio streaming app that has a library of past episodes that can be downloaded. I would like users to be able to begin a download and then lock the screen. Currently this suspends the download(s) in progress. My download function :

func downloadFile(withUrl url: URL, andFilePath filePath: URL) {
        URLSession.shared
            .downloadTaskPublisher(for: url)
            .retry(4)
            .map(\.0)
            .receive(on: RunLoop.main)
            .sink(receiveCompletion: { [self] _ in
                      downloading = false
                      downloaded = true
                  },
                  receiveValue: { data in
                      do {
                          self.downloading = true
                          try FileManager.default.moveItem(atPath: data.path,
                                                           toPath: filePath.path)
                      } catch {
                          self.downloaded = false
                          print("Error: \(error.localizedDescription)")
                          print("an error happened while downloading or saving the file")
                      }
                  })
            .store(in: &networkSubscription)
    }

Where .downloadTaskPublisher(for: url) is :

import Combine
import Foundation

public extension URLSession {
    /// Returns a publisher that wraps a URL session download task for a given
    /// URL.
    ///
    /// - Parameter url: The URL for which to create a download task.
    /// - Returns: A publisher that wraps a download task for the URL.
    func downloadTaskPublisher(for url: URL) -> DownloadTaskPublisher {
        DownloadTaskPublisher(session: self, request: URLRequest(url: url))
    }

    /// Returns a publisher that wraps a URL session download task for a given
    /// URL request.
    ///
    /// - Parameter request: The URL request for which to create a download task.
    /// - Returns: A publisher that wraps a download task for the URL request.
    func downloadTaskPublisher(for request: URLRequest) -> DownloadTaskPublisher {
        DownloadTaskPublisher(session: self, request: request)
    }
}

public struct DownloadTaskPublisher {
    fileprivate let session: URLSession
    fileprivate let request: URLRequest
}

extension DownloadTaskPublisher: Publisher {
    public typealias Output = (URL, URLResponse)
    public typealias Failure = Error

    public func receive<Subscriber>(subscriber: Subscriber)
        where
        Subscriber: Combine.Subscriber,
        Subscriber.Failure == Failure,
        Subscriber.Input == Output
    {
        let subscription = Subscription(subscriber: subscriber, session: session, request: request)
        subscriber.receive(subscription: subscription)
    }
}

private extension DownloadTaskPublisher {
    final class Subscription {
        private let downloadTask: URLSessionDownloadTask

        init<Subscriber>(subscriber: Subscriber, session: URLSession, request: URLRequest)
            where
            Subscriber: Combine.Subscriber,
            Subscriber.Input == Output,
            Subscriber.Failure == Failure
        {
            downloadTask = session.downloadTask(with: request, completionHandler: { url, response, error in

                guard let url = url, let response = response else {
                    subscriber.receive(completion: .failure(error!))
                    return
                }

                _ = subscriber.receive((url, response))
                subscriber.receive(completion: .finished)
            })
        }
    }
}

extension DownloadTaskPublisher.Subscription: Subscription {
    fileprivate func request(_: Subscribers.Demand) {
        downloadTask.resume()
    }

    fileprivate func cancel() {
        downloadTask.cancel()
    }
}

This download function writes the episode to disk but is canceled when the app is suspended.

I would like to write something like

func downloadFile(withUrl url: URL, andFilePath filePath: URL) {
        URLSession.init(configuration: URLSessionConfiguration.background(withIdentifier: "background.download.session"))
            .downloadTaskPublisher(for: url)
            .retry(4)
            .map(\.0)
            .receive(on: RunLoop.main)
            .sink(receiveCompletion: { [self] _ in
                      downloading = false
                      downloaded = true
                  },
                  receiveValue: { data in
                      do {
                          self.downloading = true
                          try FileManager.default.moveItem(atPath: data.path,
                                                           toPath: filePath.path)
                      } catch {
                          self.downloaded = false
                          print("Error: \(error.localizedDescription)")
                          print("an error happened while downloading or saving the file")
                      }
                  })
            .store(in: &networkSubscription)
    }

However, this throws a runtime exception: libc++abi.dylib: terminating with uncaught exception of type NSException *** Terminating app due to uncaught exception 'NSGenericException', reason: 'Completion handler blocks are not supported in background sessions. Use a delegate instead.' terminating with uncaught exception of type NSException

I would like to download files in the background using combine idioms and avoid have to attach an AppDelegate to my @main struct: App.

标签: iosswiftswiftuicombineurlsession

解决方案


As I understand the error throws by the compiler, I pretty sure you need to activate the Background Modes like this : enter image description here

I did found a detailed tutorial by raywenderlich.com that follows almost exactly your use case. Maybe it can help you to dig even deeper on this feature


推荐阅读