用安卓手机DIY WiFi遥控器:重力感应、摇杆与PID调参实战
2026/9/15 7:43:25 网站建设 项目流程

简介:一份面向安卓智能硬件遥控开发者的资源包,聚焦WiFi遥控器、四轴飞行器与平衡小车的控制APP实现,涵盖重力感应、摇杆操作及PID调试等关键环节,适合嵌入式控制入门及进阶开发者用于对照学习。压缩包共709个文件,大小约32.23MB,以125个dex编译代码、168个xml配置与布局、121个json配置、116个flat编译资源为主,另含12个apk安装包、12个java源码及若干调试文本,便于从源码、资源到可安装应用进行全链路比照。已有154人学习下载。内容涉及陀螺仪与加速度计的数据读取、WiFi通信协议、PID比例积分微分参数的整定方法,并给出多组控制参数和状态输出示例,可帮助理解四轴飞行器姿态稳定和平衡小车直立控制的差异化实现。对于打算实际搭建遥控项目、调优PID参数或分析安卓端控制逻辑的开发者,这套材料能节省大量从零排查的时间。

1. 用安卓手机做WiFi遥控器:把重力感应、摇杆和PID调试装进同一块屏

一个反直觉的事实:四轴飞行器和两轮平衡小车,对遥控端的软件要求几乎完全一样。两者都需要把人的直觉操作换算成4个控制量,经过无线通道送到机身上的MCU,再进入PID闭环。不同机型只差在“控制量的含义”上,四轴叫滚转、俯仰、油门、偏航,平衡车叫前进速度与转向差速。所以一个有重力感应、摇杆、PID调试的安卓App,天然就是同一套框架,只要把通信协议抽象出来,就能同时服务两类设备。这篇博文围绕标题涉及的每一块,讲清楚原理、代码和小坑,适合正在做毕设或DIY航模的嵌入式与安卓开发者。室内WiFi环境下,这种App替代实体遥控器的效果已经足够日常调试,最大的收益是调参不再需要随身带电脑。

2. 安卓端的Socket通信与指令协议:先让摇杆能“说人话”

一个遥控App的骨架不是界面,而是手机上那一个DatagramSocket。飞机能不能动起来,取决于每一帧指令的字节顺序、长度和发送节奏。这一章先把通道选型和帧格式定下来,后面的重力感应与摇杆都往这套帧里填数据。

2.1 为什么机身控制通道用UDP而不是TCP

常见做法是控制通道走UDP。TCP为了可靠性会做超时重传,一旦网络出现抖动,重传的旧帧会排在新帧前面,控制指令的实时性反而被破坏;遥控场景里丢一帧完全无所谓,下一帧25毫秒后又来了。UDP的send和receive都不会阻塞主线程,配合一个独立线程发送即可。

class UdpSender(private val host: String, private val port: Int) { private val socket = DatagramSocket() private val address = InetAddress.getByName(host) fun send(payload: ByteArray) { val packet = DatagramPacket(payload, payload.size, address, port) socket.send(packet) } }

这段代码创建一个绑定本地随机端口的DatagramSocket,每次send调用把完整帧交给系统socket,适合20Hz到50Hz的低频遥控场景。host是机载WiFi模块的局域网IP,端口和机载端约定成5005。sendBufferSize不需要刻意调大,控制帧只有十几个字节,过大的缓冲反而会让丢包探测变慢。如果后续机载端要主动回传状态数据,同一个socket实例也负责receive,只是要单独设置soTimeout,否则receive会在断线时永久阻塞。

2.2 通道划分与指令帧格式:用int16塞下全部遥控量

机身控制、心跳、PID调试三类数据混在一个socket里,需要按功能码区分。帧结构用固定头部加可变负载:0xAA 0x55开头,后面跟功能码和数据区。控制负载里放四个int16,对应横滚、俯仰、油门、偏航,范围归一化到[-1000, 1000],用1000而不是1024是为了后续扩展时换算更直观。

功能码方向含义
0x01手机→机身四通道控制量
0x02手机→机身心跳请求
0x03机身→手机心跳应答
0x10手机→机身读取PID参数
0x11手机→机身写入PID参数
0x12机身→手机PID参数回传

0x01和0x10共用同一通道的好处是调参面板不必再维护一个独立socket,机载端按功能码分发即可。控制帧打包代码:

fun buildControlFrame(roll: Int, pitch: Int, throttle: Int, yaw: Int): ByteArray { val buffer = ByteBuffer.allocate(11) buffer.order(ByteOrder.BIG_ENDIAN) buffer.put(0xAA.toByte()) buffer.put(0x55.toByte()) buffer.put(0x01) // 功能码 buffer.putShort(roll.toShort()) buffer.putShort(pitch.toShort()) buffer.putShort(throttle.toShort()) buffer.putShort(yaw.toShort()) return buffer.array() }

allocate(11)是因为2字节帧头加1字节功能码加8字节负载。ByteBuffer默认大端,网络字节序统一用BIG_ENDIAN,STM32端用同样的顺序memcpy出来即可,不必做字节交换。四个控制量在打包前要做一次clamp,超出[-1000, 1000]的直接截断,避免产生负数噪声。机载端如果是裸机或FreeRTOS,直接按结构体指针读取负载区即可,只要字节对齐和这里一致。

2.3 心跳与失效保护:把油门归零写在机载端

假如用户关掉屏幕,或者人走出几米导致WiFi信号质量下降,机身应该主动进入安全状态,而不是停在最后一次指令上。常见做法是加心跳和超时看门狗:手机端每500毫秒发送一帧0x02,机载端收到后回0x03;同时机载端持续统计0x01的到达间隔,超过150毫秒没收到就强制油门归零。

object Heartbeat { fun loop(sender: UdpSender) { Thread { while (!Thread.currentThread().isInterrupted) { sender.send(buildHeartbeatFrame()) Thread.sleep(500) } }.start() } }

心跳线程独立于控制发送线程,避免在主线程里睡过去。150毫秒这个阈值对应约6帧控制数据,误判率低,也给机载端留出足够的动作时间。失效保护必须写在机载端而不是App端,因为手机端的逻辑在进程被杀、屏幕休眠时不可靠。另一个细节是上一帧控制量如果保持非零,机载端在超时时要“增量减速”而不是直接切断,直接切断对四轴意味着坠机,对平衡车意味着瞬间倾倒,增量减速能减少机械冲击。

3. 重力感应与摇杆控制:把传感器原始值变成飞行器听得懂的操作量

无论重力感应还是摇杆,最终都要输出和2.2节相同范围的四个int16。这一章的要点是想清楚“人做了什么”和“机身收到什么”之间的换算关系,以及如何避免两种输入方式打架。

3.1 角度计算:用加速度计还是旋转矢量传感器

重力感应操作需要把手机的倾斜角映射成横滚和俯仰。最直接的做法是注册TYPE_ACCELEROMETER,取x、y、z三个轴的加速度分量,经过一阶低通滤波后使用atan2计算。不要用getOrientation(),它依赖磁场传感器,当机身电机大电流通过时磁场会变化导致读数漂移。

private val listener = object : SensorEventListener { private val alpha = 0.2f override fun onSensorChanged(event: SensorEvent) { val ax = alpha * event.values[0] + (1 - alpha) * lastAx val ay = alpha * event.values[1] + (1 - alpha) * lastAy val az = alpha * event.values[2] + (1 - alpha) * lastAz pitch = atan2(ay, az) * 180f / PI.toFloat() roll = atan2(ax, az) * 180f / PI.toFloat() lastAx = ax; lastAy = ay; lastAz = az } }

一阶低通滤波alpha取0.2,表示新采样权重小,数据更平滑但更滞后。遥控场景建议滞后控制在100毫秒以内,否则操作会有“黏手”感。关于坐标系要特别注意一点:手机竖屏横握时屏幕坐标和飞行器机体坐标不重合,pitch和roll要按机头方向做一次旋转矩阵转换,通常直接对调X/Y并翻转符号即可,不必引入完整的坐标变换库。转动手机时要忽略重力在额外轴上的分量,否则倾斜角度会非线性放大。

3.2 摇杆控件的坐标归一化与死区处理

摇杆控件本质是一个自定义View,外边画一个圆作为边界,内部画一个小圆作为把手,onTouchEvent里把手指位置限制在外圆半径内,再换算成[-1,1]的偏移量。油门通道很特殊,一般做在左侧,而且需要回弹和不回弹两种模式切换。

override fun onTouchEvent(event: MotionEvent): Boolean { val dx = event.x - centerX val dy = event.y - centerY val length = hypot(dx, dy) val clamped = min(length, radius) val normX = dx / length * (clamped / radius) val normY = dy / length * (clamped / radius) listener.onJoystickMoved(normX, normY) invalidate() return true }

这段代码的关键是clamped配合min处理:手指拖出外圆时,把手停在圆的边缘,偏移量仍按外圆半径归一化,不会出现数值超过1。注意手机屏幕的Y轴向下,向上推摇杆时dy是负值,而四轴的俯仰正值对应抬头,需要把最终的normY取反。死区设在0.06到0.1之间:小于死区的偏移量直接置零,避免手指轻微抖动导致飞机尾舵乱摆。手柄式摇杆和手机摇杆的手感差异就在死区宽度,这部分在App里做成可配置项更合适。

3.3 重力感应与摇杆的切换策略:别让两套输入同时生效

App里提供三种遥控模式:纯摇杆、纯重力感应、混合模式。混合模式是左边用油门滑条,右边用重力感应控制横滚和俯仰。切换时不是简单换数据源,而是做平滑过渡:连续5帧把当前输出值按比例衰减到目标值,否则满油门状态突然切到重力感应,飞机会猛冲一下。

val output = when (mode) { MODE_STICK -> stickValue MODE_GRAVITY -> gravityValue MODE_MIXED -> mix(stickValue, gravityValue) }

这个分支看起来简单,实际工程里要在emit之前统一做一次150毫秒的滑动平均。做法是维护一个长度为8的环形缓冲,每次输出前把新旧值的差值除以缓冲长度再叠加,效果比直接换源要好得多。混合模式中重力感应占据横滚和俯仰,摇杆保留油门和偏航,两个数据源互不干扰。注意重力感应模式下手机轻微晃动就会输出非零值,因此摇杆模式仍是首推的安全默认项,重力感应适合熟悉后追求手感的高级玩家。

4. PID调试面板设计与参数实时下发:调参不再需要数据线

手动整定PID的传统流程是改代码烧录、看串口波形、再改再烧,一次调参要浪费十分钟。有了WiFi遥控,手机面板直接把P、I、D三个值发到机载端,参数写在RAM里随调随改,满意后再一次性写回Flash。

4.1 读写PID参数的协议设计与交互时序

读取和写入分别走0x10和0x11功能码。负载里除了PID三个float之外,还要带一个“参数组编号”,因为四轴和平衡车都至少有外环与内环两组参数。交互时序如下:点击“读取”发送0x10+组号,机载端回0x12+当前值;拖动滑条后延迟500毫秒发送0x11+新值,机载端写入并回0x12确认。

fun buildPidWriteFrame(group: Byte, kp: Float, ki: Float, kd: Float): ByteArray { val buffer = ByteBuffer.allocate(3 + 12) buffer.order(ByteOrder.BIG_ENDIAN) buffer.put(0xAA.toByte()) buffer.put(0x55.toByte()) buffer.put(0x11) buffer.put(group) buffer.putFloat(kp) buffer.putFloat(ki) buffer.putFloat(kd) return buffer.array() }

三个float用大端浮点传输,机载端如果跑STM32,直接按字节拷贝进float变量即可,注意全程关闭编译器的结构体对齐优化。写操作不要求机身立刻生效,但需要回包确认,App在收到0x12之前保持滑条可拖动,但把状态标成“已修改未生效”。参数组设计成三组以上,除了姿态内外环,预留一组给遥控通道的响应曲线调校。随机载端RAM里跑的是一份临时值,断电后要能回到Flash里的旧值,这一点要在机载端区分“写RAM”和“写Flash”两个动作。

4.2 调参面板的布局:滑条、波形图与参数组切换

面板上半部分放三个SeekBar分别绑定Kp、Ki、Kd,滑条下面展示当前数值;下半部分用自定义View画设定值和测量值的滚动曲线。不引入MPAndroidChart这类大型图表库,因为遥控App对包体积敏感,且实时刷新的曲线自己写也不过几十行。

class CurveView : View { private val path = Path() override fun onDraw(canvas: Canvas) { super.onDraw(canvas) path.reset() for (i in data.indices) { val x = i * width / data.size.toFloat() val y = height / 2 + data[i] * height / 4 if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) } canvas.drawPath(path, paint) } }

数据数组每到一个新采样点就整体左移一位,曲线呈现从右往左流动的效果。横轴显示最近5秒的数据,机载端回传频率通常设10Hz,所以数组容量用50点。纵轴范围写成可配置,默认正负2000对应控制量满量程。设定值SP和实际值PV用不同颜色的Paint绘制,两线靠得越近说明当前参数收敛得越好。曲线View的postInvalidate调用要控制在15到30毫秒一次,避免过高的重绘开销挤占UDP发送线程的时间片。

4.3 飞行器与平衡车的PID差异:串级内环与外环

两类设备都在用串级PID,但参数角色不同。平衡车的外环是角度环,内环是角速度环;四轴的外环是姿态角度环,内环同样是角速度环。外环输出作为内环的目标值,内环输出叠加到电机PWM。以下是一组常见的起始参考值,不是通解,只用于跑通控制链路。

设备外环P内环Kp内环Ki内环Kd采样率
平衡小车20~603~80.05~0.24~8200~500Hz
四轴3~85~120.01~0.12~6250~1000Hz

调参面板切换设备类型时,需要区分两种发散趋势:平衡车直接看角度偏差就能发现外环P给大后低频摇摆;四轴更依赖阻尼习惯,内环Kp给太大会高频震动,这时先降内环Kp再动外环P。实际调试顺序要先整定内环,再整定外环,面板上的参数组切换按钮正好对应这个顺序。波形图同时显示两组PID的输出会让画面混乱,所以一次只让一组参数处于“可编辑”状态,其他组降为只读,这是面板交互上容易被忽略但很重要的约束。

5. 联调排错与进阶:把延迟、日志与图形回放做成开发功能

遥控链路基本跑通后,最后一公里是验证可靠性和排错效率。这里集中讲三个进阶点,它们都直接解决联调现场最耗时的三种问题。

5.1 丢包率统计:给UDP通道加一个肉眼可见的指示

UDP乱序不可避免,更麻烦的是无法感知通道是否堵塞。常见做法是在0x01控制帧里加一个递增的帧序号,机载端把最近收到的序号通过0x20功能码回传,App端统计每1000帧里跳过的序号数量即可算出丢包率,显示在界面上角。连续几秒超过5%就说明WiFi干扰或距离过远,比反复试飞观察抖动靠谱得多。机载端回传的频率不必太高,1Hz足够,回传帧本身走独立的0x20功能码,与控制帧共用socket也不会造成拥堵。

5.2 波形回放与CSV导出:用屏幕之外的数据找问题

调参时只盯实时曲线容易漏掉瞬态。常见做法是在面板里加一个“录波”开关,把最近30秒的设定值、测量值、输出值缓存在内存数组,点击导出写入CSV文件。

fun exportCsv(rows: List<Triple<Long, Int, Int>>, file: File) { file.printWriter().use { out -> rows.forEach { (time, sp, pv) -> out.println("$time,$sp,$pv") } } }

导出后可以放到电脑上用Excel或MATLAB分析,对判断超调量和振荡周期比肉眼看屏幕准确得多。写文件要放到后台线程,避免卡住UI线程。内存数组的容量按30秒乘采样频率计算,30秒在10Hz回传下只有300个点,完全不用担心内存占用。文件路径写在外部存储的App专属目录,减少权限申请面。

5.3 失效保护实测与WiFi休眠策略

给机载端设置150毫秒超时后,用手机热点做联调实验:关闭热点,观察机身是否在阈值内归零油门。如果归零延迟明显,优先检查机载端是否在中断里做超时判断,而不是在主循环里。另一个容易踩的坑是安卓手机WiFi的休眠策略,默认状态下锁屏一会数据通道就会被系统关闭,开发期间要把“休眠时保持WiFi连接”设为“始终”,否则锁屏再唤醒后会丢掉Socket会话,遥控界面还停留在“已连接”的假象里。最后建议从零搭建这套系统的读者:先把0x01的控制帧跑通,看到机身响应再去做PID面板,不要一上来就把重力感应和波形图都加上,头尾相扣,调试时反而更快。

本文还有配套的精品资源,点击获取

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

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

立即咨询