Swift/SwiftUI修改代码实现MP3边下载边播放而非先下载后播放
解决方案:改用AVPlayer实现边下载边播放+本地缓存
AVAudioPlayer的设计决定了它必须获取完整的音频数据才能初始化播放,无法实现边下载边播放。要解决大音频文件的播放阻塞问题,我们需要改用AVPlayer,配合自定义资源加载器实现流式播放+本地缓存的功能,既可以立即开始播放,又能将音频缓存到本地供后续使用。
以下是修改后的完整代码:
import Foundation import AVFoundation import AVFAudio import Combine final class AudioManager: ObservableObject { // 替换AVAudioPlayer为AVPlayer private var player: AVPlayer? private var playerItem: AVPlayerItem? private var resourceLoaderDelegate: CacheableAVAssetResourceLoaderDelegate? @Published private(set) var isDownloading = false @Published private(set) var isPlaying: Bool = false { didSet { print(isPlaying, "isPlaying") } } @Published private(set) var isLooping: Bool = false @Published private(set) var currentProgress: Double = 0.0 @MainActor func startPlayer(track: String) async { guard let remoteURL = URL(string: track) else { return } do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try AVAudioSession.sharedInstance().setActive(true) let songName = remoteURL.lastPathComponent let tracksFolderUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).last!.appendingPathComponent("tracks") let localCacheUrl = tracksFolderUrl.appendingPathComponent(songName) // 检查本地缓存是否存在 if FileManager.default.fileExists(atPath: localCacheUrl.path) { print("使用本地缓存播放: \(localCacheUrl)") let asset = AVURLAsset(url: localCacheUrl) setupPlayer(with: asset) } else { // 创建自定义资源加载器,实现边下载边播放+缓存 resourceLoaderDelegate = CacheableAVAssetResourceLoaderDelegate(remoteURL: remoteURL, localCacheUrl: localCacheUrl) let asset = AVURLAsset(url: resourceLoaderDelegate!.customSchemeURL) asset.resourceLoader.setDelegate(resourceLoaderDelegate!, queue: DispatchQueue.global()) setupPlayer(with: asset) // 启动下载(后台执行) isDownloading = true await resourceLoaderDelegate?.startDownload() isDownloading = false } } catch { print("初始化播放器失败: \(error)") } } private func setupPlayer(with asset: AVURLAsset) { playerItem = AVPlayerItem(asset: asset) player = AVPlayer(playerItem: playerItem) // 监听播放状态 let statusObserver = playerItem?.observe(\.status, options: [.new]) { [weak self] item, _ in guard let self = self else { return } if item.status == .readyToPlay { self.player?.play() self.isPlaying = true // 监听进度更新 self.addProgressObserver() } else if item.status == .failed { print("播放器准备失败: \(item.error?.localizedDescription ?? "未知错误")") } } statusObserver?.store(in: &cancellables) // 监听播放结束,处理循环逻辑 let endObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main ) { [weak self] _ in guard let self = self else { return } if self.isLooping { self.player?.seek(to: .zero) self.player?.play() } else { self.isPlaying = false } } cancellables.insert(endObserver) } private func addProgressObserver() { guard let player = player else { return } let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) let progressObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let self = self, let duration = self.player?.currentItem?.duration else { return } if duration.isNumeric { self.currentProgress = CMTimeGetSeconds(time) / CMTimeGetSeconds(duration) } } cancellables.insert(progressObserver) } func playPause() { guard let player = player else { print("播放器未初始化") return } if player.isPlaying { player.pause() isPlaying = false } else { player.play() isPlaying = true } } func stop() { guard let player = player else { print("播放器未初始化") return } player.pause() player.seek(to: .zero) isPlaying = false } func toggleLoop() { isLooping.toggle() print("isLooping", isLooping) } // 倒回/快进功能 func seek(to progress: Double) { guard let player = player, let duration = player.currentItem?.duration, duration.isNumeric else { return } let targetTime = CMTime(seconds: CMTimeGetSeconds(duration) * progress, preferredTimescale: duration.timescale) player.seek(to: targetTime) } // 存储监听对象,防止提前释放 private var cancellables = Set<AnyCancellable>() } // 自定义资源加载器:实现边下载边播放+本地缓存 class CacheableAVAssetResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate { let remoteURL: URL let localCacheUrl: URL let customSchemeURL: URL private var downloadTask: URLSessionDataTask? private var fileHandle: FileHandle? private var receivedDataLength: Int64 = 0 private var response: URLResponse? private var pendingRequests = [AVAssetResourceLoadingRequest]() init(remoteURL: URL, localCacheUrl: URL) { self.remoteURL = remoteURL self.localCacheUrl = localCacheUrl // 创建自定义scheme的URL,让AVPlayer请求走我们的资源加载器 self.customSchemeURL = URL(string: "cache://\(remoteURL.lastPathComponent)")! super.init() // 初始化缓存文件(如果不存在) try? FileManager.default.createDirectory(at: localCacheUrl.deletingLastPathComponent(), withIntermediateDirectories: true) if !FileManager.default.fileExists(atPath: localCacheUrl.path) { FileManager.default.createFile(atPath: localCacheUrl.path, contents: Data(), attributes: nil) } do { fileHandle = try FileHandle(forWritingTo: localCacheUrl) } catch { print("打开缓存文件失败: \(error)") } } func startDownload() async { let session = URLSession.shared downloadTask = session.dataTask(with: remoteURL) { [weak self] data, response, error in guard let self = self else { return } if let error = error { print("下载失败: \(error)") self.finishAllRequests(with: error) return } guard let response = response, let data = data else { print("无响应或数据") self.finishAllRequests(with: NSError(domain: "CacheLoader", code: -1, userInfo: [NSLocalizedDescriptionKey: "下载无数据"])) return } self.response = response self.receivedDataLength += Int64(data.count) // 写入缓存文件 do { try self.fileHandle?.write(contentsOf: data) } catch { print("写入缓存文件失败: \(error)") } // 处理所有待处理的请求 self.processPendingRequests() // 下载完成,关闭文件句柄 if self.receivedDataLength == response.expectedContentLength { self.fileHandle?.closeFile() } } downloadTask?.resume() } // MARK: - AVAssetResourceLoaderDelegate func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { pendingRequests.append(loadingRequest) processPendingRequests() return true } func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForResponseTo authenticationChallenge: URLAuthenticationChallenge) -> Bool { // 处理认证(如果需要),这里默认允许所有请求 authenticationChallenge.sender?.continueWithoutCredential(for: authenticationChallenge) return true } // MARK: - 处理请求 private func processPendingRequests() { guard let response = response, let fileHandle = fileHandle else { return } var completedRequests = [AVAssetResourceLoadingRequest]() for request in pendingRequests { // 设置响应头 if let infoRequest = request.contentInformationRequest, infoRequest.contentType.isEmpty { infoRequest.contentType = response.mimeType infoRequest.contentLength = response.expectedContentLength infoRequest.isByteRangeAccessSupported = true } // 获取请求的字节范围 let requestedOffset = request.dataRequest?.requestedOffset ?? 0 let requestedLength = request.dataRequest?.requestedLength ?? 0 let currentAvailableLength = receivedDataLength - requestedOffset if currentAvailableLength >= requestedLength { // 有足够的数据,返回给播放器 fileHandle.seek(toFileOffset: requestedOffset) let data = fileHandle.readData(ofLength: Int(requestedLength)) request.dataRequest?.respond(with: data) request.finishLoading() completedRequests.append(request) } else if currentAvailableLength > 0 { // 返回已有的部分数据 fileHandle.seek(toFileOffset: requestedOffset) let data = fileHandle.readData(ofLength: Int(currentAvailableLength)) request.dataRequest?.respond(with: data) // 更新请求的已加载偏移量 request.dataRequest?.requestedOffset += currentAvailableLength } } // 移除已完成的请求 pendingRequests.removeAll { completedRequests.contains($0) } } private func finishAllRequests(with error: Error) { for request in pendingRequests { request.finishLoading(with: error) } pendingRequests.removeAll() } } // 扩展辅助方法 extension CMTime { var isNumeric: Bool { !self.isIndefinite && !self.isInvalid } }
关键功能说明
- AVPlayer替代AVAudioPlayer:AVPlayer支持流式播放,无需等待完整文件下载即可开始播放。
- 自定义资源加载器:
CacheableAVAssetResourceLoaderDelegate负责拦截AVPlayer的资源请求,将已下载的缓存数据返回给播放器,同时后台继续下载剩余内容。 - 本地缓存管理:自动检查缓存目录,存在缓存则直接使用本地文件播放,不存在则启动下载并实时写入缓存。
- 播放控制增强:保留原有的播放/暂停/停止/循环功能,新增进度监听与倒回/快进功能。
- 主线程安全:所有UI相关的状态更新都在主线程处理,避免界面冻结。
注意事项
- 缓存文件存储在
cachesDirectory的tracks子目录下,系统在磁盘空间不足时会自动清理缓存目录,若需要永久保存可改为documentsDirectory。 - 若需要处理HTTPS认证或复杂的网络请求,可在
resourceLoader(_:shouldWaitForResponseTo:)方法中添加对应逻辑。 - 记得在合适的时机(如页面销毁)取消下载任务和移除监听,避免内存泄漏。
内容的提问来源于stack exchange,提问作者user19727360
相关产品推荐
相关产品推荐

