OC高仿iOS網(wǎng)易云音樂AFNetworking+SDWebImage+MJRefresh+MVC+MVVM
效果
因為OC版本大部分截圖和Swift版本一樣,所以就不再另外截圖了。
列文章目錄
因為目錄比較多,每次更新這里比較麻煩,所以推薦點(diǎn)擊到主頁,然后查看iOS云音樂專欄。
目簡介
這是一個使用OC語言(還有Swift,Android版本),從0開發(fā)一個iOS平臺,接近企業(yè)級的項目(我的云音樂),包含了基礎(chǔ)內(nèi)容,高級內(nèi)容,項目封裝,項目重構(gòu)等知識;主要是使用系統(tǒng)功能,流行的第三方框架,第三方服務(wù),完成接近企業(yè)級商業(yè)級項目。
目功能點(diǎn)
隱私協(xié)議對話框
啟動界面和動態(tài)處理權(quán)限
引導(dǎo)界面和廣告
輪播圖和側(cè)滑菜單
首頁復(fù)雜列表和列表排序
音樂播放和音樂列表管理
全局音樂控制條
桌面歌詞和自定義樣式
全局媒體控制中心
評論和回復(fù)評論
評論富文本點(diǎn)擊
評論提醒人和話題
朋友圈動態(tài)列表和發(fā)布
高德地圖定位和路徑規(guī)劃
阿里云OSS上傳
視頻播放和控制
QQ/微信登錄和分享
商城/購物車\微信\支付寶支付
文本和圖片聊天
消息離線推送
自動和手動檢查更新
內(nèi)存泄漏和優(yōu)化
...
發(fā)環(huán)境概述
2022年5月開發(fā)完成的,所以全部都是最新的,平均每3年會重新制作,現(xiàn)在已經(jīng)是第三版了。
Xcode 13.4
iOS 15
譯和運(yùn)行
先安裝pod,用最新Xcode打開MyCloudMusic.xcworkspace,然后運(yùn)行,如果要運(yùn)行到真機(jī),先登陸自己的開發(fā)者賬戶,如果不是付費(fèi)賬戶,請刪除推送等付費(fèi)功能,更改BundleId,然后運(yùn)行。
目目錄結(jié)構(gòu)
├── MyCloudMusic
│ ├── AppDelegate.h
│ ├── AppDelegate.m
│ ├── Assets.xcassets #資源目錄
│ ├── Base.lproj
│ ├── Cell #通用cell
│ ├── Component #每個功能模塊
│ │ ├── Ad #廣告相關(guān)
│ │ ├── Address #收貨地址相關(guān)
│ ├── Config #配置目錄,例如:網(wǎng)絡(luò)地址配置
│ ├── Controller #通用控制器
│ ├── Extension #擴(kuò)展,例如:字符串?dāng)U展
│ ├── Info.plist
│ ├── Manager #管理器,例如:音樂播放管理器
│ ├── Model #通用模型
│ ├── MyCloudMusic.entitlements
│ ├── Network
│ ├── PrefixHeader.pch
│ ├── Repository #數(shù)據(jù)倉庫,例如:網(wǎng)絡(luò)請求封裝
│ ├── 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
賴框架
內(nèi)容太多,只列出部分。
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的自由啟發(fā),獲取自動完成的本地化字符串、資產(chǎn)目錄圖像名稱和故事板對象
pod 'R.objc'
#輪播圖
#https://github.com/QuintGao/GKCycleScrollView
pod 'GKCycleScrollView'
#網(wǎng)絡(luò)框架
#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'
#極光統(tǒng)計
#https://docs.jiguang.cn/janalytics/guideline/intro/
pod 'JAnalytics'
#webview和js交互框架
#可以直接使用系統(tǒng)提供的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
戶協(xié)議對話框
使用自定義Dialog實(shí)現(xiàn)。
@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];
//內(nèi)容容器
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];
//標(biāo)題
[self.contentContainer addSubview:self.titleView];
self.textView=[UITextView new];
self.textView.myWidth=MyLayoutSize.fill;
//超出的內(nèi)容,自動支持滾動
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;
//點(diǎn)擊外部不隱藏
[self.modalController setModal:YES];
//邊距
self.modalController.contentViewMargins=UIEdgeInsetsMake(PADDING_LARGE2, PADDING_LARGE2, PADDING_LARGE2, PADDING_LARGE2);
//設(shè)置要顯示的內(nèi)容控件
self.modalController.contentViewController=self;
[self.modalController showWithAnimated:YES completion:nil];
}
- (void)hide{
[self.modalController hideWithAnimated:YES completion:nil];
}
#pragma mark - 創(chuàng)建控件
- (UILabel *)titleView{
if (!_titleView) {
_titleView=[UILabel new];
_titleView.myWidth=MyLayoutSize.fill;
_titleView.myHeight=MyLayoutSize.wrap;
_titleView.text=@"標(biāo)題";
_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
導(dǎo)界面
引導(dǎo)界面比較簡單,就是多個圖片可以左右滾動。
@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 輪播圖數(shù)據(jù)源
/// 有多少個
/// @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
廣告界面
實(shí)現(xiàn)圖片廣告和視頻廣告,廣告數(shù)據(jù)是在首頁是緩存到本地,目的是在啟動界面加載更快,因為真實(shí)項目中,大部分項目啟動頁面廣告時間一共就5秒,如果太長了用戶體驗不好,如果是從網(wǎng)絡(luò)請求,那么網(wǎng)絡(luò)可能就耗時2秒左右,所以導(dǎo)致就美喲多少時間顯示廣告了。
廣告
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{
//播放應(yīng)用內(nèi)嵌入視頻,放根目錄中
//同樣其他的文件,也可以通過這種方式讀取
//data = [NSBundle.mainBundle URLForResource:@"ixueaeduTestVideo" withExtension:@".mp4"];
_player = [AVPlayer playerWithURL:data];
//靜音
_player.muted = YES;
/// 添加進(jìn)度監(jiān)聽
__weak typeof(self) weakSelf = self;
[_player addPeriodicTimeObserverForInterval:CMTimeMake(1.0, 1.0) queue:dispatch_get_main_queue() usingBlock:^(CMTime time) {
//當(dāng)前時間,秒
Float64 current=CMTimeGetSeconds(weakSelf.player.currentItem.currentTime);
//總時間
CGFloat duration = CMTimeGetSeconds(weakSelf.player.currentItem.duration);
if (current==duration) {
//視頻播放結(jié)束
[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];
}
顯示圖片就是顯示本地圖片了,沒什么難點(diǎn),就不貼代碼了。
首頁/歌單詳情/黑膠唱片界面
首頁沒有頂部是輪播圖,然后是可以左右的菜單,接下來是熱門歌單,推薦單曲,最后是首頁排序模塊;整體上使用RecycerView實(shí)現(xiàn),輪播圖:
//輪播圖
BannerCell *cell = [tableView dequeueReusableCellWithIdentifier:BannerCellName forIndexPath:indexPath];
//綁定數(shù)據(jù)
[cell bind:data];
return cell;
詳情
頂部是歌單信息,通過Cell實(shí)現(xiàn),底部是列表,顯示歌單內(nèi)容的音樂,點(diǎn)擊音樂進(jìn)入黑膠唱片播放界面。
@implementation SheetDetailController
- (void)initViews{
[super initViews];
//添加背景圖片控件
_backgroundImageView = [UIImageView new];
//默認(rèn)隱藏
_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);
//點(diǎn)擊事件
[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:^{
//透明度設(shè)置為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) {
//有音樂才設(shè)置
//設(shè)置數(shù)據(jù)
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{
//把當(dāng)前歌單所有音樂設(shè)置到播放列表
//有些應(yīng)用
//可能會實(shí)現(xiàn)添加到已經(jīng)播放列表功能
[[MusicListManager shared] setDatum:self.data.songs];
//播放當(dāng)前音樂
[[MusicListManager shared] play:data];
[self startMusicPlayerController];
}
/// 有多少組
/// @param tableView <#tableView description#>
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView{
return self.datum.count;
}
/// 當(dāng)前組有多少個
/// @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;
//取出組數(shù)據(jù)
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];
}];
//綁定數(shù)據(jù)
[header bind:groupData];
//返回header
return header;
}
/// 返回當(dāng)前位置的cell
/// 相當(dāng)于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
唱片
上面是黑膠唱片,和網(wǎng)易云音樂差不多,隨著音樂滾動或暫停,頂部是控制相關(guān),音樂播放邏輯是封裝到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];
//默認(rèn)狀態(tài)
self.status = PlayStatusNone;
}
return self;
}
- (void)play:(NSString *)uri data:(Song *)data{
//設(shè)置音頻會話
[SuperAudioSessionManager requestAudioFocus];
//更改播放狀態(tài)
_status = PlayStatusPlaying;
//保存音樂對象
self.data = data;
NSURL *url=nil;
if ([uri hasPrefix:@"http"]) {
//網(wǎng)絡(luò)地址
url=[NSURL URLWithString:uri];
} else {
//本地地址
url=[NSURL fileURLWithPath:uri];
}
//創(chuàng)建一個播放Item
AVPlayerItem *item = [[AVPlayerItem alloc] initWithURL:url];
//替換掉原來的播放Item
[self.player replaceCurrentItemWithPlayerItem:item];
//播放
[self.player play];
...
}
-(void)prepareLyric{
//歌詞處理
//真實(shí)項目可能會
//將歌詞這個部分拆分到其他組件中
if (_data.parsedLyric) {
//解析好了
[self onLyricReady];
} else if(_data.lyric) {
//有歌詞,但是沒有解析
[self parseLyric];
}else{
//沒有歌詞,并且不是本地音樂才請求
//真實(shí)項目中可以會緩存歌詞
//獲取歌詞數(shù)據(jù)
[[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];
}
//通知歌詞準(zhǔn)備好了
[self onLyricReady];
}
-(void)onLyricReady{
if (self.delegate) {
[self.delegate onLyricReady:_data];
}
}
-(void)initListeners{
//KVO方式監(jiān)聽播放狀態(tài)
//KVC:Key-Value Coding,另一種獲取對象字段的值,類似字典
//KVO:Key-Value Observing,建立在KVC基礎(chǔ)上,能夠觀察一個字段值的改變
[self.player.currentItem addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];
//監(jiān)聽音樂緩沖狀態(tài)
[self.player.currentItem addObserver:self
forKeyPath:@"loadedTimeRanges" options:NSKeyValueObservingOptionNew
context:nil];
//播放結(jié)束事件
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(onComplete:)
name:AVPlayerItemDidPlayToEndTimeNotification
object:self.player.currentItem];
}
/// 播放完畢了回調(diào)
- (void)onComplete:(NSNotification *)notification {
self.complete(_data);
}
/// 移除監(jiān)聽器
-(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監(jiān)聽回調(diào)方法
/// @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
{
//判斷監(jiān)聽的字段
if ([keyPath isEqualToString:@"status"]) {
switch (self.player.status) {
case AVPlayerStatusReadyToPlay:
{
//準(zhǔn)備播放完成了
//音樂的總時間
self.data.duration= CMTimeGetSeconds(self.player.currentItem.asset.duration);
LogDebugTag(MusicPlayerManagerTag, @"observeValue status ReadyToPlay duration:%f",self.data.duration);
//回調(diào)代理
if (self.delegate) {
[self.delegate onPrepared:_data];
}
//更新媒體控制中心信息
[self updateMediaInfo];
}
break;
case AVPlayerStatusFailed:
{
//播放失敗了
_status = PlayStatusError;
LogDebugTag(MusicPlayerManagerTag, @"observeValue status play error");
}
break;
default:{
//未知狀態(tài)
LogDebugTag(MusicPlayerManagerTag, @"observeValue status unknown");
_status = PlayStatusNone;
}
break;
}
}
...
}
- (void)startPublishProgress{
//判斷是否啟動了
if (_playTimeObserve) {
//已經(jīng)啟動了
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);
//當(dāng)前播放的時間
self.data.progress = CMTimeGetSeconds(time);
//判斷是否有代理
if (!self.delegate) {
//沒有回調(diào)
//停止定時器
[self stopPublishProgress];
return;
}
//回調(diào)代理
[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{
//更改狀態(tài)
_status = PlayStatusPause;
//暫停
[self.player pause];
//移除監(jiān)聽器
[self removeListeners];
//回調(diào)代理
if (self.delegate) {
[self.delegate onPaused:_data];
}
//停止進(jìn)度分發(fā)定時器
[self stopPublishProgress];
}
- (void)resume{
//設(shè)置音頻會話
[SuperAudioSessionManager requestAudioFocus];
//更改播放狀態(tài)
_status = PlayStatusPlaying;
//播放
[self.player play];
...
}
- (void)seekTo:(float)data{
[self.player seekToTime:CMTimeMake(data, 1.0) toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero];
}
#pragma mark - 媒體中心
/// 更新系統(tǒng)媒體控制中心信息
/// 不需要更新進(jìn)度到控制中心
/// 他那邊會自動倒計時
/// 這部分可以重構(gòu)到公共類,因為像播放視頻也可以更新到系統(tǒng)媒體中心
-(void)updateMediaInfo{
//下載圖片,這部分應(yīng)該封裝,因為其他界面也用到了
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) {
//進(jìn)度,這里用不到
} 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;
}];
//設(shè)置封面
[songInfo setObject:albumArt forKey:MPMediaItemPropertyArtwork];
...
//設(shè)置到系統(tǒng)
[[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;
//設(shè)置播放完畢回調(diào)
[self.musicPlayerManager setComplete:^(Song * _Nonnull data) {
//判斷播放循環(huán)模式
if ([weakSelf getLoopModel] == MusicPlayRepeatModelOne) {
//單曲循環(huán)
[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;
}
/// 設(shè)置默認(rèn)播放音樂
-(void)defaultPlaySong{
_data=_datum[0];
}
/// 設(shè)置播放列表
- (void)setDatum:(NSArray *)datum{
//將原來數(shù)據(jù)playList標(biāo)志設(shè)置為false
[DataUtil changePlayListFlag:_datum inList:NO];
//保存到數(shù)據(jù)庫
[self saveAll];
//清空原來的數(shù)據(jù)
[_datum removeAllObjects];
//添加新的數(shù)據(jù)
[_datum addObjectsFromArray:datum];
//更改播放列表標(biāo)志
[DataUtil changePlayListFlag:_datum inList:YES];
//保存到數(shù)據(jù)庫
[self saveAll];
[self sendMusicListChanged];
}
/// 保存當(dāng)前播放列表到數(shù)據(jù)庫
-(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;
//標(biāo)記為播放了
self.isPlay = YES;
NSString *path;
//查詢是否有下載任務(wù)
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];
//設(shè)置最后播放音樂的Id
[PreferenceUtil setLastPlaySongId:_data.id];
}
/**
* 暫停
*/
- (void)pause{
LogDebugTag(MusicListManagerTag, @"pause");
[_musicPlayerManager pause];
}
...
/// 更改循環(huán)模式
- (MusicPlayRepeatModel)changeLoopModel{
//循環(huán)模式+1
_model++;
//判斷循環(huán)模式邊界
if (_model > MusicPlayRepeatModelRandom) {
//如果當(dāng)前循環(huán)模式
//大于最后一個循環(huán)模式
//就設(shè)置為第0個循環(huán)模式
_model = MusicPlayRepeatModelList;
}
//返回最終的循環(huán)模式
return _model;
}
/**
* 獲取循環(huán)模式
*/
- (MusicPlayRepeatModel)getLoopModel{
return _model;
}
- (Song *)getData{
return self.data;
}
/**
* 獲取上一個
*/
- (Song *)previous{
//音樂索引
NSUInteger index = 0;
//判斷循環(huán)模式
switch (self.model) {
case MusicPlayRepeatModelRandom:{
//隨機(jī)循環(huán)
//在0~datum.size()中
//不包含datum.size()
index = arc4random() % [_datum count];
}
break;
default:{
//找到當(dāng)前音樂索引
index = [_datum indexOfObject:self.data];
if (index != -1) {
//找到了
//如果當(dāng)前播放是列表第一個
if (index == 0) {
//第一首音樂
//那就從最后開始播放
index = [_datum count] - 1;
} else {
index--;
}
} else {
//拋出異常
//因為正常情況下是能找到的
}
}
break;
}
//獲取音樂
return [_datum objectAtIndex:index];
}
...
@end
外界統(tǒng)一使用播放列表管理器播放音樂,上一曲下一曲:
-(void)onLoopModelClick:(UIButton *)sender{
//更改循環(huán)模式
[[MusicListManager shared] changeLoopModel];
//顯示循環(huán)模式
[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]];
}
歌詞
歌詞實(shí)現(xiàn)了LRC,KSC兩種歌詞,封裝到LyricListView,單個歌詞行封裝到LyricView中,外界直接使用LyricListView就行:
/// 顯示歌詞數(shù)據(jù)
-(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{
//設(shè)置約束
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];
//創(chuàng)建一個水平方向容器
_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) {
//已經(jīng)計算了填充數(shù)量
[self next];
}
}
- (void)next{
//清空原來的歌詞
[_datum removeAllObjects];
if (_data) {
//添加占位數(shù)據(jù)
[self addLyricFillData];
[_datum addObjectsFromArray:_data.datum];
//添加占位數(shù)據(jù)
[self addLyricFillData];
}
_isReloadData=YES;
[_tableView reloadData];
}
/// 添加歌詞占位數(shù)據(jù)
/// 添加的目的是讓第一行歌詞也能顯示到控件垂直方向中心
-(void)addLyricFillData {
for (int i=0; i<_lyricPlaceholderSize; i++) {
[_datum addObject:@"fill"];
}
}
- (void)setProgress:(float)progress{
if(!_isReloadData && _lyricPlaceholderSize > 0){
//還沒有加載數(shù)據(jù)
//所以這里加載數(shù)據(jù)
[self next];
}
if (_data && _datum.count>0) {
if (_isDrag) {
//正在拖拽歌詞
//就直接返回
return;
}
//獲取當(dāng)前時間對應(yīng)的歌詞索引
NSInteger newLineNumber = [LyricUtil getLineNumber:_data progress:progress] + _lyricPlaceholderSize;
if (newLineNumber != _lyricLineNumber) {
//滾動到當(dāng)前行
[self scrollPosition:newLineNumber];
_lyricLineNumber = newLineNumber;
}
//如果是精確到字歌曲
//還需要將時間分發(fā)到item中
//因為要持續(xù)繪制
if (_data.isAccurate) {
NSObject *object = _datum[_lyricLineNumber];
if ([object isKindOfClass:[LyricLine class]]) {
//只有是歌詞行才處理
//獲取當(dāng)前時間是該行的第幾個字
NSInteger lyricCurrentWordIndex=[LyricUtil getWordIndex:object progress:progress];
//獲取當(dāng)前時間改字
//已經(jīng)播放的時間
NSInteger wordPlayedTime=[LyricUtil getWordPlayedTime:object progress:progress];
//獲取cell
LyricCell *cell= [self getCell:self.lyricLineNumber];
if (cell) {
//有可能獲取不到當(dāng)前位置的Cell
//因為上面使用了滾動動畫
//如果不使用滾動動畫效果不太好
//將當(dāng)前時間對應(yīng)的字索引設(shè)置到控件
[cell.lineView setLyricCurrentWordIndex:lyricCurrentWordIndex];
//設(shè)置當(dāng)前字已經(jīng)播放的時間
[cell.lineView setWordPlayedTime:wordPlayedTime];
//標(biāo)記需要繪制
[cell.lineView setNeedsDisplay];
}
}
}
}
}
...
#pragma mark - 列表數(shù)據(jù)源
/// 有多少個
/// @param tableView <#tableView description#>
/// @param section <#section description#>
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
return _datum.count;
}
/// 返回當(dāng)前位置的cell
/// @param tableView <#tableView description#>
/// @param indexPath <#indexPath description#>
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
//獲取cell
LyricCell *cell=[tableView dequeueReusableCellWithIdentifier:Cell forIndexPath:indexPath];
//設(shè)置Tag
cell.tag = indexPath.row;
//取出數(shù)據(jù)
NSObject *data = _datum[indexPath.row];
//綁定數(shù)據(jù)
[cell bind:data accurate:_data.isAccurate];
//返回cell
return cell;
}
#pragma mark - 滾動相關(guān)
/// 開始拖拽時調(diào)用
/// @param scrollView <#scrollView description#>
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{
LogDebugTag(LyricListViewTag, @"scrollViewWillBeginDragging");
[self showDragView];
}
/// 拖拽結(jié)束
/// @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];
}
}
/// 滾動結(jié)束(慣性滾動)
/// @param scrollView <#scrollView description#>
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
NSLog(@"lyric view scrollViewDidEndDecelerating");
//如果需要減速,在這里延時后,顯示歌詞
[self prepareScrollLyricView];
}
...
@end
控制器
使用了可以通過系統(tǒng)媒體控制器,通知欄,鎖屏界面,耳機(jī),藍(lán)牙耳機(jī)等設(shè)備控制媒體播放暫停,只需要把媒體信息更新到系統(tǒng):
- (void)setMediaInfo:(UIImage *)image{
//初始化一個可變字典
NSMutableDictionary *songInfo=[[NSMutableDictionary alloc] init];
//初始化一個封面
MPMediaItemArtwork *albumArt=[[MPMediaItemArtwork alloc] initWithBoundsSize:image.size requestHandler:^UIImage * _Nonnull(CGSize size) {
return image;
}];
//設(shè)置封面
[songInfo setObject:albumArt forKey:MPMediaItemPropertyArtwork];
//歌曲名稱
[songInfo setObject:self.data.title forKey:MPMediaItemPropertyTitle];
...
//設(shè)置到系統(tǒng)
[[MPNowPlayingInfoCenter defaultCenter] setNowPlayingInfo:songInfo];
}
媒體控制
/// 接收遠(yuǎn)程音樂播放控制消息
/// 例如:點(diǎn)擊耳機(jī)上的按鈕,點(diǎn)擊媒體控制中心按鈕等
/// @param event <#event description#>
- (void)remoteControlReceivedWithEvent:(UIEvent *)event{
//判斷是不是遠(yuǎn)程控制事件
if (event.type == UIEventTypeRemoteControl) {
if ([[MusicListManager shared] getData] == nil) {
//當(dāng)前播放列表中沒有音樂
return;
}
//判斷事件類型
switch (event.subtype) {
case UIEventSubtypeRemoteControlPlay:{
//點(diǎn)擊了播放按鈕
[[MusicListManager shared] resume];
NSLog(@"AppDelegate play");
}
break;
case UIEventSubtypeRemoteControlPause:{
//點(diǎn)擊了暫停
[[MusicListManager shared] pause];
NSLog(@"AppDelegate pause");
}
break;
case UIEventSubtypeRemoteControlNextTrack:{
//下一首
//雙擊iPhone有線耳機(jī)上的控制按鈕
Song *song = [[MusicListManager shared] next];
[[MusicListManager shared] play:song];
NSLog(@"AppDelegate Next");
}
break;
...
default:
break;
}
}
}
登錄/注冊/驗證碼登錄
登錄注冊沒有多大難度,用戶名和密碼登錄,就是把信息傳遞到服務(wù)端,可以加密后在傳輸,服務(wù)端判斷登錄成功,返回一個標(biāo)記,客戶端保存,其他需要的登錄的接口帶上;驗證碼登錄就是用驗證碼代替密碼,發(fā)送驗證碼都是服務(wù)端發(fā)送,客戶端只需要調(diào)用接口。
評論
評論列表包括下拉刷新,上拉加載更多,點(diǎn)贊,發(fā)布評論,回復(fù)評論,Emoji,話題和提醒人點(diǎn)擊,選擇好友,選擇話題等。
刷新和下拉加載更多
核心邏輯就只需要更改page就行了
//下拉刷新
MJRefreshNormalHeader *header=[MJRefreshNormalHeader headerWithRefreshingBlock:^{
@strongify(self);
[self loadData];
}];
//隱藏標(biāo)題
header.stateLabel.hidden = YES;
// 隱藏時間
header.lastUpdatedTimeLabel.hidden = YES;
self.tableView.mj_header=header;
//上拉加載更多
MJRefreshAutoNormalFooter *footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{
@strongify(self);
[self loadMore];
}];
// 設(shè)置空閑時文字
[footer setTitle:@"" forState:MJRefreshStateIdle];
self.tableView.mj_footer = footer;
人和話題點(diǎn)擊
通過正則表達(dá)式,找到特殊文本,然后使用富文本實(shí)現(xiàn)點(diǎn)擊。
/// 處理文本點(diǎn)擊事件
/// 這部分可以用監(jiān)聽器回調(diào)到界面處理
/// @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結(jié)構(gòu)
[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 - 列表數(shù)據(jù)源
/// 返回當(dāng)前位置的cell
/// 相當(dāng)于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
視頻和播放
真實(shí)項目中視頻播放大部分都是用第三方服務(wù),例如:阿里云視頻服務(wù),騰訊視頻服務(wù),因為他們提供一條龍服務(wù),包括審核,轉(zhuǎn)碼,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];
//
// //設(shè)置標(biāo)題
// [self.playerView.controlView setTitle:data.title];
}
用戶詳情/更改資料
用戶詳情頂部顯示用戶信息,好友數(shù)量,下面分別顯示創(chuàng)建的歌單,收藏的歌單,發(fā)布的動態(tài),類似微信朋友圈,右上角可以更改用戶資料;使用第三方框架里面的kJXPagingListRefreshView控件實(shí)現(xiàn)。
-(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)];
//標(biāo)題
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];
//默認(rèn)顏色
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;
}
然后就是把每個子界面放到單獨(dú)View中,并在代理方法返回就行了。
發(fā)布動態(tài)/選擇位置/路徑規(guī)劃
發(fā)布效果和微信朋友圈類似,可以選擇圖片,和地理位置;地理位置使用高德地圖實(shí)現(xiàn)選擇,路徑規(guī)劃是調(diào)用系統(tǒng)中安裝的地圖,類似微信。
位置
/// 搜索該位置的poi,方便用戶選擇,也方便其他人找
-(void)searchPOI{
//LogDebug(@"searchPOI %f %f %@",data.);
if (_keyword) {
//關(guān)鍵字搜索
AMapPOIKeywordsSearchRequest *request = [AMapPOIKeywordsSearchRequest new];
//關(guān)鍵字
request.keywords=_keyword;
//距離排序
request.sortrule = 0;
//是否返回擴(kuò)展信息
request.requireExtension=YES;
[self.search AMapPOIKeywordsSearch:request];
} else {
//搜索位置附近
AMapPOIAroundSearchRequest *request = [AMapPOIAroundSearchRequest new];
request.location=[AMapGeoPoint locationWithLatitude:_coordinate.latitude longitude:_coordinate.longitude];
//距離排序
request.sortrule=0;
//是否返回擴(kuò)展信息
request.requireExtension=YES;
[self.search AMapPOIAroundSearch:request];
}
}
地圖路徑規(guī)劃
+ (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];
}
聊天/離線推送
大部分真實(shí)項目中聊天都會選擇第三方商業(yè)級付費(fèi)聊天服務(wù),常用的有騰訊云聊天,融云聊天,網(wǎng)易云聊天等,這里選擇融云聊天服務(wù),使用步驟是先在服務(wù)端生成聊天Token,這里是登錄后返回,然后客戶端登錄聊天服務(wù)器,然后設(shè)置消息監(jiān)聽,發(fā)送消息等。
聊天服務(wù)器
/// 連接聊天服務(wù)器
/// @param data <#data description#>
-(void)connectChat:(Session *)data{
[[RCIMClient sharedRCIMClient] connectWithToken:data.chatToken dbOpened:^(RCDBErrorCode code) {
//消息數(shù)據(jù)庫打開,可以進(jìn)入到主頁面
} success:^(NSString *userId) {
//連接成功
} error:^(RCConnectErrorCode status) {
if (status == RC_CONN_TOKEN_INCORRECT) {
//從 APP 服務(wù)獲取新 token,并重連
} else {
//無法連接到 IM 服務(wù)器,請根據(jù)相應(yīng)的錯誤碼作出對應(yīng)處理
}
//因為我們這個應(yīng)用,不是類似微信那樣純聊天應(yīng)用,所以聊天服務(wù)器連接失敗,也讓進(jìn)入應(yīng)用
//真實(shí)項目中按照需求實(shí)現(xiàn)就行了
[SuperToast showWithTitle:R.string.localizable.errorMessageLogin];
}];
}
消息監(jiān)聽
- (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];
}
//發(fā)送消息到通知(這個通知是,跨界面通訊,不是顯示到通知欄)
[NSNotificationCenter.defaultCenter postNotificationName:ON_MESSAGE object:nil userInfo:@{@"data":message}];
//發(fā)送消息未讀數(shù)改變了通知
[NSNotificationCenter.defaultCenter postNotificationName:ON_MESSAGE_COUNT_CHANGED object:nil userInfo:nil];
});
}
文本消息
發(fā)送圖片等其他消息也是差不多。
/// 發(fā)送文本消息
-(void)sendTextMessage{
NSString *result=_contentInputView.text;
if([StringUtil isBlank:result]){
[SuperToast showWithTitle:R.string.localizable.hintEnterMessage];
return;
}
//1.構(gòu)造文本消息
RCTextMessage *txtMsg = [RCTextMessage messageWithContent:result];
//2.將文本消息發(fā)送出去
[[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(@"消息發(fā)送成功,message id 為 %@",@(messageId));
dispatch_async(dispatch_get_main_queue(), ^{
//清空輸入框
[self clearInput];
});
[self addMessage:[[RCIMClient sharedRCIMClient] getMessage:messageId]];
} error:^(RCErrorCode nErrorCode, long messageId) {
NSLog(@"消息發(fā)送失敗,錯誤碼 為 %@",@(nErrorCode));
}];
}
離線推送
需要付費(fèi)蘋果開發(fā)者賬戶,先開啟SDK離線推送,然后在蘋果開發(fā)者后臺創(chuàng)建推送證書,配置到融云,最后在代碼中處理通知點(diǎn)擊等。
/// 界面已經(jīng)顯示了
/// @param animated <#animated description#>
- (void)viewDidAppear:(BOOL)animated{
[super viewDidAppear:animated];
//延時的目的是讓當(dāng)前界面顯示出來以后,在檢查
//檢查是否需要處理通知點(diǎn)擊
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(500 * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{
//檢查是否需要處理通知點(diǎn)擊
[self checkProcessNotificationClick];
});
}
/// 檢查是否需要處理通知點(diǎn)擊
-(void)checkProcessNotificationClick{
if ([AppDelegate shared].pushData) {
[self processPushClick:[AppDelegate shared].pushData];
[AppDelegate shared].pushData=nil;
}
}
商城/訂單/支付/購物車
學(xué)到這里,大家不能說熟悉,那么看到上面的界面,那么大體要能實(shí)現(xiàn)出來。
詳情富文本
//詳情
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,然后請求服務(wù)端獲取支付信息,設(shè)置到SDK,最后就是處理支付結(jié)果。
/// 處理支付寶支付
/// @param data <#data description#>
- (void)processAlipay:(NSString *)data{
//支付寶官方開發(fā)文檔:https://docs.open.alipay.com/204/105295/
[[AlipaySDK defaultService] payOrder:data fromScheme:ALIPAY_CALLBACK_SCHEME callback:^(NSDictionary *resultDic) {
//如果手機(jī)中沒有安裝支付寶客戶端
//會跳轉(zhuǎn)H5支付頁面
//支付相關(guān)的信息會通過這個方法回調(diào)
//處理支付寶支付結(jié)果
[self processAlipayResult:resultDic];
}];
}
支付結(jié)果
/// 處理支付寶支付結(jié)果
/// @param data <#data description#>
- (void)processAlipayResult:(NSDictionary *)data{
NSString *resultStatus=data[@"resultStatus"];
if ([@"9000" isEqualToString:resultStatus]) {
//本地支付成功
//不能依賴本地支付結(jié)果
//一定要以服務(wù)端為準(zhǔn)
[SuperToast showLoading:R.string.localizable.hintPayWait];
[self checkPayStatus];
//這里就不根據(jù)服務(wù)端判斷了
//購買成功統(tǒng)計
[AnalysisUtil onPurchase:YES data:self.data];
}if ([@"6001" isEqualToString:resultStatus]) {
//取消了
[self showCancel];
} else {
//支付失敗
[self showPayFailedTip];
}
}
項目總結(jié)
總體來說項目功能還是很全的,還有一些小功能,例如:快捷方式等就不在貼代碼了,但肯定沒發(fā)和原版比,相信大家只要做過程序員就能理解,畢竟原版是一個商業(yè)級項目,幾十個人天天開發(fā)和維護(hù),而且持續(xù)了幾年了;不過恕我直言,現(xiàn)在的常見的音樂軟件都太復(fù)雜了,各種功能,不過都要恰飯,好像又能理解了??。
浙公網(wǎng)安備 33010602011771號