AOSP思绎|Android 17 MediaSession:系统如何找到播放器、控制播放并同步状态?

0 阅读49分钟

播放器应用知道怎样播放一首歌,系统界面却还需要回答另一组问题:当前应该控制哪个应用?播放键交给谁?曲名、封面和进度从哪里来?本地音量与投屏设备的音量又该怎样调整?

MediaSession 把播放器接入系统媒体控制体系。应用通过它公布状态与控制能力,控制器通过统一接口发送请求,系统服务负责会话归属、媒体键选择、通知与生命周期管理。真正的解码和播放仍由播放器完成。

理解这套机制,可以沿三个问题展开:怎样找到播放器、怎样控制播放、怎样同步状态。本文从平台 android.media.session.MediaSession 的源码出发,再将音量、元数据、队列、权限与销毁放回同一张协作图中。MediaSession2 和 AndroidX Media3 有各自的实现与适配;本文最后对比其设计方向,不把它们与平台会话混作同一套接口。

先看全貌:会话是控制协议,也是信息发布接口

AAOS_AOSP_Android17_MediaSession_2026-11-04_Architecture_Juejin-01 架构:控制向下、信息向下.jpg

图 1:左侧①—⑥展示控制主路径,右侧⑦—⑨展示信息发布与通知,横向箭头连接注册、音频观察和信息衔接。灰色框按职责分组,进程归属看节点文字;重复展示的 MediaSession、MediaSessionRecord 为同一对象。蓝色表示请求,橙色表示信息回报,绿色虚线表示音频活动通知。右侧展示查询结果与变化通知,查询请求由控制器发起;编号用于阅读,不表示各条路径严格串行。

层次核心对象职责
会话拥有者,播放器应用进程MediaSession / Callback / VolumeProvider接收命令,发布播放信息,处理远程音量
统一控制接口,控制端所在进程MediaController / TransportControls / Callback通过 Token 控制指定会话,查询并订阅信息
系统会话管理,system_serverMediaSessionService / 用户记录 / MediaSessionStack创建记录,确定归属,组织媒体键目标选择与生命周期
单会话记录,system_serverMediaSessionRecord持有拥有者与控制端接口,保存信息,转交请求并发布更新
音频观察与音量协作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() 会改变应用默认按键处理行为。

AAOS_AOSP_Android17_MediaSession_2026-11-04_Architecture_Juejin_Input.jpg

图 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() {
}

应用需要在自己的回调或播放器适配层中实现播放动作,并根据播放器结果维护会话信息。回调是否执行、播放器是否接受请求、数据是否开始输出,应分别记录。

源码:投递播放回调、处理播放消息、默认 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 就抢到控制权”。

源码:状态保存与通知、状态分类、会话排序更新、控制器状态回调。

AAOS_AOSP_Android17_MediaSession_2026-11-04_Architecture_Juejin_State.jpg

图 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 更新界面。

AAOS_AOSP_Android17_MediaSession_2026-11-04_Architecture_Juejin_Flow.jpg

图 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 的数据量约束:队列变化属于控制信息,也可能因列表很大而需要分批传递。接口组合与回调发布需要考虑传输成本,不能把整份内容库当成一个无限大的会话状态包。

源码:保存元数据、应用发送队列、服务端保存队列、队列更新通知、元数据通知。

AAOS_AOSP_Android17_MediaSession_2026-11-04_Architecture_Juejin_Info.jpg

图 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);
}

源码:provider 当前值更新、向会话记录回报音量。

远程分支中的 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);

源码:PlaybackInfo 的音量来源。

本地读取从 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。

MediaSession 本地与远程音量控制转存失败,建议直接上传图片文件

图 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 不能自动代表重新创建的新会话。

源码:主动释放、监听拥有者死亡、服务销毁会话、关闭记录、通知并清空订阅。

AAOS_AOSP_Android17_MediaSession_2026-11-04_Architecture_Juejin_Lifecycle.jpg

图 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 桌面 MPRISD-Bus 协议定义播放命令、元数据、播放状态及属性通知播放器实现协议,桌面控制端按协议访问;其对象与权限模型不同于 Android
FFmpeg / GStreamer提供媒体处理库或管线,应用可据此实现播放器集成方另外连接平台媒体控制、设备策略与生命周期

平台 MediaSession 把控制协议与媒体执行分离,使播放器保留实现自由;Media3 提供更紧密的会话与 Player 适配,减少手工维护两套信息;MPRIS 在桌面跨进程场景提供标准协议。三者都追求控制互操作,但适配责任、系统管理能力和部署环境不同。对比设计时,不能只看是否有 play/pause 两个方法。

参考:Media3 会话与播放器、MPRIS 接口规范、FFmpeg 文档、GStreamer 应用开发。

Android 的架构约束解释了它为何需要这些组件

多应用与多用户让目标选择成为系统职责。 应用能描述自己的状态,却不能仅凭自己的愿望决定下一次系统媒体键归谁。用户记录、权限、音频活动观察和会话策略共同参与这个判断,指定 Token 控制则是另一条路径。

跨进程使信息发布必须考虑数据量与对象寿命。 队列使用分段传输,回调注册同时关联死亡监听,销毁通知之后清理持有者。这些代码体现了接口设计要承担的资源与生命周期责任,不只是功能调用。

状态与设备结果之间存在时间差。 播放位置可以推算,远程音量可以先给出临时估计,应用回报和控制端通知又经 Handler 与 Binder 传递。系统用这些机制改善交互反馈,但估计值、应用回报值和设备执行结果各有来源;系统音量界面的交互事件也不能代替设备确认。

系统控制不包办设备输出。 MediaSession 接收播放请求、连接本地或远端音量处理,但音频焦点、路由、解码与真实设备状态仍由各自链路承担。接上统一控制协议,并不意味着所有输出条件都已经满足。

从 Framework 开发角度看,真正需要维护的是一组连接关系:哪个用户的会话,由谁拥有,谁正在订阅,哪个回调等待处理,持有的 Token 是否仍有效。从应用角度看,则让命令执行、播放器状态和信息发布围绕同一个播放器状态来源组织。把两边的责任对应起来,才有可能在多个播放器、多个控制端和设备状态变化之间保持一致。