设备数据在界面上显示出来,只完成了采集流程的一部分。
实际项目通常还需要查询某个时间段的记录、导出测试结果,或者在设备异常后回看数据变化。对于单机上位机,SQLite 不需要额外部署数据库服务,比较适合保存结构化采集记录。
下面以时间戳、设备编号、通道编号和测量值为例,介绍基本的数据保存流程。
一、创建数据库和数据表
项目需要引入 SQL 模块:
find_package(Qt6 REQUIRED COMPONENTS Sql)
target_link_libraries(your_app PRIVATE Qt6::Sql)
创建命名连接:
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QSqlError>
#include <QVariant>
#include <QDebug>
QSqlDatabase db =
QSqlDatabase::addDatabase("QSQLITE", "acquisition");
db.setDatabaseName("/your/data/directory/device_data.db");
if (!db.open()) {
qWarning() << db.lastError().text();
return;
}
数据库目录需要事先存在。部署程序时,也要确保包含 Qt 的 SQLite 驱动插件。
创建表和索引:
QSqlQuery query(db);
if (!query.exec(R"(
CREATE TABLE IF NOT EXISTS samples (
id INTEGER PRIMARY KEY,
timestamp INTEGER NOT NULL,
device TEXT NOT NULL,
channel INTEGER NOT NULL,
value REAL NOT NULL
)
)")) {
qWarning() << query.lastError().text();
return;
}
if (!query.exec(R"(
CREATE INDEX IF NOT EXISTS idx_samples_device_time
ON samples(device, timestamp)
)")) {
qWarning() << query.lastError().text();
return;
}
timestamp 使用 Unix 毫秒时间戳。设备编号和时间组成索引,便于按设备查询某个时间段的数据。
时间戳应在采集时确定,不要等到批量写库时才统一生成,否则无法还原真实采样时间。
二、使用事务批量写入
频繁写入时,每条记录单独提交会增加事务开销。可以先积累一批数据,再在一个事务中写入。
struct Sample {
qint64 timestamp;
QString device;
int channel;
double value;
};
批量保存函数:
#include <QVector>
bool insertBatch(QSqlDatabase& db,
const QVector<Sample>& samples,
QString* error)
{
if (samples.isEmpty())
return true;
if (!db.transaction()) {
if (error)
*error = db.lastError().text();
return false;
}
QSqlQuery query(db);
if (!query.prepare(R"(
INSERT INTO samples(timestamp, device, channel, value)
VALUES(?, ?, ?, ?)
)")) {
if (error)
*error = query.lastError().text();
db.rollback();
return false;
}
for (const auto& sample : samples) {
query.bindValue(0, sample.timestamp);
query.bindValue(1, sample.device);
query.bindValue(2, sample.channel);
query.bindValue(3, sample.value);
if (!query.exec()) {
if (error)
*error = query.lastError().text();
db.rollback();
return false;
}
}
if (!db.commit()) {
if (error)
*error = db.lastError().text();
db.rollback();
return false;
}
return true;
}
这里使用参数绑定,不把设备名称等内容直接拼进 SQL。
批量大小可以按条数或时间控制,例如累计 500 条,或者每隔 500 ms 提交一次。具体数值应结合设备速率和可接受的数据丢失窗口调整。
内存里尚未提交的数据,在程序异常退出时可能丢失,因此不能只追求大批量,也要考虑及时落盘。
三、按设备和时间范围查询
下面查询某台设备在指定时间段内的前 1000 条数据:
QSqlQuery query(db);
query.prepare(R"(
SELECT timestamp, channel, value
FROM samples
WHERE device = ?
AND timestamp >= ?
AND timestamp < ?
ORDER BY timestamp, id
LIMIT 1000
)");
query.addBindValue(deviceId);
query.addBindValue(beginMs);
query.addBindValue(endMs);
if (!query.exec()) {
qWarning() << query.lastError().text();
return;
}
while (query.next()) {
const qint64 timestamp = query.value(0).toLongLong();
const int channel = query.value(1).toInt();
const double value = query.value(2).toDouble();
// 添加到历史数据模型或曲线缓存。
}
时间范围使用左闭右开区间:
beginMs <= timestamp < endMs
这样连续查询相邻时间段时,不会重复包含边界时刻的数据。
这里的 LIMIT 1000 用于限制界面一次加载的数据量,不代表这个时间段只有 1000 条记录。数据较多时应实现分页,导出时则需要读取完整范围。
四、导出 CSV 文件
导出可以边查询边写文件,不必先把所有结果装进内存。
下面用 QSaveFile 写出文件,只有全部成功后才提交,避免把半成品当成完整结果:
#include <QSaveFile>
#include <QTextStream>
#include <QStringConverter>
bool exportCsv(QSqlDatabase& db,
const QString& path,
const QString& deviceId,
qint64 beginMs,
qint64 endMs)
{
QSqlQuery query(db);
query.setForwardOnly(true);
query.prepare(R"(
SELECT timestamp, channel, value
FROM samples
WHERE device = ?
AND timestamp >= ?
AND timestamp < ?
ORDER BY timestamp, id
)");
query.addBindValue(deviceId);
query.addBindValue(beginMs);
query.addBindValue(endMs);
if (!query.exec())
return false;
QSaveFile file(path);
if (!file.open(QIODevice::WriteOnly))
return false;
QTextStream out(&file);
out.setEncoding(QStringConverter::Utf8);
out.setGenerateByteOrderMark(true);
out << "时间戳毫秒,通道,测量值\n";
while (query.next()) {
out << QString::number(query.value(0).toLongLong()) << ','
<< QString::number(query.value(1).toInt()) << ','
<< QString::number(query.value(2).toDouble(), 'g', 17)
<< '\n';
}
if (query.lastError().isValid())
return false;
out.flush();
if (out.status() != QTextStream::Ok)
return false;
return file.commit();
}
这个示例只导出数值字段。如果增加设备名称、备注等文本列,应正确处理逗号、双引号和换行:通常把字段包在双引号中,并把字段内的 " 替换为 ""。
导出记录很多时,应放到工作线程执行,并增加进度和取消操作。
五、数据库连接的线程与生命周期
Qt 数据库连接需要在所属线程中使用。不要在界面线程创建连接,再把同一连接拿到保存线程中直接写入。
比较清晰的做法是:
保存线程:自己的数据库连接,负责批量写入
查询线程:自己的数据库连接,负责历史查询和导出
界面线程:接收查询结果,更新控件
每个连接使用不同名称。SQLite 同一时刻仍只有一个写入者,多建几个写线程并不能直接提高写入性能。
释放命名连接时,需要先销毁相关 QSqlQuery,再释放所有引用该连接的 QSqlDatabase 对象,最后调用 removeDatabase()。
此外,设备长期运行时,数据库文件会持续增长。可以按日期拆分文件,或者设置数据保留周期,避免直到磁盘写满才发现问题。