Android蓝牙搜索APP开发:经典蓝牙+BLE完整实战与权限适配 简介这是一款基于Android Studio开发的蓝牙搜索APP项目面向Android开发者与蓝牙通信学习者解决在真机上快速搜索经典蓝牙与BLE低功耗设备的需求。APP界面会实时列出扫描到的周边设备并支持点击设备进行后续连接操作可作为蓝牙开发入门或项目改造的基础模板。压缩包共645个文件约12.73MB以xml布局与配置、flat资源编译文件、json数据、dex/class编译产物以及java源码为主同时包含gradle构建脚本和APK安装包结构完整导入Android Studio即可查看或运行。已有5230人学习下载适合正在学习蓝牙API、需要参考实际扫描流程的开发者。资源包含完整的Android工程目录与可执行APK既可直接安装体验搜索效果也可对照源码研究经典蓝牙与BLE两种模式的扫描回调、权限适配及列表展示逻辑省去从零搭建环境的时间是蓝牙功能开发中实用的参考资料。AndroidStudio经典蓝牙BLE蓝牙搜索APP从原理到实践的完整复盘做Android蓝牙开发这些年最常被问到的就是为什么我的手机搜不到设备或者经典蓝牙和BLE到底该用哪套API。说实话这两套体系看似都叫蓝牙但从底层的射频策略到上层的协议栈实现完全是两套逻辑。这篇文章我想把我实际开发经典蓝牙BLE蓝牙搜索APP的完整思路、踩坑记录和代码细节都整理出来给正在做类似项目的朋友一个可参考的路线。这个APP的核心需求很直接一是扫描周围的经典蓝牙设备比如蓝牙音箱、HC-05模块、车载蓝牙二是扫描低功耗BLE设备比如手环、ibeacon信标、ESP32开发板三是把两者的扫描结果统一展示在列表里支持点击连接。适合刚接触Android蓝牙开发的初学者也适合需要快速实现设备搜索功能的硬件配套开发者。1. 整体思路为什么要把经典蓝牙和BLE放在同一个APP里1.1 两套协议的本质区别很多新手上来就直接写代码结果发现经典蓝牙的startDiscovery()根本搜不到BLE设备或者BLE扫描回调里永远只有一个BluetoothDevice却连不上老式蓝牙音箱。这不是代码问题是协议本身决定了它们的工作方式不同。经典蓝牙BR/EDR面向持续、稳定的数据流传输比如语音通话、音频播放配对后链路会一直保持。它的发现机制是BluetoothAdapter.startDiscovery()扫描周期通常在12秒左右在这期间设备会广播自己的名字、MAC地址和设备类别Class of Device。这也是为什么经典蓝牙扫描特别费电因为它要持续监听多个频点。BLEBluetooth Low Energy则是为间歇性、小数据量的传输设计的。广播包的数据结构更灵活可以携带自定义的Service UUID、设备名称、厂商特定数据Manufacturer Specific Data。扫描通过BluetoothLeScanner.startScan()实现底层是基于周期性的信道跳频监听功耗比经典蓝牙低一个数量级。BLE设备通常不主动保持连接而是处于广播—监听—休眠的循环里。理解了这层差异就明白为什么要在一个APP里分别调两套扫描逻辑。Android系统没有提供一键扫全部的API只有自己同时发起两套扫描再把结果合并展示。1.2 方案选型系统原生API还是第三方库我在项目初期考虑过用第三方蓝牙库比如RxAndroidBle或者FastBLE它们封装了扫描、连接和MTU协商这些繁琐细节。但最后我选择了原生API原因有三个第一蓝牙权限模型在Android 6、Android 10、Android 12这几个版本里各有大改用库反而难排查问题。第二经典蓝牙的配对流程PIN码输入、用户确认无法被库完全接管最终还是要回到原生API。第三作为教学型项目原生API能让你看到每个步骤的运作逻辑对理解蓝牙协议大有帮助。如果你是做产品级BLE通信应用使用库可以节省大量开发时间但如果你像我一样需要同时处理经典蓝牙和BLE并且需要精细控制扫描策略原生API反而是更可控的选择。1.3 架构设计和页面拆分整个APP我按照功能模块拆成了三块权限准备模块负责检查和请求所有运行时权限包括定位、蓝牙、附近设备权限扫描模块经典蓝牙BluetoothAdapter.startDiscovery()和BLEBluetoothLeScanner.startScan()并行扫描统一回调展示与连接模块RecyclerView统一展示点击经典蓝牙设备走配对流程点击BLE设备走GATT连接流程UI上就是一个MainActivity顶部一个开始扫描按钮中间是结果列表底部展示当前扫描状态和日志信息。简洁但足够覆盖所有核心功能点。2. 核心细节权限配置和Android版本适配2.1 权限申请完整清单蓝牙权限是Android所有权限里最复杂的体系之一因为它涉及到定位权限、旧版蓝牙权限和新版附近设备权限的三代叠加。我整理了一份当前最新版的权限清单uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN / uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE / uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION android:maxSdkVersion30 / uses-permission android:nameandroid.permission.ACCESS_COARSE_LOCATION android:maxSdkVersion30 /注意maxSdkVersion30的意思是在Android 11及以下才需要这些权限Android 12及以上系统使用新的蓝牙权限模型。如果你在Android 12及以上还只申请了定位权限那么获取设备列表时大概率会返回空或者抛出SecurityException。2.2 Android 12的新蓝牙权限模型Android 12API 31引入了三个与蓝牙相关的新权限BLUETOOTH_SCAN、BLUETOOTH_CONNECT和BLUETOOTH_ADVERTISE。它们与旧版权限的最大区别在于系统不再将定位权限作为蓝牙扫描的前置条件。也就是说在Android 12以上如果你只想扫描BLE设备可以完全不需要定位权限——前提是APP的目标SDK版本为31及以上。不过这里有一个隐蔽的坑如果你的targetSdkVersion仍然是30或以下在Android 12手机上运行时系统还会强制你保留定位权限。所以务必把targetSdkVersion设为31以上才能使用新的权限模型。BLUETOOTH_SCAN和BLUETOOTH_CONNECT都属于运行时权限需要动态申请。我建议在APP启动时就同时申请这两个权限加上定位权限兼容Android 11及以下设备一次性到位避免用户在使用过程中频繁被弹窗打扰。2.3 动态权限请求代码模板private static final int REQUEST_PERMISSION_CODE 100; private void checkAndRequestPermissions() { String[] permissions new String[]{ Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.ACCESS_FINE_LOCATION }; ListString needRequest new ArrayList(); for (String permission : permissions) { if (ContextCompat.checkSelfPermission(this, permission) ! PackageManager.PERMISSION_GRANTED) { needRequest.add(permission); } } if (!needRequest.isEmpty()) { ActivityCompat.requestPermissions(this, needRequest.toArray(new String[0]), REQUEST_PERMISSION_CODE); } else { startScan(); } } Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode REQUEST_PERMISSION_CODE) { boolean allGranted true; for (int result : grantResults) { if (result ! PackageManager.PERMISSION_GRANTED) { allGranted false; break; } } if (allGranted) { startScan(); } else { // 提示用户去设置页开启权限 Toast.makeText(this, 需要蓝牙权限才能扫描设备, Toast.LENGTH_SHORT).show(); } } }实际上这里还有一个经验部分国产ROM对BLUETOOTH_SCAN权限的弹窗逻辑处理不同如果用户选择了“仅本次允许”杀掉APP重进后可能又失效。所以最稳妥的做法是在onResume()里加一次权限状态检测若有缺失就重新申请。3. 经典蓝牙扫描的实现细节3.1 传统扫描方式startDiscovery()经典蓝牙的扫描非常简单粗暴核心步骤是注册BroadcastReceiver接收ACTION_FOUND和ACTION_DISCOVERY_FINISHED然后调用startDiscovery()。private final BroadcastReceiver bluetoothReceiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { String action intent.getAction(); if (BluetoothDevice.ACTION_FOUND.equals(action)) { BluetoothDevice device intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); short rssi intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE); updateDeviceList(device, rssi, DeviceType.CLASSIC); } else if (BluetoothAdapter.ACTION_DISCOVERY_FINISHED.equals(action)) { updateScanStatus(经典蓝牙扫描结束); } } }; private void startClassicScan() { if (bluetoothAdapter.isDiscovering()) { bluetoothAdapter.cancelDiscovery(); } bluetoothAdapter.startDiscovery(); }这里有一个必须注意的细节BluetoothAdapter.EXTRA_RSSI在经典蓝牙的搜索结果里不一定有效很多设备返回的是Short.MIN_VALUE也就是-32768。这不是你的代码问题而是部分蓝牙协议栈没有上报RSSI。所以在展示时要做特殊判断不要直接显示一个荒谬的负值。3.2 startDiscovery()的局限性startDiscovery()只能搜索经典蓝牙设备而且它有一个天然缺陷扫描过程中如果系统或其他应用再次调用cancelDiscovery()你的扫描就会被中断。此外某些低功耗设备如BLE手环在经典蓝牙扫描模式下是完全不可见的。另一个要注意的是startDiscovery()是有副作用的扫描它会打断正在进行的蓝牙连接。如果在扫描期间你的APP正在播放蓝牙音频或者传输文件可能会引发连接断开。所以最佳实践是在开始扫描前检查连接状态或者至少给用户一个提示。3.3 经典蓝牙设备信息的读取每次ACTION_FOUND返回的BluetoothDevice对象里可以通过以下方法获取信息getName()设备名称可能是nullgetAddress()MAC地址格式如AA:BB:CC:DD:EE:FFgetBluetoothClass()设备类别可以判断是音频设备、手机还是电脑设备类别在实际开发中挺有用比如你想过滤出所有音频类设备可以这样判断BluetoothClass btClass device.getBluetoothClass(); if (btClass ! null btClass.hasService(BluetoothClass.PROFILE_A2DP)) { // 这是支持A2DP的音频设备 }BluetoothClass的常量挺多但常用的就那几个PROFILE_A2DP音频、PROFILE_HID键盘鼠标、PROFILE_OPP文件传输。在展示列表时加上设备类型图标体验会好很多。4. BLE扫描的核心实现4.1 BluetoothLeScanner的使用BLE扫描的核心类是BluetoothLeScanner获取方式很简单bluetoothAdapter.getBluetoothLeScanner()。扫描结果通过ScanCallback回调返回。private final ScanCallback bleScanCallback new ScanCallback() { Override public void onScanResult(int callbackType, ScanResult result) { super.onScanResult(callbackType, result); BluetoothDevice device result.getDevice(); int rssi result.getRssi(); byte[] scanRecord result.getScanRecord() ! null ? result.getScanRecord().getBytes() : null; updateDeviceList(device, rssi, DeviceType.BLE); } Override public void onBatchScanResults(ListScanResult results) { super.onBatchScanResults(results); for (ScanResult result : results) { BluetoothDevice device result.getDevice(); updateDeviceList(device, result.getRssi(), DeviceType.BLE); } } Override public void onScanFailed(int errorCode) { super.onScanFailed(errorCode); updateScanStatus(BLE扫描失败错误码 errorCode); } }; private void startBleScan() { bluetoothLeScanner.startScan(bleScanCallback); }4.2 ScanFilter配置按需过滤广播包如果只是想扫描特定设备比如某个ESP32开发板用ScanFilter可以大大减少无效设备的干扰。BLE广播包里最常见的是Service UUID你可以根据目标设备的UUID来过滤ListScanFilter filters new ArrayList(); ScanFilter filter new ScanFilter.Builder() .setServiceUuid(ParcelUuid.fromString(0000ffe0-0000-1000-8000-00805f9b34fb)) .build(); filters.add(filter); ScanSettings settings new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build(); bluetoothLeScanner.startScan(filters, settings, bleScanCallback);这里ScanSettings的三个模式含义分别是SCAN_MODE_LOW_POWER低功耗模式扫描间隔长适合后台扫描SCAN_MODE_BALANCED平衡模式默认值间隔和功耗居中SCAN_MODE_LOW_LATENCY低延迟模式持续扫描适合主动寻找设备的场景我在做这个APP时主动扫描用SCAN_MODE_LOW_LATENCY扫描间隔大约100msRSSI变化明显更平滑设备发现速度很快。但同时功耗也高如果长时间扫描会明显发热。所以在扫描开始后5~10秒就应该自动停止提升用户体验。4.3 从广播包中解析厂商自定义数据BLE协议的强大之处在于广播包非常灵活。很多IoT设备会把自定义数据塞进Manufacturer Specific Data里用于标识设备状态或传输传感器值。解析方法如下byte[] manufacturerData scanRecord ! null ? scanRecord.getManufacturerSpecificData(0xFFFF) : null; if (manufacturerData ! null manufacturerData.length 2) { // 前两个字节通常是厂商ID小端序 int companyId (manufacturerData[1] 0xFF) 8 | (manufacturerData[0] 0xFF); // 后面的字节是自定义数据 byte[] customData Arrays.copyOfRange(manufacturerData, 2, manufacturerData.length); }这个能力在做一个扫描工具型APP时尤为重要因为你可以根据厂商ID快速识别设备类型比如Apple的ID是0x004CNordic的ID是0x0059。把这些信息展示在列表里会让用户觉得这个APP非常“专业”。5. 合并扫描列表与去重策略5.1 两种扫描结果统一处理经典蓝牙和BLE扫描虽然API不同但结果都包含BluetoothDevice对象。我统一用HashMapString, DeviceItem缓存key是MAC地址value包含设备名称、MAC地址、RSSI、设备类型经典或BLE、最后发现时间。public class DeviceItem { public String name; public String address; public int rssi; public int deviceType; // 0经典 1BLE public long lastSeenTime; } private final HashMapString, DeviceItem deviceMap new HashMap(); private void updateDeviceList(BluetoothDevice device, int rssi, int deviceType) { String address device.getAddress(); DeviceItem item deviceMap.get(address); if (item null) { item new DeviceItem(); item.address address; item.deviceType deviceType; item.name device.getName() ! null ? device.getName() : 未知设备; deviceMap.put(address, item); } item.rssi rssi; item.lastSeenTime System.currentTimeMillis(); runOnUiThread(() - adapter.notifyDataSetChanged()); }上这种结构有几个好处首先一个设备如果同时被两种扫描方式发现极少见但可能后扫描到的类型会覆盖前一种其次展示列表实时更新时不需要频繁创建新对象避免RecyclerView闪烁。5.2 排序按RSSI信号强度降序排列信号强度是用户选择设备的最直观依据。我采用RSSI降序排序信号越强排越前面。但要注意把Short.MIN_VALUE经典蓝牙无RSSI的情况特殊处理排在最后面Collections.sort(deviceList, (a, b) - { int rssiA a.rssi Short.MIN_VALUE 1 ? a.rssi : -1000; int rssiB b.rssi Short.MIN_VALUE 1 ? b.rssi : -1000; return rssiB - rssiA; });实际体验中RSSI的波动是比较大的比如一台设备可能上一秒是-45dBm下一秒掉到-65dBm。如果每次刷新都重新排序列表会不停地跳动观感很糟糕。我加了一个简单的平滑策略只当RSSI变化超过5dBm才触发排序或者每隔2秒才重排一次。这样既保留了实时性又不至于闪烁。5.3 点击设备后的连接处理列表点击事件要根据设备类型分道扬镳。经典蓝牙走createBond()配对流程BLE走connectGatt()连接流程// 经典蓝牙配对 if (deviceType 0) { boolean bondResult device.createBond(); // 结果通过 BroadcastReceiver 接收 ACTION_BOND_STATE_CHANGED } else { // BLE连接 bluetoothGatt device.connectGatt(context, false, gattCallback); }这里有个容易犯错的地方createBond()调用了但立刻返回的结果基本都是true实际配对是否成功要看异步的BOND_STATE_CHANGED广播。同样的BLE的connectGatt()也是异步的连接结果要通过onConnectionStateChange回调里判断newState BluetoothProfile.STATE_CONNECTED。6. 实操过程中的常见问题与排查方法6.1 扫描不到任何设备这是最常遇到的场景90%的情况都是权限问题。第一步先确认Android版本和targetSdkVersion是否匹配Android 12及以上必须使用BLUETOOTH_SCAN权限并动态申请成功。第二步检查定位开关是否打开针对Android 11及以下设备即使你申请了权限系统级定位开关关闭也会导致扫描直接失败。第三步查看logcat是否出现SecurityException或Failed to start scan的报错。我还遇到过一种特殊情况部分手机的省电模式会强制停掉后台蓝牙扫描表现为扫描开始没几秒就自动停止且没有任何回调。这时候需要引导用户关闭省电模式或者将APP加入电池优化白名单。6.2 经典蓝牙扫描到了但无法配对如果扫描正常但配对时一直失败或者卡在配对状态先看设备类型。像HC-05这类老式蓝牙模块默认PIN码通常是1234或0000需要在BOND_STATE_CHANGED广播里监听ACTION_PAIRING_REQUEST然后调用setPin()方法自动输入PIN码if (BluetoothDevice.ACTION_PAIRING_REQUEST.equals(action)) { BluetoothDevice device intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); try { device.setPin(1234.getBytes()); device.setPairingConfirmation(true); } catch (Exception e) { e.printStackTrace(); } }对于新式蓝牙设备可能使用数字比较配对Numeric Comparison这种模式需要在两端屏幕上确认六位数字一致。APP端可以通过setPairingConfirmation(true)来模拟用户点击“确认”。需要注意的是配对请求广播是受系统保护的部分ROM会拦截第三方APP的自动配对操作。如果你的目标用户主要在国内测试时要特别留意小米、华为等机型上的表现差异。6.3 BLE连接后不稳定、频繁断开BLE连接不稳定通常有三个原因。第一是扫描模式太激进一直用SCAN_MODE_LOW_LATENCY在连接后不停止扫描蓝牙协议栈同频段干扰严重。解决方案是在连接成功后立即停止扫描bluetoothLeScanner.stopScan(bleScanCallback);第二是MTU没有协商。如果设备需要传输大数据包比如超过20字节默认的23字节MTU会导致分包传输失败。连接成功后要主动请求requestMtu(247)bluetoothGatt.requestMtu(247);但这个操作是异步的必须在onMtuChanged回调里确认是否成功。如果设备端不支持大MTU请求会失败或回退到默认值这时候需要降级为分包发送策略。第三是GATT同时只支持一个连接。如果你的APP先扫描到设备A并连接了再点设备BconnectGatt()返回的BluetoothGatt对象会替换掉之前的连接。要支持多设备连接必须为每个设备创建独立的BluetoothGatt实例并保存好引用。6.4 Android 12上无法获取设备名称在Android 12及以上如果你只申请了BLUETOOTH_SCAN而没有BLUETOOTH_CONNECT权限扫描到的BluetoothDevice对象的名称会显示为null。这是因为读取设备名称属于“连接”类操作不仅仅是你主动去连接连读取属性都需要BLUETOOTH_CONNECT权限。所以清单里的两个权限都要申请缺一不可。还有一点getParcelableExtra在Android 12及以上如果继续使用API 33以下的写法会提示要指定类型// 新的写法 BluetoothDevice device intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice.class);如果你还在用老的写法编译会报警告但不至于崩溃在Android 14上这个警告会变成运行时异常建议直接改掉。6.5 常见问题速查表现象可能原因解决方案扫描无任何结果未授予蓝牙权限或定位权限检查动态权限申请流程和Android版本适配经典蓝牙能扫到BLE扫不到未调用startScan()或ScanCallback未实现确认BLE扫描启动成功查看logcat错误扫描一开始就结束系统省电策略或蓝牙协议栈限制引导用户关闭省电模式降低扫描模式配对弹窗未出现广播接收器未正确注册确认在onCreate注册ACTION_PAIRING_REQUEST广播BLE连接后立即断开MTU过小或广播包冲突连接成功后请求requestMtu(247)先停止扫描再连接RSSI重复且跳变严重扫描回调刷新频率过高增加RSSI变化阈值5dBm再更新UIAndroid 12设备名称全为null缺少BLUETOOTH_CONNECT权限补充该权限并进行动态申请7. 项目优化与后续扩展7.1 扫描策略优化使用延迟停止无条件长时间扫描会消耗大量电量和系统资源。我建议做一个自动停止逻辑启动扫描后启动一个5秒的Handler延迟任务到时自动调用stopScan()和cancelDiscovery()并把状态改为“已停止”。用户如果需要持续扫描可以再点一次“继续扫描”按钮。最终测试下来5秒能发现绝大多数场景下的设备10秒收益并不显著。7.2 扩展方向一BLE数据交互封装本项目的重点在“搜索”和“连接”阶段。实际项目里连接之后才是重头戏——读写特征值、订阅通知、处理分包。建议在现有基础上增加一个BleConnectionManager类封装以下操作discoverServices()完成后的特征值遍历setCharacteristicNotification()开启通知writeCharacteristic()写数据注意Android 13需要传入WriteType参数onCharacteristicChanged()回调里解析数据7.3 扩展方向二经典蓝牙的文件传输经典蓝牙的FileTransfer能力通过OPP协议在Android 8之后被系统隐藏了部分API无法直接通过BluetoothDevice.ACTION_SEND来发送文件。但如果你想做蓝牙聊天、蓝牙遥控之类的应用使用RFCOMM socket通信依然是稳定可行的路线。可以参考BluetoothSocket相关的createRfcommSocketToServiceRecord(UUID)方法。7.4 扩展方向三导出扫描报告我这个项目后期加了一个功能把扫描结果导出为CSV文件方便外场测试时做信号覆盖分析。每行记录包含设备名称、MAC地址、RSSI、设备类型、扫描时间。方法很简单就是遍历deviceMap写到文件时间戳用SimpleDateFormat格式化。这一步看似不起眼但在实际做硬件联调时非常有用能帮你分析某个设备在哪个位置信号最优。8. 个人实操经验总结做完这个蓝牙搜索APP我最深的感触是蓝牙开发里80%的问题都不是代码逻辑错误而是对系统权限模型和底层协议理解不到位。很多朋友一上来就写扫描代码结果在真机上各种失败然后就开始怀疑人生。我的建议是先花一个下午把Android文档里关于蓝牙权限的几页仔细读一遍把API 30和API 31的差异搞清楚再动手写代码会省掉大量的排查时间。另外一个小技巧是开发测试时尽量准备多个不同类型的设备——一个老式蓝牙音箱、一个HC-05模块、一个ESP32开发板、一个BLE手环。这样能同时覆盖经典蓝牙、BLE广播、自定义服务UUID等不同场景比单纯拿两台手机互测要靠谱得多。而且手头有这些真实设备调试功耗、连接稳定性、配对流程这些细节问题时会非常高效。最后如果你在开发过程中遇到什么新的坑欢迎在评论区分享你的设备和系统版本。蓝牙这块的兼容性问题特别依赖真实设备反馈一个具体的报错信息对后来者可能就是最宝贵的排查线索。本文还有配套的精品资源点击获取