iOS 音频实战:边播边缓存、预加载与断点续播完整实现

0 阅读15分钟

在 iOS 音频开发中,单纯的“播放功能”早已无法满足用户需求——无论是音乐 App、有声书 App,还是播客类应用,用户都希望实现「网络音频边播边存」「切换音频无等待」「断网/退出后继续播放」的体验。这三个核心功能(边播边缓存、预加载、断点续播),直接决定了音频 App 的用户留存率。

此前我们已经对比过 AVAudioPlayer 与 AVPlayer 的差异,明确了AVPlayer 是实现复杂音频功能的唯一选择——AVAudioPlayer 仅支持本地文件播放,无法实现流媒体边播边缓存和断点续播,而 AVPlayer 凭借灵活的资源管理和可扩展的加载机制,能完美适配这些需求。

今天这篇博客,将从实战角度出发,手把手教你实现 iOS 音频的「边播边缓存、预加载、断点续播」,涵盖核心原理、完整代码、避坑细节,所有代码可直接集成到项目中,帮你快速搞定音频播放的进阶需求,提升用户体验。

一、核心需求解析:为什么需要这三个功能?

在动手实现前,我们先明确三个功能的核心价值,避免盲目开发:

  • 边播边缓存:解决“重复播放网络音频耗流量”“弱网环境播放卡顿”问题,将播放过的网络音频数据缓存到本地,下次播放直接读取本地文件,无需重新请求网络。
  • 预加载:解决“切换音频时出现加载延迟”问题,在当前音频播放即将结束时,提前加载下一首音频的资源(缓存+解码),实现无缝切换,无等待感。
  • 断点续播:解决“退出 App、断网、暂停后,重新播放需从头开始”问题,记录用户的播放进度和缓存状态,下次启动时自动恢复到上次播放位置,提升用户粘性。

核心前提:三者均基于 AVPlayer 实现,依赖 AVFoundation 框架,结合文件缓存管理、播放状态监听、资源加载拦截等技术,形成完整的音频播放闭环。

二、核心技术铺垫:边播边缓存的底层原理

边播边缓存是三个功能的基础,其核心逻辑是「拦截 AVPlayer 的资源请求→下载音频数据→同时供 AVPlayer 播放+写入本地缓存」。目前主流的实现方案有三种,各有优劣,我们选择最贴合实战、兼容性最好的方案:

主流方案对比(选型建议)

实现方案核心原理优势劣势适用场景
AVAssetResourceLoader(推荐)拦截 AVPlayer 的资源加载请求,手动管理数据下载、缓存与回填,掌控整个加载流程兼容性好(iOS 6+)、灵活可控,无需依赖第三方库,可自定义缓存策略需手动处理请求拦截、数据分片,开发成本略高绝大多数音频 App(音乐、有声书、播客)
LocalServer(GCDWebServer)搭建本地服务器,将网络音频 URL 转为本地服务器 URL,拦截请求后实现缓存适配所有流播放器,无 AVPlayer 真机拦截限制需集成第三方库,增加 App 体积,配置复杂多播放器兼容、复杂流媒体场景
NSURLProtocol拦截 URL Loading System 的请求,实现缓存与数据回填开发简单,无需修改播放器逻辑真机上无法拦截 AVPlayer 的请求(AVPlayer 不依赖 URL Loading System)模拟器调试、非 AVPlayer 播放器场景

本文将采用 AVAssetResourceLoader 方案 实现边播边缓存,兼顾兼容性和灵活性,同时避免第三方库的依赖,适合大多数实战场景。核心注意点:使用该方案时,必须自定义 URL Scheme(如将 https 改为 https-streaming),否则无法触发 AVAssetResourceLoader 的代理回调。

三、实战实现:三大功能完整代码(Swift)

我们按“边播边缓存→预加载→断点续播”的顺序,逐步实现,所有代码可直接复制到项目中,只需根据自身业务调整参数即可。

第一步:基础配置(缓存管理+工具类)

先实现缓存管理工具类,负责本地缓存的创建、读取、删除、校验,以及音频数据的分片存储(适配 HTTP Range 请求,支持断点下载)。

import AVFoundation
import Foundation

// 缓存管理单例(负责音频缓存的增删改查)
class AudioCacheManager: NSObject {
    static let shared = AudioCacheManager()
    private override init() {}
    
    // 缓存存储路径(沙盒 Library/Caches/AudioCache)
    private var cachePath: String {
        let cacheDir = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first!
        let audioCacheDir = cacheDir + "/AudioCache"
        if !FileManager.default.fileExists(atPath: audioCacheDir) {
            try? FileManager.default.createDirectory(atPath: audioCacheDir, withIntermediateDirectories: true)
        }
        return audioCacheDir
    }
    
    // 生成缓存文件路径(用音频URL的md5作为文件名,避免重复)
    private func cacheFilePath(for url: URL) -> String {
        let md5 = url.absoluteString.md5 // 需自行实现md5加密方法
        return cachePath + "/(md5).mp3" // 可根据音频格式调整后缀
    }
    
    // 1. 检查音频是否已缓存(完整缓存)
    func isAudioCached(for url: URL) -> Bool {
        let filePath = cacheFilePath(for: url)
        return FileManager.default.fileExists(atPath: filePath)
    }
    
    // 2. 检查指定时间段的音频是否已缓存(用于断点续播、分片加载)
    func isRangeCached(for url: URL, start: Int64, end: Int64) -> Bool {
        let filePath = cacheFilePath(for: url)
        guard FileManager.default.fileExists(atPath: filePath) else { return false }
        let fileSize = try? FileManager.default.attributesOfItem(atPath: filePath)[.size] as? Int64 ?? 0
        // 检查请求的范围是否在已缓存文件范围内
        return start >= 0 && end <= fileSize ?? 0
    }
    
    // 3. 读取缓存的音频数据(指定范围)
    func readCachedData(for url: URL, start: Int64, end: Int64) -> Data? {
        let filePath = cacheFilePath(for: url)
        guard let fileHandle = try? FileHandle(forReadingFromPath: filePath) else { return nil }
        fileHandle.seek(toFileOffset: UInt64(start))
        let data = fileHandle.readData(ofLength: Int(end - start + 1))
        fileHandle.closeFile()
        return data
    }
    
    // 4. 写入缓存数据(支持分片写入,用于边播边缓存)
    func writeCachedData(_ data: Data, for url: URL, offset: Int64) {
        let filePath = cacheFilePath(for: url)
        let fileManager = FileManager.default
        // 若文件不存在,创建空文件;若存在,追加写入
        if !fileManager.fileExists(atPath: filePath) {
            fileManager.createFile(atPath: filePath, contents: nil)
        }
        guard let fileHandle = try? FileHandle(forWritingToPath: filePath) else { return }
        fileHandle.seek(toFileOffset: UInt64(offset))
        fileHandle.write(data)
        fileHandle.closeFile()
    }
    
    // 5. 获取已缓存的音频长度
    func cachedLength(for url: URL) -> Int64 {
        let filePath = cacheFilePath(for: url)
        guard FileManager.default.fileExists(atPath: filePath) else { return 0 }
        return try? FileManager.default.attributesOfItem(atPath: filePath)[.size] as? Int64 ?? 0
    }
    
    // 6. 删除指定音频缓存
    func deleteCache(for url: URL) {
        let filePath = cacheFilePath(for: url)
        try? FileManager.default.removeItem(atPath: filePath)
    }
    
    // 7. 清空所有音频缓存
    func clearAllCache() {
        try? FileManager.default.removeItem(atPath: cachePath)
    }
}

// 辅助扩展:URL md5加密(用于生成唯一缓存文件名)
extension String {
    var md5: String {
        let data = Data(self.utf8)
        let hash = data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> [UInt8] in
            var hash = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
            CC_MD5(bytes.baseAddress, CC_LONG(data.count), &hash)
            return hash
        }
        return hash.map { String(format: "%02x", $0) }.joined()
    }
}

第二步:边播边缓存实现(核心)

通过 AVAssetResourceLoader 拦截 AVPlayer 的资源加载请求,实现“请求拦截→缓存校验→数据下载/读取→数据回填”的完整流程,同时支持 HTTP Range 分片请求,适配边播边缓存场景。

// 资源加载代理(负责拦截AVPlayer请求,实现边播边缓存)
class AudioResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
    // 音频原始URL(用于实际网络请求)
    private var originalUrl: URL?
    // 下载任务(用于分片下载音频数据)
    private var downloadTask: URLSessionDataTask?
    // URLSession(管理网络请求)
    private let session = URLSession(configuration: .default)
    
    // 初始化:传入原始音频URL,转换为自定义Scheme的URL(触发代理回调)
    func getCustomUrl(for originalUrl: URL) -> URL {
        self.originalUrl = originalUrl
        // 自定义Scheme:将https转为https-streaming(必须自定义,否则不触发代理)
        var components = URLComponents(url: originalUrl, resolvingAgainstBaseURL: true)!
        components.scheme = "https-streaming" // 自定义Scheme,可任意命名
        return components.url!
    }
    
    // 核心代理方法:拦截AVPlayer的资源加载请求
    func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didReceive loadingRequest: AVAssetResourceLoadingRequest) {
        guard let originalUrl = originalUrl else {
            loadingRequest.finishLoading(with: NSError(domain: "AudioCacheError", code: -1, userInfo: [NSLocalizedDescriptionKey: "原始URL为空"]))
            return
        }
        
        // 1. 解析请求的音频数据范围(如bytes=0-1023)
        guard let rangeHeader = loadingRequest.request.value(forHTTPHeaderField: "Range"),
              let range = parseRangeHeader(rangeHeader) else {
            // 无Range请求,返回完整数据
            handleFullRequest(loadingRequest, originalUrl: originalUrl)
            return
        }
        
        let (start, end) = range
        let cacheManager = AudioCacheManager.shared
        
        // 2. 检查该范围是否已缓存,已缓存则直接返回缓存数据
        if cacheManager.isRangeCached(for: originalUrl, start: start, end: end) {
            if let cachedData = cacheManager.readCachedData(for: originalUrl, start: start, end: end) {
                handleCachedData(loadingRequest, data: cachedData, start: start, end: end, totalLength: cacheManager.cachedLength(for: originalUrl))
                return
            }
        }
        
        // 3. 未缓存,发起网络请求下载该范围数据(边下载边缓存+边回填给AVPlayer)
        handleRangeRequest(loadingRequest, originalUrl: originalUrl, start: start, end: end)
    }
    
    // 解析Range请求头(如"bytes=0-1023" → (0, 1023))
    private func parseRangeHeader(_ header: String) -> (start: Int64, end: Int64)? {
        let prefix = "bytes="
        guard header.hasPrefix(prefix) else { return nil }
        let rangeStr = header.dropFirst(prefix.count)
        let components = rangeStr.split(separator: "-").map(String.init)
        guard components.count == 2,
              let start = Int64(components[0]),
              let end = Int64(components[1]) else { return nil }
        return (start, end)
    }
    
    // 处理无Range的完整请求
    private func handleFullRequest(_ request: AVAssetResourceLoadingRequest, originalUrl: URL) {
        let cacheManager = AudioCacheManager.shared
        // 检查是否已完整缓存
        if cacheManager.isAudioCached(for: originalUrl) {
            let totalLength = cacheManager.cachedLength(for: originalUrl)
            if let data = cacheManager.readCachedData(for: originalUrl, start: 0, end: totalLength - 1) {
                handleCachedData(request, data: data, start: 0, end: totalLength - 1, totalLength: totalLength)
            } else {
                request.finishLoading(with: NSError(domain: "AudioCacheError", code: -2, userInfo: [NSLocalizedDescriptionKey: "缓存读取失败"]))
            }
        } else {
            // 未缓存,发起完整请求
            var urlRequest = URLRequest(url: originalUrl)
            urlRequest.httpMethod = "GET"
            downloadTask = session.dataTask(with: urlRequest) { [weak self] data, response, error in
                guard let self = self else { return }
                if let error = error {
                    request.finishLoading(with: error)
                    return
                }
                guard let data = data, let response = response as? HTTPURLResponse else {
                    request.finishLoading(with: NSError(domain: "AudioCacheError", code: -3, userInfo: [NSLocalizedDescriptionKey: "请求失败"]))
                    return
                }
                // 写入缓存(完整缓存)
                cacheManager.writeCachedData(data, for: originalUrl, offset: 0)
                // 回填数据给AVPlayer
                self.handleCachedData(request, data: data, start: 0, end: Int64(data.count) - 1, totalLength: Int64(data.count))
            }
            downloadTask?.resume()
        }
    }
    
    // 处理Range请求(边下载边缓存+边回填)
    private func handleRangeRequest(_ request: AVAssetResourceLoadingRequest, originalUrl: URL, start: Int64, end: Int64) {
        let cacheManager = AudioCacheManager.shared
        var urlRequest = URLRequest(url: originalUrl)
        // 设置Range请求头,获取指定范围的数据
        urlRequest.httpMethod = "GET"
        urlRequest.setValue("bytes=(start)-(end)", forHTTPHeaderField: "Range")
        
        downloadTask = session.dataTask(with: urlRequest) { [weak self] data, response, error in
            guard let self = self else { return }
            if let error = error {
                request.finishLoading(with: error)
                return
            }
            guard let data = data, let response = response as? HTTPURLResponse else {
                request.finishLoading(with: NSError(domain: "AudioCacheError", code: -3, userInfo: [NSLocalizedDescriptionKey: "请求失败"]))
                return
            }
            // 写入缓存(分片写入,offset为start)
            cacheManager.writeCachedData(data, for: originalUrl, offset: start)
            // 回填数据给AVPlayer
            let totalLength = self.getTotalLength(from: response) ?? end
            self.handleCachedData(request, data: data, start: start, end: end, totalLength: totalLength)
        }
        downloadTask?.resume()
    }
    
    // 从响应中获取音频总长度
    private func getTotalLength(from response: HTTPURLResponse) -> Int64? {
        guard let contentRange = response.value(forHTTPHeaderField: "Content-Range"),
              let totalStr = contentRange.split(separator: "/").last else {
            return nil
        }
        return Int64(totalStr)
    }
    
    // 将缓存/下载的数据回填给AVPlayer,完成加载
    private func handleCachedData(_ request: AVAssetResourceLoadingRequest, data: Data, start: Int64, end: Int64, totalLength: Int64) {
        // 配置响应信息
        let response = HTTPURLResponse(
            url: originalUrl!,
            statusCode: 206, // 206 Partial Content,对应Range请求
            httpVersion: "HTTP/1.1",
            headerFields: [
                "Content-Type": "audio/mpeg",
                "Content-Range": "bytes (start)-(end)/(totalLength)",
                "Content-Length": "(data.count)"
            ]
        )!
        
        // 回填响应和数据
        request.response = response
        request.dataRequest?.respond(with: data)
        // 完成加载
        request.finishLoading()
    }
    
    // 取消下载任务(避免内存泄漏)
    func cancelDownloadTask() {
        downloadTask?.cancel()
        downloadTask = nil
    }
}

第三步:预加载实现(无缝切换音频)

预加载的核心逻辑是「监听当前音频播放进度→当播放到指定进度(如剩余3秒)时→提前加载下一首音频的资源」,利用 AVPlayer 的资源预加载机制,实现无缝切换。同时结合缓存管理,优先读取本地缓存,无缓存则提前下载。

// 音频播放器管理类(整合边播边缓存、预加载、断点续播)
class AudioPlayerManager: NSObject {
    static let shared = AudioPlayerManager()
    private override init() {
        super.init()
        setupPlayer()
        setupPlaybackObserver()
    }
    
    // 核心属性
    private var player: AVPlayer! // 播放器
    private var resourceLoader: AudioResourceLoader! // 资源加载器(边播边缓存)
    private var currentAudioUrl: URL? // 当前播放音频URL
    private var nextAudioUrl: URL? // 下一首音频URL(用于预加载)
    private var preloadedAsset: AVAsset? // 预加载的音频资源
    private let cacheManager = AudioCacheManager.shared
    
    // 初始化播放器
    private func setupPlayer() {
        player = AVPlayer()
        // 配置后台播放(需在Info.plist中配置后台模式)
        let audioSession = AVAudioSession.sharedInstance()
        try? audioSession.setCategory(.playback, mode: .default)
        try? audioSession.activate(options: .notifyOthersOnDeactivation)
    }
    
    // 监听播放进度(用于预加载、断点续播记录)
    private func setupPlaybackObserver() {
        // 每0.5秒监听一次播放进度
        let interval = CMTime(seconds: 0.5, preferredTimescale: 1000)
        player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] currentTime in
            guard let self = self, let currentItem = self.player.currentItem else { return }
            let totalTime = currentItem.duration.seconds
            let remainingTime = totalTime - currentTime.seconds
            
            // 1. 预加载触发:当剩余3秒时,加载下一首音频
            if remainingTime <= 3.0, let nextUrl = self.nextAudioUrl, self.preloadedAsset == nil {
                self.preloadNextAudio(url: nextUrl)
            }
            
            // 2. 记录播放进度(用于断点续播)
            self.savePlaybackProgress(url: self.currentAudioUrl!, progress: currentTime.seconds)
        }
    }
    
    // 播放音频(支持本地/网络音频,自动适配缓存)
    func playAudio(url: URL, isLocal: Bool = false) {
        currentAudioUrl = url
        // 本地音频直接播放,无需缓存
        if isLocal {
            let playerItem = AVPlayerItem(url: url)
            player.replaceCurrentItem(with: playerItem)
            player.play()
            return
        }
        
        // 网络音频:使用资源加载器,实现边播边缓存
        resourceLoader = AudioResourceLoader()
        let customUrl = resourceLoader.getCustomUrl(for: url)
        let asset = AVURLAsset(url: customUrl)
        // 设置资源加载代理(关键:关联resourceLoader)
        asset.resourceLoader.setDelegate(resourceLoader, queue: .main)
        
        let playerItem = AVPlayerItem(asset: asset)
        player.replaceCurrentItem(with: playerItem)
        player.play()
        
        // 恢复断点续播(如果有历史进度)
        restorePlaybackProgress(url: url)
    }
    
    // 预加载下一首音频
    func preloadNextAudio(url: URL) {
        nextAudioUrl = url
        // 检查是否已缓存,已缓存则直接加载资源
        if cacheManager.isAudioCached(for: url) {
            let asset = AVURLAsset(url: url)
            preloadedAsset = asset
            // 提前加载资源(解码),避免切换时卡顿
            asset.loadValuesAsynchronously(forKeys: ["playable", "duration"]) { [weak self] in
                guard let self = self else { return }
                if asset.statusOfValue(forKey: "playable", error: nil) == .loaded {
                    self.preloadedAsset = asset
                }
            }
            return
        }
        
        // 未缓存:提前发起请求,边下载边缓存(不播放)
        let tempLoader = AudioResourceLoader()
        let customUrl = tempLoader.getCustomUrl(for: url)
        let asset = AVURLAsset(url: customUrl)
        asset.resourceLoader.setDelegate(tempLoader, queue: .main)
        // 加载资源,完成后缓存
        asset.loadValuesAsynchronously(forKeys: ["playable"]) { [weak self] in
            guard let self = self else { return }
            if asset.statusOfValue(forKey: "playable", error: nil) == .loaded {
                self.preloadedAsset = asset
            }
        }
    }
    
    // 切换到下一首音频(使用预加载的资源,无缝切换)
    func playNextAudio() {
        guard let nextUrl = nextAudioUrl, let preloadedAsset = preloadedAsset else { return }
        currentAudioUrl = nextUrl
        let playerItem = AVPlayerItem(asset: preloadedAsset)
        player.replaceCurrentItem(with: playerItem)
        player.play()
        // 重置预加载资源,准备下一次预加载
        self.preloadedAsset = nil
        nextAudioUrl = nil
    }

第四步:断点续播实现(记录+恢复进度)

断点续播的核心是「记录播放进度+缓存状态」,将用户的播放进度(秒数)存储到本地(UserDefaults/数据库),下次播放该音频时,自动读取进度并跳转,同时结合缓存状态,确保跳转后能流畅播放。

// 继续在AudioPlayerManager中添加断点续播相关方法
extension AudioPlayerManager {
    // 存储播放进度(key:音频URL的md5,value:播放进度秒数)
    private func savePlaybackProgress(url: URL, progress: Double) {
        let key = url.absoluteString.md5
        UserDefaults.standard.set(progress, forKey: "AudioPlaybackProgress_(key)")
        UserDefaults.standard.synchronize()
    }
    
    // 读取播放进度
    private func getPlaybackProgress(url: URL) -> Double? {
        let key = url.absoluteString.md5
        return UserDefaults.standard.double(forKey: "AudioPlaybackProgress_(key)")
    }
    
    // 恢复断点续播
    private func restorePlaybackProgress(url: URL) {
        guard let progress = getPlaybackProgress(url: url), progress > 0 else { return }
        let totalLength = cacheManager.cachedLength(for: url)
        let totalSeconds = CMTimeGetSeconds(AVURLAsset(url: url).duration)
        
        // 检查进度是否有效(不超过音频总长度,且对应范围已缓存)
        if progress < totalSeconds,
           let start = Int64(progress * 1000) / 1000, // 取整到秒级,避免精度问题
           cacheManager.isRangeCached(for: url, start: start, end: totalLength - 1) {
            // 跳转到历史进度
            let targetTime = CMTimeMakeWithSeconds(progress, preferredTimescale: 1000)
            player.seek(to: targetTime, toleranceBefore: .zero, toleranceAfter: .zero)
        }
    }
    
    // 清除指定音频的播放进度记录
    func clearPlaybackProgress(url: URL) {
        let key = url.absoluteString.md5
        UserDefaults.standard.removeObject(forKey: "AudioPlaybackProgress_(key)")
        UserDefaults.standard.synchronize()
    }
    
    // 暂停播放(记录当前进度)
    func pause() {
        guard let currentUrl = currentAudioUrl else { return }
        let currentProgress = player.currentTime().seconds
        savePlaybackProgress(url: currentUrl, progress: currentProgress)
        player.pause()
    }
    
    // 停止播放(记录当前进度,取消下载和预加载)
    func stop() {
        guard let currentUrl = currentAudioUrl else { return }
        let currentProgress = player.currentTime().seconds
        savePlaybackProgress(url: currentUrl, progress: currentProgress)
        player.pause()
        player.replaceCurrentItem(with: nil)
        resourceLoader?.cancelDownloadTask()
        preloadedAsset = nil
        nextAudioUrl = nil
    }
}

四、功能调用示例(实战用法)

以下是完整的调用示例,涵盖“播放网络音频(边播边缓存)→预加载下一首→切换音频→暂停/恢复(断点续播)”的全流程,可直接集成到ViewController中使用。

import UIKit
import AVFoundation

class AudioPlayerViewController: UIViewController {
    let playerManager = AudioPlayerManager.shared
    // 示例音频URL(网络音频)
    let currentAudioUrl = URL(string: "https://xxx.com/audio/current.mp3")!
    let nextAudioUrl = URL(string: "https://xxx.com/audio/next.mp3")!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }
    
    private func setupUI() {
        // 播放按钮
        let playBtn = UIButton(frame: CGRect(x: 100, y: 200, width: 100, height: 44))
        playBtn.setTitle("播放", for: .normal)
        playBtn.backgroundColor = .blue
        playBtn.addTarget(self, action: #selector(playBtnClick), for: .touchUpInside)
        view.addSubview(playBtn)
        
        // 暂停按钮
        let pauseBtn = UIButton(frame: CGRect(x: 220, y: 200, width: 100, height: 44))
        pauseBtn.setTitle("暂停", for: .normal)
        pauseBtn.backgroundColor = .gray
        pauseBtn.addTarget(self, action: #selector(pauseBtnClick), for: .touchUpInside)
        view.addSubview(pauseBtn)
        
        // 下一首按钮
        let nextBtn = UIButton(frame: CGRect(x: 160, y: 280, width: 100, height: 44))
        nextBtn.setTitle("下一首", for: .normal)
        nextBtn.backgroundColor = .green
        nextBtn.addTarget(self, action: #selector(nextBtnClick), for: .touchUpInside)
        view.addSubview(nextBtn)
    }
    
    // 播放音频(边播边缓存+断点续播)
    @objc private func playBtnClick() {
        playerManager.playAudio(url: currentAudioUrl, isLocal: false)
        // 预加载下一首音频
        playerManager.preloadNextAudio(url: nextAudioUrl)
    }
    
    // 暂停播放(记录进度)
    @objc private func pauseBtnClick() {
        playerManager.pause()
    }
    
    // 切换到下一首(无缝切换,使用预加载资源)
    @objc private func nextBtnClick() {
        playerManager.playNextAudio()
    }
    
    // 退出页面,停止播放
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        playerManager.stop()
    }
}

五、避坑细节:这些问题一定要注意

实战中,边播边缓存、预加载、断点续播容易出现卡顿、缓存失效、进度错乱等问题,以下是高频避坑点,提前规避可节省大量调试时间:

1. 边播边缓存避坑

  • 必须自定义 URL Scheme:AVAssetResourceLoader 只有在 AVURLAsset 的 URL 是自定义 Scheme 时,才会触发代理回调,否则无法拦截请求;
  • 支持 HTTP Range 请求:大多数音频服务器都支持 Range 分片请求,若服务器不支持,无法实现分片缓存和断点下载,需提前和后端确认;
  • 避免重复下载:缓存时用音频 URL 的 md5 作为唯一文件名,避免同一音频重复缓存,节省本地存储空间;
  • 及时取消下载任务:切换音频、退出页面时,务必取消当前的下载任务,避免内存泄漏和无效网络请求。

2. 预加载避坑

  • 控制预加载时机:不要在 App 启动时预加载过多音频,避免占用过多内存和网络资源,建议在当前音频剩余3-5秒时触发预加载;
  • 释放预加载资源:切换音频后,及时重置 preloadedAsset,避免内存泄漏;
  • 适配弱网环境:弱网下预加载可能失败,需添加失败重试逻辑,避免切换音频时出现加载卡顿。

3. 断点续播避坑

  • 进度精度控制:存储进度时取整到秒级,避免毫秒级精度导致的跳转误差;
  • 缓存校验:恢复进度前,必须检查该进度对应的音频范围是否已缓存,否则会出现跳转后卡顿、无法播放的问题;
  • 清理无效进度:音频缓存删除后,同步清理对应的播放进度记录,避免恢复到无效进度。

4. 通用避坑

  • 后台播放配置:需在 Info.plist 中添加“Background Modes”→“Audio, AirPlay, and Picture in Picture”,否则 App 进入后台后会停止播放;
  • 内存管理:AVPlayer、AVPlayerItem、AudioResourceLoader 需用弱引用持有,避免循环引用导致内存泄漏;
  • 错误处理:添加播放器错误监听(AVPlayer、AVPlayerItem 的 error 回调),处理网络错误、解码错误等异常,提升用户体验。

六、总结:实战核心要点

iOS 音频的边播边缓存、预加载、断点续播,核心是「以 AVPlayer 为基础,结合 AVAssetResourceLoader 实现资源拦截与缓存,通过播放进度监听实现预加载和断点续播」,三者相互配合,才能打造流畅的音频播放体验。

核心总结:

  • 边播边缓存:核心是 AVAssetResourceLoader 拦截请求,实现“下载→缓存→回填”的闭环,自定义 Scheme 是关键;
  • 预加载:核心是“提前触发、优先缓存”,在当前音频即将结束时加载下一首,实现无缝切换;
  • 断点续播:核心是“进度记录+缓存校验”,确保恢复进度时能流畅播放,避免无效跳转。