閱讀949 返回首頁    go 阿裏雲 go 技術社區[雲棲]


OPhone網絡應用編程實例: 豆瓣電台客戶端

 

本文以豆瓣電台客戶端介紹了在OPhone平台上網絡應用程序開發所涉及到的一些典型問題和處理方案。 豆瓣電台是隻為你量身訂做的音樂服務,是你的私人電台,知道你下一首歌想聽什麼。欲知詳情請勐擊https://douban.fm

說明:本文的客戶端是開發版本,電台logo等圖標做了虛化處理,以避免誤導; 另外源碼涉及到豆瓣內部api的地方也省略。

功能需求
下麵是豆瓣電台網絡版和客戶端(開發版)的界麵。

(圖)OPhone網絡應用編程實例: 豆瓣電台客戶端

豆瓣電台網絡版

下圖的豆瓣電台客戶端和網絡版一樣,除了播放歌曲,顯示專輯 封麵, 播放時間,歌手和歌的名稱,以及喜歡/不喜歡,垃圾桶,跳過的3個操作按鈕外,還涉及到登錄,更換用戶,暫停的操作。

(圖)OPhone網絡應用編程實例: 豆瓣電台客戶端

設計需求
豆瓣電台是網絡音樂服務,客戶端需要有後台播放的服務,前台的播放器以及任務條的通知;
推薦給用戶的歌曲列表是從豆瓣網站上獲取的,歌曲也是在線播放的,需要網絡數據的獲取和異常處理;
本地還要保存一些數據,比如播放曆史和自動登錄使用的信息;
網絡操作都是比較耗時的,所以操作需要做成異步的方式來通知更新UI;
針對手機應用的功能:比如接電話自動暫停,掛電話自動繼續播放,橫豎屏轉換要加載不同的UI Layout;
利用OPhone平台提供的一些特性來改進用戶體驗,比如使用Toast提示,動畫效果,重力感應,手勢等
架構設計
針對上麵的應用本身的功能和設計的需求,主要的架構就是前台Player的Activity,主要負責界麵顯示和用戶的交互; 加上後台的 Service,負責歌曲的播放和向豆瓣服務器發送請求,是個C/S的結構。

如下圖所示:

(圖)OPhone網絡應用編程實例: 豆瓣電台客戶端

Player和Service的架構

Player調用Service
使用OMS平台提供的service機製,通過下麵的aidl把接口定義好。比如用戶點跳過按鈕時,Player就會調用Service的skip。

interface IRadioService{ void stop(); void exit(); void play(); void like(boolean isLike); void skip(); void hate(); boolean isPlaying(); Song getSongPlaying(); void logout(); Bitmap getSongPicture(); int pos(); int duration(); } 


這裏值得注意的是service的啟動方式:


private static final Intent service_intent = new Intent(Consts.INTENT_RADIO_SERVICE); protected void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); startService(service_intent); } 


在Player的onCreate方法裏麵使用startService,這樣在Player Activity退出時,Service進程不會被殺死。而使用bindService方式 啟動的service會在所有的client unbind後結束。

然後要在onResume和onDestroy的時候分別進行bindService和unbindService。


protected void onResume(){ super.onResume(); bindService(service_intent, connection, Context.BIND_AUTO_CREATE); } protected void onDestroy(){ super.onDestroy(); unbindService(connection); } 


另外,如果希望service返回你自定義的對象,你需要實現parcelable接口,比如上麵的Song。

Service裏麵的Handler
因為網絡通訊都是比較耗時的。比如上麵的skip,是要向豆瓣提交跳過的是哪首歌的信息,並獲取新的播放列表。實際上,為了比較好的用戶體驗,用戶點跳過按鈕時,在該按鈕的onClick方法裏麵調用了Service的skip,而Service隻是向Downloader發了一個消息,告訴執行了skip操作就返回了。這樣按鈕就不會一直停在按下去的狀態。


private Handler downloader = new Handler() { public void handleMessage(Message msg) { Bundle bundle = msg.getData(); switch (msg.what){ case Consts.MSG_PLAYLIST_REQUIRE: //下載播放列表 requireList(...); //播放下一首 playNext(); break; case Consts.MSG_PICTURE_DOWNLOADING: //下載圖片 pic = web.getImage(bundle.getString("pic_url")); //圖片下載完成,通知Player更新圖片 Intent intent = new Intent(Consts.INTENT_UPDATE_SONG_PICTURE); sendBroadcast(intent); break; ... } } }; 

如上麵代碼所示,在service裏麵的Downloader是一個Handler, Handler本身實現了一個消息隊列,在handleMessage函數裏麵來處理消息。這裏的Handler是在service線程的,並沒有新起線程。因為這裏用Handler最主要的目的是使Player的調用馬上返回,達 到異步的目的。上麵的skip就是向Downloader發了一個消息,而Downloader收到這個消息後,就去下載新的列表。

這裏的Downloader最早的設計是在一個Thread裏的,但是那樣需要service的主線程也要有一個handler來處理Thread發給主線程 的消息,比較複雜。而且對於電台本身來講,都是先下載播放列表,開始播放後,才需要下載正在播的歌曲的封麵圖片,所以不需要真正的並發下載,也就是說,同一時刻隻有一個下載的任務在執行就可以了。所以最後是使用現在的設計,可以避免多創建一個線 程,成本更低。

Service通知Player
上麵講了Player怎麼調用Service的,但Service還需要通知Player。比如當圖片下載完成時, Service需要通知Player來拿圖片,因為 Service和Player是不同進程,所以在Player裏麵注冊了一個Receiver來實現的。

BroadcastReceiver本身可以接受Intent,也可以設置filter接受特定的Intent,然後在onReceive函數裏麵來具體處理。


private BroadcastReceiver receiver = new BroadcastReceiver(){ public void onReceive(Context context, Intent intent) { if (intent.getAction.equals(Consts.INTENT_UPDATE_SONG_PICTURE)){ updateSongPicture(); //更新專輯封麵 } ... } } 


上麵的代碼裏,就是在Service裏麵圖片下載完成後sendBroadcast,然後Receiver收到後去更新專輯封麵。這裏要注意的是,要在 Player的onResume和onPause方法裏麵分別調用registerReceiver和unregisterReceiver。


protected void onResume(){ super.onResume(); ... filter = new IntentFilter(); filter.addAction(Consts.INTENT_UPDATE_SONG_PICTURE); ... registerReceiver(receiver, filter); } protected void onPause(){ super.onPause(); unregisterReceiver(receiver); } 


Login裏麵的Handler
Login也是一個單獨的Activity,左圖為Login的頁麵,因為登錄比 較耗時,所以要顯示一個有進度的提示框給用戶,告訴用戶正在 登錄。

這個時候,登錄的網絡操作是新起一個線程去做的,因為OPhone的 UI是單線程的模型,在子線程裏是不能直接去碰UI的。所以子線 程完成登錄要在主線程裏麵有一個Handler去處理子線程的消息 來更新UI。

如下圖所示

(圖)OPhone網絡應用編程實例: 豆瓣電台客戶端

下麵是在登錄按鈕的OnClickListener裏麵onClick方法裏麵的調用,向子線程發完消息後就會返回,不會阻塞住UI。


Message msg = looper.handler.obtainMessage(MSG_LOGIN); Bundle bundle = new Bundle(); ... //向子線程發消息執行login looper.handler.sendMessage(msg); //顯示進度對話框 dialog.show(); 


private Handler mainHandler = new Handler() { public void handleMessage(Message msg) { switch (msg.what){ case MSG_DONE: dialog.dismiss(); //把對話框關掉 int error = msg.arg1; if (error == 0){ Intent intent = new Intent(Consts.INTENT_RADIO_PLAYER); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(intent); //啟動Player finish(); }else{ showToast(error); //顯示錯誤提示 } ... } } }; 


下麵是執行登錄的子線程,這裏使用了平台提供的Looper,可以方便的在線程裏實現一個消息隊列,然後用一個Handler來處理消 息。Looper的用法很簡單,隻需要在循環的開始和結束分別進行prepare和loop就好了。


private class LooperThread extends Thread { private Handler handler; public void run() { Looper.prepare(); handler = new Handler(){ public void handleMessage(Message msg) { switch (msg.what) { case MSG_LOGIN: ... //執行login網絡操作 Message m = mainHandler.obtainMessage(MSG_DONE); ... mainHandler.sendMessage(m); //完成後給主線程發消息 break; ... } }; Looper.loop(); } } } 

OPhone平台的進程/線程間通訊
上麵用到了Service,Receiver和Handler,裏麵涉及到了不同進程,不同線程間的通訊。 進程間通訊是基於libutils的Binder機製的,這裏簡單的總結一下:

Service和Client是比較緊的耦合,通過aidl來定義接口,Service接口返回的數據類型必須實現parcelable。
BroadcastReceiver是廣播和訂閱的模式,比較鬆散,是用發送Intent來通訊的,數據的話可以放到Bundle裏。
受限於UI的單線程模型,需要把比較耗時的操作用子線程來做,避免UI阻塞。平台提供了Handler和Looper,可以比較方便的在主線程和子線程之間通訊。這種方法比AsycTask的方式要更靈活,而且避免了反複創建啟動線程的開銷。

網絡操作
下麵是網絡操作涉及到的一些問題,使用的是Apache接口。

 設置連接參數

HttpParams params = new BasicHttpParams(); //設置超時為Consts.TIMEOUT毫秒 HttpConnectionParams.setConnectionTimeout(params, Consts.TIMEOUT); //buffer大小 HttpConnectionParams.setSocketBufferSize(params, Consts.BUFFER_SIZE); //user agent HttpProtocolParams.setUserAgent(params, Consts.USER_AGENT); DefaultHttpClient client = new DefaultHttpClient(params); 

HttpGet
以get方法訪問一個url,注意args需要用URLEncoder.encode()處理下。一般網絡調用返回JSON格式,可以把返回的字符串轉換成 JSONObject就可以處理了。


public String getString(String path, String args) throws Exception{ client.getCookieStore().clear(); //清cookie,根據需要設置 HttpGet get = new HttpGet(new URI("http", null, Consts.HOST, 80, path, args, null)); HttpResponse response = client.execute(get); HttpEntity entity = response.getEntity(); return EntityUtils.toString(entity);} 


HttpPost

public String post(String url, List <NameValuePair> nvps) throws Exception{ HttpPost httpost = new HttpPost(url); httpost.setEntity(new UrlEncodedFormEntity(nvps, HTTP.UTF_8)); HttpResponse response = client.execute(httpost); HttpEntity entity = response.getEntity(); return EntityUtils.toString(entity); } 


下載圖片
OPhone平台沒有UI控件可以直接顯示一個網絡上的圖片,必須要先下載下來再顯示.

public Bitmap getImage(String url) throws Exception{ HttpGet get = new HttpGet(url); HttpResponse response = client.execute(get); HttpEntity entity = response.getEntity(); byte[] data = EntityUtils.toByteArray(entity); return BitmapFactory.decodeByteArray(data, 0, data.length); } 


數據庫操作
OPhone平台的數據庫是Sqlite,並且提供了SQLiteOpenHelper這個類來方便使用

數據庫表創建和設計

class DatabaseHelper extends SQLiteOpenHelper{ public DatabaseHelper(Context context){ super(context, DATABASE_NAME, null, DATABASE_VERSION); } public void onCreate(SQLiteDatabase db) { db.execSQL(create_table_sql); //這裏創建表 } public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { //當數據庫版本升級的時候會調這個函數 } } 


查詢
這裏使用的rawQuery,直接是執行sql,我覺得比別的接口更靈活。這裏注意的是,從cursor裏取數據時,首先要cursor.moveToFirst... 還有記得cursor和help最後都要調他們的close方法來釋放資源,否則會內存泄漏


public String[] getStrings(){ SQLiteDatabase db = helper.getReadableDatabase(); try { Cursor cursor = db.rawQuery("select col from table", null); int total = cursor.getCount(); if (total > 0){ String[] ss = new String[total]; cursor.moveToFirst(); for (int i =0; i < total; i++){  
                ss[i] = cursor.getString(0);  
                cursor.moveToNext();  
            }  
            cursor.close();  
            return ss;  
        }  
        cursor.close();  
    } catch (SQLiteException e) {  
    }  
    return null;  

UI相關
下麵是電台這個應用涉及到的一些比較常用的UI組件的用法介紹。

(圖)OPhone網絡應用編程實例: 豆瓣電台客戶端

Notification


電台在後台播放時,Service會在任務欄上放一個通知,以便Player退出時,來顯示當前播放的歌曲,而且點通知會打開Player。Notification在下來列表裏的UI是RemoteView, 可以設置簡單的view進去,比如TextView,ImageView,也可以用自己的layout,但是不能設置別的複雜View。如果有人找到方法,請發個mail給我。: )


private void sendNotifcation(){  
    int icon = R.drawable.title_icon;  
    CharSequence tickerText = context.getString(R.string.notification_title);  
    long when = System.currentTimeMillis();  
    if (notification == null){  
        notification = new Notification(icon, tickerText, when);  
        notification.flags = Notification.FLAG_NO_CLEAR; //設置不能被清除  
    }  
   
    Intent notificationIntent = new Intent(Consts.INTENT_RADIO_PLAYER);  
    PendingIntent contentIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0);  
    RemoteViews contentView = new RemoteViews(getPackageName(), R.layout.notification);  
    contentView.setImageViewResource(R.id.notification_image, R.drawable.icon);  
    contentView.setTextViewText(R.id.songName, song.title);  
    contentView.setTextViewText(R.id.artistName, song.artist);  
   
    notification.contentView = contentView;  
    notification.contentIntent = contentIntent;  
   
    notifyManager.notify(Consts.NOTIFICATION_PLAY, notification);  


帶進度條的對話框
如果隻是像登錄時那個登錄進度框的話,比較簡單,就像下麵的方式定義一個ProgressDialog就好了。如果需要自己來更新進度的話,就要在handler裏麵去更新進度值了。


dialog = new ProgressDialog(this);  
dialog.setMessage(getResources().getString(R.string.logging));  
dialog.setIndeterminate(true);  
dialog.setCancelable(true); 

對話框風格的Activity
有時候你需要一個長的像Dialog的Activity,比如之前的登錄頁麵。那麼需要就使用style,就像css一樣,但是還沒那麼好用就是了。如下


<activity Android:name=".Login" ...   
            android:theme="@style/DoubanTheme.Dialog">  
...  
</activity>   
   
而style在res/value/style.xml裏麵定義  
<resources>  
    <style name="DoubanTheme.Dialog" parent="@android:style/Theme.Dialog">  
        <item name="android:windowNoTitle">true</item>  
        ...  
    </style>  
    ...  
</resources> 


自動補齊的輸入框

(圖)OPhone網絡應用編程實例: 豆瓣電台客戶端

自動補齊的輸入框,也就是AutoCompleteTextView,使用很簡單,隻需要把匹配的數據做成一個ArrayAdapter,然後setAdapter就好了,根據需要選擇layout,比如simple_dropdown_item_1line。


emailText = (AutoCompleteTextView)findViewById(R.id.emailText);  
emailText.setAdapter(new ArrayAdapter<String>  
    (this, android.R.layout.simple_dropdown_item_1line, emails)); 


超鏈接
如果你需要超鏈接,不是那麼容易的。比如Login頁麵那個注冊的鏈接,是先要寫一個正則表達式,然後用Linkify.addLinks把一個view裏的符合正則表達式的文字加上鏈接。


registerLink = (TextView)findViewById(R.id.registerLink);  
Pattern p = Pattern.compile(getResources().getString(R.string.register_link_text));  
Linkify.addLinks(registerLink, p, Consts.REGISTER_URL); 


菜單
OPhone平台創建菜單也比較方便,下麵是Player裏麵Options Menu的用法


public boolean onCreateOptionsMenu(Menu menu){  
    super.onCreateOptionsMenu(menu);  
    menu.add(0, MENU_PAUSE, 0, R.string.menu_pause);  
    menu.add(0, MENU_LOGOUT, 0, R.string.menu_logout);  
    menu.add(0, MENU_QUIT, 0, R.string.menu_quit);  
    return true;  


你也可以根據需要在prepare的時候來更改菜單


public boolean onPrepareOptionsMenu(Menu menu){  
    super.onPrepareOptionsMenu(menu);  
    MenuItem item = menu.findItem(MENU_PAUSE);  
   
    //根據現在是不是在播放來顯示是“暫停”還是"開始"的title  
    item.setTitle(isPlaying ? R.string.menu_pause : R.string.menu_start);  
    return true;  


public boolean onOptionsItemSelected(MenuItem item) {  
    switch (item.getItemId()) {  
        case MENU_PAUSE:  
            pause();  
            return true;  
        case MENU_LOGOUT:  
            logout();  
            return true;  
        case MENU_QUIT:  
            quit();  
            return true;  
    }  
    return false;  


動畫
在歌曲專輯封麵圖片的加載時使用了一個淡入/淡出的動畫效果。下麵簡單介紹一下OPhone裏麵動畫效果的用法,隻是用的xml的方式。

定義動畫效果的xml,在res/anim/fade_in.xml


<alpha 
android:fromAlpha="0.1" 
android:toAlpha="1.0" 
android:duration="3000" 
android:interpolator="@android:anim/accelerate_decelerate_interpolator" 
/> 


定義ImageView,加載動畫


songPicture = (ImageView)findViewById(R.id.songPicture);  
inAnimation = AnimationUtils.loadAnimation(this,R.anim.fade_in); 


當更新專輯封麵時,開始動畫


songPicture.setImageBitmap(pic);  
songPicture.startAnimation(inAnimation); 


AndroidManifest裏麵的設置
permission
需要的permission,主要有網絡,存儲,監聽手機和網絡狀態。


<uses-permission android:name="android.permission.INTERNET" />  
<uses-permission android:name="android.permission.WRITE_OWNER_DATA" />  
<uses-permission android:name="android.permission.READ_OWNER_DATA" />  
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>  
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>  
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> 


要求SDK最低版本

<uses-sdk android:minSdkVersion="3"/> 


設置應用為單實例模式

<application ... android:launchMode="singleInstance" ...> 


設置Service
禁止別的應用使用該Service,需要將裏麵的exported設為false,另外設置Service的進程名字後綴為":radio"。


<service android:name=".RadioService" android:exported="false" android:process=":radio" >  
    <intent-filter>  
        <action android:name="..." />  
    </intent-filter></service> 


其他技巧
MediaPlayer的prepare
MediaPlayer有2種prepare方式,建議使用異步的prepare,然後在OnPreparedListener裏麵監聽,當準備好時處理。


mplayer.setOnPreparedListener(prepareListener); //設置listener  
...  
mplayer.reset();  
mplayer.setDataSource(song.url); //設置歌曲url  
mplayer.prepareAsync();   
...  
private MediaPlayer.OnPreparedListener prepareListener = new MediaPlayer.OnPreparedListener(){  
   
    public void onPrepared(MediaPlayer mp){  
        mp.start(); //開始播放  
        //通知Player更新歌曲時間  
        sendBroadcast(new Intent(Consts.INTENT_UPDATE_SONG_TIME));  
    }  
}; 


查看有沒有可用的網絡連接
下麵是監聽網絡連接狀態的函數,這裏隻是檢查有沒有可用的連接。其實還可用根據不同連接的類型來使用不同的策略,比如用WIFI的時候就用高音質的音樂,而當用3G/2G上網時就用比較低的音樂。


public boolean isNetworkAvailable() {   
    Context context = getApplicationContext();  
    ConnectivityManager connectivity =   
        (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);  
    nkify.addLinksif (connectivity == null) {      
        return false;  
    } else {   
        NetworkInfo[] info = connectivity.getAllNetworkInfo();      
        if (info != null) {          
            for (int i = 0; i < info.length; i++) {             
                if (info[i].getState() == NetworkInfo.State.CONNECTED) {                
                    return true;   
                }          
            }       
        }   
    }     
    return false;  


監聽手機狀態
利用PhoneStateListener來監聽手機是否收到電話。這裏沒有處理打電話的問題是因為我覺得,你如果要播電話的時候一定會想先把電台關了的,嗬嗬。


private class TeleListener extends PhoneStateListener{  
    public void onCallStateChanged(int state, String incomingNumber){   
        super.onCallStateChanged(state, incomingNumber);  
        switch (state){  
            case TelephonyManager.CALL_STATE_IDLE:  
                startPlay(); //當掛斷電話時,繼續播放  
                break;  
            case TelephonyManager.CALL_STATE_OFFHOOK:  
                doStop(); //當有電話在等時,暫停音樂  
                break;  
            case TelephonyManager.CALL_STATE_RINGING:  
                doStop(); //當有電話進來時,暫停音樂  
                break;  
        }  
    }  


橫豎屏轉換
默認橫豎屏轉換是會把你的Activity關掉重新啟動的,但你可以在你的manifest裏麵設置Activity的屬性configChanges來自己處理,也可以在手機橫豎屏轉換的時候來加載不同的UI layout。


<activity android:name=".RadioPlayer" ....   
    android:configChanges="orientation|keyboardHidden|navigation" >  
...  
</activity> 


然後在onConfigurationChanged來處理加載不同的layout。


public void onConfigurationChanged(Configuration newConfig){  
    if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE){  
        setContentView(R.layout.landscape);  
    }else{  
        setContentView(R.layout.portrait);  
    }     


結束語
本文的例子豆瓣電台還在開發中,在做UI的美化以及一些改進用戶體驗的特性,會在不遠的將來發布,還請大家關注。

就到這裏,本文是以OMS平台的網絡應用為例,講一個web開發人員在學習OMS平台的應用開發時會遇到的典型問題的解決方案,嗬嗬。希望對大家有所幫助。

最後更新:2017-04-02 06:51:52

  上一篇:go JVM第三篇(簡單demo)
  下一篇:go Intent屬性的設置