基于ST BLE Drone的Android遥控器App开发:BLE通信与协议解析

基于ST BLE Drone的Android遥控器App开发:BLE通信与协议解析 做无人机遥控器 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 支持和稳定性都要好得多如果反过来让手机做 PeripheralAndroid 的广播配置能力弱而且绝大多数国产手机对 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 以下只要声明BLUETOOTH和BLUETOOTH_ADMIN6.0 到 11 之间BLE 扫描还需要ACCESS_FINE_LOCATION理由是广播包里可能携带位置信息系统要求调用方有定位权限到了 Android 12API 31及以上新增了BLUETOOTH_SCAN和BLUETOOTH_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_STARTED或SCAN_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.x和event.y相对 View 原点的坐标来算就不会有这个问题。4.4 指令打包与发送频率控制指令下发的核心逻辑就是把摇杆状态按协议打包写进控制特征值。这里要特别注意 Android 版本差异。Android 13API 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 才能继续下一次写入一来一回延迟翻倍而且一旦飞机端忙于处理飞控任务来不及回 ACKApp 就会卡在等待队列里指令越积越多。无响应模式只管发丢包由协议层的校验和重发策略兜底。对高频小数据帧来说这是更务实的选择。发送频率我用一个 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_CONNECT6. 调试工具与个人实战心得调试 BLE 应用光靠 Log 远远不够。我强烈建议准备一台备用手机装 nRF Connect 或者 LightBlue开发早期先用这种通用工具手动连接飞机验证广播、服务、特征值的行为是否和协议文档一致。电脑端可以用 Wireshark 配合 USB 蓝牙适配器抓 BLE 包能看到连接间隔、丢包、重传这些底层信息。注意 BLE 抓包需要硬件适配器支持普通的蓝牙适配器只能抓 HCI 层抓不到空中的数据包。我个人的开发流程是先用 nRF Connect 手动连一次飞机看服务列表和特征值属性是否符合预期再用自己的 App 连一次对比两边行为差异最后才动手改代码。这样做能快速把问题隔离在手机侧还是飞机侧而不是在两个端之间来回猜。还有一个我非常推荐的土办法在 App 里做一个协议调试模式把每次下发的控制帧和每次收到的遥测帧都按十六进制打印到日志和调试页面。这个功能看着不起眼但现场排查时比任何高端工具都好用。我之前定位一个偶发的丢帧问题飞机就摆在桌上靠这个调试页面逐帧对比最后发现是飞机端一个数组越界导致的不定时停推。没有逐帧日志这种问题几乎不可能靠猜找出来。最后分享一个小技巧BLE 设备的 MAC 地址在部分手机上会被随机化处理。如果你在 App 里保存了飞机地址做自动重连第二次连不上先怀疑是不是地址变了。解决方案是保存设备名而不是地址或者每次连接时重新扫描匹配。这个坑我踩过一次写出来省得大家再走一遍弯路。做 BLE 遥控器这类项目耐心和日志习惯比写代码本身更重要把每次断连、每次丢帧都当成数据来记录问题就会清晰很多。