The Util Designer
在SwiftUI 如何在URLSessionDownloadTask中實現Pause、Resume、Cancel等功能
xcode 13.4.1, swift 5.5, iOS 15.4
2022-09-12
通常在下載一些大檔案時,需要的時間會很長,或網絡不好時先暫停下載,能網絡好了,再繼續進行下載,避免每次都要重新下載,這次我們來講講在如何在URLSessionDownloadTask中實現Pause、Resume、Cancel等功能。
1. 首先講講下載的幾個狀態的變化,首先在還沒有開始下載之前的等待(waiting),在使用者按完下載(Download),狀態變成下載中(downloading),在下載中可以按下暫定(Pause),狀態就會變成暫定中(Pause),若按下取消鍵(Cancel),下載狀態又回到等待狀態(waiting),在暫定狀態中,使用者可以有取消(Cancel)和繼續下載(Resume),狀態流程圖如下:
2. 以下分別先講講如何實現URLSessionDownloadTask中實現Download、Pause、Resume、Cancel三種功能:
3. 實現URLSessionDownloadTask的下載Download功能:
    func fetchFileAtUrl(_ url : URL) {
        self.fileUrl = url
        let configuration = URLSessionConfiguration.default
        self.urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
        guard let urlSession = self.urlSession else {
            return
        }
        self.downloadTask = urlSession.downloadTask(with: fileUrl!)
        self.downloadTask?.resume()
        DispatchQueue.main.async {
            self.state = .downloading
            self.downloadPercent = 0.0
        }
    }
4. 實現URLSessionDownloadTask的取消Cancel功能:
    func cancel() {
        state = .waiting
        self.downloadTask?.cancel()
        DispatchQueue.main.async {
            self.downloadPercent = 0.0
        }
    }
5. 實現URLSessionDownloadTask的暫停Pause功能,在暫停時,需要將已下載的數據保存起來,等Resume時再傳給下載任務:
    func pause() {
        downloadTask?.cancel(byProducingResumeData: { data in
            DispatchQueue.main.async {
                self.resumeData = data
                self.state = .paused
            }
        })
    }
6. 實現URLSessionDownloadTask的繼續Resume功能,繼續下載時,要把之前已下載的數據傳回給下載任務:
    func resume() {
        guard let resumeData = resumeData else {
            return
        }
        guard let urlSession = self.urlSession else {
            return
        }
        downloadTask = urlSession.downloadTask(withResumeData: resumeData)
        downloadTask?.resume()
        DispatchQueue.main.async {
            self.state = .downloading
        }
    }
7. 為了演示,下載完就可以播放這首音樂(關於如何播放音樂可以參考 SwiftUI 通過UIViewControllerRepresentable protocol來封裝AVPlayerViewController),完整實現如下:
import SwiftUI

class Downloader : NSObject, ObservableObject, URLSessionDownloadDelegate {
    
    var fileUrl : URL?
    var downloadTask : URLSessionDownloadTask?
    var urlSession : URLSession?
    var resumeData : Data?
    
    @Published var fileLocation : URL?
    @Published var downloadPercent : Float = 0
    @Published var state : DownloadState = .waiting
    
    enum DownloadState {
        case waiting
        case downloading
        case paused
        case finished
    }
    
    func fetchFileAtUrl(_ url : URL) {
        self.fileUrl = url
        let configuration = URLSessionConfiguration.default
        self.urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
        guard let urlSession = self.urlSession else {
            return
        }
        self.downloadTask = urlSession.downloadTask(with: fileUrl!)
        self.downloadTask?.resume()
        DispatchQueue.main.async {
            self.state = .downloading
            self.downloadPercent = 0.0
        }
    }
    
    func cancel() {
        state = .waiting
        self.downloadTask?.cancel()
        DispatchQueue.main.async {
            self.downloadPercent = 0.0
        }
    }
    
    func pause() {
        downloadTask?.cancel(byProducingResumeData: { data in
            DispatchQueue.main.async {
                self.resumeData = data
                self.state = .paused
            }
        })
    }
    
    func resume() {
        guard let resumeData = resumeData else {
            return
        }
        guard let urlSession = self.urlSession else {
            return
        }
        downloadTask = urlSession.downloadTask(withResumeData: resumeData)
        downloadTask?.resume()
        DispatchQueue.main.async {
            self.state = .downloading
        }
    }
    
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        let fileManager = FileManager.default
        guard let documentPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
            fatalError()
        }
        guard let lastPathComponent = self.fileUrl?.lastPathComponent else {
            fatalError()
        }
        let fileLocation = documentPath.appendingPathComponent(lastPathComponent)
        
        do {
            if fileManager.fileExists(atPath: fileLocation.path) {
                try fileManager.removeItem(at: fileLocation)
            }
            try fileManager.copyItem(at: location, to: fileLocation)
            DispatchQueue.main.async {
                self.state = .finished
                self.downloadPercent = 100.0
                self.fileLocation = fileLocation
            }
        } catch {
            print(error)
        }
    }
    
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        DispatchQueue.main.async {
            self.downloadPercent = Float(totalBytesWritten*100)/Float(totalBytesExpectedToWrite)
        }
    }
}

struct DownloadExample: View {
    
    @State var playFlag = false
    @ObservedObject var downloader = Downloader()
    
    var body: some View {
        VStack {
            if downloader.state == .waiting {
                Button {
                    guard let fileUrl = URL(string : "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview125/v4/0c/94/76/0c9476bc-7ef0-c690-de28-c441a35c11e5/mzaf_11460274548565212191.plus.aac.p.m4a") else {
                        return
                    }
                    downloader.fetchFileAtUrl(fileUrl)
                } label: {
                    Text("Download")
                }
            } else if downloader.state == .downloading {
                Button {
                    downloader.cancel()
                } label: {
                    Text("Cancel")
                }
                Button {
                    downloader.pause()
                } label: {
                    Text("Pause")
                }
                Text("\(downloader.downloadPercent)% downloaded")
            } else if downloader.state == .paused {
                Button {
                    downloader.cancel()
                } label: {
                    Text("Cancel")
                }
                Button {
                    downloader.resume()
                } label: {
                    Text("Resume")
                }
                Text("\(downloader.downloadPercent)% downloaded")
            } else if downloader.state == .finished {
                Button {
                    guard let fileUrl = URL(string : "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview125/v4/0c/94/76/0c9476bc-7ef0-c690-de28-c441a35c11e5/mzaf_11460274548565212191.plus.aac.p.m4a") else {
                        return
                    }
                    downloader.fetchFileAtUrl(fileUrl)
                } label: {
                    Text("Redownload")
                }
                
                Button {
                    playFlag.toggle()
                } label: {
                    Text("Play")
                }
                Text("Finished")
            }

        }.sheet(isPresented: $playFlag) {
            AudioPlayerView(url : downloader.fileLocation!)
        }
    }
}