Android BLE开发入门(3) —— 读写

在上一篇文章中,我们详细讨论了BLE的连接和断开连接。在本文中,我们将研究BLE的 读写 ,以及打开和关闭通知

原文: medium.com/@martijn.va...

读写特征值(characteristics)

许多在 Android 上 进行BLE 开发的人都会遇到读写方面的问题。在 Stackoverflow 上,你可以找到许多人建议你可以通过添加延迟来解决这些问题... ... 所有这些都是错误的!人们所面临的问题是由以下两个事实引起的:

  1. 读写操作是异步的。 这意味着您发出的命令将立即返回,稍后您将通过回调函数收到数据。例如,onCharacteristicRead()onCharacteristicWrite()
  2. 一次只能执行一个异步操作。 你必须等待一个操作完成后才能执行下一个操作。如果您查看 BluetoothGatt 的源代码,您会看到一个锁变量在操作启动时被设置,并且在回调完成时被清除。这是谷歌在他们的文档中忘记提到的部分...

第一个事实并不是一个真正的问题,它只是 BLE 的特性。异步编程非常常见,在进行网络请求时也经常使用。然而,第二个问题,是非常棘手的!

下面是从 BluetoothGatt.java 截取的一段代码。它展示了使用一个锁变量 mDeviceBusy, 来确定是否可以启动一个操作,并在执行读操作之前将其设置为 true

ini 复制代码
public boolean readCharacteristic(BluetoothGattCharacteristic characteristic) {
    if ((characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_READ) == 0) {
        return false;
    }

    if (VDBG) Log.d(TAG, "readCharacteristic() - uuid: " + characteristic.getUuid());
    if (mService == null || mClientIf == 0) return false;

    BluetoothGattService service = characteristic.getService();
    if (service == null) return false;

    BluetoothDevice device = service.getDevice();
    if (device == null) return false;

    // 同步块
    synchronized (mDeviceBusy) {
        if (mDeviceBusy) return false;
        mDeviceBusy = true;
    }

    try {
        mService.readCharacteristic(mClientIf, device.getAddress(),
                characteristic.getInstanceId(), AUTHENTICATION_NONE);
    } catch (RemoteException e) {
        Log.e(TAG, "", e);
        mDeviceBusy = false;
        return false;
    }

    return true;
}

当读/写操作完成时, mDeviceBusy 锁再次被设置为 false:

arduino 复制代码
public void onCharacteristicRead(String address, int status, int handle, byte[] value) {
    if (VDBG) {
        Log.d(TAG, "onCharacteristicRead() - Device=" + address
                + " handle=" + handle + " Status=" + status);
    }

    if (!address.equals(mDevice.getAddress())) {
        return;
    }

    // 同步块
    synchronized (mDeviceBusy) {
        mDeviceBusy = false;
    }
....

使用队列

由于同一时刻,一次只能执行一个读/写操作,因此APP应该对 BLE的操作 进行管理。这个问题的解决方案是实现一个操作队列。我前面提到的所有 BLE 库都实现了一个队列!

其思想是首先将要执行的每个命令添加到队列中。然后从队列中取出一个命令,执行该命令,当该命令执行完成时,该命令被标记为"已完成"并从队列中取出。然后可以执行队列中的下一个命令。这样,您就可以随时发出读/写命令,并按照排队的顺序执行这些命令。这使得 BLE 编程简单了一些。 在 iOS 系统中,CoreBluetooth 内部实现了类似的排队机制!

我们需要创建的队列必须是为每个 BluetoothGatt 对象创建一个队列。幸运的是,Android 将处理来自多个 BluetoothGatt 对象的命令排队,所以您不需要担心这个问题。创建队列的方法有很多,在本文中,我将展示如何创建一个简单的队列, 使用 Runnable 来做泛型参数。我们首先使用 Queue 对象声明队列,并声明一个 lock 变量来跟踪一个操作是否正在进行:

arduino 复制代码
private Queue<Runnable> commandQueue;
private boolean commandQueueBusy;

然后,在执行命令时向队列添加一个新的 Runnable。下面是 readCharacteristic 命令的一个示例:

typescript 复制代码
public boolean readCharacteristic(final BluetoothGattCharacteristic characteristic) {
    if(bluetoothGatt == null) {
        Log.e(TAG, "ERROR: Gatt is 'null', ignoring read request");
        return false;
    }

    // 检查 characteristic 是否有效
    if(characteristic == null) {
        Log.e(TAG, "ERROR: Characteristic is 'null', ignoring read request");
        return false;
    }

    // 检查此 characteristic 是否真正具有 READ 属性
    if((characteristic.getProperties() & PROPERTY_READ) == 0 ) {
        Log.e(TAG, "ERROR: Characteristic cannot be read");
        return false;
    }

    // 现在所有检查都已通过,对 read 命令进行排队
    boolean result = commandQueue.add(new Runnable() {
        @Override
        public void run() {
            if(!bluetoothGatt.readCharacteristic(characteristic)) {
                Log.e(TAG, String.format("ERROR: readCharacteristic failed for characteristic: %s", characteristic.getUuid()));
                completedCommand();
            } else {
                Log.d(TAG, String.format("reading characteristic <%s>", characteristic.getUuid()));
                nrTries++;
            }
        }
    });

    if(result) {
        nextCommand();
    } else {
        Log.e(TAG, "ERROR: Could not enqueue read characteristic command");
    }
    return result;
}

在这些方法中,我们首先检查所有的状态是否正常。因为如果我们知道它们会失败,我们就不会向队列添加命令。这还有助于调试应用程序,因为如果您试图对不支持这些操作的特性执行读写操作,您将在日志中看到打印。

Runnable 中,我们实际上调用 readCharacteristic() ,它将向设备发出 read 命令。我们还记录了多少次尝试执行这个命令,因为可能以后还要重试。如果返回 false,我们将记录一个错误并"完成"该命令,以便可以启动队列中的下一个命令。最后,我们调用 nextCommand() 来推动队列开始执行:

typescript 复制代码
private void nextCommand() {
    // 是否有命令正在执行?
    if(commandQueueBusy) {
        return;
    }

    // 检查 bluetoothGatt 是否有效
    if (bluetoothGatt == null) {
        Log.e(TAG, String.format("ERROR: GATT is 'null' for peripheral '%s', clearing command queue", getAddress()));
        commandQueue.clear();
        commandQueueBusy = false;
        return;
    }

    // 执行队列中的下一条命令
    if (commandQueue.size() > 0) {
        final Runnable bluetoothCommand = commandQueue.peek();
        commandQueueBusy = true;
        nrTries = 0;

        bleHandler.post(new Runnable() {
            @Override
            public void run() {
                    try {
                        bluetoothCommand.run();
                    } catch (Exception ex) {
                        Log.e(TAG, String.format("ERROR: Command exception for device '%s'", getName()), ex);
                    }
            }
        });
    }
}

请注意,我们使用 peeck() 从队列中获取 Runnable。这使得 Runnable 保留在队列中,以便我们可以在必要时重试它。

读操作 完成后,结果将出现在你的回调中:

arduino 复制代码
@Override
public void onCharacteristicRead(BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, int status) {
    // 检查 status
    if (status != GATT_SUCCESS) {
        Log.e(TAG, String.format(Locale.ENGLISH,"ERROR: Read failed for characteristic: %s, status %d", characteristic.getUuid(), status));
        completedCommand();
        return;
    }

    // Characteristic has been read so processes it   处理读取的数据
    ...
    // We done, complete the command
    completedCommand();
}

所以如果有错误,我们就记录下来。否则,您将处理 characteristic 的值。请注意,我们仅在处理完新值之后才调用 completedCommand() !这将确保在您处理该值时没有其他命令在运行。

然后我们完成这个命令,通过调用 poll()Runnable 从队列中 移除,并在队列中启动下一个命令:

ini 复制代码
private void completedCommand() {
    commandQueueBusy = false;
    isRetrying = false;
    commandQueue.poll();
    nextCommand();
}

在某些情况下,可能需要重试命令。我们可以很容易地做到这一点,因为 Runnable 仍然在队列中。为了确保不会无休止地重试命令,我们还应该增加重试次数的限制 :

ini 复制代码
private void retryCommand() {
    commandQueueBusy = false;
    Runnable currentCommand = commandQueue.peek();
    if(currentCommand != null) {
        if (nrTries >= MAX_TRIES) {
            // Max retries reached, give up on this one and proceed
            Log.v(TAG, "Max number of tries reached");
            commandQueue.poll();
        } else {
            isRetrying = true;
        }
    }
    nextCommand();
}

写特征值(characteristics)

读操作非常简单,但是写操作需要更多的解释。为了写入数据,必须提供 characteristicbyte arraywrite type 。有几种可能的写类型: WRITE_TYPE_DEFAULTWRITE_TYPE_NO_RESPONSE。如果您使用 WRITE_TYPE_DEFAULT,您将收到来自设备的响应,例如写入成功。如果使用 WRITE_TYPE_NO_RESPONSE,则不会得到写操作的响应。这实际上取决于您的设备和您使用的特征值,您是否应该使用其中之一或其他。有时一个特征值只支持一种写入类型,但是这两种写入类型都由一个特征值支持也并不少见。

在 Android 上,每个特征值都有一个 默认的写入类型,该类型是在创建特征时确定的。下面是实际的代码片段:

ini 复制代码
...
if ((mProperties & PROPERTY_WRITE_NO_RESPONSE) != 0) {
    mWriteType = WRITE_TYPE_NO_RESPONSE;
} else {
    mWriteType = WRITE_TYPE_DEFAULT;
}
...

如你所见,只要该特征值只支持两种写入类型中的一种,就可以很好地工作。但是,如果一个特性同时支持两种写入类型,那么默认值可能不是您想要的,因为 Android 随后将使用 WRITE_TYPE_NO_RESPONSE。因此,请注意,默认的写入类型可能并不总是您想要的!

在写操作之前,你可以通过检查特性的属性来检查它是否支持你想要使用的写入类型:

arduino 复制代码
// Check if this characteristic actually supports this writeType
int writeProperty;
switch (writeType) {
    case WRITE_TYPE_DEFAULT: writeProperty = PROPERTY_WRITE; break;
    case WRITE_TYPE_NO_RESPONSE : writeProperty = PROPERTY_WRITE_NO_RESPONSE; break;
    case WRITE_TYPE_SIGNED : writeProperty = PROPERTY_SIGNED_WRITE; break;
    default: writeProperty = 0; break;
}
if((characteristic.getProperties() & writeProperty) == 0 ) {
    Log.e(TAG, String.format(Locale.ENGLISH,"ERROR: Characteristic <%s> does not support writeType '%s'", characteristic.getUuid(), writeTypeToString(writeType)));
    return false;
}

我建议您始终显式地设置您想要使用的写类型,并且不要依赖于 Android 已经选择的默认类型!因此,将一个字节数组 bytesToWrite 写入操作如下:

less 复制代码
characteristic.setValue(bytesToWrite);
characteristic.setWriteType(writeType);
if (!bluetoothGatt.writeCharacteristic(characteristic)) {
    Log.e(TAG, String.format("ERROR: writeCharacteristic failed for characteristic: %s", characteristic.getUuid()));
    completedCommand();
} else {
    Log.d(TAG, String.format("writing <%s> to characteristic <%s>", bytes2String(bytesToWrite), characteristic.getUuid()));
    nrTries++;
}

打开和关闭通知

除了读写操作之外,您还可以打开或关闭通知。如果您打开通知,设备会在被操作或者有新数据时, 主动通知你。

在Android上, 要打开通知,你要做两件事:

1、调用 setCharacteristicNotification 方法, 2、将 12 作为 16位的 无符号整形,写入特征值的客户特性配置(CCC)描述符。CCC 描述符具有短的 UUID 2902

你可能想知道为什么你需要写 12 。这是因为底层有 NotificationsIndications 。接收到的 Indications 需要通过蓝牙栈来确认,而 Notifications 不需要确认。通过使用 Indications ,设备将有效地知道已经收到数据,并可以从本地存储中删除数据。但是,从 Android 应用程序的角度来看,这两种情况并没有什么不同: 在这两种情况下,你将只会收到一个新的字节数组,而栈会在接收到指示时自动处理确认。因此,值 1 用于打开 Notifications ,值2用于 Indications 。要关闭它们,请写 0 。因此,在写入描述符之前,您需要弄清楚自己需要写什么。

在 iOS 上,有一个 setNotify() 方法可以为您完成所有工作。下面是一个如何在 Android 上做同样事情的例子。它首先进行一些安全性检查,然后确定向描述符写入什么值,然后对必要的调用进行排队。

java 复制代码
private final String CCC_DESCRIPTOR_UUID = "00002902-0000-1000-8000-00805f9b34fb";


public boolean setNotify(BluetoothGattCharacteristic characteristic, final boolean enable) {
    // 检查 characteristic 是否为空
    if(characteristic == null) {
        Log.e(TAG, "ERROR: Characteristic is 'null', ignoring setNotify request");
        return false;
    }

    // 获取 Descriptor
    final BluetoothGattDescriptor descriptor = characteristic.getDescriptor(UUID.fromString(CCC_DESCRIPTOR_UUID));
    if(descriptor == null) {
        Log.e(TAG, String.format("ERROR: Could not get CCC descriptor for characteristic %s", characteristic.getUuid()));
        return false;
    }

    //  判断 characteristic 是否有 NOTIFY 或 INDICATE 属性,并写入正确的值 
    
    byte[] value;
    int properties = characteristic.getProperties();
    if ((properties & PROPERTY_NOTIFY) > 0) {
        value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
    } else if ((properties & PROPERTY_INDICATE) > 0) {
        value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE;
    } else {
        Log.e(TAG, String.format("ERROR: Characteristic %s does not have notify or indicate property", characteristic.getUuid()));
        return false;
    }
    final byte[] finalValue = enable ? value : BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE;

    // Queue Runnable to turn on/off the notification now that all checks have been passed
    boolean result = commandQueue.add(new Runnable() {
        @Override
        public void run() {
            // First set notification for Gatt object  if(!bluetoothGatt.setCharacteristicNotification(descriptor.getCharacteristic(), enable)) {
                Log.e(TAG, String.format("ERROR: setCharacteristicNotification failed for descriptor: %s", descriptor.getUuid()));
            }

            // Then write to descriptor
            descriptor.setValue(finalValue);
            boolean result;
            result = bluetoothGatt.writeDescriptor(descriptor);
            if(!result) {
                Log.e(TAG, String.format("ERROR: writeDescriptor failed for descriptor: %s", descriptor.getUuid()));
                completedCommand();
            } else {
                nrTries++;
            }
        }
    });

    if(result) {
        nextCommand();
    } else {
        Log.e(TAG, "ERROR: Could not enqueue write command");
    }

    return result;
}

在写入描述符之后,您将在 onDescriptorWrite方法 上获得一个回调。在这里,您必须区分对 注册 描述符的写操作和对其他描述符的写操作。在处理回调时,我们还应该跟踪当前通知的特征。

scss 复制代码
@Override
public void onDescriptorWrite(BluetoothGatt gatt, final BluetoothGattDescriptor descriptor, final int status) {
    // Do some checks first
    final BluetoothGattCharacteristic parentCharacteristic = descriptor.getCharacteristic();
    if(status!= GATT_SUCCESS) {
        Log.e(TAG, String.format("ERROR: Write descriptor failed value <%s>, device: %s, characteristic: %s", bytes2String(currentWriteBytes), getAddress(), parentCharacteristic.getUuid()));
    }

    // 判断 Descriptor 是否是我们注册通知的Descriptor 
    if(descriptor.getUuid().equals(UUID.fromString(CCC_DESCRIPTOR_UUID))) {
        if(status==GATT_SUCCESS) {
            // Check if we were turning notify on or off
            byte[] value = descriptor.getValue();
            if (value != null) {
                if (value[0] != 0) {
                    // Notify set to on, add it to the set of notifying characteristics          notifyingCharacteristics.add(parentCharacteristic.getUuid());
                    }
                } else {
                    // Notify was turned off, so remove it from the set of notifying characteristics               notifyingCharacteristics.remove(parentCharacteristic.getUuid());
                }
            }
        }
        // This was a setNotify operation
        ....
    } else {
        // This was a normal descriptor write....
        ...
        });
    }
    completedCommand();
}

如果您希望稍后了解正在通知的特征值,现在可以调用 isNotifying()

typescript 复制代码
public boolean isNotifying(BluetoothGattCharacteristic characteristic) {
    return notifyingCharacteristics.contains(characteristic.getUuid());
}

限制通知数量

不幸的是,您无法打开任意多的通知。在 Android 5中,你只能打开15个通知。在旧版本的安卓系统中,这个数字是7,之前是4。对于我见过的大多数设备来说,15个就足够了,不会给你带来麻烦。然而,一旦你不再需要通知,再次关闭它们是一个很好的做法。

多线程问题

现在我们可以进行读/写和打开/关闭通知的操作,我们已经准备好了一些实际使用。我一直认为 BLE 设备分为两类:

  • 简单设备 例如,符合蓝牙标准的体温计。这样的设备只是简单的使用,你只需打开通知, 然后数据开始流入。不需要进行写入操作,只需订阅通知和读取系统信息。

  • 复杂设备 这些设备可以是任何类型的设备,但通常使用 专有协议。这些协议通常不是为BLE设计的,而只是将内部串行通信接口通过一个发送特征和一个接收特征转换为BLE。另外,设备之所以被称为"复杂",是因为协议要求您执行一系列命令来完成身份验证序列、选择/更新用户、设置设备参数、获取存储的数据等操作。

对于简单的设备,遇到线程问题的几率很低,但对于复杂的设备,您确实需要注意。这是因为读、写和通知都是交错执行的,一个可能会干扰另一个,特别是如果你的设备流数据频率很高,比如30Hz 左右。

典型的线程问题是这样的:

  • 收到一个通知

  • 你将事件分发到自己的线程进行处理

  • 你开始处理接收到的数据

  • 与此同时,一个新的通知进来并覆盖 BluetoothGattCharacteristic 对象中的前一个值

  • 如果处理速度太慢,可能会丢失第一个通知的值

造成这类问题的原因是:

  • 一旦 通知发送完成Android 将发送下一个通知(如果有的话)。由于您在另一个线程上发送了处理,因此您完成了通知的传递,而 Android 继续传递通知。

  • Android在 内部复用BluetoothGattCharacteristic对象 。它们在服务发现时创建,之后会被 复用。因此,当收到通知时,Android 将值放入特征对象中。如果其他线程也在处理相同的特征对象,就会出现竞争条件,结果完全取决于时间的先后顺序。

对于这种线程问题的明显保护措施是 始终对字节数组进行复制。当您接收到数据时,立即进行复制,并在复制品上进行处理。

例如:

java 复制代码
@Override
public void onCharacteristicChanged(BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
    // Copy the byte array so we have a threadsafe copy
    final byte[] value = new byte[characteristic.getValue().length];
    System.arraycopy(characteristic.getValue(), 0, value, 0, characteristic.getValue().length );

    // Characteristic has new value so pass it on for processing
    bleHandler.post(new Runnable() {
        @Override
        public void run() {         myProcessor.onCharacteristicUpdate(BluetoothPeripheral.this, value, characteristic);
        }
    });
}

其他线程指南

在Android上进行BLE开发时,还有一些额外的指南需要遵循。由于BLE是异步的,会涉及到很多线程操作。

在Android 上:

  • BLE的搜索结果会转发到主线程

  • BluetoothGattCallback 的回调结果都会运行在 Binder 线程。

因此,扫描结果不是问题,你可能会希望他们运行在主线程。但是 Binder 线程更棘手。当你在一个 Binder 线程上收到一些东西时,Android 不会发送任何新的回调,直到你的代码在 Binder 线程上完成。因此,一般来说,应该避免在 Binder 线程上执行大量操作,因为在执行该操作时会阻塞新的回调。除了在 Binder 线程上停留时间过长外,你还绝对不能阻塞它!因此,不要使用 usleep() 或类似的方法!此外,在 Binder 线程上执行新的 BluetoothGatt 调用也是不可取的,尽管大多数调用都是异步的。

我建议采取以下措施:

  • 始终在你自己选择的线程调用 BluetoothGatt,甚至可能从 UI 线程 调用 BluetoothGatt
  • 尽快离开 Binder 线程,并且绝对 不要阻塞 它们。

实现上述操作的最简单方法是创建一个专用的 Handler 对象,并使用该对象处理数据和转发命令。这样就能解决问题了。如果您回头看一下 onCharacteristicUpdate 回调的代码示例,您将看到我已经使用了一个处理程序来传递数据。

如何定义?

ini 复制代码
Handler bleHandler = new Handler();

如果你想在主线程上运行你的处理程序,你应该这样声明它:

ini 复制代码
Handler bleHandler = new Handler(Looper.getMainLooper());

如果向上滚动查看 nextCommand() 方法,您将看到我们在自己的 Handler 上执行每个 Runnable,因此我们确保所有命令永远不会在 Binder 线程上执行。

相关推荐
新子y3 小时前
【操作记录】我的 MNN Android LLM 编译学习笔记记录(一)
android·学习·mnn
lincats4 小时前
一步一步学习使用FireMonkey动画(1) 使用动画组件为窗体添加动态效果
android·ide·delphi·livebindings·delphi 12.3·firemonkey
想想吴5 小时前
Android.bp 基础
android·安卓·android.bp
写点啥呢12 小时前
Android为ijkplayer设置音频发音类型usage
android·音视频·usage·mediaplayer·jikplayer
coder_pig17 小时前
🤡 公司Android老项目升级踩坑小记
android·flutter·gradle
死就死在补习班18 小时前
Android系统源码分析Input - InputReader读取事件
android
死就死在补习班18 小时前
Android系统源码分析Input - InputChannel通信
android
死就死在补习班18 小时前
Android系统源码分析Input - 设备添加流程
android
死就死在补习班18 小时前
Android系统源码分析Input - 启动流程
android
tom4i18 小时前
Launcher3 to Launchpad 01 布局修改
android