如何使用 BluetoothAdapter 和 BluetoothGatt 实现 BLE 设备连接?

解读

国内面试中,BLE 连接是 Android 端 IoT/健康/车载岗位的高频考点。面试官不仅想知道“能不能连”,更关注“连得稳、断得优雅、权限合规、功耗可控”。因此,回答要覆盖:运行时权限、扫描过滤、连接参数、主线程与 Binder 线程隔离、异常重试、12/19 字节 MTU 协商、国产 ROM 后台限制、以及 Google 从 2021 年起强制启用的 BLUETOOTH_CONNECT 权限适配。回答顺序遵循“权限→扫描→连接→发现服务→读写→断开”完整闭环,并给出可直接落地的代码骨架。

知识点

  1. 权限模型:Android 12+ 的 BLUETOOTH_CONNECT、BLUETOOTH_SCAN、BLUETOOTH_ADVERTISE 为 runtime permission;旧版本用 LOCATION 权限。国产 ROM(小米、华为)在后台扫描时需额外“定位权限+GPS 开关”。
  2. 扫描优化:ScanFilter 指定 ServiceUuid 减少 CPU 唤醒;ScanSettings.SCAN_MODE_LOW_LATENCY 仅前台使用,后台必须 LOW_POWER 且加 10 min 轮询,否则被系统 kill。
  3. 连接与并发:BluetoothDevice.connectGatt() 返回 BluetoothGatt,必须在主线程调用;并发连接数 ≤7(Bluetooth 芯片固件限制)。国产 ROM 对后台应用限制 1 个。
  4. Gatt 回调线程:onConnectionStateChange 在 Binder 线程,不能直接在里做耗时操作;需 Handler 切换到主线程再操作 UI。
  5. MTU 与 PHY:requestMtu(247) 在 onServicesDiscovered 后发起;国内手环普遍只支持 23→185,失败需 fallback。requestConnectionPriority(CONNECTION_PRIORITY_HIGH) 可缩短连接间隔到 7.5 ms,但耗电+30%。
  6. 断开与重连:gatt.disconnect() 与 close() 必须成对出现,否则 BluetoothService 句柄泄漏导致 133 错误。国产 ROM 后台断开 5 min 后自动清理缓存,需刷新 BluetoothAdapter 再扫。
  7. 省电与后台:WorkManager + 前台服务提升进程优先级;扫描时使用 PendingIntent 扫描替代长驻 Service,适配 Android 12 后台启动限制。
  8. 安全合规:配对阶段如果设备使用 Just-Works,需弹窗提醒用户“明文传输风险”;若设备支持 LESC,主动启用 BluetoothDevice.createBond() 触发配对,避免明文泄露数据。

答案

以下代码演示“最小可运行 + 国内 ROM 兼容”的 BLE 连接流程,可直接在 Android 12 真机通过。

class BleConnector(private val context: Application) {

    private val mainHandler = Handler(Looper.getMainLooper())
    private var bluetoothGatt: BluetoothGatt? = null
    private val bluetoothManager =
        context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
    private val adapter: BluetoothAdapter
        get() = bluetoothManager.adapter

    /* ---------- 权限 ---------- */
    private val permissions12 = arrayOf(
        Manifest.permission.BLUETOOTH_CONNECT,
        Manifest.permission.BLUETOOTH_SCAN
    )
    private val permissionsLegacy = arrayOf(
        Manifest.permission.ACCESS_FINE_LOCATION
    )

    fun checkPermission(activity: Activity, block: () -> Unit) {
        val target = if (Build.VERSION.SDK_INT >= 31) permissions12 else permissionsLegacy
        val lacked = target.filter {
            ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
        }
        if (lacked.isEmpty()) block()
        else ActivityCompat.requestPermissions(activity, lacked.toTypedArray(), 1001)
    }

    /* ---------- 扫描 ---------- */
    private val scanCallback = object : ScanCallback() {
        override fun onScanResult(callbackType: Int, result: ScanResult) {
            val name = result.device.name ?: return
            if (name.startsWith("MyDevice")) {
                adapter.bluetoothLeScanner?.stopScan(this)
                connect(result.device)
            }
        }
    }

    fun startScan() {
        val filter = ScanFilter.Builder()
            .setServiceUuid(ParcelUuid.fromString("0000fff0-0000-1000-8000-00805f9b34fb"))
            .build()
        val settings = ScanSettings.Builder()
            .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
            .build()
        adapter.bluetoothLeScanner?.startScan(listOf(filter), settings, scanCallback)
    }

    /* ---------- 连接 ---------- */
    private val gattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
            if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) {
                mainHandler.post { gatt.discoverServices() }
            } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                mainHandler.post { close() }
            }
        }

        override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
            if (status == BluetoothGatt.GATT_SUCCESS) {
                gatt.requestMtu(247)          // 国产手环常见 185
                val service = gatt.getService(UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb"))
                val char = service?.getCharacteristic(UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb"))
                char?.let { enableNotification(gatt, it) }
            }
        }

        override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
            if (status == BluetoothGatt.GATT_SUCCESS) Log.d("BLE", "MTU=$mtu")
        }

        override fun onCharacteristicChanged(
            gatt: BluetoothGatt,
            characteristic: BluetoothGattCharacteristic
        ) {
            val data = characteristic.value
            Log.d("BLE", "Notify data=${data.toHexString()}")
        }
    }

    private fun connect(device: BluetoothDevice) {
        bluetoothGatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
    }

    private fun enableNotification(gatt: BluetoothGatt, char: BluetoothGattCharacteristic) {
        gatt.setCharacteristicNotification(char, true)
        val descriptor = char.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))
        descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
        gatt.writeDescriptor(descriptor)
    }

    /* ---------- 写数据 ---------- */
    fun write(cmd: ByteArray) {
        bluetoothGatt?.let { gatt ->
            val service = gatt.getService(UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb"))
            val char = service?.getCharacteristic(UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb"))
            char?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
            char?.value = cmd
            gatt.writeCharacteristic(char)
        }
    }

    /* ---------- 断开 ---------- */
    fun close() {
        bluetoothGatt?.disconnect()
        bluetoothGatt?.close()
        bluetoothGatt = null
    }
}

使用方式(在 Activity 内):

val connector = BleConnector(application)
connector.checkPermission(this) {
    connector.startScan()
}

要点回顾:

  1. 权限申请放在最前,Android 12 与旧版本分支处理。
  2. 扫描过滤指定 ServiceUuid,减少系统唤醒。
  3. connectGatt 第三个参数传 TRANSPORT_LE,避免国产双模芯片回退到 BR/EDR。
  4. onConnectionStateChange 的 status 非 0 时记录错误码,133 表示缓存失效,需刷新 BluetoothAdapter。
  5. disconnect() 与 close() 必须成对,防止 fd 泄漏。

拓展思考

  1. 断线重连策略:使用指数退避(1 s、2 s、4 s… 到 64 s),并在国产 ROM 后台限制场景下结合 WorkManager 拉起前台服务,保证重连进程存活。
  2. 高并发场景:车载蓝牙钥匙需同时连接 4 把钥匙,需自行维护“连接池”队列,限制并发 ≤7,超出则排队等待 onDisconnected 回调后再补位。
  3. 安全加固:对写特征值使用 AES-CCM 加密,密钥通过 LESC 配对阶段协商;在 TEE 中存储 LTK,防止国产 ROM 被 root 后 dump 出密钥。
  4. 功耗压测:使用 Battery Historian 观察扫描占空比,目标后台扫描电流 < 1 mA;若超过,改用 PendingIntent 扫描 + 10 min 间隔,符合国内终端厂商功耗白名单。
  5. 折叠屏适配:折叠展开导致 Activity 重建,需在 ViewModel 中持有 BleConnector,并通过 rememberSaveable 保存 MacAddress,重建后自动 reconnectGatt 复用缓存,避免用户重新扫描。