在 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 是关键;
- 预加载:核心是“提前触发、优先缓存”,在当前音频即将结束时加载下一首,实现无缝切换;
- 断点续播:核心是“进度记录+缓存校验”,确保恢复进度时能流畅播放,避免无效跳转。