Android 音乐播放器及RemoteView实践
前言
上一篇文章《Android前台服务及通知使用总结》把前台服务和通知的内容简单介绍了下,算是这篇文章的前提吧,这篇文章我想通过一个音乐播放器,整合下前台服务、自定义通知、桌面小组件,希望能有所收获。
需求分析
做之前还是简单分析下我要做的东西吧,简单列一下:
- 编写工具类,获取所有音频文件(uri),在页面列表显示
- 启动前台服务,自定义通知栏通知
- 编写音频播放器,简单及控制播放音乐
- 页面列表中,点击播放音乐,通知栏更新显示数据
- 通知栏通知能播放、暂停、上一首、下一首音乐
- 桌面小组件和通知栏通知功能一致
东西不难,工作量还挺多的,摸鱼也写了很久,下面慢慢介绍吧,实在不行分两篇文章。
Audio数据获取
要做一个音乐播放器,首先就是要拿到数据嘛,一般都是扫描手机内的音频,通过MediaStore去查询。这个查询是同步的,所以我们还要做下异步操作。
下面我就写下查询Audio的方法,异步操作之类的,我放github文件链接,有兴趣的读者再看吧:
kotlin
/**
* 使用ContentResolver获取所有音频文件(耗时操作)
*
* @param context 上下文
* @param path 在Music路径下的相对路径,不填全局搜索
* @return 所有音频文件列表
*/
fun loadAudiosByMediaStore(context: Context, path: String? = null): List<Audio> {
val result: MutableList<Audio> = ArrayList()
// 查询音频的uri
val uri: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
// 查询条目
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.SIZE
)
// 查询条件
var selection = "${MediaStore.Audio.Media.IS_MUSIC} != 0"
var selectionArg = ""
// 是否增加路径
path?.let {
// Android 10,路径保存在RELATIVE_PATH
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
selection += " AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} like ?"
selectionArg += "%" + Environment.DIRECTORY_MUSIC + File.separator + it + "%"
} else {
val dstPath = StringBuilder().let { sb ->
sb.append(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC)!!.path)
sb.append(File.separator)
sb.append(it)
sb.toString()
}
selection += " AND ${MediaStore.Video.Media.DATA} like ?"
selectionArg += "%$dstPath%"
}
}
// 排序
val sortOrder = "${MediaStore.Audio.Media.TITLE} ASC"
// 查询
val cursor = context.contentResolver.query(
uri,
projection,
selection,
if (selectionArg.isNotEmpty()) arrayOf(selectionArg) else null,
sortOrder
)
// 处理结果
cursor?.let {
while (it.moveToNext()) {
try {
val id = it.getLong(it.getColumnIndexOrThrow(MediaStore.Audio.Media._ID))
result.add(
Audio(
id,
it.getString(it.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)),
it.getString(it.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)),
it.getString(it.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)),
it.getLong(it.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)),
ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id),
it.getLong(it.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE))
)
)
}catch (e: Exception) {
e.printStackTrace()
}
}
}
cursor?.close()
return result
}
对应Audio数据类的格式如下:
kotlin
/**
* 音频数据类
*/
@Parcelize
data class Audio(
val id: Long,
val title: String,
val artist: String,
val album: String,
val duration: Long,
val uri: Uri,
val size: Long
) : Parcelable
上面的查询是同步的,我们可以通过协程、线程池、AsyncTask把它转成异步,也可以Loader机制去异步加载,可以参考我写好的工具类:
启动前台服务
上一篇文章已经有介绍了,我们就从前台应用去启动前台服务:
kotlin
fun startForegroundServiceFromForeground() {
val intent = Intent(context, AudioService::class.java)
if (Build.VERSION.SDK_INT >= 26) {
// Android8 必须通过startForegroundService开启前台服务,Android9 需要添加权限
context.startForegroundService(intent)
}else {
context.startService(intent)
}
}
AudioService代码如下,里面加载了下数据,立马创建前台通知,将服务变为前台服务:
kotlin
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import androidx.lifecycle.LifecycleService
import com.silencefly96.module_base.utils.NotificationUtil.Companion.NOTIFICATION_ID
import com.silencefly96.module_tech.service.audio_player.audio.Audio
import com.silencefly96.module_tech.service.audio_player.audio.AudioLoader
import com.silencefly96.module_tech.service.audio_player.player.AudioPlayerManager
class AudioService: LifecycleService() {
// 音乐播放器
private val mAudioPlayerManager by lazy {
AudioPlayerManager(this@AudioService)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
// 使用协程查找音频
AudioLoader.loadAudioByMediaStoreCoroutines(
this,
object : AudioLoader.OnAudioPrepared {
override fun onAudioPrepared(result: List<Audio>?) {
result?.let {
// 获取播放列表,然后播放第一首
mAudioPlayerManager.init(it)
if (it.isNotEmpty()) {
mAudioPlayerManager.play(0)
}
}
}
},
lifecycle)
// 创建通知启动前台服务(要在5s内)
val notification = mAudioPlayerManager.createNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// 安卓10要添加一个参数,在manifest中配置
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
return START_STICKY
}
override fun onDestroy() {
Log.d("TAG", "AudioService onDestroy: ")
mAudioPlayerManager.onDestroy()
super.onDestroy()
}
}
这里用的LifecycleService,能够通过它的lifecycle启动协程。音乐播放器的管理类,我也在这里创建了,由它来控制音乐播放等操作,下面来看。
创建音频播放器
AudioService中加载完数据,把数据全部传给了AudioPlayerManager,并将它初始化:
kotlin
// 音乐播放器
private val mAudioPlayer: AudioPlayer = AudioPlayer(context)
public fun init(audios: List<Audio>) {
mAudios.addAll(audios)
// 启动广播接收器
registerReceiver()
}
registerReceiver我们后面会讲到,这里只是把数据放到了AudioPlayerManager里面.
而音乐播放器就在AudioPlayerManager里面初始化的时候就创建了:
kotlin
import android.content.Context
import android.media.MediaPlayer
import android.net.Uri
/**
* 简单的音乐播放器
*
* @author fdk
* @date 2024-04-08
*/
class AudioPlayer(private val mContext: Context) {
// 当前音乐
private var curUri: Uri? = null
private val mediaPlayer: MediaPlayer = MediaPlayer().apply {
// 单曲循环
setOnCompletionListener {
curUri?.let {
start(curUri!!)
}
}
}
/**
* 播放音乐
*
* @param uri 音乐的uri
*/
fun start(uri: Uri) {
curUri = uri
mediaPlayer.reset()
mediaPlayer.setDataSource(mContext, uri)
mediaPlayer.prepareAsync()
mediaPlayer.setOnPreparedListener {
mediaPlayer.start()
}
}
/**
* 继续播放音乐
*/
fun play() {
if (!mediaPlayer.isPlaying) {
mediaPlayer.start()
}
}
/**
* 暂停播放音乐
*/
fun pause() {
if (mediaPlayer.isPlaying) {
mediaPlayer.pause()
}
}
/**
* 停止播放音乐
*/
fun stop() {
if (mediaPlayer.isPlaying || mediaPlayer.currentPosition > 0) {
mediaPlayer.stop()
mediaPlayer.prepareAsync()
}
}
/**
* 释放音乐播放器资源
*/
fun release() {
mediaPlayer.release()
}
}
音乐播放器写的比较简单,还有很多功能写在AudioPlayerManager里面,因为和remoteView有关,这里先不讲吧。
自定义通知
还记得AudioService里面的前台通知吗?实际是在AudioPlayerManager里面创建的:
kotlin
// 创建通知启动前台服务(要在5s内)
val notification = mAudioPlayerManager.createNotification()
创建通知
这里面就包含我们的自定义通知,使用了remoteView:
kotlin
/**
* 创建一个默认的通知,不发送,由service发送
*/
public fun createNotification(): Notification {
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
// 创建一个pendingIntent,点击整个通知跳转APP
val pendingIntent = PendingIntent.getActivity(
context,
REQUEST_CODE_ACTIVITY,
intent,
PendingIntent.FLAG_IMMUTABLE
)
// 通过builder,把声音和震动关了
mNotificationManager.mNotificationBuilder.apply {
setSound(null)
setVibrate(null)
setDefaults(0)
}
// importance也会影响声音和震动
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
mNotificationManager.mChannel.apply {
importance = NotificationManager.IMPORTANCE_LOW
mNotificationManager.updateChannel(this)
}
}
// 创建通知管理器
return mNotificationManager.createCustomNotification(
getRemoteView(null),
pendingIntent
)
}
private fun updateNotification(audio: Audio) {
// 更新remoteView到通知栏
mNotificationManager.sendOrUpdateCustomNotification(getRemoteView(audio))
}
创建RemoteView
创建通知和更新通知,在上一篇文章讲了,我们着重看下remoteView的使用:
kotlin
private fun getRemoteView(audio: Audio?): RemoteViews {
val remoteViews = RemoteViews(context.packageName, R.layout.item_remote_notification)
// 设置标题
val title = audio?.title ?: "waiting for play music..."
remoteViews.setTextViewText(R.id.title, title)
// 设置播放按钮
val text = if (isPlaying) "PAUSE" else "PALY"
remoteViews.setTextViewText(R.id.pause, text)
// 上一首
val lastIntent = Intent(ACTION_LAST)
val lastPendingIntent = PendingIntent.getBroadcast(
context, REQUEST_CODE_CLICK_LAST, lastIntent, PendingIntent.FLAG_IMMUTABLE
)
remoteViews.setOnClickPendingIntent(R.id.last, lastPendingIntent)
// 暂停
val pauseIntent = Intent(ACTION_PAUSE)
val pausePendingIntent = PendingIntent.getBroadcast(
context, REQUEST_CODE_CLICK_PAUSE, pauseIntent, PendingIntent.FLAG_IMMUTABLE
)
remoteViews.setOnClickPendingIntent(R.id.pause, pausePendingIntent)
// 下一首
val nextIntent = Intent(ACTION_NEXT)
val nextPendingIntent = PendingIntent.getBroadcast(
context, REQUEST_CODE_CLICK_NEXT, nextIntent, PendingIntent.FLAG_IMMUTABLE
)
remoteViews.setOnClickPendingIntent(R.id.next, nextPendingIntent)
return remoteViews
}
item_remote_notification页面效果如下:

前面我们给通知设置的pendingIntent是点击通知跳转到activity的,我们要给remoteViews上的按钮也加上事件,也需要用到pendingIntent,这里我们选的是发送Broadcast的pendingIntent。
接收自定义通知点击事件
我们给自定义通知的按钮设置好发送Broadcast的pendingIntent后,我们就应该去接收这个事件,需要用到receiver:
kotlin
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.silencefly96.module_tech.service.audio_player.player.AudioPlayerManager
import com.silencefly96.module_tech.service.audio_player.audio.Audio
/**
* 动态广播,接受通知栏动作
*/
class AudioActionReceiver(private val audioPlayerManager: AudioPlayerManager): BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
audioPlayerManager.apply {
when(intent.action) {
AudioPlayerManager.ACTION_LAST -> last()
AudioPlayerManager.ACTION_PAUSE -> playOrPause()
AudioPlayerManager.ACTION_NEXT -> next()
AudioPlayerManager.ACTION_PLAY -> {
val position = intent.getIntExtra("position", 0)
val audio = intent.getParcelableExtra<Audio>("audio")
Log.d("TAG", "onReceive: position=$position, audio=$audio")
// 两种播放方法
if (audio == null) {
play(position)
}else {
play(audio)
}
}
}
}
}
}
AudioActionReceiver持有了AudioPlayerManager的引用,通过AudioPlayerManager去控制音频播放,前面AudioPlayerManager在init的时候我们就注册了动态广播:
kotlin
private fun registerReceiver() {
val intentFilter = IntentFilter().apply {
addAction(ACTION_LAST)
addAction(ACTION_PAUSE)
addAction(ACTION_NEXT)
addAction(ACTION_PLAY)
}
mActionReceiver = AudioActionReceiver(this)
context.registerReceiver(mActionReceiver, intentFilter)
}
也就是说remoteView中按钮的点击事件,会通过pendingIntent,通过广播发送出去,然后到达AudioActionReceiver,而AudioActionReceiver又持有了AudioPlayerManager,于是就能控制音频的播放了。
这里是我们需求点的第五步,而第四步也需要用到AudioActionReceiver。
markdown
4. 页面列表中,点击播放音乐,通知栏更新显示数据
5. 通知栏通知能播放、暂停、上一首、下一首音乐
实现点击播放
因为我们的AudioPlayerManager是在AudioService里面的,所以在页面列表里面是没法控制的,我们只能通过AudioActionReceiver去控制:
kotlin
val intent = Intent(AudioPlayerManager.ACTION_PLAY)
intent.putExtra("position", position)
intent.putExtra("audio", itemObj)
requireContext().sendBroadcast(intent)
AudioActionReceiver收到播放的intent之后,就会通过AudioPlayerManager的play去播放
kotlin
/**
* 播放列表音乐
*
* @param index 列表内索引
*/
public fun play(index: Int) {
// 列表循环
mCurPosition = (index + mAudios.size) % mAudios.size
// 更新通知栏
updateNotification(mAudios[mCurPosition])
// 更新桌面小组件
updateAppWidget(mAudios[mCurPosition])
// 播放音乐
mAudioPlayer.start(mAudios[mCurPosition].uri)
isPlaying = true
Log.d("TAG", "play: $mCurPosition")
}
而这时候,AudioPlayerManager会重新创建通知,去更新通知栏的自定义通知:
kotlin
private fun updateNotification(audio: Audio) {
// 更新remoteView到通知栏
mNotificationManager.sendOrUpdateCustomNotification(getRemoteView(audio))
}
桌面小组件
上面写的有点乱,但是音乐播放功能,以及通知栏对音乐播放的控制实际已经完成了。
这里,我们用和通知栏一样布局的remoteView做一个桌面小组件,也来实现对APP音频的控制,这个内容相对独立,讲起来应该不会那么乱。
创建小组件
首先我们继承AppWidgetProvider,实现一个小组件,onUpdate里面对小组件进行更新,设置好remoteView,还是类似的代码:
kotlin
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.util.Log
import android.widget.RemoteViews
import com.silencefly96.module_tech.R
import com.silencefly96.module_tech.service.audio_player.audio.Audio
import com.silencefly96.module_tech.service.audio_player.player.AudioPlayerManager
/**
* Implementation of App Widget functionality.
*/
class AudioWidget : AppWidgetProvider() {
// 当前音乐信息
private var mAudio: Audio? = null
// 当前音乐位置
private var mPosition = 0
// 音乐播放状态
private var isPlaying = false
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
// There may be multiple widgets active, so update all of them
for (appWidgetId in appWidgetIds) {
val views = getRemoteView(context, mAudio)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
private fun getRemoteView(context: Context, audio: Audio?): RemoteViews {
val remoteViews = RemoteViews(context.packageName, R.layout.item_remote_notification)
// 设置标题
val title = audio?.title ?: "waiting for play music..."
remoteViews.setTextViewText(R.id.title, title)
// 设置播放按钮
val text = if (isPlaying) "PAUSE" else "PALY"
remoteViews.setTextViewText(R.id.pause, text)
// 上一首
val lastIntent = Intent(AudioPlayerManager.ACTION_LAST)
val lastPendingIntent = PendingIntent.getBroadcast(
context, AudioPlayerManager.REQUEST_CODE_CLICK_LAST, lastIntent, PendingIntent.FLAG_IMMUTABLE
)
remoteViews.setOnClickPendingIntent(R.id.last, lastPendingIntent)
// 暂停
val pauseIntent = Intent(AudioPlayerManager.ACTION_PAUSE)
val pausePendingIntent = PendingIntent.getBroadcast(
context, AudioPlayerManager.REQUEST_CODE_CLICK_PAUSE, pauseIntent, PendingIntent.FLAG_IMMUTABLE
)
remoteViews.setOnClickPendingIntent(R.id.pause, pausePendingIntent)
// 下一首
val nextIntent = Intent(AudioPlayerManager.ACTION_NEXT)
val nextPendingIntent = PendingIntent.getBroadcast(
context, AudioPlayerManager.REQUEST_CODE_CLICK_NEXT, nextIntent, PendingIntent.FLAG_IMMUTABLE
)
remoteViews.setOnClickPendingIntent(R.id.next, nextPendingIntent)
return remoteViews
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if(intent.action.equals(AudioPlayerManager.ACTION_UPDATE)) {
isPlaying = intent.getBooleanExtra("state", false)
mPosition = intent.getIntExtra("position", 0)
mAudio = intent.getParcelableExtra("audio")
// 更新小部件
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, AudioWidget::class.java)
appWidgetManager.updateAppWidget(componentName, getRemoteView(context, mAudio))
Log.d("TAG", "AudioWidget onReceive: isPlaying=$isPlaying position=$mPosition, audio=$mAudio")
}
}
}
重点就在onReceive方法里面,说白了AppWidgetProvider实际就是一个receiver,我们要更新它,只要用它的onReceive接收广播就行了。
我们给AppWidgetProvider发送要更新的广播,重新创建一个remoteView给它更新下就OK了。
注册小组件
既然AppWidgetProvider是一个receiver,那么肯定要注册啊,manifest里面注册:
xml
<receiver
android:name=".service.audio_player.app_widget.AudioWidget"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<!--自定义播放器action,用于更新remoteView-->
<action android:name="com.silencefly96.module_tech.tech.remote_view.update" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/audio_widget_info" />
</receiver>
注意下intent-filter里面加上更新的ACTION,以及我们要让AudioWidget更换remoteView的ACTION。
这里还要配置一份audio_widget_info.xml,用来描述小组件的信息:
xml
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="150dp"
android:minHeight="50dp"
android:updatePeriodMillis="86400000"
android:previewImage="@mipmap/ic_launcher"
android:initialLayout="@layout/item_remote_notification"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen">
</appwidget-provider>
<!--
代码配置说明如下:
1、minWidth和minHeight属性指定默认情况下App Widget占用的最小尺寸(为了使您App的小组件适配多种屏幕,
此处的最小尺寸不得大于4 x 4单元)。更多信息请参见应用程序小组件设计指南。
在Android12以上,可以通过targetCellWidth和targetCellHeight设置单元格数量
2、updatePeriodMillis属性定义App Widget框架通过调用onUpdate()回调方法从AppWidgetProvider请求更新的频率。
使用该值不能保证实际的更新会准时进行,我们不建议频繁地更新(每小时不超过一次),以便节省电池电量。
3、initialLayout属性指向定义App Widget布局的布局资源。
4、configure属性(可选属性)定义了用户添加App Widget时要启动的活动(Activity),以便配置App Widget属性。
5、previewImage属性指定配置后的应用小组件的预览,用户在选择应用小组件时可见。如果未提供,用户看到的为您应用程序的
启动器图标。
6、resizeMode属性指定可以调整窗口小组件大小的规则,例如水平、垂直或双向调整大小等。
7、minResizeHeight和minResizeWidth属性指定窗口小组件可以调整大小的最小高度与最小宽度(单位为dp)。
-->
代码注释里面讲的很清楚了,这里有点坑的就是只有Android12后才能按单元格给小组件设置宽高,难怪没人用。。。
更新小组件
要更新小组件,我们只要用AudioWidget在intent-filter设置好的更新action就行了:
kotlin
private fun updateAppWidget(audio: Audio) {
val intent = Intent(ACTION_UPDATE)
intent.component = ComponentName(context, AudioWidget::class.java)
intent.putExtra("state", isPlaying)
intent.putExtra("position", mCurPosition)
intent.putExtra("audio", audio)
context.sendBroadcast(intent)
}
比如在AudioPlayerManager播放或暂停音乐里面:
kotlin
/**
* 播放或暂停音乐
*/
public fun playOrPause() {
// 暂停或者重新播放音乐
if (isPlaying) {
mAudioPlayer.pause()
}else {
mAudioPlayer.play()
}
isPlaying = !isPlaying
// 更新通知栏
updateNotification(mAudios[mCurPosition])
// 更新桌面小组件
updateAppWidget(mAudios[mCurPosition])
Log.d("TAG", "pause: $isPlaying")
}
还有个问题,因为我们对播放器的控制都是通过发广播形式实现的,所以通知栏的remoteView和桌面小组件的remoteView展示的内容是同步更新的,这就很nice!
效果图
UI设计有点丑了,功能还是很好使的:
源码及Demo
源码涉及的文件比较多,不过我都放在了这个目录下,有兴趣可以参考下:
Demo则是在这:
小结
简单写了个音频播放器,把Uri音频查询、前台服务、自定义通知(RemoteView)、pengdingIntent、播放器、桌面小组件实践了下。