做Android蓝牙开发这几年,真正让我觉得“这东西看着文档简单、落地全是坑”的,SCO通话绝对排得上号。设备明明连上了,耳机也能听歌,可一到通话场景,音频路径像脱缰的野马:有的卡在connecting,有的直接外放,有的SCO一通A2DP就断。我印象很深的一次项目排期,原本预估一周搞定通话功能,结果光音频路由切换就耗了两周,最后翻源码才发现是AudioManager时序和HFP状态机的兼容问题。这篇就把Android蓝牙SCO通话从API调用到音频路由切换的完整链路拆开聊,适合正在做通话App、车载蓝牙、对讲类应用,或者需要自行控制蓝牙音频路由的同学参考,尽量说人话,把文档里不写的细节也讲透。
1. 先把SCO这条链路想清楚
1.1 SCO到底传的是什么
SCO全称是Synchronous Connection-Oriented,同步面向连接链路。它和A2DP音频流是两套完全不同的机制。SCO是经典蓝牙为实时语音专门保留的同步链路,时延低、带宽固定,常见的编码方式是CVSD(连续可变斜率增量调制,速率64kbps),HFP 1.6以后支持WBS宽带语音,也就是mSBC(16kHz采样)。人说话到耳机听到的这个往返延迟必须控制在几十毫秒以内,A2DP那种“攒够一包再发车”的异步缓冲策略根本扛不住,所以HFP协议默认语音就走SCO。
做个不太严谨但好理解的类比:A2DP像快递运输,为了效率会攒批量、走中转,早到晚到一会儿无所谓;SCO像电话专线,每个时隙必须准点到达,晚一步这个包就作废了。蓝牙控制器会在ACL链路基础上再建立一个SCO/eSCO逻辑传输,普通SCO没有重传机制,eSCO则带重传。这个区别直接影响你调试通话音质、偶发断音时的判断方向——如果你在通话中出现明显的丢字、吞音,先想想是不是设备只支持SCO不支持eSCO。
把三条常用链路放在一起看会更直观:
| 链路 | 用途 | 常见编码 | 典型时延 | 重传机制 |
|---|---|---|---|---|
| SCO/eSCO | HFP语音通话 | CVSD、mSBC | 通常小于20ms | eSCO支持,SCO不支持 |
| A2DP | 音乐播放 | SBC、AAC、LDAC等 | 100-300ms | 依赖缓冲和重发包,时延较高 |
| BLE | 低功耗数据 | 无固定语音通道 | 高且可变 | L2CAP层可靠传输 |
1.2 HFP连接和SCO不是一回事
好多刚接触蓝牙的开发把HFP连接和SCO建立当成同一个动作,这一步错了后面全乱。HFP是Hands-Free Profile,它的连接走RFCOMM通道,主要负责AT指令交互,比如接听、挂断、读取电量、切换音频等。SCO是语音数据通道,是HFP连接之后按需建立的。换句话说,手机和耳机HFP连上了,只代表控制通道通了,不代表语音通道已经就绪。
典型流程是这样:设备配对完成后建立ACL链路,做SDP服务发现,找到对端支持的HFP版本;然后建立RFCOMM连接,交换AT+BRSF、AT+CMER这些能力协商指令;等到有来电或者去电时,HFP状态机再驱动两端建立SCO链表传输语音。所以你在应用层看到HFP连接成功,但SCO还在connecting,这是完全正常的中间状态,不是bug。
1.3 方案选型:为什么绕不开SCO
有朋友问过,为什么不能直接用BLE或者A2DP做通话语音?BLE没有同步语音通道,A2DP的时延和缓冲机制又不适合双向实时语音,虽然理论上可以自己封装RTP流走A2DP或L2CAP,但延迟、功耗、设备兼容性都很不可控,尤其是各种耳机对非标准链路的支持参差不齐。做标准蓝牙通话,SCO就是从协议上绕不开的路线。理解了这一点,后续所有API调用和音频路由操作,其实都是围绕“建好SCO链路”和“把系统音频指向这条链路”来做文章。
2. 从API调用到连接建立的完整链路
2.1 应用层到底能碰到哪些API
Android应用层跟SCO相关的API主要集中在三个类:BluetoothAdapter、BluetoothHeadset、AudioManager。
BluetoothAdapter负责最底层的设备操作,比如获取BluetoothHeadset服务代理:
BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); adapter.getProfileProxy(context, profileServiceListener, BluetoothProfile.HEADSET);BluetoothHeadset负责HFP相关的控制,常用的有connect、disconnect、isAudioConnected,以及getAudioRouteAllowed这种很少见、但有的系统定制项目会用到的方法。AudioManager负责音频路由切换,比如setMode、startBluetoothSco(已废弃但还在)、setCommunicationDevice(API 31新增)。
这里有个关键点:直接拉起SCO的隐藏API叫startScoUsingVirtualVoiceCall,在原生AOSP里存在,但Google明确标注为隐藏API,普通应用通过反射调用在多数厂商ROM上会被限制或失效。厂商的ROM经常改这块逻辑,尤其是国内深度定制系统,所以应用层最好不要把手伸到SCO建立这一步,而是让系统电话服务或者HFP状态机去处理。
2.2 系统内部HFP状态机的角色
从API到底层,完整调用链大概是这样的:
- 应用层拿到BluetoothHeadset代理后,通过Binder调用HeadsetService。
- HeadsetService内部维护一个状态机,根据AT指令和音频请求切换连接态与音频态。
- 蓝牙协议栈的BTIF层收到请求后,组装AT指令,比如AT+CMER、AT+BRSF、AT+CHLD。
- 指令通过RFCOMM发到对端耳机/车机,对端同意后,控制器会在ACL基础上建立SCO/eSCO逻辑传输。
- 底层语音数据开始走同步链路,同时Framework层通过广播通知应用层音频状态变更。
对应用层开发者来说,不需要全部看懂,但要清楚一个核心:SCO的建立不是应用层直接发起的,应用层只是“请求”系统去建立,最终结果要通过BluetoothHeadset的广播回调获知。
2.3 代码示例:监听SCO音频状态
最常用的监听方式是注册BroadcastReceiver,监听音频状态变化:
private val scoReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) { val state = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, -1) when (state) { BluetoothHeadset.STATE_AUDIO_CONNECTING -> Log.d("ScoDemo", "SCO建立中") BluetoothHeadset.STATE_AUDIO_CONNECTED -> Log.d("ScoDemo", "SCO已建立") BluetoothHeadset.STATE_AUDIO_DISCONNECTED -> Log.d("ScoDemo", "SCO已断开") } } } }注册方式也值得注意,建议在onStart/onStop里动态注册,不要只依赖静态注册,Android 8以后静态注册的隐式广播大部分已经被限制,动态注册更稳。还要记得在Android 12以上动态申请BLUETOOTH_CONNECT权限,否则Receiver可能收不到任何蓝牙相关回调。
2.4 几个实际操作中容易踩的坑
权限问题是最常见的。Android 12以下需要BLUETOOTH权限,Android 12及以上需要BLUETOOTH_CONNECT运行时权限,如果漏了,getProfileProxy返回的代理可能是空的,而且不一定崩,只是静默失败。
proxy的使用时机也很关键。BluetoothHeadset代理不是在getProfileProxy调用后立刻可用的,必须等onServiceConnected回调触发后再去调用connect、isAudioConnected等方法。我看到很多同学在onCreate里直接拿proxy调方法,结果拿到null,排查半天。另外,应用进程被杀后,如果没有重新绑定服务,广播也会收不到,这在做通话保活时要特别小心。
还有一个被忽略的细节:SCO状态广播的action,旧代码里习惯用AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED,新代码更推荐用BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED。两个action触发时机不完全一样,如果要同时兼容多个版本,建议以BluetoothHeadset的为准,AudioManager的作为辅助判断。
3. 音频路由切换的细节与实操要点
3.1 AudioManager的“新旧两代”接口
SCO链路建立起来以后,还需要把系统音频路由到这条链路上,否则就会出现“链路通了但手机还是外放”的问题。这里就是AudioManager的主场。
老的接口是startBluetoothSco和setBluetoothScoOn,这两个方法在Android 5时代很流行,但setBluetoothScoOn是隐藏API,普通应用直接用会报错。startBluetoothSco倒是公开的,但已经废弃,且在不同版本上行为不一致。新版接口是setCommunicationDevice,API 31引入,可以明确指定通信设备为蓝牙SCO设备。
| 功能方向 | 旧方案 | 新方案(API 31+) |
|---|---|---|
| 启动SCO | startBluetoothSco() | 一般交给系统HFP处理 |
| 强制切换路由 | setBluetoothScoOn(true) | setCommunicationDevice(scoDevice) |
| 切换通话模式 | setMode(MODE_IN_CALL) | setMode(MODE_IN_COMMUNICATION) |
| 清除路由设备 | 无明确接口 | clearCommunicationDevice() |
我的建议是:如果你做的是普通应用,不要在旧接口上死磕;如果目标设备升级到了Android 12及以上,直接用setCommunicationDevice,配合MODE_IN_COMMUNICATION,这套组合在原生系统上车机、手机、耳机的兼容性都相对稳定。如果是老设备存量市场,再退回到startBluetoothSco加setMode的旧方案,但要做好兼容性测试。
3.2 音频焦点与模式切换的时序
很多SCO音频出问题,不是链路没建立,而是时序乱了。比较稳妥的操作顺序是这样:
- 先请求音频焦点,使用AudioAttributes.USAGE_VOICE_COMMUNICATION,这个用途类型本身就表示要使用通信音频路径。
- 把AudioManager的模式切到MODE_IN_COMMUNICATION。
- 触发SCO建立(在系统应用里调用startScoUsingVirtualVoiceCall,普通应用则是模拟一次通话请求)。
- 等SCO状态变为CONNECTED后,再开始AudioTrack播放或打开录音。
- 结束通话时,先停止播放/录音,再断开SCO,最后恢复MODE_NORMAL,释放音频焦点。
这个顺序里最容易出错的是第4步。如果SCO还没连接就启动AudioTrack,音频数据可能走了扬声器或者A2DP路径,等SCO好了再切过来,会出现开头一句话丢失或者“咯哒”一声爆音。实测过程中,把播放启动放在SCO_CONNECTED回调之后再执行,能明显减少首包丢失。
3.3 SCO音频状态机都经历了什么
SCO本身有状态机,理解它有助于定位异常。常见状态包括:
| 状态 | 含义 | 常见进入方式 |
|---|---|---|
| STATE_AUDIO_DISCONNECTED | 无语音链路 | 初始状态、通话结束后 |
| STATE_AUDIO_CONNECTING | 正在建立SCO | 来电去电、主动请求语音链路 |
| STATE_AUDIO_CONNECTED | 语音链路已建立 | 协商完成,底层同步传输建立 |
| STATE_AUDIO_DISCONNECTING | 正在断开SCO | 挂断、远端断开、应用主动释放 |
如果状态卡在CONNECTING长时间不动,大概率是对端设备没响应,或者当前HFP连接已经异常。此时可以试着先断开HFP再重连,而不是直接反复调用SCO建立接口,反复调用反而会让协议栈状态机更混乱。
3.4 采样率、编码协商这些细节
SCO音质不佳,很多时候是编码协商的锅。CVSD是8kHz采样,语音听起来发闷,类似打电话的老式固话感;mSBC是16kHz采样,就是所谓的宽带语音,清晰度明显提升。HFP 1.6以上支持WBS,但前提是手机和耳机两端都开启,并且系统蓝牙协议栈支持自动协商。
实际项目里,如果发现耳机支持宽带语音但协商结果还是CVSD,可以先查一下系统设置里有没有“HD Voice”或者“宽带语音”这类开关,部分手机默认关闭。另外,mSBC对丢包更敏感,如果在射频环境差的地方,宽带语音的听感反而可能比CVSD更差,这属于正常现象。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
整理一个我实际项目中遇到的常见问题表,方便速查:
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| SCO一直connecting | 蓝牙权限未申请、系统电话占用、对端不支持 | 查看HeadsetService日志,确认HFP版本 |
| SCO建立了但耳机听不到声音 | 音频焦点被抢占或路由未切换 | 用dumpsys audio检查活动路由设备 |
| 通话结束后音乐不恢复 | SCO未完全断开或音频模式未还原 | 确保停止语音、断开SCO、恢复MODE_NORMAL |
| 部分耳机音质发闷 | 协商成了CVSD而非mSBC | 查HFP版本和WBS支持情况 |
| 通话时A2DP断流严重 | SCO抢占带宽,A2DP缓冲加大 | 非SCO问题,考虑调整A2DP的codec优先级 |
4.2 稳定高效的调试命令
遇到SCO相关问题时,建议先抓系统状态,而不是盲目改代码。以下几条命令够用:
# 查看音频路由状态,确认蓝牙SCO设备是否在活动路由里 adb shell dumpsys audio | grep -i "bluetooth" # 查看蓝牙HeadsetService的HFP状态机和音频状态 adb shell dumpsys bluetooth_manager | grep -A 30 -i "HeadsetService" # 查看蓝牙协议栈的日志,定位AT指令交互和SCO建立失败位置 adb logcat -s BluetoothHeadset -s bt_hf -s audio_hw_primary有一种情况很迷惑:应用层能看到SCO_CONNECTED,但是录音和播放都没有声音。这时候先不要排查蓝牙,先检查应用自己有没有拿到音频焦点,以及音频流类型对不对。使用VOICE_COMMUNICATION这个USAGE时,音量键控制的是通话音量,而不是媒体音量,很多开发者没意识到,导致“以为声音没出来,实际只是音量是0”。
4.3 设备和厂商兼容性的几个“玄学”
不同厂商ROM对SCO相关隐藏API的处理差异很大。原生AOSP上能用反射调起来的startScoUsingVirtualVoiceCall,在部分国产ROM上直接抛SecurityException,或者调用了之后没有效果。这种场景下,与其跟系统较劲,不如换思路:要么走系统通话服务,要么用InCallService接听系统来电,让系统自动完成SCO链路管理。
TWS耳机这块也比较特殊。很多TWS耳机在连接后,SCO链路只建立在主耳上,副耳会通过私有协议接收语音。如果应用层或者系统层反复切换SCO状态,可能导致左右耳不同步,甚至一只耳朵声音正常、另一只出现回音或断续。做蓝牙音频测量时要选一款行为标准的头戴式耳机做基准,不要拿TWS做主要验证设备。
车载环境还有回音问题。车机通常把麦克风放在离司机较远的地方,如果通话音量开得太大,对端会听到明显的回音。这不是代码能解决的,更多是靠HFP的echo cancellation协商。调试时可以查一下AT指令里是否带有关联的NR/EC能力,如果没带,基本只能靠手机端的音频后处理硬扛。
5. 写在最后的一点体会
做SCO通话功能,最忌讳把它当成“调一个API”就能解决的问题。它是一整条从蓝牙协议栈到AudioManager到设备硬件协商的完整链路,任何一环掉链子,表现都是“声音不对”或“连不上”。我在实际项目中踩过几次坑之后,最大的体会是:应用层尽量当“告警者”,别当“控制者”,把SCO建链和路由切换的主导权交给系统HFP状态机和InCallService,应用只负责监听状态并给用户反馈。除非你是做系统定制或车机中间件,否则强行在普通App里控制SCO,最后大概率会陷入厂商兼容性的泥潭。如果确实需要自己控制,也请一定按“先请求音频焦点,再切通信模式,等SCO_CONNECTED后再启动音频流”的顺序来,这套流程是我对比了多条路径后验证下来最稳的。希望这篇链路拆解能帮你在下次遇到SCO问题时,快速定位到具体是哪一段出了问题,少走我当年走过的弯路。