You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

关键功能说明

  1. AVPlayer替代AVAudioPlayer:AVPlayer支持流式播放,无需等待完整文件下载即可开始播放。
  2. 自定义资源加载器:CacheableAVAssetResourceLoaderDelegate负责拦截AVPlayer的资源请求,将已下载的缓存数据返回给播放器,同时后台继续下载剩余内容。
  3. 本地缓存管理:自动检查缓存目录,存在缓存则直接使用本地文件播放,不存在则启动下载并实时写入缓存。
  4. 播放控制增强:保留原有的播放/暂停/停止/循环功能,新增进度监听与倒回/快进功能。
  5. 主线程安全:所有UI相关的状态更新都在主线程处理,避免界面冻结。

注意事项

  • 缓存文件存储在cachesDirectory的tracks子目录下,系统在磁盘空间不足时会自动清理缓存目录,若需要永久保存可改为documentsDirectory。
  • 若需要处理HTTPS认证或复杂的网络请求,可在resourceLoader(_:shouldWaitForResponseTo:)方法中添加对应逻辑。
  • 记得在合适的时机(如页面销毁)取消下载任务和移除监听,避免内存泄漏。

内容的提问来源于stack exchange,提问作者user19727360

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.03 11:31:06