简介:一套基于 Android Studio 的 BLE 蓝牙串口助手完整工程源码,面向移动开发者、物联网爱好者以及需要调试智能硬件的人群。工程实现了蓝牙 4.0 低功耗通信的完整链路,涵盖设备扫描、GATT 连接、服务发现、特征值读写与通知回调,并在 MainActivity、BluetoothLeService、GattCallback 等类中展示了清晰的分层结构,便于直接运行或移植到实际项目中。压缩包共 1946 个文件,约 27.69MB,包含 719 个 XML 界面与配置、618 个 PNG 图标资源、32 个 JAR 依赖库、13 个 Java 源文件及 APK 安装包与 Gradle 构建脚本,统观类型即可判断这是一份可编译调试的完整工程。目前已有 1423 人学习下载。通过研读这套源码,既能掌握 Android 端 BLE 串口通信的权限适配、连接稳定性与功耗控制要点,也能获得一套可直接改造的通信框架,特别适合课程设计、毕业设计或产品原型的起步参考。 搞安卓开发这些年,经常要跟嵌入式设备打交道。尤其是做智能硬件、机器人、环境监测这类项目时,最离不开的就是“手机蓝牙收数据”这个动作。市面上的蓝牙串口助手不少,但真正能用、能改、能嵌到自己工程里的,其实不多。看到 BLE_SPP 安卓手机蓝牙串口助手源码 这个名字,我第一反应是:这正好戳中了嵌入式调试和安卓联调的一大痛点。这篇文章我就顺着这个项目,把 BLE_SPP 到底是什么、源码该怎么拆、接入时有哪些坑,一次性说清楚。
先说结论:BLE_SPP 不是传统意义上的蓝牙 SPP(串行端口协议),而是基于 BLE(低功耗蓝牙)的 GATT 协议,在逻辑上“模拟”出一个串口通道。它解决的典型问题是:手机端做一个上位机,实时接收 MCU、传感器、蓝牙模块发来的数据,同时也能向下发指令。适合做嵌入式开发联调、外设控制、数据采集展示,也适合刚入门蓝牙开发的安卓同学拿来当学习样板。
1. 出发点:为什么蓝牙串口助手要选 BLE_SPP 这条路
1.1 串口调试的线缆困局
做嵌入式开发的人都清楚,调试阶段最烦的不是代码,而是那根串口线。USB 转 TTL 的线一插,电脑端开个串口助手,看起来稳定,但只要设备一挪动、一装箱、一上电,线就掉了。更麻烦的是,很多设备是电池供电的,没有独立电源地线,插上电脑调试时电平参考不一致,稍不留神就是乱码甚至烧串口。
蓝牙串口助手的出现,本质上是把“物理串口线”换成“无线虚拟串口”。手机当成上位机,模块端则通过蓝牙透传,和 MCU 的 UART 对接。这样调试时不用再蹲在设备旁边,尤其是跑小车、调电机、看传感器实时曲线的时候,体验完全不同。
1.2 经典蓝牙 SPP 与 BLE 的取舍
一听“SPP”,做过蓝牙开发的老哥可能会想起经典蓝牙的 BluetoothSocket。但安卓从 4.3 开始全面支持 BLE 之后,新设备上做串口透传,我个人的建议是:别再死磕经典蓝牙了。
经典蓝牙 SPP 的问题是系统层限制多,连接前必须配对,配对弹窗、权限、发现流程都很繁琐。而且现在很多低成本的 BLE 模块只支持低功耗蓝牙,根本没开经典蓝牙那一路射频。反过来,BLE 的优势非常明显:功耗低、连接快、不需要显式配对、安卓手机兼容性好。唯一要适应的,是把“流式写入”的心智模型转换成“特征值读写 + 通知”的模型。
1.3 BLE_SPP 到底解决了什么问题
BLE_SPP 这个项目,实际上就是做了一层“协议转换壳”:硬件上,MCU 的串口数据通过 BLE 模块的 UART 透传出去;软件上,手机 App 封装 GATT 客户端逻辑,把收到的数据拼成串口流,把用户输入的命令拆包发下去。
对使用源码的人来说,它最大的价值不是“能连接”这三个字,而是背后的一整套处理逻辑:扫描过滤、连接状态机、MTU 协商、分包发送、粘包重组、异常重连。如果没有这套逻辑,你写一个 demo 连上蓝牙模块很容易,但要做到“稳定地收发数据”,没有一个月踩坑是写不顺的。
2. 核心源码架构:先看懂 GATT 这盘棋
2.1 BLE 串口的本质:用 GATT 模拟串口
BLE 没有像经典蓝牙那样现成的串口 Profile,所以大家约定俗成,在 GATT 服务里自定义一个“UART 服务”,通常包含三个特征值:
- 写特征(Write):手机发给设备的数据通道。
- 通知特征(Notify):设备发给手机的数据通道,需要先开启通知才能接收。
- 读特征(Read):用来读一些静态状态,部分实现会省略。
一般大家会直接采用北欧半导体(Nordic)定义的 UART Service UUID,或者用厂商自定义的 FFE0/FFE1。无论用什么 UUID,对于安卓客户端来说,核心工作是一样的:拿到服务,找到对应的特征值,然后读写。
我给个配置示例:
object BleUartProfile { // 以 Nordic UART Service 为例 val SERVICE_UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e") val WRITE_UUID = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e") val NOTIFY_UUID = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e") }2.2 服务与特征声明:一套可以拿来即用的 UUID
用这套 UUID 有个好处:市面上的 BLE 透传模块很多出厂默认就广播这个服务。比如常见的 JDY 系列、HM-10 系列的 AT 透传模式,连上后就能用。
但如果你手里模块的服务是自定义的,比如很多国产模块的默认服务 UUID 是FFE0,特征值是FFE1,那你只需要改前面那个BleUartProfile对象里的三个 UUID 即可。源码里把这一层抽出来,后面换任何模块都只动一处,不用满屏去改常量。这是初学者最容易忽略的设计:UUID 必须集中管理。
2.3 数据收发链路:从手机到 MCU 的完整路径
整个收发链路可以理解为三层:
- 物理链路层:BLE 射频传输,每次最多传“MTU-3”字节。
- 逻辑链路层:利用 GATT 的 Write 和 Notify 特征完成双向数据交互。
- 应用协议层:对字节流进行分帧、组包、校验。
打个比方,BLE 就像一条限宽的乡间小路,你有一卡车数据,不能一次全倒过去,得把货分成小箱子一箱箱运。MTU 协商就是决定箱子能装多大,应用协议层就是决定箱子外面贴什么标签、收到之后怎么按顺序装回来。源码里不出意外会在这层写一个数据缓冲池,把onCharacteristicChanged回调里的原始字节攒起来,再按自定义协议吐给上层业务。
3. 从零到上手:安卓端接入实操与关键代码
3.1 工程配置:权限、SDK 版本与依赖
这一步是最容易出幺蛾子的,尤其是安卓 12 之后权限策略大改,很多老代码直接崩。用这份源码时,请对照下面的配置清单核对。
先在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" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />这里特别提醒三点:
- 安卓 6.0 到 11.0,扫描蓝牙必须开定位权限,而且是运行时权限,不是清单里写一下就完事。
- 安卓 12(API 31)及以上,必须用
BLUETOOTH_SCAN和BLUETOOTH_CONNECT,并且要用requestPermissions动态申请。 - 如果你的项目
targetSdk是 30 及以下,不要在旧手机上声明BLUETOOTH_SCAN,否则系统会直接忽略这个权限,还可能引发问题。
build.gradle里的关键配置我建议这样:
android { compileSdk 34 defaultConfig { minSdk 21 targetSdk 34 } }minSdk 21覆盖了绝大多数安卓设备,如果你只需要在近几年的手机上跑,也可以直接minSdk 23,省去动态申请定位权限的一部分兼容性判断。
3.2 核心流程:扫描→连接→发现服务→收发数据
BLE 客户端的标准流程就是四步:扫描、连接、发现服务、收发数据。源码里通常会把这几步封装在一个BluetoothLeClient类里,我直接讲关键调用点。
扫描要设置合理的过滤条件,不然会扫出一堆无关设备。建议按服务 UUID 过滤,也就是只显示广播了SERVICE_UUID的设备:
val scanFilter = ScanFilter.Builder() .setServiceUuid(ParcelUuid(BleUartProfile.SERVICE_UUID)) .build() val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() bluetoothLeScanner.startScan(listOf(scanFilter), settings, scanCallback)扫描到目标设备后立刻停止扫描,然后发起连接。连接要用BluetoothGatt的connectGatt方法,注意传autoConnect=false,这样连接失败会快速回调,方便做超时判断。
连接成功后在onServicesDiscovered回调里拿到 UART 服务,同时开启通知:
val gattService = gatt.getService(BleUartProfile.SERVICE_UUID) val notifyChar = gattService.getCharacteristic(BleUartProfile.NOTIFY_UUID) gatt.setCharacteristicNotification(notifyChar, true) val descriptor = notifyChar.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")) descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor)开通知这一步很多人容易漏掉 descriptor 的写入。光调setCharacteristicNotification(true)只是本地启用了回调,服务端的通知开关是通过写0x0001到 CCCD(客户端特征配置描述符)来打开的。漏掉这步,你就会发现设备明明在发数据,手机端却一点动静都没有。
发送数据时,老的写法是gatt.writeCharacteristic(characteristic),但 API 33 之后推荐用writeCharacteristic(characteristic, value, WRITE_TYPE_DEFAULT)。
3.3 大包传输:MTU 协商与分帧组包
发送数据最常见的坑是“超过单包长度直接丢”。BLE 在未协商 MTU 时,默认的有效数据长度是 20 字节(ATT 层默认 MTU 23,减 3 字节 ATT 头)。如果一次写入超过 20 字节,部分模块会报错,部分模块直接丢弃。
协商 MTU 的办法是gatt.requestMtu(247),然后在onMtuChanged回调里拿到实际协商后的 MTU。注意,这个值不是你说 247 就一定是 247,要看手机蓝牙协议栈和从机模块的支持上限,实际值以回调里的mtu参数为准。
拿到 MTU 后,单包能传的有效字节数就是mtu - 3。比如协商结果是 247,每次最多写 244 字节数据。
发送大数据时,自己写一个发送队列,按mtu - 3切片,每片之间要有 20ms 左右的间隔,防止手机蓝牙缓冲溢出。很多从机模块的串口波特率不高,发送太快会在模块端把数据挤丢。我一般会维护一个ArrayDeque<ByteArray>,一帧一帧地吐。
3.4 通信协议的防坑设计
BLE 传输本质是数据包,不是流。如果只发一堆裸字节,接收端没法判断一句命令在哪结束。所以我强烈建议源码里一定要有简单的分帧协议。最保险的格式是:
帧头 + 数据长度 + 数据区 + 校验帧头可以自定义,比如取0xAA 0x55,长度字段占 1~2 字节,数据区按长度读取,最后加一个累加和校验。安卓端收到数据后先判断帧头,再按长度收满,再做校验,校验通过才交给上层解析。这能挡住 90% 以上的粘包/拆包/错位问题。
4. 实机验证与调参心得
4.1 不同安卓手机的兼容性表现
源码写完之后,我会拿至少三台不同系统版本的手机实机测,分别是:一台老旧的安卓 8 手机、一台主流安卓 11 手机、一台最新的安卓 14 手机。实测下来有几个规律:
- 老手机扫描慢,有时要扫三到五秒才能发现设备,可以适当延长扫描超时。
- 部分国产 ROM 在系统级别对后台蓝牙扫描做了限制,App 退到后台后回调会被挂起。所以要长连调试,建议把连接过程放在前台 Service 里,并加一个常驻通知。
- 同一台手机,第一次连接设备成功,断开后马上重连可能失败,原因是系统缓存了旧的 GATT 信息。遇到这种情况,先调
gatt.refresh()(反射调用)清一下缓存,再重新连接,成功率会高很多。
4.2 吞吐与耗电平衡
BLE 本身是低功耗协议,但如果代码写得粗糙,手机端多耗的电比其他无线方案还猛。几个调参重点:
- 扫描模式用
SCAN_MODE_LOW_LATENCY仅在扫描阶段用,连接成功后马上停。 - 数据接收频率高时,不要把 UI 刷新和数据解析放在同一个线程。用
Handler或协程切线程,避免回调阻塞。 - 如果只需要定时采样,比如每秒钟读一次传感器,建议在下位机做定时上报,而不是手机端轮询读取,这样手机和模块都省电。
我实测过一个方案:MTU 协商到 247,单帧 244 字节、间隔 20ms 发送,手机端实时显示和存储,连续跑两个小时没有掉包。吞吐大概能到 10KB/s 左右,对绝大多数传感器数据上报场景完全够用。
5. 常见问题速查与排错实录
很多人拿到源码跑不通,问题往往不在源码本身,而在环境。我把实际联调中最常碰到的几类问题整理成一张表,方便你快速定位。
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 扫描不到设备 | 没开定位权限,或扫描过滤条件不对 | 确认权限已授予;先用无过滤扫描看模块是否广播正常 |
| 连接后马上断开 | 模块进入了休眠,或服务 UUID 不匹配 | 查看连接回调的错误码,用 nRF Connect 交叉验证 |
| 能连接但收不到数据 | 没有写 CCCD descriptor | 检查是否调用了writeDescriptor(ENABLE_NOTIFICATION_VALUE) |
| 发送没反应 | 数据超过单包 MTU | 确认 MTU 协商结果,按mtu-3分包发送 |
| 数据乱码 | 波特率不匹配 | 检查串口模块端的波特率;BLE 透传模块发送侧波特率一般要一致 |
| 偶发断连 | 系统蓝牙缓存异常 | 反射调用refresh()清缓存后重连 |
| 安卓 13 以上崩溃 | 缺少BLUETOOTH_CONNECT权限 | 检查运行时权限申请,权限被拒绝要引导到设置页 |
问得比较多的还有一个:为什么用 nRF Connect 能连上,自己的 App 连不上。这大概率是 UUID 写错了或者权限不全。nRF Connect 是个很强大的通用 BLE 调试工具,遇到问题先用它验证模块的广播和服务,再用它和你的 App 做交叉对比,能省掉非常多排查时间。
断线重连这块,源码如果没有内置,我建议至少要做到:在onConnectionStateChange里监听断开状态,断开后用指数退避重连,重连前先扫描一次,再连目标 MAC。重连次数不要无限,五次失败后给用户弹提示,避免后台无限重试把手机电量耗干。
我个人在实际调试中还有个习惯:在 App 里加一个“原始数据日志”的开关。打开后把onCharacteristicChanged收到的每个字节都按 HEX 格式打出来。很多时候看起来是数据不对,其实是设备端根本没发,或者帧结构不对,有原始日志一眼就能定位问题。这个习惯帮我省了无数次来回改固件的折腾。
如果后面时间充裕,我会把这份源码再扩展一版,加上数据库缓存和历史曲线展示,做成通用调试平台。目前这套 BLE_SPP 方案,其实已经能覆盖我绝大部分嵌入式联调场景了。希望这篇文章能帮你少走点弯路。
本文还有配套的精品资源,点击获取