30、通知的实战项目二:仿音乐播放器通知(播放控制、进度条、封面显示、后台播放)
好,终于到了我们通知系列的压轴实战了。
前面我们聊了那么多通知的基础、样式、分组、渠道……说实话,那些都是“零件”。今天这个项目,才是真正把零件组装成一台机器的过程。
仿音乐播放器通知,说白了,就是让你在通知栏里能控制音乐播放。暂停、下一首、看封面、看进度条,甚至App被杀了还能继续播。嗯,这活儿我当年第一次做的时候,踩了不少坑。
今天我把这些经验全盘托出,你跟着走一遍,基本就能掌握通知在复杂场景下的全部用法。
30.1 项目需求与整体架构
先明确我们要做什么。一个完整的音乐播放器通知,至少需要这几个能力:
- 播放控制:播放/暂停、上一首、下一首、停止
- 封面显示:用大图样式展示专辑封面
- 进度条:实时显示当前播放位置和总时长
- 后台播放:App退到后台甚至被杀死,音乐继续播
这背后涉及三个核心组件:MediaSession、MediaStyle 通知、以及 Foreground Service。我习惯把它们的关系画成一张图,你一看就明白。
你看,整个流程是闭环的:用户点击通知 → 触发 PendingIntent → Service 收到回调 → MediaSession 处理 → 更新通知状态。每一步都环环相扣。
30.2 创建前台服务与 MediaSession
后台播放的核心,就是前台服务。为什么?因为 Android 8.0 之后,普通后台服务活不过几分钟。前台服务有个持续的通知,系统就不敢杀你。
我个人习惯把 Service、MediaSession、通知管理拆成三个类,职责清晰。但为了演示方便,这里我把核心逻辑放在一个 Service 里。
public class MusicPlaybackService extends Service {
private MediaSessionCompat mediaSession;
private MediaSessionCallback callback;
private NotificationManager notificationManager;
private boolean isPlaying = false;
private int currentPosition = 0;
private static final int NOTIFICATION_ID = 1001;
private static final String CHANNEL_ID = "music_playback";
@Override
public void onCreate() {
super.onCreate();
notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
createNotificationChannel();
initMediaSession();
}
private void initMediaSession() {
ComponentName mediaButtonReceiver = new ComponentName(this, MediaButtonReceiver.class);
mediaSession = new MediaSessionCompat(this, "MusicPlaybackSession", mediaButtonReceiver, null);
callback = new MediaSessionCallback();
mediaSession.setCallback(callback);
mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS |
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);
mediaSession.setActive(true);
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"音乐播放",
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription("音乐播放控制通知渠道");
channel.setShowBadge(false);
notificationManager.createNotificationChannel(channel);
}
}
// ... 后续代码
}
这里有个细节:IMPORTANCE_LOW。为什么不用 HIGH?因为音乐通知不需要弹窗打扰用户,它只需要出现在通知栏就行。我之前有个项目用了 HIGH,结果用户疯狂投诉“听个歌还一直震动”……嗯,从那以后我再也不敢乱设重要性了。
30.3 构建带封面和进度条的 MediaStyle 通知
通知的样式,我们选 MediaStyle。它能自动适配锁屏界面,还能把控制按钮放在通知底部。
封面怎么显示?用 BigPictureStyle 或者 DecoratedMediaCustomViewStyle。我推荐后者,兼容性更好。
private NotificationCompat.Builder buildNotification() {
// 加载封面图片(这里假设从资源或网络获取)
Bitmap albumArt = loadAlbumArt();
// 构建控制按钮的 PendingIntent
Intent playIntent = new Intent(this, MusicPlaybackService.class);
playIntent.setAction(ACTION_PLAY_PAUSE);
PendingIntent playPendingIntent = PendingIntent.getService(
this, 0, playIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
Intent nextIntent = new Intent(this, MusicPlaybackService.class);
nextIntent.setAction(ACTION_NEXT);
PendingIntent nextPendingIntent = PendingIntent.getService(
this, 1, nextIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
Intent prevIntent = new Intent(this, MusicPlaybackService.class);
prevIntent.setAction(ACTION_PREVIOUS);
PendingIntent prevPendingIntent = PendingIntent.getService(
this, 2, prevIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
// 构建通知
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_music_note)
.setContentTitle("当前歌曲:" + currentSongTitle)
.setContentText(currentArtistName)
.setLargeIcon(albumArt)
.setStyle(new androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSession.getSessionToken())
.setShowActionsInCompactView(0, 1, 2) // 在紧凑视图显示三个按钮
.setShowCancelButton(true)
.setCancelButtonIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(
this, PlaybackStateCompat.ACTION_STOP)))
.addAction(R.drawable.ic_skip_previous, "上一首", prevPendingIntent)
.addAction(isPlaying ? R.drawable.ic_pause : R.drawable.ic_play,
isPlaying ? "暂停" : "播放", playPendingIntent)
.addAction(R.drawable.ic_skip_next, "下一首", nextPendingIntent)
.setOngoing(isPlaying)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC);
// 添加进度条(Android 7.0+ 支持)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
builder.setShowWhen(false)
.setUsesChronometer(false);
// 使用 MediaMetadata 传递进度信息
MediaMetadataCompat metadata = new MediaMetadataCompat.Builder()
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, totalDuration)
.putLong(MediaMetadataCompat.METADATA_KEY_POSITION, currentPosition)
.build();
mediaSession.setMetadata(metadata);
}
return builder;
}
你想想看,这里最难的是什么?是进度条的实时更新。通知不是活的 View,它是个静态的快照。要让它动起来,你得定时更新通知。
30.4 定时更新进度与状态同步
更新通知,说白了就是重新构建一次 Notification,然后调用 NotificationManager.notify()。注意 ID 要一致,否则会变成多条通知。
private void updateNotificationProgress() {
// 模拟进度更新(实际项目中从播放器获取)
if (isPlaying) {
currentPosition += 1000; // 每秒加1秒
if (currentPosition > totalDuration) {
currentPosition = totalDuration;
}
}
Notification notification = buildNotification().build();
notificationManager.notify(NOTIFICATION_ID, notification);
// 同时更新 MediaSession 的播放状态
PlaybackStateCompat state = new PlaybackStateCompat.Builder()
.setActions(PlaybackStateCompat.ACTION_PLAY_PAUSE |
PlaybackStateCompat.ACTION_SKIP_TO_NEXT |
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS)
.setState(isPlaying ? PlaybackStateCompat.STATE_PLAYING
: PlaybackStateCompat.STATE_PAUSED,
currentPosition, 1.0f)
.build();
mediaSession.setPlaybackState(state);
}
// 在 Service 中启动定时器
private void startProgressUpdater() {
if (progressHandler == null) {
progressHandler = new Handler(Looper.getMainLooper());
}
progressRunnable = new Runnable() {
@Override
public void run() {
updateNotificationProgress();
progressHandler.postDelayed(this, 1000); // 每秒执行
}
};
progressHandler.post(progressRunnable);
}
这里有个坑:Handler 一定要绑定主线程 Looper。为什么?因为 NotificationManager.notify() 需要在主线程调用。我曾经在子线程直接调,结果通知死活不更新,查了半天才发现是线程问题。
30.5 处理通知点击事件与后台存活
通知上的按钮点击后,会通过 PendingIntent 启动 Service。我们在 Service 的 onStartCommand 里处理这些 Action。
public static final String ACTION_PLAY_PAUSE = "com.example.action.PLAY_PAUSE";
public static final String ACTION_NEXT = "com.example.action.NEXT";
public static final String ACTION_PREVIOUS = "com.example.action.PREVIOUS";
public static final String ACTION_STOP = "com.example.action.STOP";
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null) {
String action = intent.getAction();
switch (action) {
case ACTION_PLAY_PAUSE:
if (isPlaying) {
pauseMusic();
} else {
playMusic();
}
break;
case ACTION_NEXT:
nextTrack();
break;
case ACTION_PREVIOUS:
previousTrack();
break;
case ACTION_STOP:
stopMusic();
stopForeground(true);
stopSelf();
break;
}
}
return START_STICKY;
}
private void playMusic() {
isPlaying = true;
// 实际播放逻辑...
startForeground(NOTIFICATION_ID, buildNotification().build());
startProgressUpdater();
}
private void pauseMusic() {
isPlaying = false;
// 暂停逻辑...
updateNotificationProgress();
// 注意:暂停时不要 stopForeground,否则通知会消失
}
startForeground() 必须在 Service 的 onStartCommand 或 onCreate 中,且必须在 5 秒内完成。否则系统会报 Context.startForegroundService() did not then call Service.startForeground() 错误。我当年上线前被这个 crash 搞到凌晨三点。
关于后台存活,还有个关键点:START_STICKY。这个标志告诉系统,如果 Service 被系统杀掉了,等资源充足时重新创建它。但注意,重新创建时 onStartCommand 会传入 null intent,所以你的代码要处理 null 的情况。
30.6 完整代码整合与测试要点
好了,我们把所有代码串起来。一个完整的音乐播放通知 Service,核心流程就是:
- 创建通知渠道(低重要性)
- 初始化 MediaSession,设置回调
- 在
onStartCommand中处理 Action - 构建 MediaStyle 通知,包含封面、按钮、进度
- 调用
startForeground()显示通知 - 定时更新进度和播放状态
- 处理停止事件,清理资源
测试的时候,我建议你重点关注这几个场景:
| 测试场景 | 预期行为 | 常见问题 |
|---|---|---|
| App 退到后台 | 音乐继续播放,通知正常显示 | Service 被销毁 → 检查 START_STICKY |
| 滑动清除通知 | 音乐停止,Service 销毁 | 通知无法清除 → 检查 setOngoing(false) |
| 锁屏界面 | 显示控制按钮和封面 | 不显示 → 检查 VISIBILITY_PUBLIC |
| 快速点击暂停/播放 | 状态同步,不闪退 | ANR → 检查 PendingIntent 的 FLAG_IMMUTABLE |
核心总结:
- 前台服务 + MediaStyle 通知 = 后台播放的黄金组合
- 进度条更新用 Handler 定时器,每秒一次足够
- MediaSession 负责状态同步,通知负责展示
- 别忘了处理 Android 12 以上的 PendingIntent 可变性要求
说实话,音乐播放器通知这个功能,看起来简单,做起来全是细节。但只要你把 Service、MediaSession、Notification 这三者的关系理清了,剩下的就是体力活。
嗯,今天的实战就到这里。代码你可以直接拿去用,但建议你手敲一遍。有些坑,只有自己踩过才记得住。
公众号:蓝海资料掘金营,微信deep3321