经典蓝牙和BLE的开发笔记

606 阅读8分钟

概述

  • 概念

    • 蓝牙MAC地址

      • 设备的标识符
    • RSSI

      • 描述了蓝牙设备的信号强度,应该是一个负数值,数值越大信号强度越大
  • 蓝牙连接的过程:搜索>>配对>>连接

    • 搜索

      • 设备搜索是一个对附近蓝牙可连接设备的搜索并询问其相关信息的过程,附近的蓝牙设备只有在它愿意接收请求时才会响应

        • 如果一个设备时是能被发现的,这个设备会通过分享设备名称、类以及MAC地址来回应请求
        • 基于Android的设备默认蓝牙都不能够被发现。要令设备在短暂时间内能被发现,用户可以通过系统设置,亦或是app要求用户允许被发现的能力
    • 配对

      • 配对意味着两台设备都知道对方的存在

      • 在初次连接后,安卓会出现提示框,让用户选择是否进行配对。配对过程会点耗费时间

      • 安卓要求设备在建立连接前完成配对

      • 当一个设备配对成功后,设备的基本信息会被保存

        • 这些信息存在于蓝牙设置的已配对列表中
        • 对于已配对的设备,无需再通过搜索即可连接,只要设备仍能被连接
  • BluetoothAdapter

    • 这是蓝牙开发中的主要角色,提供了搜索、连接等功能
private val bluetoothAdapter by lazy {
    context.getSystemService(BluetoothManager::class.java)?.adapter
}

可用性检查

蓝牙权限

  • 蓝牙权限中,安卓11及以下设备必须同时获取ACCESS_COARSE_LOCATION和ACCESS_FINE_LOCATION才能够进行设备搜索

    • 如果缺少权限,app运行不会崩溃不会有任何提示,但无法搜索蓝牙设备
/**
 * 请求蓝牙权限
 */
fun requestBluetoothPermission(context: Activity, requestCode: Int) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        ActivityCompat.requestPermissions(
            context,
            arrayOf(
                Manifest.permission.BLUETOOTH_CONNECT,
                Manifest.permission.BLUETOOTH_SCAN,
                // 懒得试错了,所以还是获取了定位权限
                Manifest.permission.ACCESS_COARSE_LOCATION,
                Manifest.permission.ACCESS_FINE_LOCATION
            ),
            requestCode
        )
    } else {
        ActivityCompat.requestPermissions(
            context,
            arrayOf(
                Manifest.permission.BLUETOOTH,
                Manifest.permission.BLUETOOTH_ADMIN,
                Manifest.permission.ACCESS_COARSE_LOCATION,
                Manifest.permission.ACCESS_FINE_LOCATION
            ),
            requestCode
        )
    }
}

/**
 * 检查是否具备蓝牙权限
 */
fun hasBluetoothPermission(context: Context): Boolean {
    val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        arrayOf(
            Manifest.permission.BLUETOOTH_CONNECT,
            Manifest.permission.BLUETOOTH_SCAN,
            Manifest.permission.ACCESS_COARSE_LOCATION,
            Manifest.permission.ACCESS_FINE_LOCATION
        )
    } else {
        arrayOf(
            Manifest.permission.BLUETOOTH,
            Manifest.permission.BLUETOOTH_ADMIN,
            Manifest.permission.ACCESS_COARSE_LOCATION,
            Manifest.permission.ACCESS_FINE_LOCATION
        )
    }
    for (p in permissions) {
        if (ContextCompat.checkSelfPermission(context, p) == PackageManager.PERMISSION_DENIED)
            return false
    }
    return true
}

蓝牙可用检查

  • 使用蓝牙前,进行检查
/**
 * 检查是否支持蓝牙
 */
fun hasBluetooth(context: Context) =
    context.getSystemService(BluetoothManager::class.java)?.adapter != null

/**
 * 检查蓝牙是否已开启
 */
fun hasBluetoothEnable(context: Context): Boolean =
    context.getSystemService(BluetoothManager::class.java)?.adapter?.isEnabled == true

/**
 * 检查是否具备蓝牙权限
 */
fun hasBluetoothPermission(context: Context): Boolean {
    val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        arrayOf(
            Manifest.permission.BLUETOOTH_CONNECT,
            Manifest.permission.BLUETOOTH_SCAN,
            Manifest.permission.ACCESS_COARSE_LOCATION,
            Manifest.permission.ACCESS_FINE_LOCATION
        )
    } else {
        arrayOf(
            Manifest.permission.BLUETOOTH,
            Manifest.permission.BLUETOOTH_ADMIN,
            Manifest.permission.ACCESS_COARSE_LOCATION,
            Manifest.permission.ACCESS_FINE_LOCATION
        )
    }
    for (p in permissions) {
        if (ContextCompat.checkSelfPermission(context, p) == PackageManager.PERMISSION_DENIED)
            return false
    }
    return true
}

经典蓝牙开发

搜索

  • 设备搜索的结果由Broadcast返回,因此需要先注册BraodcastReceiver再开始搜索

    • 经典蓝牙的设备搜索过程大概会持续12s
    • 执行蓝牙搜索会消耗大量蓝牙适配器的资源。在进行设备连接之前,应该要通过cancelDiscovery()停止搜索
// 使用Stateflow向ui层展示搜索到的设备
private val _scannedDevices = MutableStateFlow<List<BluetoothDeviceInfo>>(emptyList())

fun startDiscovery() {
    _scannedDevices.update { emptyList() }
    context.registerReceiver(foundDeviceReceiver, IntentFilter(BluetoothDevice.ACTION_FOUND))
    bluetoothAdapter?.startDiscovery()
}
  • 蓝牙设备搜索的BroadcastReceiver

    • Intent:BluetoothDevice.ACTION_FOUND
    • 经典蓝牙获取的rssi值是蓝牙设备被发现时的值,无法随着时间而刷新
private val foundDeviceReceiver = FoundDeviceReceiver { newDevice ->
    _scannedDevices.update { devices ->
        if (newDevice in devices) devices else devices + newDevice
    }
}

class FoundDeviceReceiver(
    private val onDeviceFound: (BluetoothDeviceInfo) -> Unit
) : BroadcastReceiver() {
    override fun onReceive(context: Context?, intent: Intent?) {
        when(intent?.action) {
            BluetoothDevice.ACTION_FOUND -> {
                val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                    intent.getParcelableExtra(
                        BluetoothDevice.EXTRA_DEVICE,
                        BluetoothDevice::class.java
                    )
                } else {
                    intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
                }
                val rssi = intent.extras?.getShort(BluetoothDevice.EXTRA_RSSI)?.toInt()
                // 转换为自己定义的数据结构
                device?.toInfo(rssiValue = rssi)?.let(onDeviceFound)
            }
        }
    }
}

配对

  • 查询配对的设备

    • bluetoothAdapter中包含了已配对设备信息的集合
/**
 * 查询当前设备是否已配对
 */
fun BluetoothDevice.isDeviceBonded(): Boolean {
    val target = bluetoothAdapter
        ?.bondedDevices
        ?.find { bonded -> bonded.address == this.address }
    return target != null
}

连接

  • 经典蓝牙的发起连接过程需要两个参数

    • 待连接设备的MAC地址

      • MAC地址在搜索过程或者已配对列表中获取
    • UUID

      • 可以自定义的128位的字符串id,用于标识蓝牙通信设备所提供的业务
      • 可以在网上找到随机生成UUID的网站
  • 等待被连接

    • 使用BluetoothServerSocket来等待连接

      • 在双方通信建立连接之后,就可以关闭BluetoothServerSocket了
private var currentServerSocket: BluetoothServerSocket? = null
private var currentClientSocket: BluetoothSocket? = null

currentServerSocket = bluetoothAdapter?.listenUsingRfcommWithServiceRecord(
    "chat_service",
    UUID.fromString(SERVICE_UUID)
)

currentClientSocket = try {
    currentServerSocket?.accept()  // 阻塞!
} catch(e: IOException) {
    shouldLoop = false
    null
}

currentClientSocket?.let {
    currentServerSocket?.close()
}
  • 发起连接
currentClientSocket = bluetoothAdapter
    ?.getRemoteDevice(device.address)
    ?.createRfcommSocketToServiceRecord(
        UUID.fromString(SERVICE_UUID)
    )

连接状态

  • 监听连接状态

    • 在连接之前注册广播接收器以获取蓝牙信息
context.registerReceiver(
    bluetoothStateReceiver,
    IntentFilter().apply {
        addAction(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED)
        addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
        addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
    }
)
  • 连接状态监听的BroadcastReceiver

    • Intent

      • BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED
      • BluetoothDevice.ACTION_ACL_CONNECTED
      • BluetoothDevice.ACTION_ACL_DISCONNECTED
private val bluetoothStateReceiver = BluetoothStateReceiver { isConnected, bluetoothDevice ->
    if(bluetoothAdapter?.bondedDevices?.contains(bluetoothDevice) == true) {
        _isConnected.update { isConnected }
    } else { /* ... */}
}
    
class BluetoothStateReceiver(
    private val onStateChanged: (isConnected: Boolean, BluetoothDevice) -> Unit
): BroadcastReceiver() {

    override fun onReceive(context: Context?, intent: Intent?) {
        val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            intent?.getParcelableExtra(
                BluetoothDevice.EXTRA_DEVICE,
                BluetoothDevice::class.java
            )
        } else {
            intent?.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
        }
        when(intent?.action) {
            BluetoothDevice.ACTION_ACL_CONNECTED -> {
                onStateChanged(true, device ?: return)
            }
            BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
                onStateChanged(false, device ?: return)
            }
        }
    }
}

通信

  • 发送信息
socket.outputStream.write(bytes)
  • 等待并读取消息
fun listenForIncomingMessages(): Flow<String> {
    return flow {
        if(!socket.isConnected) {
            return@flow
        }
        val buffer = ByteArray(1024)
        while(true) {
            val byteCount = try {
                socket.inputStream.read(buffer)
            } catch(e: IOException) { /* ... */ }

            emit(buffer.decodeToString(endIndex = byteCount))
        }
    }.flowOn(Dispatchers.IO)
}

断开连接

  • 关闭socket即可

经典蓝牙的开发很大部分上参考了github.com/philipplack… 。这是一个油管up主的蓝牙聊天app的开发的介绍视频对应的项目,在b站上有转载,搜索“蓝牙聊天”就能找到这个视频了。 这个视频还介绍了Flow、Compose的应用,十分推荐!

我在毕设上用到了经典蓝牙api,当时的使用场景也恰好是两部手机之间发送消息。为了方便演示,我先让手机之间保持着对方设备已配对的状态。

BLE开发

搜索

  • ble搜索结果以回调方式返回

    • 返回内容包括了蓝牙设备的名字、MAC地址、rssi和描述广播信息的scanRecord

    • 回调中会多次返回同一设备信息,但是其rssi值会随之而更新

    • ble的搜索并不会自动暂停,需要主动调用stopScan()停止搜索

      • 暂停搜索需要传入与开始搜索同样的回调作为参数
// 使用Stateflow向ui层展示搜索到的设备
private val _scannedDevices = MutableStateFlow<List<BluetoothDeviceInfo>>(emptyList())

// ScanCallback回调
class LeScanCallback(
    private val onDeviceFound: (BluetoothDeviceInfo) -> Unit
) : ScanCallback() {
    override fun onScanResult(callbackType: Int, result: ScanResult) {
        super.onScanResult(callbackType, result)
        val scanRecord = result.scanRecord?.bytes
        
        // 转换为自定义的数据结构
        result.device?.toInfo(result.rssi, HexUtils.encodeHexStr(scanRecord, false))?.let(onDeviceFound)
    }
}

// 如果搜索列表中已存在对于的设备,那么只用更新rssi值就好
private val leScanCallback = LeScanCallback { newDevice ->
    _scannedDevices.update { devices ->
        val existedDevice = devices
            .find { it.address == newDevice.address }
            .also { it?.rssiValue = newDevice.rssiValue }
        if (existedDevice == null) devices + newDevice else devices
    }
}

bluetoothAdapter?.bluetoothLeScanner?.startScan(leScanCallback)

配对(与经典蓝牙相同)

  • 查询配对的设备

    • bluetoothAdapter中包含了已配对设备信息的集合
/**
 * 查询当前设备是否已配对
 */
fun BluetoothDevice.isDeviceBonded(): Boolean {
    val target = bluetoothAdapter
        ?.bondedDevices
        ?.find { bonded -> bonded.address == this.address }
    return target != null
}

连接

  • 连接

    • ble通信中,连接状态、设备收到对方数据、写入数据结果都会在回调BluetoothGattCallback中反馈。该回调需要在进行连接时传入
// 变量的命名只是为了方便开发理解
var client: BluetoothGatt? = null

client = bluetoothAdapter
    ?.getRemoteDevice(macAddress)
    ?.connectGatt(context, false, gattCallback)

连接状态及连接后的初始化

推荐使用nRF Connect这款app。该app可以查看ble设备的服务和特征等

  • 概念

    • GATT

      • BLE的规范
    • MTU

      • 最大传输单元。在发送数据时,所发送的数据量会被截断为MTU的大小
    • 服务 Service

      • 表示着特征的集合
    • 特征 Characteristic

      • 我觉得可以把特征看作一项业务
      • 特征可能可以被读取、可以写入、可以订阅其通知,这由被连接的设备所设定
      • 特征包含了0-n个描述符
    • 描述符 Descriptors

    • 服务、特征、描述符的UUID

      • 蓝牙技术联盟定义了这样的UUID:0x0000xxxx-0000-1000-8000-00805F9B34FB。通常服务、特征、描述符之间只有该UUID之间的"xxxx"是不同的
      • 具体UUID由被连接的设备所决定
  • 当连接状态更改时,回调BluetoothGattCallback的onConnectionStateChange()会被调用

override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
    super.onConnectionStateChange(gatt, status, newState)
    Log.d(TAG, "connected: ${newState == BluetoothProfile.STATE_CONNECTED}")
    gatt?.requestMtu(512)
}
  • 在确定连接上时,设置mtu

    • 设置MTU并非是必须的

    • MTU值的设置未必能如愿

      • 该值大概由通信双方设备共同决定的
      • 请求设置MTU后,回调中onMtuChanged()会被调用,告知请求设置后MTU的值
override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) {
    super.onMtuChanged(gatt, mtu, status)
    Log.d(TAG, "onMtuChanged: $mtu $status")
    gatt?.discoverServices()
}
  • 在设置mtu成功后,开始搜索服务

    • 搜索到服务时会调用回调中的onServicesDiscovered()
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
    super.onServicesDiscovered(gatt, status)
    Log.d(TAG, "onServicesDiscovered: $status")
    gatt?.let { g ->
        onServiceDiscovered(g.services)
    }
}
  • 记录特征,以便后续调用

    • 在我目前项目中,特征的UUID是有开发文档记录的。因此需要通过UUID来获取对应特征再进行业务操作
private val characteristicMap = HashMap<String, BluetoothGattCharacteristic>()

fun onServiceDiscoverd(serviceList: List<BluetoothGattService>){
    serviceList.forEach { service ->
        service.characteristics.forEach { ch ->
            characteristicMap[ch.uuid.toString()] = ch
        }
    }
}

通信

  • 启用通知

    • 调用writeDescriptor()成功后,会触发回调的onDescriptorWrite()
suspend fun enableBleNotification(ch: BluetoothGattCharacteristic) {
    if (client?.setCharacteristicNotification(ch, true) == true) {
        // 默认的通知标识符
        ch.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))?.let {
            it.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
            client?.writeDescriptor(it)
        } ?: _errors.emit("无法找到通知服务")
    } else _errors.emit("设置通知失败")
}
  • 收到通知

    • 收到通知时会触发回调的onCharacteristicChanged()

      • 注意在compileSDK=33时,该方法有两个版本,一个被标记@Deprecated的旧版,另一个是新版。测试发现无论设备是否Android13,这两个版本的方法都不会同时被调用。因此两个方法都应该要进行处理
// 旧版本
override fun onCharacteristicChanged(
    gatt: BluetoothGatt?,
    characteristic: BluetoothGattCharacteristic?
) {
    super.onCharacteristicChanged(gatt, characteristic)
    characteristic?.let {onCharacteristicChanged(it, it.value)}
}

// targetSDK = 33, compileSDK =33, 运行设备为Android13
override fun onCharacteristicChanged(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic,
    value: ByteArray
) {
    super.onCharacteristicChanged(gatt, characteristic, value)
    onCharacteristicChanged(characteristic, value)
}
  • 读取消息

    • 读取到的消息会出现在回调的onCharacteristicRead()中

      • 该方法和onCharacteristicChanged()一样,在compileSDK=33时有两个版本
client?.readCharacteristic(ch)

// 旧版本
override fun onCharacteristicRead(
    gatt: BluetoothGatt?,
    characteristic: BluetoothGattCharacteristic?,
    status: Int
) {
    super.onCharacteristicRead(gatt, characteristic, status)
    characteristic?.let {
        onCharacteristicRead(it.value, status == BluetoothGatt.GATT_SUCCESS)
    }
}

// targetSDK = 33, compileSDK =33, 运行设备为Android13
override fun onCharacteristicRead(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic,
    value: ByteArray,
    status: Int
) {
    super.onCharacteristicRead(gatt, characteristic, value, status)
    onCharacteristicRead(value, status == BluetoothGatt.GATT_SUCCESS)
}
  • 写入数据

    • 写入数据成功后,会触发回调的onCharacteristicWrite()方法
suspend fun write(ch: BluetoothGattCharacteristic, value: ByteArray): Boolean? {
    ch.value = value
    return client?.writeCharacteristic(ch)
}

override fun onCharacteristicWrite(
    gatt: BluetoothGatt?,
    characteristic: BluetoothGattCharacteristic?,
    status: Int
) {
    super.onCharacteristicWrite(gatt, characteristic, status)
    Log.d(TAG, "onCharacteristicWrite: $status")
    onCharacteristicWrite(status)
}
  • 对于BluetoothGatt,当同一时间内不支持发生多个读写操作时

    • BluetoothGatt内部使用mDeviceBusy字段来表示当前是否有正在进行的操作,如果有,返回false
    • 可以将各种操作的回调视为结束标志。在我目前项目中,每次读写操作后都会等待回调,然后再进行其他操作

断开连接

  • 关闭BluetoothGatt即可

进行ble开发是公司的一个项目,需求是使用手机对蓝牙设备进行简单调试,有一份该蓝牙设备的开发文档。