Android 低功耗蓝牙终极指南

·

21 min read

17 四月 2024作者:Chee Yi Ong

更新内容:

  • 此终极指南及其配套代码存储库现在支持compileSdkVersiontargetSdkVersion 34 (Android 14)。

  • 本指南中使用的 API 来自android.bluetooth包,而不是来自名称非常相似的androidx.bluetooth包,该包在撰写本文时仍处于 alpha 阶段。

  • 本指南中详述的方法使开发人员能够完全控制设备发现和连接过程,这与配套设备配对方法有很大不同,后者从开发人员那里抽象出许多控制(和复杂性)。请继续关注介绍其他方法的后续文章。

介绍

由于它能够消耗很少的功率,但仍能提供与小型设备通信的连接性,越来越多的人希望加入 Android 应用程序的低功耗蓝牙 (BLE) 潮流。不幸的是,Android SDK 的 BLE API 充满了未记录的陷阱,尽管该平台占据了全球 70% 以上的市场份额,但还有很多不足之处。

不用担心,因为 Punch Through 团队在 15+ 年从事 BLE 连接 Android 应用程序的工作中学到了很多东西,我们在这里与读者分享我们的连接专业知识、经验和重要教训!
来自战壕的专家指导

对于不熟悉我们是谁和我们做什么的新读者,我们是 Punch Through,这是领先的定制软件开发咨询公司,提供互联产品和移动介导的设备到云连接。

我们的软件工程师编写我们的指南,我们的见解来自解决跨连接产品生态系统中复杂的 BLE 连接问题的第一手经验。Punch Through 的多学科团队帮助产品和工程领导者及其团队完成为医疗设备、消费技术和整个物联网领域的关键任务项目构建创新、安全、合规和可靠的连接解决方案的复杂旅程。

我们帮助 Google 识别并修复了许多 Android BLE 问题**:
**尤其
是在 Android 13 版本中,该版本为无数用户破坏了 BLE。我们关于“Android 13:无法可靠地重新连接到绑定外围设备”的详细错误报告获得了超过 885 +1 秒,并获得了 P1(优先级 1)和 S1(严重性 1)评级。此报告导致 Android 13 QPR1 中的修复,我们在 QPR1 处于 Beta 2 时帮助测试了该修复程序,在撰写本文时,我们还有其他 Google 仍在处理的报告: Android 13:无法从加密的低功耗蓝牙 GATT 特征和 Android 13 中读取值:确认操作系统绑定对话框后,onCharacteristic读取状态 133。

本终极指南包含哪些内容

此更新后的指南介绍了Android 开发人员需要了解的 BLE 基础知识,并介绍了在 Android 上执行常见 BLE 操作的一些简单而真实的示例,例如扫描、连接、读取、写入以及设置通知或指示。大多数代码片段和示例都是用 Kotlin 编写的,但它们也可以很好地翻译成 Java。

代码伴侣和示例

本指南中的所有代码片段旨在展示应如何执行给定的 BLE 操作。示例应用上下文中的实际完整实现可在我们的开源GitHub 存储库中找到。

注意*:自 2024 年 4 月 20 日起,我们更新了整个存储库,以支持*Android 14 的targetSdk,以及适用于 Android 12+ 的新“附近设备”权限处理,以及适用于 Android 13+ 的新 BluetoothGattCallback 方法。

面向 Android 开发人员的 BLE 基础知识

我们首先列出了一些您在 Android 上开始 BLE 开发时会遇到的关键字。

词汇表

术语和定义
BLE
它代表低功耗蓝牙,是 2.4 GHz 蓝牙无线技术的一个子集,专门用于连接设备的低功耗且通常不频繁的数据传输。
中央/客户端
扫描并连接到 BLE 外围设备以执行某些操作的设备。在应用开发的上下文中,这通常是 Android 设备。
外围设备/服务器
一种通告其存在并连接到中央以完成某些任务的设备。在应用开发中,这通常是您正在使用的 BLE 设备,例如心率监测器。
GATT 服务
描述设备功能的特征(数据字段)集合(例如,设备信息服务)可以包含表示设备序列号的特征和表示设备电池电量的另一个特征。
GATT 特征
包含有意义的数据的实体,通常可以从中读取或写入这些数据,例如序列号字符串特征。
GATT 描述符
描述它所附加的特征(例如,客户端特征配置描述符)的定义属性,显示中心当前是否订阅了特征的值更改。
通知
当特征值发生变化时,BLE 外设通知中心的一种方式。中心通常不需要确认收到数据包。
适应症
它与通知相同,只是中央确认每个数据包。这保证了以吞吐量为代价的交付。
UUID
通用唯一标识符。它是一个 128 位数字,用于标识服务、特征和描述符。

BLE与Bluetooth Classic有何不同?

BLE首字母缩略词的“低能量”部分基本上放弃了它。虽然经典蓝牙旨在传输连续的数据流,例如音乐播放,但 BLE 针对电源效率进行了优化。BLE 设备通常可以使用小型电池运行数周、甚至数年,非常适合基于传感器或物联网 (IoT) 的用例。

经典蓝牙和低功耗蓝牙之间的另一个关键区别是 BLE 对开发人员更加友好。BLE 允许开发人员为不同的用例指定各种自定义配置文件,从而开辟了一个充满无限可能性的世界,而 Bluetooth Classic 主要支持用于发送自定义数据的串行端口配置文件 (SPP)。由于以下原因,Android 蓝牙 API 对于蓝牙经典用例也不是很容易使用:

  • Bluetooth Classic 扫描 API 使用 ,它本质上是多播的,杂乱无章,在现代 Android 开发中通常避免使用。BroadcastReceiver

  • Android SDK 要求蓝牙经典设备与 Android 配对,然后才能建立 RFCOMM 连接,而 BLE 用例没有施加此限制。

  • Android SDK 仅为有限数量的开箱即用的 Bluetooth Classic 配置文件提供实现

  • 配对和连接后,您需要自己启动和管理专用的 Android Thread 对象,以便通过该对象与蓝牙经典设备进行通信,这提供了灵活性,但同时也容易出错。BluetoothSocket

BLE中的中枢-外围关系

作为中央设备的 Android 设备可以同时连接到多个外围设备(外部 BLE 设备)。尽管如此,每个充当外围设备的 BLE 设备通常一次只能与一个 Central 交互。最常见的行为是,当 BLE 设备连接到中央设备时,它将停止作为外围设备进行广告宣传,因为它无法再连接到它。

将 BLE Central 和 Peripheral 之间的关系视为客户端-服务器关系也有助于此。服务器(外围设备)托管一个 GATT 数据库,该数据库提供客户端(中心)通过 BLE 访问的信息。需要注意的是,您的 Android 设备也可以充当外围设备,但在本指南中,我们将重点介绍它充当 BLE Central 的更流行的场景。

BLE通信的要点可以总结为三种常见的BLE操作:

  • 写入:客户端(应用)将一些字节写入服务器(BLE 设备)上的特征或描述符。服务器的固件处理写入并执行服务器端操作作为响应。例如,智能恒温器可能具有在写入时改变目标温度的特性。

  • 读取:客户端(应用)读取服务器(BLE 设备)上特征或描述符的值,并根据事先建立的协议对其进行解释。智能恒温器可能具有其值代表当前目标温度的特性。

  • 通知/指示:客户端(应用)订阅通知或指示的特征,并在特征值更改时收到服务器通知。智能恒温器可能具有可通知的特征,该特征将在订阅时报告环境温度的变化。

Android SDK 中的 BLE

注意:正如我们的 Android BLE 开发技巧一文中所述,我们假设该应用至少以 API 21 (Android 5.0) 为目标,因为有更好的 BLE API(如 和 )。BluetoothLeScannerScanFilter

在本节的开头,我们将介绍我们将使用的 Android SDK 中的主要类。

满足 Android BLE API 要求

类别和目的
BluetoothAdapter
Android 设备的蓝牙硬件的表示形式。此类的实例由 BluetoothManager 类提供。 提供有关蓝牙硬件开/关状态的信息,允许我们查询绑定到 Android 的蓝牙设备,并允许我们启动 BLE 扫描。BluetoothAdapter
BluetoothLeScanner
由该类提供,该类允许我们启动 BLE 扫描。
BluetoothLeScanner 由BluetoothAdapter类提供,此类允许我们启动BLE扫描。 注意:从Android M(6.0)及以上版本开始,需要ACCESS_COARSE_LOCATION或ACCESS_FINE_LOCATION进行BLE扫描,Android 10及以上版本需要ACCESS_FINE_LOCATION,Android 12及以上版本需要BLUETOOTH_SCAN。
ScanFilter
它允许人们缩小扫描结果范围,以针对我们在 BLE 扫描期间寻找的特定设备。应用的典型用例是根据 BLE 设备的通告服务 UUID 筛选 BLE 扫描结果。
ScanResult
它表示通过 BLE 扫描获得的 BLE 扫描结果,并包含 BLE 设备的 MAC 地址、RSSI(信号强度)和通告数据等信息。getDevice() 方法公开 BluetoothDevice 句柄,该句柄可能包含 BLE 设备的名称,并允许应用连接到它。
BluetoothDevice
表示应用可以连接到、绑定(配对)或两者兼而有之的物理蓝牙(不是 BLE)设备。此类提供关键信息,包括设备名称(如果可用)、其 MAC 地址及其当前绑定状态。
BluetoothGatt
BLE设备的GATT配置文件的入口点。它允许我们执行服务发现和连接拆解,请求 MTU 更新(稍后会详细介绍),并访问 BLE 设备上存在的服务和特征。我们可以将其视为已建立的 BLE 连接的句柄。
BluetoothGattServiceBluetoothGattCharacteristicBluetoothGattDescriptor
Wrapper 类表示 GATT 服务、特征和描述符,如本指南前面的词汇表中所定义。
BluetoothGattCallback
应用必须实现主界面才能接收大多数相关操作的回调,例如读取、写入或接收有关传入通知或指示的通知。BluetoothGatt

浏览 Android BLE API 的快速提示

关键要点:如果从本指南中只能学到一件事,那就是 Android 不喜欢执行快速、异步、无序的 BLE 操作。没有内部排队机制来防止开发人员的错误或(幸福的、无辜的)无知——我们靠自己。

我们在另一篇文章中重点介绍了 Android BLE 开发中的一些常见陷阱,因此这里有一个快速总结:

  • 不要以连续的、快速的方式做 BLE 的事情。对操作进行排队并按顺序执行,并在启动新操作之前始终等待上一个操作的回调。这听起来似乎只对连续的读取和写入操作有意义。尽管如此,它仍然适用于所有 BLE 操作,包括连接、服务发现、MTU 请求,甚至连接拆卸。不用担心;在本指南中,我们还将指导您实现排队机制。

  • **尽量不要使用反射来调用私有 API。**在 2017 年之前,有很多示例可能展示了如何使用私有 API 来实现本地 GATT 数据库的强制刷新或强制 LE 类型的绑定。从 Android 9 开始,由于 Google 对非 SDK 接口的新立场,不再推荐这样做。除非你的应用有一个特定的用例,并辅以这些私有 API,否则开发人员应避免使用私有 API。

  • **始终假设任何操作的失败概率不为零,**并且应用需要能够处理这些失败。即使是简单的断开连接,在幕后重新连接也可以解决大多数问题。

在本指南的其余部分,我们将引导您创建自己的 Android 应用程序,该应用程序与 BLE 设备进行通信。我们旨在提供有关如何执行 BLE 操作的高级概述,以便您可以在编写代码时阅读和遵循。我们的开源 GitHub 存储库中提供了实现的完整示例。

有了基本的介绍和技巧,是时候卷起袖子开始编码了!


设置项目的基础知识

对于使用 BLE API 的应用,我们喜欢使用 Kotlin 作为主要语言,我们支持的最低 API 级别为 21(Android 5.0 Lollipop)。对于大多数现代项目来说,这可能太低了——最低 API 级别 29 (Android 10) 也可以。查看我们的 Android 架构文章,详细了解我们做出这些选择的原因!最后,确保在项目向导中选中了“Use androidx.* artifacts”。如果您正在处理现有项目,请确保您已迁移到 AndroidX,因为 Google 正在将其支持库迁移到 AndroidX 软件包

注意: 本文是最新的 34 和 (Android 14)。compileSdkVersiontargetSdkVersion

关于 Android 最佳实践的思考

为了展示使用 Android BLE API 的基本知识,我们将在代码片段和示例实现中采用一些快捷方式来节省时间:

  • 为了展示使用 Android BLE API 的基本知识,我们将在代码片段和示例实现中采用一些快捷方式来节省时间:

  • 要在 UI 上向用户显示的字符串在代码中硬编码为字符串文本,而不是提取到可本地化的文件中。这是为了提高代码的可读性,并使意图更易于理解。您应该将这些字符串提取到应用的 strings.xml 文件中。strings.xml

  • 属于屏幕的所有代码都填充到表示该屏幕的类中。对于应用,应确定是否可以将某些代码重构为公共帮助程序类或委托给其他实体(如类)。ActivityViewModel

  • 对边缘情况和 UX 的处理保持在最低限度,足以让应用程序在快乐的路径场景中工作并处理最严重的错误。生产级应用应具有更高级的错误处理流,或者可能考虑教育用户如何从错误中恢复。

  • 我们将调用 Kotlin 的 error(...) 函数,该函数会在不满足某些操作的先决条件时引发应用崩溃。换句话说,我们假设所有信息在需要时始终存在。在应用中,应通过向用户显示错误、自行补救或明智地忽略错误来处理错误。IllegalStateException

权限处理

在 AndroidManifest.xml 中声明所需的权限

设置新项目后,或者打开现有项目后,我们要做的第一件事是声明应用执行 BLE 任务所需的一些权限(如果尚未声明)。

导航到项目文件或展开应用→清单→通过“项目”工具窗口。我们将在 <manifest> 标记中添加的权限包括:AndroidManifest.xmlAndroidManifest.xml

<!-- Request legacy Bluetooth permissions on versions older than API 31 (Android 12). -->
<uses-permission android:name="android.permission.BLUETOOTH"
    android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
    android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
    android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
    android:maxSdkVersion="30" />

<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation"
    tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

以下是每个权限的作用:

  • BLUETOOTH:允许应用连接到蓝牙设备的旧权限。

  • BLUETOOTH_ADMIN:允许应用扫描蓝牙设备并与之绑定的旧权限。

  • ACCESS_FINE_LOCATION:在 Android 6 和 Android 11(包括两端)之间,应用需要位置权限才能获取 BLE 扫描结果。必须要求用户明确授予此权限的主要动机是保护用户的隐私。BLE 扫描通常会无意中将用户的位置泄露给扫描特定 BLE 信标的不道德应用程序开发人员,或者某些 BLE 设备可能会公布特定位置的信息。在 Android 10 之前,ACCESS_COARSE_LOCATION可用于访问 BLE 扫描结果,但我们建议改用 ACCESS_FINE_LOCATION,因为它适用于所有版本的 Android。

  • ACCESS_COARSE_LOCATION:除了ACCESS_FINE_LOCATION之外,面向 Android 12 (API 31) 及更高版本的应用还必须申请此权限。

  • BLUETOOTH_SCAN:对于以 Android 12 及更高版本为目标平台的应用,开发者最终可以请求明确许可,以便仅执行蓝牙扫描,而无需为搭载 Android 12 及更高版本的设备获取位置信息访问权限。

  • BLUETOOTH_CONNECT:对于以 Android 12 及更高版本为目标平台的应用,开发者可以申请此权限,以连接到当前绑定到搭载 Android 12 及更高版本的 Android 设备的蓝牙外围设备。

在所有这些权限中,、、 和 被视为 Android 术语中的危险权限或运行时权限。这意味着,虽然在安装期间会自动向应用授予某些权限,但用户需要从应用 UI 显式授予应用这些权限。最重要的是要向用户强调,在这种情况下,除了确保 BLE 扫描正常工作之外,您的应用程序不会将他们的位置用于任何事情。ACCESS_COARSE_LOCATIONACCESS_FINE_LOCATIONBLUETOOTH_SCANBLUETOOTH_CONNECT

如果您的应用需要 BLE 硬件,您可以选择声明您的应用在 Android 设备上使用 BLE 功能。这样一来,使用没有 BLE 功能的设备上的用户将无法在 Google Play 商店中看到您的应用。如果此行为听起来不错,请在标记下方添加以下代码段。<uses-permission>

<uses-feature
    android:name="android.hardware.bluetooth_le"
    android:required="true" />

向用户请求运行时权限

我们建议您在 、 或至少在扩展函数中采用的其中一种方法是了解用户是否授予了所需的运行时权限。以下扩展函数协同工作即可实现此目的:ActivityViewModelContextContext

/**
 * Determine whether the current [Context] has been granted the relevant [Manifest.permission].
 */
fun Context.hasPermission(permissionType: String): Boolean {
    return ContextCompat.checkSelfPermission(this, permissionType) ==
        PackageManager.PERMISSION_GRANTED
}

/**
 * Determine whether the current [Context] has been granted the relevant permissions to perform
 * Bluetooth operations depending on the mobile device's Android version.
 */
fun Context.hasRequiredBluetoothPermissions(): Boolean {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        hasPermission(Manifest.permission.BLUETOOTH_SCAN) &&
            hasPermission(Manifest.permission.BLUETOOTH_CONNECT)
    } else {
        hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)
    }
}

上面的两个函数是对象上的扩展函数,这意味着每个有权访问这些函数的实例都可以调用它们并使用它们,就好像这些函数是原始类声明的一部分一样。根据源代码的结构,这些扩展函数可能驻留在另一个 Kotlin 源文件中,以便其他人也可以访问它们。ContextContextActivities

现在,在 的类声明之外添加一个顶级常量声明 — 常量的实际值可以是你想要的任何正整数:Activity

private const val PERMISSION_REQUEST_CODE = 1

由于运行时权限仅从用户想要执行 BLE 扫描的那一刻起才需要,因此我们只应在用户想要启动 BLE 扫描时提示他们授予此权限是有意义的。以下代码片段假设您有一个按钮,点击该按钮后将开始扫描 BLE 设备。

在那个 中,我们要调用一个函数。如果这是唯一要做的事情,你的代码应该看起来像这样:ButtonOnClickListenerstartBleScan()Button

scanButton.setOnClickListener { startBleScan() }

startBleScan()在允许用户继续执行 BLE 扫描之前,将检查是否已授予所需的运行时权限。

private fun startBleScan() {
    if (!hasRequiredBluetoothPermissions()) {
        requestRelevantRuntimePermissions()
    } else { /* TODO: Actually perform scan */ }
}

private fun Activity.requestRelevantRuntimePermissions() {
    if (hasRequiredBluetoothPermissions()) { return }
    when {
        Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> {
            requestLocationPermission()
        }
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            requestBluetoothPermissions()
        }
    }
}

private fun requestLocationPermission() = runOnUiThread {
    AlertDialog.Builder(this)
        .setTitle("Location permission required")
        .setMessage(
            "Starting from Android M (6.0), the system requires apps to be granted " +
            "location access in order to scan for BLE devices."
        )
        .setCancelable(false)
        .setPositiveButton(android.R.string.ok) { _, _ ->
            ActivityCompat.requestPermissions(
                this,
                arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
                PERMISSION_REQUEST_CODE
            )
        }
        .show()
}

@RequiresApi(Build.VERSION_CODES.S)
private fun requestBluetoothPermissions() = runOnUiThread {
    AlertDialog.Builder(this)
        .setTitle("Bluetooth permission required")
        .setMessage(
            "Starting from Android 12, the system requires apps to be granted " +
                "Bluetooth access in order to scan for and connect to BLE devices."
        )
        .setCancelable(false)
        .setPositiveButton(android.R.string.ok) { _, _ ->
            ActivityCompat.requestPermissions(
                this,
                arrayOf(
                    Manifest.permission.BLUETOOTH_SCAN,
                    Manifest.permission.BLUETOOTH_CONNECT
                ),
                PERMISSION_REQUEST_CODE
            )
        }
        .show()
    }
}

startBleScan()如果我们还没有相关的运行时权限,将调用 — 这个依赖于 API 的检查封装在我们之前分享的扩展函数中。requestRelevantRuntimePermissions()hasRequiredBluetoothPermissions()

如果缺少至少一个运行时权限,则会显示一条警报,通知用户需要授予缺少的运行时权限,并为他们提供“确定”的唯一选项,这将触发系统对话框,提示用户向应用授予相关权限。

我们现在需要做和以前一样的事情,并对用户的操作做出反应,这次是通过重写函数来处理 when the 等于 :onRequestPermissionsResult()ActivityCompatrequestCodePERMISSION_REQUEST_CODE

override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    if (requestCode != PERMISSION_REQUEST_CODE) return

    val containsPermanentDenial = permissions.zip(grantResults.toTypedArray()).any {
        it.second == PackageManager.PERMISSION_DENIED &&
            !ActivityCompat.shouldShowRequestPermissionRationale(this, it.first)
    }
    val containsDenial = grantResults.any { it == PackageManager.PERMISSION_DENIED }
    val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
    when {
        containsPermanentDenial -> {
            // TODO: Handle permanent denial (e.g., show AlertDialog with justification)
            // Note: The user will need to navigate to App Settings and manually grant
            // permissions that were permanently denied
        }
        containsDenial -> {
            requestRelevantRuntimePermissions()
        }
        allGranted && hasRequiredBluetoothPermissions() -> {
            startBleScan()
        }
        else -> {
            // Unexpected scenario encountered when handling permissions
            recreate()
        }
    }
}

如果用户同意向应用授予我们请求的运行时权限,我们就可以开始扫描 BLE 设备了;否则,如果他们尚未通过选中“不再询问”框永久拒绝请求,我们将继续要求他们授予这些权限。

Android 11 及更高版本上的注意事项:Android 会将重复拒绝隐式视为永久拒绝,并且应用无法再提示用户授予这些权限。在这种情况下,开发人员应该有适当的用户体验,解释为什么需要权限,以及如何通过将用户引导到应用程序设置来纠正这种情况,以便他们可以手动授予那些被拒绝的权限。一种常见的方法是启动 并将 的数据设置为描述应用包名称的特定 URI,例如 .Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)Intentpackage:com.punchthrough.lightblueandroid

同样,一个设计良好的应用程序应该有一种更优雅的方式来处理各种拒绝场景,但我们将把它留给读者作为练习。

向前!

此时,我们希望您再次在物理 Android 设备上构建并运行该应用。如果您的 Android 设备运行的是 Android 6 – 11(包括范围的两端),您应该会在点击“开始扫描”时看到位置访问提示;如果您的设备运行的是 Android 12 或更高版本,您应该会看到附近的蓝牙设备访问提示。验证拒绝选项是否会导致权限请求提示重新出现,之后应授予请求的权限以避免进入永久拒绝执行分支。

为简洁起见,我们假定已为以下所有部分授予这些运行时权限。

确保蓝牙已启用

如果启用蓝牙对应用的核心功能至关重要,则需要确保用户在使用应用时保持蓝牙启用状态。 幸运的是,Android 提供了一个操作,可以提示用户在其设备上打开蓝牙。唯一需要注意的是,如果您的应用以 Android 12 或更高版本为目标平台,则必须先授予新权限,然后才能使用此操作。IntentBLUETOOTH_CONNECTIntent

在下面的示例中,我们想检查是否启用了蓝牙;如果不是,我们会显示警报:ActivityonResume()

private val bluetoothAdapter: BluetoothAdapter by lazy {
    val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
    bluetoothManager.adapter
}

private val bluetoothEnablingResult = registerForActivityResult(
    ActivityResultContracts.StartActivityForResult()
) { result ->
    if (result.resultCode == Activity.RESULT_OK) {
        // Bluetooth is enabled, good to go
    } else {
        // User dismissed or denied Bluetooth prompt
        promptEnableBluetooth()
    }
}

override fun onResume() {
    super.onResume()
    if (!bluetoothAdapter.isEnabled) {
        promptEnableBluetooth()
    }
}

/**
 * Prompts the user to enable Bluetooth via a system dialog.
 *
 * For Android 12+, [Manifest.permission.BLUETOOTH_CONNECT] is required to use
 * the [BluetoothAdapter.ACTION_REQUEST_ENABLE] intent.
 */
private fun promptEnableBluetooth() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
        !hasPermission(Manifest.permission.BLUETOOTH_CONNECT)
    ) {
        // Insufficient permission to prompt for Bluetooth enabling
        return
    }
    if (!bluetoothAdapter.isEnabled) {
        Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE).apply {
            bluetoothEnablingResult.launch(this)
        }
    }
}

当您的第一个 Activity 即将显示这些添加内容时,我们会检查是否启用了 Activity。如果不是,我们会使用 显示系统警报。 请求用户在其 Android 设备上启用蓝牙。请记住,您需要先获得许可,然后才能在 Android 12 及更高版本上启动此功能!BluetoothAdapterBluetoothAdapterACTION_REQUEST_ENABLEBLUETOOTH_CONNECTIntent

此时,请花时间在物理 Android 设备上构建和运行应用。我们使用的是 Android 物理设备,因为在撰写本文时,模拟器尚未提供蓝牙支持。您会注意到,如果您禁用蓝牙并转到 ,系统对话框会提示您启用蓝牙。如果您关闭该对话框,应用程序将尝试一遍又一遍地显示相同的警报,直到用户接受我们打开蓝牙的建议。Activity

在生产应用程序中,可能会有某种用户体验来教育用户为什么应该启用蓝牙。而不是非常粗略的调用示例,您可以显示一个新的信息警报,稍后将调用您的函数以再次显示启用蓝牙的系统警报,或者启动其他一些花哨的用户教育流程。promptEnableBluetooth()

Android Studio“MissingPermission”警告

以下部分中的代码示例假定,仅当用户已授予所有相关运行时权限时,应用代码才会调用它们。

为了运行我们的示例代码,如果您要将所有 BLE 扫描和连接逻辑封装在一个或两个主类中,则可能需要添加一个特定的“MissingPermission”警告,Android Studio 可能会为使用 Android 蓝牙 API 的类发出警告,而没有围绕这些用法进行显式运行时权限检查。@SuppressLint

@SuppressLint("MissingPermission") // App's role to ensure permissions are available
class BluetoothManager { ... }

执行 BLE 扫描

除非句柄是从最近的扫描中缓存的,或者 BLE 设备本身已与系统绑定或连接,否则我们通常需要先执行 BLE 扫描,然后才能连接到 BLE 设备。典型的 BLE 应用连接设置流程如下所示:BluetoothDevice

  1. 使用可选的扫描结果过滤执行 BLE 扫描。

  2. 获取与感兴趣的 BLE 设备匹配的对象。ScanResult

  3. 调用对象的方法以获取基础对象。ScanResultgetDevice()BluetoothDevice

  4. 调用对象的方法以启动 BLE 连接进程。BluetoothDeviceconnectGatt()

在Android上进行BLE扫描的入门

扫描结果过滤

在扫描周围的设备之前,询问我们感兴趣的设备是否具有独特的特征,这将有助于 Android BLE 扫描仪将扫描结果缩小到我们关心的设备。该类允许我们根据以下条件过滤传入的 s:ScanFilterScanResult

  • 通告服务 UUID

  • 播发服务的服务数据

  • 广告 BLE 设备的名称

  • 广告 BLE 设备的 MAC 地址

  • 其他特定于制造商的数据

虽然有些应用程序确实有在没有扫描的情况下进行扫描的用例(我们自己的 LightBlue® 应用程序就是其中之一),但大多数应用程序使用 BLE 连接到特定类型的设备,因为它们旨在仅使用该类型的设备执行某些有意义的任务。例如,显示当前温度、湿度和气压的应用只会尝试连接到具有这些功能的 BLE 设备,例如宣传环境传感服务的设备。ScanFilter

我们可以创建一个 using the class 并调用其方法来设置过滤条件,然后再最终调用,例如:ScanFilterScanFilter.Builderbuild()

val filter = ScanFilter.Builder().setServiceUuid(
    ParcelUuid.fromString(ENVIRONMENTAL_SERVICE_UUID.toString())
).build()

根据我们为客户开发 BLE 密集型应用程序的经验,如果以任何方式涉及自定义固件,确保应用程序仅选择运行该自定义固件的设备的最简单方法是生成随机 UUID 并让固件通告此服务 UUID。然后,应用程序会根据此 UUID 执行扫描过滤,并且由于 UUID 对于实际目的来说足够独特,因此我们可以预期扫描只会选择我们感兴趣的设备。

解析和理解扫描结果

对象作为 方法的一部分浮出水面,通常我们在 a 中关心的事情是:ScanResultScanCallbackonScanResult(...)ScanResult

  • 用于标识广告扫描结果的设备的 MAC 地址

    • 通过后跟 获得,或简单地在 Kotlin 中获取。getDevice()getAddress()device.address

    • 警告:实现蓝牙 4.2 的 LE 隐私功能的设备将定期随机化其公共 MAC 地址,因此通过扫描获得的 MAC 地址通常不应用作识别设备的长期手段——除非固件保证它不会轮换 MAC 地址,或者如果它有一种带外方式来传达它当前的公共 MAC 地址是什么, 或者,如果用例涉及绑定,这将允许 Android 派生设备的最新/当前 MAC 地址。

  • 我们可以向用户显示的设备名称

    • 通过后跟 获得,或简单地在 Kotlin 中获取。并非所有 BLE 设备都播发设备名称,因此某些 BLE 设备的名称可能为 null。getDevice()getName()device.name
  • 广告 BLE 设备的 RSSI 或信号强度,以 dBm 为单位。

    • 通过 或简单地在 Kotlin 中获取。getRssi()rssi

    • 按信号强度降序对扫描结果进行排序是查找最接近 Android 设备的外围设备的好方法,但这并不能 100% 保证,因为 RSSI 可能会受到广告设备天线的传输功率以及其他物理因素的影响,例如 Android 或 BLE 设备周围是否存在金属物体。

    • 分贝值通常是相对的,而不是基于绝对刻度。这意味着 -42 dBm 的 RSSI 读数对于一部 Android 手机来说可能是“近距离”,但对于另一部手机来说可能是“中距离”。通常不建议将 RSSI 读数普遍映射到实际物理距离。

  • 我们需要的BluetoothDevice句柄,以便连接到设备,通过该方法访问。getDevice()

  • 的额外广告数据,可通过 访问。ScanResultScanRecordgetScanRecord()

    • ScanRecord方便地从扫描记录中解析出任何制造商特定的数据和服务数据,这些数据和服务数据都可以使用 AND 方法进行访问。getManufacturerSpecificData(...)getServiceData(...)

    • 可以使用该方法访问原始扫描记录字节。getBytes()

指定扫描设置

除了扫描结果过滤之外,Android 还允许我们指定扫描期间要使用的扫描设置,由类表示,该类还带有自己的构建器类。以下是一些实用且常用的扫描设置,Android允许我们进行调整:ScanSettingsScanSettings.Builder

  • 指定所需的 BLE 扫描模式,从低功耗高延迟扫描到高功率低延迟扫描。

    • 大多数在前台扫描的应用都应用于持续时间超过 30 秒的扫描。SCAN_MODE_BALANCED

    • SCAN_MODE_LOW_LATENCY如果应用仅扫描一小段时间,通常是为了查找非常特定类型的设备,则建议这样做。

    • SCAN_MODE_LOW_POWER用于持续时间极长的扫描或在后台进行的扫描(经用户许可)。请注意,如果您要扫描的设备具有足够高的广告间隔,并且不会与应用的扫描频率重叠,则此低功耗扫描模式的高延迟性质可能会导致错过播发数据包。

  • 指定遇到的 BLE 通告数据包的回调类型

    • 像 LightBlue 这样需要持续更新传入播发数据包的应用应该用于获取有关所有传入数据包的通知。如果应用未指定所需的回调类型,则这是默认设置。CALLBACK_TYPE_ALL_MATCHES

    • CALLBACK_TYPE_FIRST_MATCH如果应用只关心获取与 指定的筛选条件匹配的每个设备的单个回调(如果未指定 A,则为附近的所有设备)。ScanFilterScanFilter

    • CALLBACK_TYPE_MATCH_LOST有点奇怪——我们使用它的经验好坏参半,一般不推荐它。通常,您最好自己实现一个计时器,该计时器会定期检查您的列表并根据 的方法删除过时的计时器(例如,在过去 10 秒左右没有遇到),但请注意,此方法提供自 Android 系统启动时间以来的时间戳,而不是自 Epoch 时间以来的时间。ScanResultsScanResultgetTimestampNanos()

  • 指定应将通告数据包发现显示为扫描结果的阈值

    • MATCH_MODE_STICKY在过滤掉距离 Android 设备太远的广告 BLE 设备时很有用,因为它需要更高的信号强度阈值和目击次数,然后 BLE 设备才会作为扫描结果出现在我们的应用程序中。

    • MATCH_MODE_AGGRESSIVE与BLE扫描仪范围内的每个设备广告相反,无论远近。MATCH_MODE_STICKY

“实现 BLE 扫描”部分中,我们将向您展示一个低延迟设置的简单示例。ScanSetting

值得注意的扫描错误

在执行 BLE 扫描时,您会看到的最常见的错误是未记录的“应用程序扫描过于频繁”错误。Android 的内部限制是每个应用每 30 秒在 BluetoothLeScanner 对象上调用 5 次 startScan(...) 方法,超过这个限制不会触发任何错误回调——只是 Logcat 中的一个模糊条目,表明您的应用扫描太频繁了。更奇怪的是,除非您在 Logcat 窗口中没有任何活动过滤,否则您通常不会看到此 Logcat 条目,因为该条目具有与之关联的未知包名称“?”。

如果您的应用超出了此限制,则扫描将显示为已开始,但不会将扫描结果传送到您的回调正文。30 秒后,应用应先调用,然后再次调用以开始接收扫描结果 - 导致错误的旧方法调用不会在 30 秒冷却计时器完成后自动再次开始接收扫描结果。实际上,大多数应用程序不会超过此内部限制,但这是您应该注意的错误。也许你应该警告你的用户,你的代码或你的用户是否有可能重复启动和停止 BLE 扫描。stopScan()startScan(...)startScan(...)

您可能遇到的另一个晦涩难懂的错误是该错误。此错误由 的方法显示,并被模糊地描述为应用程序无法向 BLE 扫描仪注册。未记录的解决方案是请求用户在其设备上禁用并重新启用蓝牙。如果这失败了,那么接下来也是唯一要做的就是要求您的用户执行 Android 重启。哦,喜悦! SCAN_FAILED_APPLICATION_REGISTRATION_FAILEDScanCallbackonScanFailed(...)

实现 BLE 扫描

眼尖的人会注意到,我们在上一节中关于向用户请求位置权限的功能除了确保用户已授予位置权限外,没有做太多事情。本节将向您展示如何扫描周围的 BLE 设备。startBleScan()

首先,我们将添加一个名为 :lazybleScanner

private val bleScanner by lazy {
    bluetoothAdapter.bluetoothLeScanner
}
// From the previous section:
private val bluetoothAdapter: BluetoothAdapter by lazy {
    val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
    bluetoothManager.adapter
}

该属性是其值的延迟设置的属性,仅在需要时初始化。我们之所以没有将其声明为 just,是因为 — 它本身就是一个属性 — 依赖于该函数,该函数仅在已经为我们的 .bleScannervalbluetoothAdapter.bluetoothLeScannerbluetoothAdapterlazygetSystemService()onCreate()Activity

通过推迟 的初始化以及我们需要它们的时间,我们可以避免在返回之前初始化时发生的崩溃。BluetoothAdapterbleScannerBluetoothAdapteronCreate()

在开始扫描之前,我们需要指定扫描设置。您可以根据我们在“指定扫描设置”部分中讨论的内容进行相应的调整,但在此示例中,我们将添加一个简单的属性作为属性:

private val scanSettings = ScanSettings.Builder()
    .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
    .build()

接下来,我们需要创建一个实现函数的对象,以便在扫描结果可用时收到通知:ScanCallback

private val scanCallback = object : ScanCallback() {
    override fun onScanResult(callbackType: Int, result: ScanResult) {
        with(result.device) {
            Log.i(“ScanCallback”, "Found BLE device! Name: ${name ?: "Unnamed"}, address: $address")
        }
    }
}

有了所有部分,我们终于可以修改之前的函数来实际执行扫描了:startBleScan()

private fun startBleScan() {
    if (!hasRequiredRuntimePermissions()) {
        requestRelevantRuntimePermissions()
    } else {
        bleScanner.startScan(null, scanSettings, scanCallback)
    }
}

在您的物理 Android 设备上运行并构建应用程序,然后点击“开始扫描”——如果您被一些正在积极宣传的 BLE 设备包围,您应该开始在 Logcat 窗口中看到扫描结果。

停止 BLE 扫描

要停止正在进行的 BLE 扫描,请提供 stopScan() 方法。BluetoothLeScanner

基于到目前为止的示例,我们可以添加一个属性,该属性将通知我们 的状态,因为扫描程序的状态未公开。isScanningBluetoothLeScanner

private var isScanning = false
    set(value) {
        field = value
        runOnUiThread { scanButton.text = if (value) "Stop Scan" else "Start Scan" }
    }

我们还覆盖了其 setter,以便每当字段的值发生变化时,我们都会将扫描按钮文本更新为与扫描状态相反的文本。如果扫描正在进行中,用户将点击按钮停止扫描,如果扫描尚未进行,他们将希望开始扫描。

然后,我们将更新函数以在启动扫描时设置为 true,并实现一个函数:startBleScan()isScanningstopBleScan()

private fun startBleScan() {
    if (!hasRequiredBluetoothPermissions()) {
        requestRelevantRuntimePermissions()
    } else {
        bleScanner.startScan(null, scanSettings, scanCallback)
        isScanning = true
    }
}

private fun stopBleScan() {
    bleScanner.stopScan(scanCallback)
    isScanning = false
}

现在我们已经了解了 BLE 扫描仪的状态,并且有办法开始和停止扫描,我们可以更新扫描按钮以对给定值执行正确的操作。OnClickListenerisScanning

scanButton.setOnClickListener {
    if (isScanning) {
        stopBleScan()
    } else {
        startBleScan()
    }
}

在那里,完成了,完成了!再次运行应用,并验证应用现在是否能够启动和停止 BLE 扫描。

向用户显示扫描结果

到目前为止,我们只能查看 Logcat 来确定附近有哪些 BLE 设备。现在不是很有趣,是吗?在本节中,我们将更新我们的应用,以使用 RecyclerView 在 UI 上显示这些广告 BLE 设备。此 Android 组件允许您在可滚动界面中显示项目集合。或者,如果您的项目使用 Jetpack Compose,您可以选择使用 a 来完成同样的事情。LazyColumn

如果选择继续执行 ,则需要为每个扫描结果实现类和布局 XML。为了让本指南专注于 BLE 方面,我们不会在这里详细介绍。相反,您可以在我们的开源 GitHub 存储库中找到我们的示例实现。假设您的适配器类的编写方式与我们的类似,并且您已经在 .RecyclerViewRecyclerView.AdapterRecyclerViewActivity

回到我们的 ,我们首先创建一个属性来保存我们的 BLE 扫描结果,以及一个表示我们刚刚实现的适配器类的属性。请注意 lambda 表达式中的 。这就是我们将在点击表示扫描结果的行项时实现的地方,我们稍后会谈到这一点。ActivityscanResultsscanResultAdapterTODOOnClickListener

private val scanResults = mutableListOf<ScanResult>()
private val scanResultAdapter: ScanResultAdapter by lazy {
    ScanResultAdapter(scanResults) {
        // TODO: Implement
    }
}

由于我们现在维护扫描结果列表,因此最好在开始每次 BLE 扫描之前清除其内容列表。更新您的函数,以便每当开始扫描时,我们都会清除支持的数据,因为它们现在被视为已过期:startBleScan()RecyclerView

private fun startBleScan() {
    if (!hasRequiredBluetoothPermissions()) {
        requestRelevantRuntimePermissions()
    } else {
        scanResults.clear()
        scanResultAdapter.notifyDataSetChanged()
        bleScanner.startScan(null, scanSettings, scanCallback)
        isScanning = true
    }
}

接下来,我们希望在出现新的 BLE 扫描结果时正确填充我们的属性。我们将修改属性的回调,并实现该方法,以便将任何错误记录到 Logcat 中:scanResultsscanCallbackonScanResultonScanFailed

private val scanCallback = object : ScanCallback() {
    override fun onScanResult(callbackType: Int, result: ScanResult) {
        val indexQuery = scanResults.indexOfFirst { it.device.address == result.device.address }
        if (indexQuery != -1) { // A scan result already exists with the same address
            scanResults[indexQuery] = result
            scanResultAdapter.notifyItemChanged(indexQuery)
        } else {
            with (result.device) {
                Log.i("ScanCallback", "Found BLE device! Name: ${name ?: "Unnamed"}, address: $address")
            }
            scanResults.add(result)
            scanResultAdapter.notifyItemInserted(scanResults.size - 1)
        }
    }
    override fun onScanFailed(errorCode: Int) {
        Log.e("ScanCallback", "onScanFailed: code $errorCode")
    }
}

由于我们没有明确指定 的回调类型,因此我们的回调被属于同一组设备所淹没,但具有不同的信号强度 (RSSI) 读数。为了使 UI 更新最新的 RSSI 读数,我们首先检查是否已经有一个 MAC 地址与新传入的 MAC 地址相同的扫描结果。如果是这样,我们将旧条目替换为新条目。如果这是新的扫描结果,我们会将其添加到列表中。对于这两种情况,我们都会通知更新的项目,以便进行相应的更新。CALLBACK_TYPE_FIRST_MATCHScanSettingsonScanResultscanResultscanResultListscanResultscanResultscanResultAdapterRecyclerView

花一些时间在您的 Android 设备上构建和运行该应用程序,您应该会看到您的扫描结果正在填充代表您附近的 BLE 设备。每次出现新的扫描结果时,它们都应该自我更新(如果将扫描结果的 RSSI 显示为行项布局的一部分,则很容易验证)。RecyclerView


连接到 BLE 设备

在 Android 上启动 BLE 连接概述

在应用中启动 BLE 连接的典型流程大致可分为两种类型:

  • 自动连接: 该应用根据特定的启发式方法从返回的扫描结果自动连接到设备,例如,我们正在扫描通告某些专用服务 UUID 的设备,在低延迟模式下扫描几秒钟后,只有一个这样的设备。

  • 手动连接:用户仔细阅读扫描结果列表并手动选择要连接的设备。

本节的示例假设使用手动连接的用例。无论您的应用属于哪种用例,您都应始终在连接到 BLE 设备之前停止 BLE 扫描。这样做可以节省电力,更重要的是,根据我们的经验,还有助于提高连接过程的可靠性。

connectGatt() 方法及其 autoConnect 参数

句柄上的 connectGatt() 方法将启动与 BLE 设备的连接。有多个重载,但我们将使用的重载具有以下签名:BluetoothDeviceconnectGatt()

public BluetoothGatt connectGatt(
    Context context, 
    boolean autoConnect, 
    BluetoothGattCallback callback
)

原因是这个版本早在 API 级别 18 就可用,我们打算使用最低 API 级别 21(Android 5.0 Lollipop),正如我们的文章《让 Android BLE 实际工作的 4 个技巧》中所解释的那样。其他重载仅在 API 级别 23 或 26 中可用。connectGatt(...)

如果您只能支持 API 级别 23 及更高版本,则可以选择将重载与 int 传输参数一起使用,这样您就可以将传输指定为 .connectGatt(...)BluetoothDevice.TRANSPORT_LE

此方法的所有变体在其参数中都包含布尔值,这可能会误导新手 - 将此标志设置为 true 不会导致 Android 在连接时自动尝试重新连接到BluetoothDevice!相反,将标志设置为仅使连接操作不超时,如果您尝试连接到之前缓存的 a,这可能很有帮助。autoConnecttrueBluetoothDevice

根据我们的经验,将标志设置为可能会导致连接过程比平时慢,即使设备已经在附近被发现。在开发 BLE 应用程序时,我们更喜欢将标志设置为,而是依靠回调来通知我们连接过程是否成功。如果连接失败,我们可以简单地尝试再次连接,设置为 。truefalseonConnectionStateChangeautoConnectfalse

所有变体都返回一个对象,该对象可以被视为我们正在建立的 BLE 连接上的句柄,允许我们启动读取和写入操作。但是,我们通常不会保留对此返回的引用,而只保留对作为回调方法参数提供给我们的引用。connectGatt(...)BluetoothGattBluetoothGattBluetoothGattCallback

在 BluetoothGattCallback 中实现回调

前面提到的方法还接受一个名为 BluetoothGattCallback 的对象。这是一个抽象类,我们必须重写这些方法才能获得有关相关回调的通知。这些方法和它们的作用是有据可查的,所以我们将解释留给官方文档。connectGatt()BluetoothGatt

我们在此阶段需要实现的主要回调方法是 ,它提供了有关 BLE 连接状态的关键信息。这是与给定 BLE 连接相关的连接和断开连接事件的真实来源onConnectionStateChange()

识别成功的连接尝试

成功的连接尝试将看到回调发生,其状态参数设置为 BluetoothProfile.STATE_CONNECTED 且参数设置为 。此时,存储对此回调提供的对象的引用至关重要。这将是我们向 BLE 设备发出命令的主要界面。onConnectionStateChange()GATT_SUCCESSnewStateBluetoothGatt

连接时处理常见错误情况

建立 BLE 连接时的错误也会通过 的方法显示出来。典型的错误检查流检查 的 status 参数是否为 。如果不是,则我们手上有一个由参数表示的错误。BluetoothGattCallbackonConnectionStateChange()onConnectionStateChange()GATT_SUCCESSstatus

一些错误状态代码被记录为 BluetoothGatt 的公共常量,但我们在尝试连接到 A 时遇到的最常见的错误必须是臭名昭著的状态 133 错误。这个错误不仅没有记录在常量列表下,而且查看 Android 源代码会发现它的名称很简单(0x85,即 133 的十六进制)。如果您认为这非常模糊,那么您并不孤单。我们坚信谷歌可以在这方面做得更好。BluetoothDeviceBluetoothGattGATT_ERROR

除了随机出现的 133 之外,我们通常看到错误 133 在以下两种情况之一中最常发生:

  • 我们尝试连接到的不再是广告或在蓝牙范围内,并且呼叫在大约 30 秒后超时(依赖于 OEM 的值 - 我们已经看到三星设备在 10 秒后超时)尝试连接到它,参数设置为 false。BluetoothDeviceconnectGatt()autoConnect

  • BLE设备上运行的固件已拒绝Android的连接尝试。

无论您在回调中得到哪个错误状态代码,恢复流程通常如下所示:onConnectionStateChange()

  • 调用该对象以指示我们已完成此实例,并且系统可以释放任何挂起的资源。close()BluetoothGattBluetoothGatt

  • 空掉对此对象的任何引用。BluetoothGatt

  • 如果状态代码为GATT_INSUFFICIENT_ENCRYPTIONGATT_INSUFFICIENT_AUTHENTICATION,请先调用 createBond() 并等待绑定过程成功,然后再调用 connectGatt()。在后面的部分中,我们将介绍如何启动与 BLE 设备的绑定。

  • 对于其他状态代码:要么向用户显示错误,要么尝试以静默方式重新连接几次,然后再放弃。后者是一种可行的策略,因为错误 133 有时可能非常随机,并且在连接尝试最终成功之前连续发生几次。

实现 BLE 连接流

由于我们正在实现一个流程,用户负责告诉我们他或她有兴趣连接到哪个设备,因此我们可以从以前更新我们的设备。当用户点击显示在 BLE 扫描结果时,将调用以下代码:ScanResultsAdapterRecyclerView

private val scanResultAdapter: ScanResultAdapter by lazy {
    ScanResultAdapter(scanResults) { result ->
        // User tapped on a scan result
        if (isScanning) {
            stopBleScan()
        }
        with(result.device) {
            Log.w("ScanResultAdapter", "Connecting to $address")
            connectGatt(context, false, gattCallback)
        }
    }
}

这些添加应该很简单:如果 BLE 扫描正在进行,我们希望停止它,并且我们调用相关的 's 句柄,传入一个定义为:connectGatt()scanResultBluetoothDeviceBluetoothGattCallback

private val gattCallback = object : BluetoothGattCallback() {
    override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
        val deviceAddress = gatt.device.address
        if (status == BluetoothGatt.GATT_SUCCESS) {
            if (newState == BluetoothProfile.STATE_CONNECTED) {
                Log.w("BluetoothGattCallback", "Successfully connected to $deviceAddress")
                // TODO: Store a reference to BluetoothGatt
            } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                Log.w("BluetoothGattCallback", "Successfully disconnected from $deviceAddress")
                gatt.close()
            }
        } else {
            Log.w("BluetoothGattCallback", "Error $status encountered for $deviceAddress! Disconnecting...")
            gatt.close()
        }
    }
}

请注意 success 分支中的 TODO,我们希望在此处存储对对象的引用。如前所述,它是其他 BLE 操作的网关,例如服务发现、读取和写入数据,甚至执行连接拆解。运行您的应用程序,启动 BLE 扫描,然后点击扫描结果。检查 Logcat 以查看连接尝试的结果,如果前几次失败,可以选择重试。BluetoothGatt


发现服务

如果你做到了这一步,恭喜你。您的应用程序现在可以连接到 BLE 设备!现在我们已经连接到一个设备,我们意识到目前我们没有太多有趣的事情可以做。然后,我们必须“走关贸总协定表”,正如我们喜欢所说的那样,以发现 BLE 设备上可用的服务和特性。

与设备建立 BLE 连接后,服务发现至关重要。它允许我们探索设备的功能,这有点像第一次去商场并查看其楼层目录。由于服务是特征的集合,并且每个特征都可以有定义其属性的描述符,因此我们可以将每个服务视为商场中的一层或一层,将特征视为给定楼层上的单个商店,将描述符视为给定商店中的特定过道或部分。

执行服务发现非常简单。使用我们从成功的连接尝试中保存的对象,我们只需在其上调用 discoverServices():BluetoothGatt

关贸总协定。发现服务()

尽管听起来很奇怪,**但我们强烈建议从主/UI 线程调用 discoverServices(),**以防止罕见的线程问题导致死锁情况,即应用程序可能会等待以某种方式被丢弃的 onServicesDiscovered() 回调。此问题很少发生,并且可能已在最近的 Android 版本中修复,但由于我们支持 Android 5.0 及更高版本,因此为了高枕无忧,这种预防性解决方法是值得的。

调用后,服务发现的结果将通过 的方法传递,我们应该在回调正文中覆盖该方法,以确保我们收到事件的通知。回调交付后,我们可以安全地使用 的 getServices() 方法浏览可用的服务和特征。discoverServices()BluetoothGattCallbackonServicesDiscovered()BluetoothGattCallbackonServicesDiscovered()BluetoothGatt

下面是一个扩展函数的快速示例,该函数打印出 BLE 设备必须提供的可用服务和特征的所有 UUID:BluetoothGatt

private fun BluetoothGatt.printGattTable() {
    if (services.isEmpty()) {
        Log.i("printGattTable", "No service and characteristic available, call discoverServices() first?")
        return
    }
    services.forEach { service ->
        val characteristicsTable = service.characteristics.joinToString(
            separator = "\n|--",
            prefix = "|--"
        ) { it.uuid.toString() }
        Log.i("printGattTable", "\nService ${service.uuid}\nCharacteristics:\n$characteristicsTable"
        )
    }
}

服务发现作为连接流的一部分

由于服务发现是在建立 BLE 连接后执行的一项基本操作,因此通常最好将其视为连接流的一部分。请考虑对我们的财产进行以下修改:BluetoothGattCallback

private val gattCallback = object : BluetoothGattCallback() {
    override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
        val deviceAddress = gatt.device.address
        if (status == BluetoothGatt.GATT_SUCCESS) {
            if (newState == BluetoothProfile.STATE_CONNECTED) {
                Timber.w("BluetoothGattCallback", "Successfully connected to $deviceAddress")
                bluetoothGatt = gatt
                Handler(Looper.getMainLooper()).post {
                    bluetoothGatt?.discoverServices()
                }
            } else if (...) { /* Omitted for brevity */ }
        } else { /* Omitted for brevity */ }
    }

    override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
        with(gatt) {
            Log.w("BluetoothGattCallback", "Discovered ${services.size} services for ${device.address}")
            printGattTable() // See implementation just above this section
            // Consider connection setup as complete here
        }
    }
}

通过此更改,每个成功的连接都将在连接设置被视为完成之前自动导致对对象的服务发现。如果要创建一个类型类,该类从类中抽象出所有 BLE 实现详细信息,则这一点尤其重要,因为这些类不需要了解服务发现。他们想调用一些函数并完成!另请注意,服务发现是在主线程上执行的。BluetoothGattConnectionManagerActivityActivityconnect()

关于服务和特征的 UUID 的说明

您可能已经注意到,官方蓝牙网站将 GATT 服务特性的 UUID 列为 16 位(2 字节)值(例如,用于电池服务)。如果我们尝试创建一个仅使用 16 位值的实例:0x180Fjava.util.UUID

val batteryServiceUuid = UUID.fromString("180F")

当实例化时,我们很快就会看到崩溃:batteryServiceUuid

java.lang.IllegalArgumentException: Invalid UUID string: 180F

在 Android 上处理 UUID 时,重要的是要知道 16 位值应该与基本 UUID 00000000-0000-1000-8000-00805f9b34fb 一起使用,如官方蓝牙网站的服务发现页面上详细说明(我们认为相当模糊),因此电池服务的完全合格 UUID 是:

val batteryServiceUuid = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")

请求更大的 ATT MTU

在开始执行读写操作之前,我们应该确保发送或接收的数据非常适合连接的 ATT 最大传输单元 (MTU)。ATT 数据包的最大长度由 ATT MTU 确定,ATT MTU 通常在 Android 和 BLE 设备之间协商,作为连接过程的一部分。如果您有兴趣了解有关 ATT MTU 及其如何影响 BLE 吞吐量的更多信息,您可以阅读我们的文章。

在 BLE 连接开始时协商的 ATT MTU 在 Android 设备(跨不同版本和 OEM)和 BLE 设备的组合之间有所不同,因此谨慎的做法是不要对此做出任何假设。但是,如果我们打算使用 BLE 设备交换更大的数据块(通常来自 Android 的应用程序数据超过 20 字节),我们应该请求更大的 ATT MTU,以便每次传输可以交换更多信息。

这可以在 Android 上使用 requestMtu() 方法轻松完成。一个可行的策略是在连接到设备时尝试尽可能大的 ATT MTU 值,Android 源代码中的 gatt_api.h 头文件显示,Android 可以请求的最大可能 MTU 大小(如常量所示)为 517。BluetoothGattGATT_MAX_MTU_SIZE

// Top level declaration
private const val GATT_MAX_MTU_SIZE = 517

// Call site
gatt.requestMtu(GATT_MAX_MTU_SIZE)

从 Android 14 开始即使您的应用不以 API 34 为目标平台,当充当 GATT 客户端的应用在 BLE 连接上调用该方法(即使方法调用中提供了不同的值)时,Android 也会自动协商 ATT MTU 为 517(最大值),并且会忽略后续的方法调用。这让每个人的生活都更轻松,特别是对于涉及多个应用程序连接到同一设备的 BLE 设备用例,因为蓝牙核心规范的 5.2 版确指出,“但是,一旦协商,[ATT MTU] 在此连接期间无法更改。requestMturequestMtu

请求某个 ATT MTU 值后,应发送回调 onMtuChanged() (不出所料,是 的一部分),通知应用请求是否成功以及当前的 ATT MTU 是什么。最终的 ATT MTU 值通常与最初请求的值不同,因为最终值必须在 Android 和 BLE 设备的固件之间协商。BluetoothGattCallback

override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
    Log.w("BluetoothGattCallback", "ATT MTU changed to $mtu, success: ${status == BluetoothGatt.GATT_SUCCESS}")
}

不幸的是,我们还看到,在使用闭源固件时,有时回调无法传递,如果应用程序在继续执行某些操作之前依赖于传递的回调,这可能会阻碍事情的发展。我们的建议是始终假设最坏的情况 - ATT MTU的最小值为23 - 并在使用闭源固件时围绕这一点进行计划,任何成功的调用都被视为额外的奖励。onMtuChanged()onMtuChanged()

在不支持更高 ATT MTU 的 BLE 设备固件上,每次呼叫后协商的 ATT MTU 可能会保持不变,最小值为 23。考虑到大多数读取、写入和指示/通知操作(操作码和属性句柄)的 ATT 标头的 3 个字节,在这种情况下,我们可以发送的最大应用程序字节数为 20 个字节。如果我们的应用执行的写入超过连接的 ATT MTU,则写入将失败,并且我们的回调将显示状态参数 GATT_INVALID_ATTRIBUTE_LENGTHrequestMtu(...)onCharacteristicWrite(...)

执行读写操作

现在我们知道了哪些服务和特征可用,并且我们确信需要传输的任何内容都适合我们连接的 ATT MTU,我们可以继续了解有关每个特征属性的更多信息,并对支持它们的特征执行读写操作。

避免与受限服务/特征交互

如果你正在实现一个像LightBlue®这样的BLE应用程序,它打算与各种BLE设备一起工作,那么有一个不错的小惊喜可能会偷偷溜到你身后,让你的应用程序在现场崩溃——Android 认为某些服务和特征是“受限的”,以任何形式与它们交互都会导致.java.lang.SecurityException

截至撰写本文时,查看GattService.java,Android 限制了与以下类别下的特征和服务的交互:

  • 人机接口设备 (HID) 服务:0x1812

    • HID 信息特征:0x2A4A

    • HID 报告地图:0x2A4B

    • HID 控制点:0x2A4C

    • HID 报告:0x2A4D

  • FIDO 身份验证服务:0xFFFD

  • Android TV 遥控器服务:AB5E0001-5A21-4F05-BC7D-AF01F617B664

  • LE Audio服务:

    • 音量控制服务:0x1844

    • 音量偏移控制服务:0x1845

    • 音频输入控制服务:0x1843

    • 已发布的音频功能服务:0x1850

    • 音频流控制服务:0x184E

    • 广播音频扫描服务:0x184F

    • 听力无障碍服务:0x1854

    • 协调集识别服务:0x1846

分析 BluetoothGattCharacteristic 属性

在对 执行读写操作之前,我们需要确保它们是可读或可写的。否则,操作将失败并显示 or 错误(这两个常量都是 BluetoothGatt 的一部分)。BluetoothGattCharacteristicGATT_READ_NOT_PERMITTEDGATT_WRITE_NOT_PERMITTED

BluetoothGattCharacteristic包含一个 getProperties() 方法,该方法是由常量表示的其属性的位掩码。然后,我们可以在给定常量之间执行按位 AND 来确定特征上是否存在某个属性。BluetoothGattCharacteristic.PROPERTY_*getProperties()PROPERTY_*

现在,我们唯一关心的常量是 、 和 。我们将使用以下扩展函数来确定特征是可读的、可写的还是两者兼而有之:PROPERTY_READPROPERTY_WRITEPROPERTY_WRITE_NO_RESPONSE

fun BluetoothGattCharacteristic.isReadable(): Boolean =
    containsProperty(BluetoothGattCharacteristic.PROPERTY_READ)

fun BluetoothGattCharacteristic.isWritable(): Boolean =
    containsProperty(BluetoothGattCharacteristic.PROPERTY_WRITE)

fun BluetoothGattCharacteristic.isWritableWithoutResponse(): Boolean =
    containsProperty(BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE)

fun BluetoothGattCharacteristic.containsProperty(property: Int): Boolean {
    return properties and property != 0
}

从特征读取

与向特征写入值相比,读取特征的值可能是更常见的操作。它允许我们利用 BLE 设备必须提供的信息,符合我们对服务器-客户端交互的期望,其中服务器(BLE 外围设备)执行一些工作并向客户端提供信息(BLE Central)。例如,测量环境读数的物联网传感器也可能将其电池电量作为电池服务的一部分提供,其中包含我们可以从中读取的电池电量特征。

对可读特征执行读取后,我们将看到一个字节数组(Kotlin 中的 a),这些字节表示在执行读取期间给定特征的值。这一点很重要,因为特征的值可能随时更改。在下一节中,我们将介绍如何获得特征值变化的通知。ByteArray

在处理具有由蓝牙 SIG 标准化的特殊 UUID(分配编号)的特征时,我们可以通读与此处每个特征相关的 XML,以了解如何理解阅读后将遇到的原始字节。对于自定义和专有服务或特征,解析字节的格式将取决于 BLE 固件对某种数据传输协议的实现,其详细信息可能公开,也可能不公开。

对于其余的示例,我们假设我们正在尝试读取 BLE 设备的电池电量值。根据您使用的 BLE 设备,它可能不包含电池服务,因此不包含电池电量特性。此时,选择任何其他可读的可用特征。如果遇到困难,请参阅“连接流”部分的“服务发现”,其中包含在服务发现完成后打印出 BLE 设备上的哪些特征可读的代码。

请记住,在“发现服务”部分中,我们的对象现在包含所有已发现的服务和特征,这些服务和特征可以通过 访问,随后可以访问。此外,回想一下,作为蓝牙规范一部分的 GATT 服务和特性的 UUID 需要与基本 UUID 结合使用,然后才能使用。有了这些知识,我们可以通过执行以下操作来读取 BLE 设备的电池电量特性:BluetoothGattgetService()getCharacteristics()00000000-0000-1000-8000-00805f9b34fb

private fun readBatteryLevel() {
    val batteryServiceUuid = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
    val batteryLevelCharUuid = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
    val batteryLevelChar = gatt
        .getService(batteryServiceUuid)?.getCharacteristic(batteryLevelCharUuid)

    if (batteryLevelChar?.isReadable() == true) {
        gatt.readCharacteristic(batteryLevelChar)
    }
}

请注意,我们如何利用 Kotlin 的可选链接来获得上面粗体部分中的电池电量特性。默认情况下,由于 和 未使用 批注,因此这两种方法都返回 a 和 type — 请注意,单个 “!” 表示这些返回的值是平台类型可能为 null,也可能不为 null。getService()getCharacteristic()@NullableBluetoothGattService!BluetoothGattCharacteristic!

由于官方文档提到**,如果发现的服务或特征中不存在给定的 UUID,这些方法调用确实可以返回 null,因此我们可以(并且应该)附加一个“?”以在调用这些方法时获得 null 安全性**。否则,我们将面临 when either 或返回 null。由于是可选的,我们用来检查它是否为空,以及它确实是一个可读的特征。NullPointerExceptiongetService()getCharacteristic()batteryLevelChar== true

在调用 之后不久,无论读取操作是否成功,回调都应传递到 BluetoothGattCallback 的 onCharacteristicRead()。BluetoothGattreadCharacteristic()

请注意,回调有两个版本,一个已弃用且仅应用于 API < 33,另一个更新版本仅适用于 API >= 33。此较新版本提供读取特征值作为回调方法参数的一部分,并且更具有内存安全性。不幸的是,如果我们支持早于 Android 13 的 Android 版本,我们需要在我们的 中实现这两种回调方法,即使它们可能包含大致相同的逻辑。onCharacteristicRead()BluetoothGattCallback

@Deprecated("Deprecated for Android 13+")
@Suppress("DEPRECATION")
override fun onCharacteristicRead(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic,
    status: Int
) {
    with(characteristic) {
        when (status) {
            BluetoothGatt.GATT_SUCCESS -> {
                Log.i("BluetoothGattCallback", "Read characteristic $uuid:\n${value.toHexString()}")
            }
            BluetoothGatt.GATT_READ_NOT_PERMITTED -> {
                Log.e("BluetoothGattCallback", "Read not permitted for $uuid!")
            }
            else -> {
                Log.e("BluetoothGattCallback", "Characteristic read failed for $uuid, error: $status")
            }
        }
    }
}

override fun onCharacteristicRead(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic,
    value: ByteArray,
    status: Int
) {
    val uuid = characteristic.uuid
    when (status) {
        BluetoothGatt.GATT_SUCCESS -> {
            Log.i("BluetoothGattCallback", "Read characteristic $uuid:\n${value.toHexString()}")
        }
        BluetoothGatt.GATT_READ_NOT_PERMITTED -> {
            Log.e("BluetoothGattCallback", "Read not permitted for $uuid!")
        }
        else -> {
            Log.e("BluetoothGattCallback", "Characteristic read failed for $uuid, error: $status")
        }
    }
}

// ... somewhere outside BluetoothGattCallback
fun ByteArray.toHexString(): String =
    joinToString(separator = " ", prefix = "0x") { String.format("%02X", it) }

像大多数事情一样,这个参数讲述了一个操作成功或失败的整个故事。在特征读取操作的情况下,如果参数不是 ,则很可能是 ,我们的代码希望在尝试执行读取之前检查特征是否包含标志时缓解这种情况。如果读取成功,则可以通过在已弃用的回调方法中调用 的方法(或在 Kotlin 中仅使用该属性)来访问读取数据,或者通过直接引用较新的回调方法的参数来访问读取数据,因为 API >= 33 已弃用 s 方法。BluetoothGattstatusstatusGATT_SUCCESSGATT_READ_NOT_PERMITTEDPROPERTY_READBluetoothGattCharacteristicgetValue()valuevalueBluetoothGattCharacteristicgetValue()

解析和理解数据

运行这段代码时,我们意识到在成功读取后,扩展函数调用了打印“0x64”的读取值。从蓝牙网站提取电池电量特性规范,我们看到该值应该被解释为无符号字节,可能的值介于 0 到 100 之间,以百分比表示电池的充电水平。toHexString()

由于 Java(以及 Kotlin)的原生字节类型是值介于 -127 到 +127 之间的有符号字节,并且我们确信传入的字节将只包含一个字节,因此我们可以简单地调用第一个元素而不必担心溢出(电池电量在 0-100 之间)。ByteArraytoInt()ByteArray

val readBytes: ByteArray // ... obtained from onCharacteristicRead()
val batteryLevel = readBytes.first().toInt() // 0x64 -> 100 (percent)

在其他情况下,您可能要处理几个字节的数据,您需要执行一些字节转换和位掩码来计算最终数值。大多数 BLE 固件实现都使用 little-endian 字节顺序,因此确保以正确的顺序解析字节非常重要。例如,给定大小为 4(32 字节)的 little-endian,最终值可以按如下方式计算:ByteArrayInt

val fourBytes: ByteArray // ... obtained from onCharacteristicRead()
val numericalValue = (fourBytes[3].toInt() and 0xFF shl 24) +
    (fourBytes[2].toInt() and 0xFF shl 16) +
    (fourBytes[1].toInt() and 0xFF shl 8) +
    (fourBytes[0].toInt() and 0xFF)

写入特征

虽然读取特征的值很简单,但将值写入它们稍微复杂一些,但只是稍微复杂一些。要知道的关键是,**有两种主要的写入类型,**支持写入操作的特征通常支持其中一种写入类型,但也可以支持两种写入类型:

  • 写回应。也称为写入请求,此写入类型需要 BLE 设备固件确认它已处理写入或发送错误数据包(如果写入失败)。以WRITE_TYPE_DEFAULT为代表。

  • 写而不回应。也称为写入命令,此写入类型不需要 BLE 设备的确认。尽管在大多数用例中很少发生,但从技术上讲,由于缺乏带宽或处理能力来处理写入,写入可能会被忽略或丢弃。以WRITE_TYPE_NO_RESPONSE为代表。

一般来说,**大多数简单的 BLE 用例都只使用带有响应的写入,**因为吞吐量通常不是这些用例的优先级,而且通常最好是收到写入状态的通知,而不是在黑暗中。对于大多数 BLE 用例,我们建议使用带有 Android 响应的写入,而不是在要编写特性以支持两种写入类型的情况下不带响应的写入。

保证使用无响应写入的用例通常是高吞吐量或低延迟的短突发用例。例如,能够从应用接收大文件的 BLE 设备可能希望使用无响应的写入,并结合使用高 ATT MTU、数据长度扩展(请参阅我们的 DLE 指南)和某种具有 CRC 支持的重试机制,以防 ATT 有效负载被丢弃。连接参数(如连接间隔、从站延迟和监控超时)可能还需要调整,以针对此类用例进行优化,从而使无响应的写入更适合团队(或个人)可以访问移动和 BLE 固件源代码的高级系统实现。

与读取特征类似,在执行写入操作之前,我们应该确保我们要写入的特征支持我们打算使用的写入类型。有关如何执行此检查的详细信息,请参阅解析 BluetoothGattCharacteristic 属性部分中的扩展函数。

与特征阅读场景类似,s 方法再次有两种变体:已弃用的 API 33+ 的旧方法将我们正在编写的 s 方法作为其唯一参数;仅适用于 API 33+ 的较新版本将我们正在编写的有效负载和所需的写入类型作为方法调用的附加参数。BluetoothGatt’writeCharacteristic()BluetoothGattCharacteristic

那么,我们如何为旧方法变体指定写入类型和有效负载呢?我们必须在 using 和 上设置这些字段,但请记住,API 33+ 也已弃用这两个字段,因此在支持 Android 12 及更低版本之外不会使用它们。BluetoothGattCharacteristicsetWriteType()setValue()

下面是对特征执行写入的函数的示例:

fun writeCharacteristic(characteristic: BluetoothGattCharacteristic, payload: ByteArray) {
    val writeType = when {
        characteristic.isWritable() -> BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
        characteristic.isWritableWithoutResponse() -> {
            BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
        }
        else -> error("Characteristic ${characteristic.uuid} cannot be written to")
    }
    bluetoothGatt?.let { gatt ->
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            gatt.writeCharacteristic(characteristic, payload, writeType)
        } else {
            // Fall back to deprecated version of writeCharacteristic for Android <13
            gatt.legacyCharacteristicWrite(characteristic, payload, writeType)
        }
    } ?: error("Not connected to a BLE device!")
}

@TargetApi(Build.VERSION_CODES.S)
@Suppress("DEPRECATION")
private fun BluetoothGatt.legacyCharacteristicWrite(
    characteristic: BluetoothGattCharacteristic,
    value: ByteArray,
    writeType: Int
) {
    characteristic.writeType = writeType
    characteristic.value = value
    writeCharacteristic(characteristic)
}

上面的示例根据特征的属性选择最合适的写入类型。请注意,如果一个特征恰好支持两种写入类型,我们将如何使用带响应的写入 - 只有当它是特征支持的唯一写入类型时,才将不带响应的写入用作写入操作的写入类型。请随意交换顺序,以便优先考虑没有响应的写入,如果这更适合您的用例。

根据写入类型,可能会也可能不会收到 的回调。对于(有响应的写入),此回调是有保证的,但对于(无响应的写入),我们预计回调不会被传递,它仍然会被传递——至少在我们一直在测试的 Pixel 设备中是这样。BluetoothGattCallbackonCharacteristicWrite()WRITE_TYPE_DEFAULTWRITE_TYPE_NO_RESPONSE

下面是解释此行为的 StackOverflow答案。由于此行为的未记录性质,如果特征支持两种写入类型,我们仍建议您使用 over。WRITE_TYPE_DEFAULTWRITE_TYPE_NO_RESPONSE

override fun onCharacteristicWrite(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic,
    status: Int
) {
    with(characteristic) {
        val value = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
            characteristic.value
        } else {
            TODO("Get from cache somewhere as getValue is deprecated for Android 13+")
        }
        when (status) {
            BluetoothGatt.GATT_SUCCESS -> {
                Log.i("BluetoothGattCallback", "Wrote to characteristic $uuid | value: ${value.toHexString()}")
            }
            BluetoothGatt.GATT_INVALID_ATTRIBUTE_LENGTH -> {
                Log.e("BluetoothGattCallback", "Write exceeded connection ATT MTU!")
            }
            BluetoothGatt.GATT_WRITE_NOT_PERMITTED -> {
                Log.e("BluetoothGattCallback", "Write not permitted for $uuid!")
            }
            else -> {
                Log.e("BluetoothGattCallback", "Characteristic write failed for $uuid, error: $status")
            }
        }
    }
}

不出所料,我们的回调需要检查状态是否为处理成功案例之前。如上面的“请求更大的 ATT MTU”部分所述,如果应用程序尝试写入大于连接当前 ATT MTU 的字节数,则可能会获得状态,因此我们应该适当地处理此问题。最后,我们还应该检查 ,我们之前的代码片段应该缓解,因为我们确保在执行写入之前可以写入该特征。onCharacteristicWrite()GATT_SUCCESSGATT_INVALID_ATTRIBUTE_LENGTHGATT_WRITE_NOT_PERMITTED

至于写入的值,通常可以安全地假设,如果状态是你写的值越过了电线。对于在 Android 12 或更早版本上运行的设备,您可以通过访问 来仔细检查是否确实是这种情况,但请注意,此方法已在 Android 13 及更高版本中弃用。GATT_SUCCESSBluetoothGattCharacteristicgetValue()

描述符读取和写入呢?

您可能还记得,描述符是描述特征的属性。从层次结构上讲,描述符嵌套在特征下,类似于特征嵌套在服务下的方式。描述符可预测地由 BluetoothGattDescriptor 对象表示,我们可以通过 检查其权限,就像 一样。getPermissions()BluetoothGattCharacteristicgetProperties()

也就是说,我们强烈建议您在对 BluetoothGattDescriptor 执行读取或写入操作时不要检查它们权限。根据我们的经验,权限标志通常不反映描述符的实际可读性或可写性。

例如,我们将使用的两个最常见的描述符是客户端特征配置描述符 (CCCD) 和特征用户描述描述符 (CUDD)。CCCD 可以写入和读取,因为它控制是否为特定特征启用通知或指示。CUDD 为自定义特征提供人类可读的名称,因此是可读的。但是,Android SDK 的方法在检查这些特征的可读性和可写性时始终返回。getPermissions()false

出于信息目的,下面是一些扩展函数,这些函数允许您根据权限标志的指定方式检查 a 是否可读或可写。BluetoothGattDescriptor

fun BluetoothGattDescriptor.isReadable(): Boolean =
    containsPermission(BluetoothGattDescriptor.PERMISSION_READ) ||
        containsPermission(BluetoothGattDescriptor.PERMISSION_READ_ENCRYPTED) ||
        containsPermission(BluetoothGattDescriptor.PERMISSION_READ_ENCRYPTED_MITM)

fun BluetoothGattDescriptor.isWritable(): Boolean =
    containsPermission(BluetoothGattDescriptor.PERMISSION_WRITE) ||
        containsPermission(BluetoothGattDescriptor.PERMISSION_WRITE_ENCRYPTED) ||
        containsPermission(BluetoothGattDescriptor.PERMISSION_WRITE_ENCRYPTED_MITM) ||
        containsPermission(BluetoothGattDescriptor.PERMISSION_WRITE_SIGNED) ||
        containsPermission(BluetoothGattDescriptor.PERMISSION_WRITE_SIGNED_MITM)

fun BluetoothGattDescriptor.containsPermission(permission: Int): Boolean =
    permissions and permission != 0

如何对描述符执行读取和写入的其余部分与对特征执行读取和写入的方式非常相似(使用类似命名的方法),唯一的例外是不需要为描述符写入指定写入类型。

特征描述符
writeCharacteristicwriteDescriptor
阅读特征readDescriptor
onCharacteristic写入onDescriptor写入
onCharacteristic读取onDescriptor读取

温馨提醒:不要淹没管道!

我们用一个温和的提醒来总结这一部分,即在开始新的操作之前,我们应该始终收到上一个操作的回复。在 Android 上执行任何 BLE 操作时,我们强烈建议您等待回调进入,然后再执行下一个操作。在执行读取和写入的上下文中,这意味着在继续执行其他操作之前等待 和 回调。onCharacteristicRead()onCharacteristicWrite()

对于没有响应的写入,如果写入是一次性的,那么可能没有什么可担心的。但是,如果你正在进行背靠背的写入,那么如果你确实得到了它,那么调整你的写入速度可能是一个好主意,或者如果你没有看到被传递,则用一个大致等于连接间隔的计时器来间隔写入。onCharacteristicWriteonCharacteristicWrite

在本指南的后面部分,我们将指导您实现自己的排队机制,以缓解处理 Android BLE API 时的这些痛点。


订阅通知或指示

现在我们已经熟悉了读取和写入 BLE 设备,我们进入了有趣的部分:通知和指示。

如指南开头的词汇表部分所述,通知和指示是 BLE 设备(外围设备)在特征值发生变化时通知 Android 设备 (Central) 的手段,在实践中,它是外围设备与中心设备通信的主要机制。

将通知和指示分别视为没有响应和有响应的写入的镜像版本会有所帮助,现在只有数据来自服务器(外围设备)而不是客户端(中心)。从移动开发的角度来看,通知和指示之间的唯一区别是,通知不需要客户端在收到数据包时进行确认,而指示确实需要这种确认,因此速度较慢。但是,此确认由 Android 蓝牙堆栈处理,因此我们的应用程序在两者之间不需要执行任何特殊操作。

确保特征支持通知/指示

就像我们在确保特征可以写入或读取特征时检查的其他属性一样,我们可以使用 和 来确保特征支持在订阅其值更改之前发送通知或指示。BluetoothGattCharacteristicPROPERTY_INDICATEPROPERTY_NOTIFY

fun BluetoothGattCharacteristic.isIndicatable(): Boolean =
    containsProperty(BluetoothGattCharacteristic.PROPERTY_INDICATE)

fun BluetoothGattCharacteristic.isNotifiable(): Boolean =
    containsProperty(BluetoothGattCharacteristic.PROPERTY_NOTIFY)

fun BluetoothGattCharacteristic.containsProperty(property: Int): Boolean =
    properties and property != 0

启用和禁用通知或指示

调用 setCharacteristicNotification()

一旦我们确定某个特征支持通知或指示(或两者兼而有之),就需要通知 Android 蓝牙堆栈,该应用打算选择加入或退出特征值更改的通知。为此,我们通过在对象上调用 setCharacteristicNotification() 并确保它返回 true。BluetoothGatt

写入客户端特征配置描述符 (CCCD)

CCCD(客户端特征配置描述符)特殊描述符的名称很少,但它对于订阅通知或指示的过程至关重要。任何支持发送通知或指示的特征都将具有此描述符。写入 CCCD 可启用或禁用 BLE 设备上的通知或指示。

在与 CCCD 交互时,应该期望可以写入和读取此特殊描述符,即使它不包含来自 的 和 权限。PERMISSION_READPERMISSION_WRITEBluetoothGattDescriptorgetPermissions()

根据我们是启用通知还是指示,我们需要将 CCCD 的值设置为 ENABLE_INDICATION_VALUEENABLE_NOTIFICATION_VALUE(均在 中定义),并将该值写入 BLE 设备上的远程描述符。要禁用通知、指示或两者兼而有之,我们只需将DISABLE_NOTIFICATION_VALUE写入 CCCD。BluetoothGattDescriptor

与特征写入方案类似,该方法再次有两个版本:一个用于已弃用的 API < 33,另一个用于 API >= 33。writeDescriptor

执行描述符的函数示例可能如下所示,请注意,我们正在短路属性检查描述符的 UUID 是否与 CCCD UUID 匹配:

fun writeDescriptor(descriptor: BluetoothGattDescriptor, payload: ByteArray) {
    bluetoothGatt?.let { gatt ->
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            gatt.writeDescriptor(descriptor, payload)
        } else {
            // Fall back to deprecated version of writeDescriptor for Android <13
            gatt.legacyDescriptorWrite(descriptor, payload)
        }
    } ?: error("Not connected to a BLE device!")
}

@TargetApi(Build.VERSION_CODES.S)
@Suppress("DEPRECATION")
private fun BluetoothGatt.legacyDescriptorWrite(
    descriptor: BluetoothGattDescriptor,
    value: ByteArray
): Boolean {
    descriptor.value = value
    writeDescriptor(descriptor)
}

把它们绑在一起......

下面的代码片段显示了我们通常如何实现启用和禁用通知或指示的功能。我们尝试使我们的函数尽可能易于使用,这意味着该函数不采用指定我们是否要启用通知或指示的参数。它试图通过检查特征的属性来找出我们启用的适当通知类型。

fun enableNotifications(characteristic: BluetoothGattCharacteristic) {
    val cccdUuid = UUID.fromString(CCC_DESCRIPTOR_UUID)
    val payload = when {
        characteristic.isIndicatable() -> BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
        characteristic.isNotifiable() -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
        else -> {
            Log.e("ConnectionManager", "${characteristic.uuid} doesn't support notifications/indications")
            return
        }
    }

    characteristic.getDescriptor(cccdUuid)?.let { cccDescriptor ->
        if (bluetoothGatt?.setCharacteristicNotification(characteristic, true) == false) {
            Log.e("ConnectionManager", "setCharacteristicNotification failed for ${characteristic.uuid}")
            return
        }
        writeDescriptor(cccDescriptor, payload)
    } ?: Log.e("ConnectionManager", "${characteristic.uuid} doesn't contain the CCC descriptor!")
}

fun disableNotifications(characteristic: BluetoothGattCharacteristic) {
    if (!characteristic.isNotifiable() && !characteristic.isIndicatable()) {
        Log.e("ConnectionManager", "${characteristic.uuid} doesn't support indications/notifications")
        return
    }

    val cccdUuid = UUID.fromString(CCC_DESCRIPTOR_UUID)
    characteristic.getDescriptor(cccdUuid)?.let { cccDescriptor ->
        if (bluetoothGatt?.setCharacteristicNotification(characteristic, false) == false) {
            Log.e("ConnectionManager", "setCharacteristicNotification failed for ${characteristic.uuid}")
            return
        }
        writeDescriptor(cccDescriptor, BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE)
    } ?: Log.e("ConnectionManager", "${characteristic.uuid} doesn't contain the CCC descriptor!")
}

冒着听起来像是破纪录的风险,是的,我们确实需要检查回调中的状态参数,然后才能断定启用或禁用通知或指示已成功。GATT_SUCCESSBluetoothGattCallbackonDescriptorWrite()

一旦我们对特征启用了通知或指示,任何传入的通知或指示都会通过 的回调传递。此回调还有两个需要实现的变体,类似于回调。BluetoothGattCallbackonCharacteristicChanged()onCharacteristicRead()

@Deprecated("Deprecated for Android 13+")
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic
) {
    with(characteristic) {
        Log.i("BluetoothGattCallback", "Characteristic $uuid changed | value: ${value.toHexString()}")
    }
}

override fun onCharacteristicChanged(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic,
    value: ByteArray
) {
    val newValueHex = value.toHexString()
    with(characteristic) {
        Log.i("BluetoothGattCallback", "Characteristic $uuid changed | value: $newValueHex")
    }
}

值得注意的是,我们通常设置通知或指示的流程是连接设置过程的一部分,通常如下所示:

  1. 扫描设备。

  2. 连接到设备。

  3. [可选]请求最大 ATT MTU。

  4. 了解服务和特点。

  5. 启用有关感兴趣特征的通知或指示。

  6. 向更高级别的调用方(通常是 UI 或层)发出信号,表明连接设置已完成,并且 BLE 设备已准备好与之交互(即,现在可以执行读取和写入操作)。ViewModel


与 BLE 设备绑定

如果你做到了这一步,恭喜你。您的应用现在可以连接到 BLE 设备,选择加入或退出特色通知,并执行读写操作!然而,我们的工作还远远没有完成。在本节中,我们将引导您了解什么是绑定以及如何与 BLE 设备绑定。

绑定与配对

您可能已经看到术语“绑定”和“配对”可以互换使用,甚至 Android 的“设置”应用程序也使用“配对新设备”的副本。这导致消费者甚至开发人员认为绑定和配对是一回事。从消费者的角度来看,它们也可能是,但从技术上讲是不准确的。

配对是临时加密密钥的交换,允许交换长期加密密钥(绑定过程)。故事到此结束。

想象一下,当你在公共场所时,你想和一个朋友交换秘密握手,但你不想让别人看到,所以你搭了一个帐篷,当你们俩都在帐篷里时交换秘密握手。搭建帐篷的过程将是配对的同义词,而结合则表现为在帐篷内交换秘密握手(配对过程建立的临时加密通道)。

作为 Android 开发人员,我们唯一关心的是与 BLE 设备建立联系;蓝牙堆栈为我们处理配对过程。

为什么选择邦德?

在与智能恒温器或其他物联网设备等简单设备交互时,通常不需要绑定,因为它们提供了应用程序可以使用的信息。但创建纽带的主要动机是在 BLE Central 和 Perperheral 之间建立加密通信通道。

有些数据包分析器可以“嗅探”通过无线方式进行的 BLE 数据包传输,任何未加密的流量都是公平的。想象一下,一个怀有恶意的人坐在咖啡店里,分析通过无线传输的 BLE 数据包。例如,如果某人的智能手表没有使用 BLE 绑定来加密其与智能手机的通信通道,则任何短信或联系人在智能手表和用户智能手机之间同步时都会暴露出来。

您可以考虑将绑定作为系统实施的一部分的另一个原因是,它允许 Android 应用程序记住并能够重新连接到利用 LE Privacy 的设备,这是一项蓝牙 4.2+ 功能,允许设备定期轮换其 MAC 地址。如果没有绑定,Android 就无法识别特定的蓝牙设备,因为它的 MAC 地址会定期更改——这是设计使然。但是,当 Android 与设备绑定时,它可以派生或解析设备的当前公共 MAC 地址。开发人员需要做的就是保存在初始扫描期间找到的设备的MAC地址。即使设备的 MAC 地址随后发生变化,此初始 MAC 地址也将允许 Android 使用 和 等 API 找到您想要的设备。getRemoteDevice(macAddress: String)getRemoteLeDevice(macAddress: String, addressType: Int)

也就是说,如果您使用的是通用 BLE 设备,并且无法访问其固件源代码,并且设备制造商没有明确提到需要绑定,则通常不需要与设备绑定,除非您将处理敏感信息。

启动债券

绑定请求可以来自中央设备或外围设备,具体取决于系统设计。有多种方法可以启动粘合过程,此处按我们偏好的降序列出:

  1. **Android 启动的:**当 BLE 设备因未经授权的 ATT 请求而发出身份验证不足错误时,让 Android 自动启动绑定过程。

  2. 开发人员发起:通过调用 的 createBond() 方法主动启动绑定过程。BluetoothDevice

  3. 外设启动:如果您有权访问 BLE 设备上运行的固件的源代码,则可以让它主动发送安全请求,以便在连接到中央设备后立即启动绑定过程。

Android 发起的绑定

第一种方法涉及 Android 自行启动绑定,而无需开发人员执行任何操作。当 Android 尝试执行未经授权的 ATT 请求(例如读取外围设备上加密的特征的值)而收到身份验证不足错误代码时,通常会发生这种情况。

对于大多数 Android 设备来说,使用此方法启动绑定是最可靠的。 以这种方式触发粘合过程也恰好是 Apple 在其配件设计指南文档中推荐的技术。这实际上是 iOS 设备上发生的事情,因为开发人员没有明确的方法来启动 iOS 上的绑定过程。

为了实现移动平台之间的对等性,我们建议同时控制其应用程序和 BLE 设备固件的读者尽可能使用此技术,方法是包含应用程序随后读取的加密特征作为连接过程的一部分(在事先执行服务发现之后)。

对于我们测试过的大多数 Android 设备,这足以让 Android 代表我们自动启动与 BLE 设备的绑定过程。与其他两种方法相比,以这种方式做事似乎也有更高的成功机会。

开发人员发起的绑定

接下来是第二种方法,它可能比第一种方法更广为人知,因为它实际上是一个公共 API,我们看到与 iOS 相反,Android SDK 提供了一个公共的 createBond() 方法作为对象的一部分,我们可以使用它来主动让 Android 启动与外围设备的绑定过程。当您导航到 Android 的蓝牙设置并点击附近正在做广告的设备以与之建立联系时,这就是在引擎盖下所说的。BluetoothDevice

一些具有过时说明的网站可能会建议您使用 Java Reflection 来访问该方法的私有重载。尽管如此,正如我们的文章 Android BLE 开发技巧中提到的,Google 已经开始限制私有 API 的使用,并且我们不再建议客户使用私有 API,即使讨论的私有 API 尚未列入灰名单。createBond()

当前面提到的第一种方法失败时,这种启动粘合过程的方法很有帮助。我们已经看到小米和三星的某些设备在使用第一种方法时不会启动粘合,并且对这些设备产生了很好的效果。createBond()createBond()

通话的时机非常重要。如果 BLE 设备上运行的固件不希望绑定或根本不支持绑定,则可能会拒绝绑定请求。我们还看到,根据 OEM 和 Android 版本,以及在提出绑定请求时应用程序是否已连接到 BLE 设备,调用的成功概率各不相同。createBond()createBond()

我们的建议是在尝试使用之前尝试 Android 发起的绑定的第一种方法。如果您的 Android 设备也持续失败,请尝试先连接到 BLE 设备,然后再致电以查看是否有帮助,或者如果您在失败时已连接,请先致电。createBond()createBond()createBond()createBond()connectGatt()createBond()

外设引发的绑定

我们很少建议外围设备启动粘合过程。但是,如果上述两种方法都失败了,并且您可以访问 BLE 设备上运行的固件的源代码,您可以让 BLE 固件主动发送安全请求,该请求将在您选择的时间启动绑定过程,通常是在连接到中央时立即启动。但是,需要注意的是:我们已经在某些 OEM 的 Android 设备上看到了奇怪的行为,这些行为会直接拒绝这种绑定请求。我们还没有看到太多以这种方式建立联系的成功案例,但这是您可以尝试作为最后手段的事情。

“债券确认”对话框

根据您设备上运行的 Android 版本,以及 OEM 是否对其进行了特定修改,通常会向用户显示一个绑定确认对话框,他们是确认 Android 和您的 BLE 设备之间建立绑定的最后发言权。

我们开发过的旧版本 Android(Android 7.0 及更早版本)有时不需要用户明确同意,应用就可以与 BLE 设备绑定。较新版本的 Android 越来越注重隐私,并将 Android 设备的更多控制权交还给用户,我们认为这是朝着正确方向迈出的一步。这也意味着应用程序开发人员不能再依赖默默地成功并以这种方式与 BLE 设备建立联系。与几乎所有其他事情一样,用户教育对于执行和提供良好的应用体验至关重要。createBond()

聆听债券状态变化

债券状态更改更新通过操作为 ACTION_BOND_STATE_CHANGED 的广播传递,并使用 .BroadcastReceiver

**注意:**在 Android 12+ 上运行的应用必须先获得运行时权限,然后才能收听此内容。BLUETOOTH_CONNECTBroadcast

绑定状态更改不会通过 传递,因为应用不一定需要连接到 BLE 设备才能更改其绑定状态。但是,由于 的多播性质,您还将收到有关您可能不感兴趣的其他设备的键定状态更改的通知,因此在执行任何操作之前检查其地址非常重要。BluetoothGattCallbackBroadcastBluetoothDeviceBroadcast

根据官方文件,将始终包含以下附加功能:Broadcast

  • EXTRA_DEVICE:包含其键状态已更改。大多数应用都希望检查设备的属性,以确保交付的内容与感兴趣的设备有关。BluetoothDeviceaddressBroadcast

  • EXTRA_BOND_STATE:表示 的当前键合状态。BluetoothDevice

  • EXTRA_PREVIOUS_BOND_STATE:表示 .基本上,正在从此状态过渡到 中表示的状态。BluetoothDeviceBluetoothDeviceEXTRA_BOND_STATE

while 是一个额外的字段,并且是可能值为 BOND_NONEBOND_BONDING、BOND_BONDED 的额外字段。通过检查这两个额外的字段,我们可以理解给定的键状态的转换,如下所示:EXTRA_DEVICEParcelableEXTRA_BOND_STATEEXTRA_PREVIOUS_BOND_STATEIntIntBluetoothDevice

fun listenToBondStateChanges(context: Context) {
    // Note: BLUETOOTH_CONNECT permission required for Android 12+
    context.applicationContext.registerReceiver(
        broadcastReceiver,
        IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
    )
}

private val broadcastReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        with(intent) {
            if (action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
                val device = parcelableExtraCompat<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
                val previousBondState = getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1)
                val bondState = getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
                val bondTransition = "${previousBondState.toBondStateDescription()} to " +
                    bondState.toBondStateDescription()
                Log.w("Bond state change", "${device?.address} bond state changed | $bondTransition")
            }
        }
    }

    private fun Int.toBondStateDescription() = when(this) {
        BluetoothDevice.BOND_BONDED -> "BONDED"
        BluetoothDevice.BOND_BONDING -> "BONDING"
        BluetoothDevice.BOND_NONE -> "NOT BONDED"
        else -> "ERROR: $this"
    }
}

/**
 * A backwards compatible approach of obtaining a parcelable extra from an [Intent] object.
 *
 * NOTE: Despite the docs stating that [Intent.getParcelableExtra] is deprecated in Android 13,
 * Google has confirmed in https://issuetracker.google.com/issues/240585930#comment6 that the
 * replacement API is buggy for Android 13, and they suggested that developers continue to use the
 * deprecated API for Android 13. The issue will be fixed for Android 14 (U).
 */
internal inline fun <reified T : Parcelable> Intent.parcelableExtraCompat(key: String): T? = when {
    Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU -> getParcelableExtra(key, T::class.java)
    else -> @Suppress("DEPRECATION") getParcelableExtra(key) as? T
}

按需检查设备的绑定状态

如果你的应用不需要知道 BLE 设备的绑定状态的转换,只需要知道它当前处于什么状态,你可以利用 的 getBondState() 方法(或只是 Kotlin 中的属性),它返回相同的可能常量 BOND_NONE、BOND_BONDING BOND_BONDED。 与侦听绑定状态更改类似,从 Android 12 开始需要运行时权限BluetoothDevicebondStateBLUETOOTH_CONNECT

从设备上解绑

遗憾的是,没有公共方法可以删除具有 .虽然有一个私有方法可以使用 Java Reflection 调用,但我们不再建议你按照 Google 对非 SDK 接口的最新立场(一种更花哨的私有 API 说法)来做这件事——尽管你可以自由地自担风险,因为在撰写本文时,它还没有出现在 Android 的私有 API 灰名单上。BluetoothDeviceremoveBond()BluetoothDeviceremoveBond()

fun removeBond(device: BluetoothDevice) {
    try {
        device::class.java.getMethod("removeBond").invoke(device)
    } catch (e: Throwable) {
        Timber.e("Failed to remove bond ${e.localizedMessage}")
    }
}

撇开编程方式不谈,您可以通过指示用户导航到蓝牙设置并从系统 UI 中忘记绑定设备来达到相同的效果,就像 iOS 用户必须经历的那样。


实现基本队列机制

在本节中,我们将提供有关如何为 BLE 操作实现自己的排队机制的一些提示。但是,请注意,我们的方法使用传统的基于回调的方法,而不是像 RxJava 或 Kotlin 协程之类的方法(尽管这将是我们将来可能会讨论的主题)。

正如本指南前面和我们的 Android BLE 陷阱文章中提到的,在 **BLE 应用程序开发方面,以快速方式背靠背执行 BLE 操作是 Android 上出现意外平台行为的最大原因。**如果您一直在关注并实现自己的特征和描述符的读写函数。如果您打算链接这些操作,您将很快意识到,通常只有第一个操作才能成功。所有其他的人似乎都掉进了一个黑洞,再也没有音讯。显然,这并不理想。我们的大多数应用逻辑都依赖于这些读写操作成功完成,然后我们才使用刚刚从设备读取的数据更新 UI。

如果您的 BLE 逻辑仅跨越单个屏幕或 ,并且您的需求非常简单,您可以选择做一些简单的事情,例如在执行另一个 BLE 操作之前等待成功。但是,这根本不是可扩展的,特别是考虑到所有 BLE 操作(描述符读/写、特征读/写、连接、断开连接、执行 MTU 更新请求、服务发现等)在理想情况下必须等待先前的 BLE 操作成功或先失败,然后才能执行它们。ActivityonCharacteristicWrite()

对于 Android 上的大多数 BLE 用例,实现基本的排队机制可以解决许多与并发相关的 BLE 问题的痛点。

为 BLE 操作创建抽象

首先,您需要对构成 BLE 操作的内容进行抽象。我们建议利用 Kotlin 密封,并将每种类型的 BLE 操作表示为父密封类的子类。例如,可以创建具有以下子类的调用:sealed classBleOperationType

  • 连接

  • 断开

  • 特性写入

  • 特点阅读

  • 描述符写入

  • 描述符读取

  • Mtu请求

每个子类都应该封装它所表示的操作成功执行所需的内容——一个需要句柄和一个对象,一个需要句柄、我们想要写入的句柄、要使用的写入类型,当然还有表示写入有效负载的子类。ConnectBluetoothDeviceContextCharacteristicWriteBluetoothDeviceBluetoothGattCharacteristicByteArray

由于每个操作都需要包含一个句柄,我们甚至可以使(每个操作子类)包含一个句柄作为其子类将覆盖的抽象属性。BluetoothDeviceBleOperationTypeBluetoothDevice

/** Abstract sealed class representing a type of BLE operation */
sealed class BleOperationType {
    abstract val device: BluetoothDevice
}

data class Connect(override val device: BluetoothDevice, val context: Context) : BleOperationType()

data class CharacteristicRead(
    override val device: BluetoothDevice,
    val characteristicUuid: UUID
) : BleOperationType()

实现线程安全的 FIFO 队列

现在,我们需要一个队列数据结构来保存需要执行的操作。此队列对象可能位于所有您或您都可以访问的类(可能是单例)中。一个好的候选者可以是处理应用所有 BLE 需求的类型类。ViewModelsActivitiesConnectionManager

此 FIFO(先进先出)队列背后的逻辑很简单:如果操作已排队,并且当前没有正在运行或挂起的操作,则执行此操作。否则,什么都不会发生。挂起的操作完成后,它应该向队列的包含类(例如,ConnectionManager)发出信号,以检查队列中是否有任何操作等待执行。如果至少有一个这样的操作,它就会从要执行的队列中弹出。

由于 BLE 操作可能从不同的线程排队(例如,当 UI 线程响应按钮单击时,甚至从 UI 线程排队),我们希望确保我们的队列是线程安全的。如果您有特殊的线程需求,您可以选择使用 ReentrantLock 来确保只有一个线程可以处理 BLE 操作队列。我们通常选择使用作为包一部分的 ConcurrentLinkedQueue(不带 )来保存我们的操作。除了 queue 属性之外,我们还将维护一个单独的属性来跟踪任何挂起的操作。ReentrantLockjava.util.concurrent

private val operationQueue = ConcurrentLinkedQueue<BleOperationType>()
private var pendingOperation: BleOperationType? = null

使用 @Synchronized注释敏感函数

Kotlin 特别弃用了 synchronized() 函数,该函数允许我们指定任意代码块,这些代码块应受到不同线程的并发执行保护。但是我们仍然可以在函数上使用 @Synchronized 注解,等价于将 Java 方法指定为 。synchronized

将 BLE 操作排队到队列并执行它

对于我们的操作队列,我们将实现一个同步函数,该函数将新操作添到队列中。如果没有挂起的操作,它还应该启动添加的操作。

@Synchronized
private fun enqueueOperation(operation: BleOperationType) {
    operationQueue.add(operation)
    if (pendingOperation == null) {
        doNextOperation()
    }
}

@Synchronized
private fun doNextOperation() {
    if (pendingOperation != null) {
        Log.e("ConnectionManager", "doNextOperation() called when an operation is pending! Aborting.")
        return
    }

    val operation = operationQueue.poll() ?: run {
        Timber.v("Operation queue empty, returning")
        return
    }
    pendingOperation = operation

    when (operation) {
        is Connect -> // operation.device.connectGatt(...)
        is Disconnect -> // ...
        is CharacteristicWrite -> // ...
        is CharacteristicRead -> // ...
        // ...
    }
}

在上面的代码片段中,我们还提供了一个函数示例,该函数实质上是从操作队列的头部弹出一个操作,将其缓存为属性,然后根据操作所表示的子类类型执行该操作。doNextOperation()pendingOperationBleOperationType

信令操作完成

拼图中还有最后一块缺失的拼图。由于依赖于保证执行另一个操作是安全的,因此我们的代码会出现死锁:一旦操作排队并执行完成,我们就无法继续执行任何排队的操作。我们将通过实现一种操作完成信号的方式来解决此问题。enqueueOperationpendingOperationnull

@Synchronized
private fun signalEndOfOperation() {
    Log.d("ConnectionManager", "End of $pendingOperation")
    pendingOperation = null
    if (operationQueue.isNotEmpty()) {
        doNextOperation()
    }
}

需要在 BLE 操作可能达到其最终状态(成功和失败)的位置调用此函数。下面是特征写入操作的示例:signalEndOfOperation()

override fun onCharacteristicWrite(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic,
    status: Int
) {
    // ...

    if (pendingOperation is CharacteristicWrite) {
        signalEndOfOperation()
    }
}

请注意,如果操作的类型为预期类型,则我们仅发出结束信号。这是为了防止(在一定程度上)恶意或意外回调意外取消阻止队列。pendingOperation

我们希望本节能帮助您找到实现排队机制的想法。有关我们的完整实现,请查看开源 GitHub 存储库上的此提交


在后台保持连接(未绑定用例)

对于未绑定的设备,Android 上 BLE 的默认行为是应用拥有与 BLE 设备的连接。相比之下,Android 操作系统拥有绑定设备的连接。这意味着,对于未绑定的用例,如果您的应用程序由于资源限制而作系统在后台终止,或者您的用户将您的应用程序扫开(实际上立即终止它),则 BLE 连接将丢失,您应该会看到大多数 BLE 设备再次开始广告。

利用前台服务

尝试使应用进程尽可能长时间地保持活动状态的最直接方法是使用前台服务。链接的官方文档明确定义了实现您自己的前台服务的步骤,因此,除了推荐一种前台服务类型来表示始终连接的 BLE 用例(如果适用于您的应用)之外,我们不会在这里详细介绍,因为 Android 14+ 需要这样做android:foregroundServiceType="connectedDevice"

构建应用以实现可靠的 BLE 连接

前台服务本质上是在通知抽屉中与持久性横幅一起运行的服务,使用户了解您的应用正在后台执行某些操作。从 Android 13 开始,用户可以关闭此通知。尽管如此,在我们的测试中,这似乎并没有终止关联的前台服务,除非用户使用 Android 任务管理器手动终止应用程序进程。使用前台服务时,由于内存限制,进程被系统终止或挂起的概率非常低,但并非不可能。Service

值得注意的是,当用户在前台服务运行时轻扫您的应用时,您的应用进程会继续存在,但您的堆栈和任何其他面向用户的元素都会被吹走。因此,必须格外小心,以确保您的 BLE 逻辑存在于这些实体之外,就像在处理应用程序的所有 BLE 需求的单例调用中一样。ActivityConnectionManager

通常,您不应该对 BLE 连接的状态做出任何假设。相反,他们应该依赖(或管理应用的 BLE 需求的任何实体)作为唯一的事实来源,并根据 UI 的需求在 和 中相应地更新 UI。ActivitiesFragmentsConnectionManageronCreate()onResume()

局限性和替代技术

使用前台服务也不是完全无懈可击的。Android 版本 9 及更高版本具有自适应电池功能,有时会限制运行前台服务的应用进程的正常运行时间,但根据我们的经验,这似乎是随机发生的。目前,解决此限制的唯一方法是请求用户关闭应用的电池优化,这是深埋在“设置”应用中的一个选项。为了让用户更容易理解他们需要做什么,https://dontkillmyapp.com/ 提供了一些编写得很好的说明,您可以将用户链接到这些说明。

其他 Android 后台处理技术,如依赖和也是可行的。尽管如此,连接事件不会是即时的,如果应用程序在这些事件发生时未运行,则可能会错过一些 BLE 通知或指示。长话短说,如果您的应用不需要在事件发生时了解事件,那么这些其他技术也很好。相反,它可以定期唤醒,再次连接到 BLE 设备(如果在附近),并查询其新数据。AlarmManagerWorkManager


第三方 BLE 库

呸,我们现在快要结束了。此时,您应该了解如何设置所需的应用权限、执行 BLE 扫描、连接到设备、发现其所有功能(服务)、读取或写入特征和描述符,以及在特征值更改时通过通知或指示获得通知的基础知识。你应该为自己感到骄傲!

现在,想象一下你周围有一阵掌声,只为你而来。

如果您小心并且始终以串行方式做事,Android 上的 BLE 是完全可行的,尽管我们这样说可能会略有偏见。我们知道这需要吸收很多东西,想要推出比 Android 自制 BLE 解决方案更久经考验的东西是完全可以的。

可靠的 BLE 库

为了帮助您进行搜索,我们编制了一份简短且可靠的开源第三方 BLE 库列表,这些库可以很好地完成工作:

  • RxAndroidBle:基于 RxJava 并需要了解 RxJava。庞大而活跃的社区。如果项目遵循 Rx 范式,这将是一个不错的选择。巨大的好处:RxAndroidBle 的界面很重,因此很容易测试,因为它们还提供其主要界面的模拟版本

  • Nordic Android BLE 库:我们在 Punch Through 中使用了大量 Nordic Semiconductor 的硬件,他们的 Nordic SDK 是编写嵌入式固件的好方法。这个库是他们抽象出官方 Android BLE SDK 的复杂性和危险性的方法。伟大的社区和对问题有求必应的贡献者。强烈推荐。

  • BleGattCoroutines:基于 Kotlin 协程并需要了解 Kotlin 协程。如果你对 Kotlin 协程感到满意,或者你的应用使用其中的许多协程,这是一个很好的库。

  • SweetBlue:SweetBlue 在 1.x 和 2.x 时代是一个流行的库,但从 3.0 版本开始,它就不再是免费的,也不再是开源的。尽管如此,它基于传统的 Java 风格回调接口,与 Android SDK 中提供的 API 非常相似,并且应该是最适合初学者开始使用的。如果你是一个修补匠,你可以从2.x时代分叉项目,了解它的内部工作原理,并根据你的喜好对其进行自定义。

GitHub 上还有许多其他开源库。尽管如此,我们还是建议我们的读者尝试自己实现 BLE 的点点滴滴,因为我们认为了解所有活动部件并自己实现它们会更有意义。

依赖第三方库的风险

依赖第三方依赖项还意味着,如果 Android 更新将来破坏了库的功能,您的应用很容易在维护或保养方面出现失误。如果您为您的公司这样做,则可能存在许可和 IP 问题。也就是说,如果您仍然认为开源库是要走的路,我们尊重这一决定。


最后的想法...

祝贺!您已经到达了我们的 Android 低功耗蓝牙终极指南的结尾。到目前为止,您应该已经牢牢掌握了在 Android 上构建 BLE 应用程序的基础知识和关键概念。巩固这些知识的最好方法是潜入并进行实验。不要害怕亲自动手编写代码并探索可能性。另外,请记住利用我们的示例和代码存储库来提供帮助。

我们希望您觉得这本终极指南很有价值。如果您想进一步加深对 BLE 的理解,我们建议您将本指南与下面的其他终极指南配对。它将为您提供 BLE 开发的总体视图。

现在,在 Android 上使用 BLE 创造一些令人惊奇的东西!我们很高兴看到您构建的内容。祝您编码愉快!

原文:Android 低功耗蓝牙终极指南 |穿孔 (punchthrough.com)