前段时间一直在接触Android 低功耗蓝牙的开发,这东西实在是太坑了。 用起来不稳定,总是会出现一些莫名其妙的问题, Google 文档描述的也不是很清晰。看其他人写的博客,也是一脸懵逼的去尝试理解Gatt协议, 不能很好的入门。
直到看到一篇英文博客, 写的非常详细, 原作者也分享了自己遇到的一些坑和解决方案。在此分享给大家。 由于文章是原作者2019年写的, 可能内容不是很新。 我也会做一些补充。 也欢迎大家补充讨论。
1、 前言
去年我学习了在iOS上如何开发低功耗蓝牙(Bluetooth Low Energy, 简称BLE)应用。在iOS上进行BLE开发是比较简单的。 下一步我们将把它移植到Android上... 移植起来会比较难嘛
现在我可以说, 这是一件比较难的事情。需要付出相当大的努力去优化一个稳定的版本, 运行在主流机型上。我在社区浏览了很多信息,有一些信息是错误的,有一些非常有用。在这一系列文章中,我想总结我的发现, 所以你就不用像我刚开始一样,花很多时间去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对持续性扫描做了一些限制,以下是对扫描做出的一些改变:
- Android 8.1开始, 如果没有设置Scan Filter , 手机熄屏将会 停止扫描。 就是说, 你没有设置Scan Filter, 手机在熄屏时会自动停止扫描。 亮屏时恢复扫描。 这是关于修改的提交。 很明显这是Google 对扫描耗电量的优化。
- 在Android 7上,你只能持续扫描30分钟,之后Android将会把ScanSettings改为SCAN_MODE_OPPORTUNISTIC。 所以最佳的扫描窗口应该是30分钟, 你应该在30分钟内关闭扫描并重新开启。
- 在Android 7 上,在30s内,重复开启扫描5次,Android将会暂时关闭扫描。
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);
}