Android蓝牙SPP通讯Demo全解析:权限、扫描、配对、连接与收发避坑指南
2026/9/3 20:42:48 网站建设 项目流程

简介:这是一个面向Android开发者的蓝牙通信示例工程,完整演示了蓝牙控制的核心流程:获取默认蓝牙适配器、检查并开启或关闭蓝牙、扫描周边设备、建立RFCOMM连接以及消息收发,适合刚接触蓝牙接口或需要快速搭建通信原型的开发者学习。压缩包共包含1544个文件,大小24.82MB,主要文件类型有XML布局与配置、PNG图片、Class编译产物、JSON配置、Java源码等,资源已有702人学习下载。示例中重点讲解了蓝牙适配器的典型用法,包括调用开始扫描方法搜索附近设备,使用广播接收器监听设备发现与扫描结束事件,通过串口服务UUID创建蓝牙套接字连接,并利用输入输出流完成数据发送和接收。同时展示了项目清单文件中的蓝牙权限配置、连接异常处理、缓冲区与超时机制,以及设备列表和连接状态的界面交互设计。这份代码可作为后续扩展文件传输、多设备连接等功能的基础模板,帮助开发者快速理解并改造自己的蓝牙应用。 先说我看到的现象:网上搜“android 蓝牙通讯demo”,翻出来十有八九是老项目,targetSdkVersion 还是23以前的用法。新手照着抄,编译能过,一运行要么闪退,要么扫描列表一片空白,好不容易连上又秒断。我自己最早带人做这类需求时也栽过跟头,后来才明白,Android蓝牙通讯这一块的坑,90%不是逻辑问题,而是系统权限模型和API版本差异问题。这篇文章就把我实测下来能跑通的经典蓝牙SPP通讯Demo完整拆一遍,把权限、扫描、配对、连接、收发数据这几个环节里的关键细节和翻车点一次说清。适合刚接触Android蓝牙开发的初学者,也适合正在接单片机、串口模块但被系统版本折腾得头疼的开发者。

1. 查清你做的到底是哪种“蓝牙通讯”

很多新手拿到的第一个蓝牙Demo跑不通,不是因为代码写错,而是根本没用对蓝牙协议栈。Android里“蓝牙通讯”至少分成三个完全不同的方向,搞混了后面全白做。

1.1 经典蓝牙SPP和BLE GATT,到底该做哪种

如果你要做的场景是“手机和一块蓝牙串口模块(比如HC-05、HC-06)互相发字符串”,那走的是经典蓝牙的SPP协议,底层数据通道是RFCOMM Socket,用起来最像TCP,先配对再连接,连通之后就是一个稳定的双向流。

如果场景是“手机读取一个低功耗传感器的数据”,比如手环、温湿度计、体重秤,那走的是BLE(低功耗蓝牙)的GATT协议。BLE的数据通道完全不同,需要先扫描,再连接,然后去发现Service和Characteristic,读写都在Characteristic上操作,不建立Socket连接。

这两者的代码模型、权限要求、调试方式差别非常大。我的建议是:拿到需求先问清楚对方设备是什么。如果你连的是HC-05、HC-06、杰理这类串口透传模块,或者对方明确说用SPP、RFCOMM,那就是经典蓝牙;如果对方说低功耗、BLE、GATT,那别用Socket那套,老老实实去写GATT回调。

1.2 别把A2DP、HFP这种音频通道当成数据通道

热搜词里出现了“蓝牙a2dp切sco模式”“蓝牙音频接收器模块”,说明有人把音频类的蓝牙Profile和数据通讯混在一起了。A2DP是传音乐的,HFP是通话音的,它们跟SPP是不同层的Profile,就算连上了蓝牙耳机,你也拿不到通用数据流Socket。App想通过A2DP通道收自定义数据,系统层就不开放,不用在这上面浪费时间。

所以在Demo里要认清:SPP只为双向数据流服务,手机和蓝牙模块都能作为服务端或客户端。日常我们用手机做客户端,蓝牙模块做服务端,这样最简单。如果手头只有两台手机,也可以让一台开服务端等另一台来连,效果一样。

1.3 一个能跑起来的开发环境要求

环境方面没有太多玄学。建议直接用Android Studio当前稳定版,AGP版本高一点没关系,SDK编译版本至少设到34。蓝牙相关的API变化从Android 6到Android 14都有,核心代码逻辑没变,但权限弹窗和校验方式一直在改,所以尽量用新环境去适配,别用五年前的工程硬跑。

2. 工程配置里最容易被卡住的三个Permission细节

蓝牙Demo跑不通,排第一的原因就是权限。这不是一句“清单里加上蓝牙权限”就完事了的,Android 6、Android 12、Android 13/14分别改过三轮权限模型,少任何一个都可能搜不到设备或直接闪退。

2.1 清单文件里的权限声明,随着系统版本一直在变

下面这份AndroidManifest.xml是我实测可用的权限集合,里面既有老系统需要的权限,也有新系统需要的新权限:

<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

前两个是老权限,从Android 12开始失效,但如果你不写,低版本手机真机上可能出问题。BLUETOOTH_SCAN和BLUETOOTH_CONNECT是Android 12引入的,前者管扫描,后者管连接和已配对列表。ACCESS_FINE_LOCATION在经典蓝牙扫描时不是必须,但很多国产ROM扫描结果依赖定位开关,为了兼容性和少被问“为什么搜不到设备”,建议保留。

2.2 动态权限申请:从定位权限到蓝牙专属权限

权限光写在清单里没用,运行时要动态请求。不同版本系统要请求的权限不一样,需要在代码里做分支判断:

private void checkAndRequestPermissions() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { requestPermissions(new String[]{ Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT }, REQUEST_CODE); } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { requestPermissions(new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }, REQUEST_CODE); } }

这里有个细节:Android 12以下的手机,BLUETOOTH权限是普通权限,但扫描设备时依然涉及位置信息,所以请求ACCESS_FINE_LOCATION。千万不要只请求蓝牙权限忘了定位权限,否则回调里没有任何报错,就是搜不到设备,最坑的一种情况。

2.3 Android 14上更容易被忽视的“运行时检查”

到了Android 14,即使你写了BLUETOOTH_CONNECT并弹窗请求,调用连接类API时仍可能抛SecurityException。我在实际开发中踩过这个坑,排查了很久才发现是部分国产ROM对蓝牙权限的“精确开关”做了额外校验。

稳妥做法是:每次要调用蓝牙API前,先判断一下权限状态,没有权限就提示用户去授权,而不是直接try-catch。另外,访问已配对设备列表getBondedDevices()在Android 12以上也必须持有BLUETOOTH_CONNECT权限,否则会直接抛异常。这个点非常隐蔽,很多教程里没提,但几乎人人会踩。

3. 把搜索、配对、连接串成一条完整链路

权限处理完,接下来就是跑通一条完整的“发现-配对-连接”链路。这一段是蓝牙Demo的核心骨架,我按实际调用顺序来讲,每一步都说明为什么这么写。

3.1 拿到BluetoothAdapter,检查蓝牙状态

入口就是BluetoothAdapter。必须先判断设备支不支持蓝牙,再判断蓝牙开没开。打开蓝牙有两种方式:一是跳系统页面让用户手动开,二是通过startActivityForResult弹系统授权框。

BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); if (adapter == null) { // 当前设备没有蓝牙模块 return; } if (!adapter.isEnabled()) { Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT); }

3.2 扫描周边设备和读取已配对设备

扫描用startDiscovery(),扫描结果通过BroadcastReceiver接收。在扫描前一定要先cancelDiscovery(),否则上一次扫描未结束,新的扫描可能不会触发,这个很容易被忽略。

public void startScan() { if (adapter.isDiscovering()) { adapter.cancelDiscovery(); } adapter.startDiscovery(); }

接收ACTION_FOUND广播:

IntentFilter filter = new IntentFilter(); filter.addAction(BluetoothDevice.ACTION_FOUND); filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED); registerReceiver(receiver, filter); private final BroadcastReceiver receiver = 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); if (device != null && device.getName() != null) { // 展示到列表,记录name和address } } } };

建议同时把adapter.getBondedDevices()里的已配对设备也加载进列表,因为很多蓝牙模块之前已经配对过,重新扫描反而看不到,直接从已配对列表拿更稳。

3.3 配对逻辑与状态监听

设备如果还没配对,调用device.createBond()触发系统配对弹窗。配对是异步的过程,状态变化会通过ACTION_BOND_STATE_CHANGED广播发回来。这个广播里要重点看BOND_BONDED这个状态,表示配对成功,可以继续走连接流程。

“扫码配对蓝牙”这个需求在系统原生API层面是不开放的,App不能直接弹自定义扫码配对页,能做的只有调用系统配对弹窗,或者引导用户去系统蓝牙设置里手动配对。所以做Demo时别在配对交互上花太多精力,直接让系统弹窗就好。

3.4 建立Rfcomm Socket连接

配对完成不等于连接完成。真正的数据连接要创建BluetoothSocket。经典蓝牙用createRfcommSocketToServiceRecord,传入一个UUID作为服务标识。这里有个关键点:服务端和客户端必须用同一个UUID才能连上。

private static final UUID SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"); private void connectDevice(BluetoothDevice device) { new Thread(() -> { try { if (socket != null) { socket.close(); } socket = device.createRfcommSocketToServiceRecord(SPP_UUID); socket.connect(); if (socket.isConnected()) { runOnUiThread(() -> statusText.setText("连接成功")); startReadLoop(); } } catch (IOException e) { runOnUiThread(() -> statusText.setText("连接失败:" + e.getMessage())); } }).start(); }

注意socket.connect()是阻塞操作,Android不允许在UI线程直接调,如果强行在UI线程执行,轻则ANR重则直接崩。另外,在连接之前最好先adapter.cancelDiscovery(),因为扫描和连接同时进行会严重降低连接成功率,这是官方文档明确建议过的,实际测试确实如此。

4. 这条数据通道真正干活的部分:收发与粘包处理

Socket一旦连上,剩下的就是Java里普通的InputStream和OutputStream操作。但越简单的环节越容易写出“没毛病但就是不好用”的代码,我重点讲收发两个方向的实际处理思路。

4.1 发送端:OutputStream注意事项

发送就是拿socket.getOutputStream(),write后必须flush。建议把发送封装成一个独立方法,统一走UTF-8编码。

private void sendMessage(String message) { if (socket == null || !socket.isConnected()) { // 提示未连接 return; } try { OutputStream os = socket.getOutputStream(); os.write(message.getBytes("UTF-8")); os.flush(); } catch (IOException e) { e.printStackTrace(); } }

这里有个容易忽略的点:不要每次发送都调用一次socket.getOutputStream(),拿一次存成成员变量就行。频繁获取流对象在某些机型上会引发流状态错乱。另外,如果对端是单片机串口模块,它可能只认固定协议,比如以\r\n结尾或者固定帧头,发送前要看对端说明文档,别自己随便定格式。

4.2 接收端:InputStream阻塞读与粘包处理

接收端必须在一个独立线程里循环读InputStream,因为read()是阻塞的,放UI线程必死。下面这个是我常用的接收循环:

private void startReadLoop() { new Thread(() -> { try { InputStream is = socket.getInputStream(); byte[] buffer = new byte[1024]; int bytes; while (socket.isConnected() && (bytes = is.read(buffer)) != -1) { String data = new String(buffer, 0, bytes, "UTF-8"); runOnUiThread(() -> Log.d("BluetoothDemo", "收到: " + data)); } } catch (IOException e) { e.printStackTrace(); } }).start(); }

说到粘包,这是很多做过TCP通讯的人会比较敏感的问题。蓝牙Socket和TCP一样是流式传输,数据边界不保证和一帧对一帧。也就是说,对端可能一次发了“HELLO”“WORLD”两条消息,你read()出来可能是“HELLOWORLD”,也可能分两次读。处理思路和串口通讯差不多:要么约定固定长度,要么用结束符分帧。最简单的方案是让对端每条消息以\n结尾,接收方按\n切分再逐条处理。这块别看简单,实际项目里很多“数据看起来是乱码”的问题都是粘包引起的。

4.3 可选的心跳与断开重连

如果你要把Demo改成生产可用,建议加一个心跳机制。硬件模块比较常见的情况是:手机休眠、蓝牙栈被系统回收、对端断电,这些都不会立刻触发socket异常,导致App显示“已连接”,实际已经断了。加心跳就是每几秒钟发一个约定好的短指令,超过几次没收到对端的响应就判定超时,关闭socket并更新UI。

断线重连要小心一个坑:连接失败或断开后,旧的socket必须调用close(),否则下次connect()大概率报“Socket closed”。很多二次连接失败的问题都出在这。

5. 实测Demo时的典型翻车现场

这部分都是我自己在真机上正经踩过、并且看着别人反复踩的坑。每一条都给到排查思路,而不是直接丢结论。

5.1 “Socket closed”是怎么发生的

如果你在connect()里看到Socket closed异常,优先去查资源释放。最常见的是你重复点击了连接按钮,第二次点击时上一个socket还没释放,系统直接拒绝。其次是蓝牙模块掉电或超出范围,底层Socket被系统关闭。排查时可以打印socket.isConnected(),但注意isConnected()只表示本地socket状态,不代表远端还在,不要把它当在线状态用。我一开始就吃过这个亏,界面显示已连接,实际上对面早就断电了。

5.2 设备明明开了蓝牙却搜不到

遇到扫描列表为空,按这三步排查:先在系统设置里进蓝牙页面,看系统本身能不能搜到那个设备。如果系统都搜不到,是设备没进入可发现模式,很多蓝牙模块需要先断电再上电才能进入可被发现状态。如果系统能搜到但App搜不到,检查权限,重点是定位权限和定位服务是否开启。部分国产手机即使在Android 12以下,也会在定位关闭时过滤掉扫描结果。

5.3 配对成功但connect超时

这个坑比较隐蔽。表现是系统已配对成功,但App里调用socket.connect()一直超时。原因是配对完成和SPP服务可用之间有时间差,尤其是HC-05这类模块,配对完成后还需要几百毫秒才真正就绪。解决办法是配对成功的广播回调里不要立刻connect,稍微延迟一点,比如300到500毫秒,实测会明显提高成功率。

另外一个常见原因是对端模块没有开启服务端监听。如果你是用两台手机做测试,必须在一台手机上先启动BluetoothServerSocket服务端,另一台才能连进去。服务端代码很简单:

BluetoothServerSocket serverSocket = adapter.listenUsingRfcommWithServiceRecord("DemoServer", SPP_UUID); BluetoothSocket clientSocket = serverSocket.accept(); serverSocket.close();

accept()是阻塞的,也要放子线程。

5.4 断开之后再连接就失败

这是蓝牙Demo最常见到的“二进宫”问题。我先说结论:连接前必须做三步清理,第一取消扫描,第二关闭旧的socket,第三给对端留出重新进入待连接状态的时间。尤其是断开蓝牙模块后,模块可能需要重新上电才能恢复服务端监听,此时App反复重连只会一直超时。

我再额外提醒一个真实场景:当你用的是CSR8510这类USB蓝牙适配器配合Windows测试,或者用杰理模块接单片机时,手机端逻辑不变,但硬件侧的供电和复位一定要稳。蓝牙模块在上电瞬间以及状态切换瞬间很容易丢配置,我在调试时就遇到过好几次模块死锁,怎么连都失败,最后拔电重插才恢复。这也是为什么有人会在热搜里搜“蓝牙删除不了”“蓝牙接收器代码10”,有时候手机与硬件反复配对失败,操作系统会缓存错误配对信息,需要到系统蓝牙设置里“忽略此设备”再重新配对,这个操作在写Demo时也值得写进提示里。

写在最后

回到开头那个问题:Android蓝牙通讯Demo为什么大多跑不通?本质原因是很多教程用固定代码去适配不断变化的系统,而Android的蓝牙权限从6.0到12.0再到14.0一直在调整。我这里分享一个我个人习惯的做法:写Demo前先确认三件事,第一对端设备是什么协议,第二当前手机的系统版本和权限要求,第三测试时尽量用一台自己熟悉的真机而不是模拟器。模拟器蓝牙就是个摆设,省不了这一步。

最后分享一个小技巧:平时调试蓝牙通讯时,我会在ListView里把设备的MAC地址也显示出来,很多模块的默认名称都是HC-05这种,多个设备同名,只靠名称根本分不清,看到MAC地址能少很多折腾。另外,连接成功后先在文本框里发送“AT”测试一下,很多串口模块回AT就表示通道通了,这个习惯帮我区分过好多次“是蓝牙问题还是硬件协议问题”。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询