16、蓝牙音频(A2DP/HFP)开发:A2DP Profile连接管理、音频焦点处理、HFP通话控制、SCO连接

各位同学,今天我们来聊聊蓝牙音频开发。说实话,这部分是蓝牙开发里最"接地气"的——毕竟用户每天听歌、打电话,体验好不好全看这里。A2DP和HFP这两个Profile,一个管音乐,一个管通话,是蓝牙音频的左右护法。

我个人习惯把蓝牙音频开发分成两条线:一条是数据流(音频怎么传),一条是控制流(连接怎么管、焦点怎么抢)。今天咱们重点讲控制流,数据流部分后面章节会细说。

16.1 A2DP Profile连接管理

A2DP,全称Advanced Audio Distribution Profile,说白了就是"蓝牙听歌协议"。它负责把手机上的立体声音频流,通过蓝牙传到耳机或音箱上。

连接管理这块,我建议你重点关注三个状态:连接建立连接断开连接重连。Android系统里,A2DP的连接状态是通过BluetoothA2dp这个代理类来监控的。

16.1.1 连接状态回调

先看一段核心代码,这是监听A2DP连接状态的广播接收器:

// 注册广播接收器
IntentFilter filter = new IntentFilter();
filter.addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED);
registerReceiver(mA2dpReceiver, filter);

// 接收器实现
private final BroadcastReceiver mA2dpReceiver = new BroadcastReceiver() {
    @Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        if (BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED.equals(action)) {
            int state = intent.getIntExtra(BluetoothA2dp.EXTRA_STATE, 
                                           BluetoothA2dp.STATE_DISCONNECTED);
            BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
            
            switch (state) {
                case BluetoothA2dp.STATE_CONNECTED:
                    // 连接成功,可以开始音频传输
                    Log.d("A2DP", "设备已连接: " + device.getName());
                    break;
                case BluetoothA2dp.STATE_DISCONNECTED:
                    // 连接断开,需要清理资源
                    Log.d("A2DP", "设备已断开: " + device.getName());
                    break;
                case BluetoothA2dp.STATE_CONNECTING:
                    // 连接中,显示加载状态
                    break;
            }
        }
    }
};

嗯,这里要注意:EXTRA_STATEEXTRA_PREVIOUS_STATE两个参数配合使用,可以判断状态变化的方向。比如从STATE_CONNECTINGSTATE_CONNECTED,说明连接成功了。

我的经验: 我曾经在项目中遇到一个问题——连接状态回调有时会延迟几秒。后来发现是因为系统蓝牙服务在后台做了重试逻辑。建议你在UI层不要过度依赖状态回调的实时性,加个超时保护会更稳妥。

16.1.2 主动发起连接与断开

主动连接A2DP设备,需要先获取BluetoothA2dp代理:

BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
BluetoothA2dp a2dp = (BluetoothA2dp) adapter.getProfileProxy(
    context, 
    mProfileListener, 
    BluetoothProfile.A2DP
);

// 连接设备
a2dp.connect(device);

// 断开设备
a2dp.disconnect(device);

你想想看,为什么有时候调用connect()没反应?大概率是因为设备不在已配对列表里,或者蓝牙没打开。我建议你在调用前先检查device.getBondState()是否为BOND_BONDED

16.2 音频焦点处理

音频焦点,说白了就是"谁有资格出声"。Android系统里,多个应用可以同时请求音频焦点,但只有拿到焦点的应用才能播放音频。蓝牙音频场景下,焦点处理尤其重要——你总不希望耳机里同时传出音乐和导航语音吧?

16.2.1 请求音频焦点

Android从8.0开始推荐使用AudioFocusRequest来请求焦点:

AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);

AudioFocusRequest focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
    .setAudioAttributes(new AudioAttributes.Builder()
        .setUsage(AudioAttributes.USAGE_MEDIA)
        .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
        .build())
    .setAcceptsDelayedFocusGain(true)
    .setOnAudioFocusChangeListener(focusChangeListener)
    .build();

int result = audioManager.requestAudioFocus(focusRequest);
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
    // 成功获取焦点,开始播放
}
关键点: 蓝牙A2DP场景下,建议使用AUDIOFOCUS_GAIN而不是AUDIOFOCUS_GAIN_TRANSIENT。因为音乐播放是持续性的,临时焦点会导致频繁的焦点切换,影响体验。

16.2.2 焦点变化处理

当其他应用(比如电话)抢走焦点时,你的应用需要做出响应:

private final AudioManager.OnAudioFocusChangeListener focusChangeListener = 
    new AudioManager.OnAudioFocusChangeListener() {
    @Override
    public void onAudioFocusChange(int focusChange) {
        switch (focusChange) {
            case AudioManager.AUDIOFOCUS_LOSS:
                // 永久失去焦点,停止播放
                pausePlayback();
                break;
            case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
                // 暂时失去焦点,暂停播放
                pausePlayback();
                break;
            case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
                // 可以降低音量继续播放(比如导航语音)
                setVolume(0.3f);
                break;
            case AudioManager.AUDIOFOCUS_GAIN:
                // 重新获得焦点,恢复播放
                resumePlayback();
                break;
        }
    }
};

为什么会区分LOSS_TRANSIENTLOSS_TRANSIENT_CAN_DUCK?前者是"你闭嘴",后者是"你小声点"。我记得有一次做车载蓝牙项目,导航语音和音乐同时播放,就是因为没正确处理CAN_DUCK,结果导航说话时音乐完全停了,用户投诉说"导航一说话歌就断"。

16.3 HFP通话控制

HFP(Hands-Free Profile)是蓝牙通话的核心协议。它负责把手机的通话音频路由到蓝牙耳机,同时支持接听、挂断、音量调节等控制命令。

16.3.1 通话状态监听

HFP的通话状态变化,通过BluetoothHeadset的广播来监听:

IntentFilter filter = new IntentFilter();
filter.addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED);
filter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED);
registerReceiver(mHfpReceiver, filter);

private final BroadcastReceiver mHfpReceiver = new BroadcastReceiver() {
    @Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        if (BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED.equals(action)) {
            int state = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, 
                                           BluetoothHeadset.STATE_AUDIO_DISCONNECTED);
            switch (state) {
                case BluetoothHeadset.STATE_AUDIO_CONNECTED:
                    // SCO连接已建立,通话音频开始传输
                    break;
                case BluetoothHeadset.STATE_AUDIO_DISCONNECTED:
                    // SCO连接断开,通话结束
                    break;
            }
        }
    }
};
注意: HFP的音频状态(ACTION_AUDIO_STATE_CHANGED)和连接状态(ACTION_CONNECTION_STATE_CHANGED)是两回事。连接状态表示HFP服务是否建立,音频状态表示SCO链路是否激活。我曾经见过有人把这两个搞混,结果通话时音频死活出不来。

16.3.2 接听与挂断电话

通过HFP接听或挂断电话,需要调用BluetoothHeadset的方法:

BluetoothHeadset headset = (BluetoothHeadset) adapter.getProfileProxy(
    context, 
    mProfileListener, 
    BluetoothProfile.HEADSET
);

// 接听电话
headset.acceptCall(device);

// 挂断电话
headset.rejectCall(device);

嗯,这里有个坑:acceptCall()rejectCall()只能在SCO连接建立后调用。如果SCO还没建立,调用会返回false。我建议你在调用前先检查getAudioState(device)是否为STATE_AUDIO_CONNECTED

16.4 SCO连接

SCO(Synchronous Connection Oriented)链路,是蓝牙通话音频的传输通道。它和A2DP的ACL链路不同,SCO是同步的、面向连接的,专门用于语音传输。

16.4.1 SCO连接的生命周期

SCO连接的建立和断开,通常由系统自动管理。但有些场景下,你需要手动控制:

// 手动建立SCO连接
BluetoothHeadset headset = ...;
headset.startVoiceRecognition(device);  // 这会触发SCO连接

// 手动断开SCO连接
headset.stopVoiceRecognition(device);   // 这会断开SCO连接

你想想看,为什么有时候startVoiceRecognition()会失败?大概率是因为设备不支持语音识别,或者SCO资源被占用了。我记得有一次做对讲机App,多个应用同时请求SCO,结果系统直接拒绝了所有请求。后来我们加了一个SCO请求队列,才解决了这个问题。

16.4.2 SCO与A2DP的共存

这是蓝牙音频开发里最头疼的问题之一。SCO和A2DP共享蓝牙射频资源,当SCO激活时,A2DP的带宽会被压缩,导致音质下降甚至卡顿。

场景 A2DP状态 SCO状态 用户体验
听音乐 激活 未激活 正常音质
打电话 暂停 激活 通话正常
音乐+通话 降级 激活 音质下降
避坑指南: 我曾经在项目中遇到一个bug——通话结束后,A2DP音质没有恢复。后来发现是因为系统没有自动重新协商A2DP的编码参数。解决方案是在SCO断开后,手动触发一次A2DP的编码重协商:a2dp.setCodecConfigPreference(device, codecConfig)

16.5 知识体系总览

下面这张图,是我梳理的蓝牙音频开发核心逻辑。你可以把它当作一个"地图",开发时对照着看,不容易迷路。

蓝牙音频开发核心逻辑 蓝牙音频服务 A2DP(音乐) HFP(通话) 连接管理(状态/重连) 音频焦点(请求/释放) 通话控制(接听/挂断) SCO连接(建立/断开) SCO与A2DP共存问题

这张图把蓝牙音频开发分成了两条主线:左边是A2DP(音乐),右边是HFP(通话)。底部是两者共存时的资源竞争问题。开发时,你心里要时刻装着这张图——遇到问题先定位是哪个环节出的问题。

我的建议: 刚开始做蓝牙音频开发的同学,容易陷入"代码能跑就行"的误区。其实蓝牙音频的坑大多在细节里——比如焦点丢失后没有恢复、SCO断开后A2DP音质没恢复、连接状态回调延迟等等。建议你在开发初期就建立完整的日志体系,把每个状态变化都记录下来,调试时能省不少时间。

好了,这一章的内容就到这里。A2DP和HFP是蓝牙音频的两大支柱,理解它们的连接管理和音频控制逻辑,是后续开发的基础。下一章我们会深入A2DP的音频编码协商,看看SBC、AAC、LDAC这些编码到底是怎么工作的。


公众号:蓝海资料掘金营,微信deep3321