Android BLE开发入门(1) —— 扫描

5,171 阅读5分钟

前段时间一直在接触Android 低功耗蓝牙的开发,这东西实在是太坑了。 用起来不稳定,总是会出现一些莫名其妙的问题, Google 文档描述的也不是很清晰。看其他人写的博客,也是一脸懵逼的去尝试理解Gatt协议, 不能很好的入门。

直到看到一篇英文博客, 写的非常详细, 原作者也分享了自己遇到的一些坑和解决方案。在此分享给大家。 由于文章是原作者2019年写的, 可能内容不是很新。 我也会做一些补充。 也欢迎大家补充讨论。

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

1、 前言

去年我学习了在iOS上如何开发低功耗蓝牙(Bluetooth Low Energy, 简称BLE)应用。在iOS上进行BLE开发是比较简单的。 下一步我们将把它移植到Android上... 移植起来会比较难嘛

1_nKJ3vBKV9E7FjasxfQYXVA.jpeg

现在我可以说, 这是一件比较难的事情。需要付出相当大的努力去优化一个稳定的版本, 运行在主流机型上。我在社区浏览了很多信息,有一些信息是错误的,有一些非常有用。在这一系列文章中,我想总结我的发现, 所以你就不用像我刚开始一样,花很多时间去Google 这些信息

2、为什么在Android上进行BLE开发如此艰难

主要由于一下原因:

2.1 Google文档关于BLE开发的描述非常简单

文档缺乏一些重要信息,并且有一部分API已经过时了。示例应用程序也没有完全显示如何正确操作。仅有一些开源项目来告诉你如何进行正确操作。 Stuart Kent’s presentation是一个比较好的BLE入门指引。一些更高级的技巧Nordic’s guide是一个不错的选择。

2.2 Android BLE api’s 写的很low

相比Apple 为 iOS设计的CoreBluetooth API, 轻松了实现了BLE的扫描,连接,管理 bug处理等, Android的API设计显然更混乱一些。

在Android上, 比较知名的BLE开源库有:

在iOS上开发BLE比较简单这个就不清楚了,没有接触过。 国内开源的话推荐 FastBle.

2.3 第三方手机厂商对Android BLE协议栈的修改

一些手机厂商对AOSP源码进行魔改,对Android BLE提供自己的实现。导致开发者的APP在不同的手机有不同的实现。同时因为硬件的差异, 导致BLE APP 兼容起来比较困难。

2.4 有一些已知(未知)的bug在Android 系统代码里面

在Android 4,5,6中,有很多bug需要处理。之后的Android版本bug少了很多,但是还有部分bug没有比较好的解决方案。 例如,建立BLE连接时,偶尔离奇的返回133的错误码。后面会提到它...

我不敢声称, 完全的解决了遇到的所有问题。 我只是把他们优化到一个 “可以接受的水平”...

让我们讨论一下我学到的....首先开始BLE扫描

这篇文章最低版本(minimal version)为Android 6。

3、搜索设备

连接BLE设备之前首先需要搜索到设备,你需要通过BluetoothAdapter 获取到BluetoothLeScanner, 然后调用 startScan 开始进行搜索, 你可以提供 filters对扫描结果进行过滤, 可以用 scanSettings对扫描进行设置。 提供scanCallback来进行接受扫描结果。

BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
BluetoothLeScanner scanner = adapter.getBluetoothLeScanner();

if (scanner != null) {
    scanner.startScan(filters, scanSettings, scanCallback);
    Log.d(TAG, "scan started");
}  else {
    Log.e(TAG, "could not get scanner object");
}

scanner会开始会根据你的 filters 进行扫描,发现设备后会回调 scanCallback

private final ScanCallback scanCallback = new ScanCallback() {
    @Override
    public void onScanResult(int callbackType, ScanResult result) {
        BluetoothDevice device = result.getDevice();
        // ...do whatever you want with this found device
    }

    @Override
    public void onBatchScanResults(List<ScanResult> results) {
        // Ignore for now
    }

    @Override
    public void onScanFailed(int errorCode) {
        // Ignore for now
    }
};

当获取到ScanResult时, 你可以从中获取到BluetoothDevice对象。 你可以尝试连接该设备。 在连接之前, 我们在讨论一下扫描。

除了BluetoothDevice对象 ,ScanResult 还包含了一些设备的其他信息

  • 设备数据(advertisement data) 一个与设备相关的字节流数组,大部分设备包含“name” 和 “service UUIDs”信息, 搜索时可以用这些特性信息去过滤。
  • RSSI值 (signal strength) 信号强度,这个可以判断设备在周围的距离。
  • 还有一些其他信息, 可以看Google文档详细了解。

注意: 不要再Activity中进行BLE相关操作, Activity会被系统不断重建, 如果在Activity中进行扫描可能会被启动多次。 更糟糕的是, Activity重建的过程中,BLE连接也会被打断。

3.1 设置 scan filters

扫描前需要设置一些 filters, 如果你不设置filters, 你将会扫描到你周边的所有设备。 这可能也是你想要的结果, 但是有时你会遇到只搜索特定的设备, 可能是特定的name或者mac地址。

3.1.1 通过特定的 service UUID 搜索设备

通过设置 service UUIDs 可以帮助你找到你想要设备。

举个例子, 血压监测仪在BLE的标准中“Blood Pressure Service” 使用UUID 1810.一般硬件厂商在设备制造时, 会告诉用户设备的service UUID 。 开发者可以通过service UUID快速搜索到设备。

举个例子: 如何搜索出血压监测仪service?

UUID BLP_SERVICE_UUID = UUID.fromString("00001810-0000-1000-8000-00805f9b34fb");
UUID[] serviceUUIDs = new UUID[]{BLP_SERVICE_UUID};
List<ScanFilter> filters = null;
if(serviceUUIDs != null) {
    filters = new ArrayList<>();
    for (UUID serviceUUID : serviceUUIDs) {
        ScanFilter filter = new ScanFilter.Builder()
                .setServiceUuid(new ParcelUuid(serviceUUID))
                .build();
        filters.add(filter);
    }
}
scanner.startScan(filters, scanSettings, scanCallback);

3.1.2 通过 device name 搜索

通过设备名称搜索主要有两种使用场景,搜索一个指定设备或者搜索一个指定设备model。 我的 Polar H7 宣传自己是“Polar H7 391BB014” ,后面的部分(“391BB014”)是一个独一无二的数字,前面的部分是Polar H7 所有设备的前缀。这是一个非常普遍的做法。

不幸的是,通过device name搜索设备只能搜索特定设备, 而且只能匹配全称。

举个例子

String[] names = new String[]{"Polar H7 391BB014"};
List<ScanFilter> filters = null;
if(names != null) {
    filters = new ArrayList<>();
    for (String name : names) {
        ScanFilter filter = new ScanFilter.Builder()
                .setDeviceName(name)
                .build();
        filters.add(filter);
    }
}
scanner.startScan(filters, scanSettings, scanCallback);

3.1.3 通过Mac地址搜索

过滤Mac地址有一些特殊, 通常情况下,你并不知道设备的Mac地址。 除非你之前扫描过该设备,并且保存了Mac地址。

但是有时, 你购买的设备Mac地址会写在包装盒上。特别是一些医疗设备。

通常情况下, 通过Mac地址主要是扫描一些已经连接过的设备(to reconnect to known devices) ’

举个例子:

String[] peripheralAddresses = new String[]{"01:0A:5C:7D:D0:1A"};
// Build filters list
List<ScanFilter> filters = null;
if (peripheralAddresses != null) {
    filters = new ArrayList<>();
    for (String address : peripheralAddresses) {
        ScanFilter filter = new ScanFilter.Builder()
                .setDeviceAddress(address)
                .build();
        filters.add(filter);
    }
}
scanner.startScan(filters, scanSettings, scanByServiceUUIDCallback);

你已经大概率弄清楚如果使用这些条件过滤。

4、定义 ScanSettings

ScanSettings控制Android扫描的行为,有很多属性可以进行设置,下面是一个ScanSettings的例子。在这个设置中我们使用 “low power” 但是非常灵敏的发现设备。换句话说,它会间歇性扫描。 但当他发现设备是,会立刻响应。

ScanSettings scanSettings = new ScanSettings.Builder()
        .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
        .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
        .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
        .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
        .setReportDelay(0L)
        .build();

让我们看看他们的含义:

4.1 ScanMode

这是到目前为止最重要的一个属性,它控制着BLE扫描的时机和时间。

因为BLE扫描是一件非常消耗手机电量的工作, 你必须加以控制,不能很快消耗完用户手机电量。

主要有四种扫描模式:

4.1.1 SCAN_MODE_LOW_POWER

这个是Android默认的扫描模式,耗电量最小。如果扫描不再前台,则强制执行此模式。

在这种模式下, Android会扫喵0.5s,暂停4.5s.

4.1.2 SCAN_MODE_BALANCED

平衡模式, 平衡扫描频率和耗电量的关系。

在这种模式下,Android会扫描2s, 暂停3s。 这是一种妥协模式。

4.1.3 SCAN_MODE_LOW_LATENCY

连续不断的扫描, 建议应用在前台时使用。但会消耗比较多的电量。 扫描结果也会比较快一些。

4.1.4 SCAN_MODE_OPPORTUNISTIC

这种模式下, 只会监听其他APP的扫描结果回调。它无法发现你想发现的设备。

4.2 Callback type

这个属性决定搜索到设备, 回调的策略

  • CALLBACK_TYPE_ALL_MATCHES 每搜索到一个新设备, 就会即时回调。
  • CALLBACK_TYPE_FIRST_MATCH 只有搜索到第一个设备时回调, 随后扫描到其他设备不进行回调。
  • CALLBACK_TYPE_MATCH_LOST 这个有些诡异,当发现第一个设备,不再搜索到其他设备后回调。

在实践中, 只会使用到CALLBACK_TYPE_ALL_MATCHES和CALLBACK_TYPE_FIRST_MATCH, 具体的选择要根据你的使用场景而定。

4.3 Match mode

这个属性决定如果定义 搜索“match”到一个设备

  • MATCH_MODE_AGGRESSIVE 即使信号微弱,也能匹配到设备。
  • MATCH_MODE_STICKY 与MATCH_MODE_AGGRESSIVE相反, 需要收到设备比较强大的信号才能搜索到。··

4.4 Number of matches

这个属性控制需要匹配多少设备。

  • MATCH_NUM_ONE_ADVERTISEMENT
  • MATCH_NUM_FEW_ADVERTISEMENT
  • MATCH_NUM_FEW_ADVERTISEMENT

4.5 Report delay

你可以设置扫描结果回调的延时,如果延时时间大于0,Android将会收集搜索结果,并在延迟时间到达后通知。 这种情况会回调onBatchScanResults方法, 而不是onScanResult 方法。

5、缓存Android蓝牙栈

BLE搜索不仅仅是回调你周围的一些设备信息,同时蓝牙协议栈底层会把这些设备信息缓存起来。会缓存设备的一些重要信息,例如名称,Mac地址,设备类型(经典蓝牙,低功耗蓝牙)。这些信息会缓存在文件中, 当你需要连接某个设备时, 蓝牙协议栈首先会在文件里读取设备信息, 读取到之后尝试连接设备。关键在于 依靠单独的Mac地址信息是无法成功连接到设备。

5.1 清除缓存信息

搜索缓存的设备信息, 不会一直存在。 有三种情况会清除缓存。

  • 重启蓝牙
  • 重启手机
  • 手动的在设置中清除缓存

这对开发者来说是一个痛点,重启手机会经常出现。 用户打开飞行模式时,蓝牙也会被关闭。除此之外。 各个手机制造商也有区别。 我尝试了一些三星手机, 重启蓝牙并不会清除缓存。

这意味着你不能单独依靠Android缓存的设备信息。可能在缓存中找不到你想要的设备。假设你使用设备的Mac地址对设备进行重新连接,你可以检测设备类型是否是DEVICE_TYPE_UNKNOWN, 如果是, 说明设备并没有在缓存中存在。

// Get device object for a mac address
BluetoothDevice device = bluetoothAdapter.getRemoteDevice(peripheralAddress)
// Check if the peripheral is cached or not
int deviceType = device.getType();
if(deviceType == BluetoothDevice.DEVICE_TYPE_UNKNOWN) {
    // The peripheral is not cached
} else {
    // The peripheral is cached
}

如果你在连接设备之前,并没有进行搜索。 这个信息需要注意

6、 连续不断的扫描?

总之, 你不能持续不间断的进行扫喵,因为这是一件非常耗电量的工作。会消耗很多用户的电量。

如果你想不间断的扫描来持续的发现设备。请使用SCAN_MODE_LOW_POWER扫描模式并限制扫描。例如应用只有在前台扫描,退到后台不进行扫描。或者使用间歇性扫描。

Google对持续性扫描做了一些限制,以下是对扫描做出的一些改变:

7、连续不断的在后台扫描

Google已经对连续不断的前台扫描做了很多限制, 如果你想连续不断的在后台扫描, 将会面临更多的挑战。

最主要的问题时, 新版Android系统对后台Service的运行时间做了限制。典型情况下, 后台Service存活10分钟之后会被系统杀掉。

这里提供一些建议参考:

8、检查你的蓝牙状态和权限

蓝牙扫描需要以下权限:

<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

确保你的应用获取到了这些权限。ACCESS_COARSE_LOCATION 是危险权限, 因此你需要动态获取。

private boolean hasPermissions() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        if (getApplicationContext().checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
            requestPermissions(new String[] { Manifest.permission.ACCESS_COARSE_LOCATION }, ACCESS_COARSE_LOCATION_REQUEST);
            return false;
        }
    }
    return true;
}

同时确保蓝牙状态是打开的,如果不是打开的,询问用户打开蓝牙:

BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
if (!bluetoothAdapter.isEnabled()) {
    Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
    startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT);
}