这几年在手机端做无人机遥控是件挺有意思的事。ST的BLE Drone参考设计在圈子里不算冷门,飞控端用STM32,BLE模块用BlueNRG系列,手机端则靠一个Android App通过低功耗蓝牙跟飞机通信,界面做成了类似游戏手柄的双摇杆。最近我把这个项目从头到尾完整落地了一遍,过程中踩的坑、想明白的问题,比读一百遍文档都值。这篇就把整个Android版本遥控器App的完整链路拆开讲:BLE通信怎么建立、控制指令怎么封装、延迟怎么优化、系统权限怎么适配,以及那些一搜一大把但真正实战才会暴露的细节,全给它捋清楚。
如果你正准备做基于BLE的遥控类App,或者已经在调ST BLE Drone但卡在连接和稳定性上,这篇应该能帮你省掉不少弯路。
1. 项目整体设计与技术选型
1.1 ST BLE Drone 平台的硬件链路
先把这个平台的通信链条看明白,后面所有代码和调试思路都围绕它展开。ST BLE Drone并不是一台成品无人机,而是一套参考设计:飞控板基于STM32系列,负责电机控制、姿态解算、传感器采集;BLE模块用的是BlueNRG-2或者SPBTLE-RF这类低功耗蓝牙芯片,通过UART与飞控相连。
手机App在这条链路里的角色,相当于传统遥控器的"遥控器本体"。你对虚拟摇杆的每一次操作,App都会将这些操作转化为一条指令,通过BLE无线信道发送给机身上的BLE模块,模块再经UART透传给飞控,飞控最终调节四个电机的转速。反方向,飞控的电池电压、IMU姿态、飞行状态等数据也会通过这个通道回传到手机界面。
这意味着App端要做的事很明确:扫描和连接BLE设备、发现GATT服务、发送控制指令、接收遥测数据,再把这一切包装成一个响应流畅、操作直观的遥控界面。
1.2 通信协议:App与飞控怎么对话
在动手写代码之前,协议得先定清楚,否则后期联调会非常痛苦。ST BLE Drone的官方Demo里,控制指令和遥测数据都是走一个自定义的GATT服务,特征值内部传输的payload格式通常有两种流派:一种是结构体二进制帧,一种是紧凑的JSON字符串。
我实际开发中更推荐二进制帧,原因很实际:BLE单包长度有限,即使经过MTU扩展,一包最多也就二百多字节;而一条摇杆指令,真正有效的只有几个通道值,用JSON表达的话花括号、引号、字段名全是浪费。二进制帧可以做到十几字节甚至几字节表达全部信息,而且解析逻辑简单,不容易因为字符串格式问题触发Bug。
以一个常见的摇杆指令帧为例,大概长这样:
帧头(2字节) | 指令类型(1字节) | 数据长度(1字节) | 数据区(油门/横滚/俯仰/偏航各2字节) | 校验(1字节)帧头一般用固定魔数,比如0xAA 0x55,用于在收包时做同步;指令类型区分是控制指令、起飞、降落、紧急停机还是参数设置;数据长度方便接收方处理粘包;校验字段用简单的累加和或者CRC8,防止无线传输中的比特翻转被当成有效数据。
App端需要做的就是把摇杆的浮点值或者整数映射到协议定义的范围里。比如油门通道映射到0~1000,摇杆中立点对应500,App侧每一次OnTouch事件都计算出最新的通道值,拼成上面这帧结构,再通过BLE特征值写出去。
1.3 技术选型:为什么我坚持用Android原生BLE API
Android上做BLE有两条路线,一条是直接用系统提供的BluetoothGatt这套原生API,另一条是引入第三方开源库,比如流行的RxAndroidBle,甚至跨平台的Flutter蓝牙库、C#的BLE库。我在这个项目里最终选了原生API,不是第三方库不好,而是有几个更现实的理由。
原生API的稳定性和系统粘合度是最高的。安卓的BLE实现碎片化很严重,不同厂商的ROM对蓝牙栈的调度、后台限制策略都不一样,第三方库能做的是在应用层帮你封装重试、队列、线程切换,但它没办法绕过系统底层的限制。出了问题你要排查,原生API的调用栈和系统日志一目了然;套一层库,反而可能变成"库帮你做事,但你看不懂它做了什么"。
另外遥控类App对控制指令的时序要求非常高,第三方库为了通用性往往会引入操作队列、重试机制、自动重连策略,这些在文件传输、传感器采集场景下很实用,但在遥控指令场景里,过度的封装反而会引入不可控延迟。个人经验是:核心控制链路用原生API,把队列和线程管理握在自己手里,一个字节一个字节都由自己把控,出问题才知道去哪里排查。
2. BLE通信核心机制拆解
2.1 GATT服务与特征值映射
BLE的通信模型对新手来说第一道坎就是GATT。你打开手机蓝牙,扫描到设备只是第一步,真正能读写数据,必须要走完"连接→发现服务→找到特征值→读写"这条链。
GATT结构可以理解成一个文件柜:服务(Service)是大抽屉,特征值(Characteristic)是抽屉里的文件,每个特征值还有对应的描述符(Descriptor),用来配置通知开关等属性。
ST BLE Drone固件里通常会暴露几个服务,标准的有电池服务(Battery Service,UUID是0x180F)、设备信息服务(Device Information Service,UUID是0x180A),以及一个厂商自定义的数据通道服务。以ST官方的实现为例,这个自定义服务的UUID大概是00035b03-58e6-07dd-021a-08123a000300,里面会有一个接收指令的写特征值和一个上报数据的通知特征值。
写代码的时候,以下两点值得留意:
- 特征值的属性决定了你能对它做什么。有的特征值只支持Write,有的只支持Notify,有的支持Read。在
onServicesDiscovered回调里,一定要先检查特征值的PROPERTY_WRITE_NO_RESPONSE、PROPERTY_NOTIFY等属性,再决定调用方式,否则容易踩"操作不支持"的坑。 - 通知特征值必须同时配置CCCD(Client Characteristic Configuration Descriptor),把描述符的值写成
0x0001,设备才会往手机推送数据。只调setCharacteristicNotification而不写CCCD,是新手最常见的问题之一,后面我会再提。
2.2 MTU与连接参数:延迟到底卡在哪
很多人在调BLE遥控的时候,第一反应是"指令发出去飞机没反应,是不是带宽不够"。实际上,摇杆指令数据量很小,真正影响体感的是另一组参数:MTU、连接间隔和写入类型。
MTU(Maximum Transmission Unit)是单次BLE数据包能承载的最大数据量。默认情况下ATT MTU是23字节,扣掉ATT协议头,实际应用层能放的数据只有20字节。如果你的指令帧超过20字节,就必须拆包或者协商更大的MTU。协议栈在Android上一般能协商到247字节,通过requestMtu(247)来发起。需要说明的是,MTU最终值由手机和BLE模块双方协商确认,不一定能协商到目标值,所以代码里要以onMtuChanged回调返回的实际值为准。
连接间隔(Connection Interval)是决定延迟的核心。BLE是主从模式,从设备在每一个连接事件里才能和主机通信,连接间隔越短,数据往返越及时,但功耗越高。Android应用层没法直接像嵌入式端那样配置连接间隔,但可以通过requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)来请求更短的间隔。实测下来,这个调用能让系统把连接参数往低延迟方向调整,对遥控手感提升明显,代价是耗电会增加。
写入类型也影响延迟。writeCharacteristic可以指定两种写入方式:带响应的写(WRITE_TYPE_DEFAULT)和无响应写(WRITE_TYPE_NO_RESPONSE)。摇杆控制指令这种高频、允许偶尔丢包的数据,应该用无响应写,省掉等ACK的往返时间。一次写入如果走带响应,Android内部通常要等从设备回复才会发起下一次操作,指令频率一高队列就会堆积,延迟和丢包感会非常明显。
2.3 写操作与通知机制的正确姿势
BLE的GATT操作有一个隐藏约束:同一个连接上,多个读写操作默认是串行执行的。你在主循环里连续调用十次writeCharacteristic,系统并不会并行处理,后面的操作会被阻塞或者直接返回失败,所以App里至少要有一个简单的发送队列。
我采用的策略是:控制指令不排队,直接发。因为无响应写在大多数Android版本上都可以在短时间内连续调用,而且摇杆数据本身是"最新状态覆盖旧状态"的语义,中间丢一两帧问题不大,过分追求每帧必达反而会让发出去的指令排在队列里,等队列清空了,指令已经是几百毫秒前的过期状态了。
接收方向的正确姿势则是把onCharacteristicChanged回调数据按照协议解析,处理完立即更新UI状态。这个回调运行在系统Binder线程,不是主线程,所以不能在回调里直接改UI,需要通过runOnUiThread切换,或者用LiveData、Handler等机制把数据抛到主线程。
3. Android端核心功能实现
3.1 工程搭建与权限适配
工程部分建议直接用Android Studio创建Kotlin项目,最低API Level根据你自己的测试机型来定,但如果想兼顾Android 12及以上的权限模型,建议targetSdkVersion不低于31,我这次是按targetSdk 33来适配的。
BLE相关权限配置是这份代码里最容易被忽略又最致命的部分。以Android 12(API 31)为界,权限要求完全不同:
- Android 6.0 ~ 11.0:需要
ACCESS_FINE_LOCATION或ACCESS_COARSE_LOCATION运行时权限,因为系统把蓝牙扫描归类为位置信息。 - Android 12及以上:新增了
BLUETOOTH_SCAN、BLUETOOTH_CONNECT两个运行时权限,用来替代原来的位置权限,但如果你的App targetSdk比较老,系统会强制要求位置权限。
在AndroidManifest里需要声明这些权限,并且在代码里动态申请。以Android 12+为例,我写了一个最简单的权限申请流程:
private fun checkBlePermissions(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED && checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED } else { checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED } }还有一个细节:如果你的App需要导出日志文件、在文件管理器里展示,Android 7.0以上的File://URI会直接抛FileUriExposedException,这时候必须用FileProvider。这个坑我在开发中配FileProvider路径时卡了一会儿,主要问题是file_paths.xml里的路径写错,导致应用闪退,后来统一用一个external files目录的files-path配置才解决。
3.2 扫描与连接流程
扫描是第一个容易翻车的环节。BLE扫描在Android上有两种方式:老旧的startLeScan和推荐的BluetoothLeScanner.startScan。后者是现在的主流,扫描结果通过回调方式返回。
扫描时的核心参数是过滤条件。ST BLE Drone默认设备名一般是"ST Drone"或者类似的名字,可以用ScanFilter按设备名过滤,同时设置ScanSettings的扫描模式为SCAN_MODE_LOW_LATENCY,能有效加快发现速度,代价是功耗高,但连接前短暂扫描影响不大。
连接流程建议封装成一个状态机,因为Android的GATT连接是异步的,回调顺序如果不处理好,很容易出现重复连接、回调丢失等诡异问题。一个典型的连接状态流转是:
fun connect(device: BluetoothDevice) { gatt = device.connectGatt(context, false, gattCallback) } // 在gattCallback里: override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED) { gatt.discoverServices() } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { setupNotifyAndChannel(gatt) } }注意connectGatt的第二个参数autoConnect,我设为false。true表示连接失败后系统后台自动重连,听起来很好用,但实际上会引入很多不可控行为,比如设备重新上电后的自动重连时机、连接状态的逻辑复杂度,都不便于调试。遥控类App建议手动管理重连,把重连策略做成带退避的定时重试。
连接完成后,还要做一件事:requestMtu(247)。这个调用要在服务发现之后发起,不要在onConnectionStateChange里立刻做,因为此时GATT服务还没准备好。我见过不少人在这里踩坑,一直拿到默认的20字节,然后发现指令发不完整。
3.3 控制指令封装与下发
控制指令是遥控器App的心脏。前端界面负责采集摇杆位置,核心逻辑层负责把位置转成协议帧,BLE层负责投递。
摇杆值映射这一步有一个值得提的细节:双摇杆触摸事件在Android的View.onTouchEvent里拿到的坐标是像素坐标,而实际数据处理并不希望直接依赖像素值,因为屏幕分辨率不同,摇杆范围就不一样。正确做法是定义摇杆中心、最大半径,再把触摸点相对于中心的偏移量归一化到-1.0 ~ 1.0的浮点值,最后映射到协议里定义的整数范围。
指令下发我封装成了几个方法,以油门通道为例:
fun sendControlCommand(throttle: Int, roll: Int, pitch: Int, yaw: Int) { val frame = ByteArray(9) frame[0] = 0xAA.toByte() frame[1] = 0x55.toByte() frame[2] = 0x01 // 控制指令类型 frame[3] = 0x06 // 6字节数据 frame[4] = (throttle shr 8).toByte() frame[5] = (throttle and 0xFF).toByte() frame[6] = (roll shr 8).toByte() frame[7] = (roll and 0xFF).toByte() // pitch、yaw、校验位依此类推 writeControlFrame(frame) }执行频率方面,我实测下来用50Hz(每20ms下发一次)控制手感比较顺滑,飞控端不至于跟不上,也不会因为指令太频繁导致BLE模块UART堵塞。但你不需要用Thread.sleep卡点,用Handler.postDelayed或者Choreographer配合触摸事件上报就可以实现,最好只在摇杆动作变化时发送,静止时降到低频甚至不发,既省电也减少无线信道占用。
3.4 主线程、回调线程与队列管理
Android的BLE回调都不在主线程,尤其是onCharacteristicChanged,数据到达频率和主线程UI刷新频率很可能不一致。如果每收到一帧遥测数据就刷新一次UI,会出现界面卡顿、掉帧,甚至因为主线程被高频刷新任务占满导致指令发送不及时。
我的做法是:BLE回调线程收到数据后,把最新状态写进一个带锁的共享对象,或者用ConcurrentHashMap存最新的几个状态字段;UI侧用一个心跳定时器,以25Hz到30Hz的频率读取最新状态并刷新UI。这样即使BLE遥测以100Hz进入,UI也只会按自己的节奏刷新,不会有堆积问题。
向上发送方向的线程问题同样值得注意。蓝牙写操作必须在调用者线程执行,而sendControlCommand很多场景下是从UI事件里触发的,所以写操作直接放在UI线程其实也能跑,但为了避免触摸事件频繁时卡顿,最好把发送队列放在一个专门的HandlerThread上。这个HandlerThread负责从队列里取出最新控制帧并通过gatt.writeCharacteristic下发,这样UI线程永远只做一件事:把指令塞进队列。
4. 实际开发中的问题排查与避坑记录
4.1 连接成功立刻断开:缓存与连接参数
我在开发中遇到的第一个诡异问题是:设备明明能被扫描到,也能连上,但过一两秒就自动断开,而且断开后很难再连上。排查过程持续了大半天,最终确认是Android系统蓝牙缓存的问题。
Android在连接一个BLE设备后,会把它的GATT服务等信息缓存下来,当固件更新了服务或者设备端状态异常时,旧缓存会导致连接建立后立刻失败。解决办法有两个:一是连接失败时尝试gatt.refresh()清掉缓存,再重新连接;二是避免使用autoConnect=true,手动控制连接生命周期。
另一个导致短连的原因是连接参数不匹配。部分BLE模块对连接间隔有硬性要求,而Android默认的连接参数跟模块不匹配时,模块会主动断开连接。应用层能做的有限,最好是在固件端开放连接参数,或者让BLE模块容忍更宽的连接窗口。
4.2 Android 12+ 权限适配的硬门槛
权限这块我在真机上被折腾了一场。测试机型是Android 13,当时targetSdk已经设为33,但运行时权限没有动态申请,导致扫描接口静默失败,连错误日志都不太直观。
还有一个坑是Android 13开始引入了POST_NOTIFICATIONS权限。如果App要用前台服务来维持蓝牙连接或者保活,必须在运行时申请通知权限,否则前台服务无法正常工作。很多人在这个版本上出现"后台一会儿就断连"的怪问题,其实跟通知权限没拿到、前台服务被系统杀掉有关。
权限申请的正确姿势是:进入主界面时就把BLUETOOTH_SCAN、BLUETOOTH_CONNECT、ACCESS_FINE_LOCATION、POST_NOTIFICATIONS四个权限全部申请一遍,缺一不可。不要等到用户点"连接"再申请,遥控器App的使用场景往往在户外,手忙脚乱的时候弹权限框很影响体验。
4.3 控制延迟和掉包优化
控制延迟是这类App最直观的体验指标,我实测在优化前后的差距非常明显。初始版本用带响应写、默认MTU、没有请求高优先级连接参数,从手指动到飞机响应大概有150ms以上的延迟,飞行手感很"肉";优化后能压到80ms以下,基本跟手感一致。
优化点总结下来有四个:
- MTU尽量调到最大,减少拆包重组的开销。
- 控制指令一律用
WRITE_TYPE_NO_RESPONSE。 - 连接成功后调用
requestConnectionPriority(CONNECTION_PRIORITY_HIGH)。 - 控制指令发送不排队,只发最新状态,宁可丢包不要延时。
关于掉包,不要指望BLE协议栈帮你保证每一帧都到达。BLE本身有重传机制,但重传带来的延迟可能比丢帧更不可接受。遥控场景的正确做法是让飞控端具备"最新指令覆盖旧指令"的能力,App端则持续高频下发最新状态,只要没断连,飞控最终总能在短时间内收到新指令。
4.4 常见问题速查表
整理一个我在开发中反复遇到的速查表,每一条都是真金白银踩出来的:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 扫描不到设备 | 权限未申请、蓝牙未开启、设备未进入广播态 | 检查运行时权限、确认设备广播、使用SCAN_MODE_LOW_LATENCY |
| 能扫描到但连不上 | 设备已被其他设备占用、系统蓝牙缓存损坏 | 重启蓝牙、清除App数据、连接失败后refresh缓存 |
| 连接后立刻断开 | GATT缓存过期、连接参数不匹配 | 清缓存重连、请求固件端放宽连接参数 |
| 服务发现失败 | 设备端服务异常、信号弱 | 重连后重试、靠近设备 |
| 开了通知没数据 | 没写CCCD描述符、特征值属性不是Notify | 在setCharacteristicNotification之后写0x0001 |
| 写特征值返回false | 连接已断开、操作被系统拒绝 | 检查连接状态、重连后重试 |
| 指令延迟忽高忽低 | 用了带响应写、MTU太小、连接间隔长 | 换无响应写、requestMtu、请求高优先级 |
| 后台一会儿就断连 | 权限不足、前台服务失效、系统省电策略 | 申请通知权限、启动前台服务、引导用户关闭省电限制 |
5. 一些延伸玩法与后续扩展
5.1 固件OTA升级通道
遥控器App只做控制还不够,实际产品里固件升级是刚需。ST BLE Drone的BLE模块和飞控固件都可以通过手机升级,常见做法是在自定义GATT服务里增加一个OTA特征值,App端读取本地固件文件,按协议分包写入。
这里有两个经验可以参考。OTA数据量大,建议单独走带响应写,一块一块来,每包等待ACK再发下一包,虽然慢但可靠;关键是要在写固件之前先发一个"进入OTA模式"的指令,让飞控/模块切断正常控制逻辑,避免升级过程中还在处理摇杆指令。升级界面上最好把进度、剩余包数、失败重试次数全部展示出来,否则用户很容易在升级中途关掉App,导致设备变砖。
5.2 遥测数据与日志回传
ST BLE Drone能够回传电池电压、高度、IMU姿态等数据,这些数据在GATT通知特征值里以固定周期到达App。收到后一方面要在界面上实时展示,另一方面我建议做一个本地日志系统,把飞行全程的指令和遥测数据按时间戳写入CSV文件。
为什么强调日志?因为很多飞行异常(比如突然掉高、姿态漂移)在现场根本来不及看界面,事后把CSV拉出来和视频对时间轴,能非常清楚地看到是飞控问题还是遥控指令问题。日志文件通过FileProvider导出到外部存储,Android 11以后还需要处理一下存储权限,但用FileProvider指定路径后就能避免FileUriExposedException,这个我在前面提过。
5.3 多机型适配与兼容
最后讲一个容易被低估的问题:Android设备的BLE兼容性。不同手机的蓝牙协议栈、省电策略、芯片差异非常大,同一套代码在Pixel上很稳,到某国产机型上可能就扫描慢、断连频繁。我的建议是做一个"兼容性配置表",按机型记录需要关闭的省电策略、是否需要启动前台服务保活、扫描参数怎么调整。
另外,ST的BLE Drone只是一个参考平台,实际项目里很可能换用ESP32、nRF52这类支持BLE的模组,协议和UUID都会变。所以App的BLE层和协议解析层一定要解耦:BLE层只管连接、发现服务、读写特征值,协议层只管字节解析和指令封装。这样换平台时只需要改一个映射表,App主体逻辑完全不用动。
做这个项目时我最大的体会是,BLE遥控App的技术难点不在某个单一环节,而在把"无线通信、协议设计、实时性要求、Android系统碎片化"这几件事缝合在一起的能力。如果你正卡在扫描不到设备、连接不稳定或者控制延迟这些问题上,建议按这篇文章的顺序重新审视一遍自己的代码,大概率能找到突破口。最后再分享一个小技巧:调试BLE通信时,别急着上逻辑分析仪,先在手机端开蓝牙HCI日志,Android开发者选项里有,它能帮你看到最原始的蓝牙空中包,很多"莫名其妙"的问题一眼就能看出来。