
先问自己一个问题你的录音 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分类处理内置/有线/蓝牙/USBproductName产品名称CharSequence用户可见的设备名展示address设备地址蓝牙 MAC 等区分不同蓝牙设备isSource()是否可作为输入源过滤有效输入设备getSampleRates()支持的采样率数组动态配置 AudioRecordgetChannelCounts()支持的通道数数组动态配置声道数getChannelMasks()支持的通道掩码数组生成 AudioFormatgetEncodings()支持的编码格式数组支持浮点 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)ArrayAudioDeviceInfo包含未激活设备旧版路由设备 IDAudioRecord.getRoutedDeviceId()IntAPI 28 后不推荐用对象 API从新项目角度建议新代码统一使用getRoutedDeviceAudioDeviceInfo的组合不要再碰 ID 形式的旧接口因为对象形式可以拿到地址、产品名、采样率和通道数这些参数在做设备兼容时都是刚需而旧接口给不了。6.4 路由切换的线程模型与粘性事件路由监听器在startRecording之前也可能被触发一次用于通知当前路由初始状态。部分版本在注册监听后不会立刻回调也没法保证首帧事件一定可达。稳妥的方案是初始化时先手动读一次routedDevice然后把路由监听器作为一种“增量修正”手段合入两者结合才能保住首屏状态准确。把这个习惯养成后录音链路里的路由问题基本能消灭大半。我在多个项目里把以上方案沉淀成了一个内部工具类对外只暴露三个方法start()、currentInputDescription()、setOnRouteChangedListener()。工程从 Android 9 兼容到 Android 16路由相关的回归问题几乎没有再出现过。如果你正被“录音到底用的哪个 mic”困扰按这篇文章的路线把获取、监听、设备解析三步做扎实后面的事就顺了。