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

音频实战:边播边缓存、预加载与断点续播完整实现 在 iOS 音频开发中单纯的“播放功能”早已无法满足用户需求——无论是音乐 App、有声书 App还是播客类应用用户都希望实现「网络音频边播边存」「切换音频无等待」「断网/退出后继续播放」的体验。这三个核心功能边播边缓存、预加载、断点续播直接决定了音频 App 的用户留存率。此前我们已经对比过 AVAudioPlayer 与 AVPlayer 的差异明确了AVPlayer 是实现复杂音频功能的唯一选择——AVAudioPlayer 仅支持本地文件播放无法实现流媒体边播边缓存和断点续播而 AVPlayer 凭借灵活的资源管理和可扩展的加载机制能完美适配这些需求。今天这篇博客将从实战角度出发手把手教你实现 iOS 音频的「边播边缓存、预加载、断点续播」涵盖核心原理、完整代码、避坑细节所有代码可直接集成到项目中帮你快速搞定音频播放的进阶需求提升用户体验。一、核心需求解析为什么需要这三个功能在动手实现前我们先明确三个功能的核心价值避免盲目开发边播边缓存解决“重复播放网络音频耗流量”“弱网环境播放卡顿”问题将播放过的网络音频数据缓存到本地下次播放直接读取本地文件无需重新请求网络。预加载解决“切换音频时出现加载延迟”问题在当前音频播放即将结束时提前加载下一首音频的资源缓存解码实现无缝切换无等待感。断点续播解决“退出 App、断网、暂停后重新播放需从头开始”问题记录用户的播放进度和缓存状态下次启动时自动恢复到上次播放位置提升用户粘性。核心前提三者均基于 AVPlayer 实现依赖 AVFoundation 框架结合文件缓存管理、播放状态监听、资源加载拦截等技术形成完整的音频播放闭环。二、核心技术铺垫边播边缓存的底层原理边播边缓存是三个功能的基础其核心逻辑是「拦截 AVPlayer 的资源请求→下载音频数据→同时供 AVPlayer 播放写入本地缓存」。目前主流的实现方案有三种各有优劣我们选择最贴合实战、兼容性最好的方案主流方案对比选型建议实现方案核心原理优势劣势适用场景AVAssetResourceLoader推荐拦截 AVPlayer 的资源加载请求手动管理数据下载、缓存与回填掌控整个加载流程兼容性好iOS 6、灵活可控无需依赖第三方库可自定义缓存策略需手动处理请求拦截、数据分片开发成本略高绝大多数音频 App音乐、有声书、播客LocalServerGCDWebServer搭建本地服务器将网络音频 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. 解析请求的音频数据范围如bytes0-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请求头如bytes0-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的md5value播放进度秒数 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 SchemeAVAssetResourceLoader 只有在 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 是关键预加载核心是“提前触发、优先缓存”在当前音频即将结束时加载下一首实现无缝切换断点续播核心是“进度记录缓存校验”确保恢复进度时能流畅播放避免无效跳转。