前言
本篇不是真实的行车记录仪项目,但包含有主要的录像功能。
本案例是先开发了APP应用端的日常录像,事件录像(甩动手机触发事件,锁定当前录像)功能,然后增加了控制化的扩展机制 :命令方式触发录制/停止录像,锁定当前录像,实时推送RTSP视频流,UDP广播包应答。PC端专门开发了对接这个控制化的扩展机制的协议控制层以达到控制安卓APP客户端的目的。
本篇涉及的主要知识点如下:
安卓APP端:录像,碰撞/急刹检测,RTSP视频推流,显示录像/图片,TCP控制协议服务,UDP广播包响应。
PC控制端:TCP控制协议交互,搜索设备(UDP广播包),RTSP 拉流、openGL渲染视频帧
效果图
安卓APP客户端(记录仪应用)

效果图1 APP端"实时"菜单页面-默认页面

效果图2 APP端"实时"菜单页面-点击开始录像

PC控制端(记录仪控制器)

效果图3 PC端默认页面

效果图4 PC端"控制/回放"菜单页面

效果图5 PC段显示RTSP推流
功能操作说明
1、设备互通
(1)同时打开APP端和PC端,两个设备(安卓手机和PC端)在连接同一个局域网的情况下,点击PC端的**【搜索设备】** 按钮,设备端的IP地址自动填写到设备IP列表空中,此时点击**【连接】**按钮即可完成设备互通;(见效果图3)
(2)同时打开APP端和PC端,两个设备(安卓手机和PC端)网络可达的情况下,查看APP端**"实时"** 菜单中的提醒信息,把获取到的IP地址填写到PC端的IP地址栏,此时点击**【连接】**按钮即可完成设备互通;
2、开始录像
(1)APP端,在**"实时"** 菜单中,点击**【开始录像】**按钮,即可开始录像,每隔60秒生成一个日常视频,在录像期间,如果强力甩动(模拟碰撞效果),达到预警阈值时,自动生成事件视频;(见效果图1)
(2)PC端连接成功后,首先观察APP端的**"实时"** 菜单中有提示信息"车机:proto://xx.xx.xx.xx:9001",然后在PC端进入**"控制/回放"菜单** 页面(见效果图4),点击**【开始录像(ACC_ON)】** 按钮,APP端即会触发**【开始录像】**功能,这是PC端通过两端定义的私有车机协议实现的后台服务功能,在录像期间,如果强力甩动(模拟碰撞效果),达到预警阈值时,自动生成事件视频。
3、停止录像
APP端,在**"实时"** 菜单中,点击**【开始录像】** 按钮之后,会显示出**【停止录像】和【锁定当前段】按钮。**
(1)在**"实时"** 菜单中,点击**【停止录像】**按钮,即可关闭录像,并且生成录像到日常视频中(不需要满足60秒的条件)。
(2)PC端连接成功后,首先观察APP端的**"实时"** 菜单中有提示信息"车机:proto://xx.xx.xx.xx:9001",然后在PC端进入**"控制/回放"菜单** 页面(见效果图4),点击**【停止(ACC_OFF)】** 按钮,APP端即会触发**【停止录像】**功能,并且生成录像到日常视频中(不需要满足60秒的条件)。
4、锁定当前段
在开始录像的情况下,点击APP端和PC端的【锁定当前段】按钮都会生成一个事件视频。
5、RTSP推流
PC端连接成功后,首先观察APP端的**"实时"** 菜单中有提示信息"直播:rtsp://xx.xx.xx.xxx:8554/live",,然后在PC端进入**"控制/回放"菜单** 页面(见效果图4),点击**【开始录像】**按钮,此时在做成有一个显示RTSP推流的视频窗口(见效果图5)。
环境准备
1、PC端我是在win10 QT MinGW环境下开发的(方便移植)
2、APP端是在win10上安装ubuntu虚拟机,在虚拟机上安装的QT android环境开发,ubuntu安装环境传输门如下:
功能详细讲解
概述
如功能操作说明中的介绍,这不是一个完整的行车记录仪项目,而是提供了与录像相关的功能模块:开启/关闭录像,RTSP推流,生成日常视频,模拟G-sensor生成事件视频,查看视频/图片。
并且PC端与APP端通过私有机车协议进行TCP交互落地,在机车上,可以用这个私有机车协议对接正在的机车协议即可实现适配。
功能清单
安卓APP客户端(记录仪应用)
| 功能 | 用途 | 代码文件/类名称 |
|---|---|---|
| UDP 发现应答器 | 接收私有UDP协议广播包,返回UDP应答包, 用于PC控制端自动发现终端的功能 | DiscoverResponder |
| TCP 命令交互 | 接收PC控制端下发指令,监听9001端口,提供: STATUS 查看状态 PING 心跳 ACC_ON 开启录像 ACC_OFF 关闭录像 LOCK 锁定当前段 | DvrProtoServer |
| ACC 状态机 | 模拟点火/熄火 触发实时菜单的**【开始录像】【停止录像】**按钮功能 | RecordingEngine MediaLibrary |
| 碰撞检测 | G-Sensor 阈值触发(G=2.5g / 3.4g) 触发特殊事件,自动生成事件视频 | GSensor RecordingEngine SegmentMuxerManager StorageManager |
| 分段录像 | 每60s生成一段日常视频录像(视频循环覆盖) | SegmentMuxerManager RecordingEngine |
| 手动锁档 | 触发实时菜单的**【锁定当前段】**按钮功能 | RecordingEngine MediaLibrary |
| RTSP 服务端 | 8554端口 输出视频流,PC端拉流显示 | RtspServer |
| 文件回放 | 历史视频和图片文件的显示,对应【回放】菜单 | StorageManager |
PC控制端(记录仪控制器)
| 功能 | 用途 | 代码文件/类名称 |
| 搜索设备 | 发送UDP私有协议广播包,设备端接收到之后,返回一个应答 包,接收到应答包之后,自动填写设备端的IP地址 | DeviceDiscoverer |
| TCP 命令交互 | 下发控制指令,提供: STATUS 查看状态 =》【连接】时,下发一次 PING 心跳 =》 每隔30秒,自动发一次 ACC_ON 开启录像 =》点击【开始录像】 ACC_OFF 关闭录像 =》点击**【停止】** LOCK 手动锁档 =》点击**【锁定当前段】** | MainWindow DeviceClient |
| RTSP 拉流 + YUV 渲染 | ACC_ON 自动拉 rtsp://ip:8554/live,解码线程推帧到 OpenGL 控件 | VideoWorker yuvglwidget |
| IP 历史持久化 | 下拉历史 + 端口写回 QSettings,下次启动还原 | MainWindow |
| 日志 | 显示交互日志 | MainWindow |
|---|
核心功能实现
核心功能,联动分享APP端和PC端的实现细节。
1、搜索设备
这是同个局域网下比较常用的方式:服务端发送一个UDP广播包,同个局域网下,任何一台设备都可以接收到这个包,设备端接收到之后,向指定的端口返回一个网络包即可完成在线验证。
| 功能 | 用途 | 代码文件/类名称 |
| 搜索设备(PC端) | 发送UDP私有协议广播包,设备端接收到之后,返回一个应答包,接收到应答包之后,自动填写设备端的IP地址 | DeviceDiscoverer |
| UDP 发现应答器(APP端) | 接收私有UDP协议广播包,返回UDP应答包, 用于PC控制端自动发现终端的功能 | DiscoverResponder |
|---|
1.1 PC控制端实现
在主窗口提供了一个**【搜索设备】**按钮,信号槽机制触发DeviceDiscoverer类的discover(),DeviceDiscoverer类负责搜索设备的全部实现,以下概述关键特性:
DeviceDiscoverer::discover
发送广播包(255.255.255.255 + 各网卡定向广播),设置超时机制接收回应包
- rx_.bind(QHostAddress::AnyIPv4):监听本机所有 IPv4 网卡,用于接收回应包。
- QHostAddress::Broadcast:广播地址255.255.255.255(有可能被路由器过滤)
- e.broadcast():内网广播地址(比如10.0.1.255)
- writeDatagram:写入数据报(datagram),内容是"DVR_DISCOVER"(见截图)
- QUdpSocket::readyRead:缓冲区有可读的UDP包
- QTimer::singleShot:一次性定时器,用于超时收尾,关闭后 readyRead 不会再触发

图1.1.1 发送的UDP广播数据包
DeviceDiscoverer::onReadyRead
接收数据包,过滤IP地址的前缀"::ffff:",并向主窗口发送deviceFound信号,主窗口累加到IP列表框中。
- hasPendingDatagrams():用于判断是否存在数据报,UDP传输的是数据报(datagram)
- readDatagram():读取数据报(datagram)
- "::ffff:":QT读取的IP地址有这个前缀,对前缀进行过滤
- deviceFound:把IP地址回传给主窗口的信号函数
核心代码
void MainWindow::wireSignals(){
... ...
// ---- 搜索设备(UDP广播发现,后端=已有DeviceDiscoverer,端口9002)----
connect(btnSearch_, &QPushButton::clicked, this, [this]{
appendLog(">> 搜索设备(UDP广播 :9002,1.5s窗口)...");
discoverer_->discover();
connect(discoverer_, &DeviceDiscoverer::deviceFound, this, [this](const QJsonObject& info){
const QString ip = info.value("ip").toString(); // 该文件已把源地址注入"ip"键
const QString nm = info.value("name").toString();
if (ip.isEmpty()) return;
if (ipBox_->findText(ip) < 0) ipBox_->addItem(ip); // 历史下拉去重后追加
ipBox_->setEditText(ip); // ★自动填入IP编辑框,直接可点连接
appendLog(QString("[发现] %1 → %2 已填入IP框")
.arg(nm.isEmpty() ? "DVR设备" : nm, ip), "ok");
});
... ...
}
#ifndef DEVICEDISCOVERER_H
#define DEVICEDISCOVERER_H
// =====================================================================
// UDP广播设备发现(PC端)
// 协议约定(设备端预留实现,见文末说明):
// PC → 255.255.255.255:9002 及各网卡定向广播,载荷 "DVR_DISCOVER\n"
// 设备 → 按收包源地址原路单播回复一行JSON:
// {"dvr":1,"name":"行车记录仪","proto":9001,"rtsp":8554}
// 安全原则:设备IP以回包源地址为准,不信任载荷内容
// =====================================================================
#include <QObject>
#include <QUdpSocket> // UDP 收发核心类(Qt 对 Berkeley socket 的封装)
#include <QTimer> // 一次性超时定时器,用来结束"等待回包"窗口
#include <QSet> // 已发现设备的 IP 集合,用于去重
#include <QJsonObject> // JSON 对象容器
#include <QJsonDocument> // JSON 文本 ↔ 对象的解析器
#include <QNetworkInterface>// 枚举本机所有网卡、IP、子网掩码、广播地址
class DeviceDiscoverer : public QObject {
Q_OBJECT
public:
// 约定好的探测端口:PC 往外发的目标端口,设备监听这个端口
static constexpr quint16 kPort = 9002;
explicit DeviceDiscoverer(QObject* parent = nullptr) : QObject(parent) {}
// 注意:这里没有创建 socket、没有 bind。
// 一切资源都在 discover() 调用时才建立(懒创建),避免构造时就占端口。
// --------------------------------------------------------------
// 发起一次探测,timeoutMs 毫秒内收到的所有回包都算本次结果
// --------------------------------------------------------------
void discover(int timeoutMs = 1500) {
// ★ 绑定接收端:AnyIPv4 表示监听本机所有 IPv4 网卡。
// 注意这个重载的默认 port=0,所以实际绑定的是「任意空闲临时端口」,
// 而不是 9002。这意味着:设备必须「原路回复到发包的源端口」才能被收到。
// 好处是不跟别的软件抢 9002;坏处是不能接收设备主动上报(那需要 bind(...,kPort))。
if (!rx_.bind(QHostAddress::AnyIPv4)) { emit finished(0); return; }
found_.clear(); // 清空上一轮的结果,防止累积旧设备
// ★ readyRead 是"有数据可读"信号,可能多次触发(每批包一次)。
// Qt::UniqueConnection 保证重复点击"搜索"时不会叠加多个槽连接,
// 否则同一个包会被处理 N 次、deviceFound 会重复发射 N 次。
connect(&rx_, &QUdpSocket::readyRead, this, &DeviceDiscoverer::onReadyRead,
Qt::UniqueConnection);
const QByteArray req = "DVR_DISCOVER\n";
// ① 全局受限广播:255.255.255.255 ------ 覆盖默认路由所在网段
rx_.writeDatagram(req, QHostAddress::Broadcast, kPort);
// ② 逐网卡定向广播:例如 192.168.10.255 ------ 覆盖多网卡/多网段的情况
for (const QNetworkInterface& ifa : QNetworkInterface::allInterfaces()) {
const auto fl = ifa.flags();
// 跳过不支持广播的(如 PPP/点对点)、跳过回环 lo
if (!(fl & QNetworkInterface::CanBroadcast) || (fl & QNetworkInterface::IsLoopBack))
continue;
for (const QNetworkAddressEntry& e : ifa.addressEntries()) {
if (e.ip().protocol() != QAbstractSocket::IPv4Protocol) continue; // 忽略 IPv6
if (!e.broadcast().isNull())
rx_.writeDatagram(req, e.broadcast(), kPort); // 子网定向广播
}
}
// ①+② 双管齐下:很多系统/路由器会丢弃 255.255.255.255,
// 而多网卡机器只发全局广播又只能到达一个网段,所以两者都要发。
// ★ 超时收尾:时间到就关 socket(停止收包)并汇报找到几个
QTimer::singleShot(timeoutMs, this, [this]{
rx_.close(); // 关闭后 readyRead 不会再触发
emit finished(int(found_.size())); // count == 0 表示一个都没找到
});
// 注意:定时器以 this 为父对象,超时时自动销毁,无内存泄漏。
}
signals:
void deviceFound(const QJsonObject& info); // 每发现一台新设备立即发出(流式)
void finished(int count); // 本轮探测彻底结束时发出(批量汇总)
private slots:
void onReadyRead() {
// ★ while(hasPendingDatagrams()):一次 readyRead 可能对应多个待取包,
// 必须循环读完,否则会漏掉同一批里的其他设备回包。
while (rx_.hasPendingDatagrams()) {
QByteArray d; d.resize(int(rx_.pendingDatagramSize())); // 先问大小再分配缓冲
QHostAddress from; quint16 port;
rx_.readDatagram(d.data(), d.size(), &from, &port); // 取出数据 + 发送方地址
const auto doc = QJsonDocument::fromJson(d); // 宽松解析:非法 JSON 直接丢弃
if (!doc.isObject() || !doc.object().contains("dvr")) continue; // 不是设备回包,忽略
QJsonObject o = doc.object();
QString ip = from.toString();
// ★ 兼容 Qt 的 IPv4 映射 IPv6 表示法:回包源地址可能长成 ::ffff:192.168.10.149
if (ip.startsWith("::ffff:", Qt::CaseInsensitive))
ip = ip.mid(7); // 去掉前缀,剩纯 IPv4 字符串
o["ip"] = ip; // ★★★ 安全关键:IP 取自网络层源地址,绝不取自 JSON 内容
// (否则恶意/错误载荷可伪造 IP,把后续连接引到别处)
if (!found_.contains(o["ip"].toString())) {
found_.insert(o["ip"].toString()); // 同 IP 只报一次,防重复弹窗/重复列表项
emit deviceFound(o);
}
}
}
private:
QUdpSocket rx_; // 唯一 socket,既发也收(UDP 无连接,收发可共用一个)
QSet<QString> found_; // 已上报过的 IP 集合,生命周期 = 一次 discover()
};
#endif // DEVICEDISCOVERER_H
1.2 安卓APP客户端实现
主函数main()中New UDP应答器对象(DiscoverResponder)启动的服务,DiscoverResponder类负责,以下概述关键特性:
- bind:绑定UDP 9002端口,接收PC段发送的广播包
- QJsonDocument:组装返回字符串{"dvr":1,"name":"行车记录仪","proto":9001,"rtsp":8554}
- QUdpSocket::readyRead:缓冲区有可读的UDP包
- hasPendingDatagrams():用于判断是否存在数据报,UDP传输的是数据报(datagram)
- readDatagram():读取数据报(datagram)
- writeDatagram:写入数据报(datagram),内容是上面组装好的JSON语句(见截图)
另外,还需要APP端放行广播包,需要 Android 应用 特性支持,AndroidManifest.xml
AndroidManifest.xml 是 Android 应用的"身份证 + 声明清单"------它在应用被安装时告诉系统:这个 App 叫什么、包名是什么、有几个界面(Activity)、需要哪些权限、能处理哪些类型的文件/链接、横屏还是竖屏、最低兼容哪个安卓版本。系统在启动你的 App 之前必须先读它,凡是没有在里面声明的能力和权限,运行时一律拿不到。
关键特性内容如下,完整的代码在后面有
<!-- UDP发现(:9002)------WiFi状态查询 + 接收多播/广播包 -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />

图1.2.1 返回UDP应答数据包
**注:**本篇提供网络抓包的目的是辅助检测手段,可以辅助判断是否有真实网络包、网络包中的内容是否是预期发送的内容,特别是用二进制方式节省传输内容时,抓包比对是比较常用的手段。
核心代码
new DiscoverResponder("行车记录仪", 9001, 8554, &app);
#pragma once
// =====================================================================
// UDP发现应答器 ------ 与PC端DeviceDiscoverer.h协议对齐:
// 收 "DVR_DISCOVER"(:9002) → 向【收包源IP:源端口】原路单播回JSON
// {"dvr":1,"name":"行车记录仪","proto":9001,"rtsp":8554}
// 纯被动、随APP常驻:与录像状态无关,IDLE时也要能被搜到。
// 刻意不带Q_OBJECT:lambda接收QUdpSocket信号即可,纯头文件,工程零配置。
// =====================================================================
#include <QUdpSocket>
#include <QJsonObject>
#include <QJsonDocument>
#include <QHostAddress>
class DiscoverResponder : public QObject {
public:
static constexpr quint16 kPort = 9002; // ★与PC端一致;不冲突9000/9001/8554
DiscoverResponder(const QString& name, quint16 protoPort, quint16 rtspPort,
QObject* parent = nullptr) : QObject(parent) {
if (!udp_.bind(QHostAddress::AnyIPv4, kPort, QUdpSocket::ReuseAddressHint)) {
qWarning() << "[discover] UDP" << kPort << "bind失败:" << udp_.errorString();
return;
}
reply_ = QJsonDocument(QJsonObject{ // 应答体固定 → 预生成
{"dvr", 1}, {"name", name}, {"proto", protoPort}, {"rtsp", rtspPort}
}).toJson(QJsonDocument::Compact);
QObject::connect(&udp_, &QUdpSocket::readyRead, this, [this]{
while (udp_.hasPendingDatagrams()) {
QByteArray d; d.resize(int(udp_.pendingDatagramSize()));
QHostAddress from; quint16 port;
udp_.readDatagram(d.data(), d.size(), &from, &port);
if (QString::fromUtf8(d).trimmed() != "DVR_DISCOVER") continue;
const qint64 n = udp_.writeDatagram(reply_, from, port);
if (n < 0) qWarning() << "[discover]" << from.toString() << port << "发送失败:" << udp_.errorString();
else qWarning() << "[discover]" << from.toString() << port << "已回" << n << "字节";
}
});
qWarning() << "[discover] 应答器就绪 :9002";
}
private:
QUdpSocket udp_;
QByteArray reply_;
};
<?xml version="1.0"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="1"
android:versionName="1.0"
android:installLocation="auto">
<!-- Qt模块权限注入点,勿删(官方要求保留,缺失会导致Qt模块无法注入所需权限) -->
<!-- %%INSERT_PERMISSIONS -->
<!-- %%INSERT_FEATURES -->
<!-- 行车记录仪:相机 + 网络(INTERNET为安装时权限,无需运行时弹窗) -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- UDP发现(:9002)------WiFi状态查询 + 接收多播/广播包 -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<application
android:name="org.qtproject.qt.android.bindings.QtApplication"
android:label="行车记录仪"
android:icon="@mipmap/ic_launcher"
android:hardwareAccelerated="true">
<activity
android:name="org.qtproject.qt.android.bindings.QtActivity"
android:label="行车记录仪"
android:launchMode="singleTop"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|uiMode|screenLayout|screenSize|smallestScreenSize|layoutDirection|locale|fontScale|keyboard|keyboardHidden|navigation|mcc|mnc|density"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- 强制要求:加载哪个so库,必须等于CMake里qt_add_executable的目标名 -->
<meta-data
android:name="android.app.lib_name"
android:value="Dvrqt" />
</activity>
</application>
</manifest>
2、TCP命令交互及录像功能
此功能的设计目的:
1、APP端除了按钮触发事件之外,也可以通过私有协议命令方式进行触发,可以快速适配真实场景中的信号触发方式;
2、私有协议命令支持TCP方式调用,可以实现非设备端触发的场景,即本篇中的PC控制端,当然也支持PC端产测类的上位机要求。
| 功能 | 用途 | 代码文件/类名称 |
| TCP 命令交互 (PC端) | 下发控制指令,提供: STATUS 查看状态 =》【连接】时,下发一次 PING 心跳 =》 每隔30秒,自动发一次 ACC_ON 开启录像 =》点击【开始录像】(ACC_ON) ACC_OFF 关闭录像 =》点击**【停止】(ACC_OFF)** LOCK 手动锁档 =》点击**【锁定当前段】** | MainWindow DeviceClient |
| TCP 命令交互(APP端) | 接收PC控制端下发指令,监听9001端口,提供: STATUS 查看状态 PING 心跳 ACC_ON 开启录像 ACC_OFF 关闭录像 LOCK 锁定当前段 | DvrProtoServer |
| ACC 状态机 (APP端) | 模拟点火/熄火 触发实时菜单的**【开始录像】【停止录像】按钮功能 | RecordingEngine MediaLibrary VideoEncoder CameraController |
| 手动锁档 (APP端) | 触发实时菜单的【锁定当前段】按钮功能 | RecordingEngine MediaLibrary |
| 分段录像 (APP端) | 每60s生成一段日常视频**录像(视频循环覆盖) | SegmentMuxerManager RecordingEngine |
| RTSP 服务端(APP端) | 8554端口 输出视频流,PC端拉流显示 | RtspServer |
|---|
2.1 PC控制端实现
在主窗口提供了一个**【连接】** 按钮,在控制/回放菜单中提供了**【开始录像】(ACC_ON)、【停止】(ACC_OFF)及【锁定当前段】**三个按钮生成下发到APP端的TCP交互命令。
**【连接】**按钮
调用DeviceClient类的connectTo发起连接,在DeviceClient类的构造函数中设置了4个关键定时器:自动重连定时器、心跳定时器、连接成功自动发送CMD_STATUS定时器、socket缓存可读定时器,以下概述关键特性:
DeviceClient::connectTo
connectToHost:发起一次 TCP 连接,由connected / errorOccurred / disconnected 这几个信号处理后续的交互(这几个信号槽机制写在构造函数中),这是QT的异步事件处理逻辑。
DeviceClient::DeviceClient
- reconnectTimer_:自动重连定时器
- heartTimer_:心跳定时器,向APP端发送CMD_PING帧(0x01)(见图2.1.1)
- QTcpSocket::connected:连接成功响应=>自动发送CMD_STATUS定时器
- QTcpSocket::disconnected:断开连接响应
- sendFrame:发送CMD_STATUS帧到APP端(0x05)(见图2.1.2)
- QTcpSocket::readyRead:socket缓存可读响应
- QAbstractSocket::errorOccurred:socket出错响应

图2.1.1CMD_PING帧
帧 = "DV" + ver(0x01) + type + len(2B大端) + seq(1B) + payload + CRC16(2B大端)
图片格式解读:
| 字段 | 协议定义 | 抓包字节 | 含义 |
|---|---|---|---|
| 起始标志 | "DV" |
44 56 |
ASCII 字符 'D' = 0x44、'V' = 0x56 |
| 版本 | ver(0x01) |
01 |
协议版本 1 |
| 类型 | type |
01 |
CMD_PING类型 = 0x01 |
| 载荷长度 | len(2B大端) |
00 00 |
大端解析 = 0 字节 ,说明此帧没有 payload |
| 序列号 | seq(1B) |
02 |
这是本连接的第 2 号帧 |
| 载荷 | payload |
(无) | 长度为 0,所以没有数据 |
| 校验和 | CRC16(2B大端) |
ed ab |
对前面 7 字节算出的 CRC16值 |

图2.1.2 CMD_STATUS帧
帧 = "DV" + ver(0x01) + type + len(2B大端) + seq(1B) + payload + CRC16(2B大端)
图片格式解读:
| 字段 | 协议定义 | 抓包字节 | 含义 |
|---|---|---|---|
| 起始标志 | "DV" |
44 56 |
ASCII 字符 'D' = 0x44、'V' = 0x56 |
| 版本 | ver(0x01) |
01 |
协议版本 1 |
| 类型 | type |
05 |
CMD_STATUS类型 = 0x05 |
| 载荷长度 | len(2B大端) |
00 00 |
大端解析 = 0 字节 ,说明此帧没有 payload |
| 序列号 | seq(1B) |
01 |
这是本连接的第 1 号帧 |
| 载荷 | payload |
(无) | 长度为 0,所以没有数据 |
| 校验和 | CRC16(2B大端) |
17 39 |
对前面 7 字节算出的 CRC16值 |
核心代码
void MainWindow::wireSignals() {
// ---- 连接管理 ----
connect(btnConnect_, &QPushButton::clicked, this, [this]{
const QString ip = currentIp();
if (ip.isEmpty()) { appendLog("请先填写设备IP", "err"); return; }
rememberIp(ip);
appendLog(QString("[连接] %1:%2 ...").arg(ip).arg(portProto_->value()));
dev_->connectTo(ip, quint16(portProto_->value()));
});
connect(btnDisconnect_, &QPushButton::clicked, this, [this]{
dev_->disconnectFromDevice();
appendLog("[手动断开]", "warn");
});
connect(dev_, &DeviceClient::connected, this, [this]{
appendLog("[已连接] 等待车机问候帧...", "ok");
setConnected(true);
});
connect(dev_, &DeviceClient::disconnected, this, [this]{
if (dev_->willReconnect()) appendLog("[断开] 将自动重连(手动断开除外)", "warn");
else appendLog("[断开](手动,不重连)", "warn");
setConnected(false);
if (video_->isVisible()) applyRecordState(false, "连接断开");
});
}
#pragma once
#include <QTcpSocket>
#include <QTimer>
#include <QJsonObject>
#include <QJsonDocument>
// =====================================================================
// 设备协议交互层
// 作用:与车载/终端设备建立 TCP 长连接,按自定义二进制协议【主动下发命令】
// 并【被动解析设备回帧】,把每一帧解出的 JSON 载荷以信号形式传给 UI。
// 帧结构(大端序):
// 帧 = "DV" + ver(0x01) + type + len(2B大端) + seq(1B) + payload + CRC16(2B大端)
// 即:魔数2 + 头部(ver+type+len2+seq=5) + 载荷len + CRC2 => 总长 = 9 + len
// CRC = CCITT-FALSE算法(init=0xFFFF, poly=0x1021, 不反射, xor=0),范围=ver起至载荷末
// 上行帧:PING=0x01 ACC_ON=0x02 ACC_OFF=0x03 LOCK=0x04 STATUS=0x05 G=0x06
// 下行帧:EVT_STATE=0x81 EVT_SEGMENT=0x82 EVT_ERROR=0x83 EVT_ALARM=0x84 ACK=0xE0 NAK=0xE1
// 运行行为:
// 1) seq 序号从 1 开始自增(按字节回绕);
// 2) 每次连接建立后自动发一帧 STATUS 拉取一次设备状态;
// 3) 连接存续期间每 10 秒发一帧 PING 维持心跳;
// 4) 接收侧用"滑动找魔数 + 按长度截帧 + CRC 校验"组帧,CRC 不符整帧丢弃。
// =====================================================================
class DeviceClient : public QObject {
Q_OBJECT
public:
//向APP发送的协议命令 / 设备回帧的事件类型(type 字节的全集)
// 0x01~0x06 为 APP 主动下发的命令;0x81 以上高位为设备上报的事件;0xE0/0xE1 为应答
enum Type : quint8 {
CMD_PING = 0x01, CMD_ACC_ON = 0x02, CMD_ACC_OFF = 0x03,
CMD_LOCK = 0x04, CMD_STATUS = 0x05, CMD_G = 0x06,
EVT_STATE = 0x81, EVT_SEGMENT = 0x82, EVT_ERROR = 0x83, EVT_ALARM = 0x84,
ACK = 0xE0, NAK = 0xE1
};
//APP端返回的信息:二进制转字符串
static QString typeName(quint8 t) {
switch (t) {
case CMD_PING: return "PING"; case CMD_ACC_ON: return "ACC_ON";
case CMD_ACC_OFF: return "ACC_OFF"; case CMD_LOCK: return "LOCK";
case CMD_STATUS: return "STATUS"; case CMD_G: return "G";
case EVT_STATE: return "EVT_STATE"; case EVT_SEGMENT: return "EVT_SEGMENT";
case EVT_ERROR: return "EVT_ERROR"; case EVT_ALARM: return "EVT_ALARM";
case ACK: return "ACK"; case NAK: return "NAK";
default: return QString("0x%1").arg(t, 2, 16, QChar('0'));
}
}
explicit DeviceClient(QObject* parent = nullptr) : QObject(parent) {
// ------ 自动重连定时器:单次触发(2 秒) ------
// 断线/出错后启动,到点尝试一次重连;连上后在 connected 槽里 stop。
reconnectTimer_.setInterval(2000);
reconnectTimer_.setSingleShot(true);
connect(&reconnectTimer_, &QTimer::timeout, this, &DeviceClient::tryReconnect);
//心跳定时器:连接存续期间每 10 秒触发一次,发 PING 保活
heartTimer_.setInterval(10000);
connect(&heartTimer_, &QTimer::timeout, this, [this]{
if (isConnected()) sendFrame(CMD_PING);//10秒一次心跳
});
// ------ socket 连上:复位会话状态并拉起会话级动作 ------
connect(&sock_, &QTcpSocket::connected, this, [this]{
reconnectTimer_.stop();
buf_.clear();
seq_ = 0;
heartTimer_.start();//启动心跳定时器
emit connected();// 发送"已连接"事件到主框架
sendFrame(CMD_STATUS);//连接之后,获取一次状态
});
// ------ socket 断开:停心跳并(在期望保持连接时)安排重连 ------
connect(&sock_, &QTcpSocket::disconnected, this, [this]{
heartTimer_.stop();//关闭心跳定时器
emit disconnected();// 发送"断开连接"事件到主框架
if (!host_.isEmpty()) reconnectTimer_.start();
});
// ------ socket 出错:与断开同处理,安排重连 ------
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
connect(&sock_, &QAbstractSocket::errorOccurred, this, [this](QAbstractSocket::SocketError){
if (!host_.isEmpty()) reconnectTimer_.start(); });
#else
connect(&sock_, qOverload<QAbstractSocket::SocketError>(&QAbstractSocket::error),
this, [this](QAbstractSocket::SocketError){ if (!host_.isEmpty()) reconnectTimer_.start(); });
#endif
// ------ socket 有可读数据:累加到缓冲区后尝试组帧解析 ------
connect(&sock_, &QTcpSocket::readyRead, this, &DeviceClient::onReadyRead);
}
// 发起连接:记录目标地址端口并立即尝试连接(host_ 非空 = 进入"期望保持连接"状态)
void connectTo(const QString& host, quint16 port) {
host_ = host;
port_ = port;
tryReconnect();
}
// 主动断开:清空 host_(使重连逻辑失效)并中止套接字,避免断开后被自动重连
void disconnectFromDevice() {
host_.clear();
sock_.abort();
}
// 是否处于"期望保持连接"状态(host_ 非空即会自动重连)
bool willReconnect() const { return !host_.isEmpty(); }
// 当前链路是否真正处于已连接状态
bool isConnected() const { return sock_.state() == QAbstractSocket::ConnectedState; }
// 按协议把 type + 可选 JSON 载荷组装成完整帧并发送。
// 组装顺序:body = ver + type + len(2B大端) + seq + payload,
// 对 body 求 CRC16,再 frame = "DV" + body + CRC(2B大端)。
void sendFrame(quint8 type, const QJsonObject& payload = {}) {
if (!isConnected()) return;
QByteArray pay;
if (!payload.isEmpty())
pay = QJsonDocument(payload).toJson(QJsonDocument::Compact);
const quint8 s = ++seq_; // seq 从 1 自增
QByteArray body;
body += char(0x01); // ver = 0x01
body += char(type); // type 命令/事件字节
body += char((pay.size() >> 8) & 0xFF);// len 高字节(大端)
body += char(pay.size() & 0xFF);// len 低字节
body += char(s); // seq
body += pay; // payload(可为空)
const quint16 c = crc16(body); // CRC 计算范围 = body(ver..载荷末)
QByteArray frame;
frame += "DV";
frame += body;
frame += char((c >> 8) & 0xFF); // CRC 高字节(大端)
frame += char(c & 0xFF); // CRC 低字节
sock_.write(frame); sock_.flush();// 写入缓存并立即刷新,降低时延
}
// 这是面向 UI 的"字符串命令"入口,内部翻译为 sendFrame(type, payload),主要由MainWindow调用
void sendCommand(const QString& cmd, const QString& arg = {}) {
quint8 t = 0; QJsonObject o;
if (cmd == "PING") t = CMD_PING;
else if (cmd == "ACC_ON") t = CMD_ACC_ON;
else if (cmd == "ACC_OFF") t = CMD_ACC_OFF;
else if (cmd == "LOCK") t = CMD_LOCK;
else if (cmd == "STATUS") t = CMD_STATUS;
else if (cmd == "G") { t = CMD_G; o["g"] = arg.toDouble(); }
else { emit crcLearned("未知命令名:" + cmd); return; }
sendFrame(t, o);
}
signals:
void connected();
void disconnected();
void eventReceived(const QJsonObject& o); // 载荷JSON + 注入 _t/_seq/_tname
void rawLineReceived(const QByteArray& line); // CRC错误等异常留痕
void crcLearned(const QString& desc); // 提示通道
private slots:
// 尝试重连:仅在尚未连接时 abort 残留状态并重新发起 connectToHost
void tryReconnect() {
if (sock_.state() != QAbstractSocket::ConnectedState) {
sock_.abort(); sock_.connectToHost(host_, port_);
}
}
// 可读数据到达:把新字节追加进缓冲 buf_,再交给 processBuffer 组帧
// (TCP 是字节流,一帧可能被拆包/粘包,必须先缓冲再解析)
void onReadyRead() { buf_ += sock_.readAll(); processBuffer(); }
private:
// ---- CRC16-CCITT-FALSE ----
static quint16 crc16(const QByteArray& d) {
quint16 crc = 0xFFFF;
for (char b : d) {
crc = quint16(crc ^ quint16(quint8(b) << 8));
for (int i = 0; i < 8; ++i)
crc = (crc & 0x8000) ? quint16(quint16(crc << 1) ^ 0x1021) : quint16(crc << 1);
}
return crc;
}
// ---- 接收解析----
// 在字节流缓冲上循环提取完整帧:
// 1) 滑动窗口丢弃"非 DV 魔数"前导字节,对齐帧头;
// 2) 头不足 7 字节则等待续包;按 len 字段算出整帧总长 total=9+len;
// 3) 总长不足=半帧,等待续包;len>4096 视为脏数据,滑过 1 字节继续找魔数;
// 4) 对 ver..载荷末这段 body 校验 CRC,不符则丢整帧并留痕;
// 5) 取出 type/seq/载荷,解析载荷 JSON,注入元信息后上抛 eventReceived。
void processBuffer() {
while (true) {
// 对齐帧头:只要开头两字节不是 'D''V',就丢弃首字节继续右滑
while (buf_.size() >= 2 && !(buf_.at(0) == 'D' && buf_.at(1) == 'V'))
buf_.remove(0, 1); // 滑过非魔数字节
// 连基本头(2+ver+type+len2+seq)都不够→等续包
if (buf_.size() < 7) return;
const int ln = (quint8(buf_.at(4)) << 8) | quint8(buf_.at(5));// 读 len(大端,buf[4..5])
// 异常长度→判为脏数据,滑 1 字节重找魔数
if (ln > 4096) { buf_.remove(0, 1); continue; }
const int total = 9 + ln;// 整帧总长 = 魔数2 + 头5 + 载荷len + CRC2
if (buf_.size() < total) return; // 半帧→等续包
const QByteArray body = buf_.mid(2, 5 + ln); // ver..载荷 = buf[2:7+ln]
const quint16 wc = quint16((quint8(buf_.at(7 + ln)) << 8) | quint8(buf_.at(8 + ln)));
if (crc16(body) != wc) { // CRC错→整帧丢弃(同Python)
emit rawLineReceived("[CRC错误,丢帧] " + buf_.left(total).toHex());
buf_.remove(0, total);// 本帧消费完毕,移出缓冲
continue;
}
const quint8 type = quint8(buf_.at(3));// type = buf[3]
const quint8 seq = quint8(buf_.at(6));// type = buf[6]
const QByteArray pay = buf_.mid(7, ln);// 载荷 = buf[7 .. 7+ln)
buf_.remove(0, total);// 本帧消费完毕,移出缓冲
QJsonObject o;
if (ln > 0) {
QJsonParseError e{};
o = QJsonDocument::fromJson(pay, &e).object();
if (e.error != QJsonParseError::NoError) { // 载荷非法 JSON→丢弃并留痕(继续找下一帧)
emit rawLineReceived("[载荷非JSON] " + pay.toHex());
continue;
}
}
// 注入帧元信息,便于 UI 不必解析原始帧即可识别类型/序号
o["_t"] = type; o["_seq"] = seq; o["_tname"] = typeName(type);
emit eventReceived(o);
}
}
QTcpSocket sock_;
QTimer reconnectTimer_, heartTimer_;
QByteArray buf_;// 接收粘包/拆包缓冲(字节流组帧用)
QString host_; quint16 port_ = 0;// 目标地址端口;host_ 非空=期望保持连接
quint8 seq_ = 0;// 发送序号,发帧前 ++ 后使用(从 1 起)
};
【开始录像】(ACC_ON)/【停止】(ACC_OFF)
调用DeviceClient类的sendCommand("ACC_ON")发送命令到APP端,并且调用主框架的applyRecordState处理开始/停止录像功能,以VideoWorker 解码线程 → FrameHub → YuvGLWidget 流程完成拉帧,以下概述关键特性:
DeviceClient::sendCommand
把字符串"ACC_ON"转换成协议指令CMD_ACC_ON,再调用sendFrame直接发送到APP端。
MainWindow::applyRecordState
监测RTSP拉流涉及(VideoWorker类)视频拉流和(YuvGLWidget类)视频显示功能。
VideoWorker 解码线程
-
run:当前线程重载函数,循环检测重连机制
-
startStream:记录 URL 并拉起线程,已在跑就直接返回,避免起两个线程
-
sessionOnce:一次完整 RTSP 会话。打开流→找视频轨→建解码器→循环读包解码发布,调用了ffmpeg (libav*)库,拉 RTSP、解码 H.264 成 YUV 并送入 FrameHub。
┌─────────────────────────────────────────────────────────────────┐ │ 线程 A:VideoWorker::run() (QThread 后台,无事件循环) │ │ ↓ ffmpeg 解包/解码 │ │ ↓ hub_.publish(frame) 【写 latest_ + seq_++】 │ ├──────────────────────────────────────────────────────────────────┤ │ 共享:FrameHub { mutable QMutex m_; shared_ptr<VideoFrame> │ │ latest_; quint64 seq_; } ← 唯一临界区 │ ├──────────────────────────────────────────────────────────────────┤ │ 线程 B:YuvGLWidget (GUI 主线程,有事件循环) │ │ pullTimer_(33ms) → hub_->grab(lastSeq) 【读 seq_ 比对】 │ │ ↓ pending_ = frame → update() → paintGL() → 上屏 │ └──────────────────────────────────────────────────────────────────┘
以上用的继承QThread并重载run()的方式,QT的其他多线程方式可以参阅以下传送门
| 阶段 | 关键 ffmpeg 调用 | 作用 |
|---|---|---|
| 一、打开输入流(解封装层 avformat) | av_dict_set×4 → avformat_alloc_context → avformat_open_input → avformat_find_stream_info → av_find_best_stream |
配低延迟选项、建 RTSP 连接、解析流参数、挑视频轨 |
| 二、建立解码器(avcodec) | avcodec_find_decoder → avcodec_alloc_context3 → avcodec_parameters_to_context → avcodec_open2 |
按 codec_id 找 H.264 解码器、灌参数、开解码器(LOW_DELAY + 单线程) |
| 三、缓冲与计时初始化 | av_packet_alloc / av_frame_alloc / av_gettime |
分配压缩包/解码帧,初始化统计与看门狗基准 |
| 四、读包-解码-发布主循环 | av_read_frame → avcodec_send_packet → avcodec_receive_frame(while) → hub_.publish → av_packet_unref |
读包→喂包→取帧→拷贝 YUV 发布→释放包,循环往复 |
| 五、收尾释放(逆序) | av_frame_free → av_packet_free → avcodec_free_context → avformat_close_input |
严格按分配逆序释放,避免句柄泄漏 |
FrameHub
FrameHub 是这套"RTSP 拉流 → 解码 → 显示"链路里夹在中间的一个【帧中转站 / 信箱】。即"单生产者 + 单消费者"的容量=1 信箱 ------解码线程 VideoWorker 出帧后 publish 进来、YuvGLWidget 的 33ms 定时器 grab 出去;缓冲区只保留"最新一帧",新帧直接覆盖旧帧(= 丢帧),因此显示延迟上界恒定 = 1 帧 ,与两侧快慢无关,是 ffplay framedrop 的手写等价物。
YuvGLWidget
用 OpenGL 把 YUV420 三个平面(Y/U/V)上传为 3 张纹理,再在片元着色器里做 YUV->RGB 颜色空间转换,实现 GPU 直渲,避免 CPU 端转 RGB 的开销,是低延迟播放器显示端的核心。
| 阶段 | 触发时机 | 核心 OpenGL/Qt 调用 |
|---|---|---|
| 构造函数 | 构造一次 | pullTimer_ 33ms + connect(timeout) + start()(无 GL 调用) |
initializeGL() |
GL 上下文就绪后仅一次 | initializeOpenGLFunctions → addShaderFromSourceCode(VSH/FSH) → link → vbo_.allocate → glGenTextures(3) |
| 拉帧定时器 | 每 33ms | hub_->grab → pending_ → update()(丢帧背压) |
paintGL() |
每次重绘 | glClear → upload×3(glTexImage2D) → glViewport → prog_.bind/setUniformValue/setAttributeBuffer/glActiveTexture → glDrawArrays(TRIANGLE_STRIP,0,4) |
| 片元着色器 | 每像素(GPU) | texture2D + BT.601 做 YUV→RGB 上屏 |
核心代码
void MainWindow::wireSignals() {
// ---- ACC控制:乐观UI + 3秒无应答提示 ----
connect(btnRecOn_, &QPushButton::clicked, this, [this]{
if (!dev_->isConnected()) { appendLog("未连接", "err"); btnRecOn_->setChecked(false); return; }
dev_->sendCommand("ACC_ON");
appendLog(">> ACC_ON", "tx");
cmdAcked_ = false;
QTimer::singleShot(3000, this, [this]{ if (!cmdAcked_)
appendLog("⚠ ACC_ON 3秒无应答------发送格式可能不符(需文档/抓包)", "err"); });
applyRecordState(true, "本地");
});
connect(btnRecOff_, &QPushButton::clicked, this, [this]{
if (!dev_->isConnected()) { appendLog("未连接", "err"); btnRecOff_->setChecked(false); return; }
dev_->sendCommand("ACC_OFF");
appendLog(">> ACC_OFF", "tx");
cmdAcked_ = false;
QTimer::singleShot(3000, this, [this]{ if (!cmdAcked_)
appendLog("⚠ ACC_OFF 3秒无应答------发送格式可能不符(需文档/抓包)", "err"); });
applyRecordState(false, "本地");
});
connect(btnLock_, &QPushButton::clicked, this, [this]{
if (!dev_->isConnected()) { appendLog("未连接", "err"); return; }
dev_->sendCommand("LOCK"); // 原"LCK"改为命令表实测值
appendLog(">> LOCK", "tx");
cmdAcked_ = false;
QTimer::singleShot(3000, this, [this]{ if (!cmdAcked_)
appendLog("⚠ LCK 3秒无应答------发送格式可能不符(需文档/抓包)", "err"); });
});
// ---- 解码线程 → 状态栏 ----
connect(worker_, &VideoWorker::stats, this, [this](double fps, double kbps){
lblVideo_->setText(QString("%1 fps | %2 kbps").arg(fps, 0, 'f', 1).arg(kbps, 0, 'f', 0));
});
connect(worker_, &VideoWorker::errorOccurred, this, [this](const QString& msg){
appendLog("[流错误] " + msg, "err");
});
}
// ============================ 录像状态:单一真相源 ============================
void MainWindow::applyRecordState(bool on, const QString& src) {
if (on) {
const QString url = QString("rtsp://%1:%2/live").arg(currentIp()).arg(portRtsp_->value());
if (worker_->isRunning()) worker_->stopStream(); // 已在跑就别让GUI白等3秒
worker_->startStream(url);
video_->setVisible(true);
lblVideo_->setText("连接中...");
appendLog(QString("[%1] ACC_ON → 拉流 %2").arg(src, url), "ok");
} else {
worker_->stopStream();
video_->setVisible(false);
lblVideo_->setText("无视频流");
appendLog(QString("[%1] ACC_OFF → 视频关闭").arg(src), "warn");
}
btnRecOn_->setChecked(on); // ★按钮开/关效果与视频状态同步
btnRecOff_->setChecked(!on);
}
//VideoWorker.h
// 本类是"后台解码线程":在独立 QThread 里用 ffmpeg 拉 RTSP、解码成 YUV,
// 再把帧 publish 进 FrameHub;显示端(YuvGLWidget)从 hub 取帧绘制。
// GUI 线程与解码线程之间"开/停"的控制靠原子标志 running_ 跨线程通信。
#pragma once
#include <QThread>
#include <QAtomicInt>
#include <memory> // std::shared_ptr(间接被 VideoFrame/FrameHub 使用)
#include <QByteArray>
#include "VideoFrame.h" // 一帧 YUV 的数据结构(y/u/v、w/h、各 stride)
#include "FrameHub.h" // 帧中转枢纽:本线程 publish,显示线程 grab
// VideoWorker:拉流+解码线程。对外只暴露"开流/停流/查询是否在跑/取枢纽"四件事。
class VideoWorker : public QThread {
Q_OBJECT // 启用元对象:本类要 emit stats / errorOccurred 信号
public:
explicit VideoWorker(QObject* parent = nullptr) : QThread(parent) {}
// 开流:记录 URL 并拉起线程,已在跑就直接返回,避免起两个线程。
void startStream(const QString& url) {
if (running_.loadAcquire()) return; // 已运行:忽略本次(换流需先 stopStream)
url_ = url; // 保存目标 RTSP 地址,供 run() 使用
running_.storeRelease(1); // 置运行标志=1(release 语义,解码线程可见)
start(); // QThread::start ------ 真正创建线程并进入 run()
}
// 停流:置标志=0 通知解码循环退出,再 join 等它真正结束(最多 3 秒)。
// 注意 wait(3000) 会阻塞"调用它的线程"(通常是 GUI),故上层用 isRunning() 预判。
void stopStream() { running_.storeRelease(0); wait(3000); }
bool isRunning() const { return running_.loadAcquire(); } // ★新增:避免GUI线程白等3秒
// 廉价的原子读:用来决定"要不要付 stopStream 里那个 wait 的阻塞成本"。
FrameHub* hub() { return &hub_; } // 交帧枢纽指针,MainWindow 用它 attach 显示控件
signals:
void stats(double fps, double kbps); // 每秒一次:帧率 + 码率(kbps),供界面显示
void errorOccurred(const QString& msg); // 出错/重连提示,转发到日志区
protected:
void run() override; // 外层:自愈重连循环(反复调 sessionOnce,断流自动重开)
private:
void sessionOnce(); // 内层:一次完整RTSP会话(原run的内容:打开流→解码→发布)
QString url_; // 当前拉流的 RTSP 地址
QAtomicInt running_{0}; // 运行标志:0=停/1=跑,跨线程的唯一通信变量
FrameHub hub_; // 帧枢纽(本对象拥有):解码线程 publish,显示线程 grab
};
//VideoWorker.cpp
// 本文件:在解码线程里用 ffmpeg (libav*) 拉 RTSP、解码 H.264 成 YUV 并送入 FrameHub。
#include "VideoWorker.h"
extern "C" {
#include <libavformat/avformat.h> // 解封装:打开流、读包(网络层)
#include <libavcodec/avcodec.h> // 解码:H.264 比特流 -> 原始帧
#include <libavutil/imgutils.h> // 图像/平面尺寸工具(此处间接)
#include <libavutil/time.h> // av_gettime / av_usleep:高精度时间与睡眠
}
// run():线程主体------"自愈重连"外层循环。
// 一次 RTSP 会话失败或长时间无帧,sessionOnce 返回后自动重开,直到 running_ 被置 0。
void VideoWorker::run() {
while (running_.loadAcquire()) { // 只要没被停止,就一直保持有会话在跑
sessionOnce(); // 跑一次完整会话(内部含解码主循环)
if (!running_.loadAcquire()) break; // 用户已停止→正常退出
emit errorOccurred("5秒未解出画面 → 重连RTSP(对齐SPS/PPS+IDR)..."); // 告知上层
av_usleep(800000); // 0.8s退避再重连(避免疯狂重连占满CPU)
}
}
// sessionOnce():一次完整 RTSP 会话。打开流→找视频轨→建解码器→循环读包解码发布。
// 遇到"长期 0 帧"就 return,交给外层 run() 重开,从而触发一次全新的 SPS/PPS+IDR 对齐。
void VideoWorker::sessionOnce() {
// ---- 打开流(低延迟参数与上版一致)----
AVDictionary* opt = nullptr; // 键值对选项表,传给 avformat_open_input
av_dict_set(&opt, "rtsp_transport", "tcp", 0); // 强制走 TCP(防丢包花屏)
av_dict_set(&opt, "timeout", "3000000", 0); // I/O 超时 3 秒(微秒),防永久阻塞
av_dict_set(&opt, "reordering_queue_size", "0", 0); // 关乱序重排队列,降延迟
av_dict_set(&opt, "max_delay", "0", 0); // 缓冲延迟下限置 0,尽快出帧
AVFormatContext* fmt = avformat_alloc_context(); // 分配解封装上下文
fmt->probesize = 512 * 1024; // 探测码流用的最大字节数(限流降低时延)
fmt->max_analyze_duration = 2 * AV_TIME_BASE; // 探测时长上限 2 秒
int ret = avformat_open_input(&fmt, url_.toUtf8().constData(), nullptr, &opt); // 打开 RTSP
av_dict_free(&opt); // 选项表用完即释放
if (ret < 0) { // 打开失败(连不上/URL 非法等)
char e[AV_ERROR_MAX_STRING_SIZE] = {0};
av_strerror(ret, e, sizeof(e)); // 错误码转可读文字
emit errorOccurred(QString("打开流失败: %1").arg(e));
return; // 交外层重连
}
if (avformat_find_stream_info(fmt, nullptr) < 0) { // 分析各流参数(编码/分辨率等)
emit errorOccurred("find_stream_info失败");
avformat_close_input(&fmt); // 失败要先关闭已打开的输入,避免泄漏
return;
}
// 在多个流里挑出最优视频轨索引(-1=自动选最佳,-1=不指定相关流)
const int vidx = av_find_best_stream(fmt, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
if (vidx < 0) { // 根本没视频轨
emit errorOccurred("流中无视频轨");
avformat_close_input(&fmt);
return;
}
// SDP 里没带出宽度(=编码参数缺失):设备端常见问题,需等码流内嵌 SPS/PPS
if (fmt->streams[vidx]->codecpar->width == 0)
emit errorOccurred("SDP无编码参数(设备端已知项)→ 等带内SPS/PPS+IDR,5秒未出自动重连");
// 依据编码 ID 找解码器并创建解码上下文
const AVCodec* codec = avcodec_find_decoder(fmt->streams[vidx]->codecpar->codec_id);
AVCodecContext* ctx = avcodec_alloc_context3(codec);
avcodec_parameters_to_context(ctx, fmt->streams[vidx]->codecpar); // 把流参数灌进解码器
ctx->flags2 |= AV_CODEC_FLAG_LOW_DELAY; // 低延迟模式:不缓冲等待,尽快出帧
ctx->thread_count = 1; // 单线程解码:避免帧序错乱、降延迟
ctx->thread_type = FF_THREAD_SLICE; // (单线程下基本无效,仅显式声明)
avcodec_open2(ctx, codec, nullptr); // 打开解码器,准备收包
AVPacket* pkt = av_packet_alloc(); // 压缩数据包(H.264 比特流片段)
AVFrame* frm = av_frame_alloc(); // 解码后的原始帧(YUV)
qint64 lastStats = av_gettime(); // 上次统计 fps/kbps 的时刻
qint64 lastFrame = av_gettime(); // ★看门狗基准:最近一次解出帧的时刻
int nFrames = 0; // 本统计周期内解出的帧数
qint64 nBytes = 0; // 本统计周期内收到的视频字节数
bool decodedAny = false; // 本会话是否至少解出过 1 帧(区分"整段 0 帧")
while (running_.loadAcquire()) { // 解码主循环,running_=0 即退出
ret = av_read_frame(fmt, pkt); // 从网络/缓冲读一个包
if (ret < 0) { // 读包失败(网络抖动/暂时没数据)
if (av_gettime() - lastFrame > 5000000) break; // 长期无帧→重开
av_usleep(100000); // 网络抖动稍候重试
continue;
}
if (pkt->stream_index != vidx) { av_packet_unref(pkt); continue; } // 非视频轨:丢弃
nBytes += pkt->size; // 累计码率统计字节(含网络收到的视频负载)
avcodec_send_packet(ctx, pkt); // 把压缩包喂给解码器
// 一次喂包可能吐出多帧(解码器有延迟),循环取空
while (avcodec_receive_frame(ctx, frm) == 0) {
lastFrame = av_gettime(); // ★解出帧→喂狗(刷新看门狗基准)
decodedAny = true; // 标记本会话出过帧
++nFrames; // 帧计数 +1
// 把解码帧的 YUV 三平面拷贝进 VideoFrame(拷贝是必要的:frm 会被复用)
auto f = std::make_shared<VideoFrame>();
f->w = frm->width; f->h = frm->height; // 帧宽高
f->yStride = frm->linesize[0]; // Y 行距(含可能的对齐填充)
f->uStride = frm->linesize[1]; // U 行距
f->vStride = frm->linesize[2]; // V 行距
// 按 linesize*行数 精确拷贝每个平面(Y 全尺寸,U/V 各 1/4:420)
f->y = QByteArray((const char*)frm->data[0], frm->linesize[0] * frm->height);
f->u = QByteArray((const char*)frm->data[1], frm->linesize[1] * frm->height / 2);
f->v = QByteArray((const char*)frm->data[2], frm->linesize[2] * frm->height / 2);
hub_.publish(f); // 发布到枢纽:显示线程下一次 grab 就能拿到
}
av_packet_unref(pkt); // 释放本包引用(循环复用 pkt)
if (av_gettime() - lastFrame > 5000000) break; // ★5秒0帧→重开会话
if (av_gettime() - lastStats >= 1000000) { // 每满 1 秒上报一次统计
emit stats(nFrames, nBytes * 8.0 / 1000.0); // fps + 码率(kbps)(字节*8/1000)
nFrames = 0; nBytes = 0; lastStats += 1000000; // 清零并推进统计窗口基准
}
}
// 会话结束:若整段都没解出过帧,多半是设备没下发 SPS/PPS,给出定位提示
if (!decodedAny)
emit errorOccurred("本会话始终0帧(设备未下发SPS/PPS)→ 已自动重连;若反复出现需查设备端SDP/IDR设置");
// 资源逆序释放:帧 -> 包 -> 解码上下文 -> 解封装上下文,避免泄漏
av_frame_free(&frm);
av_packet_free(&pkt);
avcodec_free_context(&ctx);
avformat_close_input(&fmt);
}
//yuvglwidget.h
// 本文件:用 OpenGL 把 YUV420 三个平面(Y/U/V)上传为 3 张纹理,
// 再在片元着色器里做 YUV->RGB 颜色空间转换,实现 GPU 直渲,
// 避免 CPU 端转 RGB 的开销,是低延迟播放器显示端的核心。
#pragma once
#include <QOpenGLWidget> // Qt 的 OpenGL 渲染窗口控件(继承自 QWidget)
#include <QOpenGLFunctions> // 提供跨平台的 GL 函数封装(glBindTexture 等)
#include <QOpenGLShaderProgram> // 顶点/片元着色器程序的封装(编译+链接+绑定)
#include <QOpenGLBuffer> // VBO(顶点缓冲对象)封装,存放全屏四边形顶点
#include <memory> // std::shared_ptr,用于跨线程安全共享 VideoFrame
#include "FrameHub.h" // 帧中转枢纽:解码线程 publish、GUI 线程 grab
#include <QTimer>
struct VideoFrame;
// YuvGLWidget:显示端控件
class YuvGLWidget : public QOpenGLWidget, protected QOpenGLFunctions {
Q_OBJECT // 启用 Qt 元对象(信号槽),需被 moc 处理
public:
// 构造:只做"装配",真正建 GL 资源在 initializeGL() 里做(那时才有 current GL context)
explicit YuvGLWidget(QWidget* parent = nullptr) : QOpenGLWidget(parent) {
pullTimer_.setInterval(33); // ~30Hz拉取,≤1帧显示延迟(33ms≈1/30秒)
// 定时器到点即从 FrameHub 抢最新帧;用 lambda 版 connect 不必依赖具名槽
connect(&pullTimer_, &QTimer::timeout, this, [this]{
if (!hub_) return; // 还没 attach 枢纽:本轮什么都不做
quint64 s0 = lastSeq_; // 记录取帧前的帧序号快照(用于丢帧统计)
auto f = hub_->grab(lastSeq_); // 取"比 lastSeq_ 更新"的最新一帧;无新帧返回空
if (f) { pending_ = std::move(f); // 有新帧:转移所有权到 pending_,等待 paintGL 绘制
if (hub_->seq() - s0 > 1) ++dropped_; // 被覆盖掉的帧数=丢帧统计
update(); } // 请求重绘,触发 paintGL(异步、非阻塞)
});
pullTimer_.start(); // 启动拉帧循环(构造即开始轮询枢纽)
}
void attach(FrameHub* h) { hub_ = h; } // MainWindow里接线:把解码线程的枢纽交给我
quint64 dropped_ = 0; // 累计丢帧数(被更快新帧覆盖的帧),供上层显示
protected:
void initializeGL() override; // GL 上下文就绪后回调一次:建着色器/VBO/纹理
void paintGL() override; // 每次 update()/重绘回调:上传帧+绘制
private:
// 把一个 YUV 平面(plane)上传到指定纹理 tex;stride=源每行字节数,w/h=该平面宽高
void upload(GLuint tex, const QByteArray& plane,
int stride, int w, int h);
QOpenGLShaderProgram prog_; // YUV->RGB 的着色器程序(顶点+片元)
QOpenGLBuffer vbo_{QOpenGLBuffer::VertexBuffer}; // 全屏四边形顶点缓冲(三角形带)
GLuint tex_[3] = {0, 0, 0}; // 3 张纹理句柄:[0]=Y [1]=U [2]=V
std::shared_ptr<VideoFrame> pending_; // 待绘制帧(由定时器线程写入,paintGL 读取)
FrameHub* hub_ = nullptr; // 帧枢纽指针(attach 后有效,跨线程取帧入口)
QTimer pullTimer_; // ~30Hz 拉帧定时器(运行在 GUI 线程)
quint64 lastSeq_ = 0; // 上一次成功取到的帧序号,保证只取更新的帧
};
//yuvglwidget.cpp
// 本文件:YuvGLWidget 的 OpenGL 实现------着色器源码、初始化、平面上传、绘制。
#include "yuvglwidget.h"
#include "VideoFrame.h" // 完整定义:paintGL 里要读 f->y/u/v、w/h、stride 等成员
// ---- 顶点着色器(GLSL 1.x / GLES2 风格)----
// 输入 pos:-1~1 的全屏四边形顶点坐标;输出 vUV:对应的纹理坐标。
// 关键:vUV.t = 1.0 - vUV.t 做 Y 轴翻转------因为内存里 YUV 首行是图像顶部,
// 而 GL 纹理坐标原点在左下,不翻转会上下颠倒。
static const char* VSH =
"attribute vec2 pos; varying vec2 vUV;\n"
"void main(){ vUV = (pos + 1.0) * 0.5; vUV.t = 1.0 - vUV.t;\n"
" gl_Position = vec4(pos, 0.0, 1.0); }";
// BT.601 全范围转换;若画面发灰改用limited range: rgb = 1.164*(y-0.0625) + ...
// ---- 片元着色器:核心 YUV->RGB 数学 ----
// 三个采样器分别取 Y/U/V;U、V 减 0.5 把 [0,1] 还原到 [-0.5,0.5] 的色度偏差,
// 再套用 BT.601 系数:R=Y+1.402V, G=Y-0.344U-0.714V, B=Y+1.772U。
static const char* FSH =
"uniform sampler2D texY, texU, texV; varying vec2 vUV;\n"
"void main(){\n"
" float y = texture2D(texY, vUV).r;\n" // Y 亮度(单通道,存于 .r)
" float u = texture2D(texU, vUV).r - 0.5;\n" // U 色度(去偏置)
" float v = texture2D(texV, vUV).r - 0.5;\n" // V 色度(去偏置)
" gl_FragColor = vec4(y + 1.402*v, y - 0.344*u - 0.714*v, y + 1.772*u, 1.0);\n"
"}";
// initializeGL:GL 上下文已就绪,整个控件生命周期只调用一次,用来创建常驻 GPU 资源。
void YuvGLWidget::initializeGL() {
initializeOpenGLFunctions(); // 解析本上下文可用的 GL 函数指针(QOpenGLFunctions 要求)
prog_.addShaderFromSourceCode(QOpenGLShader::Vertex, VSH); // 编译顶点着色器
prog_.addShaderFromSourceCode(QOpenGLShader::Fragment, FSH); // 编译片元着色器
prog_.link(); // 链接成可执行程序
const float quad[] = {-1,-1, 1,-1, -1,1, 1,1}; // 三角带:4 顶点铺满 NDC 全屏
vbo_.create(); vbo_.bind(); vbo_.allocate(quad, sizeof(quad)); // 建 VBO 并把顶点送到显存
glGenTextures(3, tex_); // 申请 3 个纹理对象(Y/U/V 各一)
}
// upload:把一块 YUV 平面(一维字节)上传为单通道纹理。
// plane=平面数据,stride=源每行有效字节数(可能因对齐>w),w/h=该平面显示宽高。
void YuvGLWidget::upload(GLuint tex, const QByteArray& plane,
int stride, int w, int h) { // ★签名加w
glBindTexture(GL_TEXTURE_2D, tex); // 绑定目标纹理,后续操作作用于它
glPixelStorei(GL_UNPACK_ALIGNMENT, 1); // 行按 1 字节对齐(默认 4,会错位 YUV)
glPixelStorei(GL_UNPACK_ROW_LENGTH, stride); // 源数据行距=stride(跳过得码器的行填充)
// ★修复B:GL_LUMINANCE 在 GL2.1/ES2/GL3兼容档全线可用(GL_R8要GL3.0+)
// 用 GL_LUMINANCE 把单通道灰度当作 .r 读,兼容性最好
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, w, h, 0,
GL_LUMINANCE, GL_UNSIGNED_BYTE, plane.constData());
glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); // 复位行距,避免影响之后的纹理上传
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); // 缩小用线性插值
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // 放大用线性插值
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); // 横向边缘钳位,防采样越界
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); // 纵向边缘钳位
}
// paintGL:每次重绘回调,负责"上传当前帧三平面 -> 按信箱比例设视口 -> 画全屏四边形"。
void YuvGLWidget::paintGL() {
glClearColor(0, 0, 0, 1); glClear(GL_COLOR_BUFFER_BIT); // 清屏为黑(信箱上下/左右黑边)
if (!pending_) return; // 没有待绘帧:保持黑屏直接返回
const auto f = pending_; // 取出帧的本地副本(shared_ptr,引用计数安全)
upload(tex_[0], f->y, f->yStride, f->w, f->h); // Y 平面:全分辨率
upload(tex_[1], f->u, f->uStride, f->w / 2, f->h / 2); // U 平面:420 色度为 1/4(宽高各半)
upload(tex_[2], f->v, f->vStride, f->w / 2, f->h / 2); // V 平面:同上
// 信箱式适配:任意窗口比例下保持16:9不拉伸
// fa=视频宽高比,wa=窗口宽高比;取较小轴缩放 1.0,较大轴缩到比例匹配,剩余即黑边
const double fa = double(f->w) / f->h, wa = double(width()) / height();
double sx = fa < wa ? fa / wa : 1.0, sy = fa < wa ? 1.0 : wa / fa;
glViewport(int(width() * (1 - sx) / 2), int(height() * (1 - sy) / 2), // 居中偏移
int(width() * sx), int(height() * sy)); // 实际绘制区域大小
prog_.bind(); // 启用 YUV->RGB 着色器程序
prog_.setUniformValue("texY", 0); prog_.setUniformValue("texU", 1); // 片元里三个采样器
prog_.setUniformValue("texV", 2); // 分别绑定到纹理单元 0/1/2
vbo_.bind(); // 绑定全屏四边形顶点缓冲
prog_.enableAttributeArray("pos"); // 启用顶点属性 pos
prog_.setAttributeBuffer("pos", GL_FLOAT, 0, 2); // 描述 pos:float、偏移0、每顶点2分量
for (int i = 0; i < 3; ++i) { // 把 Y/U/V 三纹理分别激活到单元 0/1/2
glActiveTexture(GL_TEXTURE0 + i); glBindTexture(GL_TEXTURE_2D, tex_[i]);
}
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); // 一次画 4 顶点三角形带 = 整屏
}
//VideoFrame.h
#pragma once
#include <QByteArray>
#include <QMetaType>
#include <memory>
// 跨线程视频帧:YUV420三平面,值语义(shared_ptr携带)
struct VideoFrame {
int w = 0, h = 0;
QByteArray y, u, v;
int yStride = 0, uStride = 0, vStride = 0;
};
// ★让 std::shared_ptr<VideoFrame> 成为Qt认识的元类型(跨线程排队投递的前提)
Q_DECLARE_METATYPE(std::shared_ptr<VideoFrame>)
//FrameHub.h
#pragma once
#include <QMutex>
#include <memory>
#include "VideoFrame.h"
// 有界信箱(容量=1):生产者覆盖=丢帧;消费者拉最新。
// ★延迟上界恒定,与两侧速率差无关------ffplay framedrop的手工等价物
class FrameHub {
public:
void publish(std::shared_ptr<VideoFrame> f) { // 解码线程调
QMutexLocker l(&m_);
latest_ = std::move(f);
++seq_;
}
std::shared_ptr<VideoFrame> grab(quint64& lastSeq) { // GUI线程调
QMutexLocker l(&m_);
if (seq_ == lastSeq) return nullptr; // 无新帧
lastSeq = seq_;
return latest_; // 取走即消费
}
quint64 seq() const { QMutexLocker l(&m_); return seq_; }
private:
mutable QMutex m_; // ←手误防呆:应为 QMutex
std::shared_ptr<VideoFrame> latest_;
quint64 seq_ = 0;
};
【锁定当前段】
调用DeviceClient类的sendCommand("LOCK")发送命令到APP端,没有其他后台服务模块。
核心代码
// ============================ 信号接线 ============================
void MainWindow::wireSignals() {
connect(btnLock_, &QPushButton::clicked, this, [this]{
if (!dev_->isConnected()) { appendLog("未连接", "err"); return; }
dev_->sendCommand("LOCK"); // 原"LCK"改为命令表实测值
appendLog(">> LOCK", "tx");
cmdAcked_ = false;
QTimer::singleShot(3000, this, [this]{ if (!cmdAcked_)
appendLog("⚠ LCK 3秒无应答------发送格式可能不符(需文档/抓包)", "err"); });
});
}
2.2 安卓APP客户端实现
由qml前端界面 直接调用后端总控RecEngine实现,qml前端界面在使用后端总控RecEngine之前,需要先在主函数入口main.cpp中,先把 C++ 对象变成 QML 里的全局名,并且在RecEngine类中用Q_PROPERTY/Q_INVOKABLE 向前端暴露**「数据 / 状态」/「动作 / 行为」** ,前端才可用 RecEngine.xxx 访问,代码如下:
int main(int argc, char *argv[])
{
QQmlApplicationEngine engine;
RecordingEngine recEngine;
engine.rootContext()->setContextProperty("RecEngine", &recEngine);
}
css
class RecordingEngine : public QObject {
Q_OBJECT
//Q_PROPERTY 向前端暴露「数据 / 状态」
Q_PROPERTY(QString statusText READ statusText NOTIFY statusChanged)
Q_PROPERTY(QString tcpUrl READ tcpUrl CONSTANT)// TCP 服务地址
Q_PROPERTY(QString protoUrl READ protoUrl CONSTANT)// 私有协议服务地址
Q_PROPERTY(QString rtspUrl READ rtspUrl CONSTANT)// RTSP 流地址(播放器拉流用)
Q_PROPERTY(QString lastAlarm READ lastAlarm NOTIFY lastAlarmChanged)//最近一次碰撞告警文本
// 把「媒体库」也作为嵌套后端对象暴露给 QML。
// 返回 QObject* 是为了让 QML 通过属性访问它的子属性/方法(二层分离):
// QML 里写 RecEngine.mediaLibrary.photos / .deleteVideo(...) 即可直接用相册能力。
Q_PROPERTY(QObject* mediaLibrary READ mediaLibraryObj CONSTANT)
public:
//Q_INVOKABLE 向前端暴露「动作 / 行为」
Q_INVOKABLE void start();// 开始录像(启动采集→编码→分段→各服务)
Q_INVOKABLE void stop();// 停止录像(停采集、封最后一段、关服务)
Q_INVOKABLE void lockCurrentSegment();// 手动锁定当前段:把正在写的普通段升级为
}
//main.qml
// ========== 实时菜单页面(录像控制,延续原逻辑) ==========
ColumnLayout {
spacing: 8
RowLayout {
Layout.fillWidth: true; spacing: 8
Button {
text: "开始录像"
enabled: RecEngine.statusText === "IDLE"
onClicked: RecEngine.start()
}
Button {
text: "停止录像"
enabled: RecEngine.statusText !== "IDLE"
onClicked: RecEngine.stop()
}
Button {
text: "锁定当前段"
enabled: RecEngine.statusText === "DRIVING"
onClicked: RecEngine.lockCurrentSegment()
}
}
Label {
text: RecEngine.statusText
color: RecEngine.statusText === "DRIVING" ? "#ff6b6b" : "#9fe870"
font.pixelSize: 20; font.bold: true
}
Label { text: "直播: " + (RecEngine.rtspUrl || "启动失败")
color: "#9fd7ff"; font.pixelSize: 12
elide: Text.ElideMiddle; Layout.fillWidth: true }
Label { text: "TCP: " + (RecEngine.tcpUrl || "启动失败")
color: "#7fd4ff"; font.pixelSize: 12
elide: Text.ElideMiddle; Layout.fillWidth: true }
Label { text: "车机: " + (RecEngine.protoUrl || "启动失败")
color: "#ffd479"; font.pixelSize: 12
elide: Text.ElideMiddle; Layout.fillWidth: true }
Label { text: RecEngine.lastAlarm ? "最近事件: " + RecEngine.lastAlarm
: "G-sensor 待命"
color: "#ffb86c"; font.pixelSize: 13; font.bold: true
Layout.fillWidth: true }
ScrollView {
Layout.fillWidth: true; Layout.fillHeight: true
TextArea {
id: log
readOnly: true; wrapMode: TextArea.Wrap
color: "#9fe870"; font.family: "monospace"; font.pixelSize: 12
text: "DVR ready.\n"
}
}
}
本篇案例的qml比较简单,只有一个qml界面,只简要介绍以上必要知识点,重点是行车记录仪相关的如下特性:
- RecEngine.start:开始录像功能,检查摄像头权限,创建MediaCodec硬件编码器,打开相机并回调 onCameraReady(err)
- RecordingEngine::onCameraReady:分段封装H.264 流,发送封装完成信号,把编码器挂给封装器,编码输出抽帧循环drainLoop,调用车机协议服务器DvrProtoServer
- RecordingEngine::drainLoop:不断从 MediaCodec 输出队列取出已编码的 H.264 数据,按标志分流到「RTSP 推流」与「分段封装落盘」两个出口
- RecEngine.stop:停止录像功能,通知 drainLoop 退出循环,再关相机,封存并释放分段封装器,释放编码器
- RecEngine.lockCurrentSegment:锁定当前段功能,调用VideoEncoder::requestSyncFrame主动请求一个IDR帧
核心代码
css
#ifndef RECORDINGENGINE_H
#define RECORDINGENGINE_H
=====================================================================
// RecordingEngine.h:行车记录仪的「后端总控」(C++/Qt)
// 职责:把摄像头采集、编码、分段封装、TCP/私有协议服务、RTSP 服务、
// 碰撞(G传感器)告警、媒体库相册等所有子模块聚合到一个对象里,
// 再把这套能力「翻译」成 QML 能直接用的接口(属性/方法/信号)。
// 定位:它是前后端分离里的「后端」------不含任何界面,只暴露能力。
// QML 通过 main.cpp 注入的全局名(如 RecEngine)访问本对象。
// =====================================================================
#include <QObject>
#include <QString>
#include <atomic>
#include <memory>
#include <thread>
#include "StorageManager.h"
#include "VideoEncoder.h"
#include "CameraController.h"
#include "SegmentMuxerManager.h"
#include "DvrTcpServer.h"
#include "DvrProtoServer.h"
#include "RtspServer.h"
#include "GSensor.h"
class MediaLibrary; // 前置声明
class RecordingEngine : public QObject {
Q_OBJECT
// ------- 暴露给 QML 的属性(READ=读函数, NOTIFY=变更信号) -------
// statusText:当前工作状态文本(如 IDLE/RECORDING),绑定它可做状态灯/文字。
// NOTIFY statusChanged → 后端一 setState 就 emit,前端 Label 自动刷新(响应式)。
Q_PROPERTY(QString statusText READ statusText NOTIFY statusChanged)
// 下面三个 URL 是「常量属性」(CONSTANT):构造时拼好、运行期不变,
// QML 读一次即定型,不需要 NOTIFY,也不会反复触发绑定重算。
Q_PROPERTY(QString tcpUrl READ tcpUrl CONSTANT)// TCP 服务地址
Q_PROPERTY(QString protoUrl READ protoUrl CONSTANT)// 私有协议服务地址
Q_PROPERTY(QString rtspUrl READ rtspUrl CONSTANT)// RTSP 流地址(播放器拉流用)
// lastAlarm:最近一次碰撞告警文本,NOTIFY → 前端可弹窗/红字提示。
Q_PROPERTY(QString lastAlarm READ lastAlarm NOTIFY lastAlarmChanged)
// 把「媒体库」也作为嵌套后端对象暴露给 QML。
// 返回 QObject* 是为了让 QML 通过属性访问它的子属性/方法(二层分离):
// QML 里写 RecEngine.mediaLibrary.photos / .deleteVideo(...) 即可直接用相册能力。
Q_PROPERTY(QObject* mediaLibrary READ mediaLibraryObj CONSTANT)
public:
explicit RecordingEngine(QObject* parent = nullptr);
~RecordingEngine() override;
QString statusText() const { return state_; }
QString tcpUrl() const { return tcpUrl_; }
QString protoUrl() const { return protoUrl_; }
QString rtspUrl() const { return rtspUrl_; }
QString lastAlarm() const { return lastAlarm_; }
QObject* mediaLibraryObj() const ; // ★
// ------- Q_INVOKABLE:标记为「QML 可直接调用的方法」(前端→后端 主动发命令) -------
Q_INVOKABLE void start();// 开始录像(启动采集→编码→分段→各服务)
Q_INVOKABLE void stop();// 停止录像(停采集、封最后一段、关服务)
Q_INVOKABLE void lockCurrentSegment();// 手动锁定当前段:把正在写的普通段升级为
signals:
void statusChanged(const QString& s);
void segmentFinished(const QString& name, qint64 durUs, qint64 bytes);
void errorOccurred(const QString& msg);
void lastAlarmChanged();
private:
void onCameraReady(const QString& err);
void drainLoop();
void setState(const QString& s);
void onGEvent(float g);
void makeThumb(const QString& video);
std::unique_ptr<StorageManager> storage_;
std::unique_ptr<DvrTcpServer> tcp_;
std::unique_ptr<DvrProtoServer> proto_;
std::unique_ptr<RtspServer> rtsp_;
std::unique_ptr<GSensor> gsensor_;
std::unique_ptr<MediaLibrary> mediaLibrary_; // ★ 优化篇
VideoEncoder encoder_;
CameraController camera_;
std::unique_ptr<SegmentMuxerManager> muxerMgr_;
std::atomic<bool> running_{false};
std::thread drain_;
QString tcpUrl_;
QString protoUrl_;
QString rtspUrl_;
QString lastAlarm_;
QString state_ = "IDLE";
};
#endif // RECORDINGENGINE_H
css
// ============================================================================
// RecordingEngine.cpp ------ APP后端「总控」
// ----------------------------------------------------------------------------
// 职责定位:本类是整个 DVR 后端的中枢对象(一个 QObject),在构造时把所有
// 子模块装配成一条完整的数据/控制流水线,并对外提供 start/stop/
// lockCurrentSegment 等命令。它是前后端分离设计里的「后端」:
// QML 界面只通过 setContextProperty 注入的全局名 RecEngine 访问本类,
// 靠 Q_PROPERTY(属性绑定) / Q_INVOKABLE(调用命令) / signals(事件回推)
// 三条通道交互,界面完全不含业务、业务完全不含界面。
//
// 整体数据流(摄像头 → 编码 → 三路输出):
// Camera 采集 → MediaCodec 硬编码(AMediaCodec) → drainLoop 后台线程取出 H.264
// ├─► SegmentMuxerManager 分段封装成 MP4 落盘(录像文件 + 缩略图 + 锁段)
// └─► RtspServer 实时推流(手机/PC 端 rtsp://...:8554 拉流预览)
// 另有 DVR_TCP(9000) / DVR 私有协议(9001) 两个网络服务接收远端控制指令;
// GSensor 加速度计监测碰撞(>2g) → 自动锁段 + 强制 IDR + 推报警。
//
// 线程模型(呼应前面「谁是线程」的讨论):
// - 本对象所有方法都跑在 GUI 主线程(它本身不是 QThread)。
// - 唯一显式后台线程是 drain_(std::thread 跑 drainLoop),从编码器非阻塞
// 抽帧;它通过值拷贝 + QueuedConnection 把回调安全投递回主线程。
// ============================================================================
#if defined(Q_OS_ANDROID) // 仅安卓平台需要的 JNI 头文件
#include <QJniObject> // 提供 QJniObject:从 C++ 反射调用 Java/安卓静态方法
#include <QGuiApplication> // QGuiApplication(下面取安卓 Application context 用)
#endif
#include "RecordingEngine.h"
#include "MediaLibrary.h" // ★ 优化篇:相册/缩略图媒体库(嵌套后端对象)
#include <QGuiApplication>
#include <QPermissions> // 运行时权限 API:checkPermission / requestPermission(摄像头)
#include <QStandardPaths> // 跨平台可写目录查询(AppDataLocation)
#include <QFileInfo> // 文件信息(此处 include,文件名解析等场景备用)
#include <android/log.h> // 安卓原生日志 __android_log_print
#include <QDebug> // qWarning/qDebug 等 Qt 日志流
// 封装一个安卓 Logcat 打印宏:tag 固定为 "DVR",级别 INFO。
// __VA_ARGS__ 把可变参数原样转发给底层 __android_log_print 的格式化参数。
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, "DVR", __VA_ARGS__)
// ----------------------------------------------------------------------------
// 构造函数:一次性把所有子服务「装配」起来(依赖注入 + 信号槽互联)。
// 顺序很关键:先建存储 → 建各网络服务并把「控制动作」绑回本对象的 start/stop
// → 起 RTSP → 建媒体库 → 最后武装碰撞传感器。全部跑在主线程。
// ----------------------------------------------------------------------------
RecordingEngine::RecordingEngine(QObject* parent) : QObject(parent) {
// 计算录像/数据落盘根目录:应用私有可写目录(AppDataLocation) 下拼一个 /DVR 子目录。
// 安卓上这会落到 app 专属外部存储区,免运行时存储权限即可读写。
const QString root = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/DVR";
storage_ = std::make_unique<StorageManager>(root); // 存储管理器:统管目录/分段命名/落盘路径
LOG("storage root: %s", root.toUtf8().constData());
#ifdef Q_OS_ANDROID
// 通过 JNI 反射调用安卓侧 WifiLockHelper.acquire(ctx):
// 申请「WiFi 锁 + 唤醒锁」,防止录像/推流过程中系统休眠或断开 WiFi,
// 否则后台一旦休眠,采集与网络服务会一起断掉。签名 (Context)V 表示传入 Context、无返回。
QJniObject::callStaticMethod<void>(
"org/qtproject/example/Dvrqt/WifiLockHelper", // Java 类全名
"acquire", "(Landroid/content/Context;)V", // 方法名 + JNI 方法签名
QNativeInterface::QAndroidApplication::context()); // 把安卓 Application context 传进去
#endif
// ---- DVR 控制服务(TCP,端口 9000):接收上位机/PC 的文本控制指令 ----
tcp_ = std::make_unique<DvrTcpServer>(*storage_);
tcp_->setStateProvider([this]{ return state_; }); // 注入「取当前状态」回调,让 TCP 能应答状态查询
tcp_->setActions([this]{ start(); }, [this]{ stop(); }); // 注入「开始/停止录像」动作:远端指令→调本对象
if (tcp_->start(9000)) { // 起监听;成功后把对外 URL 缓存,供 QML 显示连接信息
tcpUrl_ = tcp_->url();
LOG("tcp server: %s", tcpUrl_.toUtf8().constData());
}
// ---- DVR 私有协议服务(端口 9001):结构化控制 + 状态/分段/错误推送 ----
proto_ = std::make_unique<DvrProtoServer>(*storage_);
proto_->setStateProvider([this]{ return state_; });
// 三个控制动作:开始 / 停止 / 手动锁定当前段(紧急录像)。
proto_->setControlHandlers([this]{ start(); }, [this]{ stop(); },
[this]{ lockCurrentSegment(); });
// 注入「G 事件」回调:协议侧上报碰撞时转交本对象的 onGEvent 统一处理。
proto_->setGEventHandler([this](float g){ onGEvent(g); });
// 把本对象的三类信号「桥接」推送到协议服务,实现后端事件主动 push 给远端:
connect(this, &RecordingEngine::statusChanged, proto_.get(), // 状态变化 → 推状态字符串
[this](const QString& s){ proto_->pushState(s); });
connect(this, &RecordingEngine::segmentFinished, proto_.get(), // 一段录像封存 → 推分段(名/时长/字节)
[this](const QString& n, qint64 d, qint64 b){ proto_->pushSegment(n, d, b); });
connect(this, &RecordingEngine::errorOccurred, proto_.get(), // 出错 → 推错误消息
[this](const QString& m){ proto_->pushError(m); });
if (proto_->start(9001)) {
protoUrl_ = proto_->url();
LOG("proto server: %s", protoUrl_.toUtf8().constData());
}
// ---- RTSP 实时推流服务(端口 8554):手机/PC 拉流预览 ----
rtsp_ = std::make_unique<RtspServer>();
// 注入「请求 IDR(关键帧)」回调:新客户端接入/久无数据时,让编码器立刻吐一个
// IDR 帧,保证拉流端能立刻对齐 SPS/PPS+IDR 并解出画面(否则要等下一个 GOP 才出图)。
rtsp_->setIdrRequester([this]{
if (state_ == "DRIVING") encoder_.requestSyncFrame();
});
if (rtsp_->start(8554)) { // 在 8554 端口起 RTSP 服务
rtspUrl_ = rtsp_->url();// 生成 rtsp://<ip>:8554/live 供播放器拉流
LOG("rtsp server: %s", rtspUrl_.toUtf8().constData());
}
// ---- ★ 优化篇:媒体库(相册列表数据源 + 缩略图后台工作线程) ----
// 作为「嵌套后端对象」挂在本对象下(mediaLibrary 属性暴露给 QML),QML 可用
// RecEngine.mediaLibrary.photos 等直接作 GridView 的 model。parent 传 this 便于父子生命周期管理。
mediaLibrary_ = std::make_unique<MediaLibrary>(storage_.get(), this);
// 注入「当前正在写的文件」查询回调:媒体库刷新相册时要排除这个尚未封口(moov 未写、
// 播会失败)的文件,避免把残缺文件列进相册。
mediaLibrary_->setCurrentFileProvider([this]{
// 该回调只在主线程被调用(refresh 只在主线程跑),访问 muxerMgr_ 无竞态。
return muxerMgr_ ? muxerMgr_->currentFilePath() : QString();
});
// ★ 每封存好一段录像 → 触发媒体库刷新相册列表,实现「录完一段列表自动出现」。
connect(this, &RecordingEngine::segmentFinished, mediaLibrary_.get(),
[this](const QString&, qint64, qint64){ mediaLibrary_->refresh(); });
// ---- 碰撞传感器(加速度计):阈值 2.0g 武装 ----
gsensor_ = std::make_unique<GSensor>();
// start(阈值, 回调):当合成加速度 > 2.0g 时回调触发。回调可能在传感器采样线程上,
// 因此用 QueuedConnection 把 onGEvent「投递回本对象所属的主线程」执行------保证
// 后续操作 muxerMgr_/encoder_/发信号都发生在与它们亲和的同一线程,杜绝跨线程竞态。
gsensor_->start(2.0f, [this](float g) {
QMetaObject::invokeMethod(this, [this, g]{ onGEvent(g); },
Qt::QueuedConnection);
});
LOG("gsensor: threshold 2.0g armed");
}
// ----------------------------------------------------------------------------
// 析构:先停传感器(不再产生新的 G 事件),再走统一停止流程释放采集/编码资源。
// ----------------------------------------------------------------------------
RecordingEngine::~RecordingEngine() {
if (gsensor_) gsensor_->stop();
stop();
}
// ----------------------------------------------------------------------------
// 状态机内部 setter:更新状态字符串并 emit statusChanged。
// 该信号既驱动 QML 的 statusText 绑定刷新,又经构造函数里的 connect 桥接给协议服务。
// ----------------------------------------------------------------------------
void RecordingEngine::setState(const QString& s) {
state_ = s;
emit statusChanged(s);
}
// ----------------------------------------------------------------------------
// start():开始录像(Q_INVOKABLE,由 QML 按钮 / 远端指令调用)。
// 状态机入口:IDLE →(申请权限)→ STARTING →(相机就绪回调)→ DRIVING。
// ----------------------------------------------------------------------------
void RecordingEngine::start() {
qWarning() << "[dvr] >>> start() entered";
if (state_ != "IDLE") return; // 非空闲态一律忽略:天然的幂等/防重入保护
// 运行时权限:检查摄像头权限。未授予则发起系统授权弹窗,
// 授权回来后通过回调再次调用 start()(递归一次,届时多半已 Granted)。
if (qGuiApp->checkPermission(QCameraPermission{}) != Qt::PermissionStatus::Granted) {
qGuiApp->requestPermission(QCameraPermission{}, this, [this] { start(); });
return; // 本轮先返回,等待授权回调重新进入
}
setState("STARTING"); // 进入「正在启动」过渡态(onCameraReady 会校验这个态)
// 先创建 MediaCodec 硬件编码器,并拿到它的输入面 ANativeWindow(win)。
// 相机会把采集画面直接渲染到这个窗口,等于把「相机输出」零拷贝喂给「编码器输入」。
ANativeWindow* win = nullptr;
if (!encoder_.create(&win)) { // 编码器创建失败 → 回退 IDLE 并报错
setState("IDLE");
emit errorOccurred("encoder create failed");
return;
}
// 用刚拿到的 win 打开相机;异步:真正就绪(或失败)时回调 onCameraReady(err)。
camera_.open(win, [this](const QString& err) { onCameraReady(err); });
}
// ----------------------------------------------------------------------------
// onCameraReady:相机打开的异步结果回调(err 为空表示成功)。
// 仍在 STARTING 才继续,否则说明期间已被 stop() 取消,做清理后直接返回。
// ----------------------------------------------------------------------------
void RecordingEngine::onCameraReady(const QString& err) {
if (state_ != "STARTING") { // 已被取消/不在启动流程 → 排队清理刚打开的相机与编码器
QMetaObject::invokeMethod(this, [this] { camera_.close(); encoder_.release(); },
Qt::QueuedConnection);
return;
}
if (!err.isEmpty()) { // 相机打开失败 → 释放编码器、回 IDLE、报错
QMetaObject::invokeMethod(this, [this] { encoder_.release(); },
Qt::QueuedConnection);
setState("IDLE");
emit errorOccurred(err);
return;
}
// 成功:建「分段封装管理器」------它负责把 H.264 流切成一段段 MP4 落盘,
// 并在「一段正常封存」/「一段带 G 值封存」两类完成事件里回调本对象。
muxerMgr_ = std::make_unique<SegmentMuxerManager>(*storage_,
[this](const QString& f, qint64 dur, qint64 bytes) {
// 普通分段封存完成 → 直接 emit,触发协议推送 + 媒体库刷新
emit segmentFinished(f, dur, bytes);
},
[this](const QString& f, qint64 dur, qint64 bytes, float g) {
// 锁定段(带碰撞 G 值)封存完成 → 投回主线程做缩略图 + 发事件。
// 因为生成缩略图/发信号都必须在主线程做,故用 QueuedConnection。
QMetaObject::invokeMethod(this, [this, f, dur, bytes, g] {
makeThumb(f);
emit segmentFinished(f, dur, bytes);
}, Qt::QueuedConnection);
});
muxerMgr_->attach(encoder_.get()); // 把编码器挂给封装器(用于取码率等编码上下文)
running_ = true; // 置后台抽帧线程的运行标志(atomic)
drain_ = std::thread(&RecordingEngine::drainLoop, this); // 启动唯一的显式后台线程
setState("DRIVING"); // 正式进入「录像运行中」状态
}
// ----------------------------------------------------------------------------
// drainLoop:编码输出抽帧循环(运行在 drain_ 后台线程,非主线程)。
// 职责:不断从 MediaCodec 输出队列取出已编码的 H.264 数据,按标志分流到
// 「RTSP 推流」与「分段封装落盘」两个出口。这是整套录像/预览的发动机。
// 注意:此线程不直接碰 GUI/信号,需要回主线程的动作通过回调+QueuedConnection 完成。
// ----------------------------------------------------------------------------
void RecordingEngine::drainLoop() {
AMediaCodec* codec = encoder_.get(); // 取裸编码器句柄(安卓 NDK MediaCodec)
bool firstFormatHandled = false; // 输出格式首次变化时开第一段(下面用到)
while (running_) { // running_ 是原子标志,stop() 置 0 后本循环退出
AMediaCodecBufferInfo info{}; // 缓冲区元信息(大小/时间戳/标志位)
// 带 10ms(10000us) 超时地取一个输出缓冲区,避免无帧时死等卡死线程。
ssize_t idx = AMediaCodec_dequeueOutputBuffer(codec, &info, 10000);
if (idx == AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED) {
// 输出格式确定(拿到分辨率/CSD 等):编码器准备好出帧了。
// 首次发生时开第一个分段(MP4 封装需在首帧前写好容器头)。
if (!firstFormatHandled) {
muxerMgr_->openFirstSegment();
firstFormatHandled = true;
}
} else if (idx == AMEDIACODEC_INFO_OUTPUT_BUFFERS_CHANGED) {
// 输出缓冲区集合发生变化:无需特殊处理,下一轮重新按 idx 取即可。
} else if (idx >= 0) {
// 拿到一个有效的输出缓冲区索引 idx → 取指针与大小。
size_t outSize = 0;
uint8_t* data = AMediaCodec_getOutputBuffer(codec, idx, &outSize);
if (data && info.size > 0) {
if (info.flags & AMEDIACODEC_BUFFER_FLAG_CODEC_CONFIG) {
// 这是 SPS/PPS 等编解码器配置(NAL)。缓存到 RTSP 服务,
// 供后续新拉流客户端握手时下发(保证它们能解码出图)。
rtsp_->setCodecConfig(data, int(info.size));
} else {
// 普通视频帧:① 写进当前分段(MP4);② 同时推给 RTSP 做实时预览,
// 并携带时间戳与「是否关键帧(IDR)」标志(RTP 打包/丢帧策略需要)。
muxerMgr_->write(data, info);
//RTSP 推流:逐帧推给拉流客户端
rtsp_->pushFrame(data, int(info.size), info.presentationTimeUs,
(info.flags & AMEDIACODEC_BUFFER_FLAG_KEY_FRAME) != 0);
}
}
// 用完立即释放该输出缓冲区(false 表示「不上屏」,纯取数据)。
AMediaCodec_releaseOutputBuffer(codec, idx, false);
}
}
}
// ----------------------------------------------------------------------------
// stop():停止录像。顺序很关键------先停后台线程,再关外设,最后释放并回 IDLE。
// ----------------------------------------------------------------------------
void RecordingEngine::stop() {
if (state_ == "IDLE") return; // 本就没在跑,直接返回(幂等)
running_ = false; // 通知 drainLoop 退出循环
if (drain_.joinable()) drain_.join(); // 等后台抽帧线程真正结束(确保不再写封装器)
camera_.close(); // 关相机
if (muxerMgr_) muxerMgr_->release(); // 封存并释放分段封装器(把最后一段 MP4 收尾)
encoder_.release(); // 释放编码器
muxerMgr_.reset(); // 销毁分段封装管理器对象
setState("IDLE"); // 回到空闲态
}
// ----------------------------------------------------------------------------
// onGEvent:碰撞(G 传感器)事件统一处理。无论来自本地传感器回调还是协议上报,
// 都收敛到这里。只在实际录像中(state_=="DRIVING")才有意义。
// ----------------------------------------------------------------------------
void RecordingEngine::onGEvent(float g) {
if (state_ != "DRIVING" || !muxerMgr_) return; // 非录像态/无封装器 → 忽略
LOG("G-EVENT dev=%.2fg", (double)g);
lastAlarm_ = QString("G %1g").arg(g, 0, 'f', 2); // 格式化最近报警文本(2位小数)
emit lastAlarmChanged(); // 触发 QML 报警显示绑定刷新
proto_->pushAlarm(g); // 通过私有协议推送报警给远端
encoder_.requestSyncFrame(); // 强制立刻生成一个 IDR,锁定段首帧可独立解码
muxerMgr_->requestLock(g); // 请求把当前段「加锁」保护(带 G 值,防被循环覆盖)
}
// ----------------------------------------------------------------------------
// lockCurrentSegment():用户手动「紧急锁定」当前段(Q_INVOKABLE,QML/远端调用)。
// 与碰撞处理的差别:不带 G 值(传 0.0f),其余动作相同(强制 IDR + 锁段)。
// ----------------------------------------------------------------------------
void RecordingEngine::lockCurrentSegment() {
if (state_ != "DRIVING" || !muxerMgr_) return; // 非录像态忽略
encoder_.requestSyncFrame(); // 强制 IDR,使锁定段可独立起播
muxerMgr_->requestLock(0.0f); // 0.0f 表示「手动锁」(非碰撞)
}
// ----------------------------------------------------------------------------
// ★ 优化篇:缩略图生成转交媒体库的工作线程(主线程不再被 JNI 抽帧阻塞)。
// 本方法本身仍在主线程被调用,只是把真正的抽帧活儿甩给 MediaLibrary 的后台线程,
// 从而「录完一段封缩略图」这件事不再卡住界面。
// ----------------------------------------------------------------------------
void RecordingEngine::makeThumb(const QString& video) {
if (mediaLibrary_) mediaLibrary_->requestThumb(video);
}
// ----------------------------------------------------------------------------
// mediaLibraryObj():mediaLibrary 属性的 READ 函数。
// 返回裸指针交给 QML(生命周期仍由本对象的 unique_ptr 持有),
// 使 QML 能写成 RecEngine.mediaLibrary.photos 进行二层访问。
// ----------------------------------------------------------------------------
QObject* RecordingEngine::mediaLibraryObj() const { return mediaLibrary_.get(); }
css
#ifndef DVRPROTOSERVER_H
#define DVRPROTOSERVER_H
// =====================================================================
// DvrProtoServer.h ------ 车机二进制协议服务器(端口9001,控制面)
// CMD_G=0x06(测试钩子:注入G事件) EVT_ALARM=0x84(告警推送)
// 特性:CRC校验、失步重同步、事件推送、ACC熄火消抖、心跳超时清理
// =====================================================================
#include <QTcpServer>
#include <QTcpSocket>
#include <QTimer>
#include <QHash>
#include <QJsonDocument>
#include <QJsonObject>
#include <QtEndian>
#include <QDateTime>
#include <QNetworkInterface>
#include <functional>
#include "StorageManager.h"
class DvrProtoServer : public QTcpServer {
public:
enum Type : quint8 {
CMD_PING = 0x01,
CMD_ACC_ON = 0x02,
CMD_ACC_OFF= 0x03,
CMD_LOCK = 0x04,
CMD_STATUS = 0x05,
CMD_G = 0x06, // 模拟G事件(真实G-sensor由本机产生)
EVT_STATE = 0x81,
EVT_SEGMENT = 0x82,
EVT_ERROR = 0x83,
EVT_ALARM = 0x84, // 碰撞/急刹告警
ACK = 0xE0,
NAK = 0xE1,
};
using StateFn = std::function<QString()>;
using ControlFn = std::function<void()>;
using GFn = std::function<void(float)>; //
explicit DvrProtoServer(const StorageManager& storage) : storage_(storage) {}
void setStateProvider(StateFn fn) { stateFn_ = std::move(fn); }
void setControlHandlers(ControlFn accOn, ControlFn accOff, ControlFn lock) {
accOn_ = std::move(accOn); accOff_ = std::move(accOff); lock_ = std::move(lock);
}
void setGEventHandler(GFn fn) { gEvent_ = std::move(fn); } //
QString url() const { return QString("proto://%1:%2").arg(ip_).arg(serverPort()); }
bool start(quint16 port = 9001) {
accOffTimer_ = new QTimer(this);
accOffTimer_->setSingleShot(true);
connect(accOffTimer_, &QTimer::timeout, this, [this]{ if (accOff_) accOff_(); });
sweepTimer_ = new QTimer(this);
sweepTimer_->setInterval(10 * 1000);
connect(sweepTimer_, &QTimer::timeout, this, [this]{ reapIdle(); });
sweepTimer_->start();
connect(this, &QTcpServer::newConnection, this, [this]{ acceptClients(); });
if (!listen(QHostAddress::AnyIPv4, port)) {
qWarning() << "[proto] listen FAILED:" << errorString();
return false;
}
ip_ = firstLanIp();
qWarning() << "[proto] listening on" << ip_ << port;
return true;
}
// ---- 事件推送 ----
void pushState(const QString& s) { broadcast(EVT_STATE, {{"state", s}}); }
void pushSegment(const QString& n, qint64 durUs, qint64 bytes) {
broadcast(EVT_SEGMENT, {{"name", n}, {"durUs", double(durUs)}, {"bytes", double(bytes)}});
}
void pushError(const QString& m) { broadcast(EVT_ERROR, {{"msg", m}}); }
void pushAlarm(float g) { broadcast(EVT_ALARM, {{"g", double(g)}}); } //
private:
struct Client { QByteArray buf; qint64 lastSeenMs = 0; };
static constexpr qint64 IDLE_TIMEOUT_MS = 30 * 1000;
QHash<QTcpSocket*, Client> clients_;
const StorageManager& storage_;
StateFn stateFn_;
ControlFn accOn_, accOff_, lock_;
GFn gEvent_; //
QTimer* accOffTimer_ = nullptr;
QTimer* sweepTimer_ = nullptr;
QString ip_;
quint8 txSeq_ = 0;
// ---------- 连接 ----------
void acceptClients() {
while (QTcpSocket* s = nextPendingConnection()) {
s->setSocketOption(QAbstractSocket::LowDelayOption, 1);
clients_.insert(s, Client{{}, nowMs()});
connect(s, &QTcpSocket::readyRead, this, [this, s]{ onReadyRead(s); });
connect(s, &QTcpSocket::disconnected, this, [this, s]{
clients_.remove(s); s->deleteLater();
});
sendFrame(s, ACK, QJsonObject{
{"hello", "dvr-proto-v1"},
{"cmds", "PING|ACC_ON|ACC_OFF|LOCK|STATUS|G"},
{"state", currentState()}});
}
}
void onReadyRead(QTcpSocket* s) {
if (!clients_.contains(s)) return;
Client& c = clients_[s];
c.buf += s->readAll();
if (c.buf.size() > 65536) { s->disconnectFromHost(); return; }
parseLoop(s, c);
}
// ---------- 帧解析:长度定界 + CRC校验 + 失步重同步 ----------
void parseLoop(QTcpSocket* s, Client& c) {
forever {
if (c.buf.size() < 7) return;
if (uchar(c.buf[0]) != 0x44 || uchar(c.buf[1]) != 0x56 || uchar(c.buf[2]) != 1) {
c.buf.remove(0, 1); continue;
}
const int len = qFromBigEndian<quint16>(c.buf.constData() + 4);
if (len > 4096) { c.buf.remove(0, 1); continue; }
const int total = 9 + len;
if (c.buf.size() < total) return;
const quint16 crcGot = qFromBigEndian<quint16>(c.buf.constData() + 7 + len);
if (crc16(c.buf.constData() + 2, 5 + len) != crcGot) {
sendFrame(s, NAK, {{"err", "bad crc"}});
c.buf.remove(0, total); continue;
}
const quint8 type = uchar(c.buf[3]);
const quint8 seq = uchar(c.buf[6]);
const QByteArray payload = c.buf.mid(7, len);
c.buf.remove(0, total);
c.lastSeenMs = nowMs();
handleFrame(s, type, seq, payload);
}
}
void handleFrame(QTcpSocket* s, quint8 type, quint8 seq, const QByteArray& payload) {
switch (type) {
case CMD_PING: sendFrame(s, ACK, {{"for", "PING"}, {"seq", seq}}); break;
case CMD_ACC_ON:
accOffTimer_->stop();
if (accOn_) accOn_();
sendFrame(s, ACK, {{"for", "ACC_ON"}, {"seq", seq}});
break;
case CMD_ACC_OFF:
accOffTimer_->start(2000);
sendFrame(s, ACK, {{"for", "ACC_OFF"}, {"seq", seq}, {"stopDelayMs", 2000}});
break;
case CMD_LOCK:
if (lock_) lock_();
sendFrame(s, ACK, {{"for", "LOCK"}, {"seq", seq}});
break;
case CMD_STATUS: sendFrame(s, ACK, statusJson()); break;
case CMD_G: { // 测试注入
const QJsonObject p = QJsonDocument::fromJson(payload).object();
const float g = float(p.value(QStringLiteral("g")).toDouble());
if (gEvent_) gEvent_(g); // 与真实G-sensor同一处理通道
sendFrame(s, ACK, {{"for", "G"}, {"seq", seq}, {"g", double(g)}});
break;
}
default:
sendFrame(s, NAK, {{"err", "unknown type"}, {"type", double(type)}});
}
}
// ---------- 发送 ----------
void sendFrame(QTcpSocket* s, quint8 type, const QJsonObject& payload) {
s->write(buildFrame(type, QJsonDocument(payload).toJson(QJsonDocument::Compact)));
}
void broadcast(quint8 type, const QJsonObject& payload) {
if (clients_.isEmpty()) return;
const QByteArray f = buildFrame(type, QJsonDocument(payload).toJson(QJsonDocument::Compact));
for (QTcpSocket* s : clients_.keys()) s->write(f);
}
QByteArray buildFrame(quint8 type, const QByteArray& p) {
QByteArray f;
f += char(0x44); f += char(0x56); f += char(1); f += char(type);
const char lenBe[2] = { char((p.size() >> 8) & 0xFF), char(p.size() & 0xFF) };
f.append(lenBe, 2);
f += char(txSeq_++);
f += p;
const quint16 crc = crc16(f.constData() + 2, f.size() - 2);
const char crcBe[2] = { char(crc >> 8), char(crc & 0xFF) };
f.append(crcBe, 2);
return f;
}
// ---------- 心跳超时(三步走修复版 + 驱逐日志) ----------
void reapIdle() {
const qint64 now = nowMs();
QList<QTcpSocket*> dead;
for (auto it = clients_.cbegin(); it != clients_.cend(); ++it)
if (now - it->lastSeenMs > IDLE_TIMEOUT_MS) {
qWarning() << "[proto] evict idle client"
<< it.key()->peerAddress().toString();
dead.append(it.key());
}
for (QTcpSocket* s : dead) {
if (!clients_.contains(s)) continue;
clients_.remove(s);
s->disconnectFromHost();
s->deleteLater();
}
}
// ---------- 工具 ----------
QJsonObject statusJson() const {
return {{"state", currentState()},
{"freeMB", double(storage_.freeMb())},
{"normalCount", double(storage_.listFiles("normal").size())},
{"eventCount", double(storage_.listFiles("event").size())}};
}
QString currentState() const { return stateFn_ ? stateFn_() : QString(); }
static qint64 nowMs() { return QDateTime::currentMSecsSinceEpoch(); }
static quint16 crc16(const char* d, int n) {
quint32 crc = 0xFFFF;
for (int i = 0; i < n; ++i) {
crc ^= quint32(uchar(d[i])) << 8;
for (int b = 0; b < 8; ++b)
crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xFFFF : (crc << 1) & 0xFFFF;
}
return quint16(crc);
}
static QString firstLanIp() {
QString fallback;
const auto ifaces = QNetworkInterface::allInterfaces();
for (const QNetworkInterface& i : ifaces) {
const auto fl = i.flags();
if (!fl.testFlag(QNetworkInterface::IsUp) || fl.testFlag(QNetworkInterface::IsLoopBack)) continue;
const QString name = i.name();
if (name.startsWith("rmnet") || name.startsWith("ccmni") || name.startsWith("tun")
|| name.startsWith("tap") || name.startsWith("p2p") || name.startsWith("swlan")) continue;
for (const QNetworkAddressEntry& e : i.addressEntries())
if (e.ip().protocol() == QAbstractSocket::IPv4Protocol) {
if (name.startsWith("wlan") || name.startsWith("eth"))
return e.ip().toString();
if (fallback.isEmpty()) fallback = e.ip().toString();
}
}
return fallback.isEmpty() ? QStringLiteral("0.0.0.0") : fallback;
}
};
#endif // DVRPROTOSERVER_H
css
#ifndef RTSPSERVER_H
#define RTSPSERVER_H
// =====================================================================
// RtspServer.h -- RTSP 直播预览服务器(端口8554,实时数据面)
// 协议:RTSP over TCP(RFC2326) + interleaved RTP(RFC3550) + H264打包
// (RFC6184 Single-NAL/FU-A)。ffplay/VLC 直接可播。
// 省略RTCP发送方报告、无鉴权、
// 仅TCP交织传输。分流规则:首字节'$'=交织二进制帧,字母=RTSP文本信令。
// =====================================================================
#include <QTcpServer>
#include <QTcpSocket>
#include <QHash>
#include <QVector>
#include <QByteArray>
#include <QRandomGenerator>
#include <QMetaObject>
#include <QDateTime>
#include <QNetworkInterface>
#include <QDebug>
#include <functional>
class RtspServer : public QTcpServer {
public:
using IdrFn = std::function<void()>;
void setIdrRequester(IdrFn fn) { idrFn_ = std::move(fn); }
QString url() const { return QString("rtsp://%1:8554/live").arg(ip_); }
bool start(quint16 port = 8554) {
connect(this, &QTcpServer::newConnection, this, [this]{ acceptClients(); });
if (!listen(QHostAddress::AnyIPv4, port)) {
qWarning() << "[rtsp] listen FAILED:" << errorString();
return false;
}
ip_ = firstLanIp();
qWarning() << "[rtsp] listening on" << ip_ << port;
return true;
}
// ---- 供 drainLoop(工作线程)调用:拷贝后队列投递回属主线程再发包 ----
// (QTcpSocket 只能在属主线程读写;QByteArray隐式共享,拷贝近零成本)
void setCodecConfig(const uint8_t* d, int n) { // SPS/PPS → 进SDP
QByteArray ba(reinterpret_cast<const char*>(d), n);
QMetaObject::invokeMethod(this, [this, ba]{ codecCfg_ = ba; },
Qt::QueuedConnection);
}
void pushFrame(const uint8_t* d, int n, qint64 ptsUs, bool key) {
QByteArray ba(reinterpret_cast<const char*>(d), n);
QMetaObject::invokeMethod(this,
[this, ba, ptsUs, key]{ streamFrame(ba, ptsUs, key); },
Qt::QueuedConnection);
}
private:
struct Session {
QByteArray buf;
QString sessionId;
bool playing = false;
bool waitingIdr = true; // 新观众必须从IDR入流,否则花屏
quint16 seq = 0;
quint32 ssrc = 0;
int dropStreak = 0;
};
static constexpr int MAX_PAYLOAD = 1400; // RTP载荷上限(FU-A分片阈值)
QHash<QTcpSocket*, Session> sessions_;
QByteArray codecCfg_; // Annex-B 格式 SPS+PPS
IdrFn idrFn_;
QString ip_;
// ---------- 连接 ----------
void acceptClients() {
while (QTcpSocket* s = nextPendingConnection()) {
s->setSocketOption(QAbstractSocket::LowDelayOption, 1);
Session& se = sessions_[s];
se.ssrc = QRandomGenerator::global()->generate();
se.seq = quint16(QRandomGenerator::global()->generate());
se.sessionId = QString::number(QDateTime::currentMSecsSinceEpoch());
connect(s, &QTcpSocket::readyRead, this, [this, s]{ onReadyRead(s); });
connect(s, &QTcpSocket::disconnected, this,
[this, s]{ sessions_.remove(s); s->deleteLater(); });
qWarning() << "[rtsp] client" << s->peerAddress().toString() << "in";
}
}
// ---------- 接收:先分流 $二进制 / 文本信令 ----------
void onReadyRead(QTcpSocket* s) {
QByteArray& b = sessions_[s].buf;
b += s->readAll();
if (b.size() > (1 << 20)) { s->disconnectFromHost(); return; }
forever {
if (b.isEmpty()) return;
if (uchar(b[0]) == '$') { // 交织二进制帧(客户端RTCP等)
if (b.size() < 4) return;
const int len = (uchar(b[2]) << 8) | uchar(b[3]);
if (b.size() < 4 + len) return;
b.remove(0, 4 + len); // 接收端RR我们不需要,丢弃
continue;
}
const int idx = b.indexOf("\r\n\r\n"); // 文本信令:空行结尾
if (idx < 0) return;
const QByteArray msg = b.left(idx);
b.remove(0, idx + 4);
handleMessage(s, msg);
if (!sessions_.contains(s)) return; // 可能已被TEARDOWN清理
}
}
// ---------- RTSP 信令(文本,形似HTTP) ----------
void handleMessage(QTcpSocket* s, const QByteArray& msg) {
const QList<QByteArray> lines = msg.split('\n');
if (lines.isEmpty()) return;
const QByteArray first = lines.first().trimmed();
const int sp1 = first.indexOf(' ');
if (sp1 < 0) return;
const QString method = QString::fromLatin1(first.left(sp1));
QString cseq = "0", transport, sessionHdr;
for (int i = 1; i < lines.size(); ++i) {
const QByteArray ln = lines[i].trimmed();
const int c = ln.indexOf(':');
if (c <= 0) continue;
const QString k = QString::fromLatin1(ln.left(c).trimmed()).toLower();
const QString v = QString::fromLatin1(ln.mid(c + 1).trimmed());
if (k == "cseq") cseq = v;
else if (k == "transport") transport = v;
else if (k == "session") sessionHdr = v;
}
qWarning() << "[rtsp]" << method << "cseq=" << cseq;
if (method == "OPTIONS") {
respond(s, cseq, "Public: OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN\r\n");
} else if (method == "DESCRIBE") {
respond(s, cseq, QString("Content-Base: %1/\r\n").arg(url()),
sdp(), "application/sdp");
} else if (method == "SETUP") {
if (!transport.contains("RTP/AVP/TCP") || !transport.contains("interleaved")) {
respondErr(s, cseq, 461, "Unsupported transport (use -rtsp_transport tcp)");
return;
}
respond(s, cseq, QString("Session: %1\r\n"
"Transport: RTP/AVP/TCP;interleaved=0-1\r\n")
.arg(sessions_[s].sessionId));
} else if (method == "PLAY") {
Session& se = sessions_[s];
se.playing = true;
se.waitingIdr = true; // 从下一个IDR开始发
if (idrFn_) idrFn_(); // 让编码器立刻出IDR,亚秒入画
respond(s, cseq, QString("Session: %1\r\n").arg(se.sessionId));
qWarning() << "[rtsp] PLAY, waiting IDR...";
} else if (method == "TEARDOWN") {
sessions_[s].playing = false;
respond(s, cseq, QString());
s->disconnectFromHost();
} else { // GET_PARAMETER保活等 → 200
respond(s, cseq, QString());
}
}
// ---------- SDP(DESCRIBE应答体:告诉播放器这是什么流) ----------
QByteArray sdp() const {
QString fmtp = QStringLiteral("packetization-mode=1");
QByteArray spsB64, ppsB64;
int pr = 0x42, co = 0xC0, lv = 0x1F;
for (const QByteArray& nal : splitNals(codecCfg_)) {
const quint8 t = uchar(nal[0]) & 0x1F;
if (t == 7 && spsB64.isEmpty()) { // SPS
spsB64 = nal.toBase64();
if (nal.size() >= 4) { pr = uchar(nal[1]); co = uchar(nal[2]); lv = uchar(nal[3]); }
}
if (t == 8 && ppsB64.isEmpty()) ppsB64 = nal.toBase64(); // PPS
}
if (!spsB64.isEmpty() && !ppsB64.isEmpty())
fmtp += QString(";profile-level-id=%1%2%3;sprop-parameter-sets=%4,%5")
.arg(pr, 2, 16, QChar('0')).arg(co, 2, 16, QChar('0'))
.arg(lv, 2, 16, QChar('0'))
.arg(QString::fromLatin1(spsB64), QString::fromLatin1(ppsB64));
return QString("v=0\r\n"
"o=- 0 0 IN IP4 0.0.0.0\r\n"
"s=DVR Live\r\n"
"t=0 0\r\n"
"m=video 0 RTP/AVP 96\r\n"
"a=rtpmap:96 H264/90000\r\n"
"a=fmtp:96 %1\r\n"
"a=control:trackID=0\r\n").arg(fmtp).toUtf8();
}
// ---------- 推流:IDR门控 + 背压丢帧 ----------
void streamFrame(const QByteArray& frame, qint64 ptsUs, bool key) {
if (sessions_.isEmpty()) return;
const quint32 ts = quint32(ptsUs * 9 / 100); // μs → 90kHz时钟
for (auto it = sessions_.begin(); it != sessions_.end(); ++it) {
Session& se = it.value();
if (!se.playing) continue;
if (se.waitingIdr) { // P帧无参考帧无法解
if (!key) continue; // → 丢到IDR为止
se.waitingIdr = false;
qWarning() << "[rtsp] session enters at IDR";
}
QTcpSocket* sock = it.key();
if (sock->bytesToWrite() > 4 * 1024 * 1024) { // 直播宁可丢、不可堆
if (++se.dropStreak == 1 || se.dropStreak % 90 == 0)
qWarning() << "[rtsp] client slow, dropping";
continue;
}
se.dropStreak = 0;
writeFrame(sock, se, frame, ts);
}
}
void writeFrame(QTcpSocket* sock, Session& se,
const QByteArray& frame, quint32 ts) {
const QVector<QByteArray> nals = splitNals(frame);
for (int i = 0; i < nals.size(); ++i) {
const QByteArray& nal = nals[i];
const bool lastNal = (i == nals.size() - 1);
if (nal.size() <= MAX_PAYLOAD)
sendRtp(sock, se, nal.constData(), nal.size(), ts, lastNal);
else
sendFuA(sock, se, nal, ts, lastNal);
}
}
// Annex-B(00 00 01/00 00 00 01分隔) → NAL列表(去起始码与定界0)
static QVector<QByteArray> splitNals(const QByteArray& annexB) {
QVector<QByteArray> out;
const char* p = annexB.constData();
const int n = annexB.size();
QVector<int> sc;
for (int i = 0; i + 2 < n; ++i)
if (uchar(p[i]) == 0 && uchar(p[i+1]) == 0 && uchar(p[i+2]) == 1)
sc.append(i);
for (int k = 0; k < sc.size(); ++k) {
const int b = sc[k] + 3;
int e = (k + 1 < sc.size()) ? sc[k+1] : n;
while (e > b && uchar(p[e-1]) == 0) --e; // 尾部0属下一帧定界
if (e > b) out.append(annexB.mid(b, e - b));
}
return out;
}
// 单NAL包(小NAL)
void sendRtp(QTcpSocket* sock, Session& se, const char* pay, int len,
quint32 ts, bool marker) {
QByteArray rtp;
rtp.reserve(12 + len);
rtp += char(0x80); // V=2
rtp += char((marker ? 0x80 : 0x00) | 96); // M + PT=96
rtp += char(se.seq >> 8); rtp += char(se.seq & 0xFF); ++se.seq;
rtp += char(ts >> 24); rtp += char(ts >> 16);
rtp += char(ts >> 8); rtp += char(ts);
rtp += char(se.ssrc >> 24); rtp += char(se.ssrc >> 16);
rtp += char(se.ssrc >> 8); rtp += char(se.ssrc);
rtp.append(pay, len);
QByteArray pkt;
pkt.reserve(rtp.size() + 4);
pkt += '$'; pkt += char(0); // channel 0 = RTP
pkt += char(rtp.size() >> 8); pkt += char(rtp.size() & 0xFF);
pkt += rtp;
sock->write(pkt);
}
// FU-A分片(大NAL:一个I帧常见100KB+)
void sendFuA(QTcpSocket* sock, Session& se, const QByteArray& nal,
quint32 ts, bool lastNalOfFrame) {
const quint8 nh = uchar(nal[0]);
const char ind = char((nh & 0xE0) | 28); // 保留F/NRI + type=28
const quint8 ftype = nh & 0x1F;
const QByteArray body = nal.mid(1);
int off = 0;
bool first = true;
while (off < body.size()) {
const int chunk = qMin(MAX_PAYLOAD - 2, body.size() - off);
const bool last = (off + chunk >= body.size());
QByteArray pay;
pay += ind;
pay += char((first ? 0x80 : 0) | (last ? 0x40 : 0) | ftype); // S|E|type
pay.append(body.constData() + off, chunk);
sendRtp(sock, se, pay.constData(), pay.size(), ts,
last && lastNalOfFrame); // M=1标记帧尾
off += chunk;
first = false;
}
}
// ---------- 应答与工具 ----------
static void respond(QTcpSocket* s, const QString& cseq, const QString& extra,
const QByteArray& body = {}, const char* ctype = nullptr) {
QByteArray r = "RTSP/1.0 200 OK\r\nCSeq: " + cseq.toUtf8() + "\r\n"
+ extra.toUtf8();
if (!body.isEmpty()) {
r += "Content-Length: " + QByteArray::number(body.size()) + "\r\n";
if (ctype) r += "Content-Type: " + QByteArray(ctype) + "\r\n";
}
r += "\r\n";
r += body;
s->write(r);
}
static void respondErr(QTcpSocket* s, const QString& cseq, int code,
const QString& why) {
s->write("RTSP/1.0 " + QByteArray::number(code) + " " + why.toUtf8()
+ "\r\nCSeq: " + cseq.toUtf8() + "\r\n\r\n");
}
static QString firstLanIp() { // 与 DvrProtoServer.h 完全相同
QString fallback;
const auto ifaces = QNetworkInterface::allInterfaces();
for (const QNetworkInterface& i : ifaces) {
const auto fl = i.flags();
if (!fl.testFlag(QNetworkInterface::IsUp) || fl.testFlag(QNetworkInterface::IsLoopBack)) continue;
const QString name = i.name();
if (name.startsWith("rmnet") || name.startsWith("ccmni") || name.startsWith("tun")
|| name.startsWith("tap") || name.startsWith("p2p") || name.startsWith("swlan")) continue;
for (const QNetworkAddressEntry& e : i.addressEntries())
if (e.ip().protocol() == QAbstractSocket::IPv4Protocol) {
if (name.startsWith("wlan") || name.startsWith("eth"))
return e.ip().toString();
if (fallback.isEmpty()) fallback = e.ip().toString();
}
}
return fallback.isEmpty() ? QStringLiteral("0.0.0.0") : fallback;
}
};
#endif // RTSPSERVER_H
css
#ifndef VIDEOENCODER_H
#define VIDEOENCODER_H
#include <media/NdkMediaCodec.h>
#include <media/NdkMediaFormat.h>
#include <android/native_window.h>
#include "Config.h"
// =====================================================================
// VideoEncoder.h -- 视频编码器
// =====================================================================
class VideoEncoder {
public:
/** 创建编码器并返回输入Surface(Surface输入=零拷贝,相机帧直达VPU硬件) */
bool create(ANativeWindow** outWindow) {
AMediaFormat* fmt = AMediaFormat_new();
AMediaFormat_setString(fmt, AMEDIAFORMAT_KEY_MIME, "video/avc");
AMediaFormat_setInt32(fmt, AMEDIAFORMAT_KEY_WIDTH, Config::W);
AMediaFormat_setInt32(fmt, AMEDIAFORMAT_KEY_HEIGHT, Config::H);
AMediaFormat_setInt32(fmt, AMEDIAFORMAT_KEY_BIT_RATE, Config::BITRATE);
AMediaFormat_setInt32(fmt, AMEDIAFORMAT_KEY_FRAME_RATE, Config::FPS);
AMediaFormat_setInt32(fmt, AMEDIAFORMAT_KEY_I_FRAME_INTERVAL, Config::I_FRAME);
// 0x7F000789 = COLOR_FormatSurface。
// NDK头文件未定义此常量,取Java层CodecCapabilities的同值魔数:
AMediaFormat_setInt32(fmt, AMEDIAFORMAT_KEY_COLOR_FORMAT, 2130708361);
codec_ = AMediaCodec_createEncoderByType("video/avc");
bool ok = codec_ &&
AMediaCodec_configure(codec_, fmt, nullptr, nullptr,
AMEDIACODEC_CONFIGURE_FLAG_ENCODE) == AMEDIA_OK &&
AMediaCodec_createInputSurface(codec_, outWindow) == AMEDIA_OK;
AMediaFormat_delete(fmt);
if (!ok) return false;
window_ = *outWindow;
ANativeWindow_acquire(window_); // 持引用,防相机还在写时被提前销毁
return AMediaCodec_start(codec_) == AMEDIA_OK;
}
AMediaCodec* get() const { return codec_; }
/** 主动请求一个IDR帧(NDK键名为字符串"request-sync") */
void requestSyncFrame() {
if (!codec_) return;
AMediaFormat* p = AMediaFormat_new();
AMediaFormat_setInt32(p, "request-sync", 0);
AMediaCodec_setParameters(codec_, p);
AMediaFormat_delete(p);
}
void release() {
if (codec_) { AMediaCodec_stop(codec_); AMediaCodec_delete(codec_); codec_ = nullptr; }
if (window_) { ANativeWindow_release(window_); window_ = nullptr; }
}
private:
AMediaCodec* codec_ = nullptr;
ANativeWindow* window_ = nullptr;
};
#endif // VIDEOENCODER_H
3. 碰撞检测和回放
GSensor监测事件强度,当dev>2.0时,封存当前端并落盘为事件视频,并生成缩略图,视频和文件的回放qml前端直接
| 功能 | 用途 | 代码文件/类名称 |
| 碰撞检测 | G-Sensor 阈值触发(G=2.5g / 3.4g) 触发特殊事件,自动生成事件视频 | GSensor RecordingEngine SegmentMuxerManager StorageManager |
| 文件回放 | 历史视频和图片文件的显示,对应【回放】菜单 | StorageManager RecordingEngine |
|---|
生成缩略图需要APP特性
css
//ThumbHelper.java
package org.qtproject.example.Dvrqt;
import android.graphics.Bitmap;
import android.media.MediaMetadataRetriever;
import java.io.FileOutputStream;
// Generates a 320x180 JPEG thumbnail from a video file.
// MediaMetadataRetriever = the standard Android way (Gallery, players).
// It does NOT decode the whole file: it seeks to a sync sample and
// decodes exactly one frame. That is why it is fast.
public class ThumbHelper {
public static boolean make(String video, String jpg, long atUs) {
MediaMetadataRetriever r = new MediaMetadataRetriever();
try {
r.setDataSource(video);
Bitmap b = r.getFrameAtTime(atUs,
MediaMetadataRetriever.OPTION_CLOSEST_SYNC);
if (b == null) return false;
Bitmap s = Bitmap.createScaledBitmap(b, 320, 180, true);
FileOutputStream f = new FileOutputStream(jpg);
s.compress(Bitmap.CompressFormat.JPEG, 80, f);
f.close();
return true;
} catch (Throwable t) {
return false;
} finally {
try { r.release(); } catch (Throwable ignored) {}
}
}
}
流程图

3.1碰撞检测流程图

3.2 回放功能流程图
核心代码
css
#ifndef GSENSOR_H
#define GSENSOR_H
// =====================================================================
// G-sensor(加速度传感器)碰撞/急刹检测
// 物理量:手机任意姿态下重力方向未知,只有"模长"可用------
// |a| = sqrt(x^2+y^2+z^2),静止时约等于1g(9.81 m/s^2)
// 事件指标:dev = | |a|/9.81 - 1 |,即偏离静止重力多少g
// 抗误触发:连续3个采样(50Hz下60ms)超阈值才算事件;触发后5秒冷却
// 线程模型:独占线程跑 ALooper 轮询(NDK的事件循环);回调由调用方投递主线程
// NDK兼容:
// - pollAll已在新NDK标记unavailable → 用pollOnce(轮询模式下行为一致)
// - 队列ident是调用者自选整数(1/2/3/-2为框架保留,旧LOOPER_ID_USER已删) → 自定义常量
// =====================================================================
#include <android/sensor.h>
#include <android/looper.h>
#include <atomic>
#include <thread>
#include <chrono>
#include <cmath>
#include <functional>
class GSensor {
public:
using Cb = std::function<void(float devG)>; // devG = 事件强度(偏差g数)
bool start(float thresholdG, Cb cb) {
thresholdG_ = thresholdG;
cb_ = std::move(cb);
running_ = true;
thread_ = std::thread([this]{ loop(); });
return true;
}
void stop() {
running_ = false;
if (thread_.joinable()) thread_.join();
}
private:
// ★ 修复:自选队列ident。pollOnce唤醒时原样返回此值;
// 避开框架保留的1/2/3和-2,也避开poll自身的返回值(-1~-4)
static constexpr int SENSOR_LOOPER_ID = 100;
void loop() {
// ALooper:NDK版的"每线程一个事件循环"(类似Qt的事件循环但更底层)
ALooper* looper = ALooper_prepare(ALOOPER_PREPARE_ALLOW_NON_CALLBACKS);
ASensorManager* mgr =
#if __ANDROID_API__ >= 26
ASensorManager_getInstanceForPackage("org.qtproject.example.Dvrqt");
#else
ASensorManager_getInstance();
#endif
if (!looper || !mgr) return;
ASensorEventQueue* q = ASensorManager_createEventQueue(
mgr, looper, SENSOR_LOOPER_ID, nullptr, nullptr); // ★ 修复1
const ASensor* acc =
ASensorManager_getDefaultSensor(mgr, ASENSOR_TYPE_ACCELEROMETER);
if (!q || !acc) {
if (q) ASensorManager_destroyEventQueue(mgr, q);
return;
}
ASensorEventQueue_enableSensor(q, acc);
ASensorEventQueue_setEventRate(q, acc, 20000); // 20000us=20ms=50Hz
int aboveCnt = 0;
while (running_) {
// pollAll→pollOnce:差异仅在回调处理数量;非回调轮询模式下行为一致
const int id = ALooper_pollOnce(500, nullptr, nullptr, nullptr);
if (!running_) break;
if (id != SENSOR_LOOPER_ID) continue; // ★ 修复2:超时(-3)等→查退出标志
ASensorEvent e;
while (ASensorEventQueue_getEvents(q, &e, 1) > 0) {
const float gx = e.acceleration.x; // 单位 m/s^2,含重力
const float gy = e.acceleration.y;
const float gz = e.acceleration.z;
const float dev = std::fabs(
std::sqrt(gx*gx + gy*gy + gz*gz) / 9.81f - 1.0f);
const auto now = std::chrono::steady_clock::now();
if (now < cooldownUntil_) continue; // 冷却期:只吞不判
if (dev > thresholdG_) {
if (++aboveCnt >= 3) { // 3连击→真事件
aboveCnt = 0;
cooldownUntil_ = now + std::chrono::milliseconds(5000);
if (cb_) cb_(dev); // 调用方负责跨线程投递
}
} else {
aboveCnt = 0; // 连击被打断→重新计数
}
}
}
ASensorEventQueue_disableSensor(q, acc);
#if __ANDROID_API__ >= 29
ASensorManager_destroyEventQueue(mgr, q); // 同时释放队列持有的looper引用
#else
ALooper_release(looper); // 老API无destroy接口,兜底释放
#endif
}
float thresholdG_ = 2.0f;
Cb cb_;
std::atomic<bool> running_{false};
std::thread thread_;
std::chrono::steady_clock::time_point cooldownUntil_{};
};
#endif // GSENSOR_H
css
#ifndef SEGMENTMUXERMANAGER_H
#define SEGMENTMUXERMANAGER_H
#include <media/NdkMediaCodec.h>
#include <media/NdkMediaFormat.h>
#include <atomic>
#include <memory>
#include <functional>
#include <QString>
#include <QFile>
#include "SegmentMuxer.h"
#include "StorageManager.h"
#include "Config.h"
/** 编码器永不停,只在IDR帧边界切换Muxer("秒切")。
* 切换触发源(都要求isKey才真正切):
* ① 周期切换:segDur >= 60s
* ② 紧急锁档:requestLock() 标记 → 切段后文件进 event/ 并置只读 */
class SegmentMuxerManager {
public:
using FinishedCb = std::function<void(const QString& file, qint64 durUs, qint64 bytes)>;
using LockedCb = std::function<void(const QString& file, qint64 durUs, qint64 bytes, float g)>;
SegmentMuxerManager(const StorageManager& s, FinishedCb cb, LockedCb lcb)
: storage_(s), cb_(std::move(cb)), lcb_(std::move(lcb)) {}
void attach(AMediaCodec* codec) { codec_ = codec; }
QString currentFilePath() const { return current_ ? current_->filePath() : QString(); }
void openFirstSegment() { openSegment(); }
// 紧急锁档 ------ 下一个IDR切段后,文件移入event/并置只读
void requestLock(float g) {
lastG_ = g;
pendingLock_ = true;
pendingRotate_ = true;
}
void write(const uint8_t* data, const AMediaCodecBufferInfo& info) {
if (!current_ || !current_->isStarted()) return;
if (segStartUs_ < 0) segStartUs_ = info.presentationTimeUs;
lastPtsUs_ = info.presentationTimeUs;
const bool isKey = (info.flags & AMEDIACODEC_BUFFER_FLAG_KEY_FRAME) != 0;
const qint64 segDur = lastPtsUs_ - segStartUs_;
const bool due = segDur >= Config::SEGMENT_DURATION_US;
if (isKey && (due || pendingRotate_.exchange(false))) {
finishCurrent(segDur);
openSegment();
segStartUs_ = lastPtsUs_; // 新段起点 = 当前IDR帧
}
current_->write(data, info);
}
void release() {
pendingLock_ = false; // 停机时未及切换的锁档请求作废
pendingRotate_ = false;
if (!current_) return;
const QString f = current_->filePath();
const qint64 durUs = (segStartUs_ >= 0 && lastPtsUs_ > segStartUs_)
? (lastPtsUs_ - segStartUs_) : 0;
const qint64 bytes = current_->stop();
current_.reset();
if (bytes > 0 && durUs > 0) {
cb_(f, durUs, bytes);
storage_.enforceQuota();
} else {
QFile::remove(f);
}
}
private:
void openSegment() {
auto m = std::make_unique<SegmentMuxer>();
if (!m->open(storage_.newNormalFile())) return;
AMediaFormat* fmt = AMediaCodec_getOutputFormat(codec_);
bool ok = m->start(fmt);
AMediaFormat_delete(fmt);
if (!ok) return;
current_ = std::move(m);
}
void finishCurrent(qint64 durUs) {
const QString f = current_->filePath();
const qint64 bytes = current_->stop();
if (pendingLock_.exchange(false)) { // 锁档:先搬走,再配额
const QString ev = storage_.moveToEvent(f);
if (!ev.isEmpty()) {
storage_.enforceQuota(); // 只清normal/,event/幸存
if (lcb_) lcb_(ev, durUs, bytes, lastG_.load());
return;
}
// 移动失败(极端) → 降级为普通段,数据不丢
}
storage_.enforceQuota();
cb_(f, durUs, bytes);
}
const StorageManager& storage_;
FinishedCb cb_;
LockedCb lcb_;
AMediaCodec* codec_ = nullptr;
std::unique_ptr<SegmentMuxer> current_;
std::atomic<bool> pendingRotate_{false};
std::atomic<bool> pendingLock_{false};
std::atomic<float> lastG_{0.0f};
qint64 segStartUs_ = -1;
qint64 lastPtsUs_ = -1;
};
#endif // SEGMENTMUXERMANAGER_H
css
#ifndef STORAGEMANAGER_H
#define STORAGEMANAGER_H
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFileInfoList>
#include <QStorageInfo>
#include <QDateTime>
#include <algorithm>
#include "Config.h"
class StorageManager {
public:
explicit StorageManager(const QString& rootPath)
: rootPath_(rootPath), normal_(rootPath + "/normal"), eventPath_(rootPath + "/event") {
normal_.mkpath(".");
QDir().mkpath(eventPath_);
}
QString newNormalFile() const {
return normal_.filePath(
QDateTime::currentDateTime().toString("yyyyMMdd_HHmmsszzz") + "_front.mp4");
}
void enforceQuota() const {
while (freeMb() < Config::KEEP_FREE_MB) {
auto files = listNormalFiles();
if (files.isEmpty()) break;
QFile::remove(files.first().absoluteFilePath());
}
}
QFileInfoList listNormalFiles() const {
auto files = normal_.entryInfoList({"*.mp4"}, QDir::Files);
std::sort(files.begin(), files.end(), [](const QFileInfo& a, const QFileInfo& b){
return a.lastModified() < b.lastModified();
});
return files;
}
qint64 freeMb() const {
return QStorageInfo(rootPath_).bytesAvailable() / 1048576;
}
// =================事件锁档与缩略图 =================
// 锁档 = normal/ 移入 event/ + 置只读。
// 循环覆盖(enforceQuota)只清 normal/,锁档文件因此幸存------"锁"的本质。
// 返回 event/ 下新路径;失败返回空串(调用方降级为普通段,数据不丢)
QString moveToEvent(const QString& normalFile) const {
const QString dst = eventPath_ + "/" + QFileInfo(normalFile).fileName();
if (!QFile::rename(normalFile, dst)) return QString();
QFile::setPermissions(dst, QFile::permissions(dst)
& ~(QFile::WriteOwner | QFile::WriteUser
| QFile::WriteGroup | QFile::WriteOther));
return dst;
}
QString eventDir() const { return eventPath_; }
QString normalDir() const { return normal_.absolutePath(); }
QString thumbDir() const { QDir().mkpath(rootPath_ + "/thumb"); return rootPath_ + "/thumb"; }
// ================= TCP/HTTP共用 =================
// event/ 目录天然被覆盖:LIST event / GET event/xxx 直接可用(第2周成果)
QString pathFor(const QString& dir, const QString& name) const {
QDir d = (dir == "normal") ? normal_ : QDir(eventPath_);
const QString root = QFileInfo(d.absolutePath()).canonicalFilePath();
const QString canon = QFileInfo(d.filePath(name)).canonicalFilePath();
return (canon.startsWith(root + "/")) ? canon : QString();
}
QFileInfoList listFiles(const QString& dir) const {
QDir d = (dir == "normal") ? normal_ : QDir(eventPath_);
auto files = d.entryInfoList({"*.mp4"}, QDir::Files);
std::sort(files.begin(), files.end(), [](const QFileInfo& a, const QFileInfo& b){
return a.lastModified() < b.lastModified();
});
return files;
}
bool removeFile(const QString& dir, const QString& name) const {
const QString p = pathFor(dir, name);
return !p.isEmpty() && QFile::remove(p);
}
// ================= 删除(相册) =================
// 删除视频(dir="normal"/"event"),连带删除配对缩略图
bool deleteVideo(const QString& dir, const QString& name) const {
const QString p = pathFor(dir, name);
if (p.isEmpty()) return false;
if (dir == "event") // 解锁=锁语义的反向操作(恢复moveToEvent剥夺的写权限)。
QFile::setPermissions(p, QFile::permissions(p) // 注: POSIX的unlink只看
| QFile::WriteOwner | QFile::WriteUser // 父目录写权限,这里显式
| QFile::WriteGroup | QFile::WriteOther);// 解锁是为语义对称/跨平台
const bool ok = QFile::remove(p);
if (ok) // 配对封面一并清理;直接拼rootPath_而非thumbDir(),避免误建目录
QFile::remove(rootPath_ + "/thumb/"
+ QFileInfo(name).completeBaseName() + ".jpg");
return ok;
}
// 删除图片(仅限thumb/下纯jpg文件名,防路径穿越)
bool deletePhoto(const QString& name) const {
if (name.contains('/') || name.contains('\\')) return false;
if (!name.endsWith(QStringLiteral(".jpg"))) return false;
return QFile::remove(rootPath_ + "/thumb/" + name);
}
private:
QString rootPath_, eventPath_;
QDir normal_;
};
#endif // STORAGEMANAGER_H