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-30 | BLUETOOTH、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。
这个决策过程不是瞬时完成的,中间涉及:
- 检查SCO设备是否可用(底层蓝牙是否有已连接的HFP设备)
- 检查当前
AudioMode是否允许切SCO(所以前面强调必须切MODE_IN_COMMUNICATION) - 停止当前A2DP播放入口,把焦点让给语音通道
- 向底层AudioFlinger下发设备切换指令
所以如果你在调用startBluetoothSco()之后立刻去读AudioManager.isBluetoothScoOn(),大概率还是false,这是正常的,要给系统一点时间。
3.2 系统广播:判断SCO真的连上了的唯一依据
判断SCO是否真正建立成功,靠的不是自己猜,而是监听系统广播。
我会在代码里注册两个广播接收器:
BluetoothHeadset.ACTION_AUDIO_STATE_CHANGEDAudioManager.ACTION_SCO_AUDIO_STATE_UPDATED
第一个广播由蓝牙Profile层发出,反映的是蓝牙耳机端的音频连接状态:
<action android:name="android.bluetooth.headset.profile.action.AUDIO_STATE_CHANGED" />对应状态取值:
BluetoothHeadset.STATE_AUDIO_CONNECTINGBluetoothHeadset.STATE_AUDIO_CONNECTEDBluetoothHeadset.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断开流程做成一个标准的“逆序操作”:
audioManager.stopBluetoothSco()audioManager.isBluetoothScoOn = false(注意这是个get/set方法,直接置false就行)- 将
audioManager.mode恢复为MODE_NORMAL - 释放音频焦点(如果申请过)
其中第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建链的逻辑收敛到一个类里,其他人不需要理解复杂的协议背景,只需要看我定义的几个状态接口。如果你也在做蓝牙通话、对讲或者车机互联相关功能,建议把这套思路直接搬到自己的项目里,能省下大量排查线上问题的时间。