Log Entry

缓存系统完整解析

扔掉一件东西的时候,其实扔掉的不止是东西,记下一件事情的时候,其实记下的不止是事情

音频缓存系统完整解析

本文档基于项目现有代码整理,梳理缓存系统的整体架构与每一层、每一个类的具体作用。


一、总体概括

项目的缓存系统围绕「边下边播」这一核心需求展开,采用三层缓存架构(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;
  1. 调用 memoryManager.getAllSegmentsForSongId: 取出所有分段
  2. 调用 memoryManager.writeMergedSegmentsToFile: 流式写入 L2 临时文件
  3. 不清理 L1,L1 的清理时机留给 confirmCompleteSong: 之后

L2 → L3(下载完成时的核心流程)

- (BOOL)confirmCompleteSong:(NSString *)songId expectedSize:(NSInteger)expectedSize;
  1. 调用 tempManager.isTempFileComplete:expectedSize: 验证大小
  2. 验证通过后调用 persistentManager.moveTempFileToCache: 移动文件
  3. 移动成功后清空 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 不准确导致音频被截断(这是修复死锁和多段下载时的关键细节)。

请求处理流程

  1. 解析 streaming:// URL,还原 songId 和原始 URL
  2. 计算请求范围对应的 L1 分段索引
  3. L3 命中 → 从完整文件读取对应字节范围,直接填回 AVPlayer
  4. L2 命中 → 从临时文件读取,按需填回
  5. L1 命中 → 直接从内存分段返回,填回 AVPlayer
  6. 全部未命中 → 创建 NSURLSessionDataTask,发起 Range HTTP 请求
  7. 每收到一个数据包:存入 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 响应头中拿到后存入),后续每次探嗅请求都用这个准确值回复。

💡 有关缓存系统上的问题,欢迎您在底部评论区留言,一起交流~