Qt/QML音视频文件原始十六进制查看器

前言

在做音视频工具时,很多问题只看 FFmpeg 解析后的字段并不够。比如:

  • MP4 的 ftypmoovmdat 到底在文件哪个位置;
  • WAV/AVI 的 RIFFfmt data 块大小是否正确;
  • 某段元数据、魔数或 ASCII 字符串是否真的存在于原始文件里;
  • 文件损坏时,容器结构是否还能被粗略定位。

AudioTools 里的 RawHexPage.qml 就是为这类场景做的原始字节查看页。它不是简单把整个文件读成字符串,而是使用一个 C++ 自绘 QML 控件 HexViewerItem,按可见区域读取文件,支持十六进制/ASCII 双列显示、搜索、复制、跳转和基础容器结构标记。

本文基于当前实现,拆解这个页面从 QML 到 C++ 的设计。


效果图:

一、功能概览

RawHexPage.qml 提供三个核心区域:

  1. 顶部控制面板

    显示当前文件、文件大小、当前 Offset、选区、容器结构、搜索结果。

  2. 中间十六进制查看器

    使用 HexViewer 自绘控件展示 Offset / Hex / ASCII 三列内容。

  3. 右侧结构标记栏

    展示识别出的 RIFF 或 ISO BMFF 结构块,点击可定位到对应字节范围。

页面功能包括:

功能 说明
每行字节数切换 支持 8、16、24、32 字节/行
Offset 跳转 支持输入 0000:00200x2020 等形式
文件头/文件尾定位 快速跳到开头或结尾
鼠标选区 在 Hex 或 ASCII 区拖拽选中字节
复制 Hex/ASCII 复制选区内容到剪贴板
Hex/ASCII 搜索 支持十六进制序列或 ASCII 文本搜索
搜索结果导航 上一条/下一条循环定位
容器结构识别 当前支持 RIFF 系列和 ISO BMFF,如 WAV、AVI、MP4、MOV、M4A

二、QML 页面如何组织

页面入口是 qml/pages/inspect/RawHexPage.qml。它本身不直接读取文件,而是把当前导入文件路径绑定给 C++ 控件:

qml 复制代码
HexViewer {
    id: hexView
    anchors.fill: parent
    anchors.rightMargin: 14
    filePath: mediaAnalyzer.currentFile
    bytesPerRow: bytesPerRowBox.model[bytesPerRowBox.currentIndex]
}

这里 HexViewer 是 C++ 注册到 QML 的类型:

cpp 复制代码
qmlRegisterType<HexViewerItem>("AudioTools", 1, 0, "HexViewer");

页面只负责 UI 编排和调用控件暴露出来的 Q_INVOKABLE 方法。例如跳转 Offset:

qml 复制代码
function performJump() {
    actionMessage = hexView.jumpToHexOffset(offsetInput.text)
        ? "已跳转"
        : "Offset 无效"
}

搜索也是同样的模式:

qml 复制代码
function performSearch() {
    var mode = searchModeBox.currentIndex === 0 ? "hex" : "ascii"
    actionMessage = hexView.runSearch(searchInput.text, mode)
        ? "已定位到首个命中"
        : "未命中或搜索内容无效"
}

这种设计让 QML 保持很薄:它不关心文件读取、搜索算法、容器解析和绘制细节,只绑定属性、展示状态并响应按钮点击。


三、为什么用 C++ 自绘控件

十六进制查看器如果直接用 QML RepeaterTextArea 拼完整文本,会很快遇到性能问题:

  • 大文件不能一次性读入内存;
  • 每个字节都变成 QML delegate 会产生大量对象;
  • 搜索、选区、结构高亮都需要精确到字节;
  • Offset / Hex / ASCII 三列需要稳定对齐。

因此 HexViewerItem 继承自 QQuickPaintedItem,自己控制绘制:

cpp 复制代码
class HexViewerItem : public QQuickPaintedItem
{
    Q_OBJECT
    Q_PROPERTY(QString filePath READ filePath WRITE setFilePath NOTIFY fileChanged)
    Q_PROPERTY(qint64 fileSize READ fileSize NOTIFY fileChanged)
    Q_PROPERTY(int bytesPerRow READ bytesPerRow WRITE setBytesPerRow NOTIFY layoutChanged)
    Q_PROPERTY(qint64 activeOffset READ activeOffset NOTIFY cursorChanged)
    Q_PROPERTY(qint64 selectionLength READ selectionLength NOTIFY selectionChanged)
    Q_PROPERTY(qreal scrollRatio READ scrollRatio WRITE setScrollRatio NOTIFY viewChanged)
    Q_PROPERTY(QString searchSummary READ searchSummary NOTIFY searchChanged)
    Q_PROPERTY(QString containerName READ containerName NOTIFY structureChanged)
    Q_PROPERTY(QVariantList structureMarkers READ structureMarkers NOTIFY structureChanged)
    ...
};

这些属性一方面供 QML 显示状态,另一方面让 QML 的滚动条、按钮启用状态和结构侧栏可以直接绑定。


四、大文件浏览的关键:只读可见区域

实现十六进制查看器时最重要的一点是:不要把整个文件转成字符串。

当前实现只缓存可见行附近的数据:

cpp 复制代码
void HexViewerItem::updateVisibleCache(int visibleRows)
{
    if (!m_file.isOpen() || m_fileSize <= 0)
        return;

    // 大文件浏览的关键是虚拟化读取:只加载可见行附近的数据,不把全文件转成字符串。
    const qint64 readOffset = m_firstVisibleRow * m_bytesPerRow;
    const qint64 requestedLength = static_cast<qint64>(visibleRows + 4) * m_bytesPerRow;
    const int readLength = static_cast<int>(std::min<qint64>(requestedLength, m_fileSize - readOffset));
    if (readOffset == m_visibleDataOffset && readLength == m_visibleDataLength)
        return;

    if (!m_file.seek(readOffset)) {
        m_visibleData.clear();
        m_visibleDataOffset = -1;
        m_visibleDataLength = 0;
        return;
    }

    m_visibleData = m_file.read(readLength);
    m_visibleDataOffset = readOffset;
    m_visibleDataLength = m_visibleData.size();
}

这里有几个设计点:

  • m_firstVisibleRow 决定当前屏幕顶部是哪一行;
  • 每行字节数由 m_bytesPerRow 决定;
  • 只读取 visibleRows + 4 行,给滚动留一点缓冲;
  • 如果当前缓存范围没变,就不重复读取。

这使得查看几百 MB 甚至 GB 级音视频文件时,内存占用仍然稳定。


五、绘制 Offset / Hex / ASCII 三列

控件使用等宽字体保证三列稳定对齐:

cpp 复制代码
QFont viewerFont()
{
    // 十六进制查看器必须使用等宽字体,才能保证 Offset / Hex / ASCII 三列稳定对齐。
    QFont font(QStringLiteral("Consolas"));
    font.setStyleHint(QFont::Monospace);
    font.setFixedPitch(true);
    font.setPixelSize(14);
    return font;
}

布局数据集中在 LayoutInfo 中:

cpp 复制代码
struct LayoutInfo {
    qreal offsetLeft = 12.0;
    qreal offsetWidth = 96.0;
    qreal hexLeft = 120.0;
    qreal hexCellWidth = 30.0;
    qreal asciiLeft = 620.0;
    qreal asciiCellWidth = 10.0;
    qreal contentTop = 38.0;
    int visibleRows = 1;
};

绘制主循环按可见行和每行字节数遍历:

cpp 复制代码
for (int visualRow = 0; visualRow < layout.visibleRows; ++visualRow) {
    const qint64 row = m_firstVisibleRow + visualRow;
    const qint64 rowOffset = row * m_bytesPerRow;
    if (rowOffset >= m_fileSize)
        break;

    painter->drawText(QRectF(layout.offsetLeft, y, layout.offsetWidth, layout.rowHeight),
                      Qt::AlignLeft | Qt::AlignVCenter,
                      formatOffset(rowOffset));

    for (int column = 0; column < m_bytesPerRow; ++column) {
        const qint64 offset = rowOffset + column;
        if (offset >= m_fileSize)
            break;

        const int dataIndex = static_cast<int>(offset - m_visibleDataOffset);
        const unsigned char value = static_cast<unsigned char>(m_visibleData.at(dataIndex));
        const QRectF hexRect = hexByteRect(layout, visualRow, column);
        const QRectF asciiRect = asciiByteRect(layout, visualRow, column);

        painter->drawText(hexRect, Qt::AlignCenter, byteToHex(value));
        painter->drawText(asciiRect,
                          Qt::AlignCenter,
                          isPrintableAscii(value) ? QString(QChar(QLatin1Char(value))) : QStringLiteral("."));
    }
}

不可打印 ASCII 字节显示为 .,这是常见十六进制查看器的习惯。


六、滚动条如何和文件 Offset 对齐

QML 侧用一个 ScrollBar 绑定 C++ 控件的滚动比例:

qml 复制代码
ScrollBar {
    id: verticalBar
    orientation: Qt.Vertical
    policy: ScrollBar.AlwaysOn
    size: Math.min(1, Math.max(0.02, hexView.pageRatio))

    Binding {
        target: verticalBar
        property: "position"
        value: hexView.scrollRatio * verticalBar.travelRange
        when: !verticalBar.pressed
    }

    onPositionChanged: {
        if (pressed)
            hexView.scrollRatio = position / travelRange
    }
}

C++ 侧则把比例映射到首行行号:

cpp 复制代码
void HexViewerItem::setScrollRatio(qreal ratio)
{
    const qint64 maxRow = maxFirstVisibleRow();
    if (maxRow <= 0) {
        setFirstVisibleRow(0, true);
        return;
    }

    const qreal safeRatio = clampValue<qreal>(ratio, 0.0, 1.0);
    setFirstVisibleRow(static_cast<qint64>(std::round(safeRatio * maxRow)), true);
}

这样 UI 滚动条不需要知道文件大小、每行字节数和可见行数量,只处理 0 到 1 的比例。


七、Offset 跳转和选区

页面提供 Offset 输入框:

qml 复制代码
TextField {
    id: offsetInput
    placeholderText: "Offset,例如 0000:0020"
    onAccepted: page.performJump()
}

C++ 控件暴露:

cpp 复制代码
Q_INVOKABLE bool jumpToHexOffset(const QString &text);
Q_INVOKABLE void scrollToOffset(qint64 offset);
Q_INVOKABLE QString activeOffsetText() const;
Q_INVOKABLE QString selectionRangeText() const;

鼠标点击和拖拽通过坐标反算文件 Offset:

cpp 复制代码
qint64 HexViewerItem::offsetAtPosition(const QPointF &position, bool clampToVisible) const
{
    const LayoutInfo layout = layoutInfo();
    int visualRow = static_cast<int>(std::floor((position.y() - layout.contentTop) / layout.rowHeight));
    ...
    const qint64 offset = (m_firstVisibleRow + visualRow) * m_bytesPerRow + column;
    return offset;
}

拖拽时 clampToVisible 可以让鼠标横向稍微移出列区域时仍然吸附到最近字节列,避免选区中断。

选区复制通过重新打开文件读取选中字节:

cpp 复制代码
QByteArray HexViewerItem::selectedBytes(int maxBytes, bool *truncated) const
{
    if (!hasSelection() || m_filePath.isEmpty())
        return QByteArray();

    QFile file(m_filePath);
    if (!file.open(QIODevice::ReadOnly))
        return QByteArray();

    const qint64 length = selectionLength();
    const qint64 readLength = std::min<qint64>(length, maxBytes);
    if (!file.seek(m_selectionStart))
        return QByteArray();
    return file.read(readLength);
}

这里限制最大复制大小为 1MB,避免用户误选超大范围后把大量文本塞进剪贴板。


八、Hex/ASCII 搜索

搜索入口在 QML:

qml 复制代码
ToolComboBox {
    id: searchModeBox
    model: ["Hex", "ASCII"]
}

TextField {
    id: searchInput
    placeholderText: searchModeBox.currentIndex === 0
        ? "输入十六进制序列,例如 52 49 46 46"
        : "输入 ASCII 文本,例如 RIFF"
    onAccepted: page.performSearch()
}

C++ 中先把用户输入转换成搜索字节序列:

cpp 复制代码
QByteArray HexViewerItem::parseSearchNeedle(const QString &pattern, const QString &mode) const
{
    const QString trimmed = pattern.trimmed();
    if (trimmed.isEmpty())
        return QByteArray();

    const QString normalizedMode = mode.trimmed().toLower();
    if (normalizedMode == QStringLiteral("ascii"))
        return trimmed.toUtf8();

    QString cleaned = trimmed;
    cleaned.remove(QRegularExpression(QStringLiteral("[^0-9A-Fa-f]")));
    if (cleaned.size() % 2 != 0)
        return QByteArray();

    return QByteArray::fromHex(cleaned.toLatin1());
}

搜索采用分块读取,避免一次性把全文件放进内存:

cpp 复制代码
constexpr qint64 kSearchChunkSize = 2 * 1024 * 1024;
constexpr int kMaxSearchMatches = 4096;

QByteArray overlap;
qint64 chunkBaseOffset = 0;
while (!file.atEnd() && m_searchMatches.size() < kMaxSearchMatches) {
    const QByteArray chunk = file.read(kSearchChunkSize);
    QByteArray window = overlap + chunk;
    const qint64 windowBaseOffset = chunkBaseOffset - overlap.size();

    int searchFrom = 0;
    while (m_searchMatches.size() < kMaxSearchMatches) {
        const int index = window.indexOf(needle, searchFrom);
        if (index < 0)
            break;

        SearchMatch match;
        match.offset = windowBaseOffset + index;
        match.length = needle.size();
        m_searchMatches.append(match);
        searchFrom = index + 1;
    }

    overlap = needle.size() > 1 ? window.right(needle.size() - 1) : QByteArray();
    chunkBaseOffset += chunk.size();
}

overlap 是关键:如果搜索目标刚好跨越两个 2MB 分块边界,没有 overlap 就会漏匹配。


九、容器结构标记

Raw Hex 页面不仅显示字节,还会做轻量容器结构分析。当前支持:

  • RIFF/RF64 系列,例如 WAV、AVI;
  • ISO BMFF 系列,例如 MP4、MOV、M4A。

入口函数:

cpp 复制代码
void HexViewerItem::performStructureAnalysis()
{
    clearStructureAnalysis();

    QFile file(m_filePath);
    if (!file.open(QIODevice::ReadOnly)) {
        emit structureChanged();
        return;
    }

    bool parsed = parseRiffStructure(file);
    if (!parsed) {
        file.seek(0);
        parsed = parseIsoBmffStructure(file);
    }

    if (!parsed)
        m_containerName = QStringLiteral("未识别结构");

    rebuildStructureMarkerData();
    updateActiveStructureIndex();
    emit structureChanged();
}

RIFF 结构解析

RIFF chunk 是小端长度:

cpp 复制代码
const QString chunkName = safeAsciiLabel(header.left(4));
const quint32 chunkPayloadSize = readLe32(header, 4);
const qint64 totalChunkSize =
    std::max<qint64>(8, 8 + static_cast<qint64>(chunkPayloadSize) + (chunkPayloadSize % 2));

LISTRIFF 可以包含子 chunk,所以递归解析:

cpp 复制代码
if ((chunkName == QStringLiteral("LIST") || chunkName == QStringLiteral("RIFF"))
    && safeChunkSize > 12) {
    parseRiffChunks(file, offset + 12, offset + safeChunkSize, level + 1, depth + 1);
}

ISO BMFF 结构解析

MP4/MOV 的 box 使用大端长度:

cpp 复制代码
quint64 boxSize = readBe32(header, 0);
const QString type = safeAsciiLabel(header.mid(4, 4));
int headerSize = 8;

if (boxSize == 1) {
    header += file.read(8);
    boxSize = readBe64(header, 8);
    headerSize = 16;
} else if (boxSize == 0) {
    boxSize = end - offset;
}

容器类 box 会继续递归解析:

cpp 复制代码
if (isIsoContainerBox(type) && safeBoxSize > headerSize) {
    qint64 childStart = offset + headerSize;
    if (type == QStringLiteral("meta"))
        childStart += 4;
    parseIsoBoxes(file, childStart, offset + safeBoxSize, level + 1, depth + 1);
}

结构标记最终转成 QVariantList 给 QML 侧 ListView 展示:

cpp 复制代码
QVariantMap map;
map.insert(QStringLiteral("name"), marker.name);
map.insert(QStringLiteral("label"), marker.label);
map.insert(QStringLiteral("offset"), marker.offset);
map.insert(QStringLiteral("offsetText"), formatOffset(marker.offset));
map.insert(QStringLiteral("size"), marker.size);
map.insert(QStringLiteral("level"), marker.level);
map.insert(QStringLiteral("color"), marker.color);
m_structureMarkerData.append(map);

右侧结构栏点击后调用:

qml 复制代码
hexView.activateStructureMarker(index)

C++ 会跳转并选中该结构范围:

cpp 复制代码
void HexViewerItem::activateStructureMarker(int index)
{
    const StructureMarker &marker = m_structureMarkers.at(index);
    const qint64 endOffset = marker.offset + std::max<qint64>(0, marker.size - 1);
    setActiveOffset(marker.offset);
    setSelection(marker.offset, endOffset);
    ensureOffsetVisible(marker.offset);
}

十、页面状态如何绑定

顶部状态栏直接绑定 HexViewer 暴露的属性:

qml 复制代码
Text {
    text: "文件大小 " + page.formatSize(hexView.fileSize)
          + "    当前 Offset " + (hexView.activeOffset >= 0 ? hexView.activeOffsetText() : "-")
          + "    选区 " + (hexView.selectionLength > 0 ? hexView.selectionRangeText() : "-")
}

Text {
    text: "容器结构 " + (hexView.containerName.length > 0 ? hexView.containerName : "-")
          + "    当前结构 " + hexView.activeStructureLabel
          + "    搜索结果 " + hexView.searchSummary
}

按钮启用状态也基于属性绑定:

qml 复制代码
ActionButton {
    text: "复制 Hex"
    enabled: hexView.selectionLength > 0
    onClicked: {
        page.actionMessage = hexView.copySelectedHexToClipboard()
            ? "已复制 Hex"
            : "复制失败"
    }
}

这就是 QML + C++ 控件比较舒服的地方:重逻辑留在 C++,状态以属性暴露,QML 只做组合和反馈。


十一、实现取舍

当前实现有几个明确取舍:

取舍 原因
C++ 自绘而不是 QML delegate 避免大量 QML 对象,保证大文件浏览流畅
只读可见区域 防止大文件一次性读入内存
搜索最多记录 4096 个命中 避免高频字节模式造成大量结果
复制选区最多 1MB 防止误操作卡住剪贴板和 UI
结构解析只支持 RIFF 和 ISO BMFF 先覆盖音视频最常见容器,保持逻辑可控
结构解析有深度和数量限制 防止异常文件导致递归过深或标记过多

十二、小结

RawHexPage.qml 看起来是一个普通"十六进制查看页",但它的关键不在 UI 控件数量,而在数据规模控制:

  • 文件读取按可见区域虚拟化;
  • 搜索按 2MB 分块扫描并处理跨块匹配;
  • 绘制由 C++ 自绘控件完成;
  • 结构分析只做轻量容器标记;
  • QML 只负责面板、按钮、状态和侧栏组合。

这套实现适合音视频工具里的"原始数据检查"场景:既能快速查看字节,也能结合 RIFF/MP4 结构定位问题,而不会因为打开大文件就把 UI 和内存拖垮。

相关推荐
小c君tt1 小时前
QT笔记记录
开发语言·笔记·qt
澈2075 小时前
QT入门第十一天:数据库编程(上)SQLite入门与增删改查 | 零基础学QT
数据库·qt·sqlite
RTC实战笔记4 天前
实时互动数字人怎么做,才不是一个只会说话的视频?
音视频·数字人·rtc·数字人接入
用户805533698035 天前
不止三件套:QObject 属性系统全关键字与运行时反射!
c++·qt
xcyxiner5 天前
DicomViewer (vcpkg Windows和ubuntu编译)7
qt
Quz10 天前
QML Hello World 入门示例
qt
xcyxiner13 天前
DicomViewer (dcmtk读取dcm文件)5
qt
xcyxiner14 天前
DicomViewer (后台线程处理文件)4
qt
xcyxiner14 天前
DicomViewer (添加模型类)3
qt