效果

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

列文章目录

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

目简介

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

目功能点

隐私协议对话框

启动界面和动态处理权限

引导界面和广告

轮播图和侧滑菜单

首页复杂列表和列表排序

音乐播放和音乐列表管理

全局音乐控制条

桌面歌词和自定义样式

全局媒体控制中心

评论和回复评论

评论富文本点击

评论提醒人和话题

朋友圈动态列表和发布

高德地图定位和路径规划

阿里云OSS上传

视频播放和控制

QQ/微信登录和分享

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

文本和图片聊天

消息离线推送

自动和手动检查更新

内存泄漏和优化

...

发环境概述

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

  1. Xcode 13.4
  2. iOS 15

译和运行

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

目目录结构

  1. ├── MyCloudMusic
  2.    ├── AppDelegate.h
  3.    ├── AppDelegate.m
  4.    ├── Assets.xcassets #资源目录
  5.    ├── Base.lproj
  6.    ├── Cell #通用cell
  7.    ├── Component #每个功能模块
  8.       ├── Ad #广告相关
  9.       ├── Address #收货地址相关
  10.    ├── Config #配置目录,例如:网络地址配置
  11.    ├── Controller #通用控制器
  12.    ├── Extension #扩展,例如:字符串扩展
  13.    ├── Info.plist
  14.    ├── Manager #管理器,例如:音乐播放管理器
  15.    ├── Model #通用模型
  16.    ├── MyCloudMusic.entitlements
  17.    ├── Network
  18.    ├── PrefixHeader.pch
  19.    ├── Repository #数据仓库,例如:网络请求封装
  20.    ├── Util #工具类
  21.    ├── Vender #通过源码方式依赖的第三方框架
  22.    ├── View #通用View
  23.    ├── ViewController.h
  24.    ├── ViewController.m
  25.    ├── main.m
  26.    └── zh-Hans.lproj
  27. ├── MyCloudMusic.xcodeproj
  28. ├── MyCloudMusic.xcworkspace
  29. ├── MyCloudMusicTests
  30.    └── MyCloudMusicTests.m
  31. ├── MyCloudMusicUITests
  32. ├── Podfile
  33. ├── Podfile.lock
  34. ├── R.h
  35. ├── R.m
  36. └── ixueaeduTestVideo.mp4

赖框架

内容太多,只列出部分。

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

户协议对话框

使用自定义Dialog实现。

  1. @interface TermServiceDialogController ()<QMUIModalPresentationContentViewControllerProtocol>
  2. @end
  3. @implementation TermServiceDialogController
  4. - (void)initViews{
  5. [super initViews];
  6. self.view.backgroundColor=[UIColor colorDivider];
  7. self.view.myWidth=MyLayoutSize.fill;
  8. self.view.myHeight=MyLayoutSize.wrap;
  9. //根容器
  10. self.rootContainer = [[MyLinearLayout alloc] initWithOrientation:MyOrientation_Vert];
  11. self.rootContainer.subviewSpace=0.5;
  12. self.rootContainer.myWidth=MyLayoutSize.fill;
  13. self.rootContainer.myHeight=MyLayoutSize.wrap;
  14. [self.view addSubview:self.rootContainer];
  15. //内容容器
  16. self.contentContainer = [[MyLinearLayout alloc] initWithOrientation:MyOrientation_Vert];
  17. self.contentContainer.subviewSpace=25;
  18. self.contentContainer.myWidth=MyLayoutSize.fill;
  19. self.contentContainer.myHeight=MyLayoutSize.wrap;
  20. self.contentContainer.backgroundColor = [UIColor colorBackground];
  21. self.contentContainer.padding=UIEdgeInsetsMake(PADDING_LARGE2, PADDING_OUTER, PADDING_LARGE2, PADDING_OUTER);
  22. self.contentContainer.gravity=MyGravity_Horz_Center;
  23. [self.rootContainer addSubview:self.contentContainer];
  24. //标题
  25. [self.contentContainer addSubview:self.titleView];
  26. self.textView=[UITextView new];
  27. self.textView.myWidth=MyLayoutSize.fill;
  28. //超出的内容,自动支持滚动
  29. self.textView.myHeight=230;
  30. self.textView.text=@"...";
  31. self.textView.backgroundColor = [UIColor clearColor];
  32. //禁用编辑
  33. self.textView.editable=NO;
  34. [self.contentContainer addSubview:self.textView];
  35. [self.contentContainer addSubview:self.primaryButton];
  36. //不同意按钮按钮
  37. self.disagreeButton = [ViewFactoryUtil linkButton];
  38. [self.disagreeButton setTitle:R.string.localizable.disagree forState: UIControlStateNormal];
  39. [self.disagreeButton setTitleColor:[UIColor black80] forState:UIControlStateNormal];
  40. [self.disagreeButton addTarget:self action:@selector(disagreeClick:) forControlEvents:UIControlEventTouchUpInside];
  41. [self.disagreeButton sizeToFit];
  42. [self.contentContainer addSubview:self.disagreeButton];
  43. }
  44. - (void)show{
  45. self.modalController = [QMUIModalPresentationViewController new];
  46. self.modalController.animationStyle = QMUIModalPresentationAnimationStyleFade;
  47. //点击外部不隐藏
  48. [self.modalController setModal:YES];
  49. //边距
  50. self.modalController.contentViewMargins=UIEdgeInsetsMake(PADDING_LARGE2, PADDING_LARGE2, PADDING_LARGE2, PADDING_LARGE2);
  51. //设置要显示的内容控件
  52. self.modalController.contentViewController=self;
  53. [self.modalController showWithAnimated:YES completion:nil];
  54. }
  55. - (void)hide{
  56. [self.modalController hideWithAnimated:YES completion:nil];
  57. }
  58. #pragma mark - 创建控件
  59. - (UILabel *)titleView{
  60. if (!_titleView) {
  61. _titleView=[UILabel new];
  62. _titleView.myWidth=MyLayoutSize.fill;
  63. _titleView.myHeight=MyLayoutSize.wrap;
  64. _titleView.text=@"标题";
  65. _titleView.textAlignment=NSTextAlignmentCenter;
  66. _titleView.font=[UIFont boldSystemFontOfSize:TEXT_LARGE3];
  67. _titleView.textColor=[UIColor colorOnSurface];
  68. }
  69. return _titleView;
  70. }
  71. - (QMUIButton *)primaryButton{
  72. if (!_primaryButton) {
  73. _primaryButton = [ViewFactoryUtil primaryHalfFilletButton];
  74. [_primaryButton setTitle:R.string.localizable.agree forState:UIControlStateNormal];
  75. }
  76. return _primaryButton;
  77. }
  78. @end

导界面

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

  1. @interface GuideController ()<GKCycleScrollViewDataSource,GKCycleScrollViewDelegate>
  2. @property (nonatomic, strong) GKCycleScrollView *contentScrollView;
  3. @end
  4. @implementation GuideController
  5. - (void)initViews{
  6. [super initViews];
  7. [self initLinearLayoutSafeArea];
  8. //轮播图器容器
  9. MyRelativeLayout *bannerContainer=[MyRelativeLayout new];
  10. bannerContainer.myWidth=MyLayoutSize.fill;
  11. bannerContainer.myHeight=MyLayoutSize.wrap;
  12. bannerContainer.weight=1;
  13. [self.container addSubview:bannerContainer];
  14. //轮播图
  15. _contentScrollView=[GKCycleScrollView new];
  16. _contentScrollView.backgroundColor = [UIColor clearColor];
  17. _contentScrollView.dataSource = self;
  18. _contentScrollView.delegate = self;
  19. _contentScrollView.myWidth = MyLayoutSize.fill;
  20. _contentScrollView.myHeight = MyLayoutSize.fill;
  21. //禁用自动滚动
  22. _contentScrollView.isAutoScroll=NO;
  23. //不改变透明度
  24. _contentScrollView.isChangeAlpha=NO;
  25. _contentScrollView.clipsToBounds = YES;
  26. [bannerContainer addSubview:_contentScrollView];
  27. //按钮容器
  28. MyLinearLayout *controlContainer=[[MyLinearLayout alloc] initWithOrientation:MyOrientation_Horz];
  29. controlContainer.myBottom=PADDING_LARGE2;
  30. controlContainer.myWidth=MyLayoutSize.fill;
  31. controlContainer.myHeight=MyLayoutSize.wrap;
  32. //水平拉升,左,中,右间距一样
  33. controlContainer.gravity = MyGravity_Horz_Among;
  34. [self.container addSubview:controlContainer];
  35. //登录注册按钮
  36. QMUIButton *primaryButton = [ViewFactoryUtil primaryButton];
  37. [primaryButton setTitle:R.string.localizable.loginOrRegister forState:UIControlStateNormal];
  38. [primaryButton addTarget:self action:@selector(onPrimaryClick:) forControlEvents:UIControlEventTouchUpInside];
  39. primaryButton.myWidth=BUTTON_WIDTH_MEDDLE;
  40. [controlContainer addSubview:primaryButton];
  41. }
  42. - (void)initDatum{
  43. [super initDatum];
  44. self.datum = [NSMutableArray array];
  45. [self.datum addObject:R.image.guide1];
  46. [self.datum addObject:R.image.guide2];
  47. [self.datum addObject:R.image.guide3];
  48. [self.datum addObject:R.image.guide4];
  49. [self.datum addObject:R.image.guide5];
  50. [_contentScrollView reloadData];
  51. }
  52. - (void)onPrimaryClick:(QMUIButton *)sender{
  53. [AppDelegate.shared toLogin];
  54. }
  55. #pragma mark 轮播图数据源
  56. /// 有多少个
  57. /// @param cycleScrollView <#cycleScrollView description#>
  58. - (NSInteger)numberOfCellsInCycleScrollView:(GKCycleScrollView *)cycleScrollView{
  59. return self.datum.count;
  60. }
  61. /// 返回cell
  62. /// @param cycleScrollView <#cycleScrollView description#>
  63. /// @param index <#index description#>
  64. - (GKCycleScrollViewCell *)cycleScrollView:(GKCycleScrollView *)cycleScrollView cellForViewAtIndex:(NSInteger)index {
  65. GKCycleScrollViewCell *cell = [cycleScrollView dequeueReusableCell];
  66. if (!cell) {
  67. cell = [GKCycleScrollViewCell new];
  68. }
  69. UIImage *data=[self.datum objectAtIndex:index];
  70. cell.imageView.image = data;
  71. cell.imageView.contentMode = UIViewContentModeScaleAspectFit;
  72. return cell;
  73. }
  74. @end

广告界面

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

广告

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

广告

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

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

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

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

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

详情

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

  1. @implementation SheetDetailController
  2. - (void)initViews{
  3. [super initViews];
  4. //添加背景图片控件
  5. _backgroundImageView = [UIImageView new];
  6. //默认隐藏
  7. _backgroundImageView.clipsToBounds = YES;
  8. _backgroundImageView.alpha = 0;
  9. _backgroundImageView.contentMode = UIViewContentModeScaleAspectFill;
  10. [self.view addSubview:self.backgroundImageView];
  11. ...
  12. //注册歌单信息
  13. [self.tableView registerClass:[SheetInfoCell class] forCellReuseIdentifier:SheetInfoCellName];
  14. //注册section
  15. [self.tableView registerClass:[SongGroupHeaderView class] forHeaderFooterViewReuseIdentifier:SongGroupHeaderViewName];
  16. //注册单曲
  17. [self.tableView registerClass:[SongCell class] forCellReuseIdentifier:SongCellName];
  18. }
  19. - (void)initListeners{
  20. [super initListeners];
  21. @weakify(self);
  22. //点击事件
  23. [QTSubMain(self,ClickEvent) next:^(ClickEvent *event) {
  24. @strongify(self);
  25. [self processClick:event.style];
  26. }];
  27. }
  28. ...
  29. -(void)loadData:(BOOL)isPlaceholder{
  30. [[DefaultRepository shared] sheetDetailWithId:_id success:^(BaseResponse * _Nonnull baseResponse, id _Nonnull data) {
  31. [self show:data];
  32. }];
  33. }
  34. -(void)show:(Sheet *)data{
  35. self.data=data;
  36. [ImageUtil show:self.backgroundImageView uri:data.icon];
  37. //使用动画显示背景图片
  38. [UIView animateWithDuration:0.3 animations:^{
  39. //透明度设置为1
  40. self.backgroundImageView.alpha=1;
  41. }];
  42. [self.datum removeAllObjects];
  43. //第一组
  44. SongGroupData *groupData=[SongGroupData new];
  45. NSMutableArray *tempArray = [NSMutableArray new];
  46. [tempArray addObject:data];
  47. groupData.datum=tempArray;
  48. [self.datum addObject:groupData];
  49. if (data.songs) {
  50. //有音乐才设置
  51. //设置数据
  52. groupData=[SongGroupData new];
  53. NSMutableArray *tempArray = [NSMutableArray new];
  54. [tempArray addObjectsFromArray:data.songs];
  55. [tempArray addObjectsFromArray:data.songs];
  56. groupData.datum=tempArray;
  57. [self.datum addObject:groupData];
  58. }
  59. [self.tableView reloadData];
  60. }
  61. /// 播放音乐
  62. /// @param data <#data description#>
  63. -(void)play:(Song *)data{
  64. //把当前歌单所有音乐设置到播放列表
  65. //有些应用
  66. //可能会实现添加到已经播放列表功能
  67. [[MusicListManager shared] setDatum:self.data.songs];
  68. //播放当前音乐
  69. [[MusicListManager shared] play:data];
  70. [self startMusicPlayerController];
  71. }
  72. /// 有多少组
  73. /// @param tableView <#tableView description#>
  74. - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView{
  75. return self.datum.count;
  76. }
  77. /// 当前组有多少个
  78. /// @param tableView <#tableView description#>
  79. /// @param section <#section description#>
  80. - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
  81. SongGroupData *groupData=self.datum[section];
  82. return groupData.datum.count;
  83. }
  84. /// 返回section view
  85. /// @param tableView <#tableView description#>
  86. /// @param section <#section description#>
  87. - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section{
  88. __weak __typeof(self)weakSelf = self;
  89. //取出组数据
  90. SongGroupData *groupData=self.datum[section];
  91. //获取header
  92. SongGroupHeaderView *header=[tableView dequeueReusableHeaderFooterViewWithIdentifier: SongGroupHeaderViewName];
  93. [header setPlayAllClickBlock:^{
  94. __strong __typeof(weakSelf)strongSelf = weakSelf;
  95. if (strongSelf.datum.count>0) {
  96. return;
  97. }
  98. SongGroupData *groupData=strongSelf.datum[1];
  99. Song *data= groupData.datum[0];
  100. [strongSelf play:data];
  101. }];
  102. //绑定数据
  103. [header bind:groupData];
  104. //返回header
  105. return header;
  106. }
  107. /// 返回当前位置的cell
  108. /// 相当于Android中RecyclerView Adapter的onCreateViewHolder
  109. /// @param tableView <#tableView description#>
  110. /// @param indexPath <#indexPath description#>
  111. - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
  112. SongGroupData *groupData=self.datum[indexPath.section];
  113. NSObject *data= groupData.datum[indexPath.row];
  114. //获取类型
  115. ListStyle style=[self typeForItemAtData:data];
  116. switch (style) {
  117. case StyleSheet:{
  118. //歌单
  119. SheetInfoCell *cell = [tableView dequeueReusableCellWithIdentifier:SheetInfoCellName forIndexPath:indexPath];
  120. [cell bind:data];
  121. return cell;
  122. }
  123. ...
  124. }
  125. }
  126. /// Cell类型
  127. - (ListStyle)typeForItemAtData:(NSObject *)data{
  128. if([data isKindOfClass:[Sheet class]]){
  129. //歌单信息
  130. return StyleSheet;
  131. }
  132. return StyleSong;
  133. }
  134. /// header高度
  135. /// @param tableView <#tableView description#>
  136. /// @param section <#section description#>
  137. - (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section{
  138. if (section==1) {
  139. return 50;
  140. }
  141. //其他组不显示section
  142. return 0;
  143. }
  144. @end

唱片

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

  1. @implementation MusicPlayerManager
  2. /// 获取单例对象
  3. +(instancetype)shared{
  4. static MusicPlayerManager *sharedInstance = nil;
  5. if (!sharedInstance) {
  6. sharedInstance = [[self alloc] init];
  7. }
  8. return sharedInstance;
  9. }
  10. - (instancetype)init{
  11. if (self=[super init]) {
  12. self.player = [[AVPlayer alloc] init];
  13. //默认状态
  14. self.status = PlayStatusNone;
  15. }
  16. return self;
  17. }
  18. - (void)play:(NSString *)uri data:(Song *)data{
  19. //设置音频会话
  20. [SuperAudioSessionManager requestAudioFocus];
  21. //更改播放状态
  22. _status = PlayStatusPlaying;
  23. //保存音乐对象
  24. self.data = data;
  25. NSURL *url=nil;
  26. if ([uri hasPrefix:@"http"]) {
  27. //网络地址
  28. url=[NSURL URLWithString:uri];
  29. } else {
  30. //本地地址
  31. url=[NSURL fileURLWithPath:uri];
  32. }
  33. //创建一个播放Item
  34. AVPlayerItem *item = [[AVPlayerItem alloc] initWithURL:url];
  35. //替换掉原来的播放Item
  36. [self.player replaceCurrentItemWithPlayerItem:item];
  37. //播放
  38. [self.player play];
  39. ...
  40. }
  41. -(void)prepareLyric{
  42. //歌词处理
  43. //真实项目可能会
  44. //将歌词这个部分拆分到其他组件中
  45. if (_data.parsedLyric) {
  46. //解析好了
  47. [self onLyricReady];
  48. } else if(_data.lyric) {
  49. //有歌词,但是没有解析
  50. [self parseLyric];
  51. }else{
  52. //没有歌词,并且不是本地音乐才请求
  53. //真实项目中可以会缓存歌词
  54. //获取歌词数据
  55. [[DefaultRepository shared] songDetailWithId:_data.id success:^(BaseResponse * _Nonnull baseResponse, id _Nonnull d) {
  56. //请求成功
  57. Song *data=d;
  58. self.data.style=data.style;
  59. self.data.lyric=data.lyric;
  60. [self parseLyric];
  61. }];
  62. }
  63. }
  64. -(void)parseLyric{
  65. if ([StringUtil isNotBlank:self.data.lyric]) {
  66. //有歌词
  67. //在这里解析的好处是
  68. //外面不用管,直接使用
  69. self.data.parsedLyric = [LyricParser parse:self.data.style data:self.data.lyric];
  70. }
  71. //通知歌词准备好了
  72. [self onLyricReady];
  73. }
  74. -(void)onLyricReady{
  75. if (self.delegate) {
  76. [self.delegate onLyricReady:_data];
  77. }
  78. }
  79. -(void)initListeners{
  80. //KVO方式监听播放状态
  81. //KVC:Key-Value Coding,另一种获取对象字段的值,类似字典
  82. //KVO:Key-Value Observing,建立在KVC基础上,能够观察一个字段值的改变
  83. [self.player.currentItem addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];
  84. //监听音乐缓冲状态
  85. [self.player.currentItem addObserver:self
  86. forKeyPath:@"loadedTimeRanges" options:NSKeyValueObservingOptionNew
  87. context:nil];
  88. //播放结束事件
  89. [[NSNotificationCenter defaultCenter] addObserver:self
  90. selector:@selector(onComplete:)
  91. name:AVPlayerItemDidPlayToEndTimeNotification
  92. object:self.player.currentItem];
  93. }
  94. /// 播放完毕了回调
  95. - (void)onComplete:(NSNotification *)notification {
  96. self.complete(_data);
  97. }
  98. /// 移除监听器
  99. -(void)removeListeners{
  100. [self.player.currentItem removeObserver:self forKeyPath:@"status" context:nil];
  101. [self.player.currentItem removeObserver:self forKeyPath:@"loadedTimeRanges" context:nil];
  102. // [[NSNotificationCenter defaultCenter] removeObserver:self];
  103. }
  104. /// KVO监听回调方法
  105. /// @param keyPath <#keyPath description#>
  106. /// @param object <#object description#>
  107. /// @param change <#change description#>
  108. /// @param context <#context description#>
  109. -(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
  110. {
  111. //判断监听的字段
  112. if ([keyPath isEqualToString:@"status"]) {
  113. switch (self.player.status) {
  114. case AVPlayerStatusReadyToPlay:
  115. {
  116. //准备播放完成了
  117. //音乐的总时间
  118. self.data.duration= CMTimeGetSeconds(self.player.currentItem.asset.duration);
  119. LogDebugTag(MusicPlayerManagerTag, @"observeValue status ReadyToPlay duration:%f",self.data.duration);
  120. //回调代理
  121. if (self.delegate) {
  122. [self.delegate onPrepared:_data];
  123. }
  124. //更新媒体控制中心信息
  125. [self updateMediaInfo];
  126. }
  127. break;
  128. case AVPlayerStatusFailed:
  129. {
  130. //播放失败了
  131. _status = PlayStatusError;
  132. LogDebugTag(MusicPlayerManagerTag, @"observeValue status play error");
  133. }
  134. break;
  135. default:{
  136. //未知状态
  137. LogDebugTag(MusicPlayerManagerTag, @"observeValue status unknown");
  138. _status = PlayStatusNone;
  139. }
  140. break;
  141. }
  142. }
  143. ...
  144. }
  145. - (void)startPublishProgress{
  146. //判断是否启动了
  147. if (_playTimeObserve) {
  148. //已经启动了
  149. return;
  150. }
  151. @weakify(self);
  152. //1/60秒,就是16毫秒
  153. self.playTimeObserve=[self.player addPeriodicTimeObserverForInterval:CMTimeMake(1.0, 60) queue:dispatch_get_main_queue() usingBlock:^(CMTime time) {
  154. @strongify(self);
  155. //当前播放的时间
  156. self.data.progress = CMTimeGetSeconds(time);
  157. //判断是否有代理
  158. if (!self.delegate) {
  159. //没有回调
  160. //停止定时器
  161. [self stopPublishProgress];
  162. return;
  163. }
  164. //回调代理
  165. [self.delegate onProgress:self.data];
  166. ...
  167. }
  168. - (void)stopPublishProgress{
  169. if (self.playTimeObserve) {
  170. [self.player removeTimeObserver:self.playTimeObserve];
  171. self.playTimeObserve=nil;
  172. }
  173. }
  174. - (BOOL)isPlaying{
  175. return _status == PlayStatusPlaying;
  176. }
  177. - (void)pause{
  178. //更改状态
  179. _status = PlayStatusPause;
  180. //暂停
  181. [self.player pause];
  182. //移除监听器
  183. [self removeListeners];
  184. //回调代理
  185. if (self.delegate) {
  186. [self.delegate onPaused:_data];
  187. }
  188. //停止进度分发定时器
  189. [self stopPublishProgress];
  190. }
  191. - (void)resume{
  192. //设置音频会话
  193. [SuperAudioSessionManager requestAudioFocus];
  194. //更改播放状态
  195. _status = PlayStatusPlaying;
  196. //播放
  197. [self.player play];
  198. ...
  199. }
  200. - (void)seekTo:(float)data{
  201. [self.player seekToTime:CMTimeMake(data, 1.0) toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero];
  202. }
  203. #pragma mark - 媒体中心
  204. /// 更新系统媒体控制中心信息
  205. /// 不需要更新进度到控制中心
  206. /// 他那边会自动倒计时
  207. /// 这部分可以重构到公共类,因为像播放视频也可以更新到系统媒体中心
  208. -(void)updateMediaInfo{
  209. //下载图片,这部分应该封装,因为其他界面也用到了
  210. SDWebImageManager *manager =[SDWebImageManager sharedManager];
  211. NSURL *url= [NSURL URLWithString:[ResourceUtil resourceUri:self.data.icon]];
  212. [manager loadImageWithURL:url options:SDWebImageProgressiveLoad progress:^(NSInteger receivedSize, NSInteger expectedSize, NSURL * _Nullable targetURL) {
  213. //进度,这里用不到
  214. } completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, SDImageCacheType cacheType, BOOL finished, NSURL * _Nullable imageURL) {
  215. NSLog(@"load song image success");
  216. if (image!=NULL) {
  217. [self setMediaInfo:image];
  218. }
  219. }];
  220. }
  221. - (void)setMediaInfo:(UIImage *)image{
  222. //初始化一个可变字典
  223. NSMutableDictionary *songInfo=[[NSMutableDictionary alloc] init];
  224. //初始化一个封面
  225. MPMediaItemArtwork *albumArt=[[MPMediaItemArtwork alloc] initWithBoundsSize:image.size requestHandler:^UIImage * _Nonnull(CGSize size) {
  226. return image;
  227. }];
  228. //设置封面
  229. [songInfo setObject:albumArt forKey:MPMediaItemPropertyArtwork];
  230. ...
  231. //设置到系统
  232. [[MPNowPlayingInfoCenter defaultCenter] setNowPlayingInfo:songInfo];
  233. }
  234. - (void)setDelegate:(id<MusicPlayerManagerDelegate>)delegate{
  235. _delegate = delegate;
  236. if (_delegate) {
  237. //有代理
  238. //判断是否有音乐在播放
  239. if ([self isPlaying]) {
  240. //有音乐在播放
  241. //启动定时器
  242. [self startPublishProgress];
  243. }
  244. } else {
  245. //没有代理
  246. //停止定时器
  247. [self stopPublishProgress];
  248. }
  249. }
  250. @end

音乐列表逻辑封装到MusicListManager:

  1. @implementation MusicListManager
  2. static MusicListManager *sharedInstance = nil;
  3. - (instancetype)init
  4. {
  5. self = [super init];
  6. if (self) {
  7. _datum=[[NSMutableArray alloc] init];
  8. //初始化音乐播放管理器
  9. self.musicPlayerManager=[MusicPlayerManager shared];
  10. __weak typeof(self)weakSelf = self;
  11. //设置播放完毕回调
  12. [self.musicPlayerManager setComplete:^(Song * _Nonnull data) {
  13. //判断播放循环模式
  14. if ([weakSelf getLoopModel] == MusicPlayRepeatModelOne) {
  15. //单曲循环
  16. [weakSelf play:weakSelf.data];
  17. } else {
  18. //其他模式
  19. [weakSelf play:[weakSelf next]];
  20. }
  21. }];
  22. self.model=MusicPlayRepeatModelList;
  23. [self initPlayList];
  24. }
  25. return self;
  26. }
  27. /// 获取单例对象
  28. +(instancetype)shared{
  29. if (!sharedInstance) {
  30. sharedInstance = [[self alloc] init];
  31. }
  32. return sharedInstance;
  33. }
  34. /// 设置默认播放音乐
  35. -(void)defaultPlaySong{
  36. _data=_datum[0];
  37. }
  38. /// 设置播放列表
  39. - (void)setDatum:(NSArray *)datum{
  40. //将原来数据playList标志设置为false
  41. [DataUtil changePlayListFlag:_datum inList:NO];
  42. //保存到数据库
  43. [self saveAll];
  44. //清空原来的数据
  45. [_datum removeAllObjects];
  46. //添加新的数据
  47. [_datum addObjectsFromArray:datum];
  48. //更改播放列表标志
  49. [DataUtil changePlayListFlag:_datum inList:YES];
  50. //保存到数据库
  51. [self saveAll];
  52. [self sendMusicListChanged];
  53. }
  54. /// 保存当前播放列表到数据库
  55. -(void)saveAll{
  56. [[SuperDatabaseManager shared] saveAllSong:_datum];
  57. }
  58. -(void)sendMusicListChanged{
  59. MusicListChangedEvent *event = [[MusicListChangedEvent alloc] init];
  60. [QTEventBus.shared dispatch:event];
  61. }
  62. /**
  63. * 获取播放列表
  64. */
  65. - (NSArray *)getDatum{
  66. return _datum;
  67. }
  68. /**
  69. * 播放
  70. */
  71. - (void)play:(Song *)data{
  72. self.data = data;
  73. //标记为播放了
  74. self.isPlay = YES;
  75. NSString *path;
  76. //查询是否有下载任务
  77. DownloadInfo *downloadInfo=[[AppDelegate.shared getDownloadManager] findDownloadInfo:data.id];
  78. if (downloadInfo != nil && downloadInfo.status == DownloadStatusCompleted) {
  79. //下载完成了
  80. //播放本地音乐
  81. path = [[StorageUtil documentUrl] URLByAppendingPathComponent:downloadInfo.path].path;
  82. LogDebugTag(MusicListManagerTag, @"MusicListManager play offline:%@ %@",path,data.uri);
  83. } else {
  84. //播放在线音乐
  85. path = [ResourceUtil resourceUri:data.uri];
  86. LogDebugTag(MusicListManagerTag, @"MusicListManager play online:%@ %@",path,data.uri);
  87. }
  88. [_musicPlayerManager play:path data:data];
  89. //设置最后播放音乐的Id
  90. [PreferenceUtil setLastPlaySongId:_data.id];
  91. }
  92. /**
  93. * 暂停
  94. */
  95. - (void)pause{
  96. LogDebugTag(MusicListManagerTag, @"pause");
  97. [_musicPlayerManager pause];
  98. }
  99. ...
  100. /// 更改循环模式
  101. - (MusicPlayRepeatModel)changeLoopModel{
  102. //循环模式+1
  103. _model++;
  104. //判断循环模式边界
  105. if (_model > MusicPlayRepeatModelRandom) {
  106. //如果当前循环模式
  107. //大于最后一个循环模式
  108. //就设置为第0个循环模式
  109. _model = MusicPlayRepeatModelList;
  110. }
  111. //返回最终的循环模式
  112. return _model;
  113. }
  114. /**
  115. * 获取循环模式
  116. */
  117. - (MusicPlayRepeatModel)getLoopModel{
  118. return _model;
  119. }
  120. - (Song *)getData{
  121. return self.data;
  122. }
  123. /**
  124. * 获取上一个
  125. */
  126. - (Song *)previous{
  127. //音乐索引
  128. NSUInteger index = 0;
  129. //判断循环模式
  130. switch (self.model) {
  131. case MusicPlayRepeatModelRandom:{
  132. //随机循环
  133. //在0~datum.size()中
  134. //不包含datum.size()
  135. index = arc4random() % [_datum count];
  136. }
  137. break;
  138. default:{
  139. //找到当前音乐索引
  140. index = [_datum indexOfObject:self.data];
  141. if (index != -1) {
  142. //找到了
  143. //如果当前播放是列表第一个
  144. if (index == 0) {
  145. //第一首音乐
  146. //那就从最后开始播放
  147. index = [_datum count] - 1;
  148. } else {
  149. index--;
  150. }
  151. } else {
  152. //抛出异常
  153. //因为正常情况下是能找到的
  154. }
  155. }
  156. break;
  157. }
  158. //获取音乐
  159. return [_datum objectAtIndex:index];
  160. }
  161. ...
  162. @end

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

  1. -(void)onLoopModelClick:(UIButton *)sender{
  2. //更改循环模式
  3. [[MusicListManager shared] changeLoopModel];
  4. //显示循环模式
  5. [self showLoopModel];
  6. }
  7. -(void)onPreviousClick:(UIButton *)sender{
  8. [[MusicListManager shared] play: [[MusicListManager shared] previous]];
  9. }
  10. -(void)onPlayClick:(UIButton *)sender{
  11. [self playOrPause];
  12. }
  13. /// 播放或暂停
  14. -(void)playOrPause{
  15. if ([[MusicPlayerManager shared] isPlaying]) {
  16. [[MusicListManager shared] pause];
  17. } else {
  18. [[MusicListManager shared] resume];
  19. }
  20. }
  21. -(void)onNextClick:(UIButton *)sender{
  22. [[MusicListManager shared] play: [[MusicListManager shared] next]];
  23. }

歌词

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

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

歌词控件封装:

  1. @implementation LyricListView
  2. - (instancetype)init{
  3. self=[super init];
  4. self.datum = [NSMutableArray array];
  5. [self initViews];
  6. return self;
  7. }
  8. - (void)initViews{
  9. //设置约束
  10. self.myWidth = MyLayoutSize.fill;
  11. self.myHeight = MyLayoutSize.fill;
  12. //tableView
  13. self.tableView = [ViewFactoryUtil tableView];
  14. self.tableView.delegate = self;
  15. self.tableView.dataSource = self;
  16. [self addSubview:self.tableView];
  17. //注册歌词cell
  18. [self.tableView registerClass:[LyricCell class] forCellReuseIdentifier:Cell];
  19. //创建一个水平方向容器
  20. _lyricDragContainer = [[MyLinearLayout alloc] initWithOrientation:MyOrientation_Horz];
  21. _lyricDragContainer.visibility = MyVisibility_Gone;
  22. _lyricDragContainer.myHorzMargin = PADDING_OUTER;
  23. _lyricDragContainer.myWidth = MyLayoutSize.fill;
  24. _lyricDragContainer.myHeight = MyLayoutSize.wrap;
  25. ...
  26. //分割线
  27. UIView *dividerView = [ViewFactoryUtil smallDivider];
  28. dividerView.weight=1;
  29. dividerView.backgroundColor = [UIColor colorLightWhite];
  30. [_lyricDragContainer addSubview:dividerView];
  31. //时间
  32. _timeView = [UILabel new];
  33. _timeView.myWidth = MyLayoutSize.wrap;
  34. _timeView.myHeight = MyLayoutSize.wrap;
  35. _timeView.text = @"00:00";
  36. _timeView.textColor = [UIColor colorLightWhite];
  37. [_lyricDragContainer addSubview:_timeView];
  38. }
  39. - (void)setData:(Lyric *)data{
  40. _data=data;
  41. if (_lyricPlaceholderSize > 0) {
  42. //已经计算了填充数量
  43. [self next];
  44. }
  45. }
  46. - (void)next{
  47. //清空原来的歌词
  48. [_datum removeAllObjects];
  49. if (_data) {
  50. //添加占位数据
  51. [self addLyricFillData];
  52. [_datum addObjectsFromArray:_data.datum];
  53. //添加占位数据
  54. [self addLyricFillData];
  55. }
  56. _isReloadData=YES;
  57. [_tableView reloadData];
  58. }
  59. /// 添加歌词占位数据
  60. /// 添加的目的是让第一行歌词也能显示到控件垂直方向中心
  61. -(void)addLyricFillData {
  62. for (int i=0; i<_lyricPlaceholderSize; i++) {
  63. [_datum addObject:@"fill"];
  64. }
  65. }
  66. - (void)setProgress:(float)progress{
  67. if(!_isReloadData && _lyricPlaceholderSize > 0){
  68. //还没有加载数据
  69. //所以这里加载数据
  70. [self next];
  71. }
  72. if (_data && _datum.count>0) {
  73. if (_isDrag) {
  74. //正在拖拽歌词
  75. //就直接返回
  76. return;
  77. }
  78. //获取当前时间对应的歌词索引
  79. NSInteger newLineNumber = [LyricUtil getLineNumber:_data progress:progress] + _lyricPlaceholderSize;
  80. if (newLineNumber != _lyricLineNumber) {
  81. //滚动到当前行
  82. [self scrollPosition:newLineNumber];
  83. _lyricLineNumber = newLineNumber;
  84. }
  85. //如果是精确到字歌曲
  86. //还需要将时间分发到item中
  87. //因为要持续绘制
  88. if (_data.isAccurate) {
  89. NSObject *object = _datum[_lyricLineNumber];
  90. if ([object isKindOfClass:[LyricLine class]]) {
  91. //只有是歌词行才处理
  92. //获取当前时间是该行的第几个字
  93. NSInteger lyricCurrentWordIndex=[LyricUtil getWordIndex:object progress:progress];
  94. //获取当前时间改字
  95. //已经播放的时间
  96. NSInteger wordPlayedTime=[LyricUtil getWordPlayedTime:object progress:progress];
  97. //获取cell
  98. LyricCell *cell= [self getCell:self.lyricLineNumber];
  99. if (cell) {
  100. //有可能获取不到当前位置的Cell
  101. //因为上面使用了滚动动画
  102. //如果不使用滚动动画效果不太好
  103. //将当前时间对应的字索引设置到控件
  104. [cell.lineView setLyricCurrentWordIndex:lyricCurrentWordIndex];
  105. //设置当前字已经播放的时间
  106. [cell.lineView setWordPlayedTime:wordPlayedTime];
  107. //标记需要绘制
  108. [cell.lineView setNeedsDisplay];
  109. }
  110. }
  111. }
  112. }
  113. }
  114. ...
  115. #pragma mark - 列表数据源
  116. /// 有多少个
  117. /// @param tableView <#tableView description#>
  118. /// @param section <#section description#>
  119. - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
  120. return _datum.count;
  121. }
  122. /// 返回当前位置的cell
  123. /// @param tableView <#tableView description#>
  124. /// @param indexPath <#indexPath description#>
  125. - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
  126. //获取cell
  127. LyricCell *cell=[tableView dequeueReusableCellWithIdentifier:Cell forIndexPath:indexPath];
  128. //设置Tag
  129. cell.tag = indexPath.row;
  130. //取出数据
  131. NSObject *data = _datum[indexPath.row];
  132. //绑定数据
  133. [cell bind:data accurate:_data.isAccurate];
  134. //返回cell
  135. return cell;
  136. }
  137. #pragma mark - 滚动相关
  138. /// 开始拖拽时调用
  139. /// @param scrollView <#scrollView description#>
  140. - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{
  141. LogDebugTag(LyricListViewTag, @"scrollViewWillBeginDragging");
  142. [self showDragView];
  143. }
  144. /// 拖拽结束
  145. /// @param scrollView <#scrollView description#>
  146. /// @param decelerate <#decelerate description#>
  147. - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
  148. NSLog(@"lyric view scrollViewDidEndDragging:%d",decelerate);
  149. if (!decelerate) {
  150. //如果不需要减速,就延时后,显示歌词
  151. [self prepareScrollLyricView];
  152. }
  153. }
  154. /// 滚动结束(惯性滚动)
  155. /// @param scrollView <#scrollView description#>
  156. - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
  157. NSLog(@"lyric view scrollViewDidEndDecelerating");
  158. //如果需要减速,在这里延时后,显示歌词
  159. [self prepareScrollLyricView];
  160. }
  161. ...
  162. @end

控制器

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

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

媒体控制

  1. /// 接收远程音乐播放控制消息
  2. /// 例如:点击耳机上的按钮,点击媒体控制中心按钮等
  3. /// @param event <#event description#>
  4. - (void)remoteControlReceivedWithEvent:(UIEvent *)event{
  5. //判断是不是远程控制事件
  6. if (event.type == UIEventTypeRemoteControl) {
  7. if ([[MusicListManager shared] getData] == nil) {
  8. //当前播放列表中没有音乐
  9. return;
  10. }
  11. //判断事件类型
  12. switch (event.subtype) {
  13. case UIEventSubtypeRemoteControlPlay:{
  14. //点击了播放按钮
  15. [[MusicListManager shared] resume];
  16. NSLog(@"AppDelegate play");
  17. }
  18. break;
  19. case UIEventSubtypeRemoteControlPause:{
  20. //点击了暂停
  21. [[MusicListManager shared] pause];
  22. NSLog(@"AppDelegate pause");
  23. }
  24. break;
  25. case UIEventSubtypeRemoteControlNextTrack:{
  26. //下一首
  27. //双击iPhone有线耳机上的控制按钮
  28. Song *song = [[MusicListManager shared] next];
  29. [[MusicListManager shared] play:song];
  30. NSLog(@"AppDelegate Next");
  31. }
  32. break;
  33. ...
  34. default:
  35. break;
  36. }
  37. }
  38. }

登录/注册/验证码登录

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

评论



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

刷新和下拉加载更多

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

  1. //下拉刷新
  2. MJRefreshNormalHeader *header=[MJRefreshNormalHeader headerWithRefreshingBlock:^{
  3. @strongify(self);
  4. [self loadData];
  5. }];
  6. //隐藏标题
  7. header.stateLabel.hidden = YES;
  8. // 隐藏时间
  9. header.lastUpdatedTimeLabel.hidden = YES;
  10. self.tableView.mj_header=header;
  11. //上拉加载更多
  12. MJRefreshAutoNormalFooter *footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{
  13. @strongify(self);
  14. [self loadMore];
  15. }];
  16. // 设置空闲时文字
  17. [footer setTitle:@"" forState:MJRefreshStateIdle];
  18. self.tableView.mj_footer = footer;

人和话题点击

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

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

好友

  1. @implementation UserController
  2. - (void)initViews{
  3. [super initViews];
  4. //初始化TableView结构
  5. [self initTableViewSafeArea];
  6. [self.tableView registerClass:[TopicCell class] forCellReuseIdentifier:Cell];
  7. }
  8. - (void)initDatum{
  9. [super initDatum];
  10. if (self.style==StyleFriend || self.style==StyleSelect) {
  11. //好友
  12. [self setTitle:R.string.localizable.myFriend];
  13. } else {
  14. //粉丝
  15. [self setTitle:R.string.localizable.myFans];
  16. }
  17. [self loadData];
  18. }
  19. - (void)loadData:(BOOL)isPlaceholder{
  20. DefaultRepository *repository=[DefaultRepository shared];
  21. if (self.style==StyleFriend || self.style==StyleSelect) {
  22. //好友
  23. [repository friends:[PreferenceUtil getUserId] success:^(BaseResponse * _Nonnull baseResponse, Meta * _Nonnull meta, NSArray * _Nonnull data) {
  24. [self show:data];
  25. }];
  26. } else {
  27. //粉丝
  28. [repository fans:[PreferenceUtil getUserId] success:^(BaseResponse * _Nonnull baseResponse, Meta * _Nonnull meta, NSArray * _Nonnull data) {
  29. [self show:data];
  30. }];
  31. }
  32. }
  33. -(void)show:(NSArray *)data{
  34. [self.datum removeAllObjects];
  35. [self.datum addObjectsFromArray:data];
  36. [self.tableView reloadData];
  37. }
  38. #pragma mark - 列表数据源
  39. /// 返回当前位置的cell
  40. /// 相当于Android中RecyclerView Adapter的onCreateViewHolder
  41. /// @param tableView <#tableView description#>
  42. /// @param indexPath <#indexPath description#>
  43. - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
  44. User *data= self.datum[indexPath.row];
  45. TopicCell *cell=[tableView dequeueReusableCellWithIdentifier:Cell forIndexPath:indexPath];
  46. [cell bindWithUser:data];
  47. return cell;
  48. }
  49. - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
  50. User *data=self.datum[indexPath.row];
  51. if (self.style==StyleSelect) {
  52. //选择
  53. SelectUserEvent *event = [[SelectUserEvent alloc] init];
  54. event.data=data;
  55. [QTEventBus.shared dispatch:event];
  56. [self finish];
  57. }else{
  58. [UserDetailController start:self.navigationController id:data.id];
  59. }
  60. }
  61. #pragma mark - 启动界面
  62. +(void)start:(UINavigationController *)controller style:(ListStyle)style{
  63. UserController *target=[UserController new];
  64. target.style=style;
  65. [controller pushViewController:target animated:YES];
  66. }
  67. @end

视频和播放

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

  1. -(void)play:(Video *)data{
  2. // //不开防盗链
  3. // SuperPlayerModel *model = [[SuperPlayerModel alloc] init];
  4. //
  5. // //播放腾讯云视频
  6. // // 配置 AppId
  7. //// model.appId = 0;
  8. ////
  9. //// model.videoId = [[SuperPlayerVideoId alloc] init];
  10. //// model.videoId.fileId = "5285890799710670616"; // 配置 FileId
  11. //
  12. // //停止播放
  13. // [_playerView removeVideo];
  14. //
  15. // //直接使用url播放
  16. // model.videoURL = [ResourceUtil resourceUri:data.uri];
  17. //
  18. // [_playerView playWithModel:model];
  19. //
  20. // //设置标题
  21. // [self.playerView.controlView setTitle:data.title];
  22. }

用户详情/更改资料

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

  1. -(void)initUI{
  2. [self.container removeAllSubviews];
  3. //头部控件
  4. _userHeaderView = [[UserDetailHeaderView alloc] init];
  5. [_userHeaderView setFollowBlock:^{
  6. [self loginAfter:^{
  7. [self onFollowClick];
  8. }];
  9. }];
  10. [_userHeaderView setSendMessageBlock:^{
  11. [ChatController start:self.navigationController id:self.data.id];
  12. }];
  13. //指示器
  14. _categoryView = [[JXCategoryTitleView alloc] initWithFrame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, SIZE_INDICATOR_HEIGHT)];
  15. //标题
  16. self.categoryView.titles = @[R.string.localizable.sheet, R.string.localizable.feed];
  17. self.categoryView.backgroundColor = [UIColor clearColor];
  18. self.categoryView.delegate = self;
  19. //选择的颜色
  20. self.categoryView.titleSelectedColor = [UIColor colorPrimary];
  21. //默认颜色
  22. self.categoryView.titleColor = [UIColor colorOnSurface];
  23. //选中是否放大
  24. self.categoryView.titleLabelZoomEnabled = NO;
  25. //指示器下面那条线
  26. JXCategoryIndicatorLineView *lineView = [[JXCategoryIndicatorLineView alloc] init];
  27. //选中颜色
  28. lineView.indicatorColor = [UIColor colorPrimary];
  29. lineView.indicatorWidth = 30;
  30. self.categoryView.indicators = @[lineView];
  31. self.pagerView = [[JXPagerListRefreshView alloc] initWithDelegate:self];
  32. self.pagerView.mainTableView.gestureDelegate = self;
  33. self.pagerView.myWidth=MyLayoutSize.fill;
  34. self.pagerView.myHeight=MyLayoutSize.fill;
  35. [self.container addSubview:self.pagerView];
  36. self.categoryView.listContainer = (id<JXCategoryViewListContainer>)self.pagerView.listContainerView;
  37. }

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

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



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

位置

  1. /// 搜索该位置的poi,方便用户选择,也方便其他人找
  2. -(void)searchPOI{
  3. //LogDebug(@"searchPOI %f %f %@",data.);
  4. if (_keyword) {
  5. //关键字搜索
  6. AMapPOIKeywordsSearchRequest *request = [AMapPOIKeywordsSearchRequest new];
  7. //关键字
  8. request.keywords=_keyword;
  9. //距离排序
  10. request.sortrule = 0;
  11. //是否返回扩展信息
  12. request.requireExtension=YES;
  13. [self.search AMapPOIKeywordsSearch:request];
  14. } else {
  15. //搜索位置附近
  16. AMapPOIAroundSearchRequest *request = [AMapPOIAroundSearchRequest new];
  17. request.location=[AMapGeoPoint locationWithLatitude:_coordinate.latitude longitude:_coordinate.longitude];
  18. //距离排序
  19. request.sortrule=0;
  20. //是否返回扩展信息
  21. request.requireExtension=YES;
  22. [self.search AMapPOIAroundSearch:request];
  23. }
  24. }

地图路径规划

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

聊天/离线推送



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

聊天服务器

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

消息监听

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

文本消息

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

  1. /// 发送文本消息
  2. -(void)sendTextMessage{
  3. NSString *result=_contentInputView.text;
  4. if([StringUtil isBlank:result]){
  5. [SuperToast showWithTitle:R.string.localizable.hintEnterMessage];
  6. return;
  7. }
  8. //1.构造文本消息
  9. RCTextMessage *txtMsg = [RCTextMessage messageWithContent:result];
  10. //2.将文本消息发送出去
  11. [[RCIMClient sharedRCIMClient] sendMessage:ConversationType_PRIVATE
  12. targetId:self.id
  13. content:txtMsg
  14. pushContent:nil
  15. pushData:[MessageUtil createPushData:[MessageUtil getContent:txtMsg] targetId:[PreferenceUtil getUserId]]
  16. success:^(long messageId) {
  17. NSLog(@"消息发送成功,message id 为 %@",@(messageId));
  18. dispatch_async(dispatch_get_main_queue(), ^{
  19. //清空输入框
  20. [self clearInput];
  21. });
  22. [self addMessage:[[RCIMClient sharedRCIMClient] getMessage:messageId]];
  23. } error:^(RCErrorCode nErrorCode, long messageId) {
  24. NSLog(@"消息发送失败,错误码 为 %@",@(nErrorCode));
  25. }];
  26. }

离线推送

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

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

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

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

详情富文本

  1. //详情
  2. self.detailView = [QMUITextView new];
  3. self.detailView.myWidth = MyLayoutSize.fill;
  4. self.detailView.myHeight = MyLayoutSize.wrap;
  5. self.detailView.delegate=self;
  6. self.detailView.scrollEnabled=NO;
  7. self.detailView.editable=NO;
  8. //去除左右边距
  9. self.detailView.textContainer.lineFragmentPadding = 0;
  10. //去除上下边距
  11. self.detailView.textContainerInset = UIEdgeInsetsZero;
  12. [self.contentContainer addSubview:self.detailView];

宝/微信支付

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

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

支付结果

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

项目总结

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

OC高仿iOS网易云音乐AFNetworking+SDWebImage+MJRefresh+MVC+MVVM的更多相关文章

  1. Swift高仿iOS网易云音乐Moya+RxSwift+Kingfisher+MVC+MVVM

    效果 列文章目录 因为目录比较多,每次更新这里比较麻烦,所以推荐点击到主页,然后查看iOS Swift云音乐专栏. 目简介 这是一个使用Swift(还有OC版本)语言,从0开发一个iOS平台,接近企业 ...

  2. 【第二版】高仿Android网易云音乐企业级项目实战课程介绍

    这是一门付费Android项目课程,我们只做付费课程:同时也感谢大家的支持. 这一节,对本课程做一个简单介绍,以及放一些项目效果图,如果想直接查看项目视频演示,可以直接在腾讯课堂查看[高仿Androi ...

  3. 卡拉OK歌词原理和实现高仿Android网易云音乐

    大家好,我们是爱学啊,继上一篇讲解了[LRC歌词原理和实现高仿Android网易云音乐],今天给大家带来一篇关于卡拉OK歌词原理和在Android上如何实现歌词逐字滚动的效果,本文来自[Android ...

  4. 高仿Android网易云音乐OkHttp+Retrofit+RxJava+Glide+MVC+MVVM

    简介 这是一个使用Java(以后还会推出Kotlin版本)语言,从0开发一个Android平台,接近企业级的项目(我的云音乐),包含了基础内容,高级内容,项目封装,项目重构等知识:主要是使用系统功能, ...

  5. LRC歌词原理和实现高仿Android网易云音乐

    大家好,我们是爱学啊,今天给大家带来一篇关于LRC歌词原理和在Android上如何实现歌词逐行滚动的效果,本文来自[Android开发项目实战我的云音乐]课程:逐字滚动下一篇文章讲解. 效果图 相信大 ...

  6. Android项目实战之高仿网易云音乐项目介绍

    这一节我们来讲解这个项目所用到的一些技术,以及一些实现的效果图,让大家对该项目有一个整体的认识,推荐大家收藏该文章,因为我们发布文章后会在该文章里面加入链接,这样大家找着就很方便. 目录 第1章 前期 ...

  7. 新鲜出炉高仿网易云音乐 APP

    我的引语 晚上好,我是吴小龙同学,我的公众号「一分钟GitHub」会推荐 GitHub 上好玩的项目,一分钟 get 一个优秀的开源项目,挖掘开源的价值,欢迎关注我. 项目中成长是最快的,如何成长,就 ...

  8. iOS 开发仿网易云音乐歌词海报

    使用网易云音乐也是一个巧合,我之前一直使用QQ音乐听歌,前几天下 app 手机内存告急.于是就把QQ音乐给卸载掉了,正好晚上朋友圈里有一个朋友用网易云音乐分享了一首歌曲,于是我也就尝试下载了网易云音乐 ...

  9. 《云阅》一个仿网易云音乐UI,使用Gank.Io及豆瓣Api开发的开源项目

    CloudReader 一款基于网易云音乐UI,使用GankIo及豆瓣api开发的符合Google Material Desgin阅读类的开源项目.项目采取的是Retrofit + RxJava + ...

随机推荐

  1. 一文讲透为Power Automate for Desktop (PAD) 实现自定义模块 - 附完整代码

    概述 Power Automate for Desktop (以下简称PAD)是微软推出的一款针对Windows桌面端的免费RPA(机器人流程自动化)工具,它目前默认会随着Windows 11安装,但 ...

  2. 第06组Alpha冲刺(6/6)

    目录 1.1 基本情况 1.2 冲刺概况汇报 1.郝雷明 2.曹兰英 3. 方梓涵 4.曾丽莉 5.鲍凌函 6.杜筱 7.黄少丹 8.詹鑫冰 9.董翔云 10.吴沅静 1.3 冲刺成果展示 1.1 基 ...

  3. 第06组Alpha冲刺 (4/6)

    目录 1.1 基本情况 1.2 冲刺概况汇报 1.郝雷明 2.曹兰英 3. 方梓涵 4.鲍凌函 5.董翔云 6.杜筱 7.黄少丹 8.曾丽莉 9. 詹鑫冰 10.吴沅静 1.3 冲刺成果展示 1.1 ...

  4. 深度学习与CV教程(2) | 图像分类与机器学习基础

    作者:韩信子@ShowMeAI 教程地址:http://www.showmeai.tech/tutorials/37 本文地址:http://www.showmeai.tech/article-det ...

  5. 『忘了再学』Shell基础 — 24、Shell正则表达式的使用

    目录 1.正则表达式说明 2.基础正则表达式 3.练习 (1)准备工作 (2)*练习 (3).练习 (4)^和$练习 (5)[]练习 (6)[^]练习 (7)\{n\}练习 (8)\{n,\}练习 ( ...

  6. python实现一个加密的文字处理器

    这是一个类似于记事本的文字处理器.与正常的记事本不同的是,它会将文本文档进行加密,确保无法被常规的程序打开. 由于本人是一位业余编程爱好者,对于"python之禅"之类的规则比较不 ...

  7. java中关于while(true)的理解

    java中while(true)的理解: while(true)作为无限循环,经常在不知道循环次数的时候使用,并且需要在循环内使用break才会停止,且在run()方法中基本都会写while(true ...

  8. MVC - Request对象的主要方法

    MVC - Request对象的主要方法 setAttribute(String name,Object):设置名字为name的request的参数值 getAttribute(String name ...

  9. Spring cloud gateway 如何在路由时进行负载均衡

    本文为博主原创,转载请注明出处: 1.spring cloud gateway 配置路由 在网关模块的配置文件中配置路由: spring: cloud: gateway: routes: - id: ...

  10. 关键路径 p3 清华复试上机题

    关键路径 p3 清华复试上机题 题目描述 小H为了完成一篇论文,一共要完成n个实验.其中第i个实验需要a[i]的时问去完成.小H可以同时进行若干实验,但存在一些实验,只有当它的若干前置实验完成时,才能 ...