ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android MediaPlayer.setPreferredDevice 音频路由切换完全指南

Android MediaPlayer.setPreferredDevice 音频路由切换完全指南 做音频开发的兄弟应该都有这种经历同一个视频源在扬声器外放和蓝牙耳机上听完全是两个效果。如果你正好在做音乐播放器、视频客户端或者投屏工具肯定被“怎么让 MediaPlayer 把声音输出到指定设备”这个需求折磨过。今天这篇是Android进阶系列的第245篇我重点拆解 MediaPlayer.setPreferredDevice 这个 API 的完整调用流程从底层路由原理到实际可跑的代码再到 Android 16 上的兼容性问题一次说清楚。内容不只适合刚接触音频路由的新人也适合被切换设备、拔插回调、设备持久化这些细节坑过的老手。1. 这个API到底解决了什么问题1.1 系统默认音频路由策略为什么不够用Android 系统的音频输出不是“谁先到谁出声”而是由 AudioPolicyManager 根据一套默认路由规则来决定的。简单说插上耳机就优先走耳机连接蓝牙 A2DP 就切到蓝牙来电时通过 AudioFocus 抢占焦点让媒体音暂停。这套机制覆盖了绝大多数日常场景但它有个明显短板——它是以“全局策略”为优先级的而不是以某个播放实例的意愿为优先级。举个最典型的例子用户同时接着一个 USB Type-C 耳机和一个蓝牙音箱系统默认会倾向于走 USB 设备但App层面希望音乐继续从蓝牙音箱出声音或者反过来视频通话走 USB 耳机但媒体播放走蓝牙音箱。这种需求靠系统自动路由是搞不定的必须由应用主动指定输出设备。另一个场景是外接声卡或者专业音频设备。很多用户会在手机上调音台、接车载 AUX、插带解码芯片的 USB DAC这些设备在系统看来都是一个普通的 AudioDeviceInfo默认策略并不会优先选它。我接过一个客户需求他们的 App 必须把声音强制送到指定的 USB DAC用户选哪个就哪个不能有系统自动跳变。这种情况下MediaPlayer.setPreferredDevice 就是最直接、最可控的方案。1.2 和旧方案对比为什么选它Android 在 API 26 引入了 AudioManager.setPreferredDeviceForStrategy这个 API 是按 AudioProductStrategy 来设置路由的影响的是某个策略组的所有音频流。它的粒度更大比较适合做系统级设置比如“游戏声音都走蓝牙”。但问题是它作用于整个进程甚至全局策略如果你的 App 里同时有多个 MediaPlayer 在播放不同内容通过它做精细化路由非常别扭而且低版本设备上行为差异很大。MediaPlayer.setPreferredDevice 完全不同。它作用在单个 MediaPlayer 实例上只影响当前这个播放器创建的音频流调用方式也很简单boolean result mediaPlayer.setPreferredDevice(deviceInfo);传入一个 AudioDeviceInfo返回 true 表示底层接受了这个偏好设备返回 false 说明设备不可用或者当前状态不对。传 null 可以清除偏好恢复系统默认路由逻辑。我在项目里也对比过 AudioTrack.setPreferredDeviceMediaPlayer 的方案在逻辑上其实是一回事因为 MediaPlayer 底层创建音频输出时本质就是走 AudioTrack 或 AAudio。用 MediaPlayer 的好处是上层不用关心状态机切换、数据源解码、缓存这些事只需专注设备选择即可正好符合大多数播放器场景的使用习惯。2. 调用流程与底层机制拆解2.1 API签名和核心参数说明先看方法定义MediaPlayer.setPreferredDevice 接收的参数是 AudioDeviceInfo这是一个描述音频设备信息的对象。它包含设备类型、设备ID、产品名、采样率、通道数等元数据。注意调用前必须确认设备是输出设备判断方法是 isSink()如果是 false说明这是一个输入设备比如麦克风直接传进去会被拒绝。源码层面也是先做了这个判断再往下走。另外设备不能为空想要取消偏好时显式传 null不要传一个无效的设备对象。获取输出设备列表靠 AudioManagerval audioManager context.getSystemService(AudioManager::class.java) val outputs audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)这个方法会返回所有当前可用的输出设备包括扬声器、有线耳机、蓝牙设备、USB设备等。需要注意这个列表是“当前可用”的不是“曾经连过”的所以设备拔插后列表会变化需要监听设备事件动态更新 UI。设备类型有很多种实际开发中我们主要关心以下几类其他类型通常和普通播放器无关设备类型说明常见场景TYPE_BUILTIN_SPEAKER内置扬声器外放TYPE_WIRED_HEADSET带麦克风的有线耳机插耳机TYPE_WIRED_HEADPHONES不带麦克风的有线耳机插耳机TYPE_BLUETOOTH_A2DP蓝牙音乐设备蓝牙音箱/耳机TYPE_USB_DEVICEUSB外置音频设备USB DAC、声卡TYPE_USB_HEADSETUSB耳机Type-C耳机设备名称通过 productName 获取但部分设备返回的可能是空字符串我在实际测试中遇到过一些国产蓝牙耳机在未连接成功时返回空名称的情况UI 层要兜底。2.2 Java层到Native层完整链路setPreferredDevice 不是普通的 Java 层状态保存它最终会一路传递到音频系统底层。理解这条链路对排查问题很有帮助。链路大致如下Java 层 MediaPlayer.java 调用 native_setPreferredDevice 方法。JNI 层 android_media_MediaPlayer.cpp 将设备 ID 传给 Native 层的 MediaPlayer。Native 层 MediaPlayer 保存 mPreferredDeviceId。MediaPlayer 创建音频输出时最终会创建 AudioTrack 或使用 AAudio将 mPreferredDeviceId 传给 AudioTrack。AudioFlinger/AudioPolicyManager 检测到该 AudioTrack 带上了指定设备ID路由策略优先选择这个设备。如果设备不可用系统自动回退到默认策略。所以 setPreferredDevice 本质上不是“立刻切歌”而是给底层打了一个路由偏好标记真正生效是在音频流创建那一刻。这就引出了调用时机的问题。2.3 调用时机到底怎么选直接说结论最佳时机是 setDataSource 之后、prepare 之前。因为 MediaPlayer 处于 Prepared 状态之后才会真正创建底层的音频输出通道在此之前设置设备偏好最稳妥能保证音频流一开始就选中目标设备。如果确实需要在 prepare 之后改设备我的建议是先 pause再 setPreferredDevice最后 start。实测在绝大多数设备上这样能生效。这里有个细节部分厂家的 ROM 在 MediaPlayer.start() 时会重新拉取路由策略如果你在播放过程中直接 setPreferredDevice 而不暂停可能不会立刻切换甚至会出现声音短暂卡顿或仍然走旧设备的情况。流式播放网络流、直播流场景更要小心。由于数据源是持续到达的prepareAsync 之后可能很快就进入 Started 状态留给切换的窗口很小。我更推荐的做法是在用户点击切换设备后对当前 MediaPlayer 执行 pause然后调 setPreferredDevice再 start。如果播放的是本地文件stop 后重新 prepare 再 start 也完全可以只是耗时稍长。我看到有些开发者喜欢在 setPreferredDevice 后检查返回值如果返回 false 就提示用户切换失败。这个方向是对的但要意识到返回值 false 只表示底层拒绝了请求并不等于设备不可用。有可能是这个 AudioDeviceInfo 实例已经过期也可能是你传入的设备类型是输入设备还有可能是底层 AudioTrack 已经在运行中有状态锁。排查时先打印 getPreferredDevice() 看看当前实际生效的是什么。3. 实战实现可切换输出设备的播放器3.1 工程准备和权限处理工程语言我用 KotlintargetSdk 和 compileSdk 都设为 36也就是 Android 16。不加任何第三方库纯系统 API。权限方面除了正常的 INTERNET如果拉网络流之外主要涉及蓝牙权限。Android 12API 31开始访问蓝牙设备信息需要声明 BLUETOOTH_CONNECT 权限这是运行时权限需要在 App 启动时动态申请。如果你的应用目标版本低于 31走旧权限 BLUETOOTH 和 BLUETOOTH_ADMIN 即可但新项目基本不会这么干了。uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT /注意申请 BLUETOOTH_CONNECT 之后获取到的设备列表里蓝牙设备才会带上可用的产品名和地址信息否则系统会返回脱敏后的数据你看到的设备名可能全是空字符串。3.2 获取可用输出设备并动态刷新设备列表不能只在页面启动时取一次因为用户可能中途插拔耳机、连接蓝牙音箱。用 AudioManager.registerAudioDeviceCallback 监听设备变化最直观class AudioDeviceSwitcher(private val context: Context) { private val audioManager context.getSystemService(AudioManager::class.java) fun getOutputDevices(): ListAudioDeviceInfo { return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).filter { device - when (device.type) { AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_WIRED_HEADSET, AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_USB_DEVICE, AudioDeviceInfo.TYPE_USB_HEADSET - true else - false } } } fun registerCallback(callback: AudioDeviceCallback) { audioManager.registerAudioDeviceCallback(callback, Handler(Looper.getMainLooper())) } fun unregisterCallback(callback: AudioDeviceCallback) { audioManager.unregisterAudioDeviceCallback(callback) } }过滤设备时我列了六种常见输出设备实际项目里你可能会遇到更多类型比如 TYPE_DOCK、TYPE_HDMI、TYPE_REMOTE_SUBMIX。要不要加入过滤列表取决于业务但核心原则是只展示你产品定义里允许用户切换的设备不要把所有类型一股脑丢给用户选那样反而增加理解成本。监听回调里要做的事情有两个刷新 UI 列表以及恢复之前记录的偏好设备。千万不要在回调里直接改正在播放的 MediaPlayer因为回调可能频繁触发频繁 pause/start 会导致声音断续。3.3 核心切换逻辑实现下面是我在项目里用的切换逻辑代码不多但每一步都是踩过坑之后调整过的fun applyPreferredDevice(player: MediaPlayer, device: AudioDeviceInfo?) { val wasPlaying player.isPlaying if (wasPlaying) { player.pause() } val result player.setPreferredDevice(device) Log.d(TAG, applyPreferredDevice result$result device${device?.productName}) if (wasPlaying) { player.start() } }有人会质疑为什么要先 pause 再 start不能直接设置吗我来说说原因。MediaPlayer 在 Started 状态下底层 AudioTrack 往往已经创建并且处于运行中此时通过 setPreferredDevice 改的是 Native 层的偏好 ID但已经创建好的 AudioTrack 不会立刻重新选路。性能好的设备上可能切换成功但很多中低端机型上音频流不会自动迁移表现为“设置成功了但还在旧设备上出声”。pause 之后再 start底层有机会销毁旧的 AudioTrack 并按新偏好创建新的属于最保险的做法。如果你的播放器是持续播放网络流pause 再 start 会带来短暂缓冲体验上有点影响。一个折中方案是只在用户点击“切换设备”时做一次 pause/start不要让这个操作太频繁。另外如果 App 支持 ExoPlayer它是另一套路由 API不在本文讨论范围内不要混淆。设备选择持久化也很关键。用户把设备切到蓝牙音箱之后重启 App 总不能又回到扬声器吧。我建议用 SharedPreferences 记录设备信息和类型不建议只存设备 ID因为设备 ID 在每次连接会话中都可能变化。更稳的字段是 type 加 addressAudioDeviceInfo.getAddress() 对蓝牙设备返回的是 MAC 地址对 USB 设备返回的是端口路径它相对稳定。private fun savePreferredDevice(device: AudioDeviceInfo) { prefs.edit() .putInt(preferred_device_type, device.type) .putString(preferred_device_address, device.address) .apply() } private fun restorePreferredDevice(player: MediaPlayer): AudioDeviceInfo? { val type prefs.getInt(preferred_device_type, -1) val address prefs.getString(preferred_device_address, ) if (type -1 || address.isNullOrEmpty()) return null return getOutputDevices().firstOrNull { it.type type it.address address } }恢复偏好的时机在用户完成权限授权、AudioDeviceCallback 第一次回调之后。恢复时同样先判断是否正在播放如果正在播放走 applyPreferredDevice 方法否则直接 setPreferredDevice 即可。3.4 Android 16上的验证结果我在 Android 16 的模拟器和真机上分别测过这套逻辑。模拟器上设备类型比较有限主要验证了扬声器、虚拟蓝牙、USB 设备的枚举和切换真机测试用的是 Pixel 系列加了蓝牙音箱和 Type-C 转 USB DAC 的组合。结论是Android 16 对 USB 音频设备的枚举更稳定了插拔 Type-C DAC 时 AudioDeviceCallback 触发更及时设备断开后回退默认路由的耗时也比旧版本短。蓝牙 A2DP 切换的逻辑基本没变还是老一套流程。有一点需要提醒Android 16 上系统对音频设备回调的线程要求做了更严格的处理如果你的回调里直接访问 UI 或执行耗时操作有概率触发应用无响应。建议回调逻辑精简或者用 Handler 切到主线程再刷新 UI这一点和 Android 14/15 差异不大但官方文档在 16 上强调了更多线程限制。4. 常见问题与排查技巧实录4.1 setPreferredDevice返回false怎么排查返回值是 false 是最容易遇到的坑。按我的排查顺序来第一步先看 AudioDeviceInfo 本身。打印它的 type、id、isSink() 三个字段。如果 isSink() 是 false说明传了一个输入设备进来API 直接拒绝这是最基础的问题。第二步看调用时机。如果 MediaPlayer 已经处于 Error 状态或者正在播放还没 pause底层可能不响应设置。我遇到过一次很隐蔽的问题就是数据源 prepare 失败后再调用 setPreferredDevice返回永远是 false因为底层 MediaPlayer 已经不再接收任何设备设置了。必须先 reset 或者重建实例。第三步看设备是否真的可用。getDevices 返回的设备在某些瞬间可能已经被拔掉但你持有的 AudioDeviceInfo 对象还是旧的它的内部 id 在系统端可能已经失效。这种情况建议在用户点击前重新拉取一次设备列表而不是用缓存的数据。4.2 切换后没声音或者仍然走旧设备这个问题最常见的原因是调用时机不对我在前面已经强调过。另一个容易被忽略的是系统路由策略仍然会管理底层音频焦点如果你在切换前没有正确获取音频焦点目标设备可能被系统静音。可以用 AudioManager.requestAudioFocus 获取一个短暂的音频焦点val focusResult audioManager.requestAudioFocus( null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN )这个操作在正常情况下不是必须的因为多数播放器在创建 MediaPlayer 时已经同时处理了音频焦点。但如果你做的是极简 Demo忘了获取焦点在部分国产 ROM 上就会出现切到蓝牙后声音很小甚至没声音的现象。定位思路用 adb shell dumpsys audio 查看当前路由策略确认 Active 的设备 ID 是不是你想要的那个。还有一个因素是 AudioAttributes。如果你创建 MediaPlayer 时设置了 setAudioAttributes并且 usage 指定为 USAGE_ASSISTANCE_SONIFICATION 这类特殊场景系统可能仍然按照 usage 的强制策略路由把你的 setPreferredDevice 偏好覆盖掉。所以业务场景里尽量用 USAGE_MEDIA 或 USAGE_GAME。4.3 蓝牙断开后路由跳变问题蓝牙设备断开是音频路由里最折磨人的场景。用户正听着歌蓝牙耳机没电了系统自动把音频路由回扬声器如果此时 App 没有做任何处理音乐会直接从手机外放出来声音还很大体验非常糟糕。处理思路是在 AudioDeviceCallback 的 onAudioDevicesRemoved 回调里判断被移除的设备是不是当前 MediaPlayer.getPreferredDevice() 指向的设备。如果是立刻回调 UI弹出一个提示气泡“蓝牙设备已断开正在切换回扬声器”同时让 MediaPlayer 回到暂停状态等待用户确认后继续播放而不是自动切回扬声器继续放。注意onAudioDevicesRemoved 回调发生在系统路由切换之前还是之后不同 Android 版本行为不一样。Android 16 上我观察到的是先回调回调再切换路由所以你有机会在回调里干预。旧版本上顺序可能相反为了稳妥不要依赖回调时序所有恢复逻辑都以 getPreferredDevice() 的实际状态为准。4.4 Android 16与旧版本行为差异清单做兼容性适配时我整理过一份差异速查表测试机型覆盖了 Android 9 到 Android 16这里挑重点说问题维度Android 12及以下Android 13Android 16蓝牙权限无需动态申请需要 BLUETOOTH_CONNECT需要 BLUETOOTH_CONNECT权限弹窗后设备枚举更慢USB设备枚举设备ID不稳定稍有改善明显改善插拔回调及时切换生效速度慢存在延迟中等较快自动回退策略回退到扬声器部分设备保留上次选择回退更快更倾向默认策略我的建议是所有设备切换操作都以“当前 getDevices 实际返回”为准不要依赖历史上保存的 DeviceInfo 对象。Android 16 的设备 ID 稳定性比旧版好但跨设备恢复时依旧不要用 ID 作为主键。再补一个很多人会忽略的点MediaPlayer.getPreferredDevice() 返回的可能是 null即使你之前调用过 setPreferredDevice。出现这种情况通常是底层 AudioTrack 还没创建或者创建后又因为路由变化被重置了。我在定位问题时习惯在关键位置加一行日志Log.d(TAG, current preferred ${player.preferredDevice?.productName})这行日志能帮你确认到底哪一层把你的偏好弄丢了是 Java 层没设置成功还是底层路由策略覆盖了它。最后说一个我踩过很多次坑的点。如果你在 App 里同时创建了多个 MediaPlayer 实例setPreferredDevice 是各管各的不会互相影响但 AudioManager.registerAudioDeviceCallback 是全局监听多个页面如果都注册了回调一定要在 onStop/onDestroy 里成对注销否则会泄漏 Activity。另外不要在同一时刻对多个 MediaPlayer 调用切换设备底层 AudioPolicyManager 处理并发路由时偶尔会丢弃后到的请求导致某个实例不生效。最稳的做法是维护一个全局播放器管理类所有切换请求串行处理严格按照“停止旧的音频流 → 设置新设备偏好 → 启动新的音频流”这个顺序来。这套逻辑我在 Android 16 上反复验证过稳定可靠。
返回列表