The Util Designer
SwiftUI 使用URLSession的URLSessionDownloadTask下載和播放音樂
xcode 13.4.1, swift 5.5, iOS 15.4
2022-09-09
現在絕大部分App都不會獨立的,一定會與後台或其他的服務器互相溝通,最簡單是從服務器獲取資料然後顯示出來。在SwiftUI 使用URLSession的URLSessionDataTask下載JSON Data使用URLSessionDataTask演示了下載簡單的JSON資料,這次就使用URLSessionDownloadTask來下載一些大的檔案,我們會試試下載itune的試聽音樂檔案並進行播放。
1. 在itune上選一首自己喜歡的preview的歌,我這里選了這首 Light Year的歌:
import SwiftUI

struct SongDownloaderExample: View {
    
    var body: some View {
        VStack {
            Button {
                guard let songUrl = 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
                }
            } label: {
                Text("Download")
            }

        }
    }
}
2. 創建一個SongDownloader來接受一個url然後使用URLSessionDataTask來進行下載:
import SwiftUI

class SongDownloader : ObservableObject {
    
    func fetchSongAtUrl(_ item : URL) {
        let configuration = URLSessionConfiguration.default
        let urlSession = URLSession(configuration: configuration)
        let downloadTask = urlSession.downloadTask(with: item)
        downloadTask.resume()
    }
}

struct SongDownloaderExample: View {
    
    @ObservedObject var songDownloader = SongDownloader()
    
    var body: some View {
        VStack {
            Button {
                guard let songUrl = 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
                }
                songDownloader.fetchSongAtUrl(songUrl)
            } label: {
                Text("Download")
            }

        }
    }
}
以上實作按了Download就會執行下載程式,為了讓URLSessionDataTask完成下載後通知我們,需要在創建URLSession時,傳入一個URLSessionDownloadDelegate,而在下載完成後,URLSessionDownloadDelegate的urlSession將會被執行,這里我們就要機會做一些下載完成後的工作:
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL)
3. 在下載完成後,我們把下載的臨時檔案搬到App的sandbox的目錄中,並把保存的檔案位置放到變量songFileLocation中,而這個變量使用了Published,當發生改生時就會通知View進行重畫,就會在Text中顯示其檔案位置:
import SwiftUI

class SongDownloader : NSObject, ObservableObject,  URLSessionDownloadDelegate {
    
    var songUrl : URL?
    @Published var songFileLocation : URL?
    
    func fetchSongAtUrl(_ songUrl : URL) {
        self.songUrl = songUrl
        let configuration = URLSessionConfiguration.default
        let urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
        let downloadTask = urlSession.downloadTask(with: songUrl)
        downloadTask.resume()
    }
    
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        let fileManager = FileManager.default
        guard let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
            fatalError()
        }
        guard let lastPathComponent = self.songUrl?.lastPathComponent else {
            fatalError()
        }
        let songFileLocation = documentsPath.appendingPathComponent(lastPathComponent)

        do {
            if fileManager.fileExists(atPath: songFileLocation.path) {
                try fileManager.removeItem(at: songFileLocation)
            }
            try fileManager.copyItem(at: location, to: songFileLocation)
            DispatchQueue.main.async {
                self.songFileLocation = songFileLocation
            }
        } catch {
            print(error)
        }
    }

}

struct SongDownloaderExample: View {
    
    @ObservedObject var songDownloader = SongDownloader()
    
    var body: some View {
        VStack {
            Button {
                guard let songUrl = 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
                }
                songDownloader.fetchSongAtUrl(songUrl)
            } label: {
                Text("Download")
            }
            
            if songDownloader.songFileLocation != nil {
                Text("\(songDownloader.songFileLocation!)")
            }
        }
    }
}
4. 然後使用AVPlayerViewController來播放音樂,關於如何用UIViewControllerRepresentable來把UIKit的元件封裝成SwiftUI的元件,請參考 SwiftUI 通過UIViewControllerRepresentable protocol來封裝AVPlayerViewController,實作如下:
import SwiftUI
import AVKit

struct AudioPlayerView : UIViewControllerRepresentable {
    
    let url : URL
    
    func makeUIViewController(context: Context) -> AVPlayerViewController {
        let player = AVPlayer(url: url)
        let viewController = AVPlayerViewController()
        viewController.player = player
        viewController.entersFullScreenWhenPlaybackBegins = true
        player.play()
        return viewController
    }
    
    func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
        
    }
}
5. 當songFileLocation變化時,通過onChange 來改變playMusic,當playMusic變成true是,就會拉起AudioPlayerView進行播放:
import SwiftUI

class SongDownloader : NSObject, ObservableObject,  URLSessionDownloadDelegate {
    
    var songUrl : URL?
    @Published var songFileLocation : URL?
    
    func fetchSongAtUrl(_ songUrl : URL) {
        self.songUrl = songUrl
        let configuration = URLSessionConfiguration.default
        let urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
        let downloadTask = urlSession.downloadTask(with: songUrl)
        downloadTask.resume()
    }
    
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        let fileManager = FileManager.default
        guard let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
            fatalError()
        }
        guard let lastPathComponent = self.songUrl?.lastPathComponent else {
            fatalError()
        }
        let songFileLocation = documentsPath.appendingPathComponent(lastPathComponent)

        do {
            if fileManager.fileExists(atPath: songFileLocation.path) {
                try fileManager.removeItem(at: songFileLocation)
            }
            try fileManager.copyItem(at: location, to: songFileLocation)
            DispatchQueue.main.async {
                self.songFileLocation = songFileLocation
            }
        } catch {
            print(error)
        }
    }

}

struct SongDownloaderExample: View {
    
    @State private var playMusic = false

    @ObservedObject var songDownloader = SongDownloader()
    
    var body: some View {
        VStack {
            Button {
                guard let songUrl = 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
                }
                songDownloader.fetchSongAtUrl(songUrl)
            } label: {
                Text("Download")
            }
            
            if songDownloader.songFileLocation != nil {
                Text("\(songDownloader.songFileLocation!)")
            }
        }
        .onChange(of: songDownloader.songFileLocation) { newValue in
            if newValue != nil {
                playMusic = true
            }
        }
        .sheet(isPresented: $playMusic) {
            AudioPlayerView(url: self.songDownloader.songFileLocation!)
        }
    }
}
6. 另外URLSessionDownloadDelegate還提供了一個方法,可以跟蹤下載的過程func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64),並使用變量downloadPercent在計算已下載的百份比,並顯示出來,實作如下:
import SwiftUI

class SongDownloader : NSObject, ObservableObject,  URLSessionDownloadDelegate {
    
    var songUrl : URL?
    @Published var songFileLocation : URL?
    @Published var isDownloading = false
    @Published var downloadPercent : Float = 0
    
    func fetchSongAtUrl(_ songUrl : URL) {
        self.songUrl = songUrl
        let configuration = URLSessionConfiguration.default
        let urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
        let downloadTask = urlSession.downloadTask(with: songUrl)
        downloadTask.resume()
        self.isDownloading = true
    }
    
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        let fileManager = FileManager.default
        guard let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
            fatalError()
        }
        guard let lastPathComponent = self.songUrl?.lastPathComponent else {
            fatalError()
        }
        let songFileLocation = documentsPath.appendingPathComponent(lastPathComponent)

        do {
            if fileManager.fileExists(atPath: songFileLocation.path) {
                try fileManager.removeItem(at: songFileLocation)
            }
            try fileManager.copyItem(at: location, to: songFileLocation)
            DispatchQueue.main.async {
                self.songFileLocation = songFileLocation
            }
        } catch {
            print(error)
        }
    }
    
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error = error {
            print(error.localizedDescription)
        }
        self.isDownloading = false
    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        self.downloadPercent = Float(totalBytesWritten*100)/Float(totalBytesExpectedToWrite)
    }
}

struct SongDownloaderExample: View {
    
    @State private var playMusic = false

    @ObservedObject var songDownloader = SongDownloader()
    
    var body: some View {
        VStack {
            Button {
                guard let songUrl = 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
                }
                songDownloader.fetchSongAtUrl(songUrl)
            } label: {
                Text("Download")
            }
            if songDownloader.isDownloading {
                Text("\(songDownloader.downloadPercent)% downloaded")
            }
            if songDownloader.songFileLocation != nil {
                Text("\(songDownloader.songFileLocation!)")
            }
        }
        .onChange(of: songDownloader.songFileLocation) { newValue in
            if newValue != nil {
                playMusic = true
            }
        }
        .sheet(isPresented: $playMusic) {
            AudioPlayerView(url: self.songDownloader.songFileLocation!)
        }
    }
}