☰
Android音频路由实战:用getRoutedDevice锁定录音真实麦克风
2026/10/22 16:40:11 网站建设 项目流程

先问自己一个问题:你的录音 App 正在用哪一个麦克风?很多人的第一反应是“我用的是 MediaRecorder.AudioSource.MIC,那当然就是机身底部那个麦克风”。但现实没这么简单。用户插上 3.5mm 耳机,Android 音频策略会在你毫无感知的情况下把录音输入切到耳机麦克风;用户连接蓝牙耳机,系统很可能又悄悄切去蓝牙 SCO 通道。如果 App 连当前真正生效的输入设备都不知道,界面提示、降噪策略、回声消除开关全都无从谈起。

在 Android 16 这一轮平台更新里,AudioRecord.getRoutedDevice()依然是获取路由设备的正规入口,通过一次调用返回AudioDeviceInfo对象。这篇是 Android 音频系列里关于路由获取的完整记录,我按自己做录音模块的真实流程,把它的定位、用法、监听回调、设备对象解析和真机飞行行为都过一遍,给后面做同类功能的开发者留一份可以“抄作业”的参考。

1. 为什么在录音链路里非要拿到路由设备对象

先讨论一个基本问题:AudioRecord提供的不是“状态”,而是一个持续变化的映射结果。多数 Android 录音代码从头到尾只做两件事:构建实例、读 PCM。你确实通过AudioSource指定了类型,但这只是一个“期望源”,最终由系统音频策略根据当前外设环境决定实际采集通道。它可能变成耳机 mic,可能变成蓝牙 mic,也可能变成 USB 麦克风,而这一切不需要你的 App 参与。

我在做通话录音和语音会议功能时,碰到过三类高频问题,全部绕不开“当前路由设备”:

  • 用户反馈“插上蓝牙耳机后再录,声音从手机机身 mic 进了”,因为某些设备在 SCO 连接建立过程中路由切换不稳定,App 若无法检测到实际路由,就修不了这类 bug。
  • 音频后处理链路的降噪、AGC 参数需要按设备类型调整。内置 mic 和蓝牙 SCO mic 的采样率、通道配置存在明显差异,采集参数如果不跟着路由走,后面算法效果会非常差。
  • 用户侧 UI 提示。能不能给用户一个可见的“正在使用蓝牙麦克风录音”状态,会直接影响通话类 App 的信任感。

现在做这件事的正确 API 就落在AudioRecord实现的AudioRouting接口上。这个接口在 Android 5.0 时代就开始铺垫基本路由能力,到 API 28 之后演进为getRoutedDevice()、setPreferredDevice()和addOnRoutingChangedListener()这套完整方法。简单说,它把音频管线的“实际路由结果”从系统内部暴露给应用层。它和地址 ID 不同——老的getRoutedDeviceId()只给一个数字,你要根据这个数字反向猜设备类型、查产品名,能做的事情非常有限;而现在拿到的AudioDeviceInfo对象,携带设备类型、ID、产品名、地址、采样率、通道数等完整信息,可以直接驱动业务逻辑。

还有一点值得说清楚:getRoutedDevice()返回的是此刻正在真正生效的输入设备,不是“我期望过的设备”,更不是“系统支持的所有设备”。比如设备明明插着 USB 麦克风,但因为应用没有声明对应的录音策略,或者系统在某一阶段还没把 USB 建立为有效输入源,路由结果可能还是内置 mic。你需要它,就是在做一次“事实核对”。

2. 从构建到读取:一套能直接跑的 getRoutedDevice 用例

我先把完整代码放出来。这段代码基于 Kotlin,适用于 minSdk 28 以上的项目,在 Android 16 目标平台上编译运行没有任何问题。

import android.Manifest import android.content.pm.PackageManager import android.media.AudioDeviceInfo import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder import android.os.Bundle import android.os.Handler import android.os.Looper import android.util.Log import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat class RouteDemoActivity : AppCompatActivity() { private var audioRecord: AudioRecord? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.RECORD_AUDIO), 1001) return } startRecordAndReadRoute() } private fun startRecordAndReadRoute() { val sampleRate = 44100 val minBuf = AudioRecord.getMinBufferSize( sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT ) if (minBuf <= 0) { Log.e("RouteDemo", "getMinBufferSize 返回异常: $minBuf") return } val record = AudioRecord.Builder() .setAudioSource(MediaRecorder.AudioSource.MIC) .setAudioFormat( AudioFormat.Builder() .setSampleRate(sampleRate) .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setChannelMask(AudioFormat.CHANNEL_IN_MONO) .build() ) .setBufferSizeInBytes(minBuf) .build() if (record.state != AudioRecord.STATE_INITIALIZED) { Log.e("RouteDemo", "AudioRecord 初始化失败") record.release() return } audioRecord = record try { record.startRecording() } catch (e: IllegalStateException) { Log.e("RouteDemo", "startRecording 失败: ${e.message}") record.release() audioRecord = null return } val routedDevice = record.routedDevice logRouteInfo(routedDevice) } private fun logRouteInfo(device: AudioDeviceInfo?) { if (device == null) { Log.i("RouteDemo", "当前路由设备为空(使用系统默认输入)") return } Log.i("RouteDemo", "当前路由设备: id=${device.id}, type=${device.type}, " + "product=${device.productName}, address=${device.address}") } override fun onDestroy() { super.onDestroy() audioRecord?.stop() audioRecord?.release() audioRecord = null } }

下面逐段说明关键部位。

2.1 构建参数:AudioSource 与 AudioFormat 的关系

setAudioSource(MediaRecorder.AudioSource.MIC)表示“我想要麦克风输入”。这个参数对路由结果有很大影响。比如VOICE_COMMUNICATION会触发系统启用回声消除、自动增益等通话处理链,还会影响某些 HAL 层设备的连接方式,UNPROCESSED则表示希望拿到未处理 PCM。不要仅仅因为你想要的是“原始麦克风”就无脑选MIC。真正的原始采集路径应当用UNPROCESSED,同时在 null 时做降级。也就是说,音频源类型本身也是路由决策的一个输入项,这点在排查“为什么我选 MIC 却拿到了带降噪的 PCM”时特别好用。

setSampleRate(44100)、setEncoding(ENCODING_PCM_16BIT)、CHANNEL_IN_MONO会直接参与设备匹配。有些外接设备不支持高采样率,路由策略会倾向于选择能匹配当前 AudioRecord 配置的设备。因此,如果为了尽量使用高质量外置 mic,优先查询设备并设置一个它支持的采样率区间,会比拍脑袋定 48k 更合理。getMinBufferSize返回的 buffer 大小也要当回事,越小延迟越低,但太小会导致系统底层的缓冲区溢出。通常取返回值的 2 倍以上作为缓冲会更稳,具体在低延迟录音场景里再调。

2.2 startRecording 之后的读取时机

record.routedDevice要放在startRecording()成功之后取,这在真机上非常重要。在尚未启动采集或刚刚调用startRecording的极早期,路由可能还在协商中,此时拿到的值大概率是null。如果你的业务初始化流程里要立刻显示“当前录音设备”,至少要等几个毫秒或者干脆等路由回调事件触发后再刷新 UI,而不要“启动后读一次就再也不管”。

这段代码里我特意把routedDevice读取放在state校验和startRecording之后,就是为 Android 16 以及往前的系统都适用。实际上,AudioRecord的路由状态是异步更新的,官方文档也提醒过路由结果不保证同步。很多人踩的坑是在初始化后立刻打印路由信息,得到 null 还以为是代码写错了。

2.3 返回对象与生命周期管理

record.routedDevice每次调用都会返回一个AudioDeviceInfo?。这个对象不应该被长期缓存——设备热插拔后,老对象虽然还持有旧的 ID 和 type,但已经不代表“当前有效状态”,再拿去做业务判断会出错。我推荐的做法是:每次需要决策时都重新routedDevice拿一次,或者完全依赖回调事件里的最新对象。

AudioRecord的stop()和release()必须成对存在于生命周期清理中,否则下一次录音会发现底层的 HAL 句柄没有释放,轻则启动慢,重则IllegalStateException。这个不是路由 API 的问题,但在调试路由切换时它经常会成为干扰项:表面看是路由变了,实际是旧实例没释放,新实例抢不到输入设备。

3. 路由不会静止:监听切换事件的完整方案

getRoutedDevice()是快照,路由切换则需要事件。Android 的AudioRouting接口里配好了addOnRoutingChangedListener,当系统策略决定把输入从 A 设备切到 B 设备时,监听器会立刻收到回调。这个能力在“插拔耳机”以及“蓝牙连接断开”的时候极其关键,因为录音行为往往不能跟着断掉——用户戴着蓝牙耳机走到厨房,蓝牙偶发断开,App 得马上知道现在已经切回内置 mic,否则后台还在给用户提示“蓝牙录音中”,体验就是事故。

注册监听器的方式如下:

private val routeListener = object : AudioRouting.OnRoutingChangedListener { override fun onRoutingChanged(router: AudioRouting) { val recorder = router as? AudioRecord ?: return val device = recorder.routedDevice val desc = when (device?.type) { AudioDeviceInfo.TYPE_BUILTIN_MIC -> "内置麦克风" AudioDeviceInfo.TYPE_WIRED_HEADSET -> "有线耳机麦克风" AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "蓝牙 SCO 麦克风" AudioDeviceInfo.TYPE_USB_DEVICE -> "USB 麦克风" else -> "类型未知(type=${device?.type})" } Log.i("RouteDemo", "路由切换 -> $desc") runOnUiThread { updateMicIndicator(desc) } } } audioRecord?.addOnRoutingChangedListener(routeListener, Handler(Looper.getMainLooper()))

三个细节我必须反复强调:

第一,Handler参数不能省。系统路由回调不是必然跑在主线程,它来自音频服务端的 binder 回调。如果直接在里面操作 UI,极容易出现崩溃。我默认传入主线程 Handler,让onRoutingChanged内的 UI 更新直接安全执行。当然,你也可以传入后台 Handler,在里面做采样率调整等耗时操作,再把 UI 事件 post 到主线程。

第二,取消注册要匹配同一个对象。必须记住之前的 listener 实例,并在release前调用removeOnRoutingChangedListener(routeListener)。如果你直接 new 一个新 listener 传给 remove 方法,它不会生效,因为系统比对的是对象引用。像下面这种写法就是无效的:

// 错误示范 audioRecord?.removeOnRoutingChangedListener(object : AudioRouting.OnRoutingChangedListener { override fun onRoutingChanged(router: AudioRouting) {} })

第三,Callback 里的router可能不是你的 AudioRecord。场景是:同进程里多个 AudioRecord 都注册了监听,系统路由变化时会一次性通知所有监听器,因此回调参数统一是触发者的 router 引用。代码里我用as? AudioRecord做了安全转换,再过滤掉不属于当前实例的回调。

3.1 插拔耳机场景的实测表现

我做过一次真机验证,流程是:开始录音 → 插入有线耳机 → 拔出耳机 → 连接蓝牙耳机。日志顺序如下:

开始录音,routedDevice=内置麦克风(TYPE_BUILTIN_MIC) 插入耳机,onRoutingChanged -> TYPE_WIRED_HEADSET 拔出耳机,onRoutingChanged -> TYPE_BUILTIN_MIC 蓝牙连接完成,onRoutingChanged -> TYPE_BLUETOOTH_SCO 蓝牙关闭,onRoutingChanged -> TYPE_BUILTIN_MIC

这里有一个容易误判的点:蓝牙连接完成后,TYPE_BLUETOOTH_SCO并不一定立即成为路由。如果蓝牙耳机只建立了 A2DP 播放链路,SCO(通话/录音用的同步面向连接链路)还没有建立,录音路由就不会切过去。有些蓝牙耳机的 SCO 建立需要几百毫秒到一两秒,监听器在这段时间内可能收到多次回调,第一次还是内置 mic,第二次才切到蓝牙。所以路由变化并不是“连接耳机瞬间完成”,App 必须以最后一次回调为准。

3.2 监听线程与性能控制

回调可能很频繁。某些系统在音频焦点变化、铃声响起、语音助手启动等场景都会触发路由重算,而这时的参数变化不一定真的改变最终设备。因此回调里尽量做轻量处理:先比较新旧设备 ID,相同就直接返回,不同才更新界面或重配参数。我在项目里会维护一个lastDeviceId,在回调开头加一行:

if (lastDeviceId == device?.id) return

这条路能挡住大量无效刷新,尤其在同时管理多个 AudioRecord 实例的应用里效果非常明显。

4. AudioDeviceInfo 里到底有什么决策数据

getRoutedDevice()返回的AudioDeviceInfo?是整个链路中最有价值的一块。很多人拿到对象只会打印type,这是浪费。来看它到底能挖出什么。

属性含义典型用途
id设备唯一 ID,整数判断路由是否变化
type设备类型,如TYPE_BUILTIN_MIC分类处理:内置/有线/蓝牙/USB
productName产品名称,CharSequence用户可见的设备名展示
address设备地址,蓝牙 MAC 等区分不同蓝牙设备
isSource()是否可作为输入源过滤有效输入设备
getSampleRates()支持的采样率数组动态配置 AudioRecord
getChannelCounts()支持的通道数数组动态配置声道数
getChannelMasks()支持的通道掩码数组生成 AudioFormat
getEncodings()支持的编码格式数组支持浮点 PCM 的设备选型

我常用的一个工具函数是把它转成可读的设备描述,方便日志排查和 UI 提示:

fun describeInputDevice(device: AudioDeviceInfo?): String { device ?: return "null(系统默认路由)" val typeName = when (device.type) { AudioDeviceInfo.TYPE_BUILTIN_MIC -> "内置麦克风" AudioDeviceInfo.TYPE_WIRED_HEADSET -> "有线耳机麦克风" AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "蓝牙SCO麦克风" AudioDeviceInfo.TYPE_USB_DEVICE -> "USB音频设备" AudioDeviceInfo.TYPE_REMOTE_SUBMIX -> "远程混合输入" else -> "类型${device.type}" } return "id=${device.id}, name=${device.productName}, type=$typeName, " + "address=${device.address}, source=${device.isSource()}" }

4.1 判断“当前是不是蓝牙麦克风”的正确姿势

判断条件看起来应该是device.type == TYPE_BLUETOOTH_SCO。但实测中个别定制 ROM 会把蓝牙音频设备上报为TYPE_BLUETOOTH_A2DP,甚至有少数设备上报TYPE_UNKNOWN但 address 是一个蓝牙 MAC。如果你的录音链路有蓝牙相关的界面提示,就得两条独立特征一起判断:type in (TYPE_BLUETOOTH_SCO, TYPE_BLUETOOTH_A2DP)或address != null && address.startsWith("...")。直接靠单一type判断,覆盖不到那批非标准上报的机型。

4.2 用设备信息反向校验 AudioRecord 参数

我更推荐的做法是,在Builder构建前先问一句:当前路由设备支持什么样的格式。因为routedDevice只有在录音开始后才稳定可取,但设备本身的信息在构建之前也能通过别的入口拿到一部分。真正常见做法是双阶段:先启动时按通用参数构建并读取设备样本率列表,如果发现不匹配,再重新构建一次 AudioRecord,让参数自适应到设备能力上。

举个例子,某款 USB 麦克风最高只到 48kHz,而工程默认设了 96kHz,系统路由策略可能直接不选它作为输入源,反而继续用内置 mic。此时正确做法是通过设备能力数组尝试匹配:如果sampleRates.contains(96000)为 false,回退到 48000。很多“USB 麦克风插入不生效”的问题,根因就在这里——应用层硬编码参数把设备排除了。

5. 我在真机里踩过的几个非预期行为

最好的 API 文档也写不完整真实机型上的“临场发挥”。getRoutedDevice从 API 28 稳定下来之后,我仍然在特定机型上遇到过不少反直觉现象。整理几个值得后续开发者注意的。

5.1 startRecording 后立刻读取,返回 null 是常态

系统路由更新是异步的,及时在startRecording()返回后马上调用 getter,也可能拿到 null。为应对这种情况,我的工具类内部会做一次延迟重试:

Handler(Looper.getMainLooper()).postDelayed({ val device = audioRecord?.routedDevice logRouteInfo(device) }, 200)

200ms 是一个经验值,足够覆盖绝大多数设备的路由建立流程,又不会让用户觉得界面卡顿。在蓝牙耳机这种路由建立较慢的场景,我会改成轮询三次,间隔分别设为 100ms、300ms、800ms,取最终非 null 值作为稳定状态。

5.2 设备类型上报的“厂商兼容性”

我实测过不同厂商设备,关于type上报的差异非常大。最常见的问题集中在这三类:

  • 同一款 USB 麦克风,设备 A 上报TYPE_USB_DEVICE,设备 B 上报TYPE_UNKNOWN,但isSource()都是 true。
  • 3.5mm 耳机的麦克风,有设备上报TYPE_WIRED_HEADSET,还有设备上报TYPE_WIRED_HEADPHONES并让人无法区分是否带 mic。
  • 部分 Android 平板在 HDMI 输出且同时插着采集卡时,输入路由可能上报到TYPE_HDMI而不是 USB。

这类差异没法完全靠 API 名称规避,只能靠灰度测试积累一份“内部设备映射表”。我在工程里集成了按device.id + isSource()判断的兜底逻辑,把 type 当作参考而非唯一依据。

5.3 权限声明不完整,会导致整个模块静默失效

RECORD_AUDIO权限在 Android 6 之后属于运行时权限,但许多人漏掉了一件小事:Android 12 之后,应用如果读取路由且进行蓝牙录音,还需要声明BLUETOOTH_CONNECT权限,并在运行时申请,否则蓝牙设备信息可能被系统过滤掉,routedDevice返回的蓝牙设备上报类型和地址全都不可信。代码里直接拿路由设备去做蓝牙识别前,一定先查这两项运行时权限。

5.4 模拟器上基本拿不到有意义的设备对象

在 Android 模拟器上,routedDevice要么返回 null,要么只返回一个虚拟的TYPE_BUILTIN_MIC。模拟器虽然能在音频层面模拟 PCM 输入,但对外接设备、蓝牙路由的模拟非常有限,你费劲在模拟器上验证路由切换,多半是白费时间。这类功能尽量直接上真机联调,偶数个不同品牌的真机对照着跑一轮,比在模拟器上折腾来折腾去有价值得多。

5.5 多 AudioRecord 实例并存时路由结果互相干扰

同时打开两个 AudioRecord,一个录音,一个做实时音量监测,此时两者各拿各的底层输入。某些 HAL 实现会合并两个请求并把它们路由到不同设备上,第二个实例拿到的routedDevice可能和第一个不一致。排查录音问题时,如果发现设备对不上,先检查是不是同进程里还有别的录音实例在跑。最佳实践是复用同一个 AudioRecord 实例,或者只在主录音实例上注册路由监听器。

6. 把路由信息接进录音链路的几个进阶方向

最后,梳理一下路由信息怎么变成实实在在的产品能力。

6.1 动态切换显示与降噪配置

拿到路由设备后,最直接的是做两件事:UI 显示当前麦克风来源、按设备类型切换音频效果。以语音会议类应用为例:

  • 有线耳机接入:启用 AEC(回声消除)时会使用耳机参考信号,可以获得比免提模式更好的双讲效果。
  • 蓝牙 SCO 接入:通常只能支持 16kHz 或 8kHz 采样,需要立刻把 AudioRecord 的采样率下调到设备支持的范围,避免底噪爆炸。
  • 内置 mic 接入:如果外放同时开着,就要特别注意回声路径,把扬声器音量降低或者强制走免提 AEC 算法。

这套逻辑完全由路由监听驱动,设备一变,处理链自动换挡。

6.2 与系统设备列表 API 搭配使用

AudioManager.getDevices(GET_DEVICES_INPUTS)可以列出所有可用的输入设备,但它不代表“当前生效的设备”。一个很典型的组合用法是:列表页展示所有输入设备供用户选择,用户点选后调用AudioRecord.setPreferredDevice(device)指定偏好,然后通过routedDevice确认系统实际上是否给了你这个设备。如果routedDevice的 id 与用户选择的偏好设备不一致,就说明系统因某种策略原因拒绝了偏好,需要提示用户或回退。

val manager = getSystemService(AUDIO_SERVICE) as AudioManager val inputs = manager.getDevices(AudioManager.GET_DEVICES_INPUTS) val target = inputs.firstOrNull { it.type == AudioDeviceInfo.TYPE_USB_DEVICE } if (target != null) { audioRecord?.preferredDevice = target val actual = audioRecord?.routedDevice // 读 actual 和 target 做比较,确认偏好有没有生效 }

这里的关键认知是:preferred 是“请求”,routed 是“实际”。系统有最终裁量权,应用只能尊重结果。我见过不少团队只在设置界面上写了 preferred 就不再管路由结果,结果某些机型上用户选了 USB 麦克风,录音却一直走内置 mic,这个“选不上”的问题在路由确认之前是发现不了的。

6.3 几个同类 API 的选型对照

刚接触这个领域的开发者容易把下面几个搞混,我放一张对照表:

需要的信息推荐调用返回内容注意点
当前生效的输入设备AudioRecord.getRoutedDevice()AudioDeviceInfo?异步更新,需要监听
应用期望输入设备AudioRecord.getPreferredDevice()AudioDeviceInfo?只是期望,不等于实际
系统所有输入设备AudioManager.getDevices(GET_DEVICES_INPUTS)Array<AudioDeviceInfo>包含未激活设备
旧版路由设备 IDAudioRecord.getRoutedDeviceId()IntAPI 28 后不推荐,用对象 API

从新项目角度,建议新代码统一使用getRoutedDevice+AudioDeviceInfo的组合,不要再碰 ID 形式的旧接口,因为对象形式可以拿到地址、产品名、采样率和通道数,这些参数在做设备兼容时都是刚需,而旧接口给不了。

6.4 路由切换的线程模型与粘性事件

路由监听器在startRecording之前也可能被触发一次,用于通知当前路由初始状态。部分版本在注册监听后不会立刻回调,也没法保证首帧事件一定可达。稳妥的方案是:初始化时先手动读一次routedDevice,然后把路由监听器作为一种“增量修正”手段合入,两者结合才能保住首屏状态准确。把这个习惯养成后,录音链路里的路由问题基本能消灭大半。

我在多个项目里把以上方案沉淀成了一个内部工具类,对外只暴露三个方法:start()、currentInputDescription()、setOnRouteChangedListener()。工程从 Android 9 兼容到 Android 16,路由相关的回归问题几乎没有再出现过。如果你正被“录音到底用的哪个 mic”困扰,按这篇文章的路线把获取、监听、设备解析三步做扎实,后面的事就顺了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询