30、通知的实战项目二:仿音乐播放器通知(播放控制、进度条、封面显示、后台播放)

好,终于到了我们通知系列的压轴实战了。

前面我们聊了那么多通知的基础、样式、分组、渠道……说实话,那些都是“零件”。今天这个项目,才是真正把零件组装成一台机器的过程。

仿音乐播放器通知,说白了,就是让你在通知栏里能控制音乐播放。暂停、下一首、看封面、看进度条,甚至App被杀了还能继续播。嗯,这活儿我当年第一次做的时候,踩了不少坑。

今天我把这些经验全盘托出,你跟着走一遍,基本就能掌握通知在复杂场景下的全部用法。

30.1 项目需求与整体架构

先明确我们要做什么。一个完整的音乐播放器通知,至少需要这几个能力:

  • 播放控制:播放/暂停、上一首、下一首、停止
  • 封面显示:用大图样式展示专辑封面
  • 进度条:实时显示当前播放位置和总时长
  • 后台播放:App退到后台甚至被杀死,音乐继续播

这背后涉及三个核心组件:MediaSessionMediaStyle 通知、以及 Foreground Service。我习惯把它们的关系画成一张图,你一看就明白。

音乐播放器通知架构图 用户操作(点击通知) MediaStyle 通知(前台通知) 显示封面、进度条、控制按钮 Foreground Service(前台服务) 保证进程优先级,后台持续播放 MediaSession(媒体会话) 处理播放控制回调、同步状态 用户点击 触发 PendingIntent Service 持有 MediaSession 实例 MediaSession 更新通知状态

你看,整个流程是闭环的:用户点击通知 → 触发 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,它是个静态的快照。要让它动起来,你得定时更新通知。

我的经验:进度条更新频率不要太高。每秒更新一次就够了,太频繁反而浪费性能。我见过有人用 100ms 更新一次,结果通知栏卡成 PPT。

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 的 onStartCommandonCreate 中,且必须在 5 秒内完成。否则系统会报 Context.startForegroundService() did not then call Service.startForeground() 错误。我当年上线前被这个 crash 搞到凌晨三点。

关于后台存活,还有个关键点:START_STICKY。这个标志告诉系统,如果 Service 被系统杀掉了,等资源充足时重新创建它。但注意,重新创建时 onStartCommand 会传入 null intent,所以你的代码要处理 null 的情况。

30.6 完整代码整合与测试要点

好了,我们把所有代码串起来。一个完整的音乐播放通知 Service,核心流程就是:

  1. 创建通知渠道(低重要性)
  2. 初始化 MediaSession,设置回调
  3. onStartCommand 中处理 Action
  4. 构建 MediaStyle 通知,包含封面、按钮、进度
  5. 调用 startForeground() 显示通知
  6. 定时更新进度和播放状态
  7. 处理停止事件,清理资源

测试的时候,我建议你重点关注这几个场景:

测试场景 预期行为 常见问题
App 退到后台 音乐继续播放,通知正常显示 Service 被销毁 → 检查 START_STICKY
滑动清除通知 音乐停止,Service 销毁 通知无法清除 → 检查 setOngoing(false)
锁屏界面 显示控制按钮和封面 不显示 → 检查 VISIBILITY_PUBLIC
快速点击暂停/播放 状态同步,不闪退 ANR → 检查 PendingIntent 的 FLAG_IMMUTABLE

核心总结:

  • 前台服务 + MediaStyle 通知 = 后台播放的黄金组合
  • 进度条更新用 Handler 定时器,每秒一次足够
  • MediaSession 负责状态同步,通知负责展示
  • 别忘了处理 Android 12 以上的 PendingIntent 可变性要求

说实话,音乐播放器通知这个功能,看起来简单,做起来全是细节。但只要你把 Service、MediaSession、Notification 这三者的关系理清了,剩下的就是体力活。

嗯,今天的实战就到这里。代码你可以直接拿去用,但建议你手敲一遍。有些坑,只有自己踩过才记得住。


公众号:蓝海资料掘金营,微信deep3321