1. 问题来了:Android13扫不到蓝牙设备了!

最近好几个做智能硬件的朋友都跑来问我,说他们的App在Android13上突然“瞎了”,死活扫不到蓝牙设备。特别是三星S22 Ultra升级到Android13之后,问题集中爆发。用户急,客户更急,电话一个接一个,开口就是“你们的App是不是有bug?怎么新手机都用不了啦?”

说实话,一开始我也挺懵的。BLE(蓝牙低功耗)开发在Android上算是比较成熟的技术栈了,从Android 5.0引入官方API到现在,基本的扫描、连接流程都挺稳定。怎么到了Android13就出这么大幺蛾子?我拿了一台升级到Android13的测试机,跑了一下自己的demo,果然,熟悉的设备列表空空如也,但用系统自带的蓝牙设置去搜,设备明明就在那里。这感觉就像你的眼睛明明睁着,却选择性失明,只对某些东西视而不见。

经过一番排查,问题根源锁定在了ScanResult这个对象上。在Android13之前,我们通过ScanResult.getScanRecord().getDeviceName()能稳稳地拿到蓝牙设备的广播名称,比如“小米手环7”、“Yeelight床头灯”这种。很多开发者会依赖这个名称来做设备过滤,比如只显示名字里包含特定前缀的设备。但在Android13上,这个方法返回的deviceName突然就变成了null。你的过滤逻辑一看名字是空的,自然就把这个设备给丢弃了,结果就是用户眼里“一个设备都扫不到”。

这可不是小问题。对于依赖蓝牙进行配网、控制的智能家居、穿戴设备、玩具等产品来说,扫描是用户交互的第一步。第一步就卡住,用户体验直接归零。更让人头疼的是,这个问题具有隐蔽性。它不像崩溃那样有明确的错误日志,App本身运行正常,只是“看不到”设备,排查起来特别费劲。如果你没有Android13的真机做测试,光靠模拟器或者老版本系统,根本发现不了这个坑。

2. 刨根问底:Android13的BLE广播包到底改了啥?

要解决问题,先得弄明白Android13在底层动了什么手脚。这得从BLE设备广播数据的基本原理说起。

一个BLE设备为了被手机发现,会周期性地向外发送广播包。这个广播包就像设备的“自我介绍传单”,里面包含了设备能干什么(服务UUID)、叫什么名字(设备名)、信号强度(RSSI)等关键信息。但是,广播包的大小是有限制的,最初的标准规定一个广播包最多31个字节。这点空间要塞下所有信息有点捉襟见肘,特别是设备名如果比较长,可能就占满了。

于是,协议设计了一个补充机制:扫描回应。当手机(扫描者)收到一个广播包后,如果觉得兴趣不大,就忽略;如果感兴趣,它可以立即向该设备发送一个“扫描请求”。设备收到这个请求后,会回复一个“扫描回应包”。这个回应包也是31字节,可以用来携带那些在初始广播包里没塞下的额外信息,比如完整的设备名、更多的服务数据等。

在Android13之前,系统底层在处理这两个包(广播包和扫描回应包)时,做了一个“贴心”的合并操作。它会把广播包和扫描回应包的数据拼接起来,形成一个最长62字节的“原始数据块”,然后通过ScanRecord类提供给我们开发者。ScanRecord会智能地解析这个合并后的数据块,无论设备名是在广播包还是扫描回应包里,最终都能通过getDeviceName()方法正确提取出来。开发者几乎不用关心数据到底来自哪个包,用起来很省心。

但是,Android13把这个“贴心”服务给改了。

我翻看了Android13的源码变更和相关文档,发现系统底层不再自动合并和解析扫描回应包的数据了。它把广播包的31字节数据解析后,直接塞给了ScanRecord。而扫描回应包的31字节数据,虽然也被手机接收了,却被“雪藏”了起来,没有提供给ScanRecord类进行解析。这就导致了一个关键问题:如果设备的完整名称恰好只放在了扫描回应包里(这在很多设备上是常见做法),那么ScanRecord.getDeviceName()拿到的就永远是null

更让人无语的是,Android13并没有新增一个类似ScanResponseRecord的类,或者提供新的API来让我们直接获取和解析扫描回应包。它只是把扫描回应包的原始字节数据,悄悄地放在了ScanResult.scanRecord.bytes这个字节数组的后半部分(从第31字节开始)。前半部分是广播包数据,后半部分是扫描回应包数据,中间可能用0填充。这个变化非常底层,官方文档里也没有大张旗鼓地说明,全靠开发者自己摸索和看源码。

所以,情况就变成了这样:你的App代码逻辑没变,设备广播行为没变,但系统底层的数据供给方式变了,导致一个关键信息(设备名)丢失了。这就像送快递的,以前把包裹和发票一起给你,现在只给包裹,发票单独扔在一边但不告诉你,你找不到发票就以为没收到货。

3. 手动补救:自己动手,解析扫描回应包

既然系统不帮我们合并解析了,那我们就得自己动手,丰衣足食。核心思路就是:当从ScanRecord拿不到设备名时,我们手动去解析ScanResult.scanRecord.bytes这个字节数组的后31字节,也就是扫描回应包的部分。

这里我给出一个经过实战检验的解决方案。首先,我们定义一个简单的数据类来承载解析结果:

/**
 * 用于承载扫描回应包解析结果的简单类
 */
data class ScanResponse(
    var localName: String? = null
    // 你可以根据需要扩展其他字段,比如特定的厂商数据等
)

接下来是关键的解码函数。这个函数的目标是从原始字节数组中,从指定的起始位置开始,解析出扫描回应包里的设备名。

import android.annotation.TargetApi
import android.os.Build

/**
 * 解析扫描回应包中的设备本地名称
 * @param bytes 完整的原始字节数组,来自 ScanResult.scanRecord?.bytes
 * @param start 扫描回应包数据在 bytes 中的起始索引(通常是31)
 * @return 解析出的 ScanResponse 对象,其中 localName 字段可能为 null
 */
@TargetApi(Build.VERSION_CODES.TIRAMISU) // 即 API 33, Android 13
fun parseScanResponse(bytes: ByteArray, start: Int = 31): ScanResponse {
    val response = ScanResponse()
    var currentPos = start
    // 第一个字节是长度字段
    var length = bytes[currentPos].toInt() and 0xFF // 转换为无符号整数

    while (length > 0 && currentPos < bytes.size) {
        // 长度字段后面是数据类型字段
        val dataType = bytes[currentPos + 1].toInt() and 0xFF

        // 数据的起始位置是 currentPos + 2,结束位置是 currentPos + 1 + length
        // 注意:长度值包含了数据类型字段占用的1字节,所以数据部分长度是 length - 1
        val dataStart = currentPos + 2
        val dataEnd = currentPos + 1 + length
        if (dataEnd <= bytes.size) {
            val data = bytes.copyOfRange(dataStart, dataEnd)

            when (dataType) {
                // 短设备名
                ScanRecord.DATA_TYPE_LOCAL_NAME_SHORT -> {
                    response.localName = String(data, Charsets.UTF_8)
                }
                // 完整设备名
                ScanRecord.DATA_TYPE_LOCAL_NAME_COMPLETE -> {
                    response.localName = String(data, Charsets.UTF_8)
                }
                // 这里可以添加对其他数据类型的解析,比如厂商特定数据 (0xFF)
                // ScanRecord.DATA_TYPE_MANUFACTURER_SPECIFIC_DATA -> { ... }
            }
        }

        // 移动到下一个AD结构
        currentPos += (1 + length) // 跳过当前结构的长度字节和数据部分
        if (currentPos < bytes.size) {
            length = bytes[currentPos].toInt() and 0xFF
        } else {
            length = 0
        }
    }
    return response
}

我来解释一下这个解析过程。BLE的广播数据和扫描回应数据,都是由一个或多个“AD结构”组成的。每个AD结构非常简单,就三个部分:

  1. 长度(1字节):表示后面“数据类型+数据”这两部分一共占多少字节。
  2. 数据类型(1字节):一个预定义的数值,告诉你后面跟着的数据是什么含义。比如0x08代表短设备名,0x09代表完整设备名,0xFF代表厂商自定义数据。
  3. 数据(N字节):实际的有效载荷,长度等于“长度字段的值 - 1”。

我们的函数就是按照这个结构,一个接一个地遍历扫描回应包里的AD结构。当发现数据类型是DATA_TYPE_LOCAL_NAME_SHORTDATA_TYPE_LOCAL_NAME_COMPLETE时,就把后面的数据转换成字符串,这就是我们苦苦寻找的设备名了。

有了这个基础解析函数,我们就可以在扫描回调里,构建一个兼容Android13及之前版本的设备名获取方法了:

fun parseDeviceNameFromScanResult(scanResult: ScanResult): String? {
    // 首先,尝试用系统原生的方法获取(兼容旧版本)
    scanResult.scanRecord?.deviceName?.let {
        return it
    }

    // 如果原生方法返回null,并且是Android13及以上系统,尝试解析扫描回应包
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        scanResult.scanRecord?.bytes?.let { rawBytes ->
            // 确保原始数据长度足够包含扫描回应包部分
            if (rawBytes.size >= 62) { // 广播包31 + 扫描回应包31
                val scanResponse = parseScanResponse(rawBytes, 31)
                return scanResponse.localName
            }
        }
    }
    // 如果以上都失败,返回null
    return null
}

在你的扫描回调ScanCallbackonScanResult方法中,就可以这样使用:

override fun onScanResult(callbackType: Int, result: ScanResult) {
    val deviceName = parseDeviceNameFromScanResult(result)
    val deviceAddress = result.device.address

    Log.d(TAG, "发现设备: 地址=$deviceAddress, 名称=$deviceName")

    if (deviceName == null) {
        // 设备名称为空,可能是匿名广播设备,或者解析失败
        // 根据你的业务逻辑决定是否处理这个设备
        Log.w(TAG, "设备 $deviceAddress 名称为空,请注意!")
    } else if (deviceName.contains("YourDevicePrefix")) {
        // 找到目标设备,进行后续处理,比如连接
        // ...
    }
}

4. 实战优化与避坑指南

上面的代码给出了基本解决方案,但在实际项目中直接使用,你可能会遇到一些边缘情况。这里分享几个我踩过的坑和优化点。

第一坑:原始字节数组长度不一定总是62。 虽然理论上广播包+扫描回应包最大是62字节,但实际中,设备可能不回应扫描请求,或者回应的包很短。所以,在解析前一定要做长度检查。

fun parseDeviceNameSafely(scanResult: ScanResult): String? {
    scanResult.scanRecord?.deviceName?.let { return it }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        val rawBytes = scanResult.scanRecord?.bytes ?: return null
        // 关键检查:确保有扫描回应包数据
        // 起始索引31必须小于数组长度,且至少有一个AD结构的长度字段可读
        if (rawBytes.size > 31 && rawBytes[31].toInt() and 0xFF > 0) {
            val scanResponse = parseScanResponse(rawBytes, 31)
            return scanResponse.localName
        }
        // 如果第31字节是0,说明扫描回应包是空的(或者全是填充0)
        Log.v(TAG, "扫描回应包数据为空或无效")
    }
    return null
}

第二坑:设备名可能在广播包,也可能在扫描回应包,或者两者都有(短名在广播包,长名在回应包)。 更健壮的策略是,优先使用广播包里的名字(系统已解析),如果没有,再尝试解析扫描回应包。我们的parseDeviceNameFromScanResult函数已经体现了这个顺序。但有时候,你可能需要知道名字到底来自哪里,可以稍微修改函数,返回一个包含名称和来源的复合结果。

第三坑:编码问题。 设备名在广播数据里是UTF-8编码的,String(data, Charsets.UTF_8)在绝大多数情况下没问题。但极少数老旧的、不规范的设备可能用了其他编码方式(虽然不符合规范)。如果遇到解析出来是乱码,可以尝试Charsets.US_ASCII或者Charset.forName("ISO-8859-1")看看,但这属于处理历史遗留问题了。

第四点:性能考量。 解析字节数组是轻量级操作,但如果在onScanResult这种高频回调里对每个设备都执行,且扫描到的设备很多时,可能会对UI线程造成轻微压力(如果你在UI线程处理回调的话)。建议将解析操作放在后台线程,或者使用RxJavaCoroutine等异步框架进行流式处理。一个简单的做法是使用LiveDataFlow将扫描结果推到后台协程中进行解析和过滤。

// 使用协程通道和Flow处理扫描结果
val scanResultsFlow = MutableSharedFlow<ScanResult>()

override fun onScanResult(callbackType: Int, result: ScanResult) {
    viewModelScope.launch {
        scanResultsFlow.emit(result)
    }
}

// 在ViewModel或Repository中
fun getFilteredDevices(): Flow<List<BluetoothDevice>> {
    return scanResultsFlow
        .buffer(Channel.UNLIMITED) // 缓冲,防止背压
        .map { result ->
            // 在后台线程解析设备名
            withContext(Dispatchers.Default) {
                val name = parseDeviceNameSafely(result)
                Pair(result.device, name)
            }
        }
        .filter { (_, name) -> name != null && name.startsWith("MyDevice") }
        .map { (device, _) -> device }
        .distinctUntilChanged() // 基于地址去重
        .flowOn(Dispatchers.Default) // 确保以上操作在后台线程
}

第五点:测试策略。 这个问题和具体设备型号、系统版本强相关。你的测试矩阵必须覆盖:

  1. Android 13 及以上版本的手机(特别是三星S22系列、Pixel系列)。
  2. Android 12 及以下的手机作为对照。
  3. 不同类型的BLE设备:有的设备名放在广播包,有的放在扫描回应包,有的两者都放。 可以在开发阶段,在解析函数里加入详细的日志,打印出原始字节、解析出的数据类型和长度,这样能帮你快速判断问题出在哪里。

5. 长远之计:拥抱新API与架构设计

手动解析扫描回应包是解决当前Android13兼容性问题的有效方案,但这毕竟是一种“打补丁”式的做法。从长远来看,我们需要思考更优雅的架构设计,并关注Google未来的API更新。

首先,考虑抽象设备发现层。 不要将设备名称解析的逻辑散落在扫描回调的各个角落。应该将其封装成一个独立的“设备信息解析器”,并提供一个统一的接口。这样,无论Android系统版本如何变化,你只需要修改这个解析器的内部实现,业务逻辑代码无需变动。

interface IDeviceInfoParser {
    fun parseDeviceName(scanResult: ScanResult): String?
    fun parseManufacturerData(scanResult: ScanResult): ByteArray?
    // ... 其他需要解析的信息
}

class LegacyDeviceInfoParser : IDeviceInfoParser {
    override fun parseDeviceName(scanResult: ScanResult): String? {
        return scanResult.scanRecord?.deviceName
    }
    // ...
}

class Android13DeviceInfoParser : IDeviceInfoParser {
    private val legacyParser = LegacyDeviceInfoParser()
    override fun parseDeviceName(scanResult: ScanResult): String? {
        // 先尝试旧方法
        legacyParser.parseDeviceName(scanResult)?.let { return it }
        // 旧方法无效,则尝试解析扫描回应包
        return parseScanResponseForName(scanResult)
    }
    private fun parseScanResponseForName(scanResult: ScanResult): String? {
        // 嵌入我们之前写的解析逻辑
        // ...
    }
}

// 工厂方法,根据系统版本返回不同的解析器
fun createDeviceInfoParser(): IDeviceInfoParser {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        Android13DeviceInfoParser()
    } else {
        LegacyDeviceInfoParser()
    }
}

其次,关注官方后续更新。 Google很可能在未来的Android版本(比如Android 14)中,提供新的API来直接获取扫描回应包信息,或者修复ScanRecord的解析逻辑。我们需要保持对Android开发者网站和IssueTracker的关注。一旦有新的官方方案,就应该逐步迁移过去,并废弃我们自己的兼容性代码。

最后,重新审视设备过滤逻辑。 过度依赖设备名进行过滤本身是一种脆弱的做法。设备名是用户可以修改的(比如把“小米手环”改成“我的宝贝手环”),而且不同厂商的命名规则不一。更可靠的过滤方式应该是:

  1. 服务UUID过滤:在扫描时直接通过ScanFilter过滤特定的服务UUID。这是最标准、最稳定的方式。
  2. 厂商数据过滤:在扫描过滤器中使用厂商特定数据(Manufacturer Specific Data)进行匹配。
  3. 设备地址前缀过滤:虽然MAC地址可能随机化,但某些厂商的地址前缀(OUI)是固定的,可以作为辅助判断。
  4. 组合条件:结合信号强度(RSSI)、设备名关键词、服务UUID等多种条件进行综合判断,提高容错率。
// 示例:使用ScanFilter进行更稳定的过滤
val filter = ScanFilter.Builder()
        .setServiceUuid(ParcelUuid.fromString("0000FEED-0000-1000-8000-00805F9B34FB"))
        // .setDeviceName("MyDevice") // 谨慎使用设备名过滤
        .setManufacturerData(0x004C, byteArrayOf(0x02, 0x15)) // 例如苹果iBeacon
        .build()

val filters = mutableListOf<ScanFilter>()
filters.add(filter)

val settings = ScanSettings.Builder()
        .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
        .build()

bluetoothLeScanner.startScan(filters, settings, scanCallback)

这次Android13带来的BLE扫描问题,虽然给开发者添了麻烦,但也提醒我们,在移动开发中,尤其是涉及硬件和系统底层的交互时,永远不要假设系统API的行为一成不变。做好版本兼容、抽象核心逻辑、采用更健壮的过滤策略,才能让你的应用在系统升级的浪潮中屹立不倒。我在处理完几个项目的这个问题后,已经把上述的兼容解析器和过滤策略整合进了团队的基础组件库,以后新项目直接引用就行,算是把坑填平,还铺了条小路。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐