cf凤凰之锋:OPhone网络应用编程实例: 豆瓣电台客户端 - 技术文章 - OPhone SDN [...

来源:百度文库 编辑:偶看新闻 时间:2024/04/30 11:58:15

说明:本文的客户端是开发版本,电台logo等图标做了虚化处理,以避免误导; 另外源码涉及到豆瓣内部api的地方也省略。

功能需求

下面是豆瓣电台网络版和客户端(开发版)的界面。


豆瓣电台网络版

下图的豆瓣电台客户端和网络版一样,除了播放歌曲,显示专辑 封面, 播放时间,歌手和歌的名称,以及喜欢/不喜欢,垃圾桶,跳过的3个操作按钮外,还涉及到登录,更换用户,暂停的操作。

设计需求

  1. 豆瓣电台是网络音乐服务,客户端需要有后台播放的服务,前台的播放器以及任务条的通知;
  2. 推荐给用户的歌曲列表是从豆瓣网站上获取的,歌曲也是在线播放的,需要网络数据的获取和异常处理;
  3. 本地还要保存一些数据,比如播放历史和自动登录使用的信息;
  4. 网络操作都是比较耗时的,所以操作需要做成异步的方式来通知更新UI;
  5. 针对手机应用的功能:比如接电话自动暂停,挂电话自动继续播放,横竖屏转换要加载不同的UI Layout;
  6. 利用OPhone平台提供的一些特性来改进用户体验,比如使用Toast提示,动画效果,重力感应,手势等

架构设计

针对上面的应用本身的功能和设计的需求,主要的架构就是前台Player的Activity,主要负责界面显示和用户的交互; 加上后台的 Service,负责歌曲的播放和向豆瓣服务器发送请求,是个C/S的结构。

如下图所示:

Player和Service的架构

Player调用Service

使用OMS平台提供的service机制,通过下面的aidl把接口定义好。比如用户点跳过按钮时,Player就会调用Service的skip。

view plaincopy to clipboardprint?
  1. interface IRadioService{   
  2.     void stop();   
  3.     void exit();   
  4.     void play();   
  5.     void like(boolean isLike);   
  6.     void skip();   
  7.     void hate();   
  8.     boolean isPlaying();   
  9.     Song getSongPlaying();   
  10.     void logout();   
  11.     Bitmap getSongPicture();   
  12.     int pos();   
  13.     int duration();   
  14. }  

这里值得注意的是service的启动方式:

view plaincopy to clipboardprint?
  1. private static final Intent service_intent = new Intent(Consts.INTENT_RADIO_SERVICE);   
  2. protected void onCreate(Bundle savedInstanceState){   
  3.         super.onCreate(savedInstanceState);   
  4.         startService(service_intent);   
  5.     }  

在Player的onCreate方法里面使用startService,这样在Player Activity退出时,Service进程不会被杀死。而使用bindService方式 启动的service会在所有的client unbind后结束。

然后要在onResume和onDestroy的时候分别进行bindService和unbindService。

view plaincopy to clipboardprint?
  1. protected void onResume(){   
  2.     super.onResume();   
  3.     bindService(service_intent, connection, Context.BIND_AUTO_CREATE);   
  4. }   
  5. protected void onDestroy(){   
  6.     super.onDestroy();   
  7.     unbindService(connection);   
  8. }  

另外,如果希望service返回你自定义的对象,你需要实现parcelable接口,比如上面的Song。

Service里面的Handler

因为网络通讯都是比较耗时的。比如上面的skip,是要向豆瓣提交跳过的是哪首歌的信息,并获取新的播放列表。实际上,为了比较好的用户体验,用户点跳过按钮时,在该按钮的onClick方法里面调用了Service的skip,而Service只是向Downloader发了一个消息,告诉执行了skip操作就返回了。这样按钮就不会一直停在按下去的状态。

view plaincopy to clipboardprint?
  1. private Handler downloader = new Handler() {   
  2.         public void handleMessage(Message msg) {   
  3.             Bundle bundle = msg.getData();   
  4.             switch (msg.what){   
  5.                 case Consts.MSG_PLAYLIST_REQUIRE:   
  6.                     //下载播放列表   
  7.                     requireList(...);   
  8.                     //播放下一首   
  9.                     playNext();   
  10.                     break;   
  11.                 case Consts.MSG_PICTURE_DOWNLOADING:   
  12.                     //下载图片   
  13.                     pic = web.getImage(bundle.getString("pic_url"));   
  14.                     //图片下载完成,通知Player更新图片   
  15.                     Intent intent = new Intent(Consts.INTENT_UPDATE_SONG_PICTURE);   
  16.                     sendBroadcast(intent);   
  17.                     break;   
  18.                 ...   
  19.             }   
  20.         }   
  21.     };  

如上面代码所示,在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函数里面来具体处理。

view plaincopy to clipboardprint?
  1.     
  2. private BroadcastReceiver receiver = new BroadcastReceiver(){   
  3.     public void onReceive(Context context, Intent intent) {   
  4.         if (intent.getAction.equals(Consts.INTENT_UPDATE_SONG_PICTURE)){   
  5.             updateSongPicture(); //更新专辑封面   
  6.         }   
  7.         ...   
  8.     }   
  9. }  

上面的代码里,就是在Service里面图片下载完成后sendBroadcast,然后Receiver收到后去更新专辑封面。这里要注意的是,要在 Player的onResume和onPause方法里面分别调用registerReceiver和unregisterReceiver。

view plaincopy to clipboardprint?
  1. protected void onResume(){   
  2.     super.onResume();   
  3.     ...   
  4.     filter = new IntentFilter();   
  5.     filter.addAction(Consts.INTENT_UPDATE_SONG_PICTURE);   
  6.     ...   
  7.     registerReceiver(receiver, filter);   
  8. }   
  9. protected void onPause(){   
  10.     super.onPause();   
  11.     unregisterReceiver(receiver);   
  12. }  

Login里面的Handler

Login也是一个单独的Activity,左图为Login的页面,因为登录比 较耗时,所以要显示一个有进度的提示框给用户,告诉用户正在 登录。

这个时候,登录的网络操作是新起一个线程去做的,因为OPhone的 UI是单线程的模型,在子线程里是不能直接去碰UI的。所以子线 程完成登录要在主线程里面有一个Handler去处理子线程的消息 来更新UI。

如下图所示

下面是在登录按钮的OnClickListener里面onClick方法里面的调用,向子线程发完消息后就会返回,不会阻塞住UI。

view plaincopy to clipboardprint?
  1.     
  2. Message msg = looper.handler.obtainMessage(MSG_LOGIN); Bundle bundle = new Bundle();   
  3. ...   
  4. //向子线程发消息执行login   
  5. looper.handler.sendMessage(msg);   
  6. //显示进度对话框   
  7. dialog.show();  

下面的代码就是主线程里的Handler,里面根据子线程的消息来更新UI。

view plaincopy to clipboardprint?
  1. private Handler mainHandler = new Handler() {   
  2.     public void handleMessage(Message msg) {   
  3.         switch (msg.what){   
  4.             case MSG_DONE:   
  5.                 dialog.dismiss(); //把对话框关掉   
  6.                 int error = msg.arg1;   
  7.                 if (error == 0){   
  8.                     Intent intent = new Intent(Consts.INTENT_RADIO_PLAYER);   
  9.                     intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);   
  10.                     startActivity(intent); //启动Player   
  11.                     finish();   
  12.                 }else{   
  13.                     showToast(error); //显示错误提示   
  14.                 }   
  15.             ...   
  16.         }   
  17.     }   
  18. };  

下面是执行登录的子线程,这里使用了平台提供的Looper,可以方便的在线程里实现一个消息队列,然后用一个Handler来处理消 息。Looper的用法很简单,只需要在循环的开始和结束分别进行prepare和loop就好了。

view plaincopy to clipboardprint?
  1. private class LooperThread extends Thread {   
  2.     private Handler handler;   
  3.     public void run() {   
  4.         Looper.prepare();   
  5.         handler = new Handler(){   
  6.             public void handleMessage(Message msg) {   
  7.                 switch (msg.what) {   
  8.                     case MSG_LOGIN:   
  9.                         ... //执行login网络操作   
  10.                         Message m = mainHandler.obtainMessage(MSG_DONE);   
  11.                         ...   
  12.                         mainHandler.sendMessage(m); //完成后给主线程发消息   
  13.                         break;   
  14.                     ...   
  15.                 }   
  16.             };   
  17.         Looper.loop();   
  18.         }   
  19.     }   
  20. }  

OPhone平台的进程/线程间通讯

上面用到了Service,Receiver和Handler,里面涉及到了不同进程,不同线程间的通讯。 进程间通讯是基于libutils的Binder机制的,这里简单的总结一下:

  1. Service和Client是比较紧的耦合,通过aidl来定义接口,Service接口返回的数据类型必须实现parcelable。
  2. BroadcastReceiver是广播和订阅的模式,比较松散,是用发送Intent来通讯的,数据的话可以放到Bundle里。

受限于UI的单线程模型,需要把比较耗时的操作用子线程来做,避免UI阻塞。平台提供了Handler和Looper,可以比较方便的在主线程和子线程之间通讯。这种方法比AsycTask的方式要更灵活,而且避免了反复创建启动线程的开销。

网络操作

下面是网络操作涉及到的一些问题,使用的是Apache接口。

 设置连接参数

view plaincopy to clipboardprint?
  1. HttpParams params = new BasicHttpParams();   
  2. //设置超时为Consts.TIMEOUT毫秒   
  3. HttpConnectionParams.setConnectionTimeout(params, Consts.TIMEOUT);   
  4. //buffer大小   
  5. HttpConnectionParams.setSocketBufferSize(params, Consts.BUFFER_SIZE);   
  6. //user agent   
  7. HttpProtocolParams.setUserAgent(params, Consts.USER_AGENT);   
  8. DefaultHttpClient client = new DefaultHttpClient(params);  

HttpGet

以get方法访问一个url,注意args需要用URLEncoder.encode()处理下。一般网络调用返回JSON格式,可以把返回的字符串转换成 JSONObject就可以处理了。

view plaincopy to clipboardprint?
  1. public String getString(String path, String args) throws Exception{   
  2.     client.getCookieStore().clear(); //清cookie,根据需要设置   
  3.     HttpGet get = new HttpGet(new URI("http", null, Consts.HOST, 80, path, args, null));   
  4.     HttpResponse response = client.execute(get);   
  5.     HttpEntity entity = response.getEntity();   
  6.     return EntityUtils.toString(entity);}  

HttpPost

view plaincopy to clipboardprint?
  1. public String post(String url, List  nvps) throws Exception{   
  2.     HttpPost httpost = new HttpPost(url);   
  3.     httpost.setEntity(new UrlEncodedFormEntity(nvps, HTTP.UTF_8));   
  4.     HttpResponse response = client.execute(httpost);   
  5.     HttpEntity entity = response.getEntity();   
  6.     return EntityUtils.toString(entity);   
  7. }  

下载图片

OPhone平台没有UI控件可以直接显示一个网络上的图片,必须要先下载下来再显示.

view plaincopy to clipboardprint?
  1. public Bitmap getImage(String url) throws Exception{   
  2.     HttpGet get = new HttpGet(url);   
  3.     HttpResponse response = client.execute(get);   
  4.     HttpEntity entity = response.getEntity();   
  5.     byte[] data = EntityUtils.toByteArray(entity);   
  6.     return BitmapFactory.decodeByteArray(data, 0, data.length);   
  7. }  

数据库操作

OPhone平台的数据库是Sqlite,并且提供了SQLiteOpenHelper这个类来方便使用

数据库表创建和设计

view plaincopy to clipboardprint?
  1. class DatabaseHelper extends SQLiteOpenHelper{   
  2.     public DatabaseHelper(Context context){   
  3.         super(context, DATABASE_NAME, null, DATABASE_VERSION);   
  4.     }   
  5.     public void onCreate(SQLiteDatabase db) {   
  6.         db.execSQL(create_table_sql); //这里创建表   
  7.     }   
  8.     public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {   
  9.         //当数据库版本升级的时候会调这个函数   
  10.     }   
  11. }  

查询

这里使用的rawQuery,直接是执行sql,我觉得比别的接口更灵活。这里注意的是,从cursor里取数据时,首先要cursor.moveToFirst... 还有记得cursor和help最后都要调他们的close方法来释放资源,否则会内存泄漏。

view plaincopy to clipboardprint?
  1. public String[] getStrings(){   
  2.     SQLiteDatabase db = helper.getReadableDatabase();   
  3.     try {   
  4.         Cursor cursor = db.rawQuery("select col from table", null);   
  5.         int total = cursor.getCount();   
  6.         if (total > 0){   
  7.             String[] ss = new String[total];   
  8.             cursor.moveToFirst();   
  9.             for (int i = 0; i < total; i++){   
  10.                 ss[i] = cursor.getString(0);   
  11.                 cursor.moveToNext();   
  12.             }   
  13.             cursor.close();   
  14.             return ss;   
  15.         }   
  16.         cursor.close();   
  17.     } catch (SQLiteException e) {   
  18.     }   
  19.     return null;   
  20. }  

UI相关

下面是电台这个应用涉及到的一些比较常用的UI组件的用法介绍。

Notification

电台在后台播放时,Service会在任务栏上放一个通知,以便Player退出时,来显示当前播放的歌曲,而且点通知会打开Player。Notification在下来列表里的UI是RemoteView, 可以设置简单的view进去,比如TextView,ImageView,也可以用自己的layout,但是不能设置别的复杂View。如果有人找到方法,请发个mail给我。: )

view plaincopy to clipboardprint?
  1. private void sendNotifcation(){   
  2.     int icon = R.drawable.title_icon;   
  3.     CharSequence tickerText = context.getString(R.string.notification_title);   
  4.     long when = System.currentTimeMillis();   
  5.     if (notification == null){   
  6.         notification = new Notification(icon, tickerText, when);   
  7.         notification.flags = Notification.FLAG_NO_CLEAR; //设置不能被清除   
  8.     }   
  9.     
  10.     Intent notificationIntent = new Intent(Consts.INTENT_RADIO_PLAYER);   
  11.     PendingIntent contentIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0);   
  12.     RemoteViews contentView = new RemoteViews(getPackageName(), R.layout.notification);   
  13.     contentView.setImageViewResource(R.id.notification_image, R.drawable.icon);   
  14.     contentView.setTextViewText(R.id.songName, song.title);   
  15.     contentView.setTextViewText(R.id.artistName, song.artist);   
  16.     
  17.     notification.contentView = contentView;   
  18.     notification.contentIntent = contentIntent;   
  19.     
  20.     notifyManager.notify(Consts.NOTIFICATION_PLAY, notification);   
  21. }  

带进度条的对话框

如果只是像登录时那个登录进度框的话,比较简单,就像下面的方式定义一个ProgressDialog就好了。如果需要自己来更新进度的话,就要在handler里面去更新进度值了。

view plaincopy to clipboardprint?
  1. dialog = new ProgressDialog(this);   
  2. dialog.setMessage(getResources().getString(R.string.logging));   
  3. dialog.setIndeterminate(true);   
  4. dialog.setCancelable(true);  

对话框风格的Activity

有时候你需要一个长的像Dialog的Activity,比如之前的登录页面。那么需要就使用style,就像css一样,但是还没那么好用就是了。如下

view plaincopy to clipboardprint?
  1.             android:theme="@style/DoubanTheme.Dialog">   
  2. ...   
  3.     
  4.     
  5. 而style在res/value/style.xml里面定义   
  6.   
  7.        
  8.         true   
  9.         ...   
  10.        
  11.     ...   
  12.   

自动补齐的输入框

自动补齐的输入框,也就是AutoCompleteTextView,使用很简单,只需要把匹配的数据做成一个ArrayAdapter,然后setAdapter就好了,根据需要选择layout,比如simple_dropdown_item_1line。

view plaincopy to clipboardprint?
  1. emailText = (AutoCompleteTextView)findViewById(R.id.emailText);   
  2. emailText.setAdapter(new ArrayAdapter   
  3.     (this, android.R.layout.simple_dropdown_item_1line, emails));  

超链接

如果你需要超链接,不是那么容易的。比如Login页面那个注册的链接,是先要写一个正则表达式,然后用Linkify.addLinks把一个view里的符合正则表达式的文字加上链接。

view plaincopy to clipboardprint?
  1. registerLink = (TextView)findViewById(R.id.registerLink);   
  2. Pattern p = Pattern.compile(getResources().getString(R.string.register_link_text));   
  3. Linkify.addLinks(registerLink, p, Consts.REGISTER_URL);  

菜单

OPhone平台创建菜单也比较方便,下面是Player里面Options Menu的用法

view plaincopy to clipboardprint?
  1. public boolean onCreateOptionsMenu(Menu menu){   
  2.     super.onCreateOptionsMenu(menu);   
  3.     menu.add(0, MENU_PAUSE, 0, R.string.menu_pause);   
  4.     menu.add(0, MENU_LOGOUT, 0, R.string.menu_logout);   
  5.     menu.add(0, MENU_QUIT, 0, R.string.menu_quit);   
  6.     return true;   
  7. }  

你也可以根据需要在prepare的时候来更改菜单

view plaincopy to clipboardprint?
  1. public boolean onPrepareOptionsMenu(Menu menu){   
  2.     super.onPrepareOptionsMenu(menu);   
  3.     MenuItem item = menu.findItem(MENU_PAUSE);   
  4.     
  5.     //根据现在是不是在播放来显示是“暂停”还是"开始"的title   
  6.     item.setTitle(isPlaying ? R.string.menu_pause : R.string.menu_start);   
  7.     return true;   
  8. }  

然后在用户点击菜单项的时候处理

view plaincopy to clipboardprint?
  1. public boolean onOptionsItemSelected(MenuItem item) {   
  2.     switch (item.getItemId()) {   
  3.         case MENU_PAUSE:   
  4.             pause();   
  5.             return true;   
  6.         case MENU_LOGOUT:   
  7.             logout();   
  8.             return true;   
  9.         case MENU_QUIT:   
  10.             quit();   
  11.             return true;   
  12.     }   
  13.     return false;   
  14. }  

动画

在歌曲专辑封面图片的加载时使用了一个淡入/淡出的动画效果。下面简单介绍一下OPhone里面动画效果的用法,只是用的xml的方式。

定义动画效果的xml,在res/anim/fade_in.xml

view plaincopy to clipboardprint?
  1.   
  2. android:fromAlpha="0.1"  
  3. android:toAlpha="1.0"  
  4. android:duration="3000"  
  5. android:interpolator="@android:anim/accelerate_decelerate_interpolator"  
  6. />  

定义ImageView,加载动画

view plaincopy to clipboardprint?
  1. songPicture = (ImageView)findViewById(R.id.songPicture);   
  2. inAnimation = AnimationUtils.loadAnimation(this,R.anim.fade_in);  

当更新专辑封面时,开始动画

view plaincopy to clipboardprint?
  1. songPicture.setImageBitmap(pic);   
  2. songPicture.startAnimation(inAnimation);  

AndroidManifest里面的设置

permission

需要的permission,主要有网络,存储,监听手机和网络状态。

view plaincopy to clipboardprint?
  1.   
  2.   
  3.   
  4.   
  5.   
  6.   

要求sdk最低版本

view plaincopy to clipboardprint?
  1.   

设置应用为单实例模式

view plaincopy to clipboardprint?
  1.   

设置Service

禁止别的应用使用该Service,需要将里面的exported设为false,另外设置Service的进程名字后缀为":radio"。

view plaincopy to clipboardprint?
  1.   
  2.        
  3.            
  4.       

其他技巧

MediaPlayer的prepare

MediaPlayer有2种prepare方式,建议使用异步的prepare,然后在OnPreparedListener里面监听,当准备好时处理。

view plaincopy to clipboardprint?
  1. mplayer.setOnPreparedListener(prepareListener); //设置listener   
  2. ...   
  3. mplayer.reset();   
  4. mplayer.setDataSource(song.url); //设置歌曲url   
  5. mplayer.prepareAsync();    
  6. ...   
  7. private MediaPlayer.OnPreparedListener prepareListener = new MediaPlayer.OnPreparedListener(){   
  8.     
  9.     public void onPrepared(MediaPlayer mp){   
  10.         mp.start(); //开始播放   
  11.         //通知Player更新歌曲时间   
  12.         sendBroadcast(new Intent(Consts.INTENT_UPDATE_SONG_TIME));   
  13.     }   
  14. };  

查看有没有可用的网络连接

下面是监听网络连接状态的函数,这里只是检查有没有可用的连接。其实还可用根据不同连接的类型来使用不同的策略,比如用WIFI的时候就用高音质的音乐,而当用3G/2G上网时就用比较低的音乐。

view plaincopy to clipboardprint?
  1. public boolean isNetworkAvailable() {    
  2.     Context context = getApplicationContext();   
  3.     ConnectivityManager connectivity =    
  4.         (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);   
  5.     nkify.addLinksif (connectivity == null) {       
  6.         return false;   
  7.     } else {    
  8.         NetworkInfo[] info = connectivity.getAllNetworkInfo();       
  9.         if (info != null) {           
  10.             for (int i = 0; i < info.length; i++) {              
  11.                 if (info[i].getState() == NetworkInfo.State.CONNECTED) {                 
  12.                     return true;    
  13.                 }           
  14.             }        
  15.         }    
  16.     }      
  17.     return false;   
  18. }  

监听手机状态

利用PhoneStateListener来监听手机是否收到电话。这里没有处理打电话的问题是因为我觉得,你如果要播电话的时候一定会想先把电台关了的,呵呵。

view plaincopy to clipboardprint?
  1. private class TeleListener extends PhoneStateListener{   
  2.     public void onCallStateChanged(int state, String incomingNumber){    
  3.         super.onCallStateChanged(state, incomingNumber);   
  4.         switch (state){   
  5.             case TelephonyManager.CALL_STATE_IDLE:   
  6.                 startPlay(); //当挂断电话时,继续播放   
  7.                 break;   
  8.             case TelephonyManager.CALL_STATE_OFFHOOK:   
  9.                 doStop(); //当有电话在等时,暂停音乐   
  10.                 break;   
  11.             case TelephonyManager.CALL_STATE_RINGING:   
  12.                 doStop(); //当有电话进来时,暂停音乐   
  13.                 break;   
  14.         }   
  15.     }   
  16. }  

横竖屏转换

默认横竖屏转换是会把你的Activity关掉重新启动的,但你可以在你的manifest里面设置Activity的属性configChanges来自己处理,也可以在手机横竖屏转换的时候来加载不同的UI layout。

view plaincopy to clipboardprint?
  1.     android:configChanges="orientation|keyboardHidden|navigation" >   
  2. ...   
  3.   

然后在onConfigurationChanged来处理加载不同的layout。

view plaincopy to clipboardprint?
  1. public void onConfigurationChanged(Configuration newConfig){   
  2.     if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE){   
  3.         setContentView(R.layout.landscape);   
  4.     }else{   
  5.         setContentView(R.layout.portrait);   
  6.     }      
  7. }  

结束语

本文的例子豆瓣电台还在开发中,在做UI的美化以及一些改进用户体验的特性,会在不远的将来发布,还请大家关注。

就到这里,本文是以OMS平台的网络应用为例,讲一个web开发人员在学习OMS平台的应用开发时会遇到的典型问题的解决方案,呵呵。希望对大家有所帮助。