做一个运动记录类的 App,要把用户胸前那条心率带的数据实时读进来,画成曲线,再定时推给后端。听起来只是「连个蓝牙」,真动手才发现 BLE(低功耗蓝牙)这套东西和平时写业务代码完全是两个思路,权限、扫描、广播包、特征值订阅,每一层都有自己的坑。尤其是 Android 上那个「不开定位就扫不到设备」的行为,第一次遇到能让人怀疑人生。
这篇把当时用 react-native-ble-manager 打通心率带的整个流程拆开讲,从初始化一路讲到数据解析,顺带把原代码里几处会漏内存和拼错的地方改掉。文档地址是 https://github.com/innoveit/react-native-ble-manager#methods ,参数细节以它为准。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
BLE读数据的两条路线,广播扫描和连接订阅,分别适合什么场景react-native-ble-manager的初始化顺序,start和enableBluetooth谁先谁后- 为什么
Android上不给定位权限就扫不到任何蓝牙设备 BleManager.scan四个参数各自控制什么,scanMode和numberOfMatches怎么调- 从广播包的十六进制串里解析电量、心率、步数
- 定时扫描的写法,以及事件监听不回收会怎么样
- 原代码里的几处问题和改法
- 2019 年到现在,
Android蓝牙权限模型发生了什么变化
# 一、先选路线,广播还是连接
BLE 里拿设备数据有两条完全不同的路。搞清楚自己走哪条,后面的代码才不会拧巴。
第一条是「连接后订阅特征值」。流程是扫描发现设备,connect 建立连接,retrieveServices 拿到服务和特征值列表,然后对某个特征值 startNotification,设备每次有新数据就主动推给你。标准的心率服务(Heart Rate Service,UUID 是 180d)就是这么设计的,数据准、延迟低、能双向通信。代价是连接本身有开销,一次只能连有限个设备,断线还得写重连逻辑。
第二条是「只读广播包」。BLE 设备在没被连接的时候会周期性地往外播报一个广播包,里面能塞一小段自定义数据。你只要一直扫描,就能从广播里把这段数据捞出来,全程不建立连接。
原文这个心率带走的是第二条路。
好处很实在。不用管连接状态,不用处理断线重连,一台手机可以同时收多条心率带的数据,做团课那种多人同屏的场景特别合适。代价是数据量受限,广播包能塞的字节数很少,而且推送频率由设备决定,你只能被动等。厂商在广播包里塞了电量、心率、步数三个值,够用了。
所以下面所有代码的核心逻辑就一句话,反复扫描,在扫描回调里解析广播包。
# 二、初始化和事件监听
先看初始化这一段。
// 以下是处理蓝牙设备的逻辑
state = {
scanning: false,//蓝牙是否在扫描
}
// 初始化蓝牙设备信息
initDevInfo = ()=>{
//创建对用户的请求,以激活蓝牙
BleManager.enableBluetooth()
.then(() => {
console.log('开启成功');
})
.catch((error) => {
console.log('The user refuse to enable bluetooth');
});
//初始化设备
BleManager.start({ showAlert: false }).then(() => {
console.log('开始了')
});
this.onCheckLocation();
this.handlerDiscover = bleManagerEmitter.addListener('BleManagerDiscoverPeripheral', this.handleDiscoverPeripheral);
this.handlerStop = bleManagerEmitter.addListener('BleManagerStopScan', this.handleStopScan);
}
这里做了四件事。enableBluetooth 会弹一个系统级的请求,让用户把蓝牙开关打开,这个只在 Android 上有效,iOS 不允许应用直接开关蓝牙,只能引导用户自己去设置里开。start 是初始化 BleManager 模块本身,showAlert: false 的意思是蓝牙没开的时候不要弹那个默认的系统提示框,因为我们自己已经用 enableBluetooth 处理过了。
然后是两个事件监听。BleManagerDiscoverPeripheral 每扫到一个外围设备就触发一次,这是整套逻辑真正干活的地方。BleManagerStopScan 在一轮扫描结束时触发。
这块有个执行顺序的问题要注意。上面这段是把 enableBluetooth 和 start 并排写的,两个都是异步的,谁先完成不确定。更稳的写法是先 start,等它 resolve 之后再去 enableBluetooth 和挂监听,因为 start 没完成之前原生模块还没准备好,这时候注册的监听有可能收不到事件。
// 更稳的初始化顺序:先 start,再处理蓝牙开关和监听
initDevInfo = async () => {
await BleManager.start({ showAlert: false })
if (Platform.OS === 'android') {
try {
await BleManager.enableBluetooth()
} catch (e) {
console.log('用户拒绝开启蓝牙')
}
}
await this.onCheckLocation()
this.handlerDiscover = bleManagerEmitter.addListener(
'BleManagerDiscoverPeripheral',
this.handleDiscoverPeripheral
)
this.handlerStop = bleManagerEmitter.addListener(
'BleManagerStopScan',
this.handleStopScan
)
}
# 三、Android 上不给定位权限就扫不到设备
这个是我当时排查了整整一下午的问题。代码一行没错,iOS 上一切正常,Android 上 scan 调用成功、BleManagerStopScan 也按时触发,就是一个 BleManagerDiscoverPeripheral 都收不到。
原因在 Android 系统这边。从 Android 6.0 开始,BLE 扫描被归类成了「可以推断用户位置」的能力,因为周围有哪些蓝牙信标本身就能定位。所以系统要求应用必须持有位置权限才允许扫描,没有权限的时候扫描接口照常返回成功,只是结果列表永远是空的。不报错,不提示,就是空。
这个设计我一开始也没想到,找了半天以为是设备广播有问题。
所以要有这么一段权限检查。
//检查是否获得定位权限
onCheckLocation =()=>{
if(Platform.OS === 'ios'){
return false;
}
const granted =PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION)
granted.then((data)=>{
if(!data){
this.requestLocationPermission()
}
}).catch((err)=>{
console.log('err---------',err.toString())
})
}
PermissionsAndroid.check 只查不弹框,返回一个 Promise。没有权限就走到 requestLocationPermission 去真正申请。
//申请地址权限 有些安卓设备比如华为,需要开启定位才可以扫描到蓝牙设备
async requestLocationPermission() {
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION,
{
//第一次请求拒绝后提示用户你为什么要这个权限
'title': '是否允许地址查询权限',
'message': '此权限会造成系统异常,请允许',
buttonNeutral: '等会再问我',
buttonNegative: '不行',
buttonPositive: '好的',
}
)
if (granted === PermissionsAndroid.RESULTS.GRANTED) {
showMsg("你已获取了定位权限")
} else {
showMsg("获取定位权限失败,会造成系统异常")
}
} catch (err) {
showMsg(err.toString())
}
}
PermissionsAndroid.request 的第二个参数是弹框文案。这段文案值得多花点心思,因为用户看到「运动 App 要定位权限」的第一反应就是拒绝。原文这个「此权限会造成系统异常,请允许」的写法其实不太好,容易被当成恐吓,写成「用于搜索附近的心率设备,不会记录你的位置」这类说明性文案,通过率会高一些。
还有个坑是光有权限不够。部分机型(华为、小米那批)还要求系统的定位开关本身处于打开状态,权限给了但定位总开关关着,照样扫不到。这个用 PermissionsAndroid 检测不出来,得靠原生模块去读系统的 location 服务状态,或者干脆在扫不到设备时给用户一句提示,引导他去下拉菜单打开定位。
# 四、扫描的四个参数
扫描是这样发起的。
/**
*开始扫描蓝牙
*/
startScan = ()=>{
if(bleScanTimer) clearInterval(bleScanTimer);
bleScanTimer = setInterval(()=>{
//扫描可用的外围设备
BleManager.scan(["180d"], 8, false, { "scanMode": 2, "numberOfMatches": 3 }).then((results) => {
console.log('开始扫描')
if(!this.state.scanning) {
this.setState({ scanning: true });
}
});
}, timerHeartInterval)
}
stopScan = ()=>{
if(bleScanTimer) clearInterval(bleScanTimer);
this.setState({ scanning: false });
}
BleManager.scan 这四个参数各管一摊,值得逐个说。
第一个 ["180d"] 是服务 UUID 过滤器。180d 是蓝牙标准里心率服务的短 UUID,只有广播里声明了这个服务的设备才会被上报。加过滤能省电,也能避免回调被周围一堆蓝牙耳机、手环刷屏。如果传空数组就是不过滤,什么都上报。
第二个 8 是本轮扫描持续的秒数。到点自动停,触发 BleManagerStopScan。