前言
在做音视频工具时,很多问题只看 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++ 控件:
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 Repeater 或 TextArea 拼完整文本,会很快遇到性能问题:
- 大文件不能一次性读入内存;
- 每个字节都变成 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));
LIST 和 RIFF 可以包含子 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 和内存拖垮。