android mediasession 音频服务框架

https://mp.weixin.qq.com/s/AngSHW1qUBsl_acFhlWGDA?

概要

下面列出构建一个典型的音乐 App 需要注意的点,然后我们将一一展开。

  • 使用 MediaSession 进行 UI 与后台播放状态的同步

  • 音乐的后台播放

  • AudioFocus 的处理

  • ACTION_AUDIO_BECOMING_NOISY

  • MediaButton 事件处理

  • 通知栏更新

  • Android 旧版本的兼容

  • WifiLock && WakeLock

  • 播放进度更新

MediaSession 框架简介

MediaSession 是 Android 5.0 推出的媒体播放框架,负责 UI 和后台播放之间的状态同步,支持了绝大部分音频播放的可能会遇到的操作,而且支持自定义操作。主要由 MediaSession (受控端) 和 MediaController (控制端) 构成:

image
  1. MediaSession
  • MediaSession.Token: 用于保持与 MediaController 的正常配对

  • MediaSession.Callback:用于监听 MediaController 的各种播放指令

  1. MediaController
  • MediaController.Callback:用于监听播放状态/信息更新

  • MediaController.TransportControls:用于向 MediaSession 发送各种播放指令

基本流程就是,UI 通过使用 MediaController.TransportControls 发送播放相关的控制指令(play, pause, stop 等等),MediaSession.Callback 在接收到相关指令后,对 Player 进行对应的操作,然后状态更新通过 MediaSession 同步给 MediaController.Callback, 最后更新 UI。

后台播放

显然,后台播放需要通过 Service 实现,而且后台 Service 需要继承自 MediaSession 框架中的 MediaBrowserService,同时需要在 AndroidManifest.xml 中加入 IntentFiliter。

<intent-filter>
  <action android:name="android.media.browse.MediaBrowserService" />
 </intent-filter>

典型的初始化工作如下:

public class MediaPlaybackService extends MediaBrowserService {

  @Override
  public void onCreate() {
    super.onCreate();
    // 1\. 初始化 MediaSession
    // 2\. 设置 MedisSessionCallback
    // 3\. 开启 MediaButton 和 TransportControls 的支持
    // 4\. 初始化 PlaybackState
    // 5\. 关联 SessionToken
  }

  @Override
  public BrowserRoot onGetRoot(String clientPackageName, int clientUid, Bundle rootHints) {
    // 对每个访问端做一些访问权限判断等
  }

  @Override
  public void onLoadChildren(final String parentMediaId,
    final Result<List<MediaItem>> result) {
    // 根据访问权限返回播放列表相关信息
  }
}

而关联 UI 和 Service 的工作主要封装在了 MediaBrowser 里。MediaBrowser 主要的工作就是使用 Bind 的启动方式启动 Service,然后将 MediaSession 的 Token 回调,用于创建 MediaController。

public class MediaPlayerActivity extends AppCompatActivity {
  private MediaBrowserCompat mMediaBrowser;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    mMediaBrowser = new MediaBrowserCompat(this, 
        new ComponentName(this, MediaPlaybackService.class),
        mConnectionCallbacks, null); 
  }

  @Override
  public void onStart() {
    super.onStart();
    mMediaBrowser.connect();
  }

  @Override
  public void onStop() {
    super.onStop();
    if (MediaControllerCompat.getMediaController(MediaPlayerActivity.this) != null) {
      MediaControllerCompat.getMediaController(MediaPlayerActivity.this).unregisterCallback(controllerCallback);
    }
    mMediaBrowser.disconnect();
  }
}

需要注意的是,为了保证音频在后台能够正常的持续播放和停止,需要结合 Service 的 start 和 bind 两种启动方式。每当 UI 需要获取后台播放状态时,都需要 bind 后台 Service 以保证存活,即 MediaBrowser 就是用这种方法启动 Service。而为了让 UI 都 unbind 了之后,后台的音乐不会因此停止播放,需要在音乐播放(onPlay())时,通过 start 的方式启动 Service,而音乐停止播放(onStop())后,因为不在需要 Service 的存活了, 所以可以调用 Service.stopSelf() 来停止 Service。因为只有两个启动方式都不存在的情况下,Service 才会立即销毁。生命周期下图所示,图中 counter 表示 bind 的数量:

image.gif

另外,如果有多进程的需要的话,直接将 Service 放到单独的进程就好了,因为 MediaController 和 MediaSession 的交互底层是通过 Binder 通信的,已经很好的支持了进程间通信。

MediaButton

当音乐处于后台播放的情况下,需要支持 MediaButton 的按键事件(KEYCODE_MEDIA_XXX),比如说线控耳机上的播放/暂停按钮。

首先需要注册 MediaButtonReceiver 到 AndroidManifest.xml。

<receiver android:name="android.support.v4.media.session.MediaButtonReceiver" >
   <intent-filter>
     <action android:name="android.intent.action.MEDIA_BUTTON" />
   </intent-filter>
 </receiver>

有两个原因:

  1. 如果 MediaSession 是 Active 状态。 在Android 5.0 之后, Android MediaButton 的按键事件会直接分发到 MediaSession 的 onMediaButtonEvent(...),默认情况下回调用 MediaSession.Callback 的对应回调,如果有特别需要可以重写这个方法。Android 5.0之前,则需要监听事件广播,典型的代码是在 Service 中加入如下代码。因为,一方面 MediaButtonReceiver 在接收到事件后会直接 startService,另一方面,MediaButtonReceiver.handleIntent(...) 自动映射事件到 MediaSession.Callback 中的对应回调。
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
   MediaButtonReceiver.handleIntent(mMediaSessionCompat, intent);
   return super.onStartCommand(intent, flags, startId);
 }
  1. 如果 Android 识别出这个 MediaSession 是上一个变为 Inactive 状态的,那么 MediaButtonReceiver 就可以接受到这个广播,然后可以重新启动 MediaSession。(主要注意的是这个行为在只有在 Android 5.0 之后可以关闭)

BecomingNoisy

在耳机插入时播放音乐,然后将耳机突然拔出,可能造成音乐外放的尴尬情况。这种情况下系统会发送 AudioManager.ACTION_AUDIO_BECOMING_NOISY 广播,我们只需要在音乐播放的时候注册一个 BroadcastReceiver,在收到广播时暂停播放就好了。

private IntentFilter intentFilter = new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY);
private BecomingNoisyReceiver myNoisyAudioStreamReceiver = new BecomingNoisyReceiver();

MediaSessionCompat.Callback callback = new MediaSessionCompat.Callback() {
  @Override
  public void onPlay() {
    registerReceiver(myNoisyAudioStreamReceiver, intentFilter);
  }

  @Override
  public void onStop() {
    unregisterReceiver(myNoisyAudioStreamReceiver);
  }
}

WifiLock && WakeLock

通常情况下,当用户没有使用设备一段时间,Android 系统出于省电的考虑,可能会关闭Wifi 网络和 CPU。

为了让后台播放的音乐不会因为网络关闭而获取不到音频数据等,需要获取 WifiLock,使音乐播放过程中,Wifi 保持唤醒。

WifiLock wifiLock = ((WifiManager) getSystemService(Context.WIFI_SERVICE))
    .createWifiLock(WifiManager.WIFI_MODE_FULL, "mylock");

MediaSessionCompat.Callback callback = new MediaSessionCompat.Callback() {
  @Override
  public void onPlay() {
    wifiLock.acquire();
  }

  @Override
  public void onPause() {
    wifiLock.release(); // 或者在 onStop 中, 根据是否对网络有具体需求而定, 权衡需求和省电
  }

  @Override
  public void onStop() {
    // wifiLock.release(); 
  }
}

可以通过设置 MediaPlayer 的 WakeMode 来保持 CPU 的状态。

MediaPlayer mediaPlayer = new MediaPlayer();
mediaPlayer.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK);

其内部实现是通过 PowerManager 来实现的。WakeLock 会在播放结束之后(播放完成,播放错误,和重置/释放播放器)释放。所以如果播放器不是 MediaPlayer 则需要自行处理。

PowerManager pm = (PowerManager)context.getSystemService(Context.POWER_SERVICE);
mWakeLock = pm.newWakeLock(mode|PowerManager.ON_AFTER_RELEASE, MediaPlayer.class.getName());
mWakeLock.setReferenceCounted(false);
mWakeLock.acquire();

Notification

为了减少 Service 在后台运行的时候,被系统回收的情况,通常需要将 Service 设置为 foreground。当 Service 被设置为 foreground 的时候,系统会显示一个不可移除的 Notification(引导用户强制停止 App) 提醒用户有个正在运行的高优先级后台 Service。这种体验显然是不好的,不过好在这个 Notification 是可以自定义的, 有两种方式可以实现。

  1. 使用 Android 5.0 上引入的 MediaStyle,支持 Expanded Notification, 收缩是最多显示3个按钮,展开是最多显示5个按钮。
image
image

典型的初始化工作如下

MediaControllerCompat controller = mediaSession.getController();
MediaMetadataCompat mediaMetadata = controller.getMetadata();
MediaDescriptionCompat description = mediaMetadata.getDescription();

NotificationCompat.Builder builder = new NotificationCompat.Builder(context);

builder.setContentTitle(description.getTitle())
    .setContentText(description.getSubtitle())
    .setSubText(description.getDescription())
    .setLargeIcon(description.getIconBitmap())
    // 点击通知栏后直接跳转至播放页面
    .setContentIntent(controller.getSessionActivity()) 
    // 左滑掉通知栏后自动停止
    .setDeleteIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_STOP)) 
    .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // 在锁屏状态下显示控制按钮
    .addAction(new NotificationCompat.Action(R.drawable.pause, getString(R.string.pause),
          MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY_PAUSE)))
    .setStyle(new NotificationCompat.MediaStyle() // 使用系统提供的样式
      .setMediaSession(mediaSession.getSessionToken())
      .setShowActionsInCompactView(0) // 根据索引配置在简介的模式下应该显示哪些操作按钮
      .setShowCancelButton(true) // 显示关闭按钮,需要特别注意的是,5.0 之前显示在右上角,5.0 之后将不再显示
      .setCancelButtonIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(this,
          PlaybackStateCompat.ACTION_STOP));

startForeground(id, builder.build());
  1. 使用自己自定义的布局, MediaStyle 能做到的都能做到,只是需要自己处理的事情比较多。由于国内存在众多 ROM,且都定制了自己的 UI, MediaStyle 在不同的 ROM 上可能表现不一致,为了统一的体验和 避免不必要的 Bug 产生,我们英语流利说中采用的就是自定义自己的 Notification。
image.gif

AudioFocus

Android 的 AudioFocus 用于处理多个音频同时播放时,如何协调它们之间的竞争关系的机制。当音乐开始播放的时候,需要和其他的音频播放竞争 AudioFocus, 当获取到 AudioFocus 时,才开始播放。 1. streamType 基本上就是用 AudioManager.STREAM_MUSIC, 代表播放音乐。

  1. durationHint 申请 AudioFocus 时,告诉被竞争的播放器竞争者需要播放的时间。

  2. AUDIOFOCUS_GAIN / AUDIOFOCUS_LOSS: 没有具体时长,长期持有,比如音乐播放。当音乐要开始播放的时候就需要申请 AUDIOFOCUS_GAIN。而当其他的音乐需要播放的时候,我们会收到 AUDIOFOCUS_LOSS 的状态改变通知,处理方法可以是直接停止,或者暂停,然后过一段时间停止。

  3. AUDIOFOCUS_GAIN_TRANSIENT / AUDIOFOCUS_LOSS_TRANSIENT:一段很短时长,比如 流利说中单词读音的播放。当我们收到 AUDIOFOCUS_LOSS_TRANSIENT 时,通常的做法是暂停音乐的播放。

  4. AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK / AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:非常短的时长,比如音效。当我们收到 AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK 的状态改变通知是,通常做法是降低音量,所以音乐和其他音效都会在同一个 streamType 中播放出来。

注意:这个机制不是强制的,是一个规范,只有大家都遵守的时候,机制才能运作的很好,所以为了平台统一的体验,还是需要处理的。

播放进度更新

由于 MediaSession 很好的解耦了播放器,如果使用 MediaPlayer,UI 是拿不到 MediaPlayer 对象的,所以无法直接通过 MediaPlayer 的 getCurrentPosition() 拿到进度的。而 MediaSession 框架都是通过 PlaybackState 来同步状态的, PlaybackState.Builder 有个如下的方法:

setState(int state, long position, float playbackSpeed, long updateTime)

当 UI 拿到上述参数后,可以通过如下代码计算得出当前的播放进度:

//(当前开机时间(无法更改的) – 上次更新状态的时间)* 播放速度 + 上次更新状态时的播放进度
long currentPosition = ((SystemClock.elapsedRealtime() – playbackState.getLastPositionUpdateTime() ) * playbackState. getPlaybackSpeed() ) + playbackState.getPosition();

兼容

  1. 关于两者之间的兼容性,他们是交叉兼容的,比如 MediaBrowser 和 MediaBrowserServiceCompat 搭配,或者 MediaBrowserCompat 和 MediaBrowserService 搭配,都是可以正常工作的。不会遇到类似 PreferenceFragmentCompat 没有加到 support 库前, PreferenceFragment 与 SupportFragmentManager 不兼容的尴尬情况。

  2. 虽然 MediaSession 框架在 Api 21 的时候引入,但是 support library 在 23.2.0 的时候添加 MediaSession 的兼容。支持到 v4 版本。而且 support library 在 24.2.0 的时候拆分了 media 模块 为单独的包。

com.android.support:support-media-compat:24.2.0

总结

image

参考

  1. https://github.com/googlesamples/android-UniversalMusicPlayer

  2. https://developer.android.com/guide/topics/media-apps/index.html

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容

  • 这节课是 Android 开发(入门)课程 的第二部分《多屏幕应用》的第四节课,导师依然是 Jessica Lin...
    HsuJin阅读 458评论 1 9
  • Media Playback Android多媒体框架包涵了对播放多种通用媒体的类型的支持,所以你可以很容易的集成...
    VegetableAD阅读 874评论 0 0
  • Android 音频焦点(Audio Focus) 引子 说 Audio Focus 前先说个很简单需求:来电时暂...
    suym阅读 3,624评论 0 0
  • Android 多媒体框架包含了支持播放的一系列常见多媒体类型,以此可以很容易地整合诸如音频、视频、图片到你的应用...
    LeaYw阅读 3,197评论 1 22
  • 上次听自卑与超越没有什么感觉,昨天老师让我开始写读书日记,第一本发给我的书就是自卑与超越,再一次听,找来书翻了翻,...
    亦坤阅读 276评论 0 0