
简介在物联网与智能硬件快速普及的背景下这份基于Android Studio开发环境的蓝牙低功耗BLE串口助手源码为需要在手机端实现低功耗蓝牙4.0数据透传与设备控制的开发者提供了一套完整可运行的工程参考源码覆盖设备扫描、连接、服务发现、特征值读写与通知回调等核心链路适合正在学习BLE通信机制或希望快速搭建自定义蓝牙调试工具的初中级开发人员。压缩包为RAR格式大小约27.69MB共包含1946个文件主要类型有XML、PNG、JAR、Java、Class等其中XML和JSON负责界面布局与工程配置Java和Class对应核心业务逻辑与编译产物PNG为界面资源JAR为依赖库整体目录层次清晰便于按模块检索。目前已有1423人学习下载。源码内含主界面、蓝牙后台服务以及Gatt回调等关键类并附带了可直接安装体验的APK文件通过阅读其蓝牙权限声明、界面刷新机制、扫描连接策略和性能优化思路读者既能理解BLE串口通信的完整实现方案也能为自研物联网设备调试工具节省大量开发时间。1. 先搞清楚一件事手机当串口调试助手到底图什么很多刚接触蓝牙调试的朋友桌子上的串口线堆成一团USB转TTL模块插来插去偶尔还要为驱动装不上发愁。我早期做嵌入式开发时也是这样直到某天在客户现场调试一块蓝牙透传模块手边没有笔记本电脑只有一部安卓手机我才真正意识到手机其实是最被低估的串口调试终端。它不需要驱动不需要数据线电池自带屏幕够大触摸键盘输入十六进制数据也凑合能用。关键是只要手机本身有蓝牙芯片理论上就能同时具备传统SPP蓝牙2.0时代的串口协议和BLE低功耗蓝牙4.0及以上两种通讯能力。于是我做了一个安卓端的蓝牙串口助手把设备扫描、连接、数据收发、日志记录整合在一个App里源码开源后陆陆续续收到不少开发者的反馈这里把整个项目的设计思路、实现细节和踩坑记录整理出来希望能给准备做类似工具的朋友一些参考。这个项目的核心定位很简单在安卓设备上实现一个既支持经典蓝牙SPP、又支持BLE GATT串口透传的调试工具。它不仅是个给嵌入式工程师用的调试器也可以作为你自定义硬件设备的配套App的基础骨架——比如你要做一个智能小车、一个温湿度采集器、一个蓝牙血压计这套源码只要改改界面和协议就能快速变成你自己的专属App。2. BLE和SPP为什么必须分开处理而不能用一个模块通用这是整个项目里第一个需要想清楚的问题。蓝牙SPP全称Serial Port Profile是蓝牙2.0时代就定下来的经典蓝牙规范它的物理底层用的是BR/EDR基本速率/增强数据率数据通过RFCOMM协议层传输。你可以把它理解成“无线版的串口线”建立连接后对应用层来说几乎可以当作虚拟串口来使用字节流怎么发就怎么收不需要考虑分包、粘包、MTU最大传输单元这些东西非常直接。BLE则完全换了一套逻辑。BLE的通讯模型是GATT通用属性协议它把设备抽象成一个个服务Service服务里包含特征值Characteristic每个特征值有它自己的读写属性。数据交互本质上是“读属性”和“写属性”而不是像SPP那样直接铺一根管道过去。由于BLE每一步操作都要经过ATT层封装数据包有明确的长度上限默认MTU为23字节扣掉3字节开销单次最多传20字节所以传统的连续字节流传输在BLE上必须自己做分包和重组。很多人第一次写这类App时直接搜“安卓蓝牙串口”找到一个库就往上套结果发现连上BLE设备后要么发一条长数据过去只收到前半段要么连接的SPP设备压根搜不到。原因就是协议栈不对。我在项目里做了一个分区处理设备扫描阶段同时注册两个Receiver分别监听经典蓝牙设备Discovery和BLE广播回调。连接阶段根据用户手动勾选的设备类型走BluetoothSocketSPP或者BluetoothGattBLE两套完全独立的链路。数据收发阶段SPP走InputStream/OutputStreamBLE走writeCharacteristic和onCharacteristicChanged回调。这种“物理隔离”的设计看着代码冗余但实际用起来非常舒服。你永远不会遇到SPP的逻辑错误触发在BLE链路上的奇怪问题排查bug时也只需要盯着对应的链路看。从硬件设备侧看现在市面上的透传模块也分得很清楚HC-05、HC-06这类经典蓝牙模块走SPP几乎不需要配置而HM-10、CC2541、nRF52832这些低功耗模块清一色走BLE。如果你的App只支持一种协议正常情况下两个项目里得有一个没法用但两者都支持工具覆盖面就大了很多。3. 项目整体骨架四个模块让“连接、收发、展示、记录”各司其职这个项目的源码目录结构我就是按照功能边界拆的不搞花里胡哨的架构设计简单明了为主。整体划分为四个模块连接层、服务层、UI层、数据管理层。3.1 连接层系统蓝牙能力的最小封装连接层干什么就是对安卓系统蓝牙API做一套薄封装。这个模块不关心你连接之后要发什么数据只负责三件事发现设备、发起连接、监听连接状态。发现设备又分成两块。经典蓝牙用BluetoothAdapter.startDiscovery()回调里读取remoteDevice.getName()和getAddress()BLE则通过BluetoothLeScanner.startScan()拿到ScanResult再从result.getDevice()拿设备。这里要特别提醒一个坑部分安卓手机对同地址的BLE设备和经典设备会显示成一样的MAC如果不做类型区分用户在实际选择时很容易选错设备。我的做法是在扫描列表的每个item上打一个标签标明是“BLE”还是“经典蓝牙”再用不同的颜色区分。源码里我维护了一个DeviceType枚举连接前必须由用户或上层逻辑显式指定设备类型绝对不做自动猜测。原因很简单猜测的代价是连接失败失败后的蓝牙协议栈状态恢复有时相当慢甚至可能需要重启蓝牙才能解决。连接过程经典蓝牙走的是createRfcommSocketToServiceRecord(UUID)这里千万要注意UUID的取值。SPP的默认UUID是“00001101-0000-1000-8000-00805F9B34FB”这是个业内统一的标准值但有些自制设备或者特殊定制的模块会修改服务通道导致连不上。项目里我会提供自定义UUID的输入入口方便遇到非标设备时手动指定。BLE连接则用connectGatt(context, false, callback)其中第二个参数autoConnect我建议默认设为false。为true时系统会不断自动重连在开发调测阶段会导致回调混乱比如你已经主动断开连接了系统又在后台帮你重连上这时候你再去刷新数据就会拿到一些意外的值。后面我会细说这个问题。3.2 服务层把数据做成干净的字节流服务层是这个项目里最能体现“串口助手”本质的地方。它的核心目标是不管底层是SPP还是BLE上层拿到的都是一个统一的字节流接口。我做了一个接口叫IBluetoothSerial提供connect()、disconnect()、send(byte[] data)、setDataCallback()四个方法。SPP和BLE各写一套实现类内部细节互相隔离。对外部调用方来说不管是连HC-05还是HM-10代码路径是一样的这样业务层、UI层完全不需要关心底层协议差异。SPP实现类相对简单建立socket后直接拿到输入输出流开一个子线程循环读取inputStream.read(buffer)读到数据就通过回调抛给上层处理。BLE实现类复杂一些分包逻辑放在send()里接收逻辑依赖onCharacteristicChanged回调用handler把回调线程的数据转到主线程方便UI直接刷新。这里有一个很细节的处理BLE的MTU协商。项目中我默认尝试请求最大MTU 247字节因为很多设备端固件也支持扩展长度这样用户一次发送长数据的时候分包的包数会减少传输效率明显提升。但如果你的设备并不支持长包请求会失败那么框架要自动回退到默认的20字节一次的分包逻辑。注意这个协商失败不要报错记录一条日志就行正常功能不受影响。3.3 UI层这可不是简单放两个按钮的事UI层是一个调试助手最直观的门面也是用户用得最多的地方。项目首页是一个设备列表页上半部分显示已配对设备下半部分放扫描结果。扫描按钮打开后实时刷新设备列表点击某个设备后弹出底部弹窗让你选择“经典蓝牙SPP连接”还是“BLE连接”并输入自定义UUID。这个选择弹窗是我后期加的最初版本是自动识别结果踩了不少坑改成显式选择后问题率直接降到零。连接成功后进入通讯页面这个页面布局基本对标手机版的“串口终端”。上面是数据收发日志展示区支持ASCII和Hex两种显示模式每条日志前面带时间戳和方向箭头。中间是快捷指令按钮区可以预先配置几条常用的指令比如AT指令、开灯指令一键发送。底部是输入框支持纯文本发送和Hex发送两种模式输入框右侧是发送按钮。数据展示区我用的是一个自绘的线性布局而不是RecyclerView因为日志数据量通常不会特别大线性布局可以轻松支持自动滚动。在数据量大的情况下加个“暂停滚动”开关我发现连续收发高频数据时如果没有暂停滚动按钮人眼根本来不及看而且ListView频繁刷新还会掉帧。3.4 数据管理层一次完整的调试过程应该是可回放的这是我认为“助手”类工具和“玩具”类Demo最大的区别。调试过程中最怕什么怕你发送了一条指令设备没按预期响应但你搞不清楚是设备没收到、还是收到后处理出错、又或者是设备有响应但你在屏幕上没注意到。所以我把所有的收发数据、连接事件包括断开、异常、重连、用户手动发送的操作都记录到一个本地SQLite数据库里。每条日志记录包含时间戳毫秒级精度数据方向发送/接收/系统事件数据内容Hex和ASCII双份保存当时连接状态数据管理层的第二个功能是导出。我把记录导出成TXT文件文件路径放在“/Android/data/com.你的包名/files/Logs/”目录下方便用文件管理器直接取出来。我实际工作中很多硬件问题的分析都是先把手机里的实时日志导出来然后在电脑上做逐条比对比对着手机屏幕看轻松得多。此外指令列表也做成了可配置的。用户可以在应用内添加常用指令保存到本地下次打开无需重新输入。有些设备的AT指令或协议指令特别长手动敲一遍很痛苦做一个指令库保存功能实测下来是增加用户粘性的一个很有用的细节。4. 连接与数据链路里的关键实现细节按顺序逐一说清4.1 权限与蓝牙打开的初始化流程安卓6.0以后运行时权限是个基础问题。项目需要在AndroidManifest里声明uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / 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.ACCESS_FINE_LOCATION /其中ACCESS_FINE_LOCATION是很多新手掉坑的地方。安卓系统要求扫描BLE设备必须要有定位权限因为蓝牙广播里可以携带位置信息系统把扫码行为判定为一种“潜在定位行为”。所以代码里要在扫描之前弹出定位权限申请否则在Android 12以下扫描回调会返回空列表。项目里我在MainActivity的入口统一处理权限申请依次请求蓝牙、定位两个权限组。用户拒绝后给出提示再次点击扫描按钮时重新拉起申请。权限全部通过后再调BluetoothAdapter来解决蓝牙打开状态问题if (!bluetoothAdapter.isEnabled()) { Intent enableBtIntent new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT); }一句话总结先把权限通畅了再碰蓝牙功能否则后面每一步都像在走钢丝。4.2 经典SPP的连接与收发经典蓝牙连接的过程代码逻辑上不复杂但有几个关键细节值得反复强调。建立连接的方法我放在子线程里执行绝不放在UI线程这就是阻塞式调用一旦连接超时默认等待12秒这期间UI线程直接卡死稍有不慎就是ANR。正确做法是new Thread(() - { try { BluetoothSocket socket device.createRfcommSocketToServiceRecord(MY_UUID); socket.connect(); // 连接成功后启动读写线程 } catch (IOException e) { // 连接失败关闭socket } }).start();这里需要一个很重要的操作在connect()之前先调用bluetoothAdapter.cancelDiscovery()停止扫描动作。因为安卓系统的蓝牙协议栈有个特殊限制连接过程和扫描过程不能共存如果链路还在扫描状态连接请求会被系统直接拒绝。这个坑特别隐蔽因为报错经常不是“连接失败”而是超时或者返回“Service discovery failed”让人一头雾水。收发数据的实现很简单// 发送 OutputStream out socket.getOutputStream(); out.write(data); out.flush(); // 接收在独立线程循环 InputStream in socket.getInputStream(); byte[] buffer new byte[1024]; int bytes; while ((bytes in.read(buffer)) ! -1) { // 回调上层进行数据解析和展示 }接收线程我必须说明一点读完一个字节就回调在高频数据下会拖垮性能读满固定缓冲再整体回调又可能因为缓冲未满而一直等不到数据。我的方案是用read(byte[])的阻塞返回这个调用在有数据后就会返回不是非得等缓冲区填满才返回。返回值就是实际读取的字节数拿到后按这个长度截断数据再抛给上层性能和数据实时性都能兼顾。4.3 BLE连接的四步曲扫描→连接→发现服务→读写BLE的操作比SPP复杂一些但套路非常固定按顺序执行就好。第一步扫描用LeScanCallback或者ScanCallback。我用的是BluetoothLeScanner在Android 7.0及以上稳定性更好。第二步连接拿到设备后BluetoothGatt gatt device.connectGatt(context, false, gattCallback);autoConnect这个参数前面提过开发调试阶段设置false。设置false后connectGatt返回的瞬间连接还没建立真正的连接结果在onConnectionStateChange里回调出来Override public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { if (newState BluetoothProfile.STATE_CONNECTED) { // 连接成功先请求MTU gatt.requestMtu(247); } else if (newState BluetoothProfile.STATE_DISCONNECTED) { // 连接断开释放资源 } }这里有个顺序容易乱一定是在连接成功之后再去发现服务而不是连接成功之前。发现服务调用gatt.discoverServices()这是个异步过程结果在onServicesDiscovered回调里出来。有些新手的代码把discoverServices和connectGatt连着写导致服务发现返回空列表还会踩一两次才知道顺序很重要。第三步发现服务拿到目标服务的UUID和特征值UUID。标准BLE串口透传服务的UUID一般是0000FFE0-0000-1000-8000-00805F9B34FB特征值UUID是0000FFE1-0000-1000-8000-00805F9B34FB。但你的设备很可能不是这个所以项目界面里我加了一个占位设置让用户可以根据自己的协议文档填写UUID。如果你知道设备的服务UUID也可以在代码里预设连接成功后自动匹配。第四步读写写数据gatt.writeCharacteristic(characteristic)写入前调用characteristic.setValue(byte[])。读数据对支持Notify的特征值调用gatt.setCharacteristicNotification(characteristic, true)同时需要为这个特征值找到描述符CCCDClient Characteristic Configuration Descriptor即00002902-0000-1000-8000-00805F9B34FB并给它写入BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE。这一步是和安卓低版本兼容度的关键不写描述符很多设备不会主动上报数据。写数据和设置Notify是异步操作不可两个连着一个接一个执行需要在各自的回调确认后再进行下一次。如果连续调用部分国产芯片的蓝牙协议栈处理不过来会出现丢指令、数据错乱的问题。务必要做串行化的回调处理。Override public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { super.onCharacteristicWrite(gatt, characteristic, status); if (status BluetoothGatt.GATT_SUCCESS) { // 发送成功可以发送下一条 sendNextPendingData(); } }4.4 主动断开与被断开资源释放的顺序有讲究SPP的断开相对简单socket.close()即可。但是我建议断开前设置一个标志位通知接收线程退出循环。不做这个处理接收线程会阻塞在read()上即使socket已经关闭有些机型上线程也不会立刻返回导致所谓的“僵尸线程”问题你明明断开连接了日志还在不断输出最后一段数据或者异常。BLE的断开更讲究一些。断开前先移除Notify监听gatt.setCharacteristicNotification(characteristic, false);然后关闭Gattgatt.disconnect(); gatt.close();disconnect()和close()之间要留一点时间一些系统上立刻调用close会跳过连接状态回调导致上层UI收到的是“未断开”的假象。我实测下来在onConnectionStateChange收到STATE_DISCONNECTED后再调close是最稳妥的顺序。当然如果只是临时重连不打算释放Gatt对象也可以不调close但这种情况一定要保持引用不能回收。5. 实际调试中最常踩的四个坑以及我的排查链路5.1 扫描列表里看不到设备第一步先排查权限定位权限是否开启安卓10及以上系统还要求位置服务开关本身打开这个和定位权限是不同的东西很多人忽略。第二步看设备类型目标设备如果是经典蓝牙需要调用startDiscovery()如果是BLE需要startScan()。两者扫描机制完全不同很多设备压根不会出现在错误类型的扫描回调里。我项目里用两个扫描开关来独立控制默认同时开启但每个开关的状态在界面里展示出来。第三步确认Android 12及以上版本的“附近设备”权限。安卓12引入BLUETOOTH_SCAN权限后即使低版本权限都开了新系统上扫码也可能被静默拒绝。这个权限需要在AndroidManifest里声明并且要用requestPermissions运行时申请只声明不申请是拿不到的。5.2 连接成功但收不到数据这个是最让人头疼的因为连接状态看着是好的就是不回数据。我通常按下面的顺序排查第一个检查点特征值的Notify属性是否真正启用成功。这一步回调是异步的很多新手写完代码后没有处理onDescriptorWrite的回调结果描述符写入失败了自己还不知道。项目里我在这个回调里打了详细的日志判断status GATT_SUCCESS才认为订阅成功。第二个检查点服务UUID和特征值UUID是否正确。项目里我允许在连接前手动选择服务和特征值一般在扫描到BLE设备后点进去会显示设备支持的所有服务列表选择实际目标服务后再进去显示该服务下所有特征值和属性。看属性太重要了如果特征值没有WRITE属性那你写数据一定失败没有NOTIFY属性那你订阅一定无效。用这种方式去核对文档比自己猜要靠谱得多。第三个检查点检查CCCD描述符是否已经找到并且正确写入。我见过多个品牌的BLE透传模块它们的CCCD地址并不是标准的“00002902”尤其是一些特殊定制的模块。所以我的代码逻辑是先遍历该特征值的描述符列表找到UUID包含2902或者名称包含“CCC”的再写入使能值兜底能力特别强。5.3 数据断断续续或者粘包SPP链路上这种情况出现的核心原因是写入的数据一次性爆量超过了对端处理能力。我的建议是发送端做流控如果短时间内有大量数据待发用线程池加队列的方式保持发送间隔在20到50毫秒之间保证对端设备软硬件都能吃下。你如果是在调试一个透传模块模块端缓冲区一般是128字节左右你一秒钟丢给它几千个字节它肯定处理不过来不是丢包就是拒收。在发送大包数据前先用小包测试一下模块单包处理能力确定之后再设计发送策略。BLE链路上的“粘包”则是完全不同的原因BLE的数据包本来就要分包重组如果你的发送端没有自己在逻辑层做好分帧比如在数据头加长度标记那收端就没办法正确处理。这里特别提一个我经历过的例子一个开发者用App给自己的BLE设备传图片发200个字节收端一片乱码。我一看代码他的发送端压根没做分包直接把200个字节传给writeCharacteristicAPI只取前20字节发送后面全部静默丢掉。他以为是连接问题其实是根本没理解BLE单包上限的概念。5.4 断开后重新连接失败重新连接的补救措施现在要专门说一下。当BLE Gatt的onConnectionStateChange回调返回status不是0GATT_SUCCESS时很多人就直接关闭连接完事但这样做的后果是后续再次连接大概率失败协议栈出了问题。正规处理方式是在收到连接失败的回调后先延迟200毫秒再执行gatt.close()。然后重新获取设备对象全新一次connectGatt()。这个延迟非常关键因为系统协议栈释放连接资源需要时间。SPP则要确保上一次的socket完全close且接收线程已经退出再发起新的连接。注意安卓系统级限制BLE的扫描和连接不能同时进行如果你在做大量扫描的时候发起连接连接请求极可能直接失败。因此在发起连接前先停止扫描是小细节但影响很大。6. 几个值得长期维护的扩展方向源码本身可以稳定跑起来后我陆续做了一些小扩展这些目录都可以复用已有的连接框架。6.1 上位机联动把手机App变成上位机的辅助终端比如你有个PC端的串口调试助手通过有线串口连接一个蓝牙模块手机再连同一个设备这样就多了一个无线监视终端。我在项目中加了一个“透传模式”手机和PC同时连接设备任一端发出的数据另一端都能看到。这个功能需要设备端固件支持“双机连接”或者“广播转发”不是标准功能但调试某些特定的自定义透传协议时很好用两块串口屏之间互相发指令往常得用电脑和设备两头盯着现在手机揣兜里就行。6.2 自动化测试指令序列硬件项目批量出货前都要经过功能测试。传统做法是写个脚本跑USB串口发指令但产量小的时候弄个笔记本连设备也麻烦。我把这个App改造出一个“Batch Send”模式定时循环发送指令序列再把返回值里的关键字段做成断言判断比如判断收到的电量值是否在正常区间、版本号是否匹配等。这样整条产线的人拿手机一扫码、点一下按钮就能快速测完一个模块。开源里我做了基础版本感兴趣的话可以继续扩展。6.3 波形曲线实时绘制调试传感器数据时日志文本不够直观数值跳动要连续看曲线才能发现问题。我在App里加了一个简易绘图视图把通过BLE实时上报的数值解析后直接绘制折线图。核心实现其实不复杂就是维护一个数据队列每来一个数据就往右边推一格显示最近几百个点。做这个功能的初衷是调试一个温湿度传感器模块刚开始只能看日志数字跳后来画成曲线趋势变化一目了然。代码里做成了可插拔的视图组件需要时可以打开。7. 最后聊点关于源码使用和再开发的实际感受如果你打算把这个项目源码直接拿去改造第一个建议是保留我的包名结构修改后按你自己的项目规范分包调整。把逻辑和UI分离的结构保持一致后面自己加功能时能省很多力气。第二个建议是UI模组的修改如果想换配色、换图标、改布局这部分完全可以个性化定制不用担心会影响到底层逻辑。真正和你自己业务相关的是服务层的调用接口比如你要在App里加自己的协议解析只要在onDataReceived回调里处理即可不需要动SPP/BLE的连接代码。第三个建议如果你要做的是产品级App光有这个“调试助手”还不够因为调试助手面向的是工程师用户能看到的应该是更简单的操作界面比如“连接设备→实时显示温度→超出阈值报警”。这种情况下把IBluetoothSerial这套接口抽出来作为库工程UI部分完全重写是我比较推荐的做法。接口就是这样一套就已经足够了不要觉得代码越多越好保持接口清晰业务再好做。我的使用习惯里现在日常调试蓝牙模块时还是会把它拿出来连HM-10发AT指令出差遇到客户设备异常也不需要带一堆串口线了。这个项目不大但确实实打实解决了我作为一个嵌入式开发摸爬滚打多年的痛点。本文还有配套的精品资源点击获取