5. Android Auto媒体应用开发:音频焦点管理、播放列表构建、通知与锁屏控制

好,咱们今天聊点硬核的。Android Auto上的媒体应用,说白了就是让用户在开车时能安全地听歌、听播客。但这里头坑不少。我最早做车载媒体开发时,以为就是把手机端的播放器搬过来,结果被音频焦点和锁屏控制折腾得够呛。今天我把这些经验掰开揉碎,跟你讲清楚。

5.1 音频焦点管理:别让音乐打架

音频焦点是什么?简单说,就是系统用来协调多个App谁出声的机制。你想想看,导航在说“前方300米右转”,这时候音乐还在大声放,司机能听清吗?

Android Auto里,音频焦点管理比手机端更严格。因为车载场景下,导航、电话、语音助手都是高优先级。我见过不少App,焦点处理不当,导致导航播报时音乐忽大忽小,用户体验极差。

5.1.1 请求与释放焦点

核心API就两个:requestAudioFocus()abandonAudioFocus()。但怎么用,有讲究。

// 请求音频焦点
AudioAttributes audioAttributes = new AudioAttributes.Builder()
        .setUsage(AudioAttributes.USAGE_MEDIA)
        .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
        .build();

AudioFocusRequest focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
        .setAudioAttributes(audioAttributes)
        .setAcceptsDelayedFocusGain(true)
        .setOnAudioFocusChangeListener(focusChangeListener)
        .build();

int result = audioManager.requestAudioFocus(focusRequest);

if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
    // 焦点获取成功,开始播放
    startPlayback();
} else if (result == AudioManager.AUDIOFOCUS_REQUEST_DELAYED) {
    // 焦点被延迟授予,比如电话正在响铃
    // 这时候不要播放,等待回调
    waitForFocus();
}

这里有个细节:setAcceptsDelayedFocusGain(true)。我在项目中遇到过,如果不设置这个,电话挂断后你的App可能永远拿不到焦点。系统会认为你放弃了。

5.1.2 焦点变化监听

焦点不是一次性的。你播放过程中,导航可能突然插进来。这时候需要监听焦点变化:

private AudioManager.OnAudioFocusChangeListener focusChangeListener = 
    new AudioManager.OnAudioFocusChangeListener() {
    @Override
    public void onAudioFocusChange(int focusChange) {
        switch (focusChange) {
            case AudioManager.AUDIOFOCUS_LOSS:
                // 永久失去焦点,比如另一个媒体App开始播放
                // 应该停止播放
                pausePlayback();
                break;
            case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
                // 暂时失去焦点,比如导航播报
                // 暂停播放,等恢复
                pausePlayback();
                break;
            case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
                // 可以降低音量继续播放
                // 比如导航播报时,音乐降低音量
                setVolume(0.3f);
                break;
            case AudioManager.AUDIOFOCUS_GAIN:
                // 重新获得焦点
                // 恢复播放或恢复音量
                resumePlayback();
                break;
        }
    }
};

注意:千万别在 AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK 时直接静音。我见过有些App直接 mute,结果导航播报结束后音乐没了,用户以为App挂了。降低音量到30%左右是比较合理的做法。

5.2 播放列表构建:MediaSession与MediaBrowserService

Android Auto的媒体应用,核心是 MediaSessionMediaBrowserService。这两个东西,说白了就是你的播放器和车载系统之间的桥梁。

5.2.1 MediaSession:播放控制中心

MediaSession负责接收来自车载屏幕的播放指令:播放、暂停、下一首、上一首。它就像个遥控器接收器。

MediaSessionCompat mediaSession = new MediaSessionCompat(context, "MyMediaSession");
mediaSession.setCallback(new MediaSessionCompat.Callback() {
    @Override
    public void onPlay() {
        // 用户点击了播放按钮
        startPlayback();
    }

    @Override
    public void onPause() {
        // 用户点击了暂停
        pausePlayback();
    }

    @Override
    public void onSkipToNext() {
        // 下一首
        playNextTrack();
    }

    @Override
    public void onSkipToPrevious() {
        // 上一首
        playPreviousTrack();
    }

    @Override
    public void onSeekTo(long pos) {
        // 拖动进度条
        seekToPosition(pos);
    }
});

// 别忘了设置可用的播放动作
mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS |
        MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);

嗯,这里有个坑。我刚开始做的时候,忘了设置 FLAG_HANDLES_TRANSPORT_CONTROLS,结果车载屏幕上播放按钮一直是灰色的。折腾了半天才发现是少了个flag。

5.2.2 MediaBrowserService:内容提供者

MediaBrowserService负责向车载系统提供你的播放列表。系统通过它来浏览你的音乐库、播客列表。

public class MyMediaBrowserService extends MediaBrowserServiceCompat {

    @Override
    public void onCreate() {
        super.onCreate();
        // 初始化你的媒体会话
        mediaSession = new MediaSessionCompat(this, "MyMediaBrowserService");
        setSessionToken(mediaSession.getSessionToken());
    }

    @Override
    public BrowserRoot onGetRoot(@NonNull String clientPackageName,
                                  int clientUid,
                                  @Nullable Bundle rootHints) {
        // 验证客户端身份,Android Auto系统会通过
        if (clientPackageName.equals("com.google.android.projection.gearhead")) {
            return new BrowserRoot("root", null);
        }
        return null; // 拒绝非车载系统的访问
    }

    @Override
    public void onLoadChildren(@NonNull String parentId,
                                @NonNull Result<List<MediaBrowserCompat.MediaItem>> result) {
        // 返回子节点列表
        List<MediaBrowserCompat.MediaItem> items = new ArrayList<>();

        if (parentId.equals("root")) {
            // 根节点:返回分类
            items.add(createCategoryItem("最近播放", "recent"));
            items.add(createCategoryItem("我的歌单", "playlists"));
            items.add(createCategoryItem("播客订阅", "podcasts"));
        } else if (parentId.equals("playlists")) {
            // 返回歌单列表
            items.add(createMediaItem("摇滚精选", "playlist_1"));
            items.add(createMediaItem("轻音乐", "playlist_2"));
        }

        result.sendResult(items);
    }

    private MediaBrowserCompat.MediaItem createCategoryItem(String title, String id) {
        MediaDescriptionCompat description = new MediaDescriptionCompat.Builder()
                .setMediaId(id)
                .setTitle(title)
                .setSubtitle("浏览")
                .build();
        return new MediaBrowserCompat.MediaItem(description,
                MediaBrowserCompat.MediaItem.FLAG_BROWSABLE);
    }

    private MediaBrowserCompat.MediaItem createMediaItem(String title, String id) {
        MediaDescriptionCompat description = new MediaDescriptionCompat.Builder()
                .setMediaId(id)
                .setTitle(title)
                .build();
        return new MediaBrowserCompat.MediaItem(description,
                MediaBrowserCompat.MediaItem.FLAG_PLAYABLE);
    }
}

小技巧:如果你的播放列表很大,比如有几千首歌,别一次性全返回。用分页加载,每次返回20-50条。我在项目中遇到过,一次性返回上千条,车载系统直接卡死。

5.3 通知与锁屏控制

车载场景下,用户不能操作手机。所以通知和锁屏控制是唯一的交互入口。这部分做不好,用户只能干瞪眼。

5.3.1 媒体通知:让用户控制播放

Android Auto要求媒体应用必须发布一个媒体通知。这个通知会显示在车载屏幕上,包含播放/暂停、上一首/下一首按钮。

// 构建媒体通知
MediaSessionCompat.Token token = mediaSession.getSessionToken();

MediaStyle mediaStyle = new MediaStyle()
        .setMediaSession(token)
        .setShowActionsInCompactView(0, 1, 2) // 在紧凑模式下显示三个按钮
        .setShowCancelButton(true);

// 创建播放动作
NotificationCompat.Action playAction = new NotificationCompat.Action(
        R.drawable.ic_play, "播放",
        MediaButtonReceiver.buildMediaButtonPendingIntent(this,
                PlaybackStateCompat.ACTION_PLAY));

NotificationCompat.Action pauseAction = new NotificationCompat.Action(
        R.drawable.ic_pause, "暂停",
        MediaButtonReceiver.buildMediaButtonPendingIntent(this,
                PlaybackStateCompat.ACTION_PAUSE));

NotificationCompat.Action nextAction = new NotificationCompat.Action(
        R.drawable.ic_skip_next, "下一首",
        MediaButtonReceiver.buildMediaButtonPendingIntent(this,
                PlaybackStateCompat.ACTION_SKIP_TO_NEXT));

// 构建通知
Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
        .setSmallIcon(R.drawable.ic_music_note)
        .setContentTitle("当前播放:摇滚精选")
        .setContentText("歌手:某乐队")
        .setLargeIcon(albumArt)
        .setStyle(mediaStyle)
        .addAction(playAction)   // 索引0
        .addAction(pauseAction)  // 索引1
        .addAction(nextAction)   // 索引2
        .setOngoing(true)
        .build();

// 启动前台服务
startForeground(NOTIFICATION_ID, notification);

注意,setShowActionsInCompactView(0, 1, 2) 这个参数。它决定了在通知栏折叠状态下显示哪几个按钮。我一般把播放/暂停放在中间,上一首和下一首放两边。

5.3.2 锁屏控制:Android Auto的专属界面

Android Auto的锁屏控制,其实是通过MediaSession自动实现的。你只需要正确设置播放状态:

// 更新播放状态
PlaybackStateCompat state = new PlaybackStateCompat.Builder()
        .setActions(PlaybackStateCompat.ACTION_PLAY |
                PlaybackStateCompat.ACTION_PAUSE |
                PlaybackStateCompat.ACTION_SKIP_TO_NEXT |
                PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS |
                PlaybackStateCompat.ACTION_SEEK_TO |
                PlaybackStateCompat.ACTION_STOP)
        .setState(PlaybackStateCompat.STATE_PLAYING, // 或 STATE_PAUSED
                currentPosition, // 当前进度
                1.0f)           // 播放速度
        .build();

mediaSession.setPlaybackState(state);

// 更新元数据
MediaMetadataCompat metadata = new MediaMetadataCompat.Builder()
        .putString(MediaMetadataCompat.METADATA_KEY_TITLE, "摇滚精选")
        .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "某乐队")
        .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "专辑名")
        .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration)
        .putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArt)
        .build();

mediaSession.setMetadata(metadata);

核心要点:Android Auto的锁屏界面完全由系统根据MediaSession的播放状态和元数据自动渲染。你不需要自己画界面。但前提是——你必须正确设置 PlaybackStateMediaMetadata。少一个字段,界面就可能显示不全。

5.4 知识体系总览

下面这张图,把今天讲的内容串起来了。你可以看到音频焦点、MediaSession、MediaBrowserService、通知这四个模块如何协同工作。

Android Auto 媒体应用架构 音频焦点管理 AudioManager 请求/释放/监听 MediaSession 播放控制中心 接收车载指令 MediaBrowserService 内容提供者 浏览播放列表 通知与锁屏 前台服务 MediaStyle通知 具体实现细节 AUDIOFOCUS_GAIN AUDIOFOCUS_LOSS onPlay/onPause onSkipToNext onGetRoot onLoadChildren MediaStyle通知 前台服务 Android Auto 车载系统 通过 MediaSession 发送控制指令 → 通过 MediaBrowserService 浏览内容 → 显示通知与锁屏界面

5.5 避坑指南

最后,分享几个我踩过的坑:

  • 音频焦点不释放:我曾经有个bug,App退出后音频焦点没释放,导致导航一直没声音。后来在 onDestroy() 里加了 abandonAudioFocus() 才解决。
  • MediaSession Token 未设置:MediaBrowserService 里忘了调用 setSessionToken(),结果车载系统一直连不上。这个错很低级,但很容易忘。
  • 通知渠道兼容性:Android 8.0以上必须创建通知渠道。我见过有些App在低版本手机上崩溃,就是因为没做版本判断。
  • 前台服务权限:Android 9以上需要 FOREGROUND_SERVICE 权限。别忘了在Manifest里声明。

我的建议:开发时先用模拟器调试。Android Auto模拟器虽然慢,但能模拟大部分车载场景。真机调试时,记得把手机连上车机,或者用 Desktop Head Unit 工具。别像我一样,在模拟器上跑得好好的,一上车就翻车。

好了,这一章的内容就这些。音频焦点、播放列表、通知锁屏,这三个东西是Android Auto媒体应用的基石。把它们搞扎实了,后面的开发就顺了。