拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Android蓝牙SCO音频路由详解:从API调用到链路切换的完整实践

Android蓝牙SCO音频路由详解:从API调用到链路切换的完整实践

1. 先搞清楚SCO到底是什么:为什么通话音频不能走A2DP

做蓝牙耳机的联动App、对讲App或者车载蓝牙项目的时候,很多人都会撞上一个特别经典的怪现象:手机连着蓝牙耳机,音乐放得正欢,结果通话一接通,声音要么发闷得像隔着棉被,要么干脆就没了。一开始我以为是耳机坏了,换了好几台设备排查,最后才发现问题根本不在硬件,而在音频通道选错了。

这个怪现象的背后就是SCO和A2DP这两条链路在起作用。A2DP(Advanced Audio Distribution Profile)承载的是多媒体音频,音质好、带宽高,适合听歌;而SCO(Synchronous Connection-Oriented)是同步面向连接的链路,承载的是语音通话。Android系统里,SCO链路几乎只被HFP(Hands-Free Profile)和HSP(Headset Profile)这两个蓝牙Profile使用,专门用来传输通话语音。

1.1 蓝牙音频的两条“路”:A2DP与SCO/HFP的本质区别

很多初学者对蓝牙音频的理解是“连上了就有声音”,但这句放到通话场景里是不成立的。A2DP和SCO在设计目标上就是两套东西:

  • A2DP:异步无连接通道,数据量大,能传输44.1kHz/48kHz的高质量立体声音频,延迟相对较高,强调的是音质和带宽。
  • SCO:同步面向连接通道,固定时隙保留带宽,延迟低、实时性强,但采样率低,典型的是8kHz(CVSD编码)或16kHz(mSBC编码,即宽带语音),强调的是通话稳定性和实时性。

打个比方,A2DP像是走宽阔的高速公路,货车多、速度慢但运送量大;SCO像是专门给急救车留的专用车道,固定时段清空,保证快速直达,但能运送的东西有限。通话语音对实时性要求极高,不能容忍A2DP那套重传机制带来的延迟抖动,所以必须走SCO这条专用通道。

但问题来了,在Android里,想让系统把通话音频切到SCO这条“专用车道”上,并不是插上耳机就自动发生的。连接蓝牙耳机只是建立物理连接,而“让系统把语音路由到SCO”是另一套独立机制,这就是标题里“从API调用到音频路由切换”这句话的核心。

1.2 通话场景对音频通道的特殊要求

在日常App开发中,很多业务场景其实都在和SCO打交道,只是你可能没意识到:

  • VoIP通话类App(微信语音、钉钉通话、自研对讲App)
  • 蓝牙耳机的按键接听、挂断联动
  • 智能语音助手的前端拾音
  • 外接蓝牙耳麦的录音、PTT对讲
  • 车载系统的电话通道切换

这些场景的共同特点是:音频必须是全双工的,要同时上行(麦克风采集)和下行(扬声器播放),而且要尽可能地低延迟。A2DP本身是单向的,虽然有一些变通方案(比如A2DP Source/Active),但Android生态里并没有把它用作通话双向语音的标准做法。

所以在开发一个通话类App时,我通常会要求团队里每个人先建立这个认知:蓝牙连上了不等于通话音频路由成功了,必须要走一层额外的“申请”动作,把系统的音频策略切到通信模式并激活SCO,语音才能真正走蓝牙耳机。这一层申请动作,就是API调用链路要解决的事情。

2. 从API层面看SCO的完整调用链路:不是调一个方法那么简单

当我第一次接到蓝牙通话需求时,看到AudioManager里有startBluetoothSco()和stopBluetoothSco()两个方法,心想这不就搞定了吗?结果真正跑起来才发现事情远没有这么简单。

完整的SCO调用链路,我习惯把它拆成四个阶段:权限准备 → Profile连接 → 模式切换 → SCO激活。任何一个阶段没到位,SCO都建立不起来,或者建立了也路由不过去。

2.1 阶段一:权限准备,少了BLUETOOTH_CONNECT一切免谈

先看权限,这部分在不同Android版本上的差异很大,也是最容易在安装包上线后被用户投诉“功能异常”的地方。

Android 12(API 31)之后,蓝牙相关的运行时权限被大幅拆分:

Android版本需要声明的权限权限类型
API 31+BLUETOOTH_CONNECT、BLUETOOTH_SCAN运行时权限,必须动态申请
API 29-30BLUETOOTH、BLUETOOTH_ADMIN、ACCESS_FINE_LOCATION普通权限 + 定位权限
API 28及以下BLUETOOTH、BLUETOOTH_ADMIN普通权限

这里有个非常容易踩的坑:MODIFY_AUDIO_SETTINGS这个权限。它本身是普通权限,在Manifest里声明一下就行,但如果漏掉,setBluetoothScoOn(true)会有概率在部分ROM上静默失败,没有任何异常抛出来,就是声音出不来。

在API 31+下,我强烈建议把BLUETOOTH_CONNECT当成一个必须动态请求的权限来处理。因为SCO涉及的API——不管是BluetoothHeadset的startVoiceRecognition()还是AudioManager.getDevices()——在Android 12上都需要这个权限,否则会直接抛SecurityException。我见过不少线上问题,用户反馈“蓝牙耳机没声音”,最后查下来就是权限没申请,代码逻辑在try-catch里吞掉了异常。

2.2 阶段二:Profile连接,拿到BluetoothHeadset才算“入会”

SCO链路的主人是BluetoothHeadset这个Profile Proxy,而不是BluetoothDevice本身。要操作SCO,必须先通过BluetoothAdapter.getProfileProxy()拿到BluetoothHeadset的实例。

这里有一个很多人忽略的点:getProfileProxy()是异步的,需要通过BluetoothProfile.ServiceListener回调才能拿到代理对象。而且这个回调时机不受你控制,有时候蓝牙服务忙,几秒钟才回调。我见过有些初级的实现直接在onServiceConnected外面去调startVoiceRecognition(),结果拿到的是null,然后整个逻辑就崩了。

正确的做法是把Profile连接作为一个独立状态机来处理:

private val serviceListener = object : BluetoothProfile.ServiceListener { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { if (profile == BluetoothProfile.HEADSET) { headsetProxy = proxy as BluetoothHeadset // 此时才具备调用startVoiceRecognition/connectAudio的前提 onHeadsetReady() } } override fun onServiceDisconnected(profile: Int) { if (profile == BluetoothProfile.HEADSET) { headsetProxy = null } } } bluetoothAdapter.getProfileProxy(context, serviceListener, BluetoothProfile.HEADSET)

2.3 阶段三:模式切换,setMode(MODE_IN_COMMUNICATION)是关键中的关键

很多人在这里开始迷失方向。就算你拿到了 Headset Proxy,也申请了权限,如果AudioManager的模式不对,SCO照样起不来。

Android的音频路由策略是分模式的。在MODE_NORMAL或MODE_RINGTONE下,系统对SCO路由的优先级很低,甚至拒绝路由;只有在MODE_IN_COMMUNICATION模式下,系统才会优先把语音路由到SCO设备。

所以调用链路的第三步一定是:

audioManager.mode = AudioManager.MODE_IN_COMMUNICATION

这一步的意义是告诉底层音频策略:当前App处于通信场景,请允许语音走SCO。如果不先切模式直接调startBluetoothSco(),在部分设备上SCO还是能建立,但音频不会自动切换到蓝牙,声音从听筒出来,用户就会觉得“耳机白带了”。

2.4 阶段四:SCO激活,两条路怎么选

到这一步,系统才真正开始建立SCO链路。Android提供了两条API路径:

路径一:AudioManager.startBluetoothSco() / stopBluetoothSco()

这是最传统的做法,在API 31之前一直被广泛使用。它内部会向音频策略发起SCO激活请求,系统再去底层蓝牙协议栈建立SCO连接。

路径二:BluetoothHeadset.startVoiceRecognition() / stopVoiceRecognition()

这实际上是HFP协议层面的AT指令下发,相当于让耳机端进入语音识别/通话状态,会同时触发SCO链路的建立。

两条路径在很多场景下可以混用,但我的建议是:主用的还是AudioManager.startBluetoothSco(),因为在音频路由层面它更可靠;BluetoothHeadset的connectAudio()方法也可以在拿到proxy之后用于主动建链,它触发的是AT+BVRA或类似指令,能保证耳机端的联动状态一致。

API 31之后,官方引入了AudioManager.setCommunicationDevice()来统一管理通信设备路由,但它是新接口,需要做兼容。我自己的做法是封装一层:API 31+走新接口,API 30及以下走旧的startBluetoothSco()+setBluetoothScoOn(true),保证老设备也能正常用。

3. 音频路由切换:当SCO状态变化时,AudioManager那边到底发生了什么

每次讲到SCO,很多人都有一个误解:以为调用了startBluetoothSco()之后,声音立刻就从蓝牙耳机出来了。实际完全不是这样——这个调用是异步的,它只是提交了一个路由切换的“申请”,真正的切换要等底层蓝牙协议栈完成SCO连接建立,音频策略才会把路由指过去。

理解这个过程中AudioManager内部发生的事情,对排查问题非常有帮助。

3.1 音频路由的“指挥官”:AudioPolicy说了算

Android的音频框架里,AudioFlinger是底层混音和路由执行者,AudioPolicyManager才是真正的路由决策者。当startBluetoothSco()被调用后,上层会经过 AudioService,最终通知到AudioPolicyManager,它会根据当前的音频模式(Mode)、设备可用状态、策略优先级,决定是否把设备切到AudioDeviceType.TYPE_BLUETOOTH_SCO。

这个决策过程不是瞬时完成的,中间涉及:

  1. 检查SCO设备是否可用(底层蓝牙是否有已连接的HFP设备)
  2. 检查当前AudioMode是否允许切SCO(所以前面强调必须切MODE_IN_COMMUNICATION)
  3. 停止当前A2DP播放入口,把焦点让给语音通道
  4. 向底层AudioFlinger下发设备切换指令

所以如果你在调用startBluetoothSco()之后立刻去读AudioManager.isBluetoothScoOn(),大概率还是false,这是正常的,要给系统一点时间。

3.2 系统广播:判断SCO真的连上了的唯一依据

判断SCO是否真正建立成功,靠的不是自己猜,而是监听系统广播。

我会在代码里注册两个广播接收器:

  1. BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED
  2. AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED

第一个广播由蓝牙Profile层发出,反映的是蓝牙耳机端的音频连接状态:

<action android:name="android.bluetooth.headset.profile.action.AUDIO_STATE_CHANGED" />

对应状态取值:

  • BluetoothHeadset.STATE_AUDIO_CONNECTING
  • BluetoothHeadset.STATE_AUDIO_CONNECTED
  • BluetoothHeadset.STATE_AUDIO_DISCONNECTED

第二个广播是AudioManager层面发出的SCO状态通知:

<action android:name="android.media.ACTION_SCO_AUDIO_STATE_UPDATED" />

对应的Extra是AudioManager.EXTRA_SCO_AUDIO_STATE,取值是EXTRA_SCO_AUDIO_STATE_CONNECTED、EXTRA_SCO_AUDIO_STATE_DISCONNECTED、EXTRA_SCO_AUDIO_STATE_CONNECTING。

正常情况下,两者会先后到达:先是蓝牙协议栈层面报告CONNECTING,然后音频策略层面报告CONNECTED。只有当这两个广播都走到CONNECTED状态,才能确定音频路由真的切到了SCO。我一般会在收到EXTRA_SCO_AUDIO_STATE_CONNECTED之后才通知UI层“通话音频已就绪”,避免用户对着还没建立好的通道说话。

3.3 路由切换的时机:setBluetoothScoOn真正的作用

再补充一个特别容易被误解的API:setBluetoothScoOn(boolean)。很多老代码会在调startBluetoothSco()之后立刻setBluetoothScoOn(true),以为这样声音就能走蓝牙。其实startBluetoothSco()本身就包含了SCO路由的意图,setBluetoothScoOn(true)更多是一个“强制路由开关”,告诉音频系统“即使没有音频流正在播放,也把路由保持在SCO”。

它的实际价值出现在一个边界场景:当你的App有上行录音需求,但下行暂时没有播放任何声音时,系统可能因为“没有音频流”而自动关闭SCO。此时提前setBluetoothScoOn(true)可以防止路由被回收。

我在做对讲机App时就深刻体会过这一点。只调startBluetoothSco()而不setBluetoothScoOn(true),一旦静音2秒以上,部分手机会自动把SCO断掉,再开口说话时前半句就丢了。加了这个开关之后,SCO链路会一直保持,直到你显式stopBluetoothSco()或断开设备。

4. 实战踩坑记录:SCO链路上最容易翻车的五个环节

光看文档和API说明,很容易觉得SCO链路不算复杂。但真的搬到线上,各种奇怪问题会接踵而来。这一节我把这些年踩过的坑集中梳理一遍,基本上覆盖了SCO场景下最顽固的几个问题。

4.1 isConnected不等于isAudioConnected

这是最经典的一个坑。BluetoothHeadset.isConnected(device)返回true,只代表HFP Profile层建立了连接,耳机可以作为通话设备使用。但不代表SCO音频链路通着。

SCO链路建立是独立的,需要显示触发。我在调试一个蓝牙耳麦项目时,产品经理一直说“耳机连上了但没声音”,开发查了半天,最后发现代码里只检查了isConnected,然后就直接播放音频了,根本没有触发SCO建立。音频自然就继续走扬声器或者听筒。

正确的状态判断应该是:

  • 判断设备是否连接:BluetoothHeadset.isConnected(device)
  • 判断SCO音频是否建立:监听AUDIO_STATE_CONNECTED或EXTRA_SCO_AUDIO_STATE_CONNECTED
  • 判断当前路由设备:AudioManager.getDevices(GET_DEVICES_OUTPUTS)中是否包含TYPE_BLUETOOTH_SCO

4.2 SCO建立失败:只连上A2DP的设备不具备通话能力

再一个高频问题是,有些蓝牙设备确实连上了,但它只支持A2DP和AVRCP,不支持HFP/HSP。这种设备根本没有SCO能力,你怎么调API都白搭。

这种情况在老式蓝牙音箱或者HC-05这类经典蓝牙模块上特别常见。HC-05模块本质上是SPP(串口)模块,压根没有HFP Profile,玩家们常问“HC05蓝牙模块连接不上”“能不能用来做语音通话”,答案都是否定的——它根本不在SCO的能力范围内。

所以在我现在的实现里,第一步就会做Profile能力检查:

if (!bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEADSET) .equals(BluetoothProfile.STATE_CONNECTED)) { // 设备不支持HFP或未连接HFP,没必要往下走了 return }

这个检查能过滤掉一大半“声音出不来”的无效问题,也建议同行在做蓝牙通话前先做一版设备能力自检工具。

4.3 通话结束后的路由“卡死”

这是一个很隐蔽的时序问题。当SCO断开时,如果音频路由没有正确复位到听筒或者扬声器,就会发生“通话结束之后,系统声音也没了”的现象。

原因是这样的:stopBluetoothSco()只是停止了SCO建立请求,但如果你没把AudioManager.mode从MODE_IN_COMMUNICATION恢复成MODE_NORMAL,系统的音频策略仍然会认为你在通信场景,路由策略就不会恢复默认。广播、通知音就会继续往SCO设备上送,此时SCO又已经断了,于是声音就消失了。

我的处理是:把SCO断开流程做成一个标准的“逆序操作”:

  1. audioManager.stopBluetoothSco()
  2. audioManager.isBluetoothScoOn = false(注意这是个get/set方法,直接置false就行)
  3. 将audioManager.mode恢复为MODE_NORMAL
  4. 释放音频焦点(如果申请过)

其中第3步最容易漏,也最致命。我甚至建议在onDestroy、onPause以及通话结束回调里都做一次兜底复位,保证任何异常路径下路由都不会卡死。

4.4 第三方App抢占音频焦点导致SCO被夺走

这件事发生在一次线上故障排查中:用户用耳机听歌正常,但一打开某个语音社交App,耳机声音就消失了,过一会儿又恢复。

根因是那个App在建立SCO的同时申请了AudioManager.AUDIOFOCUS_GAIN,抢占式音频焦点导致我的App被系统强制暂停播放。而在我这个App里,播放线程没有实现对音频焦点丢失的响应,也没有在SCO建立后重新requestAudioFocus,结果就是路由被抢走、播放也停了。

现在的做法是:申请音频焦点时统一用AUDIOFOCUS_GAIN_TRANSIENT,并监听OnAudioFocusChangeListener。一旦收到AUDIOFOCUS_LOSS_TRANSIENT,就主动stopBluetoothSco()释放路由;收到AUDIOFOCUS_GAIN后再重新建立SCO链路。这样至少不会被第三方应用搞到“永久失声”。

4.5 targetSdk 31之后的权限迁移

如果你的App之前一直支持到Android 11,突然升到targetSdk 31,很多蓝牙通话功能会直接报废。原因就是前面提到的运行时权限拆分。

最典型的报错是:

SecurityException: Need BLUETOOTH_CONNECT permission for AttrId:

这个异常是被强制要求的,没有商量的余地。我的兼容处理方式是做一个权限工具类,在API 31+的动态请求BLUETOOTH_CONNECT,在API 30及以下请求定位权限和蓝牙普通权限,完整跑通整个SCO链路之前先做一次权限自检,杜绝“权限缺失导致功能静默失败”的情况。

5. 从零搭建一套SCO通话链路的代码模板与调试方法

讲了这么多原理和坑,最后放一套我这边沉淀下来的可运行模板,外加调试方法。这套模板在市面上主流的手机上验证过,基本覆盖了各种兼容性补丁。

5.1 核心封装类:ScoAudioRouter

我习惯把SCO链路封装成一个单例,对外暴露三个方法:startSco()、stopSco()、isScoActive(),内部自己管理状态广播和回调,这样业务层不用关心复杂的API调用顺序和状态机。

class ScoAudioRouter(private val context: Context) { private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager private var scoConnected = false private val scoReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED -> { val state = intent.getIntExtra( AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.EXTRA_SCO_AUDIO_STATE_DISCONNECTED ) when (state) { AudioManager.EXTRA_SCO_AUDIO_STATE_CONNECTED -> { scoConnected = true audioManager.isBluetoothScoOn = true } AudioManager.EXTRA_SCO_AUDIO_STATE_DISCONNECTED -> { scoConnected = false audioManager.isBluetoothScoOn = false audioManager.mode = AudioManager.MODE_NORMAL } } } } } } fun startSco() { // 1. 先切通信模式,这是路由切换的前提 audioManager.mode = AudioManager.MODE_IN_COMMUNICATION // 2. 注册广播,等待真正的SCO音频状态回执 context.registerReceiver( scoReceiver, IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) ) // 3. API 31+ 优先走新接口,否则走旧接口 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val scoDevice = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) .firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO } if (scoDevice != null) { audioManager.setCommunicationDevice(scoDevice) } } else { @Suppress("DEPRECATION") audioManager.startBluetoothSco() } } fun stopSco() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { audioManager.clearCommunicationDevice() } else { @Suppress("DEPRECATION") audioManager.stopBluetoothSco() } audioManager.isBluetoothScoOn = false audioManager.mode = AudioManager.MODE_NORMAL try { context.unregisterReceiver(scoReceiver) } catch (e: IllegalArgumentException) { // receiver 未注册,忽略 } scoConnected = false } fun isScoActive(): Boolean = scoConnected }

调用时机上有一点必须注意:startSco()要在UI线程调用,但不能在Activity的onCreate里直接调完就去录音播放,一定要等到广播回调里EXTRA_SCO_AUDIO_STATE_CONNECTED之后再做真正的音频操作。我在模板里加了一个scoConnected标志位,业务层通过isScoActive()判断链路是否就绪,就避免了“没连上就说话”的尴尬。

5.2 用日志和设备状态表验证整条链路

链路通不通,不能靠感觉,要靠日志和状态变化来验证。我自己调试时会在日志里输出一张“ADC状态表”,把每次AudioDeviceCallback回调的设备列表变化都记录下来:

audioManager.registerAudioDeviceCallback(object : AudioDeviceCallback() { override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) { addedDevices.forEach { device -> Log.d("ScoRouter", "Device added: type=${device.type}, product=${device.productName}") // 类型为TYPE_BLUETOOTH_SCO,说明SCO设备已经进入音频路由候选列表 } } override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) { removedDevices.forEach { device -> Log.d("ScoRouter", "Device removed: type=${device.type}, product=${device.productName}") } } }, null)

调试时需要的状态项包括:

状态项判断依据预期结果
HFP Profile已连接BluetoothHeadset.getConnectionState()STATE_CONNECTED
SCO音频已建立ACTION_SCO_AUDIO_STATE_UPDATED广播EXTRA_SCO_AUDIO_STATE_CONNECTED
当前路由设备getDevices(GET_DEVICES_OUTPUTS)包含TYPE_BLUETOOTH_SCO
音频模式AudioManager.getMode()MODE_IN_COMMUNICATION
耳机端状态HFP AT指令日志+CIEV: callheld,+CIEV: call等

我在测试时会在手机和蓝牙耳机两边同时打日志。手机侧用Android Studio的Logcat,耳机侧如果有抓取HCI日志的能力(部分开发板支持),可以直接看到底层是否有SCO Connection Complete事件。两边一对,问题出在哪一层就很清楚了。

5.3 最后分享一个调试习惯

我在实际开发时发现一个规律:SCO链路的bug,90%不是出在API调用本身,而是出在时序和状态同步上。所以我在代码里会建立一个“状态冗余检查”机制——所有对外暴露的接口都先检查当前状态是否符合前置条件,比如在startSco()之前先确认设备支持HFP、确认权限已授权、确认没有其他App占用SCO链路,不符合就直接抛出明确的错误码,而不是让调用方糊里糊涂地等广播。

这种设计在多人协作的项目里特别有用,因为它把SCO建链的逻辑收敛到一个类里,其他人不需要理解复杂的协议背景,只需要看我定义的几个状态接口。如果你也在做蓝牙通话、对讲或者车机互联相关功能,建议把这套思路直接搬到自己的项目里,能省下大量排查线上问题的时间。

返回列表