扔掉一件东西的时候,其实扔掉的不止是东西,记下一件事情的时候,其实记下的不止是事情
音频缓存系统完整解析
本文档基于项目现有代码整理,梳理缓存系统的整体架构与每一层、每一个类的具体作用。
一、总体概括
项目的缓存系统围绕「边下边播」这一核心需求展开,采用三层缓存架构(L1 / L2 / L3),配合一个资源加载拦截器,将 AVPlayer 的网络请求完整地纳入自定义缓存管理。
整体结构一览
AVPlayer
│
│ 发起资源请求(streaming:// scheme)
▼
XCResourceLoaderManager(拦截器)
│
├──► 命中 L3(完整缓存)→ 直接返回本地文件 URL,不走网络
├──► 命中 L2(临时文件)→ 返回临时文件 URL,可能不完整
├──► 命中 L1(内存分段)→ 直接将分段数据填回 AVPlayer
└──► 全部未命中 → 发起 HTTP 请求,数据边下边写 L1 + L2
│
切歌或下载完成
│
XCAudioCacheManager(主管理器)
│
L1 分段 ──合并──► L2 临时文件
L2 临时文件 ──验证大小──► L3 永久缓存三层缓存对比
| 层级 | 管理类 | 存储位置 | 容量上限 | 生命周期 |
|---|---|---|---|---|
| L1(内存分段) | XCMemoryCacheManager |
NSCache(内存) | 100 MB | 应用存活期间,内存不足时自动清理 |
| L2(临时文件) | XCTempCacheManager |
tmp/MusicTemp/ |
500 MB | 7 天过期,或移入 L3 后删除 |
| L3(永久缓存) | XCPersistentCacheManager |
Library/Caches/MusicCache/ |
1 GB | 长期保留,超限时 LRU 清理 |
常量定义统一放在 XCAudioCacheConst.h,所有数值(容量、分段大小、过期时间)均来自此文件,方便全局调整。
二、常量与枚举(XCAudioCacheConst.h)
这是整个缓存系统的「配置中心」,只有一个头文件,无 .m 文件。
static const NSUInteger kAudioSegmentSize = 512 * 1024; // 单个分段大小:512 KB
static const NSUInteger kAudioCacheMemoryLimit = 100 * 1024 * 1024; // L1 内存上限:100 MB
static const NSUInteger kAudioCacheTempLimit = 500 * 1024 * 1024; // L2 临时上限:500 MB
static const NSUInteger kAudioCacheDiskLimit = 1024 * 1024 * 1024; // L3 磁盘上限:1 GB
static const NSTimeInterval kAudioTempFileExpireTime = 7 * 24 * 60 * 60; // 临时文件过期:7 天还定义了两个枚举:
XCAudioFileCacheState:描述一首歌当前处于哪一层缓存(None / InMemory / TempFile / Complete),主管理器查询时返回此值。XCAudioPreloadPriority:预加载优先级(Low / Normal / High),供预加载管理器排队时使用。
分段大小选 512 KB 的原因:太小会导致请求次数过多;太大会让单次内存占用过高,影响流畅性。512 KB 是在两者之间取的平衡点。
三、L1 层 —— 内存分段缓存
L1 是整个缓存系统响应速度最快的一层,专门用于实时播放期间的数据暂存。
3.1 XCAudioSegmentInfo(分段数据模型)
文件:L1/XCAudioSegmentInfo.h/.m
这是 L1 层存储的最小单位,描述「一段 512 KB 的音频数据」。
| 属性 | 类型 | 含义 |
|---|---|---|
index |
NSInteger | 分段序号,从 0 开始 |
offset |
int64_t | 在完整音频文件中的字节偏移量 |
size |
NSInteger | 本段数据的实际大小(最后一段可能不足 512 KB) |
data |
NSData * | 分段的二进制数据,只存在于内存,不写磁盘 |
isDownloaded |
BOOL | 该段是否已完成下载 |
index 和 offset 的关系:offset = index × kAudioSegmentSize。这让拦截器可以根据 AVPlayer 发来的 Range 请求(bytes=start-end)快速算出需要读取哪几个分段。
3.2 XCMemoryCacheManager(L1 内存缓存管理器)
文件:L1/XCMemoryCacheManager.h/.m
底层存储:NSCache,key 格式为 "{songId}_{segmentIndex}"(如 "2140776005_0")。
核心功能:
存取分段
- (void)storeSegmentData:(NSData *)data forSongId:(NSString *)songId segmentIndex:(NSInteger)segmentIndex;
- (NSData *)segmentDataForSongId:(NSString *)songId segmentIndex:(NSInteger)segmentIndex;拦截器下载到一段数据后立即调用 storeSegmentData:,AVPlayer 需要对应字节范围时调用 segmentDataForSongId: 直接从内存返回,不走磁盘和网络。
获取所有分段(用于合并到 L2)
- (NSArray<XCAudioSegmentInfo *> *)getAllSegmentsForSongId:(NSString *)songId;切歌时,主管理器调用此方法取出所有分段,再流式写入 L2 临时文件。
流式合并写入文件(推荐)
- (BOOL)writeMergedSegmentsToFile:(NSString *)filePath forSongId:(NSString *)songId;使用 NSFileHandle 依次追加,内存中同时只存在一个 512 KB 分段,避免整首歌被一次性加载到内存(对应注释中对 mergeAllSegmentsForSongId: 的警告:大文件不推荐用内存合并)。
优先级管理
- (void)setCurrentSongPriority:(NSString *)songId;标记当前正在播放的歌曲。trimCache 被触发时(内存警告),会跳过该歌曲的分段,优先清理其他歌曲的缓存,确保当前播放不卡顿。
内存警告响应:初始化时监听 UIApplicationDidReceiveMemoryWarningNotification,收到通知后自动调用 trimCache,清理非优先歌曲的所有分段。
四、L2 层 —— 临时文件缓存
L2 的定位是「下载过程中的中间缓冲」。文件可能不完整,等下载完成并验证大小后才迁移到 L3。
4.1 XCTempCacheManager(L2 临时文件管理器)
文件:L2/XCTempCacheManager.h/.m
存储目录:NSTemporaryDirectory()/MusicTemp/
文件命名规则:{songId}_tmp.{ext},例如 2140776005_tmp.m4a
命名规则的重要性:文件名必须以正确的音频格式扩展名结尾,否则 AVPlayer 无法识别编解码器。命名为
xxx_tmp.m4a而非xxx.m4a.tmp,是为了让 AVPlayer 识别最后一个扩展名(.m4a)。这也是 git 日志中「修复缓存后缀名」那次提交解决的核心问题。
核心功能:
追加写入(边下边存)
- (BOOL)writeTempSongData:(NSData *)data forSongId:(NSString *)songId originalURL:(NSURL *)originalURL;每收到一个网络数据包就追加一次,不等下载完成。通过 NSFileHandle 追加模式写入,文件从无到有逐渐增长。
提供文件 URL(可直接给 AVPlayer 播放)
- (NSURL *)tempFileURLForSongId:(NSString *)songId originalURL:(NSURL *)originalURL;即使文件还不完整,AVPlayer 也可以从头开始播放已有的部分。
完整性验证
- (BOOL)isTempFileComplete:(NSString *)songId expectedSize:(NSInteger)expectedSize;对比临时文件的实际大小与 HTTP Content-Length 响应头,判断是否下载完整。只有通过验证才能迁移到 L3。
迁移到 L3
- (BOOL)confirmCompleteAndMoveToCache:(NSString *)songId expectedSize:(NSInteger)expectedSize;先验证,再调用 XCPersistentCacheManager 完成文件移动,移动成功后自动删除 L2 的临时文件。
过期清理
- (NSInteger)cleanExpiredTempFiles; // 清理 7 天前的文件
- (NSInteger)cleanTempFilesOlderThanDays:; // 清理指定天数前的文件应对用户切歌后没有等待下载完成、或者应用异常退出导致临时文件残留的场景。
五、L3 层 —— 永久磁盘缓存
L3 是整个缓存系统的「最终归宿」,只存放下载完整并通过验证的歌曲文件。
5.1 XCAudioSongCacheInfo(L3 缓存元数据模型)
文件:L3/XCAudioSongCacheInfo.h/.m
每一首完整缓存的歌曲在索引中对应一条 XCAudioSongCacheInfo 记录,不存文件本身,只记录元数据。
| 属性 | 类型 | 含义 |
|---|---|---|
songId |
NSString * | 歌曲唯一标识 |
totalSize |
NSInteger | 文件大小(字节) |
cacheTime |
NSTimeInterval | 首次缓存到 L3 的时间 |
lastPlayTime |
NSTimeInterval | 最后一次播放时间,LRU 清理的排序依据 |
playCount |
NSInteger | 播放次数统计 |
md5Hash |
NSString * | 文件 MD5(可选,用于完整性校验) |
updatePlayTime 方法在每次从 L3 播放时调用,更新 lastPlayTime,让 LRU 算法能正确识别「最久未使用」的歌曲。
5.2 XCCacheIndexManager(索引管理器)
文件:L3/XCCacheIndexManager.h/.m
索引文件位置:Library/Caches/MusicCache/index.plist,JSON 格式序列化。
这个类是 L3 层的「账本」,负责 L3 缓存的全部元数据管理,与实际文件 I/O 分离。
核心职责:
- 写入记录:
addSongCacheInfo:—— 文件移入 L3 后登记 - 查询记录:
getSongCacheInfo:—— 检查某首歌是否完整缓存 - 删除记录:
removeSongCacheInfo:—— 删除文件时同步删除索引 - 更新播放时间:
updatePlayTimeForSongId:—— 刷新 LRU 时间戳 - LRU 清理:
cleanCacheToSize:—— 按lastPlayTime升序排列,依次删除最久未播放的歌曲,直到总大小低于目标值
索引与文件操作分离的好处:统计和查询不需要扫描文件系统,直接读内存中的索引字典,速度很快。
5.3 XCPersistentCacheManager(L3 文件管理器)
文件:L3/XCPersistentCacheManager.h/.m
存储目录:Library/Caches/MusicCache/
文件命名规则:{songId}.{ext},例如 2140776005.m4a
这个类负责 L3 层实际文件的 I/O 操作,与 XCCacheIndexManager 配合使用:文件管理器操作磁盘,索引管理器维护元数据,两者通过 songId 关联。
核心方法:
L2 → L3 迁移(最核心)
- (BOOL)moveTempFileToCache:(NSString *)tempFilePath
cachePath:(NSString *)cachePath
forSongId:(NSString *)songId;使用 NSFileManager moveItemAtPath:toPath: 原子移动文件(同分区移动无需复制,速度极快)。移动成功后调用 XCCacheIndexManager 写入索引记录。
直接从文件 URL 或路径读取
- (NSURL *)cachedURLForSongId:(NSString *)songId;
- (NSString *)cachedFilePathForSongId:(NSString *)songId;L3 缓存命中时,返回本地文件 URL,AVPlayer 直接以 file:// 协议播放,无需经过拦截器。
LRU 清理
- (NSInteger)cleanCacheToSize:(NSInteger)targetSize;委托 XCCacheIndexManager 拿到排序后的待删列表,依次调用 deleteCacheForSongId: 删除文件,直到磁盘用量低于目标值。
六、路径管理工具(XCAudioCachePathUtils)
文件:XCAudioCachePathUtils.h/.m
单例工具类,统一管理 L2 和 L3 的目录创建、路径拼接、扩展名推断,避免各个管理类中散落的路径字符串。
三个核心属性(只读):
tempDirectory:tmp/MusicTemp/cacheDirectory:Library/Caches/MusicCache/manifestPath:Library/Caches/MusicCache/index.plist
扩展名推断:
- (NSString *)fileExtensionFromURL:(NSURL *)originalURL;从原始 URL 中提取文件后缀(如 .m4a、.mp3),支持 m4a / mp3 / aac / wav / flac / ogg / wma,无法识别时默认 mp3。这是修复「缓存后缀名导致无法播放」的核心逻辑所在。
路径生成:
tempFilePathForSongId:originalURL:→tmp/MusicTemp/{songId}_tmp.{ext}cacheFilePathForSongId:originalURL:→Library/Caches/MusicCache/{songId}.{ext}
七、缓存主管理器(XCAudioCacheManager)
文件:XCAudioCacheManager.h/.m
这是整个缓存系统对外的唯一入口,播放器、拦截器、预加载管理器都只和它通信,不直接调用 L1/L2/L3 的具体管理器。
.m 文件头有一行注释:
你可以注意到虽然我分了三个文件夹的文件,但是其实最终看的还是这个。感谢解耦。阿门。
内部持有四个成员:
@property XCMemoryCacheManager *memoryManager; // L1
@property XCTempCacheManager *tempManager; // L2
@property XCPersistentCacheManager *persistentManager; // L3
@property XCCacheIndexManager *indexManager; // 索引另外维护一个线程安全的 songURLMap(NSMutableDictionary + dispatch_queue_t),记录每个 songId 对应的原始 URL,用于在后续操作中推断文件扩展名。
关键方法详解
记录原始 URL
- (void)recordOriginalURL:(NSURL *)url forSongId:(NSString *)songId;播放器开始播放网络音频时调用,提前存入 URL 映射,之后各层缓存写文件时都能拿到正确的扩展名。
三级缓存状态查询
- (XCAudioFileCacheState)cacheStateForSongId:(NSString *)songId;查询顺序固定:L3 → L2 → L1 → None。越往前的层级数据越「成熟」(完整性更高),因此优先级更高。
获取可播放的本地 URL
- (NSURL *)cachedURLForSongId:(NSString *)songId;按 L3 → L2 的顺序查找,返回第一个存在的本地 URL。如果 L3 和 L2 都没有,返回 nil(此时需要走网络下载)。L1 的分段数据不直接提供 URL,而是由拦截器拼凑后填给 AVPlayer。
L1 → L2(切歌时的核心流程)
- (BOOL)finalizeCurrentSong:(NSString *)songId;- 调用
memoryManager.getAllSegmentsForSongId:取出所有分段 - 调用
memoryManager.writeMergedSegmentsToFile:流式写入 L2 临时文件 - 不清理 L1,L1 的清理时机留给
confirmCompleteSong:之后
L2 → L3(下载完成时的核心流程)
- (BOOL)confirmCompleteSong:(NSString *)songId expectedSize:(NSInteger)expectedSize;- 调用
tempManager.isTempFileComplete:expectedSize:验证大小 - 验证通过后调用
persistentManager.moveTempFileToCache:移动文件 - 移动成功后清空 L1 分段(
memoryManager.clearSegmentsForSongId:),释放内存
一步到位的切歌方法
- (XCAudioFileCacheState)saveAndFinalizeSong:(NSString *)songId expectedSize:(NSInteger)expectedSize;封装了完整的切歌流程:先 finalizeCurrentSong:(L1→L2),再尝试 confirmCompleteSong:(L2→L3),返回最终的缓存状态。这是播放器切歌时调用的主入口。
LRU 清理
- (NSInteger)cleanCompleteCacheToSize:(NSInteger)targetSize;
- (NSInteger)cleanExpiredTempFiles;委托给 L3 和 L2 各自的管理器执行,主管理器不直接操作文件。
八、资源加载拦截器(XCResourceLoaderManager)
文件:9. 拦截缓存管理/XCResourceLoaderManager.h/.m
这是缓存系统与 AVPlayer 的「接口层」,实现了 AVAssetResourceLoaderDelegate 协议,拦截 AVPlayer 的所有资源加载请求。
工作原理
AVPlayer 只能拦截非标准 scheme 的 URL(标准的 https:// 会被系统直接处理)。所以在把 URL 传给 AVPlayer 之前,先通过下面的方法进行转换:
- (NSURL *)streamingURLFromOriginalURL:(NSURL *)originalURL songId:(NSString *)songId;
// 输入:https://xxx.com/song.m4a
// 输出:streaming://2140776005?url=https%3A%2F%2Fxxx.com%2Fsong.m4a这样 AVPlayer 一发起请求,就会触发 shouldWaitForLoadingOfRequestedResource: 回调,交给拦截器处理。
内部加载任务(XCResourceLoadingTask)
这是拦截器内部维护的任务模型(私有类),每一个 AVPlayer 的加载请求对应一个任务:
| 属性 | 含义 |
|---|---|
loadingRequest |
AVPlayer 的原始请求对象 |
dataTask |
对应的 NSURLSession 网络任务 |
requestedRange |
AVPlayer 请求的字节范围 |
startSegmentIndex |
对应 L1 中的起始分段序号 |
totalContentLength |
文件总长度(来自 HTTP 响应头) |
contentType |
真实的 Content-Type(来自 HTTP 响应头) |
isContentInfoRequest |
是否为 AVPlayer 的「探嗅请求」(bytes=0-1) |
探嗅请求:AVPlayer 在正式播放前会先发一个 Range: bytes=0-1 的小请求,探测文件的总大小和 Content-Type。拦截器需要特殊处理这个请求,正确回填 contentInformationRequest 中的 contentLength 和 contentType,否则 AVPlayer 无法正常播放。
songTotalLengthCache:记录每首歌的总长度,避免从 L1 返回部分数据时因 contentLength 不准确导致音频被截断(这是修复死锁和多段下载时的关键细节)。
请求处理流程
- 解析
streaming://URL,还原songId和原始 URL - 计算请求范围对应的 L1 分段索引
- L3 命中 → 从完整文件读取对应字节范围,直接填回 AVPlayer
- L2 命中 → 从临时文件读取,按需填回
- L1 命中 → 直接从内存分段返回,填回 AVPlayer
- 全部未命中 → 创建
NSURLSessionDataTask,发起 Range HTTP 请求 - 每收到一个数据包:存入 L1(
storeSegmentData:)+ 追加写入 L2(writeTempSongData:)+ 填回当前 AVPlayer 的 loadingRequest
九、预加载管理器(XCPreloadManager)
文件:XCPreloadManager.h/.m
在用户还没点歌的时候,提前把「下一首」的前几个分段加载到 L1,让切歌时的启动延迟降到最低。
XCPreloadTask(预加载任务模型)
一个预加载任务记录:
songId、priority(优先级)、createTime(入队时间)progress、loadedSegments、totalSegments(进度跟踪)progressBlock、completionBlock(回调)dataTask(底层网络任务)executing、completed、cancelled(状态标志)
任务之间通过 comparePriority: 比较优先级,高优先级任务插队到队列前面。
核心功能
预加载控制
- (void)preloadSong:(NSString *)songId priority:(XCAudioPreloadPriority)priority;
- (void)cancelPreloadForSongId:(NSString *)songId;支持带进度回调和完成回调的版本,方便 UI 层显示加载状态。
分段数量限制
@property NSInteger preloadSegmentLimit;
// 设为 3 表示只预加载前 3 个分段(约 1.5 MB)
// 保证「立即可以开始播放」,而不是把整首歌下完才播智能调度
- (void)setCurrentPlayingSong:(NSString *)songId;
- (void)setNextPlayingSong:(NSString *)songId;setCurrentPlayingSong: 通知 XCAudioCacheManager 设置当前优先歌曲(L1 优先级),同时取消已播完歌曲的预加载任务。setNextPlayingSong: 等价于以 High 优先级预加载。
并发控制:maxConcurrentTasks 默认为 1,避免预加载和正在播放的下载抢占带宽。
十、早期内存缓存(XCMusicMemoryCache)
文件:10. 内存缓存/XCMusicMemoryCache.h/.m
这是在三层缓存架构建立之前的早期实现,功能较简单,已被新架构替代,保留作历史参考。
与新架构的主要区别:
- 直接存整首歌的
NSData,没有分段概念,大文件内存压力大 - 提供
localURLForSongId:把内存数据写入临时文件再给 AVPlayer 播放,多了一次磁盘 I/O downloadAndCache:用NSURLSession在后台下载完整歌曲后才存入 NSCache,无法边下边播setCurrentPlayingSong:通过重新setObject:刷新 NSCache 优先级,是对 NSCache LRU 机制的一种手动干预
十一、完整的数据流转图
播放一首新歌(无任何缓存)
用户点击播放
│
▼
播放器构造 streaming:// URL
│
▼
XCResourceLoaderManager 拦截请求
│
├─ recordOriginalURL: 记录原始 URL 到 XCAudioCacheManager
│
├─ 查询 L3 → 未命中
├─ 查询 L2 → 未命中
├─ 查询 L1 → 未命中
│
▼
发起 HTTP Range 请求(bytes=0-512KB)
│
▼
收到数据包(每个 512 KB 分段)
│
├─ storeSegmentData: → L1(NSCache)
└─ writeTempSongData: → L2(临时文件,追加)
│
▼
填回 AVPlayer loadingRequest → 开始播放
│
▼
AVPlayer 继续请求下一个 Range → 重复上面流程切歌(L1 → L2 → L3)
用户切歌
│
▼
saveAndFinalizeSong: (XCAudioCacheManager)
│
├─ finalizeCurrentSong:
│ └─ writeMergedSegmentsToFile: (L1 → L2 临时文件)
│
└─ confirmCompleteSong: (如果 expectedSize 匹配)
├─ isTempFileComplete: (验证大小)
├─ moveTempFileToCache: (L2 临时文件 → L3 永久缓存)
├─ addSongCacheInfo: (更新索引)
└─ clearSegmentsForSongId: (释放 L1 内存)播放已完整缓存的歌(L3 命中)
用户点击播放
│
▼
cacheStateForSongId: → XCAudioFileCacheStateComplete
│
▼
cachedURLForSongId: → file:///Library/Caches/MusicCache/xxx.m4a
│
▼
AVPlayer 直接以 file:// URL 播放,完全不走网络和拦截器
│
▼
updatePlayTimeForSongId: → 刷新 LRU 时间戳L3 缓存超限(LRU 清理)
isCompleteCacheOverLimit: → YES(超过 1 GB)
│
▼
cleanCompleteCacheToSize: (目标:512 MB)
│
▼
XCCacheIndexManager 按 lastPlayTime 升序排列
│
▼
依次删除最久未播放的歌曲文件 + 索引记录
│
▼
直到总大小 < 512 MB十二、文件速查表
| 功能 | 文件(相对于 Spotify - clone/ 目录) |
|---|---|
| 常量与枚举 | 11. 音频缓存/XCAudioCacheConst.h |
| 缓存主管理器 | 11. 音频缓存/XCAudioCacheManager.h/.m |
| 路径管理工具 | 11. 音频缓存/XCAudioCachePathUtils.h/.m |
| 预加载管理器 | 11. 音频缓存/XCPreloadManager.h/.m |
| L1 内存缓存管理器 | 11. 音频缓存/L1/XCMemoryCacheManager.h/.m |
| L1 分段数据模型 | 11. 音频缓存/L1/XCAudioSegmentInfo.h/.m |
| L2 临时文件管理器 | 11. 音频缓存/L2/XCTempCacheManager.h/.m |
| L3 永久缓存管理器 | 11. 音频缓存/L3/XCPersistentCacheManager.h/.m |
| L3 索引管理器 | 11. 音频缓存/L3/XCCacheIndexManager.h/.m |
| L3 缓存元数据模型 | 11. 音频缓存/L3/XCAudioSongCacheInfo.h/.m |
| 资源加载拦截器 | 9. 拦截缓存管理/XCResourceLoaderManager.h/.m |
| 早期内存缓存(已废弃) | 10. 内存缓存/XCMusicMemoryCache.h/.m |
十三、几个值得关注的设计细节
1. 为什么要把 songId 记录在 URL 里(streaming://songId?url=...)
因为 AVPlayer 的 loadingRequest 只携带请求 URL,没有其他上下文。把 songId 编码进 URL,拦截器才能在回调里快速定位是哪首歌的请求,查找对应的 L1 分段。
2. 饿汉式单例(XCResourceLoaderManager)
使用 +load 方法在类加载时就创建实例,而不是懒加载的 dispatch_once。原因是 AVPlayer 的 setDelegate:queue: 必须在第一个 loadingRequest 到达之前设置好,饿汉式保证了初始化的时序。
3. 独立的 delegateQueue 和 taskQueue
两个串行/并发队列分开,taskQueue 用于内部状态管理(字典读写),delegateQueue 与 AVPlayer 的 delegate 队列一致,避免在错误线程调用 respondWithData:/finishLoading: 引发崩溃(之前遇到过死锁问题,git 日志中「解决死锁」那次提交与此相关)。
4. songTotalLengthCache 的作用
记录每首歌的 totalContentLength。当 AVPlayer 的探嗅请求命中 L1 时,如果只从分段推算总长度会不准(分段可能只下载了一部分),导致 AVPlayer 误以为文件比实际短,播放到某个点后截断。单独缓存这个值(在第一次 HTTP 响应头中拿到后存入),后续每次探嗅请求都用这个准确值回复。
💡 有关缓存系统上的问题,欢迎您在底部评论区留言,一起交流~