Android AudioManager实战指南:音频焦点、音量控制与设备路由

发布时间:2026/9/16 4:09:26
Android AudioManager实战指南:音频焦点、音量控制与设备路由 当你的 APP 里传出一段音频却发现游戏背景音没有被压低或者蓝牙耳机明明连着、声音却还是从手机外放出来——这时候Android 开发者们最常翻出来的一个类就是 AudioManager。它几乎是 Android 音频管理里绕不开的系统服务也是“音频管理”这个话题下最核心的一把钥匙。这篇文章会把 AudioManager 从获取实例、控制音量、管理音频焦点到设备路由切换、免提通话的种种细节全部梳理一遍既适合刚接触音频开发的初学者也适合已经写过几个音频功能但总被怪毛病缠住的朋友。我做了几年音视频相关的开发AudioManager 算是每天都得打交道的对象之一。它不像 Camera2、MediaCodec 那样自带滤镜光环大多数人刚接触时觉得它不过是个“调音量的工具”但真正深入下去才会发现音量调节只是它的九牛一毛。从一个 app 的声音能不能顺利播出来到和其他应用同时播放时谁该让步再到听筒、扬声器、有线耳机、蓝牙耳机的自动切换背后全是 AudioManager 在协调。这篇文章我打算按自己的理解把 AudioManager 的几个关键能力拆开来讲最后再分享一些踩过多次坑之后沉淀下来的经验。1. 为什么几乎所有音频问题都指向 AudioManager1.1 名字叫“管理器”实际上是一整层音频策略第一次见 AudioManager 的人很容易把它理解成一个简单的“音量调整类”因为 Android 官方文档里最显眼的几个方法就是setStreamVolume、getStreamVolume、adjustStreamVolume。但实际上AudioManager 是应用层和底层音频服务之间的一条通道它处理的不是某一段音频数据的编码解码而是“声音应该怎么被播放、被谁播放、以多大音量播放、要不要让别的应用降低声音”这一整套策略问题。我通常会打一个比方MediaPlayer、ExoPlayer 这些类是“演员”它们负责把音频内容演出来AudioManager 则是“导演”和“舞台调度”它决定演员什么时候上场、灯光给多少、另一个演员在场时谁先发声。很多人 debug 音频问题时会陷入误区总觉得是播放器出了问题反复检查 MediaPlayer 的 prepare、start 流程结果最后发现是 AudioManager 那边的音频焦点没拿到或者 streamType 用错了声音根本不被系统认可。从 Android 系统架构来看AudioManager 往上层提供 Java API往下通过 Binder 调用 AudioService再往下与音频 HAL、AudioFlinger 交互。应用里调用setStreamVolume(STREAM_MUSIC, 5, 0)时这个请求会一路传递到系统服务经过权限校验、流类型映射、设备路由计算后才会真正影响当前播放音频的衰减值。理解这一条链路比死记几个方法签名重要得多因为很多“为什么我设置了音量没有变小”的问题根源恰恰发生在 AudioManager 管不到的 AudioFlinger 那一层。1.2 日常开发中最容易踩的音频管理场景我自己在项目里见过最多的音频问题大概能分成这么几类。第一类是“音量调了没效果”。很多新手会默认使用STREAM_MUSIC去控制音量但实际播放用的 streamType 可能是STREAM_RING或STREAM_NOTIFICATION。尤其做即时通讯、闹钟、VoIP 语音通话相关的应用不同业务音频走不同的流流不匹配时哪怕系统音量格子动了很多格用户听到的声音大小却纹丝不动。第二类是“我的声音一出来别人的音乐没停”。一个游戏应用播放背景音时如果不去请求音频焦点而是直接让 MediaPlayer start那么完全可能出现游戏音效和音乐播放器同时响的“大杂烩”。很多用户会因此直接卸载应用——这并不是质量问题而是基本的音频交互礼仪缺失。第三类是“切换听筒和扬声器失败”。在做拨号、语音聊天这些功能时经常需要让音频从听筒切到扬声器代码里无非是setSpeakerphoneOn(true)但有时候切过去了声音还是从听筒出。这通常和setMode有关系比如当前 mode 还是MODE_NORMAL系统就不认为你处在一个需要切换音频路由的通话场景里。这些场景的背后都离不开 AudioManager 的几个核心机制。下面我会分模块来说先讲实例获取和 API 地图再深入音量、焦点、模式与路由把每块的关键点和坑位都交代清楚。2. 拿对入口AudioManager 实例与四组核心 API2.1 获取实例时被忽略的一个细节获取 AudioManager 的写法平时特别简单AudioManager audioManager (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);一开始我觉得这行代码没什么好讲的直到有次在项目里发现有人把ApplicationContext和Activity混着用然后出现了一些很奇怪的内存引用问题。虽然 AudioManager 本身是系统单例服务获取多少次返回的都是同一个对象但建议在工具类里用 ApplicationContext 来拿不要持有 Activity 的引用避免不必要的泄漏隐患。还有一个细节是 Android 6.0API 23之后很多和音频相关的操作需要动态权限比如RECORD_AUDIO。但这里有个容易混淆的地方AudioManager的很多方法是系统 API通过Context.getSystemService就能拿到不需要额外权限。可一旦你涉及AudioRecord录音、或者通过getDevices返回的设备信息去申请路由权限情况就不一样了。所以做音频管理功能之前最好把“读取设备信息”和“修改音量”两件事分开捋权限不要把一堆 permissions 全塞进 Manifest导致应用商店审核时被盘问。2.2 四组 API 的能力地图用了一段时间 AudioManager 之后我习惯把它的 API 分成四组来记忆这样比逐个背方法高效很多。第一组是音量控制核心方法包括getStreamVolume、setStreamVolume、adjustStreamVolume、getStreamMaxVolume。这一组服务于所有和音量条有关的业务比如音量键联动、自定义音量滑块、静音模式切换。第二组是音频模式核心方法是setMode(int mode)可传入MODE_NORMAL、MODE_RINGTONE、MODE_IN_CALL、MODE_IN_COMMUNICATION。这组直接影响系统的音频路由策略后续做免提、耳机切换都要靠它。第三组是音频焦点Audio Focus核心方法有requestAudioFocus、abandonAudioFocus旧版本上用 AudioManager 的方法新版本上推荐AudioFocusRequest.Builder来构建请求。这组解决的是“多个应用同时出声时谁说了算”的问题。第四组是设备与路由典型方法有isSpeakerphoneOn、setSpeakerphoneOn、isBluetoothA2dpOn、startBluetoothSco、stopBluetoothSco以及更现代的getDevices(int flags)、registerAudioDeviceCallback。这组用来感知当前的音频输出设备并根据设备类型做不同的业务处理。把这四组功能分清楚后再去看 AudioManager 的源码和文档思路会清晰很多。后面几个章节我会按这四组展开讲每组在实际项目中会踩到的细节和优化方式。3. 音量控制实战流类型、工具类与 UI 同步3.1 为什么调音量必须带 streamTypeAndroid 系统把声音分成了多个“流”Stream常见的包括常量对应场景音量键默认控制STREAM_ALARM闹钟否STREAM_DTMF拨号音否STREAM_MUSIC媒体音乐、视频、游戏是STREAM_NOTIFICATION通知提示音否STREAM_RING来电铃声否STREAM_SYSTEM系统声音否STREAM_VOICE_CALL通话声音否每一个 Stream 都有独立的音量最大值也不一定相同。比如STREAM_MUSIC最大可能是 15STREAM_RING最大可能是 7。所以在做音量控制的时候第一件事就是想清楚你这段声音到底属于哪一类。如果是音乐播放器用STREAM_MUSIC是没错的如果是闹钟类应用应该使用STREAM_ALARM如果是拨号或网络通话大概率是STREAM_VOICE_CALL。有一个非常经典的坑很多应用下载完视频后在通知栏里放了播放控制点播放时用 MediaPlayer 默认的 streamType 去播放而 MediaPlayer 默认使用的流其实就是STREAM_MUSIC所以表面上没问题。但如果某个播放器库内部设置了STREAM_VOICE_CALL就会出现屏幕媒体音量显示为 15实际声音却非常小或非常大因为通话音量是另一套数值。遇到这种情况优先检查播放器初始化的 audioStreamType而不是怀疑硬件坏了。3.2 一个可以直接抄的音量管理工具类有了流类型的基础我们可以封装一个简单的工具类用于读取和设置某个流类型的音量。这段代码我基本每个项目都会用到按自己的习惯写成 Kotlinobject AudioVolumes { private const val TAG AudioVolumes fun getVolume(context: Context, streamType: Int): Int { val am context.getSystemService(Context.AUDIO_SERVICE) as AudioManager return try { am.getStreamVolume(streamType) } catch (e: Exception) { Log.w(TAG, get volume failed, type$streamType, e) 0 } } fun getMaxVolume(context: Context, streamType: Int): Int { val am context.getSystemService(Context.AUDIO_SERVICE) as AudioManager return try { am.getStreamMaxVolume(streamType) } catch (e: Exception) { Log.w(TAG, get max volume failed, type$streamType, e) 0 } } fun setVolume(context: Context, streamType: Int, progress: Int, flags: Int 0) { val am context.getSystemService(Context.AUDIO_SERVICE) as AudioManager try { val max am.getStreamMaxVolume(streamType) val target progress.coerceIn(0, max) am.setStreamVolume(streamType, target, flags) } catch (e: SecurityException) { Log.w(TAG, set volume permission denied, type$streamType, e) } } fun adjustVolume(context: Context, streamType: Int, direction: Int, flags: Int 0) { val am context.getSystemService(Context.AUDIO_SERVICE) as AudioManager am.adjustStreamVolume(streamType, direction, flags) } }注意setVolume里我加了coerceIn这是为了防止传入一个超过getStreamMaxVolume的数字。有些 ROM 在音量超过上限时不会直接崩溃但会返回失败日志很难看。另外flags参数可以控制音量调整时是否显示系统音量条。如果你做了一个自定义的音量条 UI通常传0就好如果想顺势调起系统那个圆滚滚的音量弹窗可以用AudioManager.FLAG_SHOW_UI。这里要特别克制很多业务上并不需要每次都弹系统 UI不然用户会觉得很打扰。3.3 音量键监听与 UI 同步的细节很多时候我们需要让自定义设置页里的 SeekBar 和系统音量实时同步。这里有两个方案一个是在页面里重写Activity.onKeyDown处理音量键另一个是注册AudioManager.OnAudioFocusChangeListener之外还要注册一个ContentObserver监听Settings.System.VOLUME_SETTINGS之类的变化。不过实践中对单个流做实时同步最好的切入点其实是AudioManager的registerAudioDeviceCallback和处理器消息配合但音量变化本身没有统一的广播所以大多数项目还是用Handler轮询或者直接依靠onKeyDown里的逻辑去更新 UI。我自己比较推荐的做法是如果页面里只有一个音量调节条就自己拦截音量键并在onKeyDown里调用adjustStreamVolume再立刻从getStreamVolume拿到最新值刷新 SeekBar。一定不要只把 SeekBar 的进度停在“按音量键之前的旧值”上否则用户按下实体音量键后页面上显示的进度和声音实际大小就对不上了。处理这种同步问题最好的时间点是在onResume里刷新一次在onStop里把回调全部解绑避免内存泄漏。还有一个值得注意的点有些系统会把“静音”和“音量为 0”看成两个状态。在STREAM_RING上尤其明显getStreamVolume返回 0 并不完全等于“静音”因为getRingerMode还区分RINGER_MODE_NORMAL、RINGER_MODE_SILENT和RINGER_MODE_VIBRATE。如果你要做的是“一键静音”功能建议同时处理setStreamVolume和setRingerMode否则可能出现声音条已经拖到 0但铃声还是从听筒隐约传出来的情况。4. 音频焦点多应用声音争夺的最终裁判4.1 焦点机制为什么是“请求-授予-释放”音频焦点Audio Focus是我认为 AudioManager 里最值得花时间理解的一个设计。想象一个场景你在听网易云音乐突然点开了一个带视频广告的 APP广告声音直接响起把音乐盖住了等广告结束音乐又自动恢复。这个“盖住”和“恢复”的行为在 Android 里不是播放器自发完成的而是通过 Audio Focus 这套机制协调的。Android 的音频焦点是建议性但被系统尊重的机制。应用在播放音频之前需要先向系统发起焦点请求。系统根据请求的AudioFocusRequest类型、当前持有焦点的应用的listener决定是授予焦点、拒绝请求还是让现有焦点持有者做一些让步比如降低音量也就是 duck。一般情况下如果两个 app 都遵循 Android 的音频规范那么它们之间的声音冲突就可以被优雅地解决掉。很多国内开发者会问我不请求焦点不也能正常播放声音吗确实能。MediaPlayer.start()不依赖焦点也能出声音。但问题在于其他规范的应用会认为你没有焦点于是它们自己的声音照常播放导致两种声音叠在一起。更关键的是系统可能在后台强制暂停你的应用比如来电时无论你有没有处理焦点铃声都会响起来。但如果你的应用正确处理了焦点丢失回调至少可以在来电时马上暂停自己的背景音乐而不是等用户手动切回去才发现音乐已经播完了一个章节。4.2 请求焦点与释放焦点的完整流程旧版的请求方式是通过requestAudioFocus传入一个AudioManager.OnAudioFocusChangeListener和 streamType 等参数AudioManager audioManager (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); int result audioManager.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); if (result AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { // 开始播放 }Android 8.0API 26之后官方推荐使用AudioFocusRequestAudioAttributes attributes new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build(); AudioFocusRequest focusRequest new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) .setAudioAttributes(attributes) .setOnAudioFocusChangeListener(focusListener, handler) .build(); int result audioManager.requestAudioFocus(focusRequest); if (result AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { // 开始播放 }这里面的关键参数AUDIOFOCUS_GAIN表示你要“长期独占”焦点适用于音乐播放、视频播放还有一些短暂请求比如AUDIOFOCUS_GAIN_TRANSIENT适合播放点击音效或者语音消息这种短音频AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK则是一种“我可以接受被压低音量”的请求非常适合地图导航语音播报。释放焦点时理论上在播放停止时调用abandonAudioFocusRequest就好。但我的经验是释放焦点并不需要每次都在onPause里做。如果用户是按 Home 键临时切走但播放器仍然在后台继续播放那就不应该释放焦点否则别的应用又可以把声音叠上来打乱你正在进行的播放。4.3 焦点被临时打断时的 duck 应对策略焦点请求处理不当最常见的结果就是“两个应用同时出声”。而正确处理焦点丢失回调可以做出很细腻的体验。假设你正在播放音乐系统通知你焦点丢失了监听器会收到AUDIOFOCUS_LOSS、AUDIOFOCUS_LOSS_TRANSIENT或AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK三个状态中的一种。我的处理建议如下AUDIOFOCUS_LOSS说明你要失去焦点了通常是有另一个应用要长期占用。此时应立即暂停播放并且清掉焦点监听。不要试图恢复播放除非用户再次主动触发。AUDIOFOCUS_LOSS_TRANSIENT暂时失去焦点比如来电、语音助手启动。这时应暂停播放等收到AUDIOFOCUS_GAIN后再恢复。AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK系统说“你可以继续播但请降低音量”。这就是很多人说的“闪避”。音乐 App 应该把音量降到原来的 20%-30% 左右而不是直接暂停。很多项目里会出现一种错误写法不管是哪种丢失都统一pause()。这样在导航语音播报时音乐忽然暂停等语音结束再恢复用户反而会觉得断断续续而正确的 duck 处理则会把音乐压低一点等语音结束时再把音量拉回来体验顺畅得多。具体实现上可以在OnAudioFocusChangeListener收到AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK时记录当前音量然后把播放器音量调低收到AUDIOFOCUS_GAIN时再恢复记录的音量。如果你用的是 MediaPlayer可以直接用setVolume(left, right)控制左右通道都乘一个衰减系数即可。5. 通话音量与免提切换setMode 和路由的配合5.1 setMode 三种状态的适用场景AudioManager.setMode是我早期做通话功能时被坑得最多的地方。它的取值主要有三种MODE_NORMAL普通模式没有通话音频路由按默认策略走。MODE_RINGTONE来电响铃模式音量走STREAM_RING主要是系统电话在响铃时使用。MODE_IN_CALL通话中通常用于传统 PSTN 电话音频路由可以切到听筒、扬声器或蓝牙。MODE_IN_COMMUNICATION网络通话或录音通信模式VoIP、微信语音、会议类应用都应该使用这个模式。很多开发者搞不清楚MODE_IN_CALL和MODE_IN_COMMUNICATION的区别。简单来说MODE_IN_CALL更像是系统电话在用的模式普通第三方应用最好不要主动占用做 VoIP 通话时应该使用MODE_IN_COMMUNICATION因为它允许你同时处理录音和放音而且对音频路由的切换支持更好。之前我有次做语音聊天一直用MODE_IN_CALL结果切换免提时声音从听筒出不来后来发现自己一开始就应该选MODE_IN_COMMUNICATION。5.2 从听筒切换到扬声器的工程坑切换听筒/扬声器的标准做法是AudioManager audioManager (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); audioManager.setSpeakerphoneOn(true);看上去只有两行但实际过程远没有这么简单。这里有三个非常容易被忽略的点第一调用setMode时必须先获取音频焦点。如果不请求焦点setMode(MODE_IN_COMMUNICATION)有可能会失败尤其当另一个应用正在使用麦克风或占据通信模式时。这里的因果链是系统要确认你有资格更改模式焦点是其中一个判断条件。第二setSpeakerphoneOn(true)之后最好稍等一下再去检查结果因为路由切换可能不会立刻生效特别是背后有蓝牙 SCO 连接时。比较可靠的办法是在切换后做一次短延迟检查看getMode()是否确实变成了MODE_IN_COMMUNICATION再往上拉路由。第三通话结束一定要恢复setMode(MODE_NORMAL)和setSpeakerphoneOn(false)。很多人只在onDestroy里释放焦点忘了把 mode 拉回来结果就是应用退出后系统仍以为你处于通信模式之后播放所有媒体声音的音量策略都会变得诡异甚至某些设备的听筒会持续占用。5.3 蓝牙耳机接入后的模式处理蓝牙音频有两种场景A2DP 用来播放音乐HFP/SCO 用来通话。平时用isBluetoothA2dpOn判断蓝牙耳机是否连接只适用于媒体播放一旦进入通话场景系统走的是 SCO 链路你需要用startBluetoothSco()去建立 SCO 连接通话声音才会转向蓝牙耳机。我在项目里碰到过一个现象手机连着蓝牙耳机正常播放音乐没问题但一进入 VoIP 通话把setSpeakerphoneOn(false)之后对方声音还是从听筒出来蓝牙耳机没声音。原因就是没有在通话前调用startBluetoothSco()系统没有建立 SCO 连接。正确的顺序一般是这样// 进入通话前 audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); audioManager.startBluetoothSco(); audioManager.setBluetoothScoOn(true); // 通话结束 audioManager.setBluetoothScoOn(false); audioManager.stopBluetoothSco(); audioManager.setMode(AudioManager.MODE_NORMAL);注意startBluetoothSco()是异步的调用后需要监听ACTION_SCO_AUDIO_STATE_UPDATED广播等 SCO 真正连接成功后再执行路由设置否则同样会出现切不过去的问题。另外一些手机厂商对setBluetoothScoOn的行为做了定制真机上表现不完全一样所以调试蓝牙通话时强烈建议准备两台不同品牌的设备交叉验证。6. 现代设备路由用 AudioDeviceCallback 代替旧轮询6.1 旧式设备判断为什么越来越不靠谱早期开发经常会写这样的代码if (audioManager.isWiredHeadsetOn()) { // 有线耳机 } else if (audioManager.isBluetoothA2dpOn()) { // 蓝牙耳机 }但现在再这么写问题非常多。一方面isWiredHeadsetOn这个 API 在较新版本上已经被标记为 deprecated行为在不同设备上不一致另一方面USB-C 耳机、LDAC 蓝牙耳机、多设备同时连接等场景越来越多老方法根本没法精确判断“当前音频到底走的是哪个输出设备”。从 Android 6.0 开始系统提供了一套更底层的设备枚举接口AudioManager.getDevices(int flags)可以直接拿到所有可用的AudioDeviceInfo列表。典型用法是AudioDeviceInfo[] devices audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS); for (AudioDeviceInfo device : devices) { int type device.getType(); if (type AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) { // 内置扬声器 } else if (type AudioDeviceInfo.TYPE_WIRED_HEADSET) { // 有线耳机 } else if (type AudioDeviceInfo.TYPE_BLUETOOTH_A2DP) { // 蓝牙耳机 } }getDevices返回的是“当前系统能看到的所有设备”并不代表“当前正在使用的设备”。如果只是判断“有没有插耳机”用 getDevices 是够的但如果要判断“声音现在是不是从耳机出来”还需要看AudioTrack的getRoutedDevice()或者使用AudioRouting接口这又是另一层逻辑了。6.2 用回调监听音频输出设备的变化设备变化监听目前最推荐的方式是AudioDeviceCallbackAudioManager audioManager (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); AudioManager.AudioDeviceCallback callback new AudioManager.AudioDeviceCallback() { Override public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) { for (AudioDeviceInfo deviceInfo : addedDevices) { if (deviceInfo.isSink()) { // 有新的音频输出设备加入 } } } Override public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) { // 设备移除 } }; audioManager.registerAudioDeviceCallback(callback, null);这个回调最大的意义是当用户插入耳机、拔出耳机、连接蓝牙音箱时应用能第一时间感知到并调整 UI 或播放策略。比如常见的“拔掉耳机自动暂停播放”功能就可以在onAudioDevicesRemoved回调里判断被移除的设备是不是TYPE_WIRED_HEADSET或TYPE_BLUETOOTH_A2DP然后暂停播放器。一个需要注意的细节回调里的addedDevices和removedDevices并不总是这个时刻才真正插拔的设备可能系统在注册回调时会把已存在的设备也作为一次“add”回调过来。所以不要在回调里直接做“只要新增耳机就立刻暂停上一个状态”的操作最好先判断deviceInfo.getType()以及当前播放状态再做处理。6.3 输出设备的筛选与业务映射设备路由建议结合业务场景封装一个工具方法比如判断“当前是否应该使用扬声器播放”fun shouldUseSpeaker(context: Context): Boolean { val am context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val outputs am.getDevices(AudioManager.GET_DEVICES_OUTPUTS) for (device in outputs) { when (device.type) { AudioDeviceInfo.TYPE_WIRED_HEADSET, AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_BLUETOOTH_SCO - return false } } return true }这里的思路是有耳机设备存在时默认把声音交还给耳机不要去强制走扬声器因为用户插耳机通常是不想打扰别人。但如果你的应用有独立的“扬声器播放”按钮就要在点击时主动调用setSpeakerphoneOn(true)不然系统默认路由往往会选择最后连接的那个设备。需要注意的是getDevices返回的是所有设备而不是当前活动路由。所以做“强制扬声器播放”时光靠setSpeakerphoneOn(true)可能不够还需要确保AudioTrack或ExoPlayer的audioAttributes设置正确并且没有采用“按设备路由”的强制逻辑。遇到极特殊的情况可以先获取当前AudioTrack的routedDevice再做针对性调整但这通常已经进入音频底层调优的范畴了。7. 积累下来的 5 条 AudioManager 避坑经验7.1 不要直接把 getStreamVolume 当 UI 唯一数据源getStreamVolume返回的是当前流音量值但它并不代表用户最终感知到的响度因为系统可能有音量曲线、单声道混合、均衡器等影响。如果你做的是一个自定义音量条以 0 到 max 的线性进度显示看起来很直观但用户实际听到的“更响/更轻”并不和进度条线性对应。尤其一些设备在低音量段的变化比高音量段更细腻所以如果产品对音量调节的细腻度有要求建议参考系统音量滑块而不是自作聪明做线性映射。7.2 音频焦点请求别在 Fragment onCreate 里做请求音频焦点的时机非常重要。我曾经试过在Fragment.onCreate里就请求焦点希望能提前锁定音频通道。但这时 UI 还没有完全就绪用户还没点击播放按钮如果其他应用同时也在请求焦点有可能导致焦点一直被你的应用占着其他应用被误伤。更合理的做法是真正要开始播放时再请求焦点播放结束后释放焦点。不要提前占着茅坑不拉屎。7.3 处理“拔掉耳机自动暂停”要考虑场景这个功能用户呼声很高但实现时要注意不要一刀切。假如用户正在用扬声器听播客只是临时插了一下耳机又立刻拔掉你的应用如果在onAudioDevicesRemoved里无条件暂停就会让用户非常恼火。比较好的策略是只在“播放状态为 true且移除的是当前路由设备”时才暂停并可以给用户一个通知栏的快速恢复入口。7.4 不同 Android 版本行为差异比想象中大AudioManager 在 Android 5.0、8.0、10.0、12.0 上的行为逻辑一直在变化。比如setMode(MODE_IN_COMMUNICATION)在 Android 10 之后会受到更严格的权限和后台限制AudioFocusRequest是 8.0 之后的新写法Android 12 引入了更细粒度的媒体控制与音频会话迁移。写代码时不要只盯着当前 minSdk 测试最好在几个主流 Android 版本上都跑一遍通话、播放、蓝牙切换的流程否则很可能会发布后收到大量“在某机型上没有声音”的反馈。7.5 让日志成为你排查问题的第一助手AudioManager 相关的问题普遍“现场难复现”所以在你自己的代码里建议在请求焦点、切换模式、设置音量、设备变更回调这几个关键节点都加上日志打上当前getMode()、getRingerMode()、getStreamVolume()、设备列表等信息。这样用户反馈问题时你才能通过日志快速缩小范围。不要嫌日志多音频路由问题最怕的就是“只知道没声音不知道从哪一步开始错的”。AudioManager 这套系统服务学起来不复杂但深入之后确实有不少细节值得琢磨。真实项目里很多声音相关的 Bug 都不是播放器写错了而是没有理解系统音频策略的运作方式。希望这篇偏实战向的梳理能帮你更快定位和解决自己应用里的音频问题。如果你正在做一个对音频体验要求比较高的应用建议把文中的代码片段按自己的业务再封装一层然后认认真真测一遍各种设备和场景下的行为差异。踩过一次坑以后就记住了。