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

📅 2026/7/4 4:23:09
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 的类型qmlRegisterTypeHexViewerItem(AudioTools,1,0,HexViewer);页面只负责 UI 编排和调用控件暴露出来的Q_INVOKABLE方法。例如跳转 Offsetfunction 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_fileSize0)return;// 大文件浏览的关键是虚拟化读取只加载可见行附近的数据不把全文件转成字符串。constqint64 readOffsetm_firstVisibleRow*m_bytesPerRow;constqint64 requestedLengthstatic_castqint64(visibleRows4)*m_bytesPerRow;constintreadLengthstatic_castint(std::minqint64(requestedLength,m_fileSize-readOffset));if(readOffsetm_visibleDataOffsetreadLengthm_visibleDataLength)return;if(!m_file.seek(readOffset)){m_visibleData.clear();m_visibleDataOffset-1;m_visibleDataLength0;return;}m_visibleDatam_file.read(readLength);m_visibleDataOffsetreadOffset;m_visibleDataLengthm_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 offsetLeft12.0;qreal offsetWidth96.0;qreal hexLeft120.0;qreal hexCellWidth30.0;qreal asciiLeft620.0;qreal asciiCellWidth10.0;qreal contentTop38.0;intvisibleRows1;};绘制主循环按可见行和每行字节数遍历for(intvisualRow0;visualRowlayout.visibleRows;visualRow){constqint64 rowm_firstVisibleRowvisualRow;constqint64 rowOffsetrow*m_bytesPerRow;if(rowOffsetm_fileSize)break;painter-drawText(QRectF(layout.offsetLeft,y,layout.offsetWidth,layout.rowHeight),Qt::AlignLeft|Qt::AlignVCenter,formatOffset(rowOffset));for(intcolumn0;columnm_bytesPerRow;column){constqint64 offsetrowOffsetcolumn;if(offsetm_fileSize)break;constintdataIndexstatic_castint(offset-m_visibleDataOffset);constunsignedcharvaluestatic_castunsignedchar(m_visibleData.at(dataIndex));constQRectF hexRecthexByteRect(layout,visualRow,column);constQRectF asciiRectasciiByteRect(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 maxRowmaxFirstVisibleRow();if(maxRow0){setFirstVisibleRow(0,true);return;}constqreal safeRatioclampValueqreal(ratio,0.0,1.0);setFirstVisibleRow(static_castqint64(std::round(safeRatio*maxRow)),true);}这样 UI 滚动条不需要知道文件大小、每行字节数和可见行数量只处理 0 到 1 的比例。七、Offset 跳转和选区页面提供 Offset 输入框TextField { id: offsetInput placeholderText: Offset例如 0000:0020 onAccepted: page.performJump() }C 控件暴露Q_INVOKABLEbooljumpToHexOffset(constQStringtext);Q_INVOKABLEvoidscrollToOffset(qint64 offset);Q_INVOKABLE QStringactiveOffsetText()const;Q_INVOKABLE QStringselectionRangeText()const;鼠标点击和拖拽通过坐标反算文件 Offsetqint64HexViewerItem::offsetAtPosition(constQPointFposition,boolclampToVisible)const{constLayoutInfo layoutlayoutInfo();intvisualRowstatic_castint(std::floor((position.y()-layout.contentTop)/layout.rowHeight));...constqint64 offset(m_firstVisibleRowvisualRow)*m_bytesPerRowcolumn;returnoffset;}拖拽时clampToVisible可以让鼠标横向稍微移出列区域时仍然吸附到最近字节列避免选区中断。选区复制通过重新打开文件读取选中字节QByteArrayHexViewerItem::selectedBytes(intmaxBytes,bool*truncated)const{if(!hasSelection()||m_filePath.isEmpty())returnQByteArray();QFilefile(m_filePath);if(!file.open(QIODevice::ReadOnly))returnQByteArray();constqint64 lengthselectionLength();constqint64 readLengthstd::minqint64(length,maxBytes);if(!file.seek(m_selectionStart))returnQByteArray();returnfile.read(readLength);}这里限制最大复制大小为 1MB避免用户误选超大范围后把大量文本塞进剪贴板。八、Hex/ASCII 搜索搜索入口在 QMLToolComboBox { id: searchModeBox model: [Hex, ASCII] } TextField { id: searchInput placeholderText: searchModeBox.currentIndex 0 ? 输入十六进制序列例如 52 49 46 46 : 输入 ASCII 文本例如 RIFF onAccepted: page.performSearch() }C 中先把用户输入转换成搜索字节序列QByteArrayHexViewerItem::parseSearchNeedle(constQStringpattern,constQStringmode)const{constQString trimmedpattern.trimmed();if(trimmed.isEmpty())returnQByteArray();constQString normalizedModemode.trimmed().toLower();if(normalizedModeQStringLiteral(ascii))returntrimmed.toUtf8();QString cleanedtrimmed;cleaned.remove(QRegularExpression(QStringLiteral([^0-9A-Fa-f])));if(cleaned.size()%2!0)returnQByteArray();returnQByteArray::fromHex(cleaned.toLatin1());}搜索采用分块读取避免一次性把全文件放进内存constexprqint64 kSearchChunkSize2*1024*1024;constexprintkMaxSearchMatches4096;QByteArray overlap;qint64 chunkBaseOffset0;while(!file.atEnd()m_searchMatches.size()kMaxSearchMatches){constQByteArray chunkfile.read(kSearchChunkSize);QByteArray windowoverlapchunk;constqint64 windowBaseOffsetchunkBaseOffset-overlap.size();intsearchFrom0;while(m_searchMatches.size()kMaxSearchMatches){constintindexwindow.indexOf(needle,searchFrom);if(index0)break;SearchMatch match;match.offsetwindowBaseOffsetindex;match.lengthneedle.size();m_searchMatches.append(match);searchFromindex1;}overlapneedle.size()1?window.right(needle.size()-1):QByteArray();chunkBaseOffsetchunk.size();}overlap是关键如果搜索目标刚好跨越两个 2MB 分块边界没有 overlap 就会漏匹配。九、容器结构标记Raw Hex 页面不仅显示字节还会做轻量容器结构分析。当前支持RIFF/RF64 系列例如 WAV、AVIISO BMFF 系列例如 MP4、MOV、M4A。入口函数voidHexViewerItem::performStructureAnalysis(){clearStructureAnalysis();QFilefile(m_filePath);if(!file.open(QIODevice::ReadOnly)){emitstructureChanged();return;}boolparsedparseRiffStructure(file);if(!parsed){file.seek(0);parsedparseIsoBmffStructure(file);}if(!parsed)m_containerNameQStringLiteral(未识别结构);rebuildStructureMarkerData();updateActiveStructureIndex();emitstructureChanged();}RIFF 结构解析RIFF chunk 是小端长度constQString chunkNamesafeAsciiLabel(header.left(4));constquint32 chunkPayloadSizereadLe32(header,4);constqint64 totalChunkSizestd::maxqint64(8,8static_castqint64(chunkPayloadSize)(chunkPayloadSize%2));LIST和RIFF可以包含子 chunk所以递归解析if((chunkNameQStringLiteral(LIST)||chunkNameQStringLiteral(RIFF))safeChunkSize12){parseRiffChunks(file,offset12,offsetsafeChunkSize,level1,depth1);}ISO BMFF 结构解析MP4/MOV 的 box 使用大端长度quint64 boxSizereadBe32(header,0);constQString typesafeAsciiLabel(header.mid(4,4));intheaderSize8;if(boxSize1){headerfile.read(8);boxSizereadBe64(header,8);headerSize16;}elseif(boxSize0){boxSizeend-offset;}容器类 box 会继续递归解析if(isIsoContainerBox(type)safeBoxSizeheaderSize){qint64 childStartoffsetheaderSize;if(typeQStringLiteral(meta))childStart4;parseIsoBoxes(file,childStart,offsetsafeBoxSize,level1,depth1);}结构标记最终转成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){constStructureMarkermarkerm_structureMarkers.at(index);constqint64 endOffsetmarker.offsetstd::maxqint64(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 和内存拖垮。