
1. 先搞清楚SCO到底是什么为什么通话音频不能走A2DP做蓝牙耳机的联动App、对讲App或者车载蓝牙项目的时候很多人都会撞上一个特别经典的怪现象手机连着蓝牙耳机音乐放得正欢结果通话一接通声音要么发闷得像隔着棉被要么干脆就没了。一开始我以为是耳机坏了换了好几台设备排查最后才发现问题根本不在硬件而在音频通道选错了。这个怪现象的背后就是SCO和A2DP这两条链路在起作用。A2DPAdvanced Audio Distribution Profile承载的是多媒体音频音质好、带宽高适合听歌而SCOSynchronous Connection-Oriented是同步面向连接的链路承载的是语音通话。Android系统里SCO链路几乎只被HFPHands-Free Profile和HSPHeadset Profile这两个蓝牙Profile使用专门用来传输通话语音。1.1 蓝牙音频的两条“路”A2DP与SCO/HFP的本质区别很多初学者对蓝牙音频的理解是“连上了就有声音”但这句放到通话场景里是不成立的。A2DP和SCO在设计目标上就是两套东西A2DP异步无连接通道数据量大能传输44.1kHz/48kHz的高质量立体声音频延迟相对较高强调的是音质和带宽。SCO同步面向连接通道固定时隙保留带宽延迟低、实时性强但采样率低典型的是8kHzCVSD编码或16kHzmSBC编码即宽带语音强调的是通话稳定性和实时性。打个比方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 12API 31之后蓝牙相关的运行时权限被大幅拆分Android版本需要声明的权限权限类型API 31BLUETOOTH_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之后用于主动建链它触发的是ATBVRA或类似指令能保证耳机端的联动状态一致。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:nameandroid.bluetooth.headset.profile.action.AUDIO_STATE_CHANGED /对应状态取值BluetoothHeadset.STATE_AUDIO_CONNECTINGBluetoothHeadset.STATE_AUDIO_CONNECTEDBluetoothHeadset.STATE_AUDIO_DISCONNECTED第二个广播是AudioManager层面发出的SCO状态通知action android:nameandroid.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真正的作用再补充一个特别容易被误解的APIsetBluetoothScoOn(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_SCO4.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: Arrayout AudioDeviceInfo) { addedDevices.forEach { device - Log.d(ScoRouter, Device added: type${device.type}, product${device.productName}) // 类型为TYPE_BLUETOOTH_SCO说明SCO设备已经进入音频路由候选列表 } } override fun onAudioDevicesRemoved(removedDevices: Arrayout AudioDeviceInfo) { removedDevices.forEach { device - Log.d(ScoRouter, Device removed: type${device.type}, product${device.productName}) } } }, null)调试时需要的状态项包括状态项判断依据预期结果HFP Profile已连接BluetoothHeadset.getConnectionState()STATE_CONNECTEDSCO音频已建立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链路的bug90%不是出在API调用本身而是出在时序和状态同步上。所以我在代码里会建立一个“状态冗余检查”机制——所有对外暴露的接口都先检查当前状态是否符合前置条件比如在startSco()之前先确认设备支持HFP、确认权限已授权、确认没有其他App占用SCO链路不符合就直接抛出明确的错误码而不是让调用方糊里糊涂地等广播。这种设计在多人协作的项目里特别有用因为它把SCO建链的逻辑收敛到一个类里其他人不需要理解复杂的协议背景只需要看我定义的几个状态接口。如果你也在做蓝牙通话、对讲或者车机互联相关功能建议把这套思路直接搬到自己的项目里能省下大量排查线上问题的时间。