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

📅 2026/7/4 4:32:14 👁️ 阅读次数 📝 编程学习
Qt/QML音视频文件原始十六进制查看器

前言

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

  • MP4 的ftypmoovmdat到底在文件哪个位置;
  • WAV/AVI 的RIFFfmtdata块大小是否正确;
  • 某段元数据、魔数或 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++ 控件:

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

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

qmlRegisterType<HexViewerItem>("AudioTools",1,0,"HexViewer");

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

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

搜索也是同样的模式:

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

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


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

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

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

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

classHexViewerItem:publicQQuickPaintedItem{Q_OBJECTQ_PROPERTY(QString filePath READ filePath WRITE setFilePath NOTIFY fileChanged)Q_PROPERTY(qint64 fileSize READ fileSize NOTIFY fileChanged)Q_PROPERTY(intbytesPerRow 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 的滚动条、按钮启用状态和结构侧栏可以直接绑定。


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

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

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

voidHexViewerItem::updateVisibleCache(intvisibleRows){if(!m_file.isOpen()||m_fileSize<=0)return;// 大文件浏览的关键是虚拟化读取:只加载可见行附近的数据,不把全文件转成字符串。constqint64 readOffset=m_firstVisibleRow*m_bytesPerRow;constqint64 requestedLength=static_cast<qint64>(visibleRows+4)*m_bytesPerRow;constintreadLength=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 三列

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

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

布局数据集中在LayoutInfo中:

structLayoutInfo{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;intvisibleRows=1;};

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

for(intvisualRow=0;visualRow<layout.visibleRows;++visualRow){constqint64 row=m_firstVisibleRow+visualRow;constqint64 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(intcolumn=0;column<m_bytesPerRow;++column){constqint64 offset=rowOffset+column;if(offset>=m_fileSize)break;constintdataIndex=static_cast<int>(offset-m_visibleDataOffset);constunsignedcharvalue=static_cast<unsignedchar>(m_visibleData.at(dataIndex));constQRectF hexRect=hexByteRect(layout,visualRow,column);constQRectF 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++ 控件的滚动比例:

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++ 侧则把比例映射到首行行号:

voidHexViewerItem::setScrollRatio(qreal ratio){constqint64 maxRow=maxFirstVisibleRow();if(maxRow<=0){setFirstVisibleRow(0,true);return;}constqreal safeRatio=clampValue<qreal>(ratio,0.0,1.0);setFirstVisibleRow(static_cast<qint64>(std::round(safeRatio*maxRow)),true);}

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


七、Offset 跳转和选区

页面提供 Offset 输入框:

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

C++ 控件暴露:

Q_INVOKABLEbooljumpToHexOffset(constQString&text);Q_INVOKABLEvoidscrollToOffset(qint64 offset);Q_INVOKABLE QStringactiveOffsetText()const;Q_INVOKABLE QStringselectionRangeText()const;

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

qint64HexViewerItem::offsetAtPosition(constQPointF&position,boolclampToVisible)const{constLayoutInfo layout=layoutInfo();intvisualRow=static_cast<int>(std::floor((position.y()-layout.contentTop)/layout.rowHeight));...constqint64 offset=(m_firstVisibleRow+visualRow)*m_bytesPerRow+column;returnoffset;}

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

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

QByteArrayHexViewerItem::selectedBytes(intmaxBytes,bool*truncated)const{if(!hasSelection()||m_filePath.isEmpty())returnQByteArray();QFilefile(m_filePath);if(!file.open(QIODevice::ReadOnly))returnQByteArray();constqint64 length=selectionLength();constqint64 readLength=std::min<qint64>(length,maxBytes);if(!file.seek(m_selectionStart))returnQByteArray();returnfile.read(readLength);}

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


八、Hex/ASCII 搜索

搜索入口在 QML:

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

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

QByteArrayHexViewerItem::parseSearchNeedle(constQString&pattern,constQString&mode)const{constQString trimmed=pattern.trimmed();if(trimmed.isEmpty())returnQByteArray();constQString normalizedMode=mode.trimmed().toLower();if(normalizedMode==QStringLiteral("ascii"))returntrimmed.toUtf8();QString cleaned=trimmed;cleaned.remove(QRegularExpression(QStringLiteral("[^0-9A-Fa-f]")));if(cleaned.size()%2!=0)returnQByteArray();returnQByteArray::fromHex(cleaned.toLatin1());}

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

constexprqint64 kSearchChunkSize=2*1024*1024;constexprintkMaxSearchMatches=4096;QByteArray overlap;qint64 chunkBaseOffset=0;while(!file.atEnd()&&m_searchMatches.size()<kMaxSearchMatches){constQByteArray chunk=file.read(kSearchChunkSize);QByteArray window=overlap+chunk;constqint64 windowBaseOffset=chunkBaseOffset-overlap.size();intsearchFrom=0;while(m_searchMatches.size()<kMaxSearchMatches){constintindex=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。

入口函数:

voidHexViewerItem::performStructureAnalysis(){clearStructureAnalysis();QFilefile(m_filePath);if(!file.open(QIODevice::ReadOnly)){emitstructureChanged();return;}boolparsed=parseRiffStructure(file);if(!parsed){file.seek(0);parsed=parseIsoBmffStructure(file);}if(!parsed)m_containerName=QStringLiteral("未识别结构");rebuildStructureMarkerData();updateActiveStructureIndex();emitstructureChanged();}

RIFF 结构解析

RIFF chunk 是小端长度:

constQString chunkName=safeAsciiLabel(header.left(4));constquint32 chunkPayloadSize=readLe32(header,4);constqint64 totalChunkSize=std::max<qint64>(8,8+static_cast<qint64>(chunkPayloadSize)+(chunkPayloadSize%2));

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

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

ISO BMFF 结构解析

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

quint64 boxSize=readBe32(header,0);constQString type=safeAsciiLabel(header.mid(4,4));intheaderSize=8;if(boxSize==1){header+=file.read(8);boxSize=readBe64(header,8);headerSize=16;}elseif(boxSize==0){boxSize=end-offset;}

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

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展示:

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);

右侧结构栏点击后调用:

hexView.activateStructureMarker(index)

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

voidHexViewerItem::activateStructureMarker(intindex){constStructureMarker&marker=m_structureMarkers.at(index);constqint64 endOffset=marker.offset+std::max<qint64>(0,marker.size-1);setActiveOffset(marker.offset);setSelection(marker.offset,endOffset);ensureOffsetVisible(marker.offset);}

十、页面状态如何绑定

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

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 }

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

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 和内存拖垮。