播放器应用知道怎样播放一首歌,系统界面却还需要回答另一组问题:当前应该控制哪个应用?播放键交给谁?曲名、封面和进度从哪里来?本地音量与投屏设备的音量又该怎样调整?
MediaSession 把播放器接入系统媒体控制体系。应用通过它公布状态与控制能力,控制器通过统一接口发送请求,系统服务负责会话归属、媒体键选择、通知与生命周期管理。真正的解码和播放仍由播放器完成。
理解这套机制,可以沿三个问题展开:怎样找到播放器、怎样控制播放、怎样同步状态。本文从平台 android.media.session.MediaSession 的源码出发,再将音量、元数据、队列、权限与销毁放回同一张协作图中。MediaSession2 和 AndroidX Media3 有各自的实现与适配;本文最后对比其设计方向,不把它们与平台会话混作同一套接口。
先看全貌:会话是控制协议,也是信息发布接口
图 1:左侧①—⑥展示控制主路径,右侧⑦—⑨展示信息发布与通知,横向箭头连接注册、音频观察和信息衔接。灰色框按职责分组,进程归属看节点文字;重复展示的 MediaSession、MediaSessionRecord 为同一对象。蓝色表示请求,橙色表示信息回报,绿色虚线表示音频活动通知。右侧展示查询结果与变化通知,查询请求由控制器发起;编号用于阅读,不表示各条路径严格串行。
| 层次 | 核心对象 | 职责 |
|---|---|---|
| 会话拥有者,播放器应用进程 | MediaSession / Callback / VolumeProvider | 接收命令,发布播放信息,处理远程音量 |
| 统一控制接口,控制端所在进程 | MediaController / TransportControls / Callback | 通过 Token 控制指定会话,查询并订阅信息 |
| 系统会话管理,system_server | MediaSessionService / 用户记录 / MediaSessionStack | 创建记录,确定归属,组织媒体键目标选择与生命周期 |
| 单会话记录,system_server | MediaSessionRecord | 持有拥有者与控制端接口,保存信息,转交请求并发布更新 |
| 音频观察与音量协作 | AudioPlayerStateMonitor / AudioManager / AudioService | 观察播放活动,参与目标判断,执行本地音量控制 |
| 媒体执行 | 应用播放器及其媒体服务链路 | 真正进行准备、读取、解码、播放与输出 |
服务与会话记录不是两个进程,应用 Callback 与播放器也不是两个独立服务。分析图中的关系,先区分进程内调用、Binder 控制请求和信息回报,再看谁持有对象与数据。图中的“播放器应用”表示应用持有播放器并发起媒体调用;采用平台 MediaPlayer 时,读取、解码和输出可以继续进入系统媒体服务,不能据此把整条媒体数据链路都放进应用进程。
| MediaSession 发布或连接的能力 | 代表接口 | 应用承担的工作 |
|---|---|---|
| 会话发现与控制连接 | Token / MediaController | 将有效 Token 提供给需要连接的控制端 |
| 播放命令 | onPlay / onPause / onSeekTo / onSkipToNext | 将命令接到实际播放器 |
| 播放状态与动作 | setPlaybackState | 发布状态、位置、速度、支持动作及自定义动作 |
| 曲目与列表信息 | setMetadata / setQueue / setQueueTitle | 维护当前内容与队列的对应关系 |
| 音量与播放类型 | setPlaybackToLocal / setPlaybackToRemote | 声明本地或远程音量处理方式 |
| 补充信息与交互 | setExtras / setSessionActivity / sendSessionEvent | 提供业务信息、界面入口或会话事件 |
| 活动与退出 | setActive / release | 维护会话活动条件并释放连接 |
这些能力可以分开使用。比如拥有 Token 的控制器访问特定会话,与系统为媒体键寻找默认目标是两条不同路径;会话处于 active,也不说明播放数据已经到达设备。
创建 MediaSession,究竟创建了什么
应用拿到会话接口与控制 Token
MediaSession 构造时先建立回调 Binder 对象,再请求系统创建会话,最后构造 Token 和对应控制器。
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,构造函数。
mCbStub = new CallbackStub(this);
MediaSessionManager manager =
(MediaSessionManager) context.getSystemService(Context.MEDIA_SESSION_SERVICE);
try {
mBinder = manager.createSession(mCbStub, tag, sessionInfo, overridePackageName);
mSessionToken = new Token(Process.myUid(), mBinder.getController());
mController = new MediaController(context, mSessionToken);
mBinder 是会话拥有者使用的系统接口;Token 包含控制端使用的会话标识与控制接口。它让控制器找到对应会话,不表示解码器、AudioTrack 或播放内容已经准备完成。构造函数传入的是框架的 CallbackStub;应用自己的 MediaSession.Callback 随后通过 setCallback() 设置,由 Handler 调用。两者共同衔接系统请求与应用处理,角色并不相同。
system_server 建立会话记录
服务入口校验包名和用户归属,然后将应用回调等信息交给内部创建逻辑。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java,createSession()。
MediaServerUtils.enforcePackageName(mContext, packageName, uid);
int resolvedUserId = handleIncomingUser(pid, uid, userId, packageName);
if (cb == null) {
throw new IllegalArgumentException("Controller callback cannot be null");
}
// …
MediaSessionRecord session =
createSessionInternal(
pid,
uid,
resolvedUserId,
packageName,
overridePackageName,
cb,
tag,
sessionInfo);
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java,createSessionInternal() 中创建记录。
new MediaSessionRecord(
callerPid,
callerUid,
userId,
callerPackageName,
overridePackageName,
cb,
tag,
sessionInfo,
this,
mRecordThread.getLooper(),
policies);
MediaSessionRecord 保存 PID、UID、用户、包名和会话回调等信息,并使用服务提供的 Looper。这里创建的是连接应用与控制端的系统记录,代码没有创建 MediaPlayer 或解码器。
源码:应用构造会话、服务校验与创建、建立 MediaSessionRecord。
找到播放器:Token、系统发现与用户归属
已知 Token 与枚举系统会话不是同一权限入口
应用给控制端一个有效 Token,控制端可以构造 MediaController 连接该会话。另一种需求是枚举系统活动会话,例如系统媒体界面需要发现多个播放器;这条路径要经过 MediaSessionService 的媒体访问权限和用户校验。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java,verifySessionsRequest()。
private int verifySessionsRequest(ComponentName componentName, int userId, final int pid,
final int uid) {
String packageName = null;
if (componentName != null) {
// If they gave us a component name verify they own the
// package
packageName = componentName.getPackageName();
MediaServerUtils.enforcePackageName(mContext, packageName, uid);
}
// Check that they can make calls on behalf of the user and get the final user id
int resolvedUserId = handleIncomingUser(pid, uid, userId, packageName);
// Check if they have the permissions or their component is enabled for the user
// they're calling from.
enforceMediaPermissions(packageName, pid, uid, resolvedUserId);
return resolvedUserId;
}
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java,enforceMediaPermissions()。
private void enforceMediaPermissions(String packageName, int pid, int uid,
int resolvedUserId) {
if (hasStatusBarServicePermission(pid, uid)) return;
if (hasMediaControlPermission(pid, uid)) return;
if (packageName == null || !hasEnabledNotificationListener(
packageName, UserHandle.getUserHandleForUid(uid), resolvedUserId)) {
throw new SecurityException("Missing permission to control media.");
}
}
verifySessionsRequest() 验证通知监听组件的包名归属,解析目标用户,并调用媒体权限检查。enforceMediaPermissions() 接受相应系统权限或启用的通知监听条件。跨用户请求还要经过 handleIncomingUser()。
因此,不能把活动会话枚举权限当成每一次 Token 控制都必经的相同检查,也不能把得到一个 Token 理解成获得了控制所有播放器的权限。应用还可以结合控制请求携带的调用方信息与 isTrustedForMediaControl() 等接口,对敏感操作作业务判断。信任查询与命令执行入口本身也需要分别分析。
源码:会话枚举入口、用户与媒体权限、媒体访问条件、控制方信任判断。
会话信息按用户组织,不能只靠包名定位
MediaSessionRecord 保存用户、UID、包名和拥有者回调;服务的用户记录及会话栈维护对应集合。一个包名可以有多个会话,多用户也可能运行相同包名。控制异常时,应同时确认用户、UID、Token 和会话是否仍存在。
普通应用注册本身所属的会话;这份源码还允许有相应权限的调用方代其他包注册,入口对 overridePackageName 单独校验。不能因此把“会话拥有者包名”与每次 Binder 调用的身份简单画等号。
控制播放:媒体键与指定会话控制
先分清按键来源:媒体 KeyEvent 不等于屏幕按钮
“媒体键”按 KeyCode 定义,不按按钮是否有实体区分。键盘、耳机、遥控器或车载输入可以生成媒体 KeyEvent,软件也可以生成同类事件;但点击屏幕播放图标,可能直接调用播放器,也可能通过 MediaController 控制已知 Token 的会话,不一定生成媒体 KeyEvent。
| 来源 | 怎样接入控制 |
|---|---|
| 键盘、耳机、遥控器等硬件 | 输入映射生成 Android KeyEvent,再经窗口或系统策略处理 |
| 方向盘或车载硬件按键 | 按产品输入实现映射为 KeyEvent,或交给厂商控制接口 |
| 应用自己的播放按钮 | 可以直接调用应用播放器,无需先派发全局媒体键 |
| 系统或其他应用的屏幕控制按钮 | 可以使用 MediaController 和目标 Token,控制指定会话 |
| 软件生成的媒体键事件 | 经相应派发接口处理,具体调用能力取决于接口及调用方身份 |
平台会话入口识别的媒体键包括播放、暂停、切换播放/暂停、耳机多功能键、停止、前后曲、快进、快退和录制。音量键走单独的音量路径。
源码位置:frameworks/base/core/java/android/view/KeyEvent.java,isMediaSessionKey()。
public static final boolean isMediaSessionKey(int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_MEDIA_PLAY:
case KeyEvent.KEYCODE_MEDIA_PAUSE:
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
case KeyEvent.KEYCODE_HEADSETHOOK:
case KeyEvent.KEYCODE_MEDIA_STOP:
case KeyEvent.KEYCODE_MEDIA_NEXT:
case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
case KeyEvent.KEYCODE_MEDIA_REWIND:
case KeyEvent.KEYCODE_MEDIA_RECORD:
case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
return true;
}
return false;
}
进入这份识别列表,不等于应用默认实现所有动作。独立播放键和播放/暂停切换键的默认处理不同;录制等动作也需要继续检查应用的处理逻辑。
默认输入处理:先看窗口,再看是否需要系统派发
物理按键需要先被映射成 Android KeyCode。平台 Generic.kl 提供通用布局,设备可以使用自己的布局。下面的数字是这份布局中的 Linux 键码映射,不是所有方向盘或耳机必须使用的编号。
源码位置:frameworks/base/data/keyboards/Generic.kl。
key 163 MEDIA_NEXT
key 164 MEDIA_PLAY_PAUSE
key 165 MEDIA_PREVIOUS
PhoneWindowManager 在媒体键处理分支检查是否需要传给前台。如果普通路径会把键交给前台,应用可以先处理;需要系统处理时,会安排媒体键派发。全局优先级会话也会影响这个选择。前台未消费事件后,窗口与回退逻辑还可能继续处理,不能把所有硬件媒体键都画成直接进入 MediaSessionService。
源码位置:frameworks/base/services/core/java/com/android/server/policy/PhoneWindowManager.java,媒体键分支。
if (MediaSessionLegacyHelper.getHelper(mContext).isGlobalPriorityActive()) {
// If the global session is active pass all media keys to it
// instead of the active window.
result &= ~ACTION_PASS_TO_USER;
}
if ((result & ACTION_PASS_TO_USER) == 0) {
// Only do this if we would otherwise not pass it to the user. In that
// case, the PhoneWindow class will do the same thing, except it will
// only do it if the showing app doesn't process the key on its own.
// Note that we need to make a copy of the key event here because the
// original key event will be recycled when we return.
mBroadcastWakeLock.acquire();
Message msg = mHandler.obtainMessage(MSG_DISPATCH_MEDIA_KEY_WITH_WAKE_LOCK,
new KeyEvent(event));
msg.setAsynchronous(true);
msg.sendToTarget();
窗口关联了 MediaController 时,可以指定该控制器的 Token;普通回退处理则使用 MediaSessionManager 的系统派发接口。
源码位置:frameworks/base/core/java/com/android/internal/policy/PhoneFallbackEventHandler.java。
private void handleMediaKeyEvent(KeyEvent keyEvent) {
getMediaSessionManager().dispatchMediaKeyEventAsSystemService(keyEvent);
}
这份版本的兼容辅助路径还使用 MediaCommunicationManager。它属于上游派发接口,不能把它与 MediaSessionManager 的不同入口强行合并成一条完全相同的调用链。
源码位置:packages/modules/Media/apex/framework/java/android/media/MediaCommunicationManager.java,dispatchMediaKeyEvent()。
public void dispatchMediaKeyEvent(@NonNull KeyEvent keyEvent, boolean asSystemService) {
Objects.requireNonNull(keyEvent, "keyEvent shouldn't be null");
// When MCS handles this, caller is changed.
// TODO: remove this when MCS implementation is done.
if (!asSystemService) {
getMediaSessionManager().dispatchMediaKeyEvent(keyEvent, false);
return;
}
try {
getService().dispatchMediaKeyEvent(mContext.getPackageName(),
keyEvent, asSystemService);
} catch (RemoteException e) {
Log.e(TAG, "Failed to send key event.", e);
}
非系统派发参数分支转交 MediaSessionManager,另一分支请求 MediaCommunicationService。本文后续展开事件进入 MediaSessionService 后的目标选择与应用回调;中间接口的不同不能被忽略,也不意味着应用播放器的执行责任改变。
能定制的是映射、系统策略和应用行为
输入层可以通过设备键布局或车载输入实现改变物理键到 KeyCode 的映射。系统层可以接入自定义媒体键派发器和会话策略提供者。AOSP 的资源默认值为空:
源码位置:frameworks/base/core/res/res/values/config.xml。
<string name="config_customMediaKeyDispatcher"></string>
<string name="config_customMediaSessionPolicyProvider"></string>
这是平台和厂商集成的扩展入口,不是普通应用或用户设置一个包名就能指定全局控制目标。应用层则通过 Callback、PlaybackState 支持动作及自己的屏幕交互决定如何响应;覆盖 onMediaButtonEvent() 会改变应用默认按键处理行为。
图 7:媒体 KeyEvent、屏幕直接播放与指定 Token 控制分开阅读。窗口处理按条件消费、指定或继续派发;这些箭头代表分支,不表示一次按键依次执行所有路径。系统媒体键进入服务后,再检查用户、监听器与会话策略。
源码:媒体键定义、默认键布局、系统输入策略、窗口指定会话、未消费事件回退、兼容辅助入口、MediaCommunicationManager 分支、默认扩展配置。
媒体键先经过系统入口与目标选择
dispatchMediaKeyEvent() 首先检查事件是不是媒体键。后续还有用户设置状态、全局优先级和媒体键监听器等条件,事件并不一定立即交给普通会话。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java,dispatchMediaKeyEvent()。
public void dispatchMediaKeyEvent(String packageName, boolean asSystemService,
KeyEvent keyEvent, boolean needWakeLock) {
if (keyEvent == null || !KeyEvent.isMediaSessionKey(keyEvent.getKeyCode())) {
Log.w(TAG, "Attempted to dispatch null or non-media key event.");
return;
}
继续进入 dispatchMediaKeyEventLocked(),自定义派发器可以提供会话或接收器;没有提供时,才读取当前用户记录中的媒体键会话。默认目标由会话栈结合音频活动及会话变化维护,按键派发通常读取这个结果,而不是每次重新遍历所有播放器。全局优先级会话的处理也在用户记录与服务判断中,不能全部归入普通会话栈排序。如果没有对应会话,还可能尝试保留的媒体键接收器。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java,dispatchMediaKeyEventLocked()。
if (mCustomMediaKeyDispatcher != null) {
MediaSession.Token token = mCustomMediaKeyDispatcher.getMediaSession(
keyEvent, uid, asSystemService);
if (token != null) {
session = getMediaSessionRecordLocked(token);
}
// …
if (session == null && mediaButtonReceiverHolder == null) {
session = (MediaSessionRecord) mCurrentFullUserRecord.getMediaButtonSessionLocked();
if (session == null) {
mediaButtonReceiverHolder =
mCurrentFullUserRecord.mLastMediaButtonReceiverHolder;
}
}
// …
session.sendMediaButton(packageName, pid, uid, asSystemService, keyEvent,
needWakeLock ? mKeyEventReceiver.mLastTimeoutId : -1,
mKeyEventReceiver);
最后一段位于 session != null 的分支中,将按键交给选定记录的 sendMediaButton()。因此排查时需要先确认按键是否被拦截、实际选中了哪个会话,再确认应用回调是否收到事件。
音频播放变化会触发媒体键目标重选
MediaSessionService 还通过 AudioPlayerStateMonitor 监听系统音频播放变化。这个入口是 AudioManager.AudioPlaybackCallback,观察的是播放器配置和活动情况;它不是对 AudioPolicy 全部策略变化的统一监听,也不代替音频焦点或设备路由的专门接口。
源码位置:frameworks/base/services/core/java/com/android/server/media/AudioPlayerStateMonitor.java,构造函数注册回调。
private AudioPlayerStateMonitor(Context context, Set<Integer> ignoredAudioUsageTypes) {
mIgnoredAudioUsageTypes = Set.copyOf(ignoredAudioUsageTypes);
AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
am.registerAudioPlaybackCallback(new AudioManagerPlaybackListener(), null);
}
onPlaybackConfigChanged() 更新活跃 UID 等记录,随后向监听者通知播放器活动变化。MediaSessionService 注册自己的监听者,按播放器 UID 找到对应用户,再请求会话栈重新检查媒体键目标。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java,初始化时注册监听。
mAudioPlayerStateMonitor.registerListener(
(config, isRemoved) -> {
if (DEBUG) {
Log.d(TAG, "Audio playback is changed, config=" + config
+ ", removed=" + isRemoved);
}
if (config.getPlayerType()
== AudioPlaybackConfiguration.PLAYER_TYPE_JAM_SOUNDPOOL) {
return;
}
synchronized (mLock) {
FullUserRecord user = getFullUserRecordLocked(
UserHandle.getUserHandleForUid(config.getClientUid())
.getIdentifier());
if (user != null) {
user.mPriorityStack.updateMediaButtonSessionIfNeeded();
}
}
}, null /* handler */);
这里还排除了 SoundPool 类型的播放器,避免将这类音效播放直接作为媒体键目标重选的触发来源。音频播放观察与会话状态报告是两条信息来源:前者帮助系统选择控制目标,后者描述应用希望向控制端发布的播放状态。
源码:注册音频播放回调、处理音频配置变化、服务监听与目标重选。
媒体键目标不等于“最后 setActive 的会话”
默认 MediaSessionStack 会参考 AudioPlayerStateMonitor 记录的音频播放 UID 顺序,寻找相应会话,并检查忽略媒体键目标的策略。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionStack.java,updateMediaButtonSessionIfNeeded()。
List<Integer> audioPlaybackUids =
mAudioPlayerStateMonitor.getSortedAudioPlaybackClientUids();
for (int i = 0; i < audioPlaybackUids.size(); i++) {
int audioPlaybackUid = audioPlaybackUids.get(i);
MediaSessionRecordImpl mediaButtonSession = findMediaButtonSession(audioPlaybackUid);
if (mediaButtonSession == null) {
if (DEBUG) {
Log.d(TAG, "updateMediaButtonSessionIfNeeded, skipping uid="
+ audioPlaybackUid);
}
// Ignore if the lastly played app isn't a media app (i.e. has no media session)
continue;
// …
boolean ignoreButtonSession =
(mediaButtonSession.getSessionPolicies()
& SESSION_POLICY_IGNORE_BUTTON_SESSION) != 0;
// …
if (!ignoreButtonSession) {
mAudioPlayerStateMonitor.cleanUpAudioPlaybackUids(mediaButtonSession.getUid());
if (mediaButtonSession != mMediaButtonSession) {
updateMediaButtonSession(mediaButtonSession);
}
在同一个 UID 下有多个会话时,系统优先寻找其播放活跃状态与观察到的音频播放状态相符的会话;未找到相符者时保留按会话顺序选出的候选。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionStack.java,findMediaButtonSession()。
private MediaSessionRecordImpl findMediaButtonSession(int uid) {
MediaSessionRecordImpl mediaButtonSession = null;
for (MediaSessionRecordImpl session : mSessions) {
if (session instanceof MediaSession2Record) {
// TODO(jaewan): Make MediaSession2 to receive media key event
continue;
}
if (uid == session.getUid()) {
if (session.checkPlaybackActiveState(
mAudioPlayerStateMonitor.isPlaybackActive(session.getUid()))) {
// If there's a media session whose PlaybackState matches
// the audio playback state, return it immediately.
return session;
}
if (mediaButtonSession == null) {
// Among the media sessions whose PlaybackState doesn't match
// the audio playback state, pick the top priority.
mediaButtonSession = session;
}
}
}
return mediaButtonSession;
}
这解释了为什么仅调用 setActive(true),不足以保证下一次媒体键一定控制自己。会话状态、音频播放活动、用户记录和策略需要一起分析。音频活动观察也不是声学测量:记录某个 UID 有活跃音频播放器,并不能证明扬声器已经产生声音。
源码:更新媒体键目标、同 UID 下选择会话、音频活跃 UID 记录。
MediaController 通过 Token 控制指定会话
控制器的 TransportControls.play() 直接调用它持有的会话控制接口。
源码位置:frameworks/base/media/java/android/media/session/MediaController.java,TransportControls.play()。
public void play() {
try {
mSessionBinder.play(mContext.getPackageName());
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}
}
这条路径已经通过 Token 定位会话,不会为了执行一次 play(),重新走默认媒体键目标选择。Token 是否有效、服务端记录是否仍存在、调用身份以及应用是否响应,仍影响结果。默认媒体键的 ACTIONS 判断也不能直接套到所有指定会话的控制调用上。
应用如何接收并执行播放请求
前文的 MediaController.play() 已经将请求发给指定会话的控制接口。接下来,请求还要经过 system_server 中的会话记录、应用侧 Binder 回调和应用回调线程,才会交到应用实现的播放逻辑。
系统记录将请求转交应用
MediaSessionRecord 的控制接口先找到它关联的会话记录。记录仍存在时,继续调用记录的 play();随后由记录持有的 SessionCb 转交给创建会话时传入的拥有者回调。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,ControllerStub.play()。
public void play(String packageName) {
MediaSessionRecord record = mRecord.get();
if (record == null) {
Log.w(TAG, IGNORE_CALL_TO_DESTROYED_SESSION_MESSAGE);
return;
}
record.play(packageName);
}
源码位置:同文件,SessionCb.play()。
public void play(String packageName, int pid, int uid) {
try {
final String reason = TAG + ":play";
mService.tempAllowlistTargetPkgIfPossible(getUid(), getPackageName(),
pid, uid, packageName, reason);
mCb.onPlay(packageName, pid, uid);
} catch (RemoteException e) {
Slog.e(TAG, "Remote failure in play.", e);
}
}
这里的 mCb.onPlay() 调用应用侧的 Binder 接口,并携带调用方包名、PID 和 UID。接收它的是 MediaSession 内部的 CallbackStub,还没有进入应用编写的播放器控制代码。
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,CallbackStub.onPlay()。
public void onPlay(String packageName, int pid, int uid) {
MediaSession session = mMediaSession.get();
if (session != null) {
session.dispatchPlay(createRemoteUserInfo(packageName, pid, uid));
}
}
CallbackStub 找到应用持有的 MediaSession,再将请求交给 dispatchPlay()。后者会投递 Handler 消息,把 Binder 请求衔接到应用选择的回调线程。
源码:控制接口接收播放请求、记录转交拥有者回调、应用侧 Binder 入口。
应用设置回调,并选择处理线程
应用需要实现 MediaSession.Callback,通过 setCallback() 将它交给 MediaSession。这个 Callback 定义 onPlay()、onPause() 等处理方法;前面的 CallbackStub 负责接收 Binder 请求,两者承担不同职责。
setCallback() 同时确定处理请求的线程:提供 Handler 时使用它的 Looper,否则使用调用线程的 Looper。
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,setCallback()。
public void setCallback(@Nullable Callback callback, @Nullable Handler handler) {
synchronized (mLock) {
if (mCallback != null) {
// We're updating the callback, clear the session from the old one.
mCallback.mCallback.clearSession();
}
if (callback == null) {
mCallback = null;
return;
}
Looper looper = handler != null ? handler.getLooper() : Looper.myLooper();
callback.setSession(this);
CallbackMessageHandler msgHandler = new CallbackMessageHandler(looper, callback);
mCallback = msgHandler;
}
}
回调线程应与播放器的线程要求相配合。耗时准备可以交给应用的任务调度机制,避免阻塞回调线程上的后续命令。Binder 请求的接收与播放器执行因此是两个阶段。
源码:绑定回调线程。
MediaSession 的应用侧实现把播放请求投递到回调 Handler,消息处理时调用应用的 onPlay()。
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,dispatchPlay() 与 CallbackMessageHandler。
void dispatchPlay(RemoteUserInfo caller) {
postToCallback(caller, CallbackMessageHandler.MSG_PLAY, null, null);
}
// …
case MSG_PLAY:
mCallback.onPlay();
break;
应用在回调中实现播放器操作
Handler 最终调用的是应用设置的 Callback。平台提供的默认 onPlay() 方法体为空:
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,Callback.onPlay()。
/**
* Override to handle requests to begin playback.
*/
public void onPlay() {
}
应用需要在自己的回调或播放器适配层中实现播放动作,并根据播放器结果维护会话信息。回调是否执行、播放器是否接受请求、数据是否开始输出,应分别记录。
媒体键先转换为对应的播放操作
以上展开的是指定会话的 play() 请求。媒体键进入应用时,则先交给 onMediaButtonEvent(),由它判断事件应对应播放、暂停还是其他操作。默认 onMediaButtonEvent() 读取 PlaybackState 的 actions,再决定如何处理按键。下面是独立播放键的分支:
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,Callback.onMediaButtonEvent()。
PlaybackState state = mSession.mPlaybackState;
validActions = state == null ? 0 : state.getActions();
// …
switch (ke.getKeyCode()) {
case KeyEvent.KEYCODE_MEDIA_PLAY -> {
if ((validActions & PlaybackState.ACTION_PLAY) != 0) {
onPlay();
return true;
}
}
ACTION_PLAY 没有设置时,这个默认分支不会调用 onPlay()。播放/暂停切换键还有单击、双击及延迟处理,不能假定每次按下都会立刻回调。应用覆盖 onMediaButtonEvent() 后,也要检查自己的返回值和事件处理逻辑。
源码:默认媒体键处理。
媒体键派发何时确认
媒体键这条路径还包含一项派发确认。应用侧 CallbackStub 安排媒体键处理后,通过 ResultReceiver 返回序号;它没有等待应用的播放逻辑完成。
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,CallbackStub.onMediaButton()。
public void onMediaButton(String packageName, int pid, int uid, Intent mediaButtonIntent,
int sequenceNumber, ResultReceiver cb) {
MediaSession session = mMediaSession.get();
try {
if (session != null) {
session.dispatchMediaButton(createRemoteUserInfo(packageName, pid, uid),
mediaButtonIntent);
}
} finally {
if (cb != null) {
cb.send(sequenceNumber, null);
}
}
}
cb.send(sequenceNumber, null) 用于这次媒体键派发的确认,与相关唤醒锁处理配合。它不等待播放器产生首帧或声音。把这条确认当成播放完成,会把控制链和数据链的时间点混在一起。
源码:媒体键确认、派发中的唤醒锁与接收器。
控制协议还包括定位、切歌与自定义交互
播放请求的接收与执行机制也适用于其他控制操作。TransportControls 除 play/pause 外,还提供 seekTo、skipToNext、skipToPrevious、prepare、评分、播放速度与多种按媒体标识启动的接口。它们把请求交给对应应用回调,具体是否能执行、如何修改播放器状态,由应用实现决定。PlaybackState.actions 表达支持动作,自定义动作可以携带业务操作;普通会话命令和会话事件则分别承载额外交互与信息通知。
例如,skipToQueueItem() 携带的是队列项 ID,应用收到请求后,需要把这个 ID 映射到自身内容,再调用播放器。队列内容、当前项和执行结果也由应用维护;相关信息如何发布给控制端,将在后面的状态同步部分展开。
源码:TransportControls 控制接口、应用回调分发、评分与速度控制、自定义动作处理、平台 MediaSession API。
会话是否活动,与播放器处于什么状态要分开看
请求进入应用后,播放器可能开始播放,也可能仍在准备、缓冲或等待其他条件。应用需要把这些变化发布给控制端。在此之前,先分清会话是否 active 与播放器处于什么状态:它们分别由 setActive() 和 setPlaybackState() 表达。
active 表示会话活动条件
服务端 setActive() 保存活动标志并通知服务,isActive() 则还检查记录是否已经销毁。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,setActive() 与 isActive()。
synchronized (mLock) {
mIsActive = active;
}
final long token = Binder.clearCallingIdentity();
try {
mService.onSessionActiveStateChanged(MediaSessionRecord.this, mPlaybackState);
} finally {
Binder.restoreCallingIdentity(token);
}
mHandler.post(MessageHandler.MSG_UPDATE_SESSION_STATE);
// …
@Override
public boolean isActive() {
return mIsActive && !mDestroyed;
}
setActive() 更新的是会话记录的活动标志。调用 setActive(false) 没有调用播放器的暂停方法,调用 setActive(true) 也没有调用播放器的播放方法。应用可以在暂停后继续保持会话活动,让系统媒体界面仍能发现它并提供后续控制。
“不活动”与“已销毁”同样要分开:前者仍是原来的会话记录,可以再次设置为 active;后者已经关闭记录,旧会话不能靠 setActive(true) 恢复。重新创建 MediaSession 会建立新的记录与 Token,销毁清理的具体流程将在后文展开。
应用构造 PlaybackState,再提交给系统记录
图中的“构造 PlaybackState”,是应用根据播放器状态填写会话信息。例如播放器暂停时,应用选择 STATE_PAUSED,填写当前播放位置、播放速度和更新时间,并声明支持的控制动作。MediaSession 不会自动读取播放器对象,完成这一步转换。
PlaybackState.Builder 提供构造接口。下面的 setState() 保存传入的状态、位置、速度和更新时间,应用还可以通过 setActions() 填写支持动作,最后调用 build() 得到 PlaybackState。
源码位置:frameworks/base/media/java/android/media/session/PlaybackState.java,Builder.setState()。
public Builder setState(@State int state, long position, float playbackSpeed,
long updateTime) {
mState = state;
mPosition = position;
mUpdateTime = updateTime;
mSpeed = playbackSpeed;
return this;
}
构造好后,应用调用 MediaSession.setPlaybackState()。这个方法保存应用侧的状态,并通过 mBinder.setPlaybackState(state) 将它提交给 system_server 中的会话记录,对应图中的“Binder:提交播放状态”。
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,setPlaybackState()。
public void setPlaybackState(@Nullable PlaybackState state) {
mPlaybackState = state;
try {
mBinder.setPlaybackState(state);
} catch (RemoteException e) {
Log.wtf(TAG, "Dead object in setPlaybackState.", e);
}
}
系统记录保存状态,再通知控制端
MediaSessionRecord 收到状态后保存这些信息,根据新旧状态判断是否更新优先级,再投递 Handler 消息,安排向控制端通知变化。下面节选的是服务端记录中的处理方法。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,setPlaybackState()。
private void setPlaybackState(PlaybackState state) {
int oldState =
mPlaybackState == null ? PlaybackState.STATE_NONE : mPlaybackState.getState();
int newState = state == null ? PlaybackState.STATE_NONE : state.getState();
boolean shouldUpdatePriority =
ALWAYS_PRIORITY_STATES.contains(newState)
|| (!TRANSITION_PRIORITY_STATES.contains(oldState)
&& TRANSITION_PRIORITY_STATES.contains(newState));
boolean isGlobalPrioritySessionActive = mService.isGlobalPrioritySessionActive();
synchronized (mLock) {
mPlaybackState = state;
updateUserEngagedStateIfNeededLocked(
/* isTimeoutExpired= */ false, isGlobalPrioritySessionActive);
}
final long token = Binder.clearCallingIdentity();
try {
mService.onSessionPlaybackStateChanged(
MediaSessionRecord.this, shouldUpdatePriority, mPlaybackState);
} finally {
Binder.restoreCallingIdentity(token);
}
mHandler.post(MessageHandler.MSG_UPDATE_PLAYBACK_STATE);
}
系统记录的 Handler 处理状态更新消息后,调用 pushPlaybackStateUpdate(),通过已注册控制器的 Binder 回调发送保存的 PlaybackState。这对应图中的“Binder:通知状态变化”:
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,pushPlaybackStateUpdate()。
private void pushPlaybackStateUpdate() {
PlaybackState playbackState;
synchronized (mLock) {
if (mDestroyed) {
return;
}
playbackState = mPlaybackState;
}
performOnCallbackHolders(
"pushPlaybackStateUpdate",
holder -> holder.mCallback.onPlaybackStateChanged(playbackState));
}
pushPlaybackStateUpdate() 在锁内读取保存的状态,随后调用 onPlaybackStateChanged() Binder 接口。控制端收到后,再经自身 Handler 调用应用实现的 MediaController.Callback.onPlaybackStateChanged()。系统记录的消息发布与控制端界面更新发生在不同阶段。
控制端显示的“播放中”来自应用提交的 PlaybackState。应用及时根据播放器状态构造并提交信息,界面才能反映相应变化;如果应用提前报告 PLAYING 或漏报暂停、错误,系统记录仍会保存并通知这个值。这条通知链路没有读取设备实际输出。
状态还会参与排序。当前实现把快进、快退和切换前后曲列为始终提升优先级的状态;从其他类别进入 BUFFERING、CONNECTING、PLAYING 时,也可能请求调整顺序。MediaSessionStack 收到 shouldUpdatePriority 后,会把记录移到列表前部。不过会话排序与媒体键目标是相关但不同的判断,不能概括成“上报 PLAYING 就抢到控制权”。
源码:状态保存与通知、状态分类、会话排序更新、控制器状态回调。
图 2:上半部分是会话记录的活动与销毁状态,setActive(true/false) 可以往返,两种未销毁状态均可进入销毁。下半部分是播放信息发布流程,应用先根据播放器状态构造 PlaybackState,再通过 Binder 提交给系统记录,最后由系统记录通过 Binder 通知控制端状态变化。橙色箭头表示信息传递,不表示 PLAYING、PAUSED 等报告值之间的状态转换。
源码:销毁记录与通知。
把这两个维度放到实际操作中,更容易看清职责:
| 应用操作或场景 | 会话记录的变化 | 播放器与播放信息的处理 |
|---|---|---|
| 暂停播放,保留后续控制 | 可以继续保持 active | 应用暂停播放器,并发布 PAUSED |
| 调用 setActive(false) | 记录转为不活动,没有因此销毁 | 该调用不负责暂停播放器,也不自动改写 PlaybackState |
| 调用 setPlaybackState(PLAYING) | 保存报告,并可能影响会话顺序 | 不会因此启动播放器,也不自动把会话设为 active |
| 释放会话或拥有者死亡 | 销毁记录,清理保存状态并安排通知 | 主动释放 MediaSession 与释放播放器资源分别处理 |
因此,图上没有从 active 自动指向 PLAYING 的箭头,也没有从不活动自动指向 PAUSED 的箭头。应用需要根据自身播放与退出策略,分别维护会话活动条件和播放状态报告。
把命令、执行与状态回报连起来
状态图分清了会话活动条件和播放信息,接下来把播放信息放回完整调用链。指定 Token 的控制请求直接定位会话记录;媒体键先经系统入口和目标判断,再交给选定记录。记录通过 Binder 调用应用的 CallbackStub,CallbackStub 将请求投递到 Handler,随后执行应用设置的 Callback,由应用调用播放器。
播放状态从另一条路径传给控制端:应用根据播放器状态构造 PlaybackState,调用 MediaSession.setPlaybackState() 经 Binder 提交给系统记录;记录保存状态,并通过已注册的 Binder 回调通知控制端,控制端再经自己的 Handler 更新界面。
图 3:左侧展示控制请求与播放器调用,右侧展示 PlaybackState 的构造、提交和通知。记录到应用 Handler 的连线概括了 Binder 调用 CallbackStub、再投递消息两个阶段。媒体处理继续进入实际输出链路,播放状态则由应用根据播放器变化提交,两条路径分别观察。
graph TD
K[系统媒体键] --> S[目标选择与策略检查]
C[持有 Token 的控制器] --> R[指定 MediaSessionRecord]
S --> R
R --> B[应用 CallbackStub 接收 Binder 回调]
B --> H[Handler 执行应用 Callback]
H --> P[应用调用播放器]
P --> U[应用构造 PlaybackState]
U --> M[MediaSession 经 Binder 提交播放状态]
M --> N[MediaSessionRecord 保存状态]
N --> V[经 Binder 通知控制端状态变化]
V --> D[控制端 Handler 更新界面]
播放器状态也可能因准备完成、缓冲、播放结束或错误而变化,并不总由一条控制命令触发。应用应从播放器的实际状态来源统一发布 PlaybackState,而不是只在 onPlay()、onPause() 被调用时更新。MediaSessionService 负责管理与转交请求,不会自动补齐播放器状态。
图中的“构造 PlaybackState”表示应用根据播放器状态组织会话报告,不表示已经确认首帧或声音输出。同样,媒体键返回派发序号、应用收到控制回调、控制端收到状态变化,分别说明各阶段的处理进展;实际输出仍要沿媒体处理、音频路由与设备链路确认。
同步状态:快照查询与回调订阅相配合
前面的图已经展示了状态通知如何到达控制端,但新连接的界面还需要读取当时的会话信息。MediaController 因此同时提供快照查询和变化订阅:查询建立初始显示,回调接续后续变化,控制端负责协调两条路径。
初始查询与变化订阅,需要一起设计
MediaController 能查询 PlaybackState、MediaMetadata、队列、extras、会话界面入口及 PlaybackInfo,也能注册回调接收后续变化。只注册监听而不读取初始信息,可能让新控制界面暂时没有内容;只查询而不监听,又会停留在旧信息。接口并不要求先查询再注册,接入顺序需要考虑两者之间的变化窗口。
源码位置:frameworks/base/media/java/android/media/session/MediaController.java,getPlaybackState() / getMetadata()。
public @Nullable PlaybackState getPlaybackState() {
try {
return mSessionBinder.getPlaybackState();
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}
}
// …
public @Nullable MediaMetadata getMetadata() {
try {
return mSessionBinder.getMetadata();
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}
}
源码位置:frameworks/base/media/java/android/media/session/MediaController.java,registerCallback()。
public void registerCallback(@NonNull Callback callback, @Nullable Handler handler) {
if (callback == null) {
throw new IllegalArgumentException("callback must not be null");
}
if (handler == null) {
handler = new Handler();
}
synchronized (mLock) {
addCallbackLocked(callback, handler);
}
}
控制端应把初始查询与变化回调统一更新到自己的界面模型,并考虑订阅与查询之间可能发生的新变化。它们不是一次原子事务,不能把两个独立调用假定为同一个时刻的快照。
例如,界面先读到 PAUSED,播放器随即切到 PLAYING,但控制器尚未完成注册,这次通知就可能落在订阅窗口之外。先完成注册,再读取初始信息,可以缩小这类窗口;但查询结果返回和回调消息处理仍可能交错,不能让稍后处理的旧查询结果无条件覆盖较新的变化。控制端应把查询和回调接到同一个模型更新机制,应用则从统一的播放器状态来源发布信息。
平台会话没有把 PlaybackState、元数据、队列和 extras 打包成带统一版本号的原子快照。切歌时,控制器可能先看到新标题,再看到新的队列位置。界面应容忍短暂的跨字段差异;有更强一致性要求的业务,可以在自己的协议中关联内容 ID 或业务版本,并明确处理规则。
服务端保存回调持有者,同时监听控制端 Binder 死亡,避免进程退出后仍保留无效订阅。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,registerCallback()。
private void registerCallback(String packageName, ISessionControllerCallback cb) {
synchronized (mLock) {
if (getControllerHolderIndexForCb(cb) < 0) {
ISessionControllerCallbackHolder holder =
new ISessionControllerCallbackHolder(
cb, packageName, () -> unregisterCallback(cb));
mControllerCallbackHolders.add(holder);
// …
try {
// cb is not referenced outside of the MediaSessionRecord, so the death
// handler won't prevent MediaSessionRecord to be garbage collected.
cb.asBinder().linkToDeath(holder.mDeathMonitor, 0);
} catch (RemoteException e) {
unregisterCallback(cb);
Slog.w(TAG, "registerCallback failed to linkToDeath", e);
}
应用侧回调在注册时选定的 Handler 上处理;服务端记录也使用自身的 Handler 组织消息发布。线程与 Binder 调用交错时,需要检查状态快照、锁范围和回调投递顺序,不能把“通知已经发出”直接等同于控制界面已经刷新。
源码:状态快照、控制器订阅、服务端订阅与死亡处理、取消订阅。
播放进度是根据上报信息推算的
服务端查询 PlaybackState 时会调用 getStateWithUpdatedPosition()。对 PLAYING、FAST_FORWARDING、REWINDING 等状态,如果更新时间有效,就利用速度和经过的时间推算位置,并限制到有效范围。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,getStateWithUpdatedPosition()。
if (state.getState() == PlaybackState.STATE_PLAYING
|| state.getState() == PlaybackState.STATE_FAST_FORWARDING
|| state.getState() == PlaybackState.STATE_REWINDING) {
long updateTime = state.getLastPositionUpdateTime();
long currentTime = SystemClock.elapsedRealtime();
if (updateTime > 0) {
long position = (long) (state.getPlaybackSpeed()
* (currentTime - updateTime)) + state.getPosition();
if (duration >= 0 && position > duration) {
position = duration;
} else if (position < 0) {
position = 0;
}
PlaybackState.Builder builder = new PlaybackState.Builder(state);
builder.setState(state.getState(), position, state.getPlaybackSpeed(),
currentTime);
result = builder.build();
}
这让控制端不必要求应用为每个进度刻度都发送通知,但推算的基础仍来自应用:位置、速度、时间戳与时长。缓冲、暂停、seek 或播放错误后如果没有及时更新,推算值就可能偏离播放器实际位置。这里的 position 是媒体位置,不是从扬声器输出反向测量得到的结果。
源码:查询时更新位置。
元数据、队列与播放器内部列表需要对应
MediaMetadata 表达当前内容,QueueItem 则提供对外展示的队列项与 ID。列表、当前项和播放状态分别更新,应用要维护它们之间的对应关系。系统会话记录负责保存与通知,没有取代应用内部的播放列表管理。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,setMetadata()。
private void setMetadata(MediaMetadata metadata, long duration, String metadataDescription) {
synchronized (mLock) {
mDuration = duration;
mMetadataDescription = metadataDescription;
mMetadata = sanitizeMediaMetadata(metadata);
}
mHandler.post(MessageHandler.MSG_UPDATE_METADATA);
}
当前实现会先清理元数据中的相关 URI 信息,再保存与发布。控制界面读到一张封面或一个标题,也不意味着该资源已经被播放器解码或正在输出。
队列还涉及跨进程传输:应用使用 ParcelableListBinder 发送列表;服务端保存后,向控制器通知时使用 ParceledListSlice,限制初始 Parcel 内联的元素数量。
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,setQueue()。
public void setQueue(@Nullable List<QueueItem> queue) {
try {
if (queue == null) {
mBinder.resetQueue();
} else {
IBinder binder = mBinder.getBinderForSetQueue();
if (binder != null) {
ParcelableListBinder.send(binder, queue);
}
}
} catch (RemoteException e) {
Log.wtf("Dead object in setQueue.", e);
}
}
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,pushQueueUpdate() 中构造回调参数。
ParceledListSlice<QueueItem> parcelableQueue = null;
if (toSend != null) {
parcelableQueue = new ParceledListSlice<>(toSend);
// Limit the size of initial Parcel to prevent binder buffer overflow
// as onQueueChanged is an async binder call.
parcelableQueue.setInlineCountLimit(1);
}
holder.mCallback.onQueueChanged(parcelableQueue);
这体现了 Binder 的数据量约束:队列变化属于控制信息,也可能因列表很大而需要分批传递。接口组合与回调发布需要考虑传输成本,不能把整份内容库当成一个无限大的会话状态包。
源码:保存元数据、应用发送队列、服务端保存队列、队列更新通知、元数据通知。
图 4:① 注册订阅,② 应用提交信息,③ 系统通知变化,④ 控制端查询,⑤ 返回查询结果。编号用于区分路径,不规定调用顺序。系统记录的订阅管理、保存、通知与查询节点均属于同一 MediaSessionRecord;灰色虚线表示通知使用已注册回调。控制端将查询结果和变化回调接到同一界面模型,两条读取路径不组成原子快照。
状态快照与一次性事件,适合表达不同信息
setPlaybackState()、setMetadata()、setExtras() 更新会话当前可读的信息,新控制端可以再查询;sendSessionEvent() 则将一次事件交给当前订阅者。前者适合“现在是什么”,后者适合业务约定的“一次发生了什么”,例如应用自定义操作的提示。
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,sendSessionEvent()。
public void sendSessionEvent(@NonNull String event, @Nullable Bundle extras) {
if (TextUtils.isEmpty(event)) {
throw new IllegalArgumentException("event cannot be null or empty");
}
try {
mBinder.sendEvent(event, extras);
} catch (RemoteException e) {
Log.wtf(TAG, "Error sending event", e);
}
}
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,pushEvent()。
private void pushEvent(String event, Bundle data) {
synchronized (mLock) {
if (mDestroyed) {
return;
}
}
performOnCallbackHolders("pushEvent",
holder -> holder.mCallback.onEvent(event, data));
}
这条发布路径遍历当时持有的回调,没有为后来连接的控制器保存可查询的事件历史。控制端未订阅或已断开时,不能指望重新连接后补收事件。如果信息会影响后续界面状态,应同时维护可查询的业务状态,而不是只发一次事件。
会话界面入口又是另一类信息:setSessionActivity(PendingIntent) 提供返回播放器界面的入口,控制器通过 getSessionActivity() 获取。它与用于媒体键恢复播放的接收器职责不同,不能因为都使用 PendingIntent 就混在一起。
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,setSessionActivity()。
public void setSessionActivity(@Nullable PendingIntent pi) {
try {
mBinder.setLaunchPendingIntent(pi);
} catch (RemoteException e) {
Log.wtf(TAG, "Failure in setLaunchPendingIntent.", e);
}
}
源码:发送会话事件、向当前订阅者发布事件、设置界面入口。
控制音量:本地播放与远程播放走不同路径
播放、暂停等命令最终由应用操作播放器;音量请求的执行方则取决于会话声明的播放类型。本地音量交给音频系统,远程音量交给应用提供的 VolumeProvider。
先声明由谁处理音量
setPlaybackToLocal(AudioAttributes) 为会话声明本地播放的音频属性;setPlaybackToRemote(VolumeProvider) 则声明由应用处理远程音量。应用仍在自己的进程中持有 MediaSession 和 VolumeProvider,远程设备连接、控制协议与音量信息获取由应用的播放方案负责。
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,setPlaybackToRemote()。
synchronized (mLock) {
mVolumeProvider = volumeProvider;
}
volumeProvider.setCallback(new VolumeProvider.Callback() {
@Override
public void onVolumeChanged(VolumeProvider volumeProvider) {
notifyRemoteVolumeChanged(volumeProvider);
}
});
try {
mBinder.setPlaybackToRemote(volumeProvider.getVolumeControl(),
volumeProvider.getMaxVolume(), volumeProvider.getVolumeControlId());
mBinder.setCurrentVolume(volumeProvider.getCurrentVolume());
} catch (RemoteException e) {
Log.wtf(TAG, "Failure in setPlaybackToRemote.", e);
}
VolumeProvider 声明控制类型、最大音量和当前音量。系统据此决定请求是否可以交给应用:
| 控制类型 | adjustVolume 相对调整 | setVolumeTo 绝对设置 |
|---|---|---|
| FIXED | 不转交应用执行 | 不转交应用执行 |
| RELATIVE | 支持非静音类调整 | 不转交应用执行 |
| ABSOLUTE | 支持非静音类调整 | 支持,设置值限制在允许范围 |
这里的远程调整路径还排除了 ADJUST_MUTE、ADJUST_UNMUTE 和 ADJUST_TOGGLE_MUTE。应用实现了 VolumeProvider,并不代表所有音量操作都会到达应用。
本地交给音频系统,远程交给应用回调
本地音量调整根据 AudioAttributes 选择对应流,投递到 Handler 后调用音频管理接口。远程路径则转交给会话拥有者,并根据支持的控制类型决定是否允许调整或设置。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,adjustVolume() 的本地与可调整远程分支。
if (mVolumeType == PLAYBACK_TYPE_LOCAL) {
int stream = getVolumeStream(mAudioAttrs);
postAdjustLocalVolume(stream, direction, flags, opPackageName, pid, uid,
asSystemService, useSuggested, previousFlagPlaySound);
}
// 远程分支通过控制类型与 direction 检查后
mSessionCb.adjustVolume(packageName, pid, uid, asSystemService, direction);
int volumeBefore = (mOptimisticVolume < 0 ? mCurrentVolume : mOptimisticVolume);
mOptimisticVolume = volumeBefore + direction;
mOptimisticVolume = Math.max(0, Math.min(mOptimisticVolume, mMaxVolume));
mHandler.removeCallbacks(mClearOptimisticVolumeRunnable);
mHandler.postDelayed(mClearOptimisticVolumeRunnable, OPTIMISTIC_VOLUME_TIMEOUT);
if (volumeBefore != mOptimisticVolume) {
pushVolumeUpdate();
}
绝对音量设置只在远端声明 VOLUME_CONTROL_ABSOLUTE 时交给应用,值会限制在允许范围。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,setVolumeTo()。
if (mVolumeType == PLAYBACK_TYPE_LOCAL) {
int stream = getVolumeStream(mAudioAttrs);
final int volumeValue = value;
mHandler.post(
() ->
setStreamVolumeForUid(
opPackageName, pid, uid, flags, stream, volumeValue));
} else {
if (mVolumeControlType != VOLUME_CONTROL_ABSOLUTE) {
if (DEBUG) {
Slog.d(TAG, "Session does not support setting volume");
}
} else {
value = Math.max(0, Math.min(value, mMaxVolume));
mSessionCb.setVolumeTo(packageName, pid, uid, value);
// 后续更新临时估计并安排清理任务
}
}
系统记录通过 Binder 将远程请求交给应用 CallbackStub,再投递到应用的回调 Handler。Handler 取出当前 VolumeProvider,调用相对调整或绝对设置方法:
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,CallbackMessageHandler 的音量分支。
case MSG_ADJUST_VOLUME:
synchronized (mLock) {
vp = mVolumeProvider;
}
if (vp != null) {
vp.onAdjustVolume((int) obj);
}
break;
case MSG_SET_VOLUME:
synchronized (mLock) {
vp = mVolumeProvider;
}
if (vp != null) {
vp.onSetVolumeTo((int) obj);
}
break;
应用回报当前值,系统另行维护临时估计
VolumeProvider 的 onAdjustVolume()、onSetVolumeTo() 是交给应用实现的处理入口。应用把请求接到远端播放方案,并在获得新的音量信息后调用 setCurrentVolume();框架不会自动替应用读取远端设备音量。
源码位置:frameworks/base/media/java/android/media/VolumeProvider.java,setCurrentVolume()。
public final void setCurrentVolume(int currentVolume) {
mCurrentVolume = currentVolume;
if (mCallback != null) {
mCallback.onVolumeChanged(this);
}
}
注册远程播放类型时,MediaSession 已为 provider 设置了回调。当前值变化后进入 notifyRemoteVolumeChanged(),框架检查 provider 是否仍为当前对象,再通过 Binder 回报音量。
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,notifyRemoteVolumeChanged()。
synchronized (mLock) {
if (provider == null || provider != mVolumeProvider) {
Log.w(TAG, "Received update from stale volume provider");
return;
}
}
try {
mBinder.setCurrentVolume(provider.getCurrentVolume());
} catch (RemoteException e) {
Log.e(TAG, "Error in notifyVolumeChanged", e);
}
远程分支中的 mOptimisticVolume 是临时估计值:发出请求后先更新表现,临时值由超时任务清理;应用回报则更新记录中的当前音量。这是一种减少控制反馈延迟的策略,估计值不等于远端设备已经执行成功。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,setCurrentVolume() 与估计清理任务。
private void setCurrentVolume(int volume) {
mCurrentVolume = volume;
mHandler.post(MessageHandler.MSG_UPDATE_VOLUME);
}
private final Runnable mClearOptimisticVolumeRunnable =
() -> {
boolean needUpdate = (mOptimisticVolume != mCurrentVolume);
mOptimisticVolume = -1;
if (needUpdate) {
pushVolumeUpdate();
}
};
这里更新 mCurrentVolume 与清理 mOptimisticVolume 是不同路径,不能把任意一次远程回报理解为清理任务已经执行。
应用回报值是否对应远端已经执行完成,由应用与远端协议保证;框架只保存传入值。当前源码的估计清理延迟为 1000 毫秒,允许的相对调整和绝对设置都会重新安排清理任务。这个超时用于结束临时显示估计,不是远端设备操作的超时或重试机制。
源码:临时估计超时常量。
PlaybackInfo 从哪里取值,怎样通知控制端
控制器读取 PlaybackInfo 时,本地和远程的音量来源也不同:本地从 AudioManager 查询音频流,远程使用应用回报的当前值;如果存在请求产生的临时估计值,则优先返回估计值。这个估计值如何产生和清理,接下来结合音量执行代码说明。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,getVolumeAttributes()。
if (mVolumeType == PlaybackInfo.PLAYBACK_TYPE_REMOTE) {
int current = mOptimisticVolume != -1 ? mOptimisticVolume : mCurrentVolume;
return new PlaybackInfo(mVolumeType, mVolumeControlType, mMaxVolume, current,
mAudioAttrs, mVolumeControlId);
}
// 本地分支
int stream = getVolumeStream(attributes);
int max = mAudioManager.getStreamMaxVolume(stream);
int current = mAudioManager.getStreamVolume(stream);
本地读取从 AudioManager 获取音频流当前值,独立于是否刚执行过一次音量调整。远程读取则按 mOptimisticVolume 是否有效选取展示值:即使应用已经回报新值,只要估计仍存在,查询或通知仍可能优先使用估计。
控制器主动调用 getPlaybackInfo() 获得查询结果;订阅后通过 MediaController.Callback.onAudioInfoChanged() 接收音量信息变化。服务端 pushVolumeUpdate() 同样调用 getVolumeAttributes(),构造 PlaybackInfo 后发送 Binder 回调。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,pushVolumeUpdate()。
private void pushVolumeUpdate() {
PlaybackInfo info;
synchronized (mLock) {
if (mDestroyed) {
return;
}
info = getVolumeAttributes();
}
performOnCallbackHolders(
"pushVolumeUpdate", holder -> holder.mCallback.onVolumeInfoChanged(info));
}
系统音量界面的交互通知,是另一条路径
远程音量请求处理后,记录还会调用 MediaSessionService.notifyRemoteVolumeChanged(flags, this)。这条路径向注册的 IRemoteSessionCallback 发送会话 Token 和 flags,让系统音量界面获得相应交互事件;它与携带 PlaybackInfo 的控制器音量信息通知不同。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java,notifyRemoteVolumeChanged()。
if (!session.isActive()) {
return;
}
// 遍历已注册的系统远程会话回调
IRemoteSessionCallback cb = mRemoteVolumeControllers.getBroadcastItem(i);
cb.onVolumeChanged(token, flags);
在远程调整或设置分支中,即使音量没有改变,记录也会调用这条服务通知路径;服务还要检查会话是否 active。因此,系统音量界面收到交互事件,不能单独证明远端已执行或当前值已更新。
源码:远程请求后的界面通知、系统远程音量回调。
媒体键与系统音量键分别选择处理目标;本节的 MediaController 音量接口已经通过 Token 指定会话。车载输入即使来自同一组方向盘按键,也需要按具体入口追踪,不能直接认为播放键和音量键总由同一会话处理。
源码:配置远程音量、音量调整与估计值、当前音量回报、估计值清理、绝对音量设置、本地音量任务、应用 VolumeProvider 回调、控制器查询 PlaybackInfo。
图 5:① 指定会话的音量请求,② 本地音频处理,③ 远程应用回调,④ 应用回报当前值,⑤ 查询 PlaybackInfo,⑥ 查询返回或音量信息通知,⑦ 系统远程音量交互事件。橙色虚线表示允许的远程请求产生临时估计,不等待应用回报。本地读取与执行独立;⑥ 和⑦ 的数据与接收者不同。
会话退出:主动释放、进程死亡与订阅清理
前面的控制、状态和音量协作都依赖有效的会话记录与回调连接。使用结束或进程退出时,接下来需要分清:退出的是会话拥有者,还是其中一个订阅控制端。两种情况清理的对象不同。
release 会销毁会话连接,不替应用关闭播放器
应用使用结束后调用 release(),清除自己的回调,并请求服务端销毁记录。
源码位置:frameworks/base/media/java/android/media/session/MediaSession.java,release()。
public void release() {
if (mIsReleased) {
return;
}
setCallback(null);
try {
mBinder.destroySession();
} catch (RemoteException e) {
Log.wtf(TAG, "Error releasing session: ", e);
} finally {
mIsReleased = true;
}
}
播放器、音频焦点和其他资源仍由各自持有者管理。不要把 release() 当作对播放器对象的 stop/release;也不要只停止播放器就认为会话记录一定随之销毁。
拥有者进程死亡走同一套服务清理
MediaSessionRecord 在构造时监听拥有者回调 Binder 的死亡;进程退出后通过 onSessionDied() 请求服务处理。销毁过程移除会话集合中的记录、清理全局目标或栈中的关联,再关闭单会话记录。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,binderDied()。
public void binderDied() {
mService.onSessionDied(this);
}
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java,destroySessionLocked()。
if (mGlobalPrioritySession == session) {
mGlobalPrioritySession = null;
if (session.isActive()) {
if (user != null) {
user.pushAddressedPlayerChangedLocked();
}
mHandler.post(this::notifyGlobalPrioritySessionActiveChanged);
}
} else {
if (user != null) {
user.mPriorityStack.removeSession(session);
}
}
session.close();
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,close()。
synchronized (mLock) {
if (mDestroyed) {
return;
}
mSessionCb.mCb.asBinder().unlinkToDeath(this, 0);
mController.release();
mSession.release();
mDestroyed = true;
mPlaybackState = null;
mUserEngagementState = USER_DISENGAGED;
mHandler.removeCallbacks(mUserEngagementTimeoutExpirationRunnable);
mHandler.post(MessageHandler.MSG_DESTROYED);
}
close() 将记录置为 destroyed,清理播放状态,并安排 Handler 处理销毁消息。pushSessionDestroyed() 遍历订阅持有者,先解除各自的死亡监听,再发送 Binder 销毁通知;随后清空服务端订阅列表。控制端经自己的 Handler 处理通知,服务端不会等待控制界面完成更新。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,pushSessionDestroyed()。
performOnCallbackHolders(
"pushSessionDestroyed",
holder -> {
holder.mCallback.asBinder().unlinkToDeath(holder.mDeathMonitor, 0);
holder.mCallback.onSessionDestroyed();
});
// After notifying clear all listeners
synchronized (mLock) {
mControllerCallbackHolders.clear();
}
控制端退出,只清理对应订阅
拥有者会话销毁,会影响连接它的控制端;控制端取消监听,则先从本地回调列表开始清理。这里还需要区分两个对象:应用注册的 MediaController.Callback,以及控制器向服务端注册的共享 Binder 回调 mCbStub。
同一个 MediaController 可以注册多个应用 Callback,但首次注册时只向服务端注册一次 mCbStub。unregisterCallback(callback) 先移除指定回调的本地 Handler;还有其他本地回调时,保留服务端订阅。只有本地回调全部取消且 Binder 订阅仍存在时,才请求系统移除这个订阅持有者。
源码位置:frameworks/base/media/java/android/media/session/MediaController.java,removeCallbackLocked()。
for (int i = mCallbacks.size() - 1; i >= 0; i--) {
MessageHandler handler = mCallbacks.get(i);
if (cb == handler.mCallback) {
mCallbacks.remove(i);
success = true;
handler.mRegistered = false;
}
}
if (mCbRegistered && mCallbacks.size() == 0) {
try {
mSessionBinder.unregisterCallback(mCbStub);
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}
mCbRegistered = false;
}
源码:本地回调与共享 Binder 订阅、最后一个本地回调取消后的注销。
控制端回调 Binder 死亡,则由服务端对应持有者的死亡监听触发清理。服务端 unregisterCallback() 查找这个 Binder 回调、解除死亡监听,再移除相应持有者;这条路径不进入 destroySessionLocked()。
源码位置:frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java,unregisterCallback()。
int index = getControllerHolderIndexForCb(cb);
if (index != -1) {
try {
cb.asBinder().unlinkToDeath(
mControllerCallbackHolders.get(index).mDeathMonitor, 0);
} catch (NoSuchElementException e) {
Slog.w(TAG, "error unlinking to binder death", e);
}
mControllerCallbackHolders.remove(index);
}
源码:移除订阅持有者。
拥有者死亡与控制器死亡的清理对象不同:前者导致会话销毁,后者通常移除一个订阅。控制界面收到 onSessionDestroyed() 后,应结束旧连接,重新发现或获取有效 Token;历史 Token 不能自动代表重新创建的新会话。
源码:主动释放、监听拥有者死亡、服务销毁会话、关闭记录、通知并清空订阅。
图 6:左侧主动释放或拥有者 Binder 死亡,进入服务清理、记录关闭、销毁通知及订阅列表清空。右侧取消一个本地 Callback 后,检查是否还存在其他回调;最后一个取消时才注销共享 Binder 订阅。控制端 Binder 死亡直接触发服务端对应订阅清理。两侧清理范围不同,服务端发出通知与控制端处理通知也不是同一时刻。
在车机上排查,需要确认哪几个对象
车载系统中,媒体键可能来自方向盘、蓝牙、系统界面或厂商控制入口。先确认入口实际使用的是系统媒体键派发,还是针对某个 Token 的控制调用,再追踪用户、UID、包名与会话归属。厂商自定义派发器也可能改变目标选择。
adb shell dumpsys media_session
使用输出中的用户会话、活动标志、播放状态和媒体键目标等信息,与应用的回调和播放器日志对应。实际字段以设备输出为准。
| 现象 | 优先检查 | 应区分的结果 |
|---|---|---|
| 按键没有响应 | 系统入口、监听器、自定义派发器、目标会话、应用回调 | 按键被接收与应用被通知 |
| 回调收到但没开始播放 | onPlay 实现、播放器返回结果、数据源准备与错误 | 命令到达与播放器执行 |
| 独立播放键没触发 onPlay | 默认媒体键处理中的 ACTION_PLAY、事件类型、覆盖逻辑 | 支持动作声明与事件处理 |
| 控制了另一播放器 | 媒体键目标、音频活跃 UID、会话策略、Token 与用户 | 默认选择与指定会话控制 |
| 界面显示播放中但无声 | 应用上报时机、播放器输出、音频路由与设备状态 | 播放状态报告与实际输出 |
| 曲名、队列或进度不一致 | 元数据、QueueItem ID、位置时间戳、查询与回调时序 | 应用内容状态与对外发布信息 |
| 本地或远端音量异常 | 播放类型、控制能力、音频流读值、应用回报与临时估计 | 请求转交、当前值更新与远端执行 |
| 系统音量界面出现,但远端没变化 | active 条件、系统远程音量回调、provider 处理与当前值 | 系统交互事件与 PlaybackInfo 通知 |
| 应用退出后控制失效 | 会话销毁、旧 Token、控制端销毁通知与重连 | 历史记录与当前有效会话 |
MediaSession 本身不决定音频路由,也不选择哪个音区实际出声。会话目标正确而输出设备错误时,应继续检查 AudioPolicy 和音频输出链路。音频焦点同样需要单独管理,setActive(true) 不等于已获得焦点。
思绎沉淀
对比的重点是责任如何分配
MediaSession 的价值不只是把一个播放键送给应用,而是定义系统与播放器之间的共同语言:身份与目标、命令与支持动作、播放与内容信息、音量模型、连接与销毁。播放器可以采用不同的数据处理实现,控制端仍使用相同会话协议。
| 对比对象 | 系统控制与播放器的关系 | 一致性由谁负责 |
|---|---|---|
| 平台 MediaSession | 会话接口与播放器实现分开,应用提供回调与信息发布 | 应用负责把命令和发布信息接到播放器,系统管理归属、派发与通知 |
| AndroidX Media3 MediaSession | 会话绑定 Media3 Player,适配控制与 Player 状态 | 标准衔接由库承担,应用维护业务扩展、控制访问与播放器实现 |
| Linux 桌面 MPRIS | D-Bus 协议定义播放命令、元数据、播放状态及属性通知 | 播放器实现协议,桌面控制端按协议访问;其对象与权限模型不同于 Android |
| FFmpeg / GStreamer | 提供媒体处理库或管线,应用可据此实现播放器 | 集成方另外连接平台媒体控制、设备策略与生命周期 |
平台 MediaSession 把控制协议与媒体执行分离,使播放器保留实现自由;Media3 提供更紧密的会话与 Player 适配,减少手工维护两套信息;MPRIS 在桌面跨进程场景提供标准协议。三者都追求控制互操作,但适配责任、系统管理能力和部署环境不同。对比设计时,不能只看是否有 play/pause 两个方法。
参考:Media3 会话与播放器、MPRIS 接口规范、FFmpeg 文档、GStreamer 应用开发。
Android 的架构约束解释了它为何需要这些组件
多应用与多用户让目标选择成为系统职责。 应用能描述自己的状态,却不能仅凭自己的愿望决定下一次系统媒体键归谁。用户记录、权限、音频活动观察和会话策略共同参与这个判断,指定 Token 控制则是另一条路径。
跨进程使信息发布必须考虑数据量与对象寿命。 队列使用分段传输,回调注册同时关联死亡监听,销毁通知之后清理持有者。这些代码体现了接口设计要承担的资源与生命周期责任,不只是功能调用。
状态与设备结果之间存在时间差。 播放位置可以推算,远程音量可以先给出临时估计,应用回报和控制端通知又经 Handler 与 Binder 传递。系统用这些机制改善交互反馈,但估计值、应用回报值和设备执行结果各有来源;系统音量界面的交互事件也不能代替设备确认。
系统控制不包办设备输出。 MediaSession 接收播放请求、连接本地或远端音量处理,但音频焦点、路由、解码与真实设备状态仍由各自链路承担。接上统一控制协议,并不意味着所有输出条件都已经满足。
从 Framework 开发角度看,真正需要维护的是一组连接关系:哪个用户的会话,由谁拥有,谁正在订阅,哪个回调等待处理,持有的 Token 是否仍有效。从应用角度看,则让命令执行、播放器状态和信息发布围绕同一个播放器状态来源组织。把两边的责任对应起来,才有可能在多个播放器、多个控制端和设备状态变化之间保持一致。