OC高仿iOS网易云音乐AFNetworking+SDWebImage+MJRefresh+MVC+MVVM

2022-10-17,,,,

效果

因为OC版本大部分截图和Swift版本一样,所以就不再另外截图了。

列文章目录

因为目录比较多,每次更新这里比较麻烦,所以推荐点击到主页,然后查看iOS音乐专栏。

目简介

这是一个使用OC语言(还有Swift,Android版本),从0开发一个iOS平台,接近企业级的项目(我的云音乐),包含了基础内容,高级内容,项目封装,项目重构等知识;主要是使用系统功能,流行的第三方框架,第三方服务,完成接近企业级商业级项目。

目功能点

隐私协议对话框

启动界面和动态处理权限

引导界面和广告

轮播图和侧滑菜单

首页复杂列表和列表排序

音乐播放和音乐列表管理

全局音乐控制条

桌面歌词和自定义样式

全局媒体控制中心

评论和回复评论

评论富文本点击

评论提醒人和话题

朋友圈动态列表和发布

高德地图定位和路径规划

阿里云OSS上传

视频播放和控制

QQ/微信登录和分享

商城/购物车\微信\支付宝支付

文本和图片聊天

消息离线推送

自动和手动检查更新

内存泄漏和优化

...

发环境概述

2022年5月开发完成的,所以全部都是最新的,平均每3年会重新制作,现在已经是第三版了。

Xcode 13.4
iOS 15

译和运行

先安装pod,用最新Xcode打开MyCloudMusic.xcworkspace,然后运行,如果要运行到真机,先登陆自己的开发者账户,如果不是付费账户,请删除推送等付费功能,更改BundleId,然后运行。

目目录结构

├── MyCloudMusic
│   ├── AppDelegate.h
│   ├── AppDelegate.m
│   ├── Assets.xcassets #资源目录
│   ├── Base.lproj
│   ├── Cell #通用cell
│   ├── Component #每个功能模块
│   │   ├── Ad #广告相关
│   │   ├── Address #收货地址相关
│   ├── Config #配置目录,例如:网络地址配置
│   ├── Controller #通用控制器
│   ├── Extension #扩展,例如:字符串扩展
│   ├── Info.plist
│   ├── Manager #管理器,例如:音乐播放管理器
│   ├── Model #通用模型
│   ├── MyCloudMusic.entitlements
│   ├── Network
│   ├── PrefixHeader.pch
│   ├── Repository #数据仓库,例如:网络请求封装
│   ├── Util #工具类
│   ├── Vender #通过源码方式依赖的第三方框架
│   ├── View #通用View
│   ├── ViewController.h
│   ├── ViewController.m
│   ├── main.m
│   └── zh-Hans.lproj
├── MyCloudMusic.xcodeproj
├── MyCloudMusic.xcworkspace
├── MyCloudMusicTests
│   └── MyCloudMusicTests.m
├── MyCloudMusicUITests
├── Podfile
├── Podfile.lock
├── R.h
├── R.m
└── ixueaeduTestVideo.mp4

赖框架

内容太多,只列出部分。

target 'MyCloudMusic' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks! # Pods for MyCloudMusic
#腾讯开源的UI框架,提供了很多功能,例如:圆角按钮,空心按钮,TextView支持placeholder
#https://github.com/QMUI/QMUIDemo_iOS
#https://qmuiteam.com/ios/get-started
pod "QMUIKit" #https://github.com/SysdataSpA/R.objc
#作者说受R.swift的自由启发,获取自动完成的本地化字符串、资产目录图像名称和故事板对象
pod 'R.objc' #轮播图
#https://github.com/QuintGao/GKCycleScrollView
pod 'GKCycleScrollView' #网络框架
#https://github.com/AFNetworking/AFNetworking
pod 'AFNetworking' #轮播图,多讲解一个是方便大家选择
#https://github.com/wwmz/WMZBanner
pod 'WMZBanner' #https://github.com/91renb/BRPickerView
#封装的是iOS中常用的选择器组件,主要包括:日期选择器
pod 'BRPickerView' #支付宝支付
#https://docs.open.alipay.com/204/105295/
pod 'AlipaySDK-iOS' #融云聊天
#https://doc.rongcloud.cn/im/IOS/5.X/noui/import
pod 'RongCloudIM/IMLib' pod 'JCore' #极光推送
#https://docs.jiguang.cn/jpush/client/iOS/ios_guide_new/
pod 'JPush' #极光统计
#https://docs.jiguang.cn/janalytics/guideline/intro/
pod 'JAnalytics' #webview和js交互框架
#可以直接使用系统提供的api,不是说一定要用框架
#只是用该框架,更方便
#https://github.com/marcuswestin/WebViewJavascriptBridge
pod 'WebViewJavascriptBridge' target 'MyCloudMusicTests' do
inherit! :search_paths
# Pods for testing
end target 'MyCloudMusicUITests' do
# Pods for testing
end end

户协议对话框

使用自定义Dialog实现。

@interface TermServiceDialogController ()<QMUIModalPresentationContentViewControllerProtocol>

@end

@implementation TermServiceDialogController
- (void)initViews{
[super initViews]; self.view.backgroundColor=[UIColor colorDivider];
self.view.myWidth=MyLayoutSize.fill;
self.view.myHeight=MyLayoutSize.wrap; //根容器
self.rootContainer = [[MyLinearLayout alloc] initWithOrientation:MyOrientation_Vert];
self.rootContainer.subviewSpace=0.5;
self.rootContainer.myWidth=MyLayoutSize.fill;
self.rootContainer.myHeight=MyLayoutSize.wrap;
[self.view addSubview:self.rootContainer]; //内容容器
self.contentContainer = [[MyLinearLayout alloc] initWithOrientation:MyOrientation_Vert];
self.contentContainer.subviewSpace=25;
self.contentContainer.myWidth=MyLayoutSize.fill;
self.contentContainer.myHeight=MyLayoutSize.wrap;
self.contentContainer.backgroundColor = [UIColor colorBackground];
self.contentContainer.padding=UIEdgeInsetsMake(PADDING_LARGE2, PADDING_OUTER, PADDING_LARGE2, PADDING_OUTER);
self.contentContainer.gravity=MyGravity_Horz_Center;
[self.rootContainer addSubview:self.contentContainer]; //标题
[self.contentContainer addSubview:self.titleView]; self.textView=[UITextView new];
self.textView.myWidth=MyLayoutSize.fill; //超出的内容,自动支持滚动
self.textView.myHeight=230;
self.textView.text=@"...";
self.textView.backgroundColor = [UIColor clearColor]; //禁用编辑
self.textView.editable=NO; [self.contentContainer addSubview:self.textView]; [self.contentContainer addSubview:self.primaryButton]; //不同意按钮按钮
self.disagreeButton = [ViewFactoryUtil linkButton];
[self.disagreeButton setTitle:R.string.localizable.disagree forState: UIControlStateNormal];
[self.disagreeButton setTitleColor:[UIColor black80] forState:UIControlStateNormal];
[self.disagreeButton addTarget:self action:@selector(disagreeClick:) forControlEvents:UIControlEventTouchUpInside];
[self.disagreeButton sizeToFit];
[self.contentContainer addSubview:self.disagreeButton];
} - (void)show{
self.modalController = [QMUIModalPresentationViewController new];
self.modalController.animationStyle = QMUIModalPresentationAnimationStyleFade; //点击外部不隐藏
[self.modalController setModal:YES]; //边距
self.modalController.contentViewMargins=UIEdgeInsetsMake(PADDING_LARGE2, PADDING_LARGE2, PADDING_LARGE2, PADDING_LARGE2); //设置要显示的内容控件
self.modalController.contentViewController=self; [self.modalController showWithAnimated:YES completion:nil];
} - (void)hide{
[self.modalController hideWithAnimated:YES completion:nil];
} #pragma mark - 创建控件
- (UILabel *)titleView{
if (!_titleView) {
_titleView=[UILabel new];
_titleView.myWidth=MyLayoutSize.fill;
_titleView.myHeight=MyLayoutSize.wrap;
_titleView.text=@"标题";
_titleView.textAlignment=NSTextAlignmentCenter;
_titleView.font=[UIFont boldSystemFontOfSize:TEXT_LARGE3];
_titleView.textColor=[UIColor colorOnSurface];
}
return _titleView;
} - (QMUIButton *)primaryButton{
if (!_primaryButton) {
_primaryButton = [ViewFactoryUtil primaryHalfFilletButton];
[_primaryButton setTitle:R.string.localizable.agree forState:UIControlStateNormal];
}
return _primaryButton;
}
@end

导界面

引导界面比较简单,就是多个图片可以左右滚动。

@interface GuideController ()<GKCycleScrollViewDataSource,GKCycleScrollViewDelegate>
@property (nonatomic, strong) GKCycleScrollView *contentScrollView;
@end @implementation GuideController
- (void)initViews{
[super initViews]; [self initLinearLayoutSafeArea]; //轮播图器容器
MyRelativeLayout *bannerContainer=[MyRelativeLayout new];
bannerContainer.myWidth=MyLayoutSize.fill;
bannerContainer.myHeight=MyLayoutSize.wrap;
bannerContainer.weight=1;
[self.container addSubview:bannerContainer]; //轮播图
_contentScrollView=[GKCycleScrollView new];
_contentScrollView.backgroundColor = [UIColor clearColor];
_contentScrollView.dataSource = self;
_contentScrollView.delegate = self;
_contentScrollView.myWidth = MyLayoutSize.fill;
_contentScrollView.myHeight = MyLayoutSize.fill; //禁用自动滚动
_contentScrollView.isAutoScroll=NO; //不改变透明度
_contentScrollView.isChangeAlpha=NO; _contentScrollView.clipsToBounds = YES;
[bannerContainer addSubview:_contentScrollView]; //按钮容器
MyLinearLayout *controlContainer=[[MyLinearLayout alloc] initWithOrientation:MyOrientation_Horz];
controlContainer.myBottom=PADDING_LARGE2;
controlContainer.myWidth=MyLayoutSize.fill;
controlContainer.myHeight=MyLayoutSize.wrap; //水平拉升,左,中,右间距一样
controlContainer.gravity = MyGravity_Horz_Among;
[self.container addSubview:controlContainer]; //登录注册按钮
QMUIButton *primaryButton = [ViewFactoryUtil primaryButton];
[primaryButton setTitle:R.string.localizable.loginOrRegister forState:UIControlStateNormal];
[primaryButton addTarget:self action:@selector(onPrimaryClick:) forControlEvents:UIControlEventTouchUpInside];
primaryButton.myWidth=BUTTON_WIDTH_MEDDLE;
[controlContainer addSubview:primaryButton];
} - (void)initDatum{
[super initDatum];
self.datum = [NSMutableArray array]; [self.datum addObject:R.image.guide1];
[self.datum addObject:R.image.guide2];
[self.datum addObject:R.image.guide3];
[self.datum addObject:R.image.guide4];
[self.datum addObject:R.image.guide5];
[_contentScrollView reloadData];
} - (void)onPrimaryClick:(QMUIButton *)sender{
[AppDelegate.shared toLogin];
} #pragma mark 轮播图数据源 /// 有多少个
/// @param cycleScrollView <#cycleScrollView description#>
- (NSInteger)numberOfCellsInCycleScrollView:(GKCycleScrollView *)cycleScrollView{
return self.datum.count;
} /// 返回cell
/// @param cycleScrollView <#cycleScrollView description#>
/// @param index <#index description#>
- (GKCycleScrollViewCell *)cycleScrollView:(GKCycleScrollView *)cycleScrollView cellForViewAtIndex:(NSInteger)index {
GKCycleScrollViewCell *cell = [cycleScrollView dequeueReusableCell];
if (!cell) {
cell = [GKCycleScrollViewCell new];
} UIImage *data=[self.datum objectAtIndex:index]; cell.imageView.image = data;
cell.imageView.contentMode = UIViewContentModeScaleAspectFit; return cell;
}
@end

广告界面

实现图片广告和视频广告,广告数据是在首页是缓存到本地,目的是在启动界面加载更快,因为真实项目中,大部分项目启动页面广告时间一共就5秒,如果太长了用户体验不好,如果是从网络请求,那么网络可能就耗时2秒左右,所以导致就美哟多少时间显示广告了。

广告

func downloadAd(_ data:Ad,_ path:URL) {
let destination: DownloadRequest.Destination = { _, _ in
return (path, [.removePreviousFile, .createIntermediateDirectories])
} AF.download(data.icon.absoluteUri(), to: destination).response { response in
if response.error == nil, let filePath = response.fileURL?.path {
print("ad downloaded success \(filePath)")
}
}
}

广告

-(void)showVideoAd:(NSURL *)data{
//播放应用内嵌入视频,放根目录中
//同样其他的文件,也可以通过这种方式读取
//data = [NSBundle.mainBundle URLForResource:@"ixueaeduTestVideo" withExtension:@".mp4"]; _player = [AVPlayer playerWithURL:data]; //静音
_player.muted = YES; /// 添加进度监听
__weak typeof(self) weakSelf = self;
[_player addPeriodicTimeObserverForInterval:CMTimeMake(1.0, 1.0) queue:dispatch_get_main_queue() usingBlock:^(CMTime time) {
//当前时间,秒
Float64 current=CMTimeGetSeconds(weakSelf.player.currentItem.currentTime); //总时间
CGFloat duration = CMTimeGetSeconds(weakSelf.player.currentItem.duration); if (current==duration) {
//视频播放结束
[weakSelf next];
} else {
[weakSelf.skipView setTitle:[R.string.localizable skipAdCount:(NSInteger)(duration-current)] forState:UIControlStateNormal];
weakSelf.skipView.myWidth=MyLayoutSize.wrap;
[weakSelf.skipView setNeedsLayout]; }
}]; [self.player play]; //显示图像
self.playerLayer = [AVPlayerLayer playerLayerWithPlayer:self.player]; //从中心等比缩放,完全显示控件
self.playerLayer.videoGravity = AVLayerVideoGravityResizeAspectFill; [self.view.layer insertSublayer:self.playerLayer atIndex:0];
}

显示图片就是显示本地图片了,没什么难点,就不贴代码了。

首页/歌单详情/黑胶唱片界面

首页没有顶部是轮播图,然后是可以左右的菜单,接下来是热门歌单,推荐单曲,最后是首页排序模块;整体上使用RecycerView实现,轮播图:

//轮播图
BannerCell *cell = [tableView dequeueReusableCellWithIdentifier:BannerCellName forIndexPath:indexPath]; //绑定数据
[cell bind:data]; return cell;

详情

顶部是歌单信息,通过Cell实现,底部是列表,显示歌单内容的音乐,点击音乐进入黑胶唱片播放界面。

@implementation SheetDetailController

- (void)initViews{
[super initViews];
//添加背景图片控件
_backgroundImageView = [UIImageView new]; //默认隐藏
_backgroundImageView.clipsToBounds = YES;
_backgroundImageView.alpha = 0;
_backgroundImageView.contentMode = UIViewContentModeScaleAspectFill;
[self.view addSubview:self.backgroundImageView]; ... //注册歌单信息
[self.tableView registerClass:[SheetInfoCell class] forCellReuseIdentifier:SheetInfoCellName]; //注册section
[self.tableView registerClass:[SongGroupHeaderView class] forHeaderFooterViewReuseIdentifier:SongGroupHeaderViewName]; //注册单曲
[self.tableView registerClass:[SongCell class] forCellReuseIdentifier:SongCellName];
} - (void)initListeners{
[super initListeners];
@weakify(self); //点击事件
[QTSubMain(self,ClickEvent) next:^(ClickEvent *event) {
@strongify(self);
[self processClick:event.style];
}];
} ... -(void)loadData:(BOOL)isPlaceholder{
[[DefaultRepository shared] sheetDetailWithId:_id success:^(BaseResponse * _Nonnull baseResponse, id _Nonnull data) {
[self show:data];
}];
} -(void)show:(Sheet *)data{
self.data=data; [ImageUtil show:self.backgroundImageView uri:data.icon]; //使用动画显示背景图片
[UIView animateWithDuration:0.3 animations:^{
//透明度设置为1
self.backgroundImageView.alpha=1;
}]; [self.datum removeAllObjects]; //第一组
SongGroupData *groupData=[SongGroupData new];
NSMutableArray *tempArray = [NSMutableArray new];
[tempArray addObject:data];
groupData.datum=tempArray;
[self.datum addObject:groupData]; if (data.songs) {
//有音乐才设置 //设置数据
groupData=[SongGroupData new];
NSMutableArray *tempArray = [NSMutableArray new];
[tempArray addObjectsFromArray:data.songs];
[tempArray addObjectsFromArray:data.songs];
groupData.datum=tempArray;
[self.datum addObject:groupData];
} [self.tableView reloadData];
} /// 播放音乐
/// @param data <#data description#>
-(void)play:(Song *)data{
//把当前歌单所有音乐设置到播放列表
//有些应用
//可能会实现添加到已经播放列表功能
[[MusicListManager shared] setDatum:self.data.songs]; //播放当前音乐
[[MusicListManager shared] play:data]; [self startMusicPlayerController];
} /// 有多少组
/// @param tableView <#tableView description#>
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView{
return self.datum.count;
} /// 当前组有多少个
/// @param tableView <#tableView description#>
/// @param section <#section description#>
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
SongGroupData *groupData=self.datum[section];
return groupData.datum.count;
} /// 返回section view
/// @param tableView <#tableView description#>
/// @param section <#section description#>
- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section{
__weak __typeof(self)weakSelf = self; //取出组数据
SongGroupData *groupData=self.datum[section]; //获取header
SongGroupHeaderView *header=[tableView dequeueReusableHeaderFooterViewWithIdentifier: SongGroupHeaderViewName]; [header setPlayAllClickBlock:^{
__strong __typeof(weakSelf)strongSelf = weakSelf; if (strongSelf.datum.count>0) {
return;
} SongGroupData *groupData=strongSelf.datum[1];
Song *data= groupData.datum[0]; [strongSelf play:data];
}]; //绑定数据
[header bind:groupData]; //返回header
return header;
} /// 返回当前位置的cell
/// 相当于Android中RecyclerView Adapter的onCreateViewHolder
/// @param tableView <#tableView description#>
/// @param indexPath <#indexPath description#>
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
SongGroupData *groupData=self.datum[indexPath.section];
NSObject *data= groupData.datum[indexPath.row]; //获取类型
ListStyle style=[self typeForItemAtData:data]; switch (style) {
case StyleSheet:{
//歌单
SheetInfoCell *cell = [tableView dequeueReusableCellWithIdentifier:SheetInfoCellName forIndexPath:indexPath]; [cell bind:data]; return cell;
}
...
} } /// Cell类型
- (ListStyle)typeForItemAtData:(NSObject *)data{ if([data isKindOfClass:[Sheet class]]){
//歌单信息
return StyleSheet;
} return StyleSong;
} /// header高度
/// @param tableView <#tableView description#>
/// @param section <#section description#>
- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section{
if (section==1) {
return 50;
} //其他组不显示section
return 0;
}
@end

唱片

上面是黑胶唱片,和网易云音乐差不多,随着音乐滚动或暂停,顶部是控制相关,音乐播放逻辑是封装到MusicPlayerManager中:

@implementation MusicPlayerManager

/// 获取单例对象
+(instancetype)shared{
static MusicPlayerManager *sharedInstance = nil;
if (!sharedInstance) {
sharedInstance = [[self alloc] init];
}
return sharedInstance; } - (instancetype)init{
if (self=[super init]) {
self.player = [[AVPlayer alloc] init]; //默认状态
self.status = PlayStatusNone;
}
return self;
} - (void)play:(NSString *)uri data:(Song *)data{
//设置音频会话
[SuperAudioSessionManager requestAudioFocus]; //更改播放状态
_status = PlayStatusPlaying; //保存音乐对象
self.data = data; NSURL *url=nil;
if ([uri hasPrefix:@"http"]) {
//网络地址
url=[NSURL URLWithString:uri];
} else {
//本地地址
url=[NSURL fileURLWithPath:uri];
} //创建一个播放Item
AVPlayerItem *item = [[AVPlayerItem alloc] initWithURL:url]; //替换掉原来的播放Item
[self.player replaceCurrentItemWithPlayerItem:item]; //播放
[self.player play]; ...
} -(void)prepareLyric{
//歌词处理
//真实项目可能会
//将歌词这个部分拆分到其他组件中
if (_data.parsedLyric) {
//解析好了
[self onLyricReady];
} else if(_data.lyric) {
//有歌词,但是没有解析
[self parseLyric];
}else{
//没有歌词,并且不是本地音乐才请求 //真实项目中可以会缓存歌词
//获取歌词数据
[[DefaultRepository shared] songDetailWithId:_data.id success:^(BaseResponse * _Nonnull baseResponse, id _Nonnull d) {
//请求成功
Song *data=d;
self.data.style=data.style;
self.data.lyric=data.lyric; [self parseLyric];
}];
}
} -(void)parseLyric{
if ([StringUtil isNotBlank:self.data.lyric]) {
//有歌词 //在这里解析的好处是
//外面不用管,直接使用
self.data.parsedLyric = [LyricParser parse:self.data.style data:self.data.lyric];
} //通知歌词准备好了
[self onLyricReady];
} -(void)onLyricReady{
if (self.delegate) {
[self.delegate onLyricReady:_data];
}
} -(void)initListeners{
//KVO方式监听播放状态
//KVC:Key-Value Coding,另一种获取对象字段的值,类似字典
//KVO:Key-Value Observing,建立在KVC基础上,能够观察一个字段值的改变
[self.player.currentItem addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil]; //监听音乐缓冲状态
[self.player.currentItem addObserver:self
forKeyPath:@"loadedTimeRanges" options:NSKeyValueObservingOptionNew
context:nil]; //播放结束事件
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(onComplete:)
name:AVPlayerItemDidPlayToEndTimeNotification
object:self.player.currentItem];
} /// 播放完毕了回调
- (void)onComplete:(NSNotification *)notification {
self.complete(_data);
} /// 移除监听器
-(void)removeListeners{
[self.player.currentItem removeObserver:self forKeyPath:@"status" context:nil];
[self.player.currentItem removeObserver:self forKeyPath:@"loadedTimeRanges" context:nil]; // [[NSNotificationCenter defaultCenter] removeObserver:self];
} /// KVO监听回调方法
/// @param keyPath <#keyPath description#>
/// @param object <#object description#>
/// @param change <#change description#>
/// @param context <#context description#>
-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
//判断监听的字段
if ([keyPath isEqualToString:@"status"]) {
switch (self.player.status) {
case AVPlayerStatusReadyToPlay:
{
//准备播放完成了
//音乐的总时间
self.data.duration= CMTimeGetSeconds(self.player.currentItem.asset.duration); LogDebugTag(MusicPlayerManagerTag, @"observeValue status ReadyToPlay duration:%f",self.data.duration); //回调代理
if (self.delegate) {
[self.delegate onPrepared:_data];
} //更新媒体控制中心信息
[self updateMediaInfo]; }
break;
case AVPlayerStatusFailed:
{
//播放失败了
_status = PlayStatusError; LogDebugTag(MusicPlayerManagerTag, @"observeValue status play error");
}
break; default:{
//未知状态
LogDebugTag(MusicPlayerManagerTag, @"observeValue status unknown");
_status = PlayStatusNone;
}
break;
} }
...
} - (void)startPublishProgress{
//判断是否启动了
if (_playTimeObserve) {
//已经启动了
return;
} @weakify(self); //1/60秒,就是16毫秒
self.playTimeObserve=[self.player addPeriodicTimeObserverForInterval:CMTimeMake(1.0, 60) queue:dispatch_get_main_queue() usingBlock:^(CMTime time) {
@strongify(self); //当前播放的时间
self.data.progress = CMTimeGetSeconds(time); //判断是否有代理
if (!self.delegate) {
//没有回调
//停止定时器
[self stopPublishProgress];
return;
} //回调代理
[self.delegate onProgress:self.data]; ...
} - (void)stopPublishProgress{
if (self.playTimeObserve) {
[self.player removeTimeObserver:self.playTimeObserve];
self.playTimeObserve=nil;
} } - (BOOL)isPlaying{
return _status == PlayStatusPlaying;
} - (void)pause{
//更改状态
_status = PlayStatusPause; //暂停
[self.player pause]; //移除监听器
[self removeListeners]; //回调代理
if (self.delegate) {
[self.delegate onPaused:_data];
} //停止进度分发定时器
[self stopPublishProgress];
} - (void)resume{
//设置音频会话
[SuperAudioSessionManager requestAudioFocus]; //更改播放状态
_status = PlayStatusPlaying; //播放
[self.player play]; ...
} - (void)seekTo:(float)data{
[self.player seekToTime:CMTimeMake(data, 1.0) toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero];
} #pragma mark - 媒体中心 /// 更新系统媒体控制中心信息
/// 不需要更新进度到控制中心
/// 他那边会自动倒计时
/// 这部分可以重构到公共类,因为像播放视频也可以更新到系统媒体中心
-(void)updateMediaInfo{
//下载图片,这部分应该封装,因为其他界面也用到了
SDWebImageManager *manager =[SDWebImageManager sharedManager]; NSURL *url= [NSURL URLWithString:[ResourceUtil resourceUri:self.data.icon]]; [manager loadImageWithURL:url options:SDWebImageProgressiveLoad progress:^(NSInteger receivedSize, NSInteger expectedSize, NSURL * _Nullable targetURL) {
//进度,这里用不到
} completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, SDImageCacheType cacheType, BOOL finished, NSURL * _Nullable imageURL) {
NSLog(@"load song image success");
if (image!=NULL) {
[self setMediaInfo:image];
}
}];
} - (void)setMediaInfo:(UIImage *)image{
//初始化一个可变字典
NSMutableDictionary *songInfo=[[NSMutableDictionary alloc] init]; //初始化一个封面
MPMediaItemArtwork *albumArt=[[MPMediaItemArtwork alloc] initWithBoundsSize:image.size requestHandler:^UIImage * _Nonnull(CGSize size) {
return image;
}]; //设置封面
[songInfo setObject:albumArt forKey:MPMediaItemPropertyArtwork]; ... //设置到系统
[[MPNowPlayingInfoCenter defaultCenter] setNowPlayingInfo:songInfo];
} - (void)setDelegate:(id<MusicPlayerManagerDelegate>)delegate{
_delegate = delegate;
if (_delegate) {
//有代理 //判断是否有音乐在播放
if ([self isPlaying]) {
//有音乐在播放 //启动定时器
[self startPublishProgress];
}
} else {
//没有代理 //停止定时器
[self stopPublishProgress];
}
}
@end

音乐列表逻辑封装到MusicListManager:

@implementation MusicListManager
static MusicListManager *sharedInstance = nil; - (instancetype)init
{
self = [super init];
if (self) {
_datum=[[NSMutableArray alloc] init]; //初始化音乐播放管理器
self.musicPlayerManager=[MusicPlayerManager shared]; __weak typeof(self)weakSelf = self; //设置播放完毕回调
[self.musicPlayerManager setComplete:^(Song * _Nonnull data) { //判断播放循环模式
if ([weakSelf getLoopModel] == MusicPlayRepeatModelOne) {
//单曲循环
[weakSelf play:weakSelf.data];
} else {
//其他模式
[weakSelf play:[weakSelf next]];
}
}]; self.model=MusicPlayRepeatModelList; [self initPlayList];
}
return self;
} /// 获取单例对象
+(instancetype)shared{
if (!sharedInstance) {
sharedInstance = [[self alloc] init];
}
return sharedInstance;
} /// 设置默认播放音乐
-(void)defaultPlaySong{
_data=_datum[0];
} /// 设置播放列表
- (void)setDatum:(NSArray *)datum{
//将原来数据playList标志设置为false
[DataUtil changePlayListFlag:_datum inList:NO]; //保存到数据库
[self saveAll]; //清空原来的数据
[_datum removeAllObjects]; //添加新的数据
[_datum addObjectsFromArray:datum]; //更改播放列表标志
[DataUtil changePlayListFlag:_datum inList:YES]; //保存到数据库
[self saveAll]; [self sendMusicListChanged];
} /// 保存当前播放列表到数据库
-(void)saveAll{
[[SuperDatabaseManager shared] saveAllSong:_datum];
} -(void)sendMusicListChanged{
MusicListChangedEvent *event = [[MusicListChangedEvent alloc] init];
[QTEventBus.shared dispatch:event];
} /**
* 获取播放列表
*/
- (NSArray *)getDatum{
return _datum;
} /**
* 播放
*/
- (void)play:(Song *)data{
self.data = data; //标记为播放了
self.isPlay = YES; NSString *path; //查询是否有下载任务
DownloadInfo *downloadInfo=[[AppDelegate.shared getDownloadManager] findDownloadInfo:data.id];
if (downloadInfo != nil && downloadInfo.status == DownloadStatusCompleted) {
//下载完成了 //播放本地音乐
path = [[StorageUtil documentUrl] URLByAppendingPathComponent:downloadInfo.path].path; LogDebugTag(MusicListManagerTag, @"MusicListManager play offline:%@ %@",path,data.uri);
} else {
//播放在线音乐
path = [ResourceUtil resourceUri:data.uri]; LogDebugTag(MusicListManagerTag, @"MusicListManager play online:%@ %@",path,data.uri);
} [_musicPlayerManager play:path data:data]; //设置最后播放音乐的Id
[PreferenceUtil setLastPlaySongId:_data.id];
} /**
* 暂停
*/
- (void)pause{
LogDebugTag(MusicListManagerTag, @"pause");
[_musicPlayerManager pause];
} ... /// 更改循环模式
- (MusicPlayRepeatModel)changeLoopModel{
//循环模式+1
_model++; //判断循环模式边界
if (_model > MusicPlayRepeatModelRandom) {
//如果当前循环模式
//大于最后一个循环模式
//就设置为第0个循环模式
_model = MusicPlayRepeatModelList;
} //返回最终的循环模式
return _model;
} /**
* 获取循环模式
*/
- (MusicPlayRepeatModel)getLoopModel{
return _model;
} - (Song *)getData{
return self.data;
} /**
* 获取上一个
*/
- (Song *)previous{
//音乐索引
NSUInteger index = 0; //判断循环模式
switch (self.model) {
case MusicPlayRepeatModelRandom:{
//随机循环 //在0~datum.size()中
//不包含datum.size()
index = arc4random() % [_datum count];
}
break;
default:{
//找到当前音乐索引
index = [_datum indexOfObject:self.data]; if (index != -1) {
//找到了 //如果当前播放是列表第一个
if (index == 0) {
//第一首音乐 //那就从最后开始播放
index = [_datum count] - 1;
} else {
index--;
}
} else {
//抛出异常
//因为正常情况下是能找到的 }
}
break;
} //获取音乐
return [_datum objectAtIndex:index];
} ...
@end

外界统一使用播放列表管理器播放音乐,上一曲下一曲:

-(void)onLoopModelClick:(UIButton *)sender{
//更改循环模式
[[MusicListManager shared] changeLoopModel]; //显示循环模式
[self showLoopModel]; } -(void)onPreviousClick:(UIButton *)sender{
[[MusicListManager shared] play: [[MusicListManager shared] previous]];
} -(void)onPlayClick:(UIButton *)sender{
[self playOrPause];
} /// 播放或暂停
-(void)playOrPause{
if ([[MusicPlayerManager shared] isPlaying]) {
[[MusicListManager shared] pause];
} else {
[[MusicListManager shared] resume];
}
} -(void)onNextClick:(UIButton *)sender{
[[MusicListManager shared] play: [[MusicListManager shared] next]];
}

歌词

歌词实现了LRC,KSC两种歌词,封装到LyricListView,单个歌词行封装到LyricView中,外界直接使用LyricListView就行:

/// 显示歌词数据
-(void)showLyricData{
_lyricView.data = [[MusicListManager shared] getData].parsedLyric;
}

歌词控件封装:

@implementation LyricListView

- (instancetype)init{
self=[super init]; self.datum = [NSMutableArray array]; [self initViews]; return self;
} - (void)initViews{
//设置约束
self.myWidth = MyLayoutSize.fill;
self.myHeight = MyLayoutSize.fill; //tableView
self.tableView = [ViewFactoryUtil tableView];
self.tableView.delegate = self;
self.tableView.dataSource = self;
[self addSubview:self.tableView]; //注册歌词cell
[self.tableView registerClass:[LyricCell class] forCellReuseIdentifier:Cell]; //创建一个水平方向容器
_lyricDragContainer = [[MyLinearLayout alloc] initWithOrientation:MyOrientation_Horz];
_lyricDragContainer.visibility = MyVisibility_Gone;
_lyricDragContainer.myHorzMargin = PADDING_OUTER;
_lyricDragContainer.myWidth = MyLayoutSize.fill;
_lyricDragContainer.myHeight = MyLayoutSize.wrap; ... //分割线
UIView *dividerView = [ViewFactoryUtil smallDivider];
dividerView.weight=1;
dividerView.backgroundColor = [UIColor colorLightWhite];
[_lyricDragContainer addSubview:dividerView]; //时间
_timeView = [UILabel new];
_timeView.myWidth = MyLayoutSize.wrap;
_timeView.myHeight = MyLayoutSize.wrap;
_timeView.text = @"00:00";
_timeView.textColor = [UIColor colorLightWhite];
[_lyricDragContainer addSubview:_timeView];
} - (void)setData:(Lyric *)data{
_data=data; if (_lyricPlaceholderSize > 0) {
//已经计算了填充数量
[self next];
}
} - (void)next{
//清空原来的歌词
[_datum removeAllObjects]; if (_data) {
//添加占位数据
[self addLyricFillData];
[_datum addObjectsFromArray:_data.datum]; //添加占位数据
[self addLyricFillData];
} _isReloadData=YES;
[_tableView reloadData];
} /// 添加歌词占位数据
/// 添加的目的是让第一行歌词也能显示到控件垂直方向中心
-(void)addLyricFillData {
for (int i=0; i<_lyricPlaceholderSize; i++) {
[_datum addObject:@"fill"];
}
} - (void)setProgress:(float)progress{
if(!_isReloadData && _lyricPlaceholderSize > 0){
//还没有加载数据 //所以这里加载数据
[self next];
} if (_data && _datum.count>0) {
if (_isDrag) {
//正在拖拽歌词
//就直接返回
return;
} //获取当前时间对应的歌词索引
NSInteger newLineNumber = [LyricUtil getLineNumber:_data progress:progress] + _lyricPlaceholderSize; if (newLineNumber != _lyricLineNumber) {
//滚动到当前行
[self scrollPosition:newLineNumber]; _lyricLineNumber = newLineNumber;
} //如果是精确到字歌曲
//还需要将时间分发到item中
//因为要持续绘制
if (_data.isAccurate) {
NSObject *object = _datum[_lyricLineNumber];
if ([object isKindOfClass:[LyricLine class]]) {
//只有是歌词行才处理 //获取当前时间是该行的第几个字
NSInteger lyricCurrentWordIndex=[LyricUtil getWordIndex:object progress:progress]; //获取当前时间改字
//已经播放的时间
NSInteger wordPlayedTime=[LyricUtil getWordPlayedTime:object progress:progress]; //获取cell
LyricCell *cell= [self getCell:self.lyricLineNumber]; if (cell) {
//有可能获取不到当前位置的Cell
//因为上面使用了滚动动画
//如果不使用滚动动画效果不太好 //将当前时间对应的字索引设置到控件
[cell.lineView setLyricCurrentWordIndex:lyricCurrentWordIndex]; //设置当前字已经播放的时间
[cell.lineView setWordPlayedTime:wordPlayedTime]; //标记需要绘制
[cell.lineView setNeedsDisplay];
} }
}
}
} ... #pragma mark - 列表数据源
/// 有多少个
/// @param tableView <#tableView description#>
/// @param section <#section description#>
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
return _datum.count;
} /// 返回当前位置的cell
/// @param tableView <#tableView description#>
/// @param indexPath <#indexPath description#>
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
//获取cell
LyricCell *cell=[tableView dequeueReusableCellWithIdentifier:Cell forIndexPath:indexPath]; //设置Tag
cell.tag = indexPath.row; //取出数据
NSObject *data = _datum[indexPath.row]; //绑定数据
[cell bind:data accurate:_data.isAccurate]; //返回cell
return cell;
} #pragma mark - 滚动相关 /// 开始拖拽时调用
/// @param scrollView <#scrollView description#>
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{
LogDebugTag(LyricListViewTag, @"scrollViewWillBeginDragging");
[self showDragView];
} /// 拖拽结束
/// @param scrollView <#scrollView description#>
/// @param decelerate <#decelerate description#>
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
NSLog(@"lyric view scrollViewDidEndDragging:%d",decelerate); if (!decelerate) {
//如果不需要减速,就延时后,显示歌词
[self prepareScrollLyricView];
}
} /// 滚动结束(惯性滚动)
/// @param scrollView <#scrollView description#>
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
NSLog(@"lyric view scrollViewDidEndDecelerating");
//如果需要减速,在这里延时后,显示歌词
[self prepareScrollLyricView];
} ...
@end

控制器

使用了可以通过系统媒体控制器,通知栏,锁屏界面,耳机,蓝牙耳机等设备控制媒体播放暂停,只需要把媒体信息更新到系统:

- (void)setMediaInfo:(UIImage *)image{
//初始化一个可变字典
NSMutableDictionary *songInfo=[[NSMutableDictionary alloc] init]; //初始化一个封面
MPMediaItemArtwork *albumArt=[[MPMediaItemArtwork alloc] initWithBoundsSize:image.size requestHandler:^UIImage * _Nonnull(CGSize size) {
return image;
}]; //设置封面
[songInfo setObject:albumArt forKey:MPMediaItemPropertyArtwork]; //歌曲名称
[songInfo setObject:self.data.title forKey:MPMediaItemPropertyTitle]; ... //设置到系统
[[MPNowPlayingInfoCenter defaultCenter] setNowPlayingInfo:songInfo];
}

媒体控制

/// 接收远程音乐播放控制消息
/// 例如:点击耳机上的按钮,点击媒体控制中心按钮等
/// @param event <#event description#>
- (void)remoteControlReceivedWithEvent:(UIEvent *)event{
//判断是不是远程控制事件
if (event.type == UIEventTypeRemoteControl) {
if ([[MusicListManager shared] getData] == nil) {
//当前播放列表中没有音乐
return;
} //判断事件类型
switch (event.subtype) {
case UIEventSubtypeRemoteControlPlay:{
//点击了播放按钮
[[MusicListManager shared] resume];
NSLog(@"AppDelegate play");
}
break;
case UIEventSubtypeRemoteControlPause:{
//点击了暂停
[[MusicListManager shared] pause];
NSLog(@"AppDelegate pause");
}
break;
case UIEventSubtypeRemoteControlNextTrack:{
//下一首
//双击iPhone有线耳机上的控制按钮
Song *song = [[MusicListManager shared] next];
[[MusicListManager shared] play:song];
NSLog(@"AppDelegate Next");
}
break;
...
default:
break;
}
}
}

登录/注册/验证码登录

登录注册没有多大难度,用户名和密码登录,就是把信息传递到服务端,可以加密后在传输,服务端判断登录成功,返回一个标记,客户端保存,其他需要的登录的接口带上;验证码登录就是用验证码代替密码,发送验证码都是服务端发送,客户端只需要调用接口。

评论

评论列表包括下拉刷新,上拉加载更多,点赞,发布评论,回复评论,Emoji,话题和提醒人点击,选择好友,选择话题等。

刷新和下拉加载更多

核心逻辑就只需要更改page就行了

//下拉刷新
MJRefreshNormalHeader *header=[MJRefreshNormalHeader headerWithRefreshingBlock:^{
@strongify(self);
[self loadData];
}]; //隐藏标题
header.stateLabel.hidden = YES; // 隐藏时间
header.lastUpdatedTimeLabel.hidden = YES;
self.tableView.mj_header=header; //上拉加载更多
MJRefreshAutoNormalFooter *footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{
@strongify(self);
[self loadMore];
}]; // 设置空闲时文字
[footer setTitle:@"" forState:MJRefreshStateIdle]; self.tableView.mj_footer = footer;

人和话题点击

通过正则表达式,找到特殊文本,然后使用富文本实现点击。

/// 处理文本点击事件
/// 这部分可以用监听器回调到界面处理
/// @param data <#data description#>
-(NSAttributedString *)processContent:(NSString *)data{
return [RichUtil processContent:data mentionClick:^(UIView * _Nonnull containerView, NSAttributedString * _Nonnull text, NSRange range, CGRect rect) {
NSString *clickText = [RichUtil processClickText:data range:range];
LogDebugTag(CommentCellTag, @"processContent mention click %@",clickText); if (self.nicknameClickBlock) {
self.nicknameClickBlock(clickText);
}
} hashTagClick:^(UIView * _Nonnull containerView, NSAttributedString * _Nonnull text, NSRange range, CGRect rect) {
NSString *clickText = [RichUtil processClickText:data range:range];
LogDebugTag(CommentCellTag, @"processContent hash click %@",clickText); if (self.TagClickBlock) {
self.TagClickBlock(clickText);
}
}];
}

好友

@implementation UserController

- (void)initViews{
[super initViews]; //初始化TableView结构
[self initTableViewSafeArea]; [self.tableView registerClass:[TopicCell class] forCellReuseIdentifier:Cell];
} - (void)initDatum{
[super initDatum]; if (self.style==StyleFriend || self.style==StyleSelect) {
//好友
[self setTitle:R.string.localizable.myFriend];
} else {
//粉丝
[self setTitle:R.string.localizable.myFans];
} [self loadData];
} - (void)loadData:(BOOL)isPlaceholder{
DefaultRepository *repository=[DefaultRepository shared]; if (self.style==StyleFriend || self.style==StyleSelect) {
//好友
[repository friends:[PreferenceUtil getUserId] success:^(BaseResponse * _Nonnull baseResponse, Meta * _Nonnull meta, NSArray * _Nonnull data) {
[self show:data];
}];
} else {
//粉丝
[repository fans:[PreferenceUtil getUserId] success:^(BaseResponse * _Nonnull baseResponse, Meta * _Nonnull meta, NSArray * _Nonnull data) {
[self show:data];
}];
}
} -(void)show:(NSArray *)data{
[self.datum removeAllObjects];
[self.datum addObjectsFromArray:data];
[self.tableView reloadData];
} #pragma mark - 列表数据源 /// 返回当前位置的cell
/// 相当于Android中RecyclerView Adapter的onCreateViewHolder
/// @param tableView <#tableView description#>
/// @param indexPath <#indexPath description#>
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{ User *data= self.datum[indexPath.row]; TopicCell *cell=[tableView dequeueReusableCellWithIdentifier:Cell forIndexPath:indexPath]; [cell bindWithUser:data]; return cell; } - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
User *data=self.datum[indexPath.row]; if (self.style==StyleSelect) {
//选择
SelectUserEvent *event = [[SelectUserEvent alloc] init];
event.data=data;
[QTEventBus.shared dispatch:event]; [self finish];
}else{ [UserDetailController start:self.navigationController id:data.id];
}
} #pragma mark - 启动界面
+(void)start:(UINavigationController *)controller style:(ListStyle)style{
UserController *target=[UserController new];
target.style=style;
[controller pushViewController:target animated:YES];
} @end

视频和播放

真实项目中视频播放大部分都是用第三方服务,例如:阿里云视频服务,腾讯视频服务,因为他们提供一条龙服务,包括审核,转码,CDN,安全,播放器等,这里用不到这么多功能,所以使用了第三方播放器播放普通mp4,这使用饺子播放器框架。

-(void)play:(Video *)data{
// //不开防盗链
// SuperPlayerModel *model = [[SuperPlayerModel alloc] init];
//
// //播放腾讯云视频
// // 配置 AppId
//// model.appId = 0;
////
//// model.videoId = [[SuperPlayerVideoId alloc] init];
//// model.videoId.fileId = "5285890799710670616"; // 配置 FileId
//
// //停止播放
// [_playerView removeVideo];
//
// //直接使用url播放
// model.videoURL = [ResourceUtil resourceUri:data.uri];
//
// [_playerView playWithModel:model];
//
// //设置标题
// [self.playerView.controlView setTitle:data.title];
}

用户详情/更改资料

用户详情顶部显示用户信息,好友数量,下面分别显示创建的歌单,收藏的歌单,发布的动态,类似微信朋友圈,右上角可以更改用户资料;使用第三方框架里面的kJXPagingListRefreshView控件实现。

-(void)initUI{
[self.container removeAllSubviews]; //头部控件
_userHeaderView = [[UserDetailHeaderView alloc] init]; [_userHeaderView setFollowBlock:^{
[self loginAfter:^{
[self onFollowClick];
}];
}]; [_userHeaderView setSendMessageBlock:^{
[ChatController start:self.navigationController id:self.data.id];
}]; //指示器
_categoryView = [[JXCategoryTitleView alloc] initWithFrame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, SIZE_INDICATOR_HEIGHT)]; //标题
self.categoryView.titles = @[R.string.localizable.sheet, R.string.localizable.feed]; self.categoryView.backgroundColor = [UIColor clearColor];
self.categoryView.delegate = self; //选择的颜色
self.categoryView.titleSelectedColor = [UIColor colorPrimary]; //默认颜色
self.categoryView.titleColor = [UIColor colorOnSurface]; //选中是否放大
self.categoryView.titleLabelZoomEnabled = NO; //指示器下面那条线
JXCategoryIndicatorLineView *lineView = [[JXCategoryIndicatorLineView alloc] init]; //选中颜色
lineView.indicatorColor = [UIColor colorPrimary];
lineView.indicatorWidth = 30;
self.categoryView.indicators = @[lineView]; self.pagerView = [[JXPagerListRefreshView alloc] initWithDelegate:self];
self.pagerView.mainTableView.gestureDelegate = self;
self.pagerView.myWidth=MyLayoutSize.fill;
self.pagerView.myHeight=MyLayoutSize.fill;
[self.container addSubview:self.pagerView]; self.categoryView.listContainer = (id<JXCategoryViewListContainer>)self.pagerView.listContainerView;
}

然后就是把每个子界面放到单独View中,并在代理方法返回就行了。

发布动态/选择位置/路径规划

发布效果和微信朋友圈类似,可以选择图片,和地理位置;地理位置使用高德地图实现选择,路径规划是调用系统中安装的地图,类似微信。

位置

/// 搜索该位置的poi,方便用户选择,也方便其他人找
-(void)searchPOI{
//LogDebug(@"searchPOI %f %f %@",data.);
if (_keyword) {
//关键字搜索
AMapPOIKeywordsSearchRequest *request = [AMapPOIKeywordsSearchRequest new]; //关键字
request.keywords=_keyword; //距离排序
request.sortrule = 0; //是否返回扩展信息
request.requireExtension=YES; [self.search AMapPOIKeywordsSearch:request]; } else {
//搜索位置附近
AMapPOIAroundSearchRequest *request = [AMapPOIAroundSearchRequest new];
request.location=[AMapGeoPoint locationWithLatitude:_coordinate.latitude longitude:_coordinate.longitude]; //距离排序
request.sortrule=0; //是否返回扩展信息
request.requireExtension=YES; [self.search AMapPOIAroundSearch:request];
}
}

地图路径规划

+ (void)amapPathPlan:(NSString *)title latitude:(double)latitude longitude:(double)longitude{
NSString *result=[NSString stringWithFormat:@"iosamap://path?sourceApplication=我的云音乐&backScheme=weichat&dlat=%f&dlon=%f&dname=%@",latitude,longitude,title];
[SuperApplicationUtil open:result];
}

聊天/离线推送

大部分真实项目中聊天都会选择第三方商业级付费聊天服务,常用的有腾讯云聊天,融云聊天,网易云聊天等,这里选择融云聊天服务,使用步骤是先在服务端生成聊天Token,这里是登录后返回,然后客户端登录聊天服务器,然后设置消息监听,发送消息等。

聊天服务器

/// 连接聊天服务器
/// @param data <#data description#>
-(void)connectChat:(Session *)data{
[[RCIMClient sharedRCIMClient] connectWithToken:data.chatToken dbOpened:^(RCDBErrorCode code) {
//消息数据库打开,可以进入到主页面
} success:^(NSString *userId) {
//连接成功
} error:^(RCConnectErrorCode status) {
if (status == RC_CONN_TOKEN_INCORRECT) {
//从 APP 服务获取新 token,并重连
} else {
//无法连接到 IM 服务器,请根据相应的错误码作出对应处理
} //因为我们这个应用,不是类似微信那样纯聊天应用,所以聊天服务器连接失败,也让进入应用
//真实项目中按照需求实现就行了
[SuperToast showWithTitle:R.string.localizable.errorMessageLogin];
}];
}

消息监听

- (void)onReceived:(RCMessage *)message left:(int)nLeft object:(id)object{
dispatch_async(dispatch_get_main_queue(), ^{
//切换到主线程 if ([message.targetId isEqualToString:self.currentChatUserId]) {
//正在和这个人聊天
}else{
//其他消息显示到通知栏
[NotificationUtil showMessage:message];
} //发送消息到通知(这个通知是,跨界面通讯,不是显示到通知栏)
[NSNotificationCenter.defaultCenter postNotificationName:ON_MESSAGE object:nil userInfo:@{@"data":message}]; //发送消息未读数改变了通知
[NSNotificationCenter.defaultCenter postNotificationName:ON_MESSAGE_COUNT_CHANGED object:nil userInfo:nil];
});
}

文本消息

发送图片等其他消息也是差不多。

/// 发送文本消息
-(void)sendTextMessage{
NSString *result=_contentInputView.text; if([StringUtil isBlank:result]){
[SuperToast showWithTitle:R.string.localizable.hintEnterMessage];
return;
} //1.构造文本消息
RCTextMessage *txtMsg = [RCTextMessage messageWithContent:result]; //2.将文本消息发送出去
[[RCIMClient sharedRCIMClient] sendMessage:ConversationType_PRIVATE
targetId:self.id
content:txtMsg
pushContent:nil
pushData:[MessageUtil createPushData:[MessageUtil getContent:txtMsg] targetId:[PreferenceUtil getUserId]]
success:^(long messageId) { NSLog(@"消息发送成功,message id 为 %@",@(messageId)); dispatch_async(dispatch_get_main_queue(), ^{
//清空输入框
[self clearInput];
}); [self addMessage:[[RCIMClient sharedRCIMClient] getMessage:messageId]]; } error:^(RCErrorCode nErrorCode, long messageId) { NSLog(@"消息发送失败,错误码 为 %@",@(nErrorCode)); }];
}

离线推送

需要付费苹果开发者账户,先开启SDK离线推送,然后在苹果开发者后台创建推送证书,配置到融云,最后在代码中处理通知点击等。

/// 界面已经显示了
/// @param animated <#animated description#>
- (void)viewDidAppear:(BOOL)animated{
[super viewDidAppear:animated]; //延时的目的是让当前界面显示出来以后,在检查
//检查是否需要处理通知点击
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(500 * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{
//检查是否需要处理通知点击
[self checkProcessNotificationClick];
});
} /// 检查是否需要处理通知点击
-(void)checkProcessNotificationClick{
if ([AppDelegate shared].pushData) {
[self processPushClick:[AppDelegate shared].pushData]; [AppDelegate shared].pushData=nil;
}
}

商城/订单/支付/购物车

学到这里,大家不能说熟悉,那么看到上面的界面,那么大体要能实现出来。

详情富文本

//详情
self.detailView = [QMUITextView new];
self.detailView.myWidth = MyLayoutSize.fill;
self.detailView.myHeight = MyLayoutSize.wrap;
self.detailView.delegate=self;
self.detailView.scrollEnabled=NO;
self.detailView.editable=NO; //去除左右边距
self.detailView.textContainer.lineFragmentPadding = 0; //去除上下边距
self.detailView.textContainerInset = UIEdgeInsetsZero;
[self.contentContainer addSubview:self.detailView];

宝/微信支付

客户端先集成微信,支付宝SDK,然后请求服务端获取支付信息,设置到SDK,最后就是处理支付结果。

/// 处理支付宝支付
/// @param data <#data description#>
- (void)processAlipay:(NSString *)data{
//支付宝官方开发文档:https://docs.open.alipay.com/204/105295/
[[AlipaySDK defaultService] payOrder:data fromScheme:ALIPAY_CALLBACK_SCHEME callback:^(NSDictionary *resultDic) {
//如果手机中没有安装支付宝客户端
//会跳转H5支付页面
//支付相关的信息会通过这个方法回调 //处理支付宝支付结果
[self processAlipayResult:resultDic];
}];
}

支付结果

/// 处理支付宝支付结果
/// @param data <#data description#>
- (void)processAlipayResult:(NSDictionary *)data{
NSString *resultStatus=data[@"resultStatus"]; if ([@"9000" isEqualToString:resultStatus]) {
//本地支付成功 //不能依赖本地支付结果
//一定要以服务端为准
[SuperToast showLoading:R.string.localizable.hintPayWait]; [self checkPayStatus]; //这里就不根据服务端判断了
//购买成功统计
[AnalysisUtil onPurchase:YES data:self.data];
}if ([@"6001" isEqualToString:resultStatus]) {
//取消了
[self showCancel];
} else {
//支付失败
[self showPayFailedTip];
}
}

项目总结

总体来说项目功能还是很全的,还有一些小功能,例如:快捷方式等就不在贴代码了,但肯定没发和原版比,相信大家只要做过程序员就能理解,毕竟原版是一个商业级项目,几十个人天天开发和维护,而且持续了几年了;不过恕我直言,现在的常见的音乐软件都太复杂了,各种功能,不过都要恰饭,好像又能理解了。

OC高仿iOS网易云音乐AFNetworking+SDWebImage+MJRefresh+MVC+MVVM的相关教程结束。

《OC高仿iOS网易云音乐AFNetworking+SDWebImage+MJRefresh+MVC+MVVM.doc》

下载本文的Word格式文档,以方便收藏与打印。