基于ST BLE Drone的Android遥控器App开发:BLE通信与协议解析
2026/8/31 22:34:50 网站建设 项目流程

做无人机遥控器 App 的朋友,应该都绕不开 ST BLE Drone 这套参考设计。它是意法半导体在 STM32 主控加 BlueNRG 系列 BLE 芯片方案上,做出来的一套无人机遥控整体参考实现。飞机端是一块集成了飞控和 BLE 通信模块的板子,手机端则是我们要做的事情——一个 Android 版本的遥控器应用,通过蓝牙低功耗(BLE)连接,完成油门、偏航、俯仰、横滚四路控制指令的下发,同时接收电量、高度、姿态角等遥测数据。本文就以 Android 端的开发为主线,把项目拆开讲透。

这套方案能解决的问题很直接:在没有专用遥控器硬件的情况下,让手机扮演遥控器。而且不只是玩具级别,ST 这套参考设计的价值在于,它把飞控端和手机端的通信协议、数据格式、控制流程都定义好了,你可以基于它快速做出自己的遥控 App,也可以只拿它的协议来学习 BLE 工程实践。适合三类人看:刚接触 BLE 开发的 Android 工程师、做嵌入式想搞懂手机端配合逻辑的开发人员、以及想二次开发无人机 App 的爱好者。

为什么选 BLE 而不是 WiFi 或传统 2.4G 遥控器,下面从架构和协议开始,一层层拆开讲。

1. 项目概述:ST BLE Drone 这套东西到底解决什么问题

1.1 这套参考方案的组成结构

ST BLE Drone 整体上分三块:飞机端的硬件与固件、手机端的遥控 App、以及两者之间的 BLE 空中协议。飞机端通常以 STM32 系列跑飞控逻辑,BlueNRG 系列芯片负责 BLE 协议栈和射频收发;手机端 App 负责把用户操作变成指令,把飞机状态变成可视化数据。

对于做上层应用的开发者来说,重点是理解手机端和飞机端的协作关系。App 不是单纯把摇杆数据"发出去"就完了,它要处理连接生命周期管理、协议封装、异常重连、数据解析、UI 刷新这一整条链路。很多新手做着做着发现代码堆了一堆却总是掉线、卡顿、指令丢失,根子往往不是某个 API 用错了,而是没把这条链路当整体来设计。

1.2 为什么选 BLE 而不是 WiFi 或传统遥控

这个问题在项目立项之初就会被反复问。我的看法是:BLE 在这个场景里是各方面权衡之后的平衡点,而不是性能最强的那一个。

先看传统 2.4G 遥控器,它优点是一对一、低延迟、抗干扰强,但缺点太要命——需要额外硬件接收机和发射机,手机没法直接当遥控用。再看 WiFi,带宽确实大,还能传图传,但 WiFi 的连接建立时间长、功耗高、抗干扰弱,而且手机 WiFi 和蓝牙同时工作时的并发协调在低端机上很容易出问题。BLE 的优势在于:手机系统原生支持、配对流程简单、功耗极低、连接间隔可以协商到 7.5ms 级别,对无人机的控制周期来说完全够用。

实测下来,BLE 在合适的连接参数下,指令端到端延迟能控制在 20 毫秒左右。这个数字对四轴飞行器的姿态控制和悬停来说,是能接受的。这也是 ST 选 BLE 作为这套参考设计通信通道的核心原因。

1.3 项目里最容易被低估的工作量

很多人以为这个项目最难的是一堆 BLE API 调用,其实不是。最容易被低估的是协议设计和异常路径处理。BLE 不是 TCP,它没有可靠传输、没有拥塞控制、没有自动重连,数据帧发出去可能就是丢了,设备连上可能就是会断。所以你在 Android 端要花大量时间处理"连不上怎么办""断了怎么重连""发出去的指令怎么确认飞机收到""遥测数据掉帧怎么保证 UI 不被带偏"。

后面几节我会重点讲这些,而不是只贴代码。

2. 系统架构与 BLE 空中协议设计

2.1 手机和飞机的角色分配

BLE 通信里有两个基本角色:Central(主机)和 Peripheral(从机)。从 GATT 协议层面看,还有 GATT Client 和 GATT Server 的区分。在这套系统里,角色分配是固定的:手机是 Central / GATT Client,飞机端是 Peripheral / GATT Server。

为什么这么定?首先手机电量充足、算力强,天然适合做扫描和连接发起方;飞机端为了减重省电,BLE 模块只做广播、等待连接、被动响应,这是从功耗角度最合理的分工。其次,Android 平台在 Central 模式下的 API 支持和稳定性都要好得多,如果反过来让手机做 Peripheral,Android 的广播配置能力弱,而且绝大多数国产手机对 Peripheral 模式的支持一言难尽。所以架构上不要给自己挖坑。

连接建立后,飞机端作为 GATT Server 暴露一组服务和特征值。Android App 通过discoverServices发现这些服务,读写特征值,订阅通知,完成全部数据交互。理解这个主从关系,后面所有代码逻辑就顺了。

2.2 自定义 GATT 服务与特征值设计

BLE 标准联盟定义的 GATT 服务里没有无人机专用 Profile,所以这类设备基本都是厂商自定义 UUID 服务。ST 这套参考设计也不例外。按我接触过的参考设计,一般至少包含两个关键服务:

  • 遥控指令服务:包含一个可写(Write)特征值,App 把摇杆数据打包成控制帧写进去。
  • 遥测数据服务:包含一个通知(Notify)特征值,飞机端周期性把状态数据推给 App。

UUID 要用 128 位格式,常见的做法是在标准 BLE 基地址0000xxxx-0000-1000-8000-00805f9b34fb基础上做 16 位编号替换。这里有个极其常见的翻车点:Android 端和嵌入式端的 UUID 一旦不一致,服务就发现不了。而且 UUID 大小写在解析时要保持一致,别以为系统会自动帮你归一化。

特征值属性也要仔细设计。有些设备把控制特征值定义为 Write(带响应),有些定义为 Write Without Response(无响应)。这两种模式在下发高频控制指令时差异非常大,后面在指令发送部分细说。

2.3 控制帧与遥测帧的格式约定

空中协议是整个系统里最需要两端严格对齐的部分。设计原则很简单:帧头定位、定长字段、校验防错。以我常用的一个参考控制帧为例:

字段长度说明
帧头1 字节固定 0xAA
帧类型1 字节0x01 遥控控制,0x02 参数设置,0x03 紧急停机
油门 Throttle1 字节0~255
偏航 Yaw1 字节0~255
俯仰 Pitch1 字节0~255
横滚 Roll1 字节0~255
校验1 字节前 6 字节累加和取低 8 位

一共 7 个字节。摇杆中位对应 127 或 128,映射到 UI 就是摇杆在中心位置时的输出。遥测帧可以稍长,典型包含帧头、类型、电池电压、飞行高度、三轴姿态角、剩余电量、校验位,飞机端按固定周期(比如 50ms)推一帧,App 在回调里解析刷新。

这套格式不是标准,但结构是通用套路。你自己定义协议时,按"帧头 + 类型 + 定长数据 + 校验"来做,基本不会出大问题。帧头建议用 0xAA、0x55 这类明显的位模式,方便接收端滑动对齐。

3. Android BLE 开发环境与权限细节

3.1 工程配置与权限声明

开发工具就是 Android Studio 官方稳定版,没有特殊要求。BLE 这块真正麻烦的是权限,而且是一层套一层的麻烦。

Android 6.0 以下只要声明BLUETOOTHBLUETOOTH_ADMIN;6.0 到 11 之间,BLE 扫描还需要ACCESS_FINE_LOCATION,理由是广播包里可能携带位置信息,系统要求调用方有定位权限;到了 Android 12(API 31)及以上,新增了BLUETOOTH_SCANBLUETOOTH_CONNECT两个运行时权限,需要动态申请。

我见过很多工程只在 Manifest 里写了权限,跑在 Android 12 以上的手机上扫描静默失败,就是漏了动态申请这一步。建议在 App 启动时做一个权限引导页,把定位和蓝牙相关运行时权限一次性申请完再进主界面。做兼容的最小支持版本建议定在 Android 6.0,再低没必要。

3.2 理解 Android BLE 的硬性约束

Android 的 BLE API 回调是串行的。同一个 BluetoothGatt 对象上,操作不能并行执行。说直白点,你不能在onCharacteristicWrite回调还没返回时,又发起下一个writeCharacteristic。很多遥控 App 出现指令丢失、卡死,就是把 BLE 当成普通网络请求,疯狂并发。

正确做法是维护一个简单的操作串行机制。我用的方案是一个状态标志位:只有在上一个操作回调完成后才允许发起下一个。实测下来,控制指令按 20~40ms 一帧的频率发送是安全的,既保证了操控手感,又不会把协议栈压垮。

另一个硬约束是扫描时长。Android 系统对 BLE 扫描有约 30 秒的强制上限,到点自动停止。要做长时间扫描,要么监听onScanFailed里回调的SCAN_FAILED_ALREADY_STARTEDSCAN_FAILED_APPLICATION_REGISTRATION_FAILED等错误码,要么自己用定时器在 25 秒左右停掉再重启。遥控器 App 一般不需要长时间扫描,用户选设备很快,但如果你做了自动重连的逻辑,这块就要处理。

4. 核心功能实现:从扫描到完整控制链路

4.1 广播扫描与设备过滤

扫描的第一步是拿到BluetoothLeScanner,然后用 ScanFilter 按服务 UUID 过滤,这样只有广播了我们关心的服务 UUID 的飞机才会出现在列表里。

val filters = listOf( ScanFilter.Builder() .setServiceUuid(ParcelUuid(SERVICE_UUID)) .build() ) val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() bluetoothLeScanner.startScan(filters, settings, scanCallback)

注意setScanMode用的SCAN_MODE_LOW_LATENCY。遥控器场景追求的是快速发现、快速连接,功耗不是首要考虑。回调里拿到ScanResult后,把设备地址、信号强度 RSSI 展示在列表里,RSSI 可以顺带当信号强度指示。

一个实测经验:如果扫描列表里始终看不到飞机,先别怀疑代码。先用系统蓝牙设置看看能不能搜到其他普通 BLE 设备。如果所有设备都搜不到,大概率是定位权限没开、或者手机厂商把扫描做了限制。我之前排查过一个用户反馈"搜不到飞机"的问题,最后发现是他手机开了省电模式,系统把后台扫描直接掐了。

4.2 连接与服务发现

扫描到设备后,调用device.connectGatt(context, false, gattCallback)。第二个参数 autoConnect 我建议传 false。遥控器场景下用户是主动点击连接的,不需要系统自动追踪。传 true 在某些手机上会导致连接时间拖到几十秒甚至直接失败。

连接成功后在onConnectionStateChanged回调里调用gatt.discoverServices()。服务发现完成后,才能拿到特征值对象。这一步是强制流程,很多新手跳过它直接拿 UUID 操作,结果拿到 null。之后把控制特征值和遥测特征值分别保存在成员变量里备用。

紧接着要给遥测特征值开通知,标准流程是设置setCharacteristicNotification为 true,然后写描述符:

gatt.setCharacteristicNotification(telemetryCharacteristic, true) val descriptor = telemetryCharacteristic.getDescriptor( UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") ) descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor)

这个 0x2902 描述符是 Client Characteristic Configuration Descriptor,只有写了它,飞机端的 Notify 才会真正推数据。我习惯把这个步骤放在回调链里串行执行,确认描述符写入成功后再把 App 状态切到"可操控"。

4.3 虚拟摇杆的实现思路

遥控器 App 的 UI 核心是两个虚拟摇杆。左侧摇杆控制油门(Throttle)和偏航(Yaw),右侧摇杆控制俯仰(Pitch)和横滚(Roll)。

我用自定义 View 实现。核心逻辑在onTouchEvent里:计算触摸点相对摇杆中心的偏移量,限制在摇杆半径范围内,归一化到 -1.0 到 1.0 区间,再映射到 0~255 的字节范围。注意摇杆要有一个回中动画:手指抬起后平滑回到原点。但油门通道要特殊处理,根据飞控配置有两种模式——回中即锁油门悬停,或者回中保持当前油门输出。我在设置里做了开关,默认用回中悬停模式。

摇杆的响应还有一个容易被忽视的细节:触摸事件的坐标要基于 View 自身的坐标系换算,不要直接用屏幕绝对坐标。我早期实现时踩过坑,横竖屏切换后摇杆坐标偏移,指令映射全乱。用event.xevent.y相对 View 原点的坐标来算,就不会有这个问题。

4.4 指令打包与发送频率控制

指令下发的核心逻辑就是把摇杆状态按协议打包,写进控制特征值。这里要特别注意 Android 版本差异。Android 13(API 33)开始提供三参数写法gatt.writeCharacteristic(characteristic, value, writeType);在旧版本上,要先设置特征值的 writeType 再调用两参数方法。

fun sendControlFrame(throttle: Int, yaw: Int, pitch: Int, roll: Int) { val frame = ByteArray(7) frame[0] = 0xAA.toByte() frame[1] = 0x01 frame[2] = throttle.toByte() frame[3] = yaw.toByte() frame[4] = pitch.toByte() frame[5] = roll.toByte() var checksum = 0 for (i in 0 until 6) checksum += frame[i].toInt() and 0xFF frame[6] = (checksum and 0xFF).toByte() controlCharacteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE gatt?.writeCharacteristic(controlCharacteristic, frame) }

写特征值类型我用的是WRITE_TYPE_NO_RESPONSE。为什么不用带响应的 Write?因为带响应模式要等飞机端回 ACK 才能继续下一次写入,一来一回延迟翻倍,而且一旦飞机端忙于处理飞控任务来不及回 ACK,App 就会卡在等待队列里,指令越积越多。无响应模式只管发,丢包由协议层的校验和重发策略兜底。对高频小数据帧来说,这是更务实的选择。

发送频率我用一个 20ms 的定时器驱动。每 tick 读一次当前摇杆状态,打包发送。这个频率对应 50Hz 的控制周期,对无人机姿态控制来说够用。不要盲目提高到 10ms 一帧,BLE 协议栈处理不过来,反而会因为竞争导致丢帧更严重。

4.5 遥测数据解析与 UI 刷新

遥测接收在onCharacteristicChanged回调里被动触发。飞机端每推一帧,系统调用一次。解析逻辑就是把字节数组按协议切字段,先做帧头校验,再做校验和校验,都通过了才更新 UI。

override fun onCharacteristicChanged( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic ) { val data = characteristic.value ?: return if (data.size < FRAME_LEN) return if (data[0] != 0xAA.toByte()) return var checksum = 0 for (i in 0 until data.size - 1) checksum += data[i].toInt() and 0xFF if ((checksum and 0xFF).toByte() != data[data.size - 1]) return val batteryVolt = ((data[3].toInt() and 0xFF) + (data[4].toInt() and 0xFF) * 0.1f) runOnUiThread { viewModel.updateTelemetry(batteryVolt, height, attitude) } }

有两个线程问题要注意。一是onCharacteristicChanged默认在 Binder 线程回调,不能直接操作 UI。我通常用runOnUiThread切主线程,或者在构造 BluetoothGattCallback 时传入主线程 Handler,让整个回调跑在主线程。对遥控器场景,主线程跑回调完全够用,还能省去频繁切换的麻烦。二是解析尽量不要再做耗时操作,比如网络请求、文件写入,这些要放到工作线程。

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

5.1 连接建立后很快就断开

这是 BLE Android 开发最高频的故障,我总结了三个排查方向。第一,服务发现是否真的完成。没完成就急着操作特征值,App 端要么空指针崩溃,要么写入静默失败。排查办法是在日志里打印onServicesDiscovered的调用时机和结果。第二,手机系统和省电策略。部分手机在省电模式下会主动踢掉非白名单的蓝牙连接,这类问题在用户反馈里经常被误判为 App 崩溃。第三,飞机端固件是否设置了连接超时。很多 BLE 外设会在几秒内收不到任何写入就主动断开,需要抓包确认。

我遇到过一个典型案例:App 能连上飞机,但 5 秒后必定断开。排查了权限、回调、UUID 全都没问题,最后用抓包工具发现是飞机端固件设置的 supervision timeout 过长和连接事件冲突导致的异常断链。这类跨端问题,App 侧再怎么调也没用,必须两端一起看日志。

5.2 指令延迟高、手感不跟手

控制命令发出后,飞行动作有明显延迟,优先排查三个参数。第一是连接间隔。有些手机默认协商出的连接间隔是 30ms 甚至更长,在连接成功后主动调requestConnectionPriority(CONNECTION_PRIORITY_HIGH),对应 7.5ms 的连接间隔。实测这个 API 大多数手机都支持,延迟能明显降下来。第二是发送频率。太慢会觉得"肉",太快协议栈会阻塞。我实测 20~30ms 一帧是最稳的区间。第三是写入类型,用带响应的 Write 会让延迟翻倍,改成WRITE_TYPE_NO_RESPONSE

还有一个和延迟相关的坑:不要在onCharacteristicWrite回调里再发下一帧。这个回调在很多手机上触发时机不稳定,用它做链路节奏控制会导致发送间隔忽长忽短。我改成独立定时器驱动发送后,摇杆手感稳定了很多。

5.3 国产手机兼容性的一些坑

国产手机在 BLE 上的系统层魔改是重灾区。最常见的是扫描不到设备或者扫描结果不稳定。这类问题通常不是 App 代码 bug,而是厂商在系统层对扫描做了限制。我的排查经验是引导用户关闭省电模式、关闭"智能蓝牙"这类优化选项,在系统设置里把 App 的定位权限设为"始终允许"。

Android 12 以上还有一个高频问题:BLUETOOTH_SCAN权限如果只写在 Manifest 里而没有动态申请,扫描会静默失败。我的对策是在主界面进入前做一个权限引导页,把所有运行时权限一次申请完。虽然多了一步,但能省掉大量兼容性投诉。

5.4 MTU 与大数据帧的处理

如果你设计的遥测帧很大(比如超过 20 字节有效载荷),就需要处理 MTU 协商。BLE 默认 MTU 是 23 字节,扣掉 3 字节协议头,单帧有效载荷只有 20 字节。超出部分会被系统直接丢弃,不会自动拆分。Android 端要主动调用requestMtu(),飞机端支持的话会协商出更大的 MTU。

但对遥控器场景,我建议优先把帧设计得紧凑,不要依赖 MTU 协商。原因很简单:MTU 协商的兼容性问题很多,部分老设备不支持,而且每次连接都要重新协商,流程变长。我遇到过一次因为遥测帧塞了太多扩展字段导致数据截断、解析错乱的问题,后来通过压缩字段设计解决,就没有再折腾 MTU。控制帧和遥测帧控制在 20 字节以内,是最省心的方案。

5.5 常见问题速查表

现象可能原因排查方向
扫描不到设备定位权限未开、厂商扫描限制、省电模式检查权限、系统设置、用其他 BLE 工具验证
能连上但马上断开服务发现未完成、系统踢连接、飞机端超时看日志、抓包、查飞机端超时设置
指令无响应UUID 不一致、未开 Notify、写入类型错误核对 UUID、检查描述符写入、换 WRITE_TYPE_NO_RESPONSE
遥测收不到未写 0x2902 描述符、字节超过 MTU检查描述符流程、压缩帧长度
Android 12 以上扫描失败权限未动态申请动态申请 BLUETOOTH_SCAN / BLUETOOTH_CONNECT

6. 调试工具与个人实战心得

调试 BLE 应用,光靠 Log 远远不够。我强烈建议准备一台备用手机装 nRF Connect 或者 LightBlue,开发早期先用这种通用工具手动连接飞机,验证广播、服务、特征值的行为是否和协议文档一致。电脑端可以用 Wireshark 配合 USB 蓝牙适配器抓 BLE 包,能看到连接间隔、丢包、重传这些底层信息。注意 BLE 抓包需要硬件适配器支持,普通的蓝牙适配器只能抓 HCI 层,抓不到空中的数据包。

我个人的开发流程是:先用 nRF Connect 手动连一次飞机,看服务列表和特征值属性是否符合预期;再用自己的 App 连一次,对比两边行为差异;最后才动手改代码。这样做能快速把问题隔离在手机侧还是飞机侧,而不是在两个端之间来回猜。

还有一个我非常推荐的土办法:在 App 里做一个"协议调试模式",把每次下发的控制帧和每次收到的遥测帧都按十六进制打印到日志和调试页面。这个功能看着不起眼,但现场排查时比任何高端工具都好用。我之前定位一个偶发的丢帧问题,飞机就摆在桌上,靠这个调试页面逐帧对比,最后发现是飞机端一个数组越界导致的不定时停推。没有逐帧日志,这种问题几乎不可能靠猜找出来。

最后分享一个小技巧:BLE 设备的 MAC 地址在部分手机上会被随机化处理。如果你在 App 里保存了飞机地址做自动重连,第二次连不上,先怀疑是不是地址变了。解决方案是保存设备名而不是地址,或者每次连接时重新扫描匹配。这个坑我踩过一次,写出来省得大家再走一遍弯路。做 BLE 遥控器这类项目,耐心和日志习惯比写代码本身更重要,把每次断连、每次丢帧都当成数据来记录,问题就会清晰很多。

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

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

立即咨询