6

OC高仿iOS网易云音乐AFNetworking+SDWebImage+MJRefresh+MVC+MVVM - 爱学啊

 2 years ago
source link: https://www.cnblogs.com/woblog/p/16572824.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
i11.png

因为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

户协议对话框

13eaa319d44e45c9b64205f5a6b2966a~tplv-k3u1fbpfcp-watermark.image

使用自定义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
i9.png

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

@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
i10.png

实现图片广告和视频广告,广告数据是在首页是缓存到本地,目的是在启动界面加载更快,因为真实项目中,大部分项目启动页面广告时间一共就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];
}

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

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

i11.png

首页没有顶部是轮播图,然后是可以左右的菜单,接下来是热门歌单,推荐单曲,最后是首页排序模块;整体上使用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]];
}
3f7cb8ed4d71459aabd5f2600e84ece8~tplv-k3u1fbpfcp-watermark.image

歌词实现了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;
        }
    }
}

登录/注册/验证码登录

i13.png

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

i14.png

评论列表包括下拉刷新,上拉加载更多,点赞,发布评论,回复评论,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

视频和播放

i15.png

真实项目中视频播放大部分都是用第三方服务,例如:阿里云视频服务,腾讯视频服务,因为他们提供一条龙服务,包括审核,转码,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];
}

用户详情/更改资料

i16.png

用户详情顶部显示用户信息,好友数量,下面分别显示创建的歌单,收藏的歌单,发布的动态,类似微信朋友圈,右上角可以更改用户资料;使用第三方框架里面的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中,并在代理方法返回就行了。

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

i17.png

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

/// 搜索该位置的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];
}

聊天/离线推送

i18.png

大部分真实项目中聊天都会选择第三方商业级付费聊天服务,常用的有腾讯云聊天,融云聊天,网易云聊天等,这里选择融云聊天服务,使用步骤是先在服务端生成聊天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;
    }
}

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

i2011.png
i2012.png

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

详情富文本

//详情
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];
    }
}

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK