Qt/QML音视频文件原始十六进制查看器
前言
在做音视频工具时,很多问题只看 FFmpeg 解析后的字段并不够。比如:
- MP4 的
ftyp、moov、mdat到底在文件哪个位置; - WAV/AVI 的
RIFF、fmt、data块大小是否正确; - 某段元数据、魔数或 ASCII 字符串是否真的存在于原始文件里;
- 文件损坏时,容器结构是否还能被粗略定位。
AudioTools 里的RawHexPage.qml就是为这类场景做的原始字节查看页。它不是简单把整个文件读成字符串,而是使用一个 C++ 自绘 QML 控件HexViewerItem,按可见区域读取文件,支持十六进制/ASCII 双列显示、搜索、复制、跳转和基础容器结构标记。
本文基于当前实现,拆解这个页面从 QML 到 C++ 的设计。
效果图:
一、功能概览
RawHexPage.qml提供三个核心区域:
顶部控制面板
显示当前文件、文件大小、当前 Offset、选区、容器结构、搜索结果。中间十六进制查看器
使用HexViewer自绘控件展示 Offset / Hex / ASCII 三列内容。右侧结构标记栏
展示识别出的 RIFF 或 ISO BMFF 结构块,点击可定位到对应字节范围。
页面功能包括:
| 功能 | 说明 |
|---|---|
| 每行字节数切换 | 支持 8、16、24、32 字节/行 |
| Offset 跳转 | 支持输入0000:0020、0x20、20等形式 |
| 文件头/文件尾定位 | 快速跳到开头或结尾 |
| 鼠标选区 | 在 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++ 自绘控件
十六进制查看器如果直接用 QMLRepeater或TextArea拼完整文本,会很快遇到性能问题:
- 大文件不能一次性读入内存;
- 每个字节都变成 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));LIST和RIFF可以包含子 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 和内存拖垮。
