☰
ROV_APP源码解析:水下机器人控制链路与MJPEG视频流实战
2026/10/15 11:44:51 网站建设 项目流程

简介:ROV_APP源代码是一份基于Android Studio开发的遥控潜水器控制程序,面向水下机器人开发者与Android应用学习者,用于实现ROV的实时视频流接收、航行速度调节及摄像头云台角度控制,适配水下探索、监测、救援等实际场景。资源共2000个文件,压缩包约30.15MB,内部以xml布局与配置文件、png资源图片、rawproto数据模板、class字节码、java源码及json配置文件为主,同时包含完整的Gradle工程与调试/发布APK,便于直接导入项目分析。已有640人学习浏览。通过这份代码,读者可系统了解Android端与ROV设备的无线通信机制、串口指令下发、PID调速、云台电机控制以及多线程视频解码等关键模块;工程内还保留了版本管理记录与测试构建产物,适合作为无人潜航器或智能硬件控制App的二次开发基础,对掌握Android底层硬件交互尤其有参考价值。

1. ROV_APP 源代码:先搞清楚“驾驶舱”在哪一层,再决定要不要抄

ROV_APP 这套源代码,看名字像是个普通 App 工程,实际上它是水下遥控机器人(ROV)整套系统里最关键的一块:岸基控制端。整台机器在水下只干三件事——回传视频、回传遥测、执行推进器指令,而“看画面、推摇杆、读深度、判断故障”全都要在 APP 这一侧完成。换句话说,这就是操作员的驾驶舱:它决定你盯着一块屏幕,能不能像坐在船上一样,把一个铁壳子平平安安开出去再收回来。

这份源码最值得抠的,不是界面花不花哨,而是它把“控制链路”和“视频链路”这两套性格完全不同的数据流,塞进同一个 App 还能不互相拖死。适合读它的人很明确:正在自研水下机器人的开发者、要给水产养殖或桥墩巡检做方案的学生团队,以及手里已经有一套下位机、只想快速搭一个地面站界面的嵌入式工程师。如果只是想看个热闹,也能从头跟到尾,但真正的价值在协议和链路管理,不在那几张布局文件。

2. 拆架构:为什么 ROV_APP 要把控制、遥测、视频拆成三条通道

2.1 视频走无状态流、控制走 TCP:不是偷懒,是怕互相拖死

拿到 ROV_APP 这类源码,第一件该做的事不是打开界面,而是把它的网络链路画出来。常见做法是把链路拆成三条:控制指令走 TCP,遥测走 UDP,视频走独立的 MJPEG over HTTP。这个分家不是我拍脑袋定的,是被实际场景逼出来的。

控制指令这路数据,特点是包小、频率高、要求可靠。一条推进器指令通常只有七八个字节,以 20~50Hz 的频率发出去,一旦丢包,轻则动作卡顿,重则在深水区失去响应。视频这路数据恰好相反,一帧 JPEG 几十到上百 KB,吞吐量大,但对“个别帧丢失”无所谓——丢一帧只是闪一下黑,下一帧马上补回来。

如果两者共用一条 TCP 连接,问题就来了:TCP 是可靠有序传输,一旦 WiFi 链路抖动开始重传旧的视频分片,排在后面的控制包就必须等。现象是画面越卡,遥控越迟钝,延迟从 50ms 一路涨到一两秒。水下作业最怕的就是这个:链路变差时恰恰是最需要可控性的时刻,结果控制先死了。这就是 ROV 控制端最常见的翻车现场,也是 Make the split 的本质原因。

视频走 MJPEG 而不是 H.264,同样出于落地代价考虑。H.264 虽然省带宽,但涉及 I 帧、P 帧、关键帧间隔,解码出错的恢复逻辑在 Android 上很折腾;MJPEG 每一帧都是一张完整 JPEG,丢帧自愈,实现简单到用一个 BitmapFactory 就能解。至于遥测,走 UDP 是因为它 10Hz 的发送频率里丢个一两帧完全不影响显示,没必要挤占 TCP 通道。理解了这个分工,后面看代码就不会被多个 Socket 绕晕。

2.2 自研控制协议长什么样:指令帧、遥测帧与 CRC 参数

不夸张地说,协议设计是 ROV_APP 源码里最值得抄的部分。很多业余项目直接用现成的航模地面站协议,字段冗余多,参数一长串,对水下机器人这种只有四路推进器加一路灯光的系统来说太重了。自研协议做得好,一条指令 10 个字节以内就能说清楚。

典型的指令帧结构如下表,按“帧头 + 长度 + 指令类型 + 载荷 + 校验”组织:

字段长度说明
帧头2 字节固定 0xAA 0x55,用于粘包定位
总长 LEN1 字节从指令类型到载荷末尾的字节数
指令类型 CMD1 字节0x01 推进器、0x02 灯光、0x03 云台、0xE0 心跳
载荷0~32 字节按指令类型自定义
CRC162 字节覆盖指令类型到载荷末尾,CCITT 多项式 0x1021

推进器指令的载荷最常见是 4 个有符号字节:前后、左右、升降、转向,数值范围 -100~100,正好对应摇杆的行程。帧头解决“数据流从哪里开始”的问题,长度字段解决“一帧在哪里结束”的问题,CRC 解决“这一帧是不是被传坏了”的问题。这三个字段缺一个,解析器都会变成玄学现场。

遥测帧同理,建议固定长度、小端字节序,字段顺序写死,这样解析代码可以做到无状态。心跳则是控制通道的看门狗,1Hz 的空指令帧,连续丢 3 次就认定链路死亡。参数上我一般这样定:推进器指令 20~50Hz,遥测 10Hz,心跳 1Hz,CRC 用 CRC16/CCITT。频率太低手感差,太高对 WiFi 小包吞吐也是负担,20Hz 已经够用。

2.3 拿到源码先看哪几个文件:最快的定位路径

一份陌生源码,直接从头到尾读会很快劝退。我一般按这个顺序切入,效率最高:

文件/目录职责为什么先看
网络层封装(TcpLink 之类)控制通道连接、心跳、重连系统稳定性的命门
协议解析类指令帧/遥测帧的封包解包和 ROV 端的唯一契约
视频流类MJPEG 拉流、切帧、解码最容易出黑屏的环节
主界面 Activity + 摇杆 View生命周期与交互映射最后再看,理解框架后这些很快

看完网络层和协议层,再看 UI,你会立刻明白很多界面写法都是为了配合链路:为什么摇杆要有个死区,为什么仪表盘不每秒刷 60 次,为什么断线时要锁摇杆。源码里这几个文件之间如果有明显耦合,通常就是作者在链路和界面之间权衡过的痕迹,值得多看两眼。

3. 跑通最小链路:连接、遥测解析、MJPEG 解码的三段代码

3.1 建立 TCP 控制通道:心跳、超时与退避重连

ROV 在水下,WiFi 链路随时可能抖动。所以控制通道的连接代码,最重要的不是“连上”,而是“断了知道断,并且能自己接回来”。下面这段是典型的 TcpLink 核心逻辑,无论是从源码里改还是自己重写,都绕不开这几个参数。

public class TcpLink { private static final int CONNECT_TIMEOUT_MS = 3000; // 握手超时 private static final int HEARTBEAT_INTERVAL_MS = 1000; // 心跳间隔 private static final int HEARTBEAT_MISS_LIMIT = 3; // 连续丢几次判死 private Socket socket; private OutputStream out; private Handler uiHandler; private volatile boolean running; private int missCount; public void connectAsync(final String host, final int port) { new Thread(() -> { try { Socket s = new Socket(); s.setTcpNoDelay(true); // 关闭 Nagle,小包不攒着发 s.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MS); socket = s; out = s.getOutputStream(); running = true; startHeartbeatLoop(); uiHandler.post(() -> onConnected()); } catch (IOException e) { uiHandler.post(() -> onConnectFailed(e)); // 交给上层退避重连 } }, "tcp-connect").start(); } private void startHeartbeatLoop() { new Thread(() -> { while (running) { try { sendCommand(0xE0, new byte[0]); // 心跳空指令 Thread.sleep(HEARTBEAT_INTERVAL_MS); } catch (Exception e) { if (++missCount >= HEARTBEAT_MISS_LIMIT) { close(); uiHandler.post(() -> onLinkLost()); break; } } } }, "heartbeat").start(); } }

逻辑说明:连接放在独立线程避免阻塞主界面,握手 3 秒超时是为了在 ROV 没上电时快速失败,而不是让用户盯着转圈。setTcpNoDelay(true) 是关键,Nagle 算法会把小控制包攒到一定大小才发,直接导致摇杆指令延迟飚升,这一行不写,之后调手感会调得怀疑人生。

参数说明:同步成功且设置 tcpNoDelay 后再开始心跳;心跳丢 3 次才判死,是为了滤掉偶发丢包,不至于在链路抖一下时就锁死操作。上层重连建议用指数退避:500ms、1s、2s、4s,封顶 10s。ROV 在几十米外抢修链路时,重连风暴只会让本来就不好的信道雪上加霜。

提示:心跳线程是典型的“生命周期黑洞”,记得在 onPause 或服务销毁时置 running 为 false,否则 App 退后台后线程还在发包,连接被系统回收后就会进入死循环重连。

3.2 解析遥测帧:字节序、负数与单位换算

遥测协议里最容易翻车的不是协议本身,而是 Java 的字节是有符号的。水下设备通常全部用小端序,一个 int16 的深度数据,在 C 端是“低字节在前”,到 Java 里不做掩码处理,负数会被读成完全不同的值。看下面这段解析代码。

public class TelemetryParser { // 帧格式: 55 AA | 类型 0x10 | 深度 2B | 俯仰 2B | 横滚 2B | 电压 2B | 状态 1B | CRC16 2B // 全部小端序; 深度单位为 0.1m, 姿态角单位为 0.01°, 电压单位为 0.01V public Telemetry parse(byte[] buf, int len) { if (len < 15) return null; if ((buf[0] & 0xFF) != 0x55 || (buf[1] & 0xFF) != 0xAA) return null; if (buf[2] != 0x10) return null; // 遥测帧类型标识 int crcExpect = ((buf[len - 2] & 0xFF) << 8) | (buf[len - 1] & 0xFF); if (crc16(buf, 2, len - 4) != crcExpect) return null; int depthRaw = (buf[3] & 0xFF) | ((buf[4] & 0xFF) << 8); // 无符号 short pitchRaw = (short)((buf[5] & 0xFF) | ((buf[6] & 0xFF) << 8)); // 有符号 short rollRaw = (short)((buf[7] & 0xFF) | ((buf[8] & 0xFF) << 8)); int voltRaw = (buf[9] & 0xFF) | ((buf[10] & 0xFF) << 8); // 无符号 Telemetry t = new Telemetry(); t.depthMeter = depthRaw * 0.1f; // 深度毫米级换算 t.pitchDeg = pitchRaw * 0.01f; // 俯仰角,可负 t.rollDeg = rollRaw * 0.01f; t.voltage = voltRaw * 0.01f; t.status = buf[11]; return t; } }

逻辑说明:每一处读字节都强制 & 0xFF,是 Java 解析二进制协议的铁律。比如 buf[3] 是 0x80 时,直接当 byte 是 -128,转成 int 就变成 0xFFFFFF80,再和其他字节组合出来的深度完全是错的。俯仰角用 short 接收后再转 float,因为姿态角可以是负值,short 保留了有符号语义。

参数说明:深度和电压用无符号的 int 来拼,姿态角用有符号的 short 来拼,这个选择要和 ROV 端固件严格对齐。深度单位约定为 0.1m,意味着 3.5m 水深传来的原始值是 35,如果不看协议文档直接当 1m 读,仪表盘会显示出荒谬的数字。CRC 校验放在解析开头,帧头、长度、CRC 三个过滤条件都过了,才认为这帧数据可信,否则直接丢弃,不给脏数据污染界面的机会。

3.3 显示摄像头 MJPEG 流:按边界切帧与丢帧策略

MJPEG 在 App 侧的本质不是“播放视频”,而是“循环解照片”。HTTP 响应体里是一串连续 JPEG 帧,每帧以 0xFFD8 开头、0xFFD9 结尾。切帧、解码、上屏,三步循环,代码不复杂,坑全在细节上。

// 从 MJPEG 流中切出完整 JPEG 帧并解码上屏 public void start() { executor.execute(() -> { try { HttpURLConnection conn = (HttpURLConnection) new URL("http://" + host + ":" + port + "/stream").openConnection(); InputStream in = conn.getInputStream(); while (running) { byte[] frame = cutNextJpeg(in); // 按 FFD8/FFD9 边界切帧 if (frame == null) break; byte[] copy = frame; if (decoding.compareAndSet(false, true)) { // 上一帧没解完就丢 uiHandler.post(() -> { Bitmap bmp = BitmapFactory.decodeByteArray(copy, 0, copy.length); if (bmp != null) { imageView.setImageBitmap(bmp); bmp.recycle(); } decoding.set(false); }); } } } catch (IOException e) { // 视频断流: 常见于相机进程重启或 WiFi 边界切帧失败 } }); }

逻辑说明:cutNextJpeg 内部用状态机扫描流,找到 FFD8 记起点,找到 FFD9 记终点,返回完整帧。解码放主线程是不得已的妥协,但必须配合解码中标志位做“丢帧策略”:主线程还在解上一帧时,网络线程直接丢弃新帧,保证界面上永远是最近的一帧,而不是积压成队列然后越播越卡。

参数说明:BitmapFactory.decodeByteArray 适合 640x480 级别的帧,超过 720p 建议改用 ImageDecoder 并配合采样缩放,否则解码耗时上来,丢帧策略会让你看到画面只有 720p 的一半流畅度。recycle() 及时释放上帧内存,ROV 长时间运行时,这一步不做就是内存水位持续上涨,最后到现场才哭就晚了。

4. 驾驶舱体验:虚拟摇杆映射、仪表刷新率与急停保护的实现取舍

4.1 虚拟摇杆的映射:死区、指数曲线与最小油门

ROV 和航模有个本质区别:它是悬浮在水里的,推进器响应慢,操控者对“零位”的感知也弱。虚拟摇杆如果直接把触控位移线性映射成 -100~100,几乎必然出现两个问题:手稍微一抖就输出小油门,船在原地晃;摇杆回中后因为触控残影,推力不归零。所以映射必须加处理,而不是简单乘个系数。

// 摇杆原始位移转成推进器指令 float rawX = (event.getX() - centerX) / radius; // 归一化 -1..1 float dead = 0.08f; // 中心死区 8% if (Math.abs(rawX) < dead) { rawX = 0f; } else { rawX = (rawX - Math.signum(rawX) * dead) / (1f - dead); } int cmd = (int) (rawX * 100f); // 指数曲线: 让低速区更细腻, 高速区不突变 float normalized = Math.abs(cmd) / 100f; normalized = (float) Math.pow(normalized, 1.3f); cmd = (int) (Math.signum(cmd) * normalized * 100f); if (Math.abs(cmd) < 5) cmd = 0; // 最小油门阈值 5%

逻辑说明:死区先压缩再缩放,是为了让死区边界处不产生跳变。指数曲线的功率系数 1.3 是我常用的起点:低速段每 1% 摇杆位移对应的推力更小,方便在水下做精细进舱;高速段响应依然足够猛。最后再加一道最小油门阈值,把 5% 以下的微小输出直接清零,防止摇杆漂移导致推进器轻微爬行。血泪经验是,这些曲线参数一定要做成可调配置,不同水域负载下最优曲线差很远。

参数说明:死区 8% 是触控屏普遍水平,屏幕采样差或贴膜厚就加大到 12%;指数 1.3 偏向操控新手,老手可以降到 1.1 得到更直接的响应。摇杆输出的最终指令,建议合并成一条推进器指令帧,按 20Hz 周期发送,而不是每次触摸事件都发——触摸事件频率有时能到 100Hz 以上,包多发只会挤占本来就脆弱的链路。

4.2 仪表盘刷新:10Hz 上限与最新帧覆盖

遥测数据 10Hz 到达,UI 刷新也做 10Hz,看起来顺理成章,但很多新手会把“每条遥测到达”和“刷新界面”直接挂钩。结果就是遥测线程里狂调 runOnUiThread,主线程消息队列被塞满,界面反而卡顿。正确的做法是“生产者-消费者”分离:遥测线程只负责解析并写入一个最新的快照,主线程按固定节奏读快照刷界面。

// 遥测线程只更新 latestTelemetry, 不碰任何 View private volatile Telemetry latestTelemetry; // 主线程 10Hz 刷仪表盘 private Runnable refreshTask = new Runnable() { @Override public void run() { Telemetry t = latestTelemetry; if (t != null) { depthView.setText(String.format("%.1f m", t.depthMeter)); pitchView.setText(String.format("%.1f°", t.pitchDeg)); voltView.setText(String.format("%.2f V", t.voltage)); } uiHandler.postDelayed(this, 100); // 100ms = 10Hz } };

逻辑说明:latestTelemetry 用 volatile 修饰,保证跨线程可见性。Telemetry 对象本身是只读的,解析线程每次 new 一个出来再赋值引用,主线程读到的一定是完整对象,不会出现读到一半的数据。这套模式比加锁简单,也够用。

参数说明:刷新周期 100ms 是经验值。深度和电压是慢变量,10Hz 完全够看;就算要显示姿态角动态,人眼也分辨不出 10Hz 和 30Hz 的差别。反过来,如果你把刷新提到 50Hz,唯一的效果是电池掉得快一点,屏幕并不会更跟手。仪表盘上所有数值的更新,都应该走这一个任务,而不是各自发 post 循环,否则界面在低端平板上必然掉帧。

4.3 掉线保护与急停:锁摇杆、发安全指令

ROV 失控的后果比航模更隐蔽:飞机会掉下来让你看到,水下的船只会静静地沉向深处或者漂走。所以掉线保护是安全的底线,不是体验优化。判断逻辑必须放在链路层,而不是界面层,这样即使 App 退到后台,保护逻辑依然在工作。

// 链路丢失后的安全动作 void onLinkLost() { controlMode = MODE_SAFE; // 进入安全模式 joystickView.setEnabled(false); // 锁死摇杆, 防止误触 for (int i = 0; i < 3; i++) { sendThrusterCommand(0, 0, 0, 0); // 连发 3 条全零指令 } statusView.setText("链路中断,已锁定,等待重连"); connectWithBackoff(); // 指数退避重连 }

逻辑说明:锁摇杆是防止操作员在慌乱中反复推杆;连发 3 条全零指令是给 ROV 端一个明确的安全指令,即使水下端丢了一两条,大概率还能收到一条并把推进器归零。状态提示必须醒目,让人一眼知道当前是不能操作的。

参数说明:全零指令发送次数 3 次、间隔 50ms,是我常用的量级。太多会挤占数据通道,太少可能全被丢光。安全模式下摇杆虽然不能动,但急停按钮要一直可用——这是给操作员的后悔药,急停按钮发送的是一条带特殊 CMD 的强制停机指令,优先级高于普通推进器指令,ROV 端必须无条件执行。这样做完,至少能保证在水下出问题时,最坏结果是漂,而不是乱窜。

5. ROV_APP 常见故障排查:黑屏、推杆无响应、延迟飙升逐个定位

5.1 现象:视频一直黑屏,但状态栏显示连接正常

原因:控制通道和视频通道是两条独立链路,控制能连上只能说明 TCP 通了,不说明视频服务活着。最常见的是这几点:视频服务地址端口写错、ROV 端相机进程没启动、App 走了错误的网卡路由。

解决:先用手机浏览器直接访问http://<ROV_IP>:<端口>/stream,如果能出画面,说明问题在 App 端;如果浏览器也黑,说明问题在水下端,App 改了也没用。这一步能立刻把排查范围砍掉一半。确认源没问题后,再看 App 的日志里 MJPEG 连接是否抛出 IOException,重点查是不是超时设太短或代理设置拦截了本地地址。

5.2 现象:摇杆推过去,推进器偶尔没反应,像抽风

原因:多半是发送频率不稳定加链路丢包。推杆指令如果只在触摸事件里发,手指快速滑动时事件密集,信号不好时连续丢几包就容易出现“推了没反应”的错觉;另一个常见情况是 CRC 校验没过,ROV 端把整帧丢了。

解决:把指令发送改成固定 20Hz 的独立线程,摇杆状态只需要被该线程读取,而不是由触摸事件触发发送。这样即使某一帧丢了,下一帧 50ms 后就到,人根本感觉不到。同时在日志里统计发送帧数和 ROV 端回传的确认帧数,一旦丢帧率超过 5%,就该怀疑是 WiFi 信号问题而不是代码问题。

5.3 现象:画面延迟从 200ms 慢慢涨到 1s 以上

原因:这是 MJPEG 方案最典型的病:解码跟不上网络到达速度时,队列里积压帧。如果实现里是“先入队再逐个解码”,积压会越来越严重,延迟只会涨不会降,跟 WiFi 无关。

解决:检查视频线程是不是用了队列或链表缓冲,如果是,直接改成“只保留最新一帧”的丢帧策略,像我上一章写的那样。再用一个 AtomicBoolean 做解码中标志,上一帧没解完就丢弃新帧。改完再看延迟曲线,如果还是慢慢涨,就该考虑降视频分辨率,而不是继续优化代码——640x480 是 MJPEG 模式下延迟和画质比较平衡的分辨率。

5.4 现象:App 切后台再回来,控制失效,必须杀掉重开

原因:Android 系统在 App 进入后台后会回收部分网络资源,Socket 可能被系统关闭;更坑的是有些机型会在后台暂停线程调度,心跳线程被挂起,回来时已经错过多次心跳,链路早已判死。而 Activity 的 onPause 里如果没停掉心跳、onResume 里没检查重连,界面看起来活着,底层链路却已经死了。

解决:把网络连接和心跳的管理从 Activity 移到前台服务里,Activity 只做界面展示。onResume 里检查连接状态,如果 linkLost,立刻触发重连而不是等心跳自愈。另外在 onPause 时不要销毁连接,只暂停界面刷新;网络线程保持运行,这样从后台回来最差情况也只是仪表盘跳回当前值,不会出现“界面活着但遥控死了”的危险状态。

6. 下水前的量化验证:延迟、丢包、续航一次测清

源码改完、界面跑通,还不算完。ROV 这东西,在岸上水池和真实水域完全是两套表现,下水前必须把三个指标量化测一遍,不然到了现场只能用“感觉还行”来赌,赌输一次就是打捞费用。

延迟测试做法是:在 ROV 端让遥测帧里带上发送侧时间戳,App 收到后用本地时间减去时间戳算出单向延迟(两机时钟若不同步,则改为发指令、ROV 端在遥测应答里回带指令时间戳,算往返 RTT 更可靠)。测 10 组取中位数,比单次测量有意义得多。丢包测试更简单:App 连续收 10 秒遥测,统计实际收到帧数和理论帧数的比值;距离从 10m 到 100m 每加一档测一次,这组数据直接决定你在现场敢让 ROV 跑多远。

指标建议阈值测量方式
控制指令 RTT中位数 < 100ms指令-应答时间戳差
视频端到端延迟< 300ms秒表入镜对比
遥测丢包率10 秒统计 < 1%收包计数值对比
续航电压曲线不低于规定安全值10Hz 遥测记录

视频端到端延迟的土办法其实很可靠:手机屏幕上摆一个秒表,同时用另一台手机拍 ROV 端摄像头看同一块屏幕,对比两边的秒表读数差,误差在 50ms 以内,够用。续航测试则在软件里加一个电压记录功能,下水前后比对曲线,看电压在什么水平开始斜率陡降,以后就知道该在什么时候收船。

我的习惯是每次下水前把这三项跑完,数值写进测试记录里,回来后和上一次比对。延迟突然涨了 30ms,往往不是软件改出来的,而是天线位置、电池电压或者水环境变了,这些数据能帮你把“玄学”变成“可排查项”。ROV_APP 这套源码能给你的,就是把测试工具做进 App 里的那一点点功夫——加个时间戳、加个计数统计,都不难,但下水时能省下大把现场抓瞎的时间。希望帮到你。

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

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

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

立即咨询