【iOS】Spotify项目总结
前言
这篇博客总结一下我简历上第一个项目------Spotify。
Serverless
这里我用腾讯云部署了网易云API。
一开始是本地通过localhost:3000直接调试Node.js+Express,后面为了让真机和线上环境可访问,我把服务迁移到了Vercel和腾讯云SCF。
这里有三种API使用方式:
- localhost本地开发:开发最快,调试方便,但仅限模拟器,不能真机测试
objc
NSString *url = @"http://localhost:3000/search?keywords=周杰伦";
- vercel部署:自动HTTPS,自动部署,不需要付费购买服务器
objc
NSString *url = @"https://netease-cloud-music-3svjf1e5u-gulugulu632s-projects.vercel.app/search?keywords=周杰伦";
- 腾讯云SCF云函数部署:国内访问稳定,可真机测试
objc
NSString *url = @"https://1393005825-e2qi36343j.ap-guangzhou.tencentscf.com/search?keywords=周杰伦";
HTTP:全称HyperText Transfer Protocol,本质上是客户端与服务器之间的通信协议。这里的服务端就是Node.js+Express,本质上是HTTP Server,它负责接收请求、解析参数、查询数据、返回JSON。
Node.js:本质是JavaScript Runtime。以前JavaScript只能运行在浏览器,Node.js出现后,JavaScript可以脱离浏览器运行。它提供了文件系统、TCP、HTTP、Socket、进程、Buffer等底层能力。
Express:本质是一个函数,帮我们封装了路由、参数解析、中间件、JSON返回、错误处理。其核心能力是URL路由匹配,即找到url对应哪个函数处理。
TCP:本质是可靠的传输协议。
Socket:可以理解为网络通信端点,即App和服务器之间的"电话线"。
Buffer:是Node.js中用于处理二进制数据的临时内存区域,本质是字节数组。
相比于传统服务器让服务器一直运行,Serverless的运行方式是:请求到达->启动函数实例->执行代码->返回结果->实例冻结(即运行环境被挂起),这样优化了成本。
延伸的对比两个问题:
- HTTP和HTTPS:
HTTPS=HTTP+TLS(传递层安全协议),因此HTTPS本质就是在HTTP和TCP之间加了一层TLS加密层。
HTTPS相比HTTP,多了加密(HTTP时明文传输,HTTPS时加密传输,防止数据被窃听)、身份认证(通过CA证书、数字签名,确认服务器是真实的,而不是伪造的)、数据完整性(防止数据被篡改)。
ATS是iOS系统级安全策略,默认要求所有网络请求必须使用HTTPS,默认禁止HTTP、若TLS、不安全证书。苹果要求防止数据泄漏、防止中间人攻击、强制推动HTTPS。
- NSURLSession底层原理:
NSURLSession是iOS官方网络框架,底层基于CFNetwork,最终依赖BSD Socket进行TCP通信。AFNetworking是对NSURLSession的二次封装,提供更高层的API
播放架构
在这个项目里,我围绕播放列表管理、全局播放状态、多界面同步、进度系统搭建了一套相对完整的播放器架构。
这里通过使用MVC架构确保实现了单一数据源、播放队列和播放器解耦、全局播放状态同步。
在完成这里时,遇到了以下几个问题:
- 播放逻辑与全局状态同步
这里我确保永远只有唯一播放器和唯一播放状态,这样所有页面不再自己管理播放器,不再自己维护播放状态,统一读取PlayerManager,实现了高度解耦。

- 进度条拖动回弹
这个播放器冲突问题的本质原因在于用户拖拽进度和AVPlayer timeObserver自动刷新进度同时在修改slider.value。这里我的解决方案是使用一个属性isSliding来区分判断进度条状态。
objc
@property(nonatomic, assign) BOOL isSliding;
让timeObserver更新前判断isSliding的值来决定是否自动更新,即拖动期间禁止自动更新;松c手后恢复播放器同步。
效果展示:

- MusicBarView和PlayerViewController同步
这里我通过通知传值来确保播放状态相同:PlayerManager播放、暂停或者切歌等发通知,MusicBarView监听到该通知,自动调用updatePlayState来更新底部歌曲、按钮等。
objc
// PlayerManager
- (void)playTrack:(TrackModel *)track {
//...
[[NSNotificationCenter defaultCenter] postNotificationName:@"PlayerStateChanged" object:nil];
[[NSNotificationCenter defaultCenter] postNotificationName:@"TrackDidPlayNotification" object:track];
}
- (void)play {
[self.player play];
self.isPlaying = YES;
[[NSNotificationCenter defaultCenter] postNotificationName:@"PlayerStateChanged" object:nil];
}
- (void)pause {
[self.player pause];
self.isPlaying = NO;
[[NSNotificationCenter defaultCenter] postNotificationName:@"PlayerStateChanged" object:nil];
}
objc
// MusicBarView
- (void)setupNotification {
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updatePlayState) name:@"PlayerStateChanged" object:nil];
}
- (void)updatePlayState {
PlayerManager *playerManager = [PlayerManager sharedManager];
if (!playerManager.currentTrack) {
return;
}
TrackModel *track = playerManager.currentTrack;
[self updateWithTrack:track.trackName artist:track.artist imageURL:track.imageURL];
}
效果展示:

评论区
这个评论区展开收起功能主要是通过UITextView和富文本字符串结合实现的。
展开收起
效果展示:

评论区的长评论笔者使用了UITextView来实现。主要有以下几个步骤:
- 在CommentModel中设置几个属性来判断是否展开的isOpen、实际的文本高度和文本的最大高度。
objc
@property (nonatomic, assign) BOOL isOpen;
@property (nonatomic, assign) CGFloat contentHeight;
@property (nonatomic, assign) CGFloat maxHeight;
objc
- (void)calculateTextHeightsForComments:(NSArray<CommentModel *> *)comments {
UIFont *font = [UIFont systemFontOfSize:15];
CGFloat width = self.view.frame.size.width - 66;
NSString *sampleText = @"这是一行用来计算高度的文本";
CGFloat lineHeight = [sampleText boundingRectWithSize:CGSizeMake(width, MAXFLOAT) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName: font} context:nil].size.height;
for (CommentModel *model in comments) {
model.maxHeight = lineHeight * 3;
model.contentHeight = [model.content boundingRectWithSize:CGSizeMake(width, MAXFLOAT) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName: font} context:nil].size.height;
}
}
- 在CommentTableViewCell中通过判断是否展开,并且添加富文本事件来实现展开与收起。
objc
- (void)configureContentTextViewWithModel:(CommentModel *)model {
NSString *suffixStr = @"";
NSString *displayContent = model.content;
// 实际高度>最大高度,需要添加后缀
if (model.contentHeight > model.maxHeight) {
// 文本展开时是"收起"的按钮
if (model.isOpen) {
suffixStr = @" 收起";
// 内容拼接后缀
displayContent = [model.content stringByAppendingString:suffixStr];
} else {
// 文本收起时是"展开"按钮
suffixStr = @"...展开";
// 内容过长时,只截取前40个字符,避免显示太乱
if (model.content.length > 40) {
displayContent = [[model.content substringToIndex:40] stringByAppendingString:suffixStr];
} else {
// 内容不长,直接拼接后缀
displayContent = [model.content stringByAppendingString:suffixStr];
}
}
}
// 创建富文本并设置字体大小、颜色
NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:displayContent attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:15], NSForegroundColorAttributeName: [UIColor whiteColor]}];
// 给富文本的后缀添加点击事件
if (suffixStr.length > 0) {
// 找到后缀在文本中的位置
NSRange range = [displayContent rangeOfString:suffixStr];
if (range.location != NSNotFound) {
// 自定义协议头,用于识别点击事件
NSString *link = [[NSString stringWithFormat:@"openclose://%@", suffixStr] stringByAddingPercentEncodingWithAllowedCharacters: [NSCharacterSet URLQueryAllowedCharacterSet]];
// 给后缀添加点击链接
[attributedString addAttribute:NSLinkAttributeName value:link range:range];
// 设置后缀文字颜色
[attributedString addAttribute:NSForegroundColorAttributeName value:[UIColor systemBlueColor] range:range];
}
}
self.contentTextView.attributedText = attributedString;
}
- 最后在CommentViewController中调用cell中的方法来完整实现评论区展开收起功能。
objc
- (void)fetchComments {
if (self.isLoading || !self.hasMore) {
return;
}
self.isLoading = YES;
TrackModel *track = [[TrackModel alloc] init];
track.trackID = [NSString stringWithFormat:@"%ld", (long)self.musicId];
[[CommentManager sharedManager] fetchCommentsWithTrack:track offset:self.currentOffset limit:20 completion:^(NSArray<CommentModel *> *comments, NSError *error) {
dispatch_async(dispatch_get_main_queue(), ^{
self.isLoading = NO;
if (error) {
if (self.currentOffset == 0) {
[self updateEmptyLabelWithText:@"评论加载失败"];
}
return;
}
if (comments.count == 0) {
self.hasMore = NO;
if (self.currentOffset == 0) {
[self updateEmptyLabelWithText:@"暂无评论"];
}
return;
}
[self calculateTextHeightsForComments:comments];
if (self.currentOffset == 0) {
self.commentList = [comments mutableCopy];
} else {
[self.commentList addObjectsFromArray:comments];
}
[self.tableView reloadData];
self.currentOffset += comments.count;
[self updateEmptyLabelWithText:@""];
});
}];
}
objc
CommentModel *currentModel = weakSelf.commentList[currentIndexPath.row];
if (currentModel.isOpen == YES) {
currentModel.isOpen = NO;
} else {
currentModel.isOpen = YES;
}
[weakSelf.tableView reloadRowsAtIndexPaths:@[currentIndexPath] withRowAnimation:UITableViewRowAnimationNone];
};
tableView的自适应高度
tableView的自适应高度主要使用第三方库Masorny来实现控制cell之间的距离,让UILabel和UITextView撑开cell的高度,从而达到tableView自适应高度的效果。
通过自定义cell中控件的位置设置让文字撑开cell,做到自适应高度:

然后关闭滚动,并且不设置textView的高度,最终实现自适应高度:
objc
self.contentTextView = [[UITextView alloc] init];
self.contentTextView.editable = NO;
self.contentTextView.selectable = YES;
self.contentTextView.scrollEnabled = NO;
self.contentTextView.backgroundColor = [UIColor clearColor];
self.contentTextView.textColor = [UIColor whiteColor];
self.contentTextView.font = [UIFont systemFontOfSize:15];
self.contentTextView.dataDetectorTypes = UIDataDetectorTypeNone;
self.contentTextView.linkTextAttributes = @{NSForegroundColorAttributeName: [UIColor systemBlueColor]};
[self.contentView addSubview:self.contentTextView];
objc
[self.contentTextView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.nameLabel);
make.top.equalTo(self.nameLabel.mas_bottom).offset(5);
make.right.equalTo(self.contentView).offset(-10);
}];
效果展示:

数据持久化与缓存机制
数据库设计
这里我使用WCDB实现数据持久化来实现了收藏、下载、缓存等功能。按照业务场景创建了多表,避免数据耦合:
- FavoriteMusic:收藏歌曲表
- DownloadMusic:下载歌曲表
- CacheRecord:缓存歌曲表
- PlayHistory:播放历史表
- PlaylistFavorite:歌单收藏表
通过WCDB实现OC对象与数据库的自动映射,避免手写SQL。以trackID作为各表的主键确保歌曲数据唯一,避免重复存储。
WCDB是一个易用、高效、完整的移动数据库框架,它基于SQLite和SQLCipher开发,支持在C++、Java、Kotlin、Swift、objc五种语言环境中使用。
WCDB区别于FMDB的优点有:
- WCDB基本上只需要一行代码就可以完成。
- 不用拼接SQL语句,模型绑定映射也是按照规定模版去实现,方便快捷。
- WCDB比FMDB更高效、完整。
- 高效性:
- 完整性:
借鉴博客:WCDB入门使用
WCDB数据库的安装
- 通过cocopods下载安装WCDB到我们的项目中

- 导入WCDB模版
WCDB官方导入WCDB模版方式:
- 未获取WCDB的github仓库的开发者,执行命令
curl https://raw.githubusercontent.com/Tencent/wcdb/master/tools/templates/install.sh -s | sh- 已获取WCDB的github仓库的开发者,手动执行
cd path-to-your-wcdb-dir/tools/templates; sh install.sh;
安装后重启Xcode,新建文件就可看到对应的文件模版:

这样就可以快速地创建是那个文件了:

WCDB模型绑定
在WCDB中,ORM是指将一个objc的类映射到数据库的表和索引,将类的property映射到数据库表的字段的过程。通过ORM,可以达到直接通过objc进行数据库操作,省去拼装过程的目的。
以收藏歌曲表为例:
objc
#import "FavoriteMusicModel.h"
#import <WCDBObjc/WCDBObjc.h>
@interface FavoriteMusicModel (WCTTableCoding) <WCTTableCoding>
WCDB_PROPERTY(trackID)
WCDB_PROPERTY(trackName)
WCDB_PROPERTY(artist)
WCDB_PROPERTY(imageURL)
WCDB_PROPERTY(playURL)
WCDB_PROPERTY(favoriteTime)
@end
objc
#import "FavoriteMusicModel.h"
#import "FavoriteMusicModel+WCTTableCoding.h"
#import <WCDBObjc/WCDBObjc.h>
@interface FavoriteMusicModel ()
@property (nonatomic, strong) WCTDatabase *database;
@property (nonatomic, strong) NSString *tableName;
@end
@implementation FavoriteMusicModel
WCDB_IMPLEMENTATION(FavoriteMusicModel)
WCDB_SYNTHESIZE(trackID)
WCDB_SYNTHESIZE(trackName)
WCDB_SYNTHESIZE(artist)
WCDB_SYNTHESIZE(imageURL)
WCDB_SYNTHESIZE(playURL)
WCDB_SYNTHESIZE(favoriteTime)
WCDB_PRIMARY(trackID)
static FavoriteMusicModel *sharedInstance = nil;
+ (instancetype)shared {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [[FavoriteMusicModel alloc] init];
[sharedInstance setupDatabase];
});
return sharedInstance;
}
- (instancetype)init {
self = [super init];
if (self) {
self.tableName = @"FavoriteMusic";
}
return self;
}
具体步骤如下:
- 让该类遵循WCTTableCoding协议,从而可以进行模型绑定
- 在分类中用WCDB_PROPERTY来绑定属性
- 在类文件中用WCDB_IMPLEMENTATION定义绑定到数据库表的类
- 在类文件中用WCDB_SYNTHESIZE定义需要绑定到数据库表的字段
WCDB的操作
创建表
objc
- (void)setupDatabase {
NSString *docPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject;
NSString *dbPath = [docPath stringByAppendingPathComponent:@"MusicPlayer.db"];
self.database = [[WCTDatabase alloc] initWithPath:dbPath];
BOOL result = [self.database createTable:self.tableName withClass:FavoriteMusicModel.class];
if (!result) {
NSLog(@"创建收藏表失败");
}
}
增删改查
objc
- (BOOL)addFavoriteWithTrackID:(NSInteger)trackID trackName:(NSString *)trackName artist:(NSString *)artist imageURL:(NSString *)imageURL playURL:(NSString *)playURL {
if (!self.database) {
return NO;
}
if ([self isFavorite:trackID]) {
return YES;
}
FavoriteMusicModel *model = [[FavoriteMusicModel alloc] init];
model.trackID = @(trackID);
model.trackName = trackName;
model.artist = artist;
model.imageURL = imageURL;
model.playURL = playURL;
model.favoriteTime = [NSDate date];
BOOL success = [self.database insertObject:model intoTable:self.tableName];
if (success) {
NSLog(@"收藏成功: %@", trackName);
[[NSNotificationCenter defaultCenter] postNotificationName:@"TrackDidFavoriteNotification" object:nil userInfo:@{@"trackID": @(trackID), @"isFavorite": @(YES)}];
}
return success;
}
objc
- (BOOL)removeFavorite:(NSInteger)trackID {
if (!self.database) {
return NO;
}
BOOL success = [self.database deleteFromTable:self.tableName where:FavoriteMusicModel.trackID == trackID];
if (success) {
NSLog(@"取消收藏: %ld", (long)trackID);
[[NSNotificationCenter defaultCenter] postNotificationName:@"TrackDidFavoriteNotification" object:nil userInfo:@{@"trackID": @(trackID), @"isFavorite": @(NO)}];
}
return success;
}
objc
- (BOOL)updateFavoriteStatus:(NSInteger)trackID isFavorite:(BOOL)isFavorite {
if (!self.database) {
return NO;
}
DownloadMusicModel *model = [self getDownload:trackID];
if (!model) {
return NO;
}
model.isFavorite = isFavorite;
return [self.database updateTable:self.tableName setProperties:DownloadMusicModel.isFavorite toObject:model where:DownloadMusicModel.trackID == trackID];
}
objc
- (BOOL)isFavorite:(NSInteger)trackID {
if (!self.database) {
return NO;
}
FavoriteMusicModel *model =
[self.database getObjectOfClass:FavoriteMusicModel.class fromTable:self.tableName where:FavoriteMusicModel.trackID == trackID];
return model != nil;
}
缓存机制
我的缓存使用LRU最近最少使用淘汰算法。
首先设置最大缓存数量为 50 首,超过后自动清理。每次播放缓存歌曲时,我会更新它的最后访问时间,让最近常听的歌曲保留下来。当缓存满时,我会把所有缓存按最后访问时间从小到大排序,把最久没有访问的歌曲缓存文件和数据库记录一起删除,保证缓存空间始终合理,同时保留用户最常听的歌曲。
效果实现:

缓存实现
- 通过WCDB创建数据库和缓存表
缓存模型字段:
objc
#import <Foundation/Foundation.h>
@interface CacheRecordModel : NSObject
// 歌曲唯一主键
@property (nonatomic, copy) NSString *trackID;
// 歌曲信息
@property (nonatomic, copy) NSString *trackName;
@property (nonatomic, copy) NSString *artist;
@property (nonatomic, copy) NSString *imageURL;
// 本地缓存音频路径
@property (nonatomic, copy) NSString *localFilePath;
// 缓存生成时间
@property (nonatomic, strong) NSDate *cacheTime;
// 最后访问时间
@property (nonatomic, strong) NSDate *lastAccessTime;
// 访问次数
@property (nonatomic, assign) NSInteger accessCount;
@end
- 自动缓存触发逻辑
这里先判断不缓存的情况:
- trackID或者playURL为空
- playURL已经为本地文件路径,即是已下载歌曲
其余歌曲交给CacheManager自动缓存。
objc
- (void)autoCacheTrackIfNeeded:(TrackModel *)track {
if (track.trackID.length == 0 || track.playURL.length == 0) {
return;
}
if ([[NSFileManager defaultManager] fileExistsAtPath:track.playURL]) {
return;
}
[[CacheManager sharedManager] cacheTrack:track];
}
歌曲播放后自动触发缓存:
objc
- (void)playTrack:(TrackModel *)track {
if (!track) {
return;
}
//...
// 添加到播放历史
[self addTrackToHistory:track];
// 自动缓存歌曲
[self autoCacheTrackIfNeeded:track];
[[NSNotificationCenter defaultCenter] postNotificationName:@"PlayerStateChanged" object:nil];
[[NSNotificationCenter defaultCenter] postNotificationName:@"TrackDidPlayNotification" object:track];
}
- 播放优先读取缓存
objc
// 检查是否缓存到本地
NSString *cachedPath = [[CacheManager sharedManager] getCachedPathForTrackID:track.trackID];
NSString *urlString = track.playURL;
if (cachedPath.length > 0) {
urlString = cachedPath;
}
if ([urlString hasPrefix:@"http://"]) {
urlString = [urlString stringByReplacingOccurrencesOfString:@"http://" withString:@"https://"];
}
NSURL *playURL = [self playableURLFromString:urlString];
if (!playURL) {
return;
}
self.currentTrack = track;
MusiclistManager *manager = [MusiclistManager sharedManager];
NSInteger index = [manager.playlist indexOfObject:track];
if (index != NSNotFound) {
manager.currentIndex = index;
}
AVPlayerItem *item = [AVPlayerItem playerItemWithURL:playURL];
if (self.player.currentItem) {
[self.player.currentItem removeObserver:self forKeyPath:@"status"];
}
// 添加监听,监听是否播放成功
[item addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];
[self.player replaceCurrentItemWithPlayerItem:item];
[self.player play];
self.isPlaying = YES;
LRU缓存淘汰策略实现
LRU全称最近最少使用,是开发常用的缓存淘汰策略。核心思想是如果缓存空间已满,优先删除最久没有被访问过的数据,保留最近频繁使用的数据。
LRU的核心规则是:
- 设置最大缓存数量
- 每次访问缓存数据,更新当前数据的最后访问时间
- 当缓存数量超过最大缓存数量时,淘汰最后访问时间最早、最久未使用的缓存
- 始终保证缓存内都是用户近期常听的歌曲
objc
- (nullable NSString *)getCachedPathForTrackID:(NSString *)trackID {
if (!trackID) return nil;
CacheRecordModel *record = [[CacheRecordModel shared] getCache:trackID];
if (record && record.localFilePath) {
if ([self isUsableFileAtPath:record.localFilePath]) {
[[CacheRecordModel shared] updateAccessTime:trackID];
return record.localFilePath;
} else {
[[CacheRecordModel shared] removeCache:trackID];
}
}
return nil;
}
这里具体分为以下几个步骤:
- 设置缓存最大数量
超过50首,自动触发LRU淘汰。
objc
#define DEFAULT_MAX_CACHE_COUNT 50
- 每次播放缓存歌曲时都更新最后访问时间
筛选出最近听过的,播放时间刷新,使得不会被删除。
objc
// 更新时间
- (BOOL)updateAccessTime:(NSString *)trackID {
if (!self.database) {
return NO;
}
CacheRecordModel *model = [self getCache:trackID];
if (!model) {
return NO;
}
model.lastAccessTime = [NSDate date];
model.accessCount = model.accessCount + 1;
return [self.database updateTable:self.tableName setProperties:{CacheRecordModel.lastAccessTime, CacheRecordModel.accessCount} toObject:model where:CacheRecordModel.trackID == trackID];
}
- 判断缓存是否已满
超过设置的缓存最大数量,进入LRU淘汰流程。
objc
NSInteger currentCount = [[CacheRecordModel shared] getCacheCount];
if (currentCount >= self.maxCacheCount) {
[self cleanOldCachesToMaxCount:self.maxCacheCount - 1];
}
- 核心步骤:LRU排序+删除最久未访问
把所有缓存按lastAccessTime从小到大排序,时间最早(即最久没听)的排在最前面,优先删除前面的旧缓存。
objc
// 当缓存满时,清理掉最久没听的旧缓存
- (void)cleanOldCachesToMaxCount:(NSInteger)maxCount {
NSArray *records = [[CacheRecordModel shared] getAllCaches];
// 缓存没满时不用清理
if (records.count <= maxCount) {
return;
}
// LRU算法本体:最后访问时间越旧,越优先淘汰
NSArray *sorted = [records sortedArrayUsingComparator:^NSComparisonResult(CacheRecordModel *obj1, CacheRecordModel *obj2) {
// 处理第一个缓存时间
NSDate *date1;
if (obj1.lastAccessTime) {
date1 = obj1.lastAccessTime;
} else if (obj1.cacheTime) {
date1 = obj1.cacheTime;
} else {
date1 = [NSDate distantPast];
}
// 处理第二个缓存时间
NSDate *date2;
if (obj2.lastAccessTime) {
date2 = obj2.lastAccessTime;
} else if (obj2.cacheTime) {
date2 = obj2.cacheTime;
} else {
date2 = [NSDate distantPast];
}
// 比较时间,优先删除排在最前面的旧缓存
return [date1 compare:date2];
}];
// 计算要删除多少首才能回到上限以内
NSInteger deleteCount = records.count - maxCount;
for (NSInteger i = 0; i < deleteCount; i++) {
CacheRecordModel *record = sorted[i];
NSError *error = nil;
// 删除本地缓存音频文件
[[NSFileManager defaultManager] removeItemAtPath:record.localFilePath error:&error];
if (error) {
NSLog(@"删除缓存文件失败: %@", error.localizedDescription);
}
// 删除数据库里的缓存记录
[[CacheRecordModel shared] removeCache:record.trackID];
NSLog(@"淘汰缓存: %@", record.trackName);
}
}
这里区分一下三种缓存:
- 整首缓存:把一首歌完整下载下来,保存成本地文件。
- 优点:实现简单、适合离线播放、数据库只需要记录本地路径。
- 缺点:必须等整首歌下载完成,如果用户只听几秒就切歌,可能会浪费流量下载整首。
- 流式缓存:边播放边缓存。用户播放时,播放器从网络流式读取音频,同时把已经播放过或已经加载过的数据保存下来。
- 优点:用户听到哪里就缓存到哪里,比整首缓存更省流量。
- 缺点:需要接管播放器的数据加载,实现复杂。
- 分片缓存:把一首歌拆成多个小块缓存,即播放时按需下载和读取对应片段。
- 优点:可以精细控制缓存,支持断点续传。
- 缺点:要合并数据或提供虚拟连续数据流,要维护分片索引,实现复杂。
总结
这篇博客笔者总结了简历上第一个项目Spotify的个人认为比较重要的部分,后续优化等笔者将再补充完善。

