Android手机应用开发(八) | 制作简单音乐播放器
实验目的
- 学会使用MediaPlayer
- 学会简单的多线程编程,使用Handler更新UI
- 学会使用Service进行后台工作
- 学会使用Service与Activity进行通信
文章目录
- 效果预览
- 布局
- 进度条的布局
- 圆形ImageView
- Service的使用
- 如何启动`Service`
- 注册Service
- 创建Service
- 绑定Service
- MediaPlayer的使用
- 添加文件访问权限
- 静态添加
- 动态添加
- onServiceConnected的补充
- 毫秒格式化为时间显示
- 播放/暂停按钮
- 停止按钮
- 图片的旋转
- 拖动条事件
- Handler的使用
- 创建新线程
- 创建Handler
- 退出播放器
- 后台播放
- 选择歌曲播放
- 解决一些细节问题
- 点击停止按钮UI不复位
效果预览
布局
进度条的布局
如何实现让进度条占满当前时间
和全部时间
中间的部分呢?
- 如果使用
match_parent
,右边的全部时间
又显示不了 - 如果使用
wrap_content
,又不能填充满 - 如果自定义
dp
值,不同尺寸显示又会不一样
这就利用了LinearLayout
的特点,只需设置中间进度条的layout_weight = 1
就好了,它就会自动延伸到右边最远处(不占据别的控件)
圆形ImageView
这里使用了github
上的开源控件:链接
-
添加依赖
implementation 'de.hdodenhof:circleimageview:2.2.0'
-
在
xml
中使用<de.hdodenhof.circleimageview.CircleImageView xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/profile_image" android:layout_width="match_parent" android:layout_height="290dp" android:layout_marginTop="30dp" android:src="@drawable/img" app:layout_constraintTop_toTopOf="parent" />
Service的使用
Service
(服务)是一种可以在后台执行长时间运行操作而没有用户界面的应用组件。
服务可由其他应用组件启动(如Activity
),服务一旦被启动将在后台一直运行,即使启动服务的组件(Activity
)
已销毁也不受影响。
如何启动Service
-
通过
startService
启动startService()
启动和stopService()
关闭服务,Service
与访问者之间基本不存在太多关联,因此Service
和访问者之间无法通讯和数据交换。 -
通过
bindService
启动用于
Service
和访问者之间需要进行方法调用或数据交换的情况
注册Service
在manifests
里面的application
里,添加
<service android:name=".MusicService" android:exported="true"/>
创建Service
右键 -> New
-> Service
-> Service
,取名为MusicService
(跟注册时一致)
在Service
里添加成员
//用来跟Activity进行绑定public final IBinder binder = new MyBinder();//媒体播放类public MediaPlayer mp = new MediaPlayer();//对Service控制的不同数字码private final int PLAY_CODE = 1, STOP_CODE = 2, SEEK_CODE = 3, NEWMUSIC_CODE = 4, CURRENTDURATION_CODE = 5, TOTALDURATION = 6;//重写onTransact方法,对不同的CODE做出不同的反应public class MyBinder extends Binder { @Override protected boolean onTransact(int code, @NonNull Parcel data, @Nullable Parcel reply, int flags) throws RemoteException { switch (code) { //service solve case PLAY_CODE: play_pause(); break; case STOP_CODE: stop(); break; case SEEK_CODE: mp.seekTo(data.readInt()); break; case NEWMUSIC_CODE: newMusic(Uri.parse(data.readString())); reply.writeInt(mp.getDuration()); case TOTALDURATION: reply.writeInt(mp.getDuration()); break; case CURRENTDURATION_CODE: reply.writeInt(mp.getCurrentPosition()); break; } return super.onTransact(code, data, reply, flags); } }
这里的
Environment.getExternalStorageDirectory()
指的外部存储不是扩展卡中的存储,而是相对程序来讲,不是程序的InternalStorage
,而是本机的通用存储
重写onBind
方法
@Overridepublic IBinder onBind(Intent intent) { try { mp.setDataSource(Environment.getExternalStorageDirectory() + "/data/山高水长.mp3"); mp.prepare(); } catch (IOException e) { Log.e("prepare error", "getService: " + e.toString()); } return binder;}
这里返回的
binder
就相当于一个Service
组件所返回的代理对象,Service
允
许客户端通过该IBinder
对象来访问Service
内部的数据,实现客户端与Service
之间的通信
编写一些简单的控制方法
public void play_pause() { if (mp.isPlaying()) { mp.pause(); } else { mp.start(); } } public void stop() { if (mp != null) { mp.stop(); try { mp.prepare(); mp.seekTo(0); } catch (Exception e) { Log.d("stop", "stop: " + e.toString()); } } } public void newMusic(Uri uri){ try{ mp.reset(); mp.setDataSource(this, uri); mp.prepare(); } catch (Exception e){ Log.d("New Music", "new music: " + e.toString()); } }
重写onDestory
方法
@Overridepublic void onDestroy() { super.onDestroy(); if(mp!= null){ //release之前一定要reset,不然会报下面的错 //W/MediaPlayer(7564): mediaplayer went away with unhandled events mp.reset(); mp.release(); }}
绑定Service
在MainActivity
中添加成员变量
private IBinder mBinder;private final int PLAY_CODE = 1, STOP_CODE = 2, SEEK_CODE = 3, NEWMUSIC_CODE = 4, CURRENTDURATION_CODE = 5, TOTALDURATION = 6;private int total_duration = 0;
添加绑定成功的操作
private ServiceConnection sc = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { mBinder = service; //绑定成功时进行的操作 //还有一些没有写出来 } @Override public void onServiceDisconnected(ComponentName name) { ms = null; //结束绑定时进行的操作 }};
使用下面代码进行绑定
Intent intent = new Intent(this, MusicService.class);bindService(intent, sc, BIND_AUTO_CREATE);
这就像用
Intent
开启一个Activity
一样,只不过这里是开启Service
但是具体的操作还是在
onServiceConnected
中完成
MediaPlayer的使用
绑定好了之后就可以通过ms
访问mp
了
但是现在直接调用ms.play()
会报错
因为没有权限去访问预先设置的音频文件
添加文件访问权限
在Android6.0
之前,我们只需要在AndroidManifest.xml
文件中直接添加权限即可
但是在Android6.0
之后,我们只在AndroidManifest.xml
文件中配置是不够的,还需要在Java
代码中进行动态获取权限。
静态添加
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
动态添加
/读写权限private static String[] PERMISSIONS_STORAGE = { Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE};//请求状态码private static int REQUEST_PERMISSION_CODE = 1;@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main);//获取权限 ActivityCompat.requestPermissions(this, PERMISSIONS_STORAGE, REQUEST_PERMISSION_CODE);......}//成功获取权限的回调方法,在这里@Overridepublic void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == REQUEST_PERMISSION_CODE) { //其实应该放在这里开始绑定,因为只有获取权限成功后绑定才能正确让音乐播放 Intent intent = new Intent(this, MusicService.class); bindService(intent, sc, BIND_AUTO_CREATE); }}
onServiceConnected的补充
绑定成功后,需要更新进度条的长度,音乐总时间和当前进度(初始化为0)
//记得在onCreate外面添加类成员变量private final int PLAY_CODE = 1, STOP_CODE = 2, SEEK_CODE = 3, NEWMUSIC_CODE = 4, CURRENTDURATION_CODE = 5, TOTALDURATION = 6;private int total_duration = 0;@Overridepublic void onServiceConnected(ComponentName name, IBinder service) { mBinder = service; //与服务通信 Parcel data = Parcel.obtain(); Parcel reply = Parcel.obtain(); try { mBinder.transact(TOTALDURATION, data, reply, 0); }catch (Exception e){ Log.d("SERVICE CONNECTION", "onServiceConnected: " + e.toString()); } total_duration = reply.readInt(); seekbar.setProgress(0); seekbar.setMax(total_duration); //两种方法实现毫秒转时间 //total_time.setText(time.format(new Date(ms.mp.getDuration()))); total_time.setText(DateFormat.format(time_format, total_duration)); current_time.setText(time.format(new Date(0)));}
毫秒格式化为时间显示
ms.mp.getDuration()
返回的是一个long
型的歌曲毫秒数,需要格式化显示为时间的形式,如:12:12
-
方法一:
private SimpleDateFormat time = new SimpleDateFormat("mm:ss");total_time.setText(time.format(new Date(ms.mp.getDuration())));
-
方法二:
private String time_format = "mm:ss";total_time.setText(DateFormat.format(time_format, (long)ms.mp.getDuration()));
播放/暂停按钮
play_pause.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { //与服务通信 Parcel data = Parcel.obtain(); Parcel reply = Parcel.obtain(); try{ mBinder.transact(PLAY_CODE, data, reply, 0); }catch (RemoteException e){ Log.e("STOP:", "onClick: " + e.toString() ); } if(isPlay){ isPlay = false; play_pause.setImageResource(R.mipmap.play); } else { isPlay = true; isStop = false; play_pause.setImageResource(R.mipmap.pause); //开始监听 myThread.run(); } }});
停止按钮
stop.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { isPlay = false; isStop = true; play_pause.setImageResource(R.mipmap.play); seekbar.setProgress(0); current_time.setText(time.format(0)); imageView.setRotation(0); //与服务通信 Parcel data = Parcel.obtain(); Parcel reply = Parcel.obtain(); try{ mBinder.transact(STOP_CODE, data, reply, 0); }catch (RemoteException e){ Log.e("STOP:", "onClick: " + e.toString() ); } }});
图片的旋转
有很多方法,这里用一种比较简单的设置角度的方法
imageView.setPivotX(imageView.getWidth()/2);imageView.setPivotY(imageView.getHeight()/2);//支点在图片中心//表示顺时针旋转90度imageView.setRotation(90);
拖动条事件
seekbar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { //判断是否来自用户,因为后面还要设置播放的时候进度条跟着变化,如果那样的变化也调用这个方法的话进度条将会卡住 if(fromUser){ imageView.setPivotX(imageView.getWidth()/2); imageView.setPivotY(imageView.getHeight()/2);//支点在图片中心 //progress表示毫秒数,非常大,所以转化为比较容易观察的数据 imageView.setRotation(progress/30); current_time.setText(time.format(progress)); //与服务通信 Parcel data = Parcel.obtain(); Parcel reply = Parcel.obtain(); data.writeInt(progress); try{ mBinder.transact(SEEK_CODE, data, reply, 0); }catch (RemoteException e){ Log.e("STOP:", "onClick: " + e.toString() ); } } } @Override public void onStartTrackingTouch(SeekBar seekBar) { } @Override public void onStopTrackingTouch(SeekBar seekBar) { }});
Handler的使用
现在已经实现了最基础的播放、暂停、停止、拖动播放功能
但是还需要播放时更新当前进度,更新进度条,旋转封面
因为要一直监听着MediaPlayer
的进度,然而它又没有onProgressChangeListener
方法可以用
所以需要单独用一个线程来观察它的变化,然后使用Handler
通知UI变化
为什么不用主线程,因为它的本质就是一个循环,观察UI线程,抽不出身来观察别的东西了
创建新线程
有两种方法,要么直接继承Thread
类,要么实现Runnable
方法,只要实现了run
方法就好了
public Runnable myThread = new Runnable() { @Override public void run() { Message msg = handler.obtainMessage(); //待补充 try{ //与服务通信 Parcel data = Parcel.obtain(); Parcel reply = Parcel.obtain(); mBinder.transact(CURRENTDURATION_CODE, data, reply, 0); msg.arg1 = reply.readInt(); }catch (Exception e){ Log.d("Run", "run: " + e.toString()); return; } handler.sendMessage(msg); } };
创建Handler
@SuppressLint("HandlerLeak") private final Handler handler = new Handler(){ @Override public void handleMessage(Message msg) { super.handleMessage(msg); switch (msg.what) { // 根据消息类型进行操作 //待补充 default: //播放结束后,模拟点击一次停止按键,置位UI if(msg.arg1 >= total_duration) stop.performClick(); seekbar.setProgress(msg.arg1); current_time.setText(time.format(new Date(msg.arg1))); imageView.setPivotX(imageView.getWidth()/2); imageView.setPivotY(imageView.getHeight()/2);//支点在图片中心 imageView.setRotation(msg.arg1/30); handler.postDelayed(myThread, 1); } } };
听说这样子可能会造成内存泄漏,不过我感觉这个应用应该不会有这个问题,如果有的话,尝试去把
Activity
设置为单实例<activity android:name=".MainActivity" android:launchMode="singleInstance">
现在就能实现播放时进度条移动,封面旋转以及播放时间更新了!
退出播放器
首先要重写onDestory
方法
虽然Service可以在后台服务,可是也保不准什么时候被系统杀掉,我的手机播放完一首歌就会被系统kill了
如果没有在onDestory
方法中处理好资源的释放,就会弹出恼人的“应用已停止”出错对话框
@Overridepublic void onDestroy(){ super.onDestroy(); handler.removeCallbacks(myThread); if(sc != null){ //解绑 unbindService(sc); }}
现在返回键和主页键都已经结束不了这个播放器了,点击退出按钮要怎么结束呢?
很简单,一句代码就够了
quit.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { finish(); }});
之前我还在里面手动调用了
onDestory
,其实finish方法之后系统会自动调用onDestory
销毁当前Activity
,所以多次调用会出错提示:“java.lang.IllegalStateException: No activity
”,因为第一遍已经销毁了,第二遍已经不存在这个Activity
了
后台播放
其实这个已经使用了Service
,程序进入后台之后应该还是会继续播放,然而结果并不是这样子
点击home
键能实现后台播放
点击back
键却结束了播放
想了想,应该是系统觉得我的应用太简单,点击返回就可以直接杀掉了,所以得制止这种行为!
重写点击返回键的方法
@Overridepublic boolean onKeyDown(int keyCode, KeyEvent event) { if (keyCode==KeyEvent.KEYCODE_BACK){ moveTaskToBack(true); return false; } return super.onKeyDown(keyCode, event);}
return true:返回键是回到上一个activity
return false:后者会直接最小化应用,重新进入应用之后首先就会看到你所操作的这个
avtivity
!
选择歌曲播放
刚刚已经获取了文件访问权限,现在就可以直接打开文件选择窗口了
这里筛选文件类型为音频
select.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.setType("audio/*"); intent.addCategory(Intent.CATEGORY_OPENABLE); startActivityForResult(intent,1); } });
重写选择文件后的返回事件
protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (data != null) { try{ //与服务通信 Parcel send_data = Parcel.obtain(); Parcel reply = Parcel.obtain(); send_data.writeString(data.getData().toString()); try{ mBinder.transact(NEWMUSIC_CODE, send_data, reply, 0); }catch (RemoteException e){ Log.e("STOP:", "onClick: " + e.toString() ); } //设置信息 seekbar.setProgress(0); seekbar.setMax(ms.mp.getDuration()); total_time.setText(DateFormat.format(time_format, (long)ms.mp.getDuration())); current_time.setText(time.format(new Date(0))); //...... //待补充 }catch (Exception e){ Log.d("Open file", "onActivityResult: " + e.toString()); } } super.onActivityResult(requestCode, resultCode, data);}
这里要注意的是,重新设置播放源文件的时候需要先reset
,但是不能release
不过也可以release
,然后ms.mp = new MediaPlayer();
就好了,虽然充分利用了JAVA的垃圾回收机制,不过总感觉这样不是很稳妥
在返回事件里还得重新解析音乐的信息
MediaMetadataRetriever mmr = new MediaMetadataRetriever();mmr.setDataSource(MainActivity.this,data.getData());//获取媒体标题music_title.setText(mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE));//获取媒体艺术家music_singer.setText(mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST));//获取媒体图片(专辑封面)byte[] picture = mmr.getEmbeddedPicture();if(picture.length!=0){ Bitmap bitmap = BitmapFactory.decodeByteArray(picture, 0, picture.length); imageView.setImageBitmap(bitmap);}//释放mmr.release();//自动播放isPlay = false;play_pause.performClick();//不自动播放//模拟停止//stop.performClick();
名称: mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE))
专辑: mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM))
歌手: mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST))
码率: mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE))
时长:mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION))
类型:mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE))
解决一些细节问题
点击停止按钮UI不复位
点击停止按钮之后进度条、当前播放时间和封面都没有复位,但是再次点击播放按钮之后还是会会重新开始
调试的时候handler.removeCallbacks(myThread);
确实起了作用,而且UI也都复位了,但是正常运行的时候却不行,也许是线程的时间间隔太短吧……
那就再想个办法!
新建一个变量指示当前是否是已经停止的状态(需要通知UI复位)
boolean isStop = false;
修改myThread
public Runnable myThread = new Runnable() { @Override public void run() { Message msg = handler.obtainMessage(); if(isStop){ msg.what = -1; handler.sendMessage(msg); return; } try{ //与服务通信 Parcel data = Parcel.obtain(); Parcel reply = Parcel.obtain(); mBinder.transact(CURRENTDURATION_CODE, data, reply, 0); msg.arg1 = reply.readInt(); }catch (Exception e){ Log.d("Run", "run: " + e.toString()); return; } handler.sendMessage(msg); } };
修改handler
@SuppressLint("HandlerLeak") private final Handler handler = new Handler(){ @Override public void handleMessage(Message msg) { super.handleMessage(msg); switch (msg.what) { // 根据消息类型进行操作 case -1: handler.removeCallbacks(myThread); seekbar.setProgress(0); current_time.setText(time.format(0)); imageView.setRotation(0); break; default: if(msg.arg1 >= total_duration) stop.performClick(); seekbar.setProgress(msg.arg1); current_time.setText(time.format(new Date(msg.arg1))); imageView.setPivotX(imageView.getWidth()/2); imageView.setPivotY(imageView.getHeight()/2);//支点在图片中心 imageView.setRotation(msg.arg1/30); handler.postDelayed(myThread, 1); } } };
修改按钮监听事件
play_pause.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { //与服务通信 Parcel data = Parcel.obtain(); Parcel reply = Parcel.obtain(); try{ mBinder.transact(PLAY_CODE, data, reply, 0); }catch (RemoteException e){ Log.e("STOP:", "onClick: " + e.toString() ); } if(isPlay){ isPlay = false; play_pause.setImageResource(R.mipmap.play); } else { isPlay = true; isStop = false; play_pause.setImageResource(R.mipmap.pause); //开始监听 myThread.run(); } } }); stop.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { isPlay = false; isStop = true; play_pause.setImageResource(R.mipmap.play); seekbar.setProgress(0); current_time.setText(time.format(0)); imageView.setRotation(0); //与服务通信 Parcel data = Parcel.obtain(); Parcel reply = Parcel.obtain(); try{ mBinder.transact(STOP_CODE, data, reply, 0); }catch (RemoteException e){ Log.e("STOP:", "onClick: " + e.toString() ); } } });
这样子的话
如果按了停止按钮 ->
isStop
就会置位true
->
myThread
知道之后就会发送值为-1的msg.what
->
handler
接收到就会让所有UI复位
然后重新点击按钮的话会重新开启另一个线程mThread
原来的线程因为没有调用handler.postDelayed(myThread, 1);
以后再也不会使用了,应该会被回收掉
更多相关文章
- Android(安卓)Jetpack 之 LifeCycle
- android通过蓝牙实现两台手机传输数据
- Android(安卓)获取判断是否有悬浮窗权限的方法
- Android简单记录和恢复ListView滚动位置的方法
- Android(安卓)使用PDF.js浏览pdf的方法示例
- Android中Bitmap用法实例分析
- Android基于SOAP标准调用Webservice实现数据交互
- Android(安卓)编程下的TraceView 简介及其案例实战
- android 使用 service 实现音乐