96SEO 2026-08-02 07:00 1
在 iOS 音频开发中,单纯的“播放功能”早已无法符合使用者需要——无论是音乐 App、有声书 App。还是播客类应用,使用者都希望实现「网络音频边播边存」「切换音频无等待」「断网/退出后继续播放」的体验。这三个主要功能,直接决定了音频 App 的使用者留存率。
此前我们已经对比过 AudioPlayer 与 Player 的差异,明确了Player 是实现复杂音频功能的唯一选择——AudioPlayer 仅支持本地文件播放。无法实现流媒体边播边缓存和断点续播,而 Player 凭借灵活的资源管理和可 的加载机制,能完美适配这些需求。

今天这篇博客,将,手把手教你实现 iOS 音频的「边播边缓存、预加载、断点续播」。涵盖主要原理、完整代码、避坑细节,所有代码可直接集成到项目中,帮你快速搞定音频播放的进阶需求,让使用者用起来更舒服。
在动手实现前,我们先明确三个功能的主要价值。避免盲目开发:
主要前提的观点是,三者均基于 Player 实现。依赖 Foundation 框架,结合文件缓存管理、播放状态监听、资源加载拦截等技术,建立完整的音频播放闭环。
边播边缓存是三个功能的基础,其主要原因是「拦截 Player 的资源请求→下载音频数据→同时供 Player 播放+写入本地缓存」。目前主流的实现方案有三种,各有优劣,我们选择最贴合实战、兼容性最好的方案:
| 实现方案 | 主要原理 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| AssetResourceLoader | 拦截 Player 的资源加载请求,手动管理数据下载、缓存与回填,掌控整个加载流程 | 兼容性好、灵活可控,无需依赖第三方库,可自定义缓存策略 | 需手动处理请求拦截、数据分片,开发成本略高 | 绝大多数音频 App |
| LocalServer | 搭建本地服务器,将网络音频 URL 转为本地服务器 URL,拦截请求后实现缓存 | 适配所有流播放器。无 Player 真机拦截限制 | 需集成第三方库,增加 App 体积,配置复杂 | 多播放器兼容、复杂流媒体场景 |
import Foundation
import Foundation
// 缓存管理单例
class AudioCacheManager: NSObject {
static let shared = AudioCacheManager
private override init {}
// 缓存存储方法
private var cachePath: String {
let cacheDir = NSSearchPathForDirectoriesInDomains.first!let audioCacheDir = cacheDir + "/AudioCache"
if!不过,FileManager.default.fileExists {
try?FileManager.default.createDirectory(atPath: audioCacheDir,withIntermediateDirectories: true)
}
return audioCacheDir
}
// 生成缓存文件方法
private func cacheFilePath -> String {
let md5 = url.absoluteString.md5 // 需自行实现 md5 加密方法
return cachePath + "/\.mp3" // 根据实际格式调整后缀
}
// MARK: - 基础增删改查
func isAudioCached -> Bool {
let filePath = cacheFilePath
return FileManager.default.fileExists
}
func isRangeCached -> Bool {
let filePath = cacheFilePath
guard FileManager.default.fileExists else { return false }
let fileSize = as?Int64),?0
return start>= 0 && end <= fileSize
}
func readCachedData -> Data?{
let filePath = cacheFilePath
guard let handle = try?FileHandle(forReadingFromURL:
URL) else { return nil }
handle.seek)
let data = handle.readData)
try?按理说,handle.close
return data
}
func writeCachedData {
let filePath = cacheFilePath
if!FileManager.default.fileExists {
FileManager.default.createFile
}
guard let handle = try?FileHandle(forWritingToURL:
URL) else { return }
handle.seek)
handle.write
try?handle.close
}
func cachedLength -> Int64 {
let path = cacheFilePath
guard FileManager.default.fileExists else { return 0 }
return as?Int64),?0
}
func deleteCache{
try?FileManager.default.removeItem)
}
func clearAllCache{
try?FileManager.default.removeItem
}
}
// 辅助
:URL md5 加密
extension String{
var md5:String{
let data=Data
var digest=)
data.withUnsafeBytes{_ in CC_MD5。&digest)}
return digest.map{String}.joined
}
}
// 资源加载代理
class AudioResourceLoader:NSObject,AssetResourceLoaderDelegate{
private var originalUrl :URL?private var downloadTask :URLSessionDataTask?private lazy var session :URLSession={
return URLSession
}
// 将 https://xxx.com/audio.mp3 转为自定义 scheme,以触发代理回调
func customUrl->URL{
self.originalUrl=original
var comps=URLComponents!comps.scheme="https-streaming" // 任意自定义 scheme
return comps.url!}
// MARK:- 主要代理回调
func resourceLoader(_ loader:
AssetResourceLoader,didReceive loadingRequest:
AssetResourceLoadingRequest){
guard let origin=originalUrl else{
loadingRequest.finishLoading(with:NSError(domain:"AudioCacheError",code:-1,userInfo:))
return
}
// ---------解析 Range ----------
if let rangeHeader=loadingRequest.request.value(
forHTTPHeaderField:"Range"),let range=parseRangeHeader{
// 检查是否已缓冲该范围
if AudioCacheManager.shared.isRangeCached(
for的观点是,origin,start:Int64。end:Int64){
if let data=AudioCacheManager.shared.readCachedData(
for这方面,origin,start:Int64,end:Int64){
fill(loadingRequest,data:data,start:Int64,end:Int64,totalLength:Int64(AudioCacheManager.shared.cachedLength(for:
origin)))
return
}
}
// 未命中 → 发起分片下载 & 回填
downloadRange(loadingRequest,url:
origin,start:Int64,end:Int64)
}else{
// ---------没有 Range → 完整请求---------
handleFullRequest(loadingRequest,urlOrigin:
origin)
}
}
// -------------------工具方法-------------------
private func parseRangeHeader->(start:Int,end:Int)?怎么说呢,{
guard header.hasPrefix else{return nil}
let rangeStr=header.dropFirst
let parts=rangeStr.split
guard parts.count==2,let s=Int,let e=Int else{return nil}
return
}
private func handleFullRequest(_ request:
AssetResourceLoadingRequest,urlOrigin origin:
URL){
if AudioCacheManager.shared.isAudioCached(for:
origin){
// 已全量缓冲 → 本地读取并回填
let total=AudioCacheManager.shared.cachedLength(
说到for。origin)
if let data=AudioCacheManager.shared.readCachedData(
至于for,origin,start:.zero,end:){
fill,totalLength:Int64)
}else{
request.finishLoading(with:NSError(domain:"AudioCacheError",code:-2,userInfo:))
}
return
}
// 未缓冲 → 发起完整下载任务
var uReq=URLRequest
uReq.httpMethod="GET"
downloadTask=session.dataTask{data,response,error in
guard error==nil,let d=data,response is HTTPURLResponse else{
request.finishLoading(with:error?,NSError(domain:"AudioCacheError",code:-3。
userInfo:))
return
}
AudioCacheManager.shared.writeCachedData(d,for,offset:.zero)
self?.fill(request,data:d,start:.zero。end:Int64,totalLength:Int64)
};downloadTask?.resume
}
private func downloadRange(_ request:
AssetResourceLoadingRequest。url origin:
URL,start:Int64,end:Int64){
var rReq=URLRequest;rReq.httpMethod="GET"
rReq.setValue-\",forHTTPHeaderField:"Range")
downloadTask=session.dataTask{data。resp,error in
guard error==nil,let d=data,resp is HTTPURLResponse else{
request.finishLoading(with:error?,NSError(domain:"AudioCacheError",code:-4,userInfo:))
return
}
AudioCacheManager.shared.writeCachedData(d。for,offset:start)
// 从响应获取总长度,用于 Content‑Range 填写
var totalLen:Int64=-1
if let httpResp=resp as?HTTPURLResponse,let cr=httpResp.value(
forHTTPHeaderField:"Content-Range")。let slashIdx=cr.lastIndex{
totalLen=Int64(cr)?,-1
}
self?.fill(request,data:d,start:start。end:end,totalLength:
totalLen==-1?end+1 : totalLen )
};downloadTask?.resume
}
private func fill(_ request:
AssetResourceLoadingRequest,data:Data,start:Int64,end:Int64。totalLength:Int64){
// 构造响应头部
var hdr=
hdr="audio/mpeg"
hdr="\"
if totalLength> 0{
hdr="bytes \-\/\"
hdr="bytes"
hdr="206"
}else{
hdr="200"
}
if #available{
request.response=
HTTPURLResponse(url:self.originalUrl!statusCode:,httpVersion:nil。headerFields:hdr)!}else{
request.response=
HTTPURLResponse(url:self.originalUrl!statusCode:,httpVersion:nil。headerFields:hdr)!}
request.dataRequest?.respond,request.finishLoading
}
func cancelDownloadTask{ downloadTask?.cancel,downloadTask=nil}
}
class AudioPlayerManager:NSObject{
static let shared = AudioPlayerManager
private override init{ super.init;setup,setupObserver}
// MARK:- 属性
private var player :Player!private var loader :AudioResourceLoader?private var curUrl :URL?private var nextUrl :URL?private var preloadedAsset :Asset?private lazy var cacheMgr = AudioCacheManager.shared
// MARK:- 初始化
private func setup{
player = Player
try?AudioSession.sharedInstance.setCategory(.playback,mode:.default )
try?按理说,AudioSession.sharedInstance.setActive(true,options:.notifyOrsOnDeactivation )
}
// MARK:- 播放进度监听
private func setupObserver{
player.addPeriodicTimeObserver(
forInterval:CMTime(seconds:0.5,preferredTimescale:
CMTimeScale)。queue:.main){ current in
guard let self=self,let item=self.player.currentItem else {return}
/// 剩余时间小于等于 3 秒时触发预加载
if item.duration.seconds - current.seconds <= 3 ,
self.nextUrl != nil , self.preloadedAsset == nil {
self.preloadNext
}
/// 保存当前进度,用于断点续播
if let cur=self.curUrl{
self.saveProgress(url:center ,progress:
current.seconds )
}
}
}
// MARK:- 对外接口
/// 播放网络或本地音频。isLocal 为 true 时直接走本地方法。func play{
curUrl=url
if isLocal{
player.replaceCurrentItem(with:
PlayerItem);player.play,return}
loader = AudioResourceLoader
let custom=loader!.customUrl
/// 使用自定义 scheme 的 asset 并绑定 loader
Let asset = URLAsset
asset.resourceLoader.setDelegate
player.replaceCurrentItem(with:
PlayerItem)
player.play
/// 恢复历史进度
restoreProgressIfNeeded
/// 设置下一首地址,仅保存。不自动播放
func setNext{ nextUrl=url }
/// 手动触发预加载,可在 UI 中提前调用
func preloadNext{
nextUrl=url
/*** 已经完整缓冲 → 本地直接创建 Asset ***/
if cacheMgr.isAudioCached{
preloadedAsset =
URLConnection// 本地文件一样可以用URl…preloadedAsset!.loadValuesAsynchronously(
forKeys:){}
return
}
/*** 未缓冲 → 创建临时 Loader 做分片下载 ***/
Let tempLoader = AudioResourceLoader
Let customTmp=tempLoader.customUrl
Let tmpAsset=URlAsset
tmpAsset.resourceLoader.setDelegate(tempLoader,queue:.
main)
tmpAsset.loadValuesAsynchronously(
fOrKeys:){ in
Guard le tself=self
el se{r eturn}
If tmpAsset.statusOfValue(
foRKey :"playable"。erRor:nil)== .loa ded{
Self.pr e loade dAss et
}}}}
swift
至于第四步,断点续播实现
extension AudioPla
五 、 功 能 调 用 示 列
六 、 避 坑 细 节 :这些问题注意
. 边 播 边 缓 存 避 坑
. ...
以上内容已全部转为 HTML 格式,可直接复制到项目文档或博客页面。
作为专业的SEO优化服务提供商,我们致力于通过科学、系统的搜索引擎优化策略,帮助企业在百度、Google等搜索引擎中获得更高的排名和流量。我们的服务涵盖网站结构优化、内容优化、技术SEO和链接建设等多个维度。
| 服务项目 | 基础套餐 | 标准套餐 | 高级定制 |
|---|---|---|---|
| 关键词优化数量 | 10-20个核心词 | 30-50个核心词+长尾词 | 80-150个全方位覆盖 |
| 内容优化 | 基础页面优化 | 全站内容优化+每月5篇原创 | 个性化内容策略+每月15篇原创 |
| 技术SEO | 基本技术检查 | 全面技术优化+移动适配 | 深度技术重构+性能优化 |
| 外链建设 | 每月5-10条 | 每月20-30条高质量外链 | 每月50+条多渠道外链 |
| 数据报告 | 月度基础报告 | 双周详细报告+分析 | 每周深度报告+策略调整 |
| 效果保障 | 3-6个月见效 | 2-4个月见效 | 1-3个月快速见效 |
我们的SEO优化服务遵循科学严谨的流程,确保每一步都基于数据分析和行业最佳实践:
全面检测网站技术问题、内容质量、竞争对手情况,制定个性化优化方案。
基于用户搜索意图和商业目标,制定全面的关键词矩阵和布局策略。
解决网站技术问题,优化网站结构,提升页面速度和移动端体验。
创作高质量原创内容,优化现有页面,建立内容更新机制。
获取高质量外部链接,建立品牌在线影响力,提升网站权威度。
持续监控排名、流量和转化数据,根据效果调整优化策略。
基于我们服务的客户数据统计,平均优化效果如下:
我们坚信,真正的SEO优化不仅仅是追求排名,而是通过提供优质内容、优化用户体验、建立网站权威,最终实现可持续的业务增长。我们的目标是与客户建立长期合作关系,共同成长。
Demand feedback