自定义表头只需要重写 headerData(),但一个可用的表格模型还必须让行数、行数据和索引规则一致。原例把目录和文件都放入 fileInfos,另一个 fileListData 却只加入成功读取的文件;遇到子目录或读取失败时,两组数据会错位,甚至在 at(index.row()) 处越界。
下面把一个已读取文件与它的四个字段放入同一个 Row。保留原来的四列表头和内容:上限频率、下限频率、线数、测量值。文件名放在提示文本中,完整路径通过 Qt::UserRole 提供;如果要显示文件名,可再显式增加一列,不能只修改注释。
表格模型的基本契约
跳转到“表格模型的基本契约”rowCount()与columnCount()对有效的父索引返回0,因为这是平面表格,没有子行。data()检查索引是否属于本模型、行列是否在范围内、角色是否支持。headerData()区分横向字段名与纵向行号,并检查范围。- 替换整批数据时,先准备新数据,再用
beginResetModel()/endResetModel()包住实际替换。
表格的根是无效的 QModelIndex()。原来的 getRootIndex() 返回首行首格,它不是文件目录树的根;给表格设置目录路径和给树模型设置根索引是不同操作。Qt:QAbstractTableModel
本例的文件规则
跳转到“本例的文件规则”程序读取指定目录下的普通文件,不递归进入子目录。每行按第一个 ASCII 冒号 : 分隔,首尾空白会被清理;值中的后续冒号保留。重复的已知字段以后一次为准,缺少字段显示“未知”,未知字段和没有分隔符的行忽略。频率中的 Hz 暂时作为文本保留,不把它误当成可直接参与计算的数值。
示例最多处理 1000 个候选文件,每个文件最多 64 KiB,使用 UTF-8 解码。每个文件是一个完整输入,解码器使用 Stateless,使文件末尾不完整的多字节序列也报告错误。Qt:QStringConverter 标志Qt:QStringConverter 标志打不开、读取失败、超限或含无效 UTF-8 的文件跳过,并记录原因;行数只计算真正加入的数据行。无效目录或文件数量超限时保留上一批模型数据,并返回失败。
这是面向小目录的同步示例。大量文件解析应移到工作线程,完成后在模型所属线程应用结果;不能在后台线程直接调用连接到界面的模型更新方法。
完整程序与实际文件检查
跳转到“完整程序与实际文件检查”普通启动传入目录路径;--check 会创建独立临时目录,写入正常文件、缺字段文件、超限文件、无效 UTF-8 文件和末尾截断的 UTF-8 文件,再使用 Qt 自带的模型检查器验证接口。测试文件随临时目录对象销毁而清理。
#include <QAbstractItemModelTester>#include <QAbstractTableModel>#include <QApplication>#include <QDir>#include <QFile>#include <QFileInfo>#include <QModelIndex>#include <QStringConverter>#include <QStringList>#include <QTableView>#include <QTemporaryDir>#include <QVariant>#include <QVector>#include <array>#include <cassert>#include <utility>
class TaskFileModel final : public QAbstractTableModel {public: explicit TaskFileModel(QObject *parent = nullptr) : QAbstractTableModel(parent) {} static constexpr qint64 maxFileBytes = 64 * 1024; static constexpr qsizetype maxFiles = 1000;
QString rootPath() const { return root; } QString lastError() const { return error; } QStringList skippedFiles() const { return skipped; } bool reload() { return setRootPath(root); }
bool setRootPath(const QString &path) { error.clear(); QDir dir(path); if (path.trimmed().isEmpty() || !dir.exists()) { error = QStringLiteral("Directory does not exist"); return false; } const QFileInfoList entries = dir.entryInfoList(QDir::Files, QDir::Name); if (entries.size() > maxFiles) { error = QStringLiteral("Too many files for this synchronous example"); return false; } QVector<Row> next; QStringList nextSkipped; for (const QFileInfo &info : entries) { QFile file(info.absoluteFilePath()); if (!file.open(QIODevice::ReadOnly)) { nextSkipped.append(info.fileName() + ": " + file.errorString()); continue; } const QByteArray bytes = file.read(maxFileBytes + 1); if (file.error() != QFileDevice::NoError || bytes.size() > maxFileBytes) { nextSkipped.append(info.fileName() + ": read failed or file too large"); continue; } QStringDecoder decoder(QStringDecoder::Utf8, QStringConverter::Flag::Stateless); const QString text = decoder(bytes); if (decoder.hasError()) { nextSkipped.append(info.fileName() + ": invalid UTF-8"); continue; } Row row; row.fileName = info.fileName(); row.path = info.absoluteFilePath(); row.fields.fill(QStringLiteral("未知")); for (const QString &line : text.split('\n')) { const qsizetype separator = line.indexOf(':'); if (separator < 0) continue; const QString key = line.left(separator).trimmed(); const QString value = line.mid(separator + 1).trimmed(); for (int column = 0; column < 4; ++column) if (key == fieldName(column)) row.fields[column] = value; } next.append(std::move(row)); } beginResetModel(); root = dir.absolutePath(); rows = std::move(next); skipped = std::move(nextSkipped); endResetModel(); return true; } int rowCount(const QModelIndex &parent = {}) const override { return parent.isValid() ? 0 : static_cast<int>(rows.size()); } int columnCount(const QModelIndex &parent = {}) const override { return parent.isValid() ? 0 : 4; } QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override { if (!index.isValid() || index.model() != this || index.row() < 0 || index.row() >= rowCount() || index.column() < 0 || index.column() >= 4) return {}; const Row &row = rows.at(index.row()); if (role == Qt::DisplayRole) return row.fields[index.column()]; if (role == Qt::ToolTipRole) return row.fileName; if (role == Qt::UserRole) return row.path; return {}; } QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override { if (role != Qt::DisplayRole || section < 0) return {}; if (orientation == Qt::Horizontal) return section < 4 ? QVariant(fieldName(section)) : QVariant{}; return section < rowCount() ? QVariant(section + 1) : QVariant{}; }private: struct Row { QString fileName; QString path; std::array<QString, 4> fields; }; static QString fieldName(int column) { static const std::array<QString, 4> names{ QStringLiteral("上限频率"), QStringLiteral("下限频率"), QStringLiteral("线数"), QStringLiteral("测量值")}; return names.at(static_cast<std::size_t>(column)); } QString root; QString error; QStringList skipped; QVector<Row> rows;};
static bool writeFixture(const QString &path, const QByteArray &bytes) { QFile file(path); return file.open(QIODevice::WriteOnly) && file.write(bytes) == bytes.size();}static void checkModel() { QTemporaryDir temporary; assert(temporary.isValid()); const QDir dir(temporary.path()); assert(dir.mkdir("subdirectory")); const QByteArray normal = QStringLiteral( "上限频率: 800Hz\n下限频率: 20Hz\n线数: 10\n测量值: 123.45\n").toUtf8(); assert(writeFixture(dir.filePath("a.txt"), normal)); assert(writeFixture(dir.filePath("b.txt"), QStringLiteral( "上限频率: 400Hz\n上限频率: 500Hz\r\n测量值: 1:2\n").toUtf8())); assert(writeFixture(dir.filePath("oversize.txt"), QByteArray(TaskFileModel::maxFileBytes + 1, 'x'))); assert(writeFixture(dir.filePath("invalid.txt"), QByteArray(1, char(0xff)))); assert(writeFixture(dir.filePath("truncated.txt"), normal + QByteArray::fromHex("e4b8")));
TaskFileModel model; QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::Fatal); assert(model.rowCount() == 0 && model.columnCount() == 4); assert(model.setRootPath(temporary.path())); assert(model.rowCount() == 2 && model.skippedFiles().size() == 3); assert(model.data(model.index(0, 0)).toString() == QStringLiteral("800Hz")); assert(model.data(model.index(0, 1)).toString() == QStringLiteral("20Hz")); assert(model.data(model.index(0, 2)).toString() == QStringLiteral("10")); assert(model.data(model.index(0, 3)).toString() == QStringLiteral("123.45")); assert(model.data(model.index(1, 0)).toString() == QStringLiteral("500Hz")); assert(model.data(model.index(1, 2)).toString() == QStringLiteral("未知")); assert(model.data(model.index(1, 3)).toString() == QStringLiteral("1:2")); assert(model.data(model.index(0, 0), Qt::ToolTipRole).toString() == "a.txt"); assert(model.data(model.index(0, 0), Qt::UserRole).toString() == dir.filePath("a.txt")); assert(model.headerData(0, Qt::Horizontal).toString() == QStringLiteral("上限频率")); assert(model.headerData(1, Qt::Vertical).toInt() == 2); assert(!model.headerData(4, Qt::Horizontal).isValid()); assert(!model.data({}).isValid() && !model.index(2, 0).isValid()); assert(!model.data(model.index(0, 0), Qt::DecorationRole).isValid()); assert(model.rowCount(model.index(0, 0)) == 0); assert(model.columnCount(model.index(0, 0)) == 0); TaskFileModel another; assert(another.setRootPath(temporary.path())); assert(!model.data(another.index(0, 0)).isValid()); const QString rootBeforeFailure = model.rootPath(); assert(!model.setRootPath(dir.filePath("missing"))); assert(!model.lastError().isEmpty() && model.rootPath() == rootBeforeFailure); assert(model.rowCount() == 2); assert(QFile::remove(dir.filePath("a.txt"))); assert(model.reload() && model.rowCount() == 1); assert(model.data(model.index(0, 0), Qt::ToolTipRole).toString() == "b.txt"); assert(model.setRootPath(dir.filePath("subdirectory")) && model.rowCount() == 0);}
int main(int argc, char *argv[]) { QApplication app(argc, argv); if (app.arguments().contains("--check")) { checkModel(); return 0; } if (app.arguments().size() != 2) return 1; // 传入一个目录路径 QTableView view; auto *model = new TaskFileModel(&view); if (!model->setRootPath(app.arguments().at(1))) return 2; view.setModel(model); view.resize(640, 360); view.show(); return app.exec();}cmake_minimum_required(VERSION 3.20)project(FileTableModel LANGUAGES CXX)set(CMAKE_CXX_STANDARD 17)set(CMAKE_CXX_STANDARD_REQUIRED ON)find_package(Qt6 REQUIRED COMPONENTS Widgets Test)add_executable(file_table main.cpp)target_link_libraries(file_table PRIVATE Qt6::Widgets Qt6::Test)Debug 构建保留检查中的断言。Qt Test 模块用于 QAbstractItemModelTester;它可以发现多种模型契约错误,但不会替应用决定字段语义、解析规则或文件错误策略。Qt:模型检查器
原示例目录 D:/shawei/temp/jpbalance/temp/TaskTemplates 只是历史工程路径;使用时传入自己的模板目录。模型对象以视图为父对象,窗口销毁时一并清理。重载目录采用模型重置,之前保存的索引和选择可能失效;需要跨次定位某个文件时,保留稳定的文件标识并在新模型中重新查找,不长期保存旧的普通 QModelIndex。