Android经典蓝牙与BLE双协议搜索实战:从权限适配到设备连接
简介这是一份基于Android Studio开发的蓝牙搜索APP项目面向Android应用开发学习者重点演示经典蓝牙与BLE低功耗蓝牙设备的搜索、识别与交互流程。压缩包共645个文件涵盖java源码、xml布局与清单配置、gradle构建脚本、flat资源编译文件、dex/class字节码以及可直接安装的apk包整体约12.73MB目录结构完整便于导入工程查看或二次开发。已有5230人学习下载。项目实现了经典蓝牙与BLE两种模式的设备扫描搜索结果实时显示在列表中并支持点击设备进一步操作对于想了解Android蓝牙权限适配、扫描回调处理、设备信息展示的开发者这份源码提供了完整的落地示例可结合博主对应文章快速对照学习。 上个月在做一个周边设备管理的小项目核心功能就是把周围所有支持蓝牙的设备都搜出来——不管是老式的蓝牙耳机、HC-05串口模块还是现在满大街的BLE传感器、手环一屏全列出来。最初想着只做BLE就够了结果回归测试时被砍了一刀桌上一堆经典蓝牙设备根本搜不到。于是老老实实用AndroidStudio把经典蓝牙 BLE两条搜索链路都实现了一遍顺便把权限适配、扫描回调、设备去重、连接状态管理这些坑全踩了个遍。这篇文章就是这次实战的记录适合正在做蓝牙搜索类APP、或者想搞清楚两种蓝牙协议到底怎么用的开发者参考。1. 项目概述与整体设计思路1.1 经典蓝牙和BLE的本质区别先说清楚一个容易混淆的点经典蓝牙BR/EDR和BLE低功耗蓝牙是两套完全不同的协议栈虽然都叫“蓝牙”但搜索机制、连接方式、传输特性都不一样。经典蓝牙解决的是“持续大流量传输”问题比如蓝牙耳机听歌、蓝牙音箱放音乐、文件传输它带宽高1~3Mbps、功耗也高连接后是一条长期占用的数据传输通道。BLE主打的是“低功耗、小数据包、偶发通信”比如运动手环上报心率、体温计传温度、门锁接收开锁指令它休眠时几乎不耗电需要时才短暂唤醒。这两者的搜索机制更是截然不同。经典蓝牙搜索走的是BluetoothAdapter.startDiscovery()系统在多个射频信道上持续广播查询直到找到设备或者超时结束。BLE则完全反过来设备处于广播状态接收端用BluetoothLeScanner.startScan()去持续监听广播包能拿到设备的广播数据比如设备名称、服务UUID、RSSI信号强度信息量比经典蓝牙多很多。对比项经典蓝牙BR/EDRBLE低功耗蓝牙传输速率1~3 Mbps理论更高125 Kbps ~ 2 Mbps功耗高适合持续传输极低纽扣电池可撑数月连接模式一对一配对后持续占用广播 按需连接典型场景耳机、音箱、文件传输、串口模块手环、传感器、智能锁、信标搜索方式startDiscovery()系统级搜索startScan()应用层监听广播搜索信息设备名、MAC地址、类型设备名、MAC、RSSI、广播包、服务UUID实际开发中不能只考虑一种协议。很多项目最初只做BLE结果测试时发现用户的蓝牙音箱、车机、老式串口模块全部搜不到因为这些设备根本不广播BLE信标。所以只要是“周边设备发现”类APP最好两条链路都实现后面按设备类型分流处理。1.2 为什么两个搜索链路要同时实现我在这个项目里把搜索入口做成了一个独立页面顶部是“开始扫描”按钮下面是结果列表。点击扫描后同时启动经典蓝牙搜索和BLE扫描用不同的Tag区分设备类型最终统一展示在RecyclerView里。这样做的好处是用户可以一次操作看到周边所有蓝牙设备不用切来切去。决定同时实现两个链路还因为真实场景里两种设备经常混在一起。比如调试硬件时树莓派跑的是BLE Beacon但旁边的蓝牙键盘用的是经典蓝牙测试蓝牙门锁门锁本身是BLE但调试用的USB蓝牙适配器搜出来却是经典蓝牙设备。如果只实现一种遇到另一种设备就只能干瞪眼。影响范围也不小。从物联网设备的配网、蓝牙外设的调试、商超室内定位到车机互联、运动健康数据采集都需要先“发现设备”这一步。这套双链路搜索的骨架搭好之后后续无论接什么协议、什么硬件只需要在设备分类和连接逻辑上做扩展。2. 环境准备与基础配置2.1 工程配置与依赖引入开发环境我建议用较新的稳定版AndroidStudio我这边用的是Ladybug版本Gradle插件版本在settings.gradle里指定差异不大。新建项目时语言选Kotlin最小SDK版本设到Android 6.0API 23左右这样低版本设备也能覆盖同时不影响使用现代API。搜索结果列表我用RecyclerView展示依赖在build.gradle里加两行就行implementation(androidx.recyclerview:recyclerview:1.3.2) implementation(com.google.android.material:material:1.12.0)工程配置里有一个细节值得注意因为要兼容低版本Android同时又要适配Android 12的新蓝牙权限模型建议把targetSdk设到31或更高。这样在低版本设备上走旧权限逻辑在高版本设备上自动走新的蓝牙权限流程逻辑清晰也方便针对不同版本做兼容处理。2.2 Android权限体系的三层演进蓝牙搜索权限是这套项目里最容易踩坑的地方因为Android系统对蓝牙权限的要求经历了几次大变化网上很多旧教程已经失效了。Android 6.0API 23之前蓝牙搜索只需要在AndroidManifest.xml里声明BLUETOOTH和BLUETOOTH_ADMIN两个权限即可安装时自动赋予。从Android 6.0开始搜索蓝牙设备必须额外申请定位权限ACCESS_FINE_LOCATION或ACCESS_COARSE_LOCATION因为系统认为蓝牙扫描可能暴露用户位置信息。这一步不做搜索结果永远为空而且不会报错非常坑。Android 12API 31是另一道分水岭新增了BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE三个细粒度权限旧的BLUETOOTH/BLUETOOTH_ADMIN被废弃了。搜索时必须申请BLUETOOTH_SCAN连接设备时还要BLUETOOTH_CONNECT两者都是危险权限需要运行时动态申请。定位权限在Android 12上依然需要不过部分设备上neverForLocation属性可以减少对定位权限的依赖但为兼容性我建议先不做这个优化老老实实申请定位权限。使用场景Android 6.0 ~ 11Android 12打开蓝牙、连接设备BLUETOOTHBLUETOOTH_CONNECT动态申请搜索周边蓝牙设备BLUETOOTH_ADMIN 定位权限BLUETOOTH_SCAN 定位权限BLE广播可选BLUETOOTH_ADMINBLUETOOTH_ADVERTISE动态申请2.3 动态权限申请的正确姿势权限申请我用了Activity Result API比老的onRequestPermissionsResult写法干净太多。核心思路是先把所有需要的权限打包成一个数组统一申请回调里根据结果决定是否继续扫描。private val permissionLauncher registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions - val allGranted permissions.values.all { it } if (allGranted) { startScan() } else { showToast(缺少蓝牙或定位权限无法扫描) } } private fun checkPermissions() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { permissionLauncher.launch( arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.ACCESS_FINE_LOCATION ) ) } else { permissionLauncher.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION ) ) } }这个写法的好处是一处封装两个版本通用。申请之前要先判断蓝牙是否开启没开启就弹系统设置让用户打开否则后续搜索直接返回空。我的判断逻辑是拿到BluetoothAdapter后检查isEnabled如果为false就发ACTION_REQUEST_ENABLE意图引导用户开启。3. 核心功能实现与关键代码3.1 经典蓝牙搜索startDiscovery 的正确打开方式经典蓝牙搜索的核心是BluetoothAdapter.startDiscovery()它是一个系统级搜索结果通过广播返回。需要注册两个BroadcastReceiver一个监听BluetoothDevice.ACTION_FOUND发现新设备另一个监听BluetoothAdapter.ACTION_DISCOVERY_FINISHED搜索结束。初版的代码我踩了一个很隐蔽的坑startDiscovery()在设备正在搜索时调用会直接返回false必须先cancelDiscovery()再重新启动。而且每次搜索结束之后系统不会自动重新搜索需要手动再次调用。我的做法是增加一个“搜索中”标志位只允许同时运行一轮扫描。private fun startDiscovery() { val adapter bluetoothManager.adapter ?: return if (adapter.isDiscovering) { adapter.cancelDiscovery() } // 注册广播接收器搜索结束后需要注销 registerReceiver(discoveryReceiver, IntentFilter().apply { addAction(BluetoothDevice.ACTION_FOUND) addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED) }) adapter.startDiscovery() } private val discoveryReceiver object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { BluetoothDevice.ACTION_FOUND - { val device: BluetoothDevice? intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) device?.let { addDeviceToList(DeviceItem( name it.name ?: 未知设备, address it.address, rssi intent.getShortExtra( BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE ).toInt(), type DeviceType.CLASSIC )) } } BluetoothAdapter.ACTION_DISCOVERY_FINISHED - { unregisterReceiver(this) isScanning false updateScanButton() } } } }广播RSSI信号强度是从EXTRA_RSSI里取出来的这个在搜索结果列表里很有用可以直观展示信号强弱。3.2 BLE扫描ScanFilter 与 ScanSettings 调参实战BLE扫描走的是BluetoothLeScanner.startScan()回调机制。相比经典蓝牙它是被动的广播端持续往外发广播包扫描端注册回调接收。Android 5.0API 21之后系统把扫描API统一为ScanFilterScanSettingsScanCallback三件套。ScanSettings可以控制扫描功耗和结果回调频率核心参数是SCAN_MODE_LOW_LATENCY高频率扫描延迟低适合寻找设备时用、SCAN_MODE_BALANCED平衡模式、SCAN_MODE_LOW_POWER低功耗适合后台扫描。我实测下来搜索阶段用SCAN_MODE_LOW_LATENCY结果刷新频率高很多设备几乎秒出。缺点是手机发热会明显一点所以我在搜索结束后立即停止扫描。private fun startBleScan() { val scanner bluetoothManager.adapter?.bluetoothLeScanner ?: return val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setReportDelay(0) // 收到广播立即回调不做批量上报 .build() scanner.startScan(null, settings, bleScanCallback) } private val bleScanCallback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val device result.device addDeviceToList(DeviceItem( name device.name ?: 未知设备, address device.address ?: 未知地址, rssi result.rssi, type DeviceType.BLE )) } override fun onScanFailed(errorCode: Int) { showToast(BLE扫描失败错误码: $errorCode) isScanning false } }这里有个参数细节值得说明setReportDelay(0)表示每收到一个广播包立即回调如果设置为大于0的数值比如1000系统就会把1秒内的扫描结果打包批量上报适合需要批量处理、不想频繁刷新UI的场景。搜索阶段我追求实时性所以用0。ScanFilter可以按照设备地址、服务UUID来精确过滤比如只扫描某一家厂商的传感器。我这次做的是通用搜索所以传了null不加过滤。如果在实际项目中只需要连接特定设备强烈建议加过滤条件能大幅减少无效回调也省电。3.3 结果去重RSSI动态更新与RecyclerView刷新搜索结果列表里最大的问题不是拿不到设备而是同设备重复出现。经典蓝牙在搜索期间同一个设备可能被广播多次BLE更是如此——广播包本来就周期性重复发送。我最早直接add进列表几秒钟列表就重复几百条整个页面卡成PPT。去重的核心是以MAC地址作为唯一键。我维护了一个HashMapString, DeviceItem每来一个扫描结果先判断key是否存在不存在就加入列表已存在就更新RSSI强度同时刷新对应行的UI。这样列表长度是稳定的信号强度还能实时跳动体验比单纯堆设备列表好得多。private val deviceMap LinkedHashMapString, DeviceItem() private fun addDeviceToList(item: DeviceItem) { val key item.address if (deviceMap.containsKey(key)) { val updated deviceMap[key]!!.copy(rssi item.rssi) deviceMap[key] updated val index deviceMap.keys.indexOf(key) adapter.notifyItemChanged(index, rssi) } else { deviceMap[key] item adapter.notifyItemInserted(deviceMap.size - 1) } }UI刷新我用notifyItemChanged而不是notifyDataSetChanged性能差异巨大。尤其是蓝牙设备二三十个时全局刷新会把ViewHolder全部重建明显掉帧局部刷新只更新有变化的item点击、滑动都流畅很多。3.4 连接状态管理从搜索到连接的完整链路搜索阶段结束后下一步通常是连接设备。经典蓝牙和BLE的连接API完全不同我分别封装了两个方法。经典蓝牙使用RfcommSocket核心是先拿到一个UUID通常是SPP协议的标准UUID然后建立socket连接private suspend fun connectClassic(device: BluetoothDevice): Boolean { val uuid UUID.fromString(00001101-0000-1000-8000-00805F9B34FB) // SPP val socket device.createRfcommSocketToServiceRecord(uuid) socket.connect() // 建议放到协程里避免主线程阻塞 return socket.isConnected }BLE连接走connectGatt通过回调监听连接状态private fun connectBle(device: BluetoothDevice) { bluetoothGatt device.connectGatt(context, false, object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState BluetoothProfile.STATE_CONNECTED) { gatt.discoverServices() } else if (newState BluetoothProfile.STATE_DISCONNECTED) { gatt.close() } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { // 可以在这里读取服务、订阅通知 } }) }连接状态我用一个简单的枚举管理IDLE空闲、SCANNING搜索中、CONNECTING连接中、CONNECTED已连接。每轮扫描结束后统一清理广播接收器和扫描回调防止状态残留导致下一次扫描异常。这里有一个实战要点经典蓝牙的socket.connect()是阻塞调用必须放到子线程或协程里否则主线程卡住会触发ANR。我建议用Kotlin协程的Dispatchers.IO成功回到主线程刷新UI失败时关闭socket并抛出异常提示用户。4. 常见问题排查与适配避坑4.1 搜索不到设备的5个常见原因这个项目调试过程中“搜索不到设备”是出现频率最高的问题绝大部分不是代码逻辑错误而是权限和环境问题。我整理了一个排查顺序建议新手按这个顺序走排查项具体操作说明蓝牙是否开启进系统设置确认蓝牙开关代码里也可以判断isEnabled定位权限是否授予检查ACCESS_FINE_LOCATION是否已授予Android 6.0 必须没有这个权限搜索永远为空定位服务是否开启Android 10及以上设备要求GPS开关打开部分国产ROM即使授权了定位权限GPS关闭也搜不到目标设备是否可被发现经典蓝牙设备需要进入配对模式很多设备默认不可发现是否在扫描中重复调用确认startDiscovery()前先cancelDiscovery()反复启动会导致搜索中断其中“定位服务是否开启”这个坑最容易忽略尤其在一些国产ROM上系统把定位权限和定位服务分开管理仅仅授权权限还不够GPS开关必须也是打开的。我一开始在模拟器上怎么搜都搜不到设备后来才发现模拟器的GPS默认关闭。4.2 Android 12 权限适配实测targetSdk 升到31之后第一个崩就是权限没申请。项目之前只申请了定位权限跑在Android 12真机上直接抛SecurityException。因为在Android 12上BLUETOOTH_SCAN和BLUETOOTH_CONNECT是运行时权限必须动态申请缺一不可。我实测过这样几种情况只申请BLUETOOTH_SCAN但不申请BLUETOOTH_CONNECT搜索和扫描都能跑但连接时崩溃反过来只申请连接权限搜索直接找不到设备。所以两个权限必须同时申请BLUETOOTH_ADVERTISE如果应用不做广播端可以不申请。另外Android 12的权限授权弹窗文案变得更细了用户可以选择“仅这一次允许”或“每次询问”权限被拒之后如果点了“不再询问”后续requestPermissions不会再弹窗只能引导用户去设置页手动打开。这个在项目里需要加一个兜底判断检测到!shouldShowRequestPermissionRationale且权限未授予时跳转系统设置。4.3 HC-05、JDY-31等经典串口模块连不上的排查项目测试时我专门接了一个HC-05模块做验证踩了不少坑。HC-05搜索不到或者连不上原因通常不在手机端而在模块端。首先是HC-05默认波特率是9600如果通过串口工具改过波特率手机端同样要以匹配的波特率连接否则数据收发全是乱码。其次是配对PIN码HC-05出厂默认是1234有些模块被改成其他值需要在模块的AT指令模式下重置。最后是工作模式HC-05有AT命令模式和透传模式之分只有处于透传模式时才能正常连接通信。排查时建议先用简单的串口助手工具比如AT指令调试软件确认模块本身工作正常再用APP连接避免两头同时排查浪费时间。如果你用的是JDY-31或者杰理系的蓝牙芯片逻辑类似但默认波特率可能是9600或115200详情看模块说明书。4.4 扫描回调异常与内存泄漏扫描回调不触发还有一个隐蔽原因是注册时机不对。广播接收器必须在ActivityonStart()之后注册在onStop()注销如果注册在onCreate()、注销在onDestroy()Activity虽然有引用但系统在屏幕旋转、退到后台时可能已经不派发广播了。如果是startScan()没有触发任何回调大概率是BluetoothLeScanner本身就是null——也就是说当前设备蓝牙没有开启或者设备不支持BLE。这个在代码入口处加一个判空就行if (bluetoothManager.adapter?.bluetoothLeScanner null) { showToast(设备不支持BLE扫描) return }内存泄漏主要出在两个地方一是广播接收器注册后忘记注销二是BLE扫描回调持有Activity引用。建议在onDestroy()里统一执行unregisterReceiver、stopScan、closeGatt三件事这个习惯能省掉大部分崩溃和泄漏问题。5. 实测效果与后续扩展5.1 实测数据与体验整套代码写完放到真机上跑了一圈体验比预期好不少。用的是中端机Android 13和一台老平板Android 8经典蓝牙搜索加BLE扫描同时开启结果列表大概2到3秒内开始出设备5秒左右能稳定列出周边所有可见设备。实测时桌上的设备有一款蓝牙耳机经典蓝牙、一个蓝牙键盘经典蓝牙、一块运动手环BLE、一个HC-05模块经典蓝牙、一个自制的BLE BeaconBLE。列表里5个设备全部出现经典蓝牙设备名完整BLE设备里那个自制Beacon因为没广播设备名显示成了“未知设备”RSSI强度跳动幅度在-60dBm到-75dBm之间。这里也暴露了一个需要优化的点有些BLE设备不广播设备名只能看到MAC地址。后续可以加一个“设备详情”弹窗展示设备的广播包内容、服务UUID列表、制造商信息这样即使无名设备也能通过特定特征判断身份。5.2 基于这套骨架还能做什么搜索骨架搭好之后扩展方向非常多。最常见的是RSSI测距——BLE信号强度与距离有一定对应关系可以通过result.rssi估算距离做室内定位、寻物提醒原理是把RSSI代入信号衰减模型求距离误差在靠近时1~2米内比较小远距离就不太准了。扫码配对也是一个实用扩展用户扫设备的二维码或条形码直接解析出MAC地址并过滤搜索减少干扰项。很多商用蓝牙设备出厂就贴了配对码这个功能落地价值很高。再往下走就是设备控制。手机连接ESP32这类物联网开发板时可以通过BLE发送JSON指令控制环境数据采集、灯带开关、智能窗帘如果设备支持配网服务比如很多WiFi模组通过BLE配网APP还可以作为配网工具把WiFi凭证传给设备。这个项目我也正在往这个方向扩展后续会单独写一篇配网流程的文章。另外如果你要对接iOS端建议留意一下BLE的连接参数规范大部分双端联调的问题都出在连接间隔、从机延迟这些参数上Android端默认参数基本够用但跨端测试时最好统一标准。最后再分享一个实用的小技巧搜索结束后建议不要再立即执行stopScan而是等1到2秒让列表里的RSSI稳定一下再停止。如果用户想持续观察信号变化就保留扫描状态让他手动停止这个交互细节能让整个APP用起来更顺手。这次项目整体做完最大的体会是做蓝牙开发不能只看API文档权限模型、设备兼容性、硬件端配置往往是卡住时间的大头把这几个环节梳理明白了后面再扩展什么功能都快很多。本文还有配套的精品资源点击获取

相关新闻

最新新闻

日新闻

周新闻

月新闻