【iOS】Spotify项目总结

【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的运行方式是:请求到达->启动函数实例->执行代码->返回结果->实例冻结(即运行环境被挂起),这样优化了成本。

延伸的对比两个问题:

  1. HTTP和HTTPS:

HTTPS=HTTP+TLS(传递层安全协议),因此HTTPS本质就是在HTTP和TCP之间加了一层TLS加密层

HTTPS相比HTTP,多了加密(HTTP时明文传输,HTTPS时加密传输,防止数据被窃听)、身份认证(通过CA证书、数字签名,确认服务器是真实的,而不是伪造的)、数据完整性(防止数据被篡改)。

ATS是iOS系统级安全策略,默认要求所有网络请求必须使用HTTPS,默认禁止HTTP、若TLS、不安全证书。苹果要求防止数据泄漏、防止中间人攻击、强制推动HTTPS。

  1. NSURLSession底层原理:

NSURLSession是iOS官方网络框架,底层基于CFNetwork,最终依赖BSD Socket进行TCP通信。AFNetworking是对NSURLSession的二次封装,提供更高层的API

播放架构

在这个项目里,我围绕播放列表管理、全局播放状态、多界面同步、进度系统搭建了一套相对完整的播放器架构。

这里通过使用MVC架构确保实现了单一数据源、播放队列和播放器解耦、全局播放状态同步。

在完成这里时,遇到了以下几个问题:

  1. 播放逻辑与全局状态同步

这里我确保永远只有唯一播放器和唯一播放状态,这样所有页面不再自己管理播放器,不再自己维护播放状态,统一读取PlayerManager,实现了高度解耦。

  1. 进度条拖动回弹

这个播放器冲突问题的本质原因在于用户拖拽进度和AVPlayer timeObserver自动刷新进度同时在修改slider.value。这里我的解决方案是使用一个属性isSliding来区分判断进度条状态。

objc 复制代码
@property(nonatomic, assign) BOOL isSliding;

让timeObserver更新前判断isSliding的值来决定是否自动更新,即拖动期间禁止自动更新;松c手后恢复播放器同步。

效果展示:

  1. 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来实现。主要有以下几个步骤:

  1. 在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;
    }
}
  1. 在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;
}
  1. 最后在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更高效、完整。
  1. 高效性:
  2. 完整性:

    借鉴博客:WCDB入门使用

WCDB数据库的安装

  1. 通过cocopods下载安装WCDB到我们的项目中
  1. 导入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;
}

具体步骤如下:

  1. 让该类遵循WCTTableCoding协议,从而可以进行模型绑定
  2. 在分类中用WCDB_PROPERTY来绑定属性
  3. 在类文件中用WCDB_IMPLEMENTATION定义绑定到数据库表的类
  4. 在类文件中用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 首,超过后自动清理。每次播放缓存歌曲时,我会更新它的最后访问时间,让最近常听的歌曲保留下来。当缓存满时,我会把所有缓存按最后访问时间从小到大排序,把最久没有访问的歌曲缓存文件和数据库记录一起删除,保证缓存空间始终合理,同时保留用户最常听的歌曲。

效果实现:

缓存实现

  1. 通过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
  1. 自动缓存触发逻辑

这里先判断不缓存的情况:

  • 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];
}
  1. 播放优先读取缓存
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;
}

这里具体分为以下几个步骤:

  1. 设置缓存最大数量

超过50首,自动触发LRU淘汰。

objc 复制代码
#define DEFAULT_MAX_CACHE_COUNT 50
  1. 每次播放缓存歌曲时都更新最后访问时间

筛选出最近听过的,播放时间刷新,使得不会被删除。

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];
}
  1. 判断缓存是否已满

超过设置的缓存最大数量,进入LRU淘汰流程。

objc 复制代码
NSInteger currentCount = [[CacheRecordModel shared] getCacheCount];
if (currentCount >= self.maxCacheCount) {
  [self cleanOldCachesToMaxCount:self.maxCacheCount - 1];
}
  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的个人认为比较重要的部分,后续优化等笔者将再补充完善。

相关推荐
鹤卿1231 天前
OC UI ——UIGestureRecognizer 手势识别
ui·ios·objective-c
hhb_6181 天前
Swift技术难点梳理与实战案例解析
开发语言·ios·swift
MonkeyKing1 天前
iOS UICollectionView 高可用架构:复用、预加载、横向嵌套实战详解
ios
冰凌时空1 天前
30 Apps 第 2 天:待办清单 App —— MVVM + Combine 响应式 UI
ios·openai·ai编程
冰凌时空1 天前
手写 Swift 运行时:objc_msgSend 的汇编级解析
ios·openai·ai编程
2601_956002811 天前
AdGuardPro_TS.ipa2026最新版ipa 下载后浏览器无广告 官方正版2026最新版pc免费下载(看到请立即转存 资源随时失效)ios必下
macos·ios·cocoa·ipa
Daniel_Coder1 天前
iOS Widget 开发-12:Widget 深度链接与导航
ios·swiftui·swift·widget·intents
Daniel_Coder1 天前
iOS Widget 开发-11:Widget 交互按钮实战(iOS 17+ App Intents)
ios·swiftui·swift·widget·link·appintents