OC高仿iOS網易雲音樂AFNetworking+SDWebImage+MJRefresh+MVC+MVVM

效果

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
  #//github.com/QMUI/QMUIDemo_iOS
  #//qmuiteam.com/ios/get-started
  pod "QMUIKit"
  
  #//github.com/SysdataSpA/R.objc
  #作者說受R.swift的自由啟發,獲取自動完成的本地化字元串、資產目錄影像名稱和故事板對象
  pod 'R.objc'
  
  #輪播圖
  #//github.com/QuintGao/GKCycleScrollView
  pod 'GKCycleScrollView'
  
  #網路框架
  #//github.com/AFNetworking/AFNetworking
  pod 'AFNetworking'

  
  #輪播圖,多講解一個是方便大家選擇
  #//github.com/wwmz/WMZBanner
  pod 'WMZBanner'
  
  #//github.com/91renb/BRPickerView
  #封裝的是iOS中常用的選擇器組件,主要包括:日期選擇器
  pod 'BRPickerView'
  
  #支付寶支付
  #//docs.open.alipay.com/204/105295/
  pod 'AlipaySDK-iOS'
  
  #融雲聊天
  #//doc.rongcloud.cn/im/IOS/5.X/noui/import
  pod 'RongCloudIM/IMLib'
  
  pod 'JCore'

  #極光推送
  #//docs.jiguang.cn/jpush/client/iOS/ios_guide_new/
  pod 'JPush'
  
  #極光統計
  #//docs.jiguang.cn/janalytics/guideline/intro/
  pod 'JAnalytics'
  
  #webview和js交互框架
  #可以直接使用系統提供的api,不是說一定要用框架
  #只是用該框架,更方便
  #//github.com/marcuswestin/WebViewJavascriptBridge
  pod 'WebViewJavascriptBridge'
  
  target 'MyCloudMusicTests' do
    inherit! :search_paths
    # Pods for testing
  end

  target 'MyCloudMusicUITests' do
    # Pods for testing
  end

end

戶協議對話框

使用自定義Dialog實現。

@interface TermServiceDialogController ()<QMUIModalPresentationContentViewControllerProtocol>

@end

@implementation TermServiceDialogController
- (void)initViews{
    [super initViews];
    
    self.view.backgroundColor=[UIColor colorDivider];
    self.view.myWidth=MyLayoutSize.fill;
    self.view.myHeight=MyLayoutSize.wrap;
    
    //根容器
    self.rootContainer = [[MyLinearLayout alloc] initWithOrientation:MyOrientation_Vert];
    self.rootContainer.subviewSpace=0.5;
    self.rootContainer.myWidth=MyLayoutSize.fill;
    self.rootContainer.myHeight=MyLayoutSize.wrap;
    [self.view addSubview:self.rootContainer];
    
    //內容容器
    self.contentContainer = [[MyLinearLayout alloc] initWithOrientation:MyOrientation_Vert];
    self.contentContainer.subviewSpace=25;
    self.contentContainer.myWidth=MyLayoutSize.fill;
    self.contentContainer.myHeight=MyLayoutSize.wrap;
    self.contentContainer.backgroundColor = [UIColor colorBackground];
    self.contentContainer.padding=UIEdgeInsetsMake(PADDING_LARGE2, PADDING_OUTER, PADDING_LARGE2, PADDING_OUTER);
    self.contentContainer.gravity=MyGravity_Horz_Center;
    [self.rootContainer addSubview:self.contentContainer];
    
    //標題
    [self.contentContainer addSubview:self.titleView];
    
    self.textView=[UITextView new];
    self.textView.myWidth=MyLayoutSize.fill;
    
    //超出的內容,自動支援滾動
    self.textView.myHeight=230;
    self.textView.text=@"...";
    self.textView.backgroundColor = [UIColor clearColor];
    
    //禁用編輯
    self.textView.editable=NO;
    
    [self.contentContainer addSubview:self.textView];
    
    [self.contentContainer addSubview:self.primaryButton];
    
    //不同意按鈕按鈕
    self.disagreeButton = [ViewFactoryUtil linkButton];
    [self.disagreeButton setTitle:R.string.localizable.disagree forState: UIControlStateNormal];
    [self.disagreeButton setTitleColor:[UIColor black80] forState:UIControlStateNormal];
    [self.disagreeButton addTarget:self action:@selector(disagreeClick:) forControlEvents:UIControlEventTouchUpInside];
    [self.disagreeButton sizeToFit];
    [self.contentContainer addSubview:self.disagreeButton];
}

- (void)show{
    self.modalController = [QMUIModalPresentationViewController new];
    self.modalController.animationStyle = QMUIModalPresentationAnimationStyleFade;
    
    //點擊外部不隱藏
    [self.modalController setModal:YES];
    
    //邊距
    self.modalController.contentViewMargins=UIEdgeInsetsMake(PADDING_LARGE2, PADDING_LARGE2, PADDING_LARGE2, PADDING_LARGE2);
    
    //設置要顯示的內容控制項
    self.modalController.contentViewController=self;
    
    [self.modalController showWithAnimated:YES completion:nil];
}

- (void)hide{
    [self.modalController hideWithAnimated:YES completion:nil];
}

#pragma mark - 創建控制項
- (UILabel *)titleView{
    if (!_titleView) {
        _titleView=[UILabel new];
        _titleView.myWidth=MyLayoutSize.fill;
        _titleView.myHeight=MyLayoutSize.wrap;
        _titleView.text=@"標題";
        _titleView.textAlignment=NSTextAlignmentCenter;
        _titleView.font=[UIFont boldSystemFontOfSize:TEXT_LARGE3];
        _titleView.textColor=[UIColor colorOnSurface];
    }
    return _titleView;
}

- (QMUIButton *)primaryButton{
    if (!_primaryButton) {
        _primaryButton = [ViewFactoryUtil primaryHalfFilletButton];
        [_primaryButton setTitle:R.string.localizable.agree forState:UIControlStateNormal];
    }
    return _primaryButton;
}
@end

導介面

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]];
}

歌詞

歌詞實現了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{
    //支付寶官方開發文檔://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];
    }
}

項目總結

總體來說項目功能還是很全的,還有一些小功能,例如:快捷方式等就不在貼程式碼了,但肯定沒發和原版比,相信大家只要做過程式設計師就能理解,畢竟原版是一個商業級項目,幾十個人天天開發和維護,而且持續了幾年了;不過恕我直言,現在的常見的音樂軟體都太複雜了,各種功能,不過都要恰飯,好像又能理解了😄。