Android-蓝牙ble(连接通信篇)

50 阅读15分钟

前言

前面我们已经了解了蓝牙ble的基本流程,主要分为:扫描发现蓝牙设备、解析蓝牙广播包、建立GATT连接、mtu协商 发现服务、打开服务、进行通信、断开连接。

上篇已经重点讲解了蓝牙ble的扫描和广播解析,本次我们介绍一下蓝牙的连接过程,对于通信过程我们后面再详细讲解。

蓝牙ble连接概念

当目标设备已经被我们用app发现的时候,我们就需要和蓝牙设备建立连接才能通信,建立连接我们通常会先打开设备的GATT连接,那么什么是GATT?为了说明GATT的本质,我们先了解下ATT。

ATT

ATT:Attribute Protocol。它是一种属性协议,它定义了设备如何被发现,以及设备的读写规则。因此,ATT是一种协议规范。(我们上篇提到的蓝牙广播和扫描也是遵循了ATT的协议的,之所以不在上一篇文章提主要是为了突出扫描和广播的的重点)。

ATT定义设备作为服务端(终端设备)提供拥有关联值的属性集(属性集组成了一个集合服务)让作为客户端的设备(手机/平板)来发现、读、写这些属性;同时服务端能主动通知客户端。

ATT定义了两种角色: 服务端(Server)和客户端(Client)

ATT中的属性包含下面三个内容:

- Attribute Type       : 由UUID(Universally Unique IDentifier)来定义 
- Attribute Handle     : 用来访问Attribute Value 
- A set of Permissions : 控制该Attribute是否可读、可写、属性值是否通过加密链路发送

GATT

image.png

GATT

GATT(Generic Attribute Profile)是Bluetooth Low Energy(BLE)协议的一部分,它是基于ATT的。它定义了设备如何传输和接收数据。他是一种通讯协议的通用数据格式,在设备端,存储了一份名为gatt profile的文件来存储数据。app和设备的进行通信,本质上就是进行GATT的数据交换。

GATT使用的是客户端(APP)-服务器(Device)架构。在这种架构中,服务器设备拥有可供其他设备访问的数据(称为特性,Characteristics)。客户端设备可以读取、写入或者订阅这些数据的变化。

一个GATT服务包含一个或多个特性,每个特性都有一个唯一的UUID(Universally Unique Identifier)用于标识。每个特性也可以包含一个或多个描述符(Descriptors),用于描述特性的属性。

例如,一个心率监测设备可能提供一个心率服务,这个服务包含一个特性,用于报告当前的心率。手机(作为客户端设备)可以连接到这个设备,读取心率特性的值,或者订阅心率的变化。

MTU

MTU,全称为Maximum Transmission Unit,是数据通信中的一个重要概念,指的是一次可以传输的最大数据单元(单位通常是用:Byte)。在蓝牙通信中,MTU大小决定了一次可以发送或接收的最大字节数。

蓝牙的MTU大小可以在连接建立后进行协商。默认的MTU大小为23字节,但是在许多现代设备中,MTU可以被协商到更大的值,从而提高数据传输效率。我们通常所说的最大payload指的就是mtu。在蓝牙4.0的时候,默认的mtu是23个字节,但是具体的mtu为多少,很大程度取决于设备端的芯片。

Service

顾名思义,指代的是服务。例如:

  1. 健康服务(Health Service):提供心率、血压等特征。
  2. 天气服务(Weather Service):提供温度、湿度、天气等特征。
  3. 基础服务(Basic Service):电量、开关等服务。
  4. 状态服务(Status Service):设备处于待配网、设备处于已配网等等

以下是通过nRF展示的已经连接的设备的提供的services

image.png

Services是由

Character:

特征值。他不会独立存在,肯定是依托于某一个Services。他往往代表一个设备特征值,例如电量、开关、亮度、心率等等都是设备等特征,也可以理解为属性。

Descriptor:

是对特征值(Character)的一种描述。例如,我们有一个特征值是心率。那么心率的描述就是:心率是指心脏在一定时间内跳动的次数,通常以每分钟的心跳次数(BPM,即每分钟的跳动次数)来计算。心率是衡量心脏健康和身体活动水平的重要指标。

小结

蓝牙通信主要是通过gatt制定的数据通用格式进行的,这些数据的通用格式是由一个或多个services组成的,其中一个services又由一个或多个character组成的。因此,蓝牙的物理连接主要就是为了打开设备的gatt通道。 这才具备了后续进行通信的充分必要条件。

Android 连接实战

首先,经过上一篇文章的了解,我们已经通过扫描,拿到了蓝牙的扫描结果ScanResult,接下来,我们使用android API来打开GATT通道。

连接API

package android.bluetooth;

public final class BluetoothDevice implements Parcelable, Attributable {
    public BluetoothGatt connectGatt(Context context, boolean autoConnect,
        BluetoothGattCallback callback, int transport,
        boolean opportunistic, int phy, Handler handler){...}
}

蓝牙ble连接的connectGatt是存在多个重载的。我们这里直接介绍参数最多的,因为其余的函数最终还是走这里的。好了。废话不多说,我们来直接看下连接的参数分别有什么作用;

  • context(Context):android赖以生存的上下文。

  • autoConnect(Boolean):如果此参数为 true,系统会在蓝牙设备可用时自动尝试连接。如果为 false,系统会立即尝试连接。

  • callback (BluetoothGattCallback) : 用于接收异步操作的结果,如连接状态改变和服务发现等。

  • transport 参数用于指定用于此连接的传输类型。取值可以是以下几种:

    • TRANSPORT_AUTO (int): 这是默认的传输层模式。系统会自动根据设备和可用的硬件选择最佳的传输模式。

    • TRANSPORT_BREDR (int): 该模式指定使用基本速率/增强数据速率 (BR/EDR) 传输,这是传统的蓝牙连接方式,通常用于较远的距离和较高的数据传输速率。

    • TRANSPORT_LE (int): 该模式指定使用低功耗 (LE) 传输,这是一种针对短距离、低数据传输速率和低功耗的蓝牙连接方式。

    选择哪种传输模式取决于你的应用需要和远程设备的支持。例如,如果你正在开发一个需要长时间运行且电池寿命重要的应用,那么可能会选择 TRANSPORT_LE。相反,如果你需要在较远的距离传输大量数据,那么可能会选择 TRANSPORT_BREDR

  • BluetoothGattCallback 是一个抽象类,用于接收来自 BluetoothGatt 对象的异步操作的回调,这个回调十分重要,后续所有和设备相关的回调都会通过该回调返回结果。以下是一些主要的回调方法及其含义:

    • onConnectionStateChange(BluetoothGatt gatt, int status, int newState): 当远程设备的连接状态发生更改时,会回调此方法。

    • onServicesDiscovered(BluetoothGatt gatt, int status): 当远程设备的服务被发现时,会回调此方法。

    • onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status): 当读取特性操作完成时,会回调此方法。

    • onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status): 当写入特性操作完成时,会回调此方法。

    • onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic): 当启用特性通知并且特性的值发生更改时,会回调此方法。

    • onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status): 当读取描述符操作完成时,会回调此方法。

    • onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status): 当写入描述符操作完成时,会回调此方法。

    • onReliableWriteCompleted(BluetoothGatt gatt, int status): 当可靠写入操作完成时,会回调此方法。

    • onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status): 当读取远程设备的 RSSI 完成时,会回调此方法。

    • onMtuChanged(BluetoothGatt gatt, int mtu, int status): 当 MTU 更改时,会回调此方法。

以上每个方法的 status 参数都是一个表示操作成功或失败的状态代码。如果操作成功, status 将是 BluetoothGatt.GATT_SUCCESS。否则,它将是一个错误代码。

调用示例

fun openGatt(scanResult: ScanResult){
    if (ActivityCompat.checkSelfPermission(
            context,
            Manifest.permission.BLUETOOTH_CONNECT
        ) != PackageManager.PERMISSION_GRANTED
    ) {
        // TODO: No permission
        return
    }
    // 打开gatt连接
    val bleGatt = scanResult.device.connectGatt(context, false, object : BluetoothGattCallback() {
        override fun onConnectionStateChange(
            gatt: BluetoothGatt?, status: Int,
            newState: Int
        ) {
            // 设备连接状态回调
            // 具体参数含义见下文
        }
    })
}

这个回调方法 onConnectionStateChangeBluetoothGattCallback 中用于处理与远程设备的连接状态改变相关的事件。以下是每个参数的详细解释:

  • gatt: BluetoothGatt? - 这是当前连接的 GATT 客户端。你可以使用这个对象来读取、写入、和接收特性的通知。

  • status: Int - 这是连接或操作的状态。如果连接或操作成功,状态将是 BluetoothGatt.GATT_SUCCESS。否则,它将是一个错误代码,表示连接或操作失败的原因。

  • newState: Int - 这是设备的新连接状态。可能的值有:

    • BluetoothProfile.STATE_CONNECTED(值为 2)表示设备已连接。

    • BluetoothProfile.STATE_DISCONNECTED(值为 0)表示设备已断开连接。

    • BluetoothProfile.STATE_CONNECTING(值为 1)表示设备正在连接。

    • BluetoothProfile.STATE_DISCONNECTING(值为 3)表示设备正在断开连接。

当设备的连接状态发生更改时(例如,从未连接状态变为已连接状态),将调用此回调方法。

Bluetooth Gatt的状态

public final class BluetoothGatt implements BluetoothProfile {
    private static final int CONN_STATE_IDLE = 0;  // 初始化
    private static final int CONN_STATE_CONNECTING = 1; // 正在连接
    private static final int CONN_STATE_CONNECTED = 2;  // 已经连接
    private static final int CONN_STATE_DISCONNECTING = 3;// 正在断连
    private static final int CONN_STATE_CLOSED = 4;// 蓝牙GATT已关闭

断开连接和关闭资源

android的断开链接和关闭资源是两个不同的API,他们分别是

class BluetoothDevice{
    public void disconnect();
}

class BluetoothGatt{
    public void close();
}

那这两个方法有什么作用呢?前者仅仅是断开和蓝牙设备的连接。后者是关闭的整个蓝牙在系统内的资源占用。因此,如果有些场景,需要暂时断开蓝牙,后续你是需要重新连接的,那么我们仅调用BluetoothDevice#disconnect即可,如果我们不再使用该蓝牙资源了,那么在调用disconnect之后,必要调用调用BluetoothGatt#close来关闭资源。

打开Gatt通道之后

当我们调用了gatt连接之后,并且设备的newStateBluetoothProfile.STATE_CONNECTED,按理来说我们应该已经可以通信了。

我们前面说过,连接的目的是为了通信,通信的数据是为了交换service-character的数据。所以在正式通信之前,我们还得保证我们要通信的service是存在的。

因此在真正进行通信之前,mtu协商、发现服务、打开服务也成了通信之前的必要准备。

mtu 协商

后面我们来看看mtu协商的API


package android.bluetooth;


public final class BluetoothGatt implements BluetoothProfile {
    // 和设备进行mtu的协商
    public boolean requestMtu(int mtu){...}
    ...
}

API很简单,就是通过上述连接成功拿到的BluetoothGatt对象直接调用requestMtu(int mtu)即可。mtu协商的结果是通过BluetoothGattCallback#onMtuChanged(BluetoothGatt gatt, int mtu, int status)返回的,这个比较简单,就不再多做叙述了。

发现服务

发现服务就是查看,当前设备提供的服务,有没有符合我们要求的service。

发现服务的service API如下:


package android.bluetooth;


public final class BluetoothGatt implements BluetoothProfile {
    // 发现设备里面所有的服务
    public boolean discoverServices();
    // 发现制定UUID的服务
    public boolean discoverServiceByUuid(UUID uuid);
    ...
}

结果是通过BluetoothGattCallback#onServicesDiscovered(BluetoothGatt gatt, int status)返回的,这个比较简单,就不再多做叙述了。

打开服务

我们在发现服务之后,就需要打开特定服务的character的通道,来确保我们的数据通道是正常可用的。

package android.bluetooth;

public final class BluetoothGatt implements BluetoothProfile {
    @RequiresLegacyBluetoothPermission
    @RequiresBluetoothConnectPermission
    @RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT)
    public boolean setCharacteristicNotification(BluetoothGattCharacteristic characteristic,boolean enable) {}

结果是通过BluetoothGattCallback#onCharacteristicChanged(@NonNull BluetoothGatt gatt, @NonNull BluetoothGattCharacteristic characteristic, @NonNull byte[] value)返回的。

BluetoothGattCharacteristic是根据Service和Character确定下来的。 在 Bluetooth GATT 中,每个服务、特性和描述符都由一个 UUID 来唯一标识。这些 UUID 可以是标准的,也可以是自定义的。

以下是一些标准的 UUID 示例:

  • 心率服务:0000180d-0000-1000-8000-00805f9b34fb
  • 心率测量特性:00002a37-0000-1000-8000-00805f9b34fb
  • 设备名称特性:00002a00-0000-1000-8000-00805f9b34fb

这些 UUID 都是按照 Bluetooth SIG 提供的规范定义的。

如果你正在开发自己的设备和服务,你可能需要自定义 UUID。自定义 UUID 通常是随机生成的,例如:

  • 自定义服务:12345678-1234-5678-1234-567812345678
  • 自定义特性:9abcdef0-1234-5678-1234-567812345678

请注意,自定义 UUID 应该避免与标准 UUID 冲突,并且在同一设备或服务中应该是唯一的。

有了Service和Character,我们就可以通过以下方式得到BluetoothGattCharacteristic

val characteristic : BluetoothGattCharacteristic? = gatt?.services?.filter {
    it.uuid == myService // 比较uuid的内容
}?.get(0)?.getCharacteristic(myCharacteristic)

使用注意点

我们发现,上面的mtu协商、发现服务、打开服务的函数返回值都是boolean,包括后面通信要说的writeXXX()的方法。那么这个boolean的值代表什么含义呢?这个比较好理解,首先,蓝牙的通信其实本质上和HTTP是一样的,是一种异步IO。我们看下从我们调用以上API到设备收到数据再回复消息的过程是怎么样的。

image.png

上述的过程有点像寄快递一样,只不过这个快递需要一个回执。

  1. 首先我们调用系统的API将数据给到系统。(将我们的快递给到快递公司)
  2. 系统通过手机上的蓝牙驱动将数据发送到外界。(快递公司将快递的打包之后装车开始运输),然后告诉我们包裹已经顺利寄出去了
  3. 外界的蓝牙目标设备发现这个是发送给他的消息开始处理(收件人收到快递)
  4. 最后处理的结果返回给手机

上述的boolean变量就代表了我们发送的数据是否正确发出,但是不代表设备是否有收到(丢数据就跟丢包裹一样)。

对于蓝牙连接的使用建议

蓝牙的连接是比较耗费资源的。因此,我们在实际开发当中势必要对蓝牙的连接进行一个管理。如果管理不当,内存泄漏、资源浪费、耗费电量等等都是会影响我们app的性能和用户体验的。

连接资源管理器

object BleConnectManager {
    // 定义连接任务对象
    data class ConnectModule(
        val gatt : BluetoothGatt,
        val gattCallback: BluetoothGattCallback,
        val bluetoothDevice: BluetoothDevice
    )
    
    // 用于管理所有已经连接的gatt,K = mac, V = BluetoothGatt
    private val gattMap : MutableMap<String, ConnectModule> = ConcurrentHashMap()
    
    // 根据mac的物理地址获取已存在的gatt,若无则返回空
    fun obtainBluetoothGatt(mac : String) : ConnectModule? = gattMap[mac]
    
    // 获取一个Gatt
    fun getBluetoothGatt(context : Context, autoConnect: Boolean,device: BluetoothDevice) : ConnectModule{
        var connectModule = obtainBluetoothGatt(device.address)
        if (connectModule != null) {
            return connectModule
        }
        val gattCallback = object : BluetoothGattCallback() {
            // ... 省略
        }
        val connectGatt = device.connectGatt(context, autoConnect, gattCallback)
        // 存储gatt
        connectModule = ConnectModule(
            gatt = connectGatt,
            gattCallback = gattCallback,
            bluetoothDevice =  device
        )
        gattMap[device.address] = connectModule
        return connectModule
    }
    
    // 断开连接
    fun disconnect(mac : String){
        gattMap[mac]?.disconnect()
    }
    
    // 断开并且释放gatt资源
    fun disconnectAndCloseGatt(mac : String){
        gattMap.remove(mac)?.let {
            it.disconnect()
            it.close()
        }
    }
}

以上的代码仅仅用来表达对于连接资源管理的重要性,实际代码当中,需要根据业务需求进行改造。

通信

发送数据(Write)

蓝牙写数据的API:

public final class BluetoothGatt implements BluetoothProfile{
    public boolean writeCharacteristic(BluetoothGattCharacteristic characteristic)
}

使用示例:

// 获取要通信的characteristic
val characteristic : BluetoothGattCharacteristic? 
    = gatt?.services?.filter { 
        it.uuid == myService // 比较uuid的内容 }?
        .get(0)?.getCharacteristic(myCharacteristic)
// 准备好下发的数据
val data : ByteArray? = assemableData()
// 设置数据
characteristic?.setValue(data)
// 发送数据
val sendResult = bluetoothGatt.writeCharacteristic(characteristic)

结果是通过BluetoothGattCallback#onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status)返回的,这个比较简单,就不再多做叙述了。

读取数据(Read)

蓝牙读数据的API:

public final class BluetoothGatt implements BluetoothProfile{
    public boolean readCharacteristic(BluetoothGattCharacteristic characteristic)
}

使用示例:

// 获取要通信的characteristic
val characteristic : BluetoothGattCharacteristic? 
    = gatt?.services?.filter { 
        it.uuid == myService // 比较uuid的内容 }?
        .get(0)?.getCharacteristic(myCharacteristic)
// 发送数据
val sendResult = bluetoothGatt.readCharacteristic(characteristic)

结果是通过BluetoothGattCallback#onCharacteristicRead(@NonNull BluetoothGatt gatt, @NonNull BluetoothGattCharacteristic characteristic, @NonNull byte[] value, int status) 返回的,这个比较简单,就不再多做叙述了。

数据的吞吐量

我们知道,数据的发送和接收都伴随着处理的。手机的数据处理目前的性能是比较过剩的,我们这里讨论的主要是设备接收的。通常情况下,设备的处理是FIFO的队列形式,如果我们发送的数据超过数据的吞吐量,那么会导致数据处理不过来。因此,最好在正式通信之前,先和设备进行吞吐量的协商,通常是决定1s内,设备能处理多少的数据包。

总结

以上就是蓝牙的连接和通信相关的分享了。下一篇我们来聊聊,app发送蓝牙广播包要如何使用?