用户反馈说,App 以前好好的,怎么最近突然就连不上设备了。不是蓝牙扫不到,也不是配对不上,而是设备已经在系统蓝牙设置里连着、音乐也在放,可 App 就是不给进设备页面。弹一个弹窗让你去蓝牙设置里配对,用户去看,明明已经配好了。 这就很搞人心态了。用户觉得你 App 有问题,你觉得蓝牙栈抽风了。
真正的原因说出来其实不复杂:那台手机连设备的时候,走的是 LE Audio,而你 App 的验证逻辑还在死盯经典蓝牙那条线。
不是连不上,是连上的方式变了
先看一个最简单的图。
以前,手机往耳机传音频,是这么走的:

App 想知道设备是不是连上了,就去问 A2DP 或者 HFP 有没有连接。这套逻辑在有 LE Audio 之前,完全没问题。因为那时候手机往耳机传音频,只有经典蓝牙这一条路。
LE Audio 出来以后,有了第二条路:

这两条路在系统层是完全独立的。但大部分 App 的验证代码,并没有跟着多出来的这条路一起更新。
你在 App 里还是问 A2DP 有没有连上。系统那边音频已经在 LE Audio 上跑了,A2DP 根本就没参与。于是 App 看到的结论就是设备没连上,而系统那边音乐已经在播了。
这件事最坑的地方在于——不是每一台手机都会走 LE Audio。
同一台耳机,连一台手机走的是经典蓝牙,连另一台手机走的就是 LE Audio。你测试的时候拿的是走经典蓝牙那台,用户手里的是走 LE Audio 那台。你测不出来,用户一用一个准。
大部分 App 的验证代码长什么样
先说清楚问题出在哪。不管是什么类型的蓝牙 App(耳机、音箱、穿戴设备、打印机),进设备详情页之前,基本都要做一个设备是否已连接的判断。这个验证,九成的项目都是这样写的:
val device = bluetoothAdapter.getRemoteDevice(macAddress)
if (device.bondState != BluetoothDevice.BOND_BONDED) {
showDialog("请先在系统蓝牙中配对设备")
return
}
if (!isClassicProfileConnected(device)) {
showDialog("请连接设备后再试")
return
}
// 放行
enterDevicePage(macAddress)
重点在 isClassicProfileConnected()。这个函数几乎在所有项目里都长一个样:
funisClassicProfileConnected(device: BluetoothDevice): Boolean {
val adapter = BluetoothAdapter.getDefaultAdapter()
// 获取 A2DP profile
adapter.getProfileProxy(context, a2dpListener, BluetoothProfile.A2DP)
// 获取 Headset profile
adapter.getProfileProxy(context, hfpListener, BluetoothProfile.HEADSET)
// 然后通过 proxy 查连接状态...
}
不管是用代理模式还是广播监听,核心就一个动作:查 A2DP 和 HFP 是不是 connected。
过去这么多年,这个做法一直是对的。你能听歌,说明 A2DP 通着;你能打电话,说明 HFP 通着。没有任何问题。
问题出在当手机走 LE Audio 的时候。音频在一条你没查的通道上跑着,A2DP 根本没连,isClassicProfileConnected() 当然返回 false。然后 App 就把一个明明在用的设备,判定成了未连接。
怎么知道 LE Audio 有没有连上
Android 13(API 33)开始,系统里多了一个叫 BluetoothProfile.LE_AUDIO 的东西。它就是专门用来查 LE Audio 连接状态的。
但问题是,大部分老项目 minSdk 都不高,直接引用这个常量会编译报错。而且 BluetoothLeAudio 这个类在低版本 SDK 里根本不存在。
所以写法上得多绕一步。先看怎么拿到 LE Audio 的 profile proxy:
// 用 Object 来接,兼容低版本 SDK
privatevar leAudioProxy: Any? = null
privateval leAudioListener = object : BluetoothProfile.ServiceListener {
overridefunonServiceConnected(profile: Int, proxy: BluetoothProfile?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
leAudioProxy = proxy
}
}
overridefunonServiceDisconnected(profile: Int) {
leAudioProxy = null
}
}
初始化的时候,要在 Android 13+ 才去拿这个 proxy:
val adapter = BluetoothAdapter.getDefaultAdapter()
// 经典蓝牙的 profile 照常拿
adapter.getProfileProxy(context, a2dpListener, BluetoothProfile.A2DP)
adapter.getProfileProxy(context, hfpListener, BluetoothProfile.HEADSET)
// Android 13+ 才拿 LE Audio
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
adapter.getProfileProxy(context, leAudioListener, BluetoothProfile.LE_AUDIO)
}
这里有个细节:getProfileProxy 是异步的。你调完以后,leAudioProxy 不会立刻有值。不过在实际使用中这通常不是问题,因为从 App 打开到用户点设备,中间通常会隔几秒。只是如果你的 App 有自动连接逻辑,需要注意这个时序。
拿到 proxy 以后,判断连接的函数长这样:
@SuppressLint("MissingPermission")
funisLeAudioConnected(device: BluetoothDevice?): Boolean {
if (device == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
returnfalse
}
val proxy = leAudioProxy ?: returnfalse
returntry {
val method = proxy.javaClass
.getMethod("getConnectionState", BluetoothDevice::class.java)
val state = method.invoke(proxy, device) asInt
state == BluetoothProfile.STATE_CONNECTED
} catch (e: Exception) {
false
}
}
这里用反射是因为 BluetoothLeAudio 这个类在低 SDK 上不存在,没法直接 import。低版本 SDK 上代码也能编译通过,运行时会走第一行 Build.VERSION.SDK_INT 判断直接返回 false。
如果你的项目 minSdk 是 33 起步,那就不用这么绕,直接写就行:
import android.bluetooth.BluetoothLeAudio
privatevar leAudioProxy: BluetoothLeAudio? = null
funisLeAudioConnected(device: BluetoothDevice?): Boolean {
if (device == null || leAudioProxy == null) returnfalse
return leAudioProxy!!.getConnectionState(device) == BluetoothProfile.STATE_CONNECTED
}
验证逻辑改一句话的事
有了 isLeAudioConnected(),原来那套验证逻辑改起来非常简单,就是条件从只查 A2DP 变成两条线都查:
privatefuncheckDeviceReady(macAddress: String): Boolean {
val device = bluetoothAdapter.getRemoteDevice(macAddress)
// 先查配对状态
if (device.bondState != BluetoothDevice.BOND_BONDED) {
showPairingDialog()
returnfalse
}
// 经典蓝牙通了就放行
if (isClassicProfileConnected(device)) {
returntrue
}
// LE Audio 通了也放行
if (isLeAudioConnected(device)) {
returntrue
}
// 两条都没通,弹窗
showConnectDialog()
returnfalse
}
核心改动其实就一行 if (isLeAudioConnected(device)),但这一行不加上去,那部分用 LE Audio 手机的用户就永远被卡在弹窗外面。
这件事用户那边是没有办法解决的。因为绝大多数手机根本就没有 LE Audio 的开关——用户在系统设置里找不到使用经典蓝牙音频还是使用 LE Audio 这个选项。系统自己决定走哪条路,用户压根不知道、也控制不了。
也就是说,App 不主动去兼容 LE Audio,那这部分用户就是无解的。
设备页面里的断连判断也得跟着改
验证只是进门那一下。真正麻烦的是进了设备页面以后,你怎么判断设备断没断。
大多数蓝牙 App 的设备详情页都会监听连接状态。断了就要弹提醒或者退页面。这个断了的判断,有不少项目也是绕经典蓝牙在转的。
问题跟验证一模一样。如果音频走的是 LE Audio,设备断开的时候,A2DP / HFP 那边本来就没连,不会有状态变化。你收不到断开事件,App 就以为设备还连着,页面就一直不处理。
所以设备页面的断连判断,也得把 LE Audio 这条线加进去。
这里顺便说一个更普遍的问题。很多 App 的设备页是一收到断开立刻退页面。这在经典蓝牙时代问题不大,因为 A2DP 断了就是真的断了。
但在 LE Audio 场景下,偶尔会有短暂的线路切换。比如经典蓝牙和 LE Audio 之间切换,或者设备在做一些内部操作,链路会短暂断开一两秒然后自动恢复。如果断开回调一来你就立刻弹窗退页面,体验非常差,而且用户会很懵——我刚进来你就让我出去?
所以断连处理的逻辑,最好加一个短暂的延迟确认窗口:
privateval disconnectHandler = Handler()
privateval disconnectRunnable = Runnable {
if (!isAnyProfileConnected(macAddress)) {
handleConfirmedDisconnect()
}
}
privatefunonDeviceDisconnected() {
disconnectHandler.removeCallbacks(disconnectRunnable)
disconnectHandler.postDelayed(disconnectRunnable, DISCONNECT_DELAY_MS)
}
companionobject {
privateconstval DISCONNECT_DELAY_MS = 1500L
}
1.5 秒这个值不绝对,根据你的设备和链路特性来调。太短了切换过程中的瞬时断连会误触,太长了用户真断开以后页面要等很久才反应。
确认断开的时候,三条线都得看:
privatefunisAnyProfileConnected(macAddress: String): Boolean {
val device = bluetoothAdapter.getRemoteDevice(macAddress)
// 内部协议链路:比如你的私有 RFCOMM / GATT 通道
if (isInternalChannelConnected(macAddress)) {
returntrue
}
// 经典蓝牙音频
if (isClassicProfileConnected(device)) {
returntrue
}
// LE Audio
if (isLeAudioConnected(device)) {
returntrue
}
returnfalse
}
很多设备其实是两条甚至三条链路并存的。比如音频走一条线,私有协议控制走另一条线。只要有一条还活着,设备就没真正断开。
扫描页面也别漏了
还有一个容易忘记的地方,就是扫描页面。
用户第一次连接设备的时候,通常是在扫描页里点设备。这个点击事件里也需要做验证——点了设备,先判断它是否已经连上了。
funonDeviceItemClick(macAddress: String) {
val device = bluetoothAdapter.getRemoteDevice(macAddress)
when {
isClassicProfileConnected(device) -> connectInternal(macAddress)
isLeAudioConnected(device) -> connectInternal(macAddress)
else -> showGotoSystemBtSettingsDialog()
}
}
很多 App 这里的弹窗逻辑是请到系统蓝牙设置里连接设备。在 LE Audio 场景下,用户会看到系统蓝牙设置里设备明明连着,App 却还让他再连一次。这就很矛盾。
改完以后上哪测
这个问题最烦的是,你自己手里的测试机大概率是测不出来的。因为不是每台手机都走 LE Audio。
翻了一圈以后发现,其实有一部分手机里是有 LE Audio 开关的。比如三星 S23 往上的一些机型、Google 的几代旗舰,在蓝牙设置或者开发者选项里能找到 LE Audio 的单独开关。如果你身边刚好有这些手机,可以把 LE Audio 打开,连上设备,看看你的 App 是不是就进不去了。
但大部分手机是没有这个开关的。能不能走 LE Audio,完全看系统和芯片平台的组合。所以你如果就想验证自己的代码有没有写对,最稳的办法还是在 isLeAudioConnected() 里加日志,连上不同型号的手机跑一遍。
Log.d("BLE", "leAudioProxy=$leAudioProxy state=$state")
逻辑先跑通,真机验证后面再补。
另一个办法是用 adb shell dumpsys bluetooth_manager,看当前连接的 profile 列表里有没有 LE_AUDIO。如果有,你的 isLeAudioConnected() 应该能正确返回 true。
还有哪些地方需要一起看的
LE Audio 这件事,看起来改的就一个判断条件。但它其实在提醒你:项目里所有连接 = A2DP 的假设,都需要重新审视一遍。
至少下面这些地方建议都过一下:
自动化连接。如果 App 一打开就自动连上次的设备,连接成功的判断条件也需要加上 LE Audio。 断线重连。重连策略可能需要区分经典蓝牙断开和 LE Audio 断开,两种情况的恢复路径可能不完全一样。 OTA 升级保护。升级过程中如果出现音频线路切换(经典蓝牙 ↔ LE Audio),不能当成断连去中断升级。 Profile 初始化的时机。 getProfileProxy是异步的,如果 App 启动后立刻就做连接操作,要确认 proxy 已经拿到。
每个地方的改动都很小,但如果漏掉某一个,那个用了 LE Audio 手机的用户还是会遇到问题。
最后
LE Audio 这件事,说实话不是什么高深理论。就是一个很简单的认知更新:以前判断音频设备连上了只需要问 A2DP,现在要多问一个人,叫 LE Audio。
真正麻烦的不是改这一行代码,而是你要知道你项目里有多少个地方在悄悄假设 A2DP = 设备连接。这个假设在过去很多年里一直是正确的,所以才渗透得到处都是。现在它不成立了,就得一个一个找出来。
而且这件事会越来越常见。2024 年下半年以后出的大多数旗舰手机都已经支持 LE Audio 了,厂商系统更新也在逐步铺开。现在可能只影响 5% 的用户,再过半年可能就 30% 了。早点改比晚改强。
夜雨聆风