概述
Android 系统从4.3开始支持BLE,但当时只支持手机作为中心设备,后来从5.0开始,手机亦可作为外围设备。这里我们讲解手机作为中心设备是如何扫描和连接外围设备的,这是我们BLE开发中最常用到的。
在 Android 系统中,SDK 提供了 BluetoothAdapter 类对蓝牙进行操作,该类提供了开启和关闭蓝牙,开始和停止扫描设备等等功能。还有另外一个关键的类是 BluetoothGatt ,从名字就可以看出它对应BLE中的GATT,GATT在前面博文中我们有讲过,这是与设备进行连接和通信的一个核心类。BluetoothGatt 的结构图如下:
BluetoothGatt 包含一个或多个服务 BluetoothGattService,每个 BluetoothGattService 服务都有唯一标识的 UUID,可以通过 UUID 获取服务,也可以获取 BluetoothGatt 中所有的服务列表。
同时每个 BluetoothGattService 也包含了一个或多个特征 BluetoothGattCharacteristic,每个特征也是通过 UUID 唯一标识,它有一个 value 字段,是一个 byte 数组,这个数组就是按照协议定义的数据,我们需要对该数据进行解析。另外每个 BluetoothGattCharacteristic 还有一个属性,一般有写类型的(PROPERTY_WRITE),订阅类型的(PROPERTY_NOTIFY),写类型是中心设备发送数据给外设用到的,订阅类型是中心设备接收外设发送的数据用到的。
每个 BluetoothGattCharacteristic 也会包含一个或多个描述 BluetoothGattDescriptor 。每个描述也是通过 UUID 唯一标识,同样也有一个 value 字段的 byte 数组。
在看具体代码的实现前,我们先说下大概步骤:
- 检查使用蓝牙相关权限;
- 扫描设备,判断是否是我们感兴趣的设备。如果是广播模式,只用不停的扫描,不需用建立连接,到这里只要解析广播里面的厂商自定义数据就完了。如果需要建立连接继续下一步;
- 停止扫描,与设备建立 GATT 连接,
- 连接成功后启动发现服务;
- 遍历设备的服务列表,通过服务的UUID判断是否有我们感兴趣的服务;
- 获取服务的读特征,开启订阅,接受设备发送过来的数据;
- 获取服务的写特征,通过该特征发送数据给设备端。
1、声明权限
Android 中使用蓝牙必须先在 AndroidManifest 中声明权限:
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
上面两个权限是蓝牙必须要的,下面的定位权限是Android 6.0中增加的,也是必须的,不然会搜索不到设备,且定位权限需要动态申请。
另外,如果你期望只有支持 BLE 的设备才能安装你的应用,也可以在清单文件中申明:
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
2、判断状态
1、首先要检查用户是否给予应用定位权限,如果没有便需要申请该权限;
2、给予定位权限后,还需要检查手机的定位服务是否开启,因为有定位权限,但是手机没有开始定位服务,也会导致无法搜索到设备。这里大家可能会奇怪使用蓝牙为什么会需要定位,其实蓝牙确实是可以做定位功能的,比如 BLE mesh ,就可以做到室内定位。如果没有开启,需要跳转到定位服务设置页面;
3、检查蓝牙是否开启,如果没有开启,提示用户开启蓝牙;
4、注册蓝牙开启和关闭的广播接收器。蓝牙开启和关闭时,系统会发出相应的广播,收到该广播时,我们需要做相应的操作。
下面是我封装的 BleBaseActivity 的代码,里面处理这些状态相关的逻辑:
abstract class BleBaseActivity : AppCompatActivity() {
private val permissionRequestCode = 1530
private val permissionSettingCode = 1532
private val locationSettingCode = 1531
private var bluetoothReceiver: BroadcastReceiver? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
BluetoothAdapter.getDefaultAdapter() ?: return//为 null 表示设备不支持蓝牙, return
checkStatus()
bluetoothReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) {
BluetoothAdapter.STATE_ON -> {//蓝牙已经打开
onBluetoothOpen()
checkStatus()
}
BluetoothAdapter.STATE_TURNING_OFF -> { //蓝牙正在关闭
onBluetoothClose()
}
}
}
}
//注册蓝牙状态改变广播接收器
val intentFilter = IntentFilter()
intentFilter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
registerReceiver(bluetoothReceiver, intentFilter)
}
private fun checkStatus() {
//检查定位权限 --> 检查定位服务 --> 检查蓝牙开启状态
if (checkLocalPermission()) {//定位权限已授权
if (locationIsEnable()) {//定位服务已开启
if (bluetoothIsEnabled()) {//蓝牙已开启
//所有状态都已经OK
onBleEverythingOk()
} else { // 蓝牙未开启 ,提示开启
openBluetooth()
}
} else { // 没有开启定位服务 ,提示去打开
goToLocationSetting()
}
} else { // 没有授权 请求定位权限
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
permissionRequestCode
)
}
}
private fun openBluetooth() {
val enableBleIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivity(enableBleIntent)
}
private fun locationIsEnable(): Boolean {
//Android6.0以下不需要开启GPS服务即可搜索蓝牙
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
private fun checkLocalPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
private fun bluetoothIsEnabled(): Boolean = BluetoothAdapter.getDefaultAdapter().isEnabled
private fun goToPermissionSetting() {
AlertDialog.Builder(this)
.setTitle("Tip")
.setMessage("蓝牙需要定位权限,是否去打开定位权限")
.setPositiveButton("是", object : DialogInterface.OnClickListener {
override fun onClick(dialog: DialogInterface, which: Int) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.parse("package:$packageName")
if (packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null) {
//使用 startActivityForResult 是为了能在用户返回后,在onActivityResult检查用户设置的结果
startActivityForResult(intent, permissionSettingCode)
}
}
}).setNegativeButton("否", object : DialogInterface.OnClickListener {
override fun onClick(dialog: DialogInterface?, which: Int) {
//用户拒绝打开权限设置页面
onLocationPermissionDenied()
}
}).show()
}
private fun goToLocationSetting() {
AlertDialog.Builder(this)
.setTitle("Tip")
.setMessage("使用蓝牙需要开启定位服务,是否去打开定位服务")
.setPositiveButton("是", object : DialogInterface.OnClickListener {
override fun onClick(dialog: DialogInterface, which: Int) {
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
if (packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null) {
//使用 startActivityForResult 是为了能在用户返回后,在onActivityResult检查用户设置的结果
startActivityForResult(intent, locationSettingCode)
}
}
}).setNegativeButton("否", object : DialogInterface.OnClickListener {
override fun onClick(dialog: DialogInterface?, which: Int) {
//用户拒绝打开定位服务设置页面
onLocationServiceDenied()
}
}).show()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
permissionSettingCode -> {
if (checkLocalPermission()) {//用户在权限设置页面给予了权限
if (locationIsEnable()) {//定位服务已打开
if (bluetoothIsEnabled()) {//蓝牙已打开
//所有状态都已经OK
onBleEverythingOk()
} else {//提示开启蓝牙
openBluetooth()
}
} else {//定位服务未开启,提示用户开启定位服务
goToLocationSetting()
}
} else {
//用户从权限设置页面回来,还是没有开启给予定位权限
onLocationPermissionDenied()
}
}
locationSettingCode -> {
if (locationIsEnable()) {//用户在定位服务设置页面开启了定位服务
if (bluetoothIsEnabled()) {//蓝牙已开启
//可以开始搜索了
onBleEverythingOk()
} else {//蓝牙未开启,提示用户开启蓝牙
openBluetooth()
}
} else {
//用户从定位服务设置页面回来,还是没有开启定位服务
onLocationServiceDenied()
}
}
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (requestCode == permissionRequestCode && grantResults.isNotEmpty()) {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {//定位权限已授权
if (locationIsEnable()) {//定位服务已开启
if (bluetoothIsEnabled()) {//蓝牙已开启
//所有状态都已经OK
onBleEverythingOk()
} else {//蓝牙未开启,提示用户开启蓝牙
openBluetooth()
}
} else {//提示用户去开启定位服务
goToLocationSetting()
}
} else {//用户拒绝了授权,提示用户去设置页面开启权限
goToPermissionSetting()
}
}
}
override fun onDestroy() {
super.onDestroy()
//注销广播接收器
bluetoothReceiver?.let {
unregisterReceiver(it)
}
}
abstract fun onBluetoothOpen()//蓝牙开启了
abstract fun onBluetoothClosing()//蓝牙正在关闭
abstract fun onBleEverythingOk()//所有状态都已经OK,可以开始搜索设备了
abstract fun onLocationServiceDenied()//用户拒绝打开定位服务
abstract fun onLocationPermissionDenied()//用户拒绝给予定位权限
}
扫描设备
接下来只要继承 BleBaseActivity ,实现相关方法即可,
class BleMainActivity : BleBaseActivity() {
private var scanCallback: ScanCallback? = null
private var leScanCallback: BluetoothAdapter.LeScanCallback? = null
private var isScanning = false//是否是扫描状态
//我们需要与之交互的设备的服务UUID
private val myDeviceUUID = ParcelUuid.fromString("0000180D-0000-1000-8000-00805F9B34FB")
override fun onBluetoothOpen() {
}
override fun onBluetoothClosing() {
isScanning = false
}
override fun onBleEverythingOk() {
//开始扫描
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {//5.0 及以上的扫描方法
scanCallback = object : ScanCallback() {
override fun onScanFailed(errorCode: Int) {
isScanning = false
}
override fun onScanResult(callbackType: Int, result: ScanResult) {
val rssi = result.rssi//信号值,单位dBm,为负数,越接近0信号越强
val address = result.device.address//设备MAC地址
val name = result.device.name//设备名字
//设备广播的数据,有可能是null
val scanRecord = result.scanRecord ?: return
//设备的服务 UUID 列表,有可能是null
val uuidList = scanRecord.serviceUuids ?: return
//设备的厂商数据,可能是null,或者是空的,即设备没有定义厂商数据
//厂商数据放在一个键值对集合中,key是厂商的ID, value是ID对应的自定义数据
val manufacturerData: SparseArray<ByteArray> = scanRecord.manufacturerSpecificData ?: return
// 这个方法中得到的设备是搜到的所有设备,需要做过滤,通过服务UUID或厂商ID过滤
// 如果搜索到的设备包含我们的UUID,表示是我们的设备,如果不包含,表示是其他的不相关设备
// 如果我们的设备有定义的厂商数据,可以判断下manufacturerData是不是空的,
// 不是空的就接着比较:manufacturerData中的ID与我们设备的厂商数据中的厂商ID是否一样,
// 一样的话,那这个设备就一定是我们要的设备了
if (uuidList.contains(myDeviceUUID) && manufacturerData.size() > 0) {
//TODO
}
}
override fun onBatchScanResults(results: MutableList<ScanResult>?) {
}
}
bluetoothAdapter.bluetoothLeScanner.startScan(scanCallback)
} else {//5.0 以下的扫描方法
leScanCallback = object : BluetoothAdapter.LeScanCallback {
override fun onLeScan(device: BluetoothDevice, rssi: Int, scanRecord: ByteArray) {
val adData = ParseBluetoothAdData.parse(scanRecord)//自己解析广播数据
val address = device.address//设备MAC地址
val name = device.name//设备名字
//设备的服务 UUID 列表,有可能是空的
val uuidList = adData.UUIDs
//设备的厂商数据,前两个字节表示厂商ID,后面的是的数据,可能是null,或者是空的,即设备没有定义厂商数据
val manufacturerBytes = adData.manufacturerByte ?: return
// 这个方法中得到的设备是搜到的所有设备,需要做过滤,通过服务UUID或厂商ID过滤
// 如果搜索到的设备包含我们的UUID,表示是我们的设备,如果不包含,表示是其他的不相关设备
// 如果我们的设备有定义的厂商数据,可以判断下manufacturerData是不是空的,
// 不是空的就接着比较:manufacturerData中的ID与我们设备的厂商数据中的厂商ID是否一样,
// 一样的话,那这个设备就一定是我们要的设备了
if (uuidList.contains(myDeviceUUID.uuid) && manufacturerBytes.isNotEmpty()) {
//TODO
}
}
}
bluetoothAdapter.startLeScan(leScanCallback)
}
isScanning = true
}
override fun onLocationServiceDenied() {
}
override fun onLocationPermissionDenied() {
}
private fun stopScan() {
if (!isScanning) return//没有在扫描,不用停止
isScanning = false
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (!bluetoothAdapter.isEnabled) return//蓝牙已经关闭了,还去停止扫描干嘛
//停止扫描
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
scanCallback?.let {
bluetoothAdapter.bluetoothLeScanner.stopScan(it)
}
} else {
leScanCallback?.let {
bluetoothAdapter.stopLeScan(it)
}
}
}
override fun onDestroy() {
super.onDestroy()
stopScan()//不要忘了在页面关闭的是否停止扫描
}
}
在5.0系统上,SDK 提供了新的扫描方法,扫描到的设备广播数据不需要我们自己解析,系统已经解析好了,并且扫描到设备的回调方法都是在主线程。而旧的回调方法在子线程里面,这点需要注意,如果需要在回调这里刷新UI,就需要切换到主线程。
另外两个不同系统版本提供的扫描方法,都可以传入一个UUID列表进行过滤,只有扫描到的设备的服务UUID包含在该UUID列表时,才调用扫描回调方法。5.0 上新的扫描方法还可以设置扫描模式,比如 SCAN_MODE_LOW_LATENCY 和 SCAN_MODE_LOW_POWER 等。
//5.0 及以上
val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)//低延迟,扫描间隔很短,不停的扫描,更容易扫描到设备,但是更耗电一些,建议APP在前台是才使用这种模式
//.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)//省电的模式,扫描间隔会长一点,扫描到设备花的时间会长一些
.build()
val scanFilter = ScanFilter.Builder()
.setServiceUuid(myDeviceUUID)
.build()
bluetoothAdapter.bluetoothLeScanner.startScan(listOf(scanFilter), scanSettings, scanCallback)
//5.0 以下
bluetoothAdapter.startLeScan(arrayOf(myDeviceUUID.uuid), leScanCallback)
扫描到我们的设备后,如果该设备只是广播数据,不是基于连接的,那就在 TODO 的地方解析出厂商自定义的数据,根据需求显示或保存数据等操作即可,到这里就结束了。关于如何解析这些 byte 数据,在后面的文章会介绍。
如果设备是需要连接,进行交互的,那接下来,就在 TODO 的地方连接该设备,并停止扫描设备。
连接设备
private fun connDevice(bluetoothDevice: BluetoothDevice) {
//false:不需要自动连接
bluetoothDevice.connectGatt(applicationContext, false, bluetoothGattCallback)
}
private fun connDevice(address: String) {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
val bluetoothDevice = bluetoothAdapter.getRemoteDevice(address)
//false:不需要自动连接
bluetoothDevice.connectGatt(applicationContext, false, bluetoothGattCallback)
}
private val bluetoothGattCallback: BluetoothGattCallback by lazy {
object : BluetoothGattCallback() {
//连接状态改变的回调
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
//连接成功,开启发现服务
gatt.discoverServices()
}
BluetoothProfile.STATE_DISCONNECTED -> {
//连接断开,关闭GATT资源
gatt.close()
}
}
}
//发现服务的回调
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
//获取感兴趣的服务
val gattService = gatt.getService(UUID.fromString("")) ?: return//填写你们定义的服务UUID
//获取该服务的读特征,用于订阅设备发送的数据
val notifyCharacteristic =
gattService.getCharacteristic(UUID.fromString("")) ?: return//填写你们定义的读特征UUID
//订阅 notify 这是模板代码,通常都是固定的------>start
gatt.setCharacteristicNotification(notifyCharacteristic, true)
val descriptor =
notifyCharacteristic.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))//这个UUID是固定的
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
//订阅 notify ------>end
//获取写特征值,用于发送数据
val writeCharacteristic =
gattService.getCharacteristic(UUID.fromString("")) ?: return//填写你们定义的写特征UUID
}
}
//接收到设备发送的数据的回调
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
//设备发送的数据, 是byte数组,按照协议解析即可
val bytes = characteristic.value
//TODO
}
//向设备发送数据时会回调该方法,每调用一次gatt.writeCharacteristic()就回调一次
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
super.onCharacteristicWrite(gatt, characteristic, status)
}
//调用gatt.readCharacteristic 后回调读到的数据
override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
super.onCharacteristicRead(gatt, characteristic, status)
}
//每调用一次gatt.writeDescriptor就回调一次
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
super.onDescriptorWrite(gatt, descriptor, status)
}
//调用gatt.readDescriptor 后回调读到的数据
override fun onDescriptorRead(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
super.onDescriptorRead(gatt, descriptor, status)
}
}
}
连接设备时,使用 bluetoothDevice 对象的 connectGatt 方法即可建立连接,或者使用 bluetoothAdapter.getRemoteDevice 方法传入扫描到的 mac 地址获取 bluetoothDevice 对象再进行连接,connectGatt 发放需要传入三个参数,第二个最好传 false ,表示不需要自动连接,第三个参数需要传入 BluetoothGattCallback 对象,该对象是与设备交互的核心。另外,需要注意,每一次和设备建立连接,都需要经过先扫描,扫描到设备后才进行连接。不能记住设备的mac地址,不经过扫描而直接连接。
BluetoothGattCallback 对象中,有很多回调方法,都是在与设备进行交互时的回调。其中有几个很重要的方法:
- onConnectionStateChange 当与设备连接成功、断开或者连接发生错误(133等)都会回调用该方法,注意这里的连接成功,只是连上了,还不能进行通信(类似只是找到人了,还需要确认身份才可以将情报给你),需要去发现服务 discoverServices ;
- onServicesDiscovered 发现了服务的回调。当获取到我们需要的服务时,就可以开始订阅消息,和获取写的特征,现在即真正和设备建立了双向通信。
- onCharacteristicChanged 接受设备发送过来的数据,是原始的二进制数据。比如一个温度探测仪,这里就会返回实时的温度,单位等信息,当然这些信息需要按照协议解析出来。
发送数据
fun sendData(bytes: ByteArray) {
if (writeCharacteristic != null) {
writeCharacteristic.setValue(bytes)
writeCharacteristic.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
bluetoothGatt.writeCharacteristic(writeCharacteristic)
}
}
外围设备通常发送的数据是原始二进制数据,也只接受二进制数据,所以往设备写入数据时,也需要按照协议,将数据解析为一个 bytes 数组里面,才可以发送。
断开连接
bluetoothGatt.disconnect()
bluetoothGatt.close()
通常当调用 bluetoothGatt.disconnect() 方法后,会回调 onConnectionStateChange ,在 onConnectionStateChange 里面再去调用 bluetoothGatt.close() 方法。但有时可能不会回调 onConnectionStateChange 方法,继而没有执行 bluetoothGatt.close() ,继而可能导致下次连接时出现错误(多次断连会出现133),所以断开连接时也可以两个方法一起调用。