開源解析 iOS App - DoubanFM
本文目錄 -
簡介
這個專案是透過豆瓣API播放音樂的開源iOS App,要注意的是豆瓣API是透過逆向工程而得的資訊,並沒有官方文件與授權,所以本文以技術分析為主,如有侵權煩請告知.
在介紹本專案之前,想先介紹豆瓣FM這個產品,在官方簡介上是這麼說的 “豆瓣FM采用个性化推荐技术作为核心的算法 它简单、聪明,熟悉每个用户的脾气 每个人的收听都与众不同”,我想有許多人對於豆瓣App是相當熟悉,沒有使用過的讀者可以先下載使用看看後應該就能感受到豆瓣是如何具體實現這樣的概念,從UI的設計到推薦歌曲,從前端到後端,所有的設計或工程思維都圍繞在這件事情上面,從前端設計來看,主要功能按鈕只有三個,刪除,愛心與下一首,分別代表討厭,喜愛跟不喜歡目前播放的歌曲,而兆赫的選擇則是確定了使用者喜歡的音樂類型或風格,而後台累積了多年的經驗與數據通常系統推薦的歌曲十之八九都是使用者不討厭甚至是喜歡的,對我來說這個專案最大的收穫不在於技術上的累積,而是看到了一個設計跟工程完美結合的一個產品.
note:
專案連結 link
豆瓣FM官方網站 link
豆瓣FM官方iOS開源項目link
一篇關於豆瓣FM 4.0的介紹link
一篇關於豆瓣FM的使用者體驗報告 link
一段介紹豆瓣FM設計概念的影片 link
iOS函式庫
- MPMoviePlayerController (在iOS9之後的版本不建議使用,建議以AVPlayer取代)
第三方函式庫
- AFNetworking
- MJRefresh
- MJExtension
- SDWebImage
- Masonry
- YYKit
- CDSideBarController
note:
一篇對於YYKit作者的介紹 link
資料夾結構

從上圖來看此專案作者按著了傳統的MVC的架構作為資料夾結構,而最上層有一個第三方函式庫管理工具Podfile與一個UI元件CDSideBarController.
RootViewControl
從Main.storyboard中可以知道根頁面是SidebarController,從SidebarController.h檔觀察到一件有趣的事情:
@interface SidebarController : UITabBarController<CDSideBarControllerDelegate>{
在進一步分析之前先看一下實際上的UI操作,下圖是開啟App後的預設畫面,如果你執行後的畫面跟我的不同是正常的,因為中間顯示的是目前播放歌曲的專輯封面,而歌曲是隨機挑選的,注意到右上角選單的按鈕,

按下去之後,會從右邊展開一系列的選單,中間的主頁面會隨著不同的按鈕切換頁面,回到一開始的宣告作者就是透過UITabBarController + CDSideBarControllerDelegate完成這件事情,

雖然根目錄是設定為SidebarController,但因為是繼承於UITabBarController,所以要在更進一步去觀察SidebarController中的內容:
self.viewControllers = @[playerVC, channelsVC, userInfoVC];
從上面那一程式可以知道,在初始畫面上使用者實際看到的是playerVC(PlayerViewController).
程式進入點
主要功能的進入點在於PlayerViewController的viewDidLoad開始看起:
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = RGB(239, 239, 244);
[self p_addSubViews];
[self p_configConstrains];
[self p_loadPlaylist];
[PlayerController sharedInstance].songInfoDelegate = self;
timer = [NSTimer scheduledTimerWithTimeInterval:0.02
target:self
selector:@selector(updateProgress)
userInfo:nil
repeats:YES];
}
- [self p_addSubViews]是將所有的UI元件加入VeiwController之中
- [self p_configConstrains]是調整UI元件的位置,基本上就是在做AutoLayout的工作
- [self p_loadPlaylist]是透過豆瓣API取得音樂資訊並執行播放的功能
- 接下來就是定義參數與建立一個timer定時更新UI頁面.
核心物件
PlayerController
這個物件儲存了音樂來源與提供播放音樂的控制選項,參考.h檔有以下功能:
-(void)startPlay;
-(void)pauseSong;
-(void)restartSong;
-(void)likeSong;
-(void)dislikesong;
-(void)deleteSong;
-(void)skipSong;
基本上官方App該有的功能都有,而從另外一個觀點來看PlayerController外層包了一個PlayerViewController處理使用者介面的操作,這樣的設計是稍微可以用MVC的方式來解釋,就是分離了View跟Controller,讓層次與架構更清楚.
NetworkManager
這個物件主要是跟豆瓣的伺服器溝通,觀察.h檔提供了以下的API:
-(void)LoginwithUsername:(NSString *)username
Password:(NSString *)password
Captcha:(NSString *)captcha
RememberOnorOff:(NSString *)rememberOnorOff;
-(void)logout;
-(void)loadCaptchaImage;
-(void)loadPlaylistwithType:(NSString *)type;
前兩個是跟帳號有關的登入與登出,第三個是加載登入時所需要的驗證碼圖檔,最後一個則是跟播放音樂有關的API,目前已知的type與代表的功能如下:
//获取播放列表信息
//type
//n : None. Used for get a song list only.
//e : Ended a song normally.
//u : Unlike a hearted song.
//r : Like a song.
//s : Skip a song.
//b : Trash a song.
//p : Use to get a song list when the song in playlist was all played.
//sid : the song's id
-(void)loadPlaylistwithType:(NSString *)type
搭配著PlayerController的API會更清楚呼叫流程,
- startPlay -> [networkManager loadPlaylistwithType:@”p”] 如果已經播放到最後一首歌才會呼叫networkManager,否則就繼續播放清單中的下一首歌
- likeSong -> [networkManager loadPlaylistwithType:@”r”]
- dislikesong -> [networkManager loadPlaylistwithType:@”u”]
- deleteSong -> [networkManager loadPlaylistwithType:@”b”]
- skipSong -> [networkManager loadPlaylistwithType:@”s”]
向作者提問
- 為何使用者的資料是透過NSKeyedUnarchiver儲存而不是NSUserDefaults
- DFMHotChannelsEntity等三個物件已沒有再使用,當初建立的目的是什麼?
心得
由於目前使用的url都是http開頭,所以要在plist.info中加入 NSAllowsArbitraryLoads = ture的選項才能播放
透過字義來看”NetworkManager”是這個App的網路層,應該獨立於其他物件之中,但事實上卻不然,在這個物件中與其他物件有很高的相依性,舉例來說loadPlaylistwithType不只是取得播放清單,還直接啟動了播放的功能[[PlayerController sharedInstance] play],違反了在軟體工程中高內聚低耦合的特性.
-(void)loadPlaylistwithType:(NSString *)type{ //playlisturl - http://douban.fm/j/mine/playlist?type=n&sid=(null)&pt=0.000000&channel=0&from=mainsite NSString *playlistURLString = [NSString stringWithFormat:PLAYLISTURLFORMATSTRING, type, [SongInfo currentSong].sid, [PlayerController sharedInstance].currentPlaybackTime, [ChannelInfo currentChannel].id]; NSLog(@"play list url:%@",playlistURLString); manager.responseSerializer = [AFJSONResponseSerializer serializer]; [manager GET:playlistURLString parameters:nil success:^(AFHTTPRequestOperation *operation, id responseObject) { DFMPlaylist *playList = [PlayerController sharedInstance].playList; NSDictionary *songDictionary = responseObject; playList = [DFMPlaylist objectWithKeyValues:songDictionary]; if ([type isEqualToString:@"r"]) { [SongInfo setCurrentSongIndex:-1]; } else{ if ([playList.song count] != 0) { //若成功取得播放清單,就會執行播放! [SongInfo setCurrentSongIndex:0]; [SongInfo setCurrentSong:[playList.song objectAtIndex:[SongInfo currentSongIndex]]]; [[PlayerController sharedInstance] setContentURL:[NSURL URLWithString:[SongInfo currentSong].url]]; [[PlayerController sharedInstance] play]; } //如果是未登录用户第一次使用红心列表,会导致列表中无歌曲 else{ UIAlertView *alertView = [[UIAlertView alloc]initWithTitle:@"HeyMan" message:@"红心列表中没有歌曲,请您先登陆,或者添加红心歌曲" delegate:self cancelButtonTitle:@"GET" otherButtonTitles: nil]; [alertView show]; ChannelInfo *myPrivateChannel = [[ChannelInfo alloc]init]; myPrivateChannel.name = @"我的私人"; myPrivateChannel.id = @"0"; [ChannelInfo updateCurrentCannel:myPrivateChannel]; } } [self.delegate reloadTableviewData]; } failure:^(AFHTTPRequestOperation *operation, NSError *error) { //FIXME: 或许信息失败有点bug,先这样把 = = // UIAlertView *alertView = [[UIAlertView alloc]initWithTitle:@"HeyMan" message:@"登陆失败啦" delegate:self cancelButtonTitle:@"哦,酱紫" otherButtonTitles: nil]; // [alertView show]; // NSLog(@"LOADPLAYLIST_ERROR:%@",error); }]; }
Delegate不好的使用方式
首先在PlayerViewController的ViewDidLoad中可以看到下面這一行程式碼:[PlayerController sharedInstance].songInfoDelegate = self;
這是一般Delegate的呼叫方式,但問題出在下面這行
-(void)initSongInfomation{ [self.songInfoDelegate initSongInfomation]; }
原作者並沒有先確認self.songInfoDelegate是否有被委託(!=nil),也沒有確認是否有實作initSongInfomation的函式存在,比較好的實作方式為:
if (self.songInfoDelegate && [self.songInfoDelegate respondsToSelector:@selector(initSongInfomation)]) { [self.songInfoDelegate performSelector:@selector(initSongInfomation)]; }
觀察原作者建立一個ProtocolClass,我想用意應該是要定義所有的Delegate,像是DoubanDelegate就被定義在裡面,這是比較少見到的做法,通常都會跟相關的物件宣告在一起,但哪種做法比較好值得思考一下~
原作者在此專案使用了許多的第三方函式庫,我個人原則是以原生函式庫為主,若非必要則不考慮,主要考量為長期支援與安全問題,當然有些第三方開發出來的函式庫更具簡便性與效率,這是值得好好衡量的問題.
- 由於這個專案是蠻早之前就開發了,我打算使用Swift3.0程式語言與AVPlayer改寫這個App,敬請期待.
參考資料
專案連結 link
一篇關於 mvmplayer與avplayer比較的文章 link