Android经典蓝牙SPP串口通信源码包:从扫描到收发的完整实现

1. 这个源码包到底解决了什么问题

搞嵌入式、做硬件联调的人大概都遇到过这种场景:板子焊好了,固件烧进去了,传感器数据在串口助手上跑得挺欢,可一旦要把这套东西挪到手机上看,麻烦就来了。要么临时找个通用蓝牙调试App,结果发现只支持BLE不支持经典蓝牙SPP;要么自己从零写一个Android工程,光配对、Socket连接、输入输出流管理这几块就能耗掉大半天。这个源码包就是冲着这个痛点来的——它把Android端经典蓝牙SPP串口通信的完整链路打包好了,附带已编译的APK和可直接导入的工程源码,拿到手就能装、能改、能二次开发。

先说清楚它是什么。SPP全称Serial Port Profile,是经典蓝牙(Bluetooth Classic)里定义串口仿真的一个协议规范。它做的事情很朴素:在蓝牙链路上模拟出一条RS232串口,让两端设备像接了一根虚拟串口线一样收发字节流。和BLE那种基于GATT、讲究低功耗和连接间隔的模式不同,SPP走的是RFCOMM通道,面向的是持续、稳定的数据流传输,延迟表现和吞吐稳定性在工业控制、仪器仪表、车载诊断这类场景里更让人放心。

这个源码包能做什么?核心功能就三块:扫描并配对经典蓝牙设备、建立SPP连接、在界面上收发十六进制或文本数据。听起来简单,但真正做过的人知道,魔鬼全在细节里——权限申请在Android 12前后差异巨大、配对状态广播的时序、Socket连接超时处理、输入流阻塞读取的线程管理、断线重连的边界条件,这些坑一个都绕不过去。源码包的价值不在于它多复杂,而在于它把这些琐碎但关键的环节都跑通了,你拿到的是一个能工作的基线,而不是一堆需要自己填坑的骨架。

适合谁看?三类人最对口。第一类是嵌入式或硬件工程师,手头有带蓝牙串口的模块或设备,需要一个手机端的上位机来发指令、看回传;第二类是Android开发初学者,想找一个真实可跑的小项目来理解蓝牙通信的完整流程,比看文档强得多;第三类是做物联网原型验证的,需要快速搭一个手机端调试工具,不想在通信层反复折腾。如果你只是想找个现成的App用,那装APK就行;如果你想改界面、加协议解析、做数据记录,那工程源码才是重点。

我见过太多人卡在“能连上但收不到数据”或者“第一次能连第二次就崩”这种问题上,本质上是对SPP的连接生命周期和Android蓝牙栈的行为理解不到位。下面我就按实际动手的顺序,把这个源码包里的关键设计、核心细节、实操流程和踩坑经验一层层拆开讲。

2. 整体设计与方案选型拆解

2.1 为什么选经典蓝牙SPP而不是BLE

这是第一个要讲清楚的选择。很多人一上来就问,现在不都推BLE吗,为什么还用经典蓝牙?答案取决于你的数据特征。BLE的GATT模型是为小包、低频、低功耗设计的,每次读写要走属性句柄,连接间隔通常在几十毫秒量级,适合传感器周期上报。但如果你要传的是持续的数据流——比如固件升级包、音频以外的连续采样数据、或者需要双向高频交互的控制指令——SPP的RFCOMM通道就更合适,它本质上是一条面向连接的字节流管道,写进去多少字节对面就收到多少字节,没有MTU分包的烦恼。

从开发角度看,SPP的API也更“老派”但更直接:BluetoothAdapter负责扫描和配对,BluetoothDevice拿到后通过createRfcommSocketToServiceRecord建立Socket,然后就是标准的InputStream/OutputStream读写。这套模型和Java的Socket编程几乎一致,心智负担低。BLE则要处理Service发现、Characteristic订阅、Descriptor配置、通知使能这一整套流程,代码量和出错面都更大。源码包选择SPP,本质上是选择了“简单可靠优先”的路线,对于调试工具这个定位来说是对的。

当然代价也要说清楚:经典蓝牙的功耗高于BLE,配对过程需要用户确认,部分新机型对经典蓝牙的支持在收紧。所以这个工具适合的是“调试场景”——你坐在设备旁边,插着电或者电量充足,追求的是稳定收发而不是续航。定位清晰了,选型就没有争议。

2.2 工程结构的分层思路

打开工程源码,结构上大致分三层。最底层是蓝牙通信层,封装了适配器获取、设备扫描、配对、Socket连接、流读写这些原始操作;中间是业务逻辑层,处理连接状态机、数据收发回调、异常重连;最上面是UI层,负责设备列表展示、连接按钮、收发数据显示和输入框。这种分层不是为了好看,而是为了让通信层可以独立复用——你完全可以把通信层抽出来,塞进自己的项目里,UI换成你需要的样式。

通信层的核心是一个管理类,内部持有BluetoothAdapter引用、当前连接的Socket、读写线程。这里有个设计决策值得说:读写是放在独立线程里的,而不是在主线程或简单的AsyncTask里。原因很直接,InputStream的read()是阻塞调用,没有数据来的时候它会一直挂着,放在主线程直接ANR。源码里用了一个循环读取线程,配合一个volatile的停止标志位来控制退出,这是最朴素但也最不容易出错的写法。有些实现会用线程池或者HandlerThread,但对于单一连接场景,一个专用线程足够了,过度设计反而增加调试难度。

UI层用的是比较传统的Activity加布局文件,没有上Compose或者复杂的架构组件。这个选择对目标读者是友好的——嵌入式工程师不一定熟悉现代Android架构,传统写法更容易看懂和修改。设备列表用ListView或RecyclerView都行,源码里用的是哪种不影响核心逻辑,关键是点击设备后的连接流程要清晰。

2.3 权限与兼容性的整体策略

Android的蓝牙权限在版本演进中变过好几次,这是新手最容易翻车的地方。粗略梳理一下:Android 6到11,扫描需要定位权限(因为蓝牙扫描理论上能推断位置),连接需要BLUETOOTH和BLUETOOTH_ADMIN;Android 12(API 31)开始引入了BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE这组新权限,并且扫描不再强制要求定位权限(如果声明了neverForLocation)。源码包要能在多数设备上跑,就必须做版本分支处理。

工程里的做法是在Manifest里同时声明新旧权限,运行时根据Build.VERSION.SDK_INT判断申请哪一组。这个策略虽然让Manifest看起来有点冗余,但兼容性最好。需要特别注意的是,Android 12以上的BLUETOOTH_CONNECT是运行时权限,不申请的话连设备名称都读不到,更别说连接了。很多人在高版本机型上遇到“扫描到了设备但getName()返回null”,根因就在这里。

提示:如果你的目标设备集中在Android 12以上,可以只保留新权限组并声明android:usesPermissionFlags="neverForLocation",这样扫描时不需要再弹定位权限,用户体验更干净。但如果要兼容老设备,两套都得留着。

3. 核心细节解析与实操要点

3.1 设备扫描与配对的状态管理

扫描这件事看起来就是调一个startDiscovery(),但实际行为比想象中复杂。startDiscovery()是异步的,结果通过广播BluetoothDevice.ACTION_FOUND回传,每次发现一个设备发一条。扫描默认持续约12秒,期间如果调用cancelDiscovery()会提前结束。这里第一个坑是:扫描期间不能建立连接,必须先cancelDiscovery()再connect,否则连接大概率失败。源码里在点击设备连接前显式调用了cancelDiscovery(),这个细节很多人会漏。

配对是另一个容易混淆的概念。扫描到的设备分“已配对”和“未配对”两种,已配对的设备可以直接连接,未配对的设备在连接时会触发系统配对流程(弹出配对码确认框)。SPP连接的前提是设备已配对,所以稳妥的做法是先检查bond状态,如果是BOND_NONE就先createBond(),等收到ACTION_BOND_STATE_CHANGED广播确认BONDED后再发起连接。源码里对这条链路做了处理,但要注意配对广播的时序——有些设备配对成功后广播会延迟到达,如果立即连接可能失败,加一个短延时或者重试机制更稳。

设备列表的展示也有讲究。扫描结果里会混入大量无关设备,源码里做了简单过滤,只显示有名称的设备(getName()非空),并且把已配对设备排在前面。这个处理很实用,因为调试现场往往设备很多,快速找到目标设备能省不少时间。

3.2 RFCOMM Socket连接的参数与超时

建立连接的核心调用是device.createRfcommSocketToServiceRecord(UUID)。这里的UUID是SPP的标准服务UUID:00001101-0000-1000-8000-00805F9B34FB。这个值不能随便改,除非你的设备端用的是自定义UUID(有些模块确实会改)。源码里把它定义成常量,方便替换。

连接本身是阻塞的,connect()调用会卡住当前线程直到成功或抛异常。所以这个调用必须放在子线程里。源码里用了一个专门的连接线程,并且在connect()前后做了状态标记,防止重复点击导致多个连接线程并发。超时方面,系统层面的connect没有直接超时参数,实际表现是如果对端不在范围内或未响应,可能卡十几秒才抛IOException。有些实现会用带超时的Socket或者Future来包装,但对于调试工具,接受系统默认行为也可以,只要UI上有“连接中”的状态提示,用户不会以为卡死。

连接成功后拿到InputStream和OutputStream,就可以读写了。这里有个关键点:读线程要在连接成功后立即启动,并且要处理read()返回-1的情况(表示对端关闭了连接)。源码里的读线程逻辑是循环read到缓冲区,然后把字节数组通过回调抛给UI层更新显示。写操作相对简单,直接outputStream.write(bytes)加flush()即可,但要注意写操作也可能抛IOException(比如连接已断),需要捕获并触发断开处理。

3.3 数据收发的线程安全与UI更新

多线程环境下,读写线程和UI线程之间的数据传递必须走主线程更新。源码里用的是Handler或者runOnUiThread,把读到的数据post到主线程再刷新TextView或列表。这个环节的坑在于数据量大时频繁post会导致UI卡顿,所以源码里对读取做了缓冲——一次read可能拿到多个字节,攒成一批再更新,而不是每读一个字节刷一次。

另一个细节是十六进制和文本两种显示模式的切换。调试串口时经常需要看原始字节,所以十六进制显示是刚需。源码里对收到的字节数组做了格式化,每字节转两位十六进制,中间加空格。发送端也支持十六进制输入,需要把用户输入的“AA BB 01”这种字符串解析成字节数组,解析时要处理空格、大小写、非法字符。这块逻辑不复杂但容易写错,源码里的解析函数做了基本的容错,遇到非法字符会提示而不是崩溃。

注意:读写流对象不是线程安全的,如果UI层允许用户在连接状态下连续快速点击发送,可能出现多个线程同时写同一个OutputStream的情况。稳妥做法是给写操作加一个同步锁,或者用单线程的发送队列。源码里对发送做了简单的同步处理,二次开发时如果加了自动发送功能,这一点要特别注意。

4. 实操过程与核心环节实现

4.1 环境准备与工程导入

拿到源码包后,第一步是把工程导入开发环境。工程是基于Gradle构建的标准Android项目,用Android Studio打开根目录即可。导入后先让它同步依赖,如果网络环境导致依赖下载慢,可以检查一下gradle配置里的仓库地址,换成可访问的镜像源。同步完成后,连接一台真机(蓝牙调试必须用真机,模拟器没有蓝牙硬件),开启开发者模式和USB调试,直接点运行就能把调试版装上去。

如果只想快速验证功能,包里附带的已编译APK更省事。传到手机上安装时注意,如果之前装过同包名的其他版本,先卸载再装,避免签名冲突。安装后首次打开会申请权限,按提示允许即可。这里提醒一句:部分定制系统对后台权限管得严,如果扫描不到设备,去系统设置里确认一下应用的位置权限或附近设备权限是否真的给了。

4.2 从扫描到连接的操作链路

打开App后的操作顺序是这样的:先确认手机蓝牙已开启(App里一般会检测并提示),然后点扫描按钮,等待设备列表出现。找到目标设备后点击,如果是未配对状态,系统会弹配对框,输入配对码(常见的是1234或0000,具体看设备端)。配对成功后再次点击设备发起连接,连接成功后界面会显示已连接状态,收发区域就可以用了。

这个链路里有几个操作要点值得强调。扫描时尽量让手机靠近目标设备,经典蓝牙的发现过程对距离敏感,太远可能扫不到或者信号弱导致连接不稳。配对码如果输错,设备会进入短时间的锁定状态,等一会儿再试。连接失败时不要连续快速重试,间隔两三秒再试,因为底层RFCOMM通道释放需要时间,立刻重连可能拿到一个“假成功”的Socket然后马上断。

4.3 收发数据的实际操作与验证

连接建立后,验证通信是否正常的最简单办法是发一条设备端能识别的指令,看有没有回传。比如很多模块支持AT指令,发一个“AT”加回车,正常会回“OK”。在App的发送框里输入对应内容,文本模式下直接打字,十六进制模式下输入字节序列。发送后观察接收区,如果有回显说明链路通了。

这里有个实操技巧:初次调试时先用文本模式发可见字符,比十六进制更直观,确认链路通了再切十六进制做协议级调试。另外,接收区的数据显示建议加上时间戳(源码里如果没加,可以自己补),这样分析设备响应延迟时有依据。如果发现收到的数据是乱码,先检查波特率——虽然蓝牙SPP本身不涉及波特率(它是字节流透传),但设备端的串口参数必须和它自己的固件匹配,手机端不需要设置波特率,这一点常被误解。

4.4 关键代码环节的实现说明

通信管理类的初始化大致是这样的逻辑:获取BluetoothAdapter.getInstance(),判空处理(有些设备没有蓝牙硬件会返回null)。扫描时注册一个BroadcastReceiver监听ACTION_FOUND和ACTION_DISCOVERY_FINISHED。连接时在子线程里执行cancelDiscovery、createRfcommSocketToServiceRecord、connect这一串调用,成功后启动读线程。

读线程的核心循环可以这样理解:定义一个byte数组作为缓冲区,while(running)里调用inputStream.read(buffer),返回值大于0就把buffer的前n个字节拷贝出来回调出去,返回-1就跳出循环并触发断开回调。这个结构简单但要注意异常处理——read抛IOException时同样要跳出循环并清理资源。写操作封装成一个方法,内部对outputStream加锁后write再flush,异常时同样触发断开。

资源释放是容易被忽视的环节。断开连接时要按顺序做:停止读线程(置running为false并等待其退出)、关闭inputStream和outputStream、关闭socket、把引用置空。顺序反了可能导致读线程在已关闭的流上抛异常。源码里对这块做了封装,二次开发时如果自己加功能,记得复用这个释放逻辑,别另起炉灶。

5. 常见问题与排查技巧实录

5.1 典型问题速查表

现象可能原因排查方向
扫描不到任何设备权限未授予或蓝牙未开检查运行时权限、系统蓝牙开关
扫描到设备但名称为空Android 12+未授予CONNECT权限补申请BLUETOOTH_CONNECT
点击连接无反应扫描未取消或未在子线程连接确认connect前调了cancelDiscovery
连接成功但收不到数据读线程未启动或设备端未发数据检查读线程状态、用设备端主动上报验证
数据乱码显示模式或设备端协议不匹配切换十六进制模式对照原始字节
连接一段时间后自动断开对端关闭或信号弱检查read返回值、拉近距离
第二次连接必失败上次Socket资源未释放检查断开时的关闭顺序
高版本系统崩溃权限组不匹配按SDK版本分支申请权限

5.2 几个踩过的坑和独家经验

第一个坑是配对广播的时序问题。有次调试一个模块,配对成功后立刻连接总是失败,加日志发现连接时bond状态还是BOND_BONDING。后来改成监听ACTION_BOND_STATE_CHANGED,等到BONDED再连接就稳了。这个经验说明,不要假设createBond()返回true就代表配对完成,一定要等广播确认。

第二个坑是读线程的退出。早期实现里断开连接时只关了socket,读线程还挂在read()上,导致线程泄漏,反复连接几次后App变卡。正确做法是先置停止标志,再关闭流和socket,让阻塞的read()抛异常退出,最后join一下线程确保它真的结束了。这个顺序不能乱。

第三个经验是关于十六进制解析的容错。用户输入“AA BB”和“aabb”和“AA,BB”都应该能解析,源码里的解析函数做了空格和逗号的分隔处理,但遇到“GG”这种非法字符要给出提示而不是静默丢弃。调试工具的可信度就体现在这些细节上,静默出错比报错更坑人。

提示:调试现场如果遇到莫名其妙的连接问题,先做两件事——重启手机蓝牙开关,以及卸载重装App清掉可能残留的状态。这两招能解决大半玄学问题。

5.3 二次开发的扩展方向

这个源码包作为基线,扩展空间不小。常见的加法包括:加数据日志保存到文件(方便事后分析)、加定时发送功能(周期发心跳或查询指令)、加协议解析层(把原始字节按自定义协议拆成字段显示)、加多设备管理(保存常用设备快速连接)。每加一个功能,都要回到前面说的线程安全和资源释放原则上来检查,别让新功能破坏了原有的稳定性。

我个人在实际使用中的体会是,这类调试工具的价值不在于功能多,而在于“关键时刻不掉链子”。现场调试时间宝贵,工具崩一次可能就要重新走一遍流程。所以二次开发时,宁可功能少一点,也要保证连接和收发的核心链路足够健壮,异常处理足够完整。把错误暴露出来、把状态显示清楚,比藏一堆花哨功能实用得多。