首页 / 资讯中心 / 文章详情

用Qt正则解析LRC歌词并实现同步滚动显示

用Qt正则解析LRC歌词并实现同步滚动显示 ★ FEATURED ARTICLE
写到第六篇这个“用Qt写一个简单的音乐播放器”系列的前面几篇我已经把窗口骨架、媒体播放、播放列表、播放进度和控制按钮都搭完了播放器能正常出声也能前后切歌。可听歌没有歌词总感觉少点灵魂尤其是中文歌不看字幕真容易听岔。这一篇就集中解决歌词功能读取 LRC 格式的歌词文件用正则表达式把时间戳和歌词文本拆开再让界面跟着 QMediaPlayer 的播放位置实时滚动。这里面的核心难点其实不在界面而在“解析”和“同步”两件事。LRC 歌词文件看起来很简单但真实世界里的歌词文件格式变体很多——时间格式有的是 [mm:ss.xx]、有的是 [mm:ss:xx]有的歌词一行带好几个时间戳还有 [ti:]、[ar:] 这类元数据行。你要是用 QString::indexOf 一个个去抠代码会越写越啰嗦还容易漏边界情况。正则表达式是处理这种半结构化文本的标准工具Qt 6 里面对应的是 QRegularExpression。同步则是靠 positionChanged 信号实时找到当前播放位置对应的歌词行再更新显示。这篇内容适合已经跑通前面几篇播放器代码、掌握信号槽和基础控件的读者我会从 LRC 格式讲起到正则设计、数据结构、完整解析器、同步逻辑、界面实现最后把高频问题一起整理出来照着敲一遍基本就能跑通。1. 项目背景与整体规划1.1 系列文章的前情回顾先快速回顾一下前面做了什么方便没跟上系列的朋友对齐。前面几篇完成了一个主窗口框架用 QMediaPlayer 接播放和暂停QMediaPlaylist 管理歌曲列表音量条和进度条联动还做了播放按钮的状态切换。到这一步程序已经能承担“播放器”的日常工作了但它看起来还是个半成品缺歌词显示这个听歌场景里最常用的功能。这一篇要加进去的东西从用户角度来看只是“播放时旁边有歌词跟着滚”但落到代码上至少要拆成四件事找到歌词文件、把 LRC 文本解析成结构化数据、根据播放位置算出当前是哪一句、把当前句渲染到界面上。这四件事互相独立可以分开开发、分开测试这也是实际的工程做法——别想着一步到位把功能拆小块每一块都能单独验证合起来才不容易出错。1.2 歌词功能拆解成四个子任务按我实际开发的顺序四个子任务分别是取词在同一首歌的文件目录下找同名 .lrc 文件比如 song.mp3 对应 song.lrc。解析读文件、处理编码然后用正则表达式把每一行拆成“时间戳 歌词文本”。同步连接 QMediaPlayer 的 positionChanged 信号实时获取播放位置定位到对应的歌词行。展示用一个列表控件或者自绘控件把歌词显示出来当前行高亮。这个顺序也很重要。解析是最底层的不依赖任何 UI可以先用 qDebug 把解析结果打出来验证同步需要播放器等解析稳定了再联调展示是最后一层甚至可以先用一个 QLabel 显示当前行验证逻辑通了再去做华丽的滚动效果。我见过不少新手一上来就想写 KTV 滚动特效结果解析还没调对全是无用功。1.3 为什么锁定正则表达式而不是手写解析肯定有人会问LRC 格式这么简单用 startsWith 和 mid 切开不就行了吗确实可以就拿今天要解析的最基础格式来说一行 [00:12.34]第一句歌词你完全可以写先定位 [再找 ]中间那段就是时间后面那段就是文本。问题出在格式变体上。真实环境里的 LRC 文件几乎不会老老实实按标准来。我见过不少文件把秒的小数部分写成 [00:12:34] 这种冒号分隔也见过分钟不补零的 [1:02.5]还有一行里面塞三四个时间戳的[00:12.34][00:28.90][00:45.10]副歌来了。这些情况要是全用字符串截取你得写多少分支每多一种格式代码就多一层 if没过多久就会变成一团浆糊。正则表达式解决的就是这个“一堆相似但又不完全一样”的问题。一个模式写清楚“方括号里是 1 到 3 位数字冒号后面是 2 位数字可选的秒小数部分用点或冒号分隔”同一段代码就能吃下所有常见变体而且一眼就能看懂你匹配的规则是什么。维护性完全不在一个档次。Qt 5 时代还有 QRegExp 和 QRegularExpression 两套 API到 Qt 6 就统一用 QRegularExpression 了这也是官方推荐的方向学它不吃亏。2. 先搞懂 LRC 歌词格式2.1 LRC 文件的“标准长相”动手写代码之前得先把要解析的格式彻底弄清楚。LRC 文件本质是一个纯文本文件每一行要么是元数据要么是时间标签加歌词文本。标准格式长这样[ti:歌名] [ar:歌手] [al:专辑] [by:随便谁] [00:12.34]第一句歌词 [00:15.20][00:18.55]副歌重复的句子 [00:20.00]拆开看就两类行。第一类是元数据行以 [ti:]、[ar:]、[al:]、[by:] 这类固定的字段名开头冒号后面是对应的值作用类似文件的属性信息。第二类是歌词行一个或多个时间标签 [mm:ss.xx]后面跟着这一句的歌词文本。注意 [00:20.00] 这种只有时间戳没有文本的行也是存在的可能是空拍或者乐器间奏解析的时候要能容忍它不能整个报错。2.2 实际文件里的格式变体规范是写在文档里的现实是残酷的。我自己的歌词库里翻一翻至少能找出下面几种非标准写法变体类型实际写法说明秒小数用冒号[00:12:34]标准里是点但很多老工具导出的是冒号分钟不补零[1:02.50]标准里分钟至少两位实际一位很常见小数部分一位[00:12.5]精度不用到百分之一秒一行多个时间戳[00:10.00][00:20.00]歌词表示同一句在多个时间点重复出现文本包含方括号[00:30.00]他说[别走]正文里可能含方括号解析要小心这些变体单看都不复杂但你架不住它们在同一个文件里组合出现。如果你只为了跑通自己手动整理的测试文件那怎么写都行可真正要解析从网上下载的各种来源的歌词时解析器必须能同时兼容上面这些情况。正则表达式的优势就在这里一个模式把可选部分用分组表达清楚所有变体一次性覆盖。2.3 解析策略元数据和歌词行分开处理我的做法是把解析分成两条线避免互相干扰。元数据行丢到 map 里存起来等需要显示歌名、歌手的时候再取歌词行则解析成“时间戳 文本”的结构化列表。判断一行属于哪一类很简单先用元数据的正则试一下命中就直接跳过没命中再用时间标签的正则去扫扫出几个时间戳就生成几条记录。这样做有个额外好处文本里如果碰巧出现 [00:12.34] 样式的字符串由于我们只用时间标签正则去匹配不会误伤元数据。反过来元数据正则带了行首行尾锚点也不会把普通歌词行当成元数据。两条线互不干扰出问题也好定位。3. 正则表达式设计解析器的核心3.1 时间标签一个模式吃下所有变体直接上代码时间标签的正则我最终定成了这个样子QRegularExpression timeTagExp( \\[(\\d{1,3}):(\\d{2})(?:[.:](\\d{1,2}))?\\]);拆开看每一段你就明白为什么这么写\\[和\\]匹配左右方括号。方括号在正则里有特殊含义所以必须转义。注意这是在 C 的普通字符串里反斜杠本身也要再转义一次所以写成了两个反斜杠。嫌难看可以直接用原生字符串字面量后面会说。(\\d{1,3})第一组捕获匹配 1 到 3 位数字对应分钟。允许 1 位是为了兼容 [1:02.50] 这种不补零的写法。:字面冒号匹配分钟和秒之间的分隔符。(\\d{2})第二组捕获匹配恰好两位数字对应秒。秒这一位我要求严格两位因为 LRC 里秒从来都是两位的放宽了反而容易误匹配。(?:[.:](\\d{1,2}))?整个组是可选的表示秒的小数部分。?:表示这是一个非捕获组我们只需要里面的内容不需要把这个组作为一个整体捕获。组内[.:]用字符类同时匹配点和冒号兼容 [00:12.34] 和 [00:12:34] 两种写法后面的(\\d{1,2})是第三组捕获匹配 1 到 2 位小数数字。这三个捕获组分别就是分钟、秒、小数部分。计算毫秒的时候分钟乘 60000秒乘 1000小数部分再根据位数换算。这样设计解析一个时间标签就变成了简单的四则运算没有任何分支判断。3.2 元数据标签与锚点的作用元数据行的正则更简单QRegularExpression metaExp( ^\\[(ti|ar|al|by)\\:(.*)\\]$);这里有两个必须讲清楚的写法^和$是行首行尾锚点作用是要求整个字符串从方括号开始、到方括号结束中间不能有多余内容。用了锚点之后歌词行 [00:12.34]第一句歌词 就不会被误判成元数据。(ti|ar|al|by)是分组加交替等价于匹配这四个字段名中的任意一个(.*)匹配冒号后面一直到行尾的任意内容点号匹配除换行符外的任意字符星号表示尽量多匹配正好把歌词文件里那些中文内容全部收进去。3.3 Qt 里的正则陷阱转义、isValid 与原生字符串用 QRegularExpression 写正则最坑的一点是 C 的字符串转义和正则转义叠加。正则里要写\dC 普通字符串里就必须写成\\d。如果你在 Qt 里把正则写成\[(\d{1,3}):(\d{2})\]不是正则错了而是 C 编译器已经把\[当成了非法转义序列程序根本编译不过。一个非常实用的技巧是用 C11 的原生字符串字面量前后加括号里面的反斜杠不用再双写QRegularExpression timeTagExp(R(\[(\d{1,3}):(\d{2})(?:[.:](\d{1,2}))?\]));写起来和你在正则测试网站上看到的一模一样复制粘贴不会出错。我强烈建议所有人在 Qt 项目里写正则都用这个姿势能省掉一多半的傻子错误。另外构造完 QRegularExpression 之后养成习惯检查一下 isValid()尤其是在正则写成变量、后续要复用的场景。简单正则一般不会出错但一旦出错程序不会立刻崩而是所有 match 都返回空排查起来非常费时间。if (!timeTagExp.isValid()) { qWarning() 正则无效: timeTagExp.errorString(); return {}; }4. 数据结构与解析器完整实现4.1 LyricLine 结构体与容器选择解析结果需要一个结构体来装。一个歌词行最核心的信息就两个这个时间戳是什么时候、这句歌词是什么内容。所以我定义成这样struct LyricLine { qint64 timestamp 0; // 单位毫秒方便和 QMediaPlayer 的位置信号直接比较 QString content; };用 qint64 而不是 int 是有讲究的。一首 24 分钟的歌时间戳换算成毫秒就有 1440000虽然 int 也放得下但 QMediaPlayer 的位置信号本身就是 qint64全程类型统一可以少写很多隐式转换也避免极端情况下的溢出问题。容器直接用QListLyricLine后续用下标访问某个歌词行非常方便。解析器对外只提供一个函数传进来 LRC 文件的完整文本返回一个排好序的 LyricLine 列表。界面层和同步层都不需要关心文本是怎么解出来的只管拿这个列表用。4.2 解析函数逐行拆解下面是完整的解析函数我加了不少注释方便你对着前面的正则设计看#include QRegularExpression #include QStringList #include algorithm #include QDebug QListLyricLine parseLrc(const QString rawText) { QListLyricLine result; // 时间标签兼容 [mm:ss.xx]、[mm:ss:xx]、[mm:ss]、[m:ss.x] static const QRegularExpression timeTagExp( \\[(\\d{1,3}):(\\d{2})(?:[.:](\\d{1,2}))?\\]); // 元数据行 [ti:xxx] [ar:xxx] [al:xxx] [by:xxx] static const QRegularExpression metaExp( ^\\[(ti|ar|al|by)\\:(.*)\\]$); const QStringList lines rawText.split(\n); for (const QString rawLine : lines) { const QString line rawLine.trimmed(); if (line.isEmpty()) continue; // 元数据行先跳过字段名和值稍后单独存 if (metaExp.match(line).hasMatch()) continue; // 没有时间标签的行直接忽略容忍损坏的歌词文件 QRegularExpressionMatchIterator it timeTagExp.globalMatch(line); if (!it.hasNext()) continue; // 收集这一行里的所有时间点 QListqint64 timePoints; QRegularExpressionMatch lastMatch; while (it.hasNext()) { lastMatch it.next(); const qint64 minutes lastMatch.captured(1).toLongLong(); const qint64 seconds lastMatch.captured(2).toLongLong(); const QString frac lastMatch.captured(3); qint64 ms minutes * 60000 seconds * 1000; if (frac.length() 2) ms frac.toInt() * 10; // 0.34 - 340ms else if (frac.length() 3) ms frac.toInt(); // 0.340 - 340ms timePoints.append(ms); } // 歌词文本 最后一个时间标签之后的内容 const QString content line.mid(lastMatch.capturedEnd()).trimmed(); for (qint64 ts : timePoints) { LyricLine item; item.timestamp ts; item.content content; result.append(item); } } // 按时间戳升序排序 std::sort(result.begin(), result.end(), [](const LyricLine a, const LyricLine b) { return a.timestamp b.timestamp; }); return result; }几个容易被忽略但非常关键的细节第一static const修饰 QRegularExpression目的是让正则只编译一次。QRegularExpression 内部是 PCRE 引擎构造时要编译模式虽然现代 CPU 编译一次也就微秒级但解析一个几百行的歌词文件要反复调用这个函数加上函数可能在循环里被多次调用static 能保证不会重复编译省得做无用功。第二capturedEnd()返回的是整个匹配在原始字符串中的结束位置。用line.mid(lastMatch.capturedEnd())截取文本天然就兼容了一行多个时间戳的情况——永远取最后一个时间标签后面的部分当歌词文本。上面提到的“文本包含方括号”的变体也不怕因为方括号如果出现在文本中间它不是完整的时间标签不会被正则匹配到。第三[00:20.00]这种只有时间戳没有内容的行解析出来是 content 为空的 LyricLine。这在同步的时候不会报错显示的时候你可以选择跳过或者显示一个空行看你想做成什么样的交互。我自己的处理是保留它空拍的时候歌词区留白效果反而贴近真实的 KTV 体验。4.3 解析结果的验证与调试解析器写完之后第一时间不是去接界面而是验证数据对不对。我的习惯是先写一小段测试逻辑直接用一个真实的 LRC 文件跑QFile file(/path/to/song.lrc); if (!file.open(QIODevice::ReadOnly)) return; QString text readLyricsText(file); QListLyricLine lyrics parseLrc(text); qDebug() 共解析出 lyrics.size() 行歌词; for (int i 0; i qMin(lyrics.size(), 10); i) { qDebug() lyrics[i].timestamp - lyrics[i].content; }看输出的时间戳是不是递增的、内容有没有错位。这一步一定要做扎实因为后面所有界面和同步代码都是建立在解析结果之上的数据错了后面再怎么修都白搭。5. 同步显示让歌词跟着歌声走5.1 用 positionChanged 信号驱动数据准备好了接下来是同步。QMediaPlayer 在播放状态变化时会高频发出positionChanged(qint64 position)信号参数就是当前播放到第几毫秒。我们只需要在这个信号里做一件事根据当前毫秒数从已经排好序的歌词列表里找到“现在应该显示的行”。所以歌词显示控件要有一个setLyrics接口用来接收解析结果然后连接信号// 主窗口初始化时 LyricsWidget *lyricsWidget new LyricsWidget(this); connect(player, QMediaPlayer::positionChanged, lyricsWidget, LyricsWidget::onPositionChanged);LyricsWidget内部维护一个当前行索引 member每次收到 position 都做增量更新。注意这里不需要每次从头扫描整个列表因为播放位置是单调递增的绝大部分时候只需要从上一个索引往后走一两步就能找到新行这是真正的 O(1) 级更新。5.2 当前行的查找顺序扫描还是二分顺序扫描的完整逻辑我写成这样void LyricsWidget::onPositionChanged(qint64 pos) { if (m_lines.isEmpty()) return; // 位置往前倒退了说明用户拖了进度条回退到头部重新线性查找 if (pos m_lines[m_currentIndex].timestamp) { m_currentIndex 0; while (m_currentIndex 1 m_lines.size() m_lines[m_currentIndex 1].timestamp pos) { m_currentIndex; } } else { // 正常往前播增量向后推进 while (m_currentIndex 1 m_lines.size() m_lines[m_currentIndex 1].timestamp pos) { m_currentIndex; } } // 行变化才更新界面 if (m_currentIndex ! m_lastShownIndex) { m_lastShownIndex m_currentIndex; setCurrentRow(m_currentIndex); scrollToItem(currentItem(), QAbstractItemView::PositionAtCenter); } }这个实现是基于一个事实除非用户来回拖进度条否则播放位置只增不减。所以正常播放时第二个 while 每次最多跑几步就停了性能完全够。拖进度条回退时我们从头线性扫一遍歌词一般也就几十上百行开销可以忽略不计。也有人说可以用二分查找std::lower_bound每次 O(log n) 定位。对几百行的歌词来说顺序扫描和二分查找的差距根本感知不到反而是顺序扫描天然利用了上次结果写起来更简洁。我自己倾向于顺序扫描加回退重扫的组合逻辑直白不容易出 bug。追求极致性能不是这个场景该操心的事。5.3 渲染更新的节流与防抖positionChanged 的发射频率在 Qt 6 里不同平台差异挺大有些平台一个播放周期会发几千次信号。如果每次信号都去调用 setCurrentRow即使只在高亮变化时才刷新也要注意别在信号处理里做任何重量级操作。我踩过的坑是这样的一开始直接在信号里调用了setStyleSheet去改当前行的颜色结果切歌时界面明显掉帧因为样式表要重新解析。后来改成只更新数据、用当前行索引控制高亮问题就没了。记住一个原则信号回调里只改状态、不做布局和样式重算真正的绘制交给 Qt 的 update 机制。另外setCurrentRow本身就带重绘效果同一行反复设置也不会有额外开销但我还是加了m_currentIndex ! m_lastShownIndex的判断保持界面更新函数是“无变化不动作”的幂等操作这样后续加动画之类的高级功能时逻辑更干净。6. 歌词界面从简陋到能用6.1 用 QListWidget 快速搞定歌词显示界面我建议第一版直接用 QListWidget把解析出来的内容逐行 addItem高亮当前行滚动到中间位置。这个方案十多行代码就能跑功能完整后面想换华丽效果再换也不迟。class LyricsWidget : public QListWidget { Q_OBJECT public: explicit LyricsWidget(QWidget *parent nullptr); void setLyrics(const QListLyricLine lines); void onPositionChanged(qint64 pos); private: QListLyricLine m_lines; int m_currentIndex -1; int m_lastShownIndex -1; };实现如下LyricsWidget::LyricsWidget(QWidget *parent) : QListWidget(parent) { setSelectionMode(QAbstractItemView::NoSelection); setFocusPolicy(Qt::NoFocus); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setStyleSheet(R( QListWidget { background: transparent; border: none; color: #777777; font-size: 14px; } QListWidget::item { padding: 6px 12px; } QListWidget::item:selected { color: #ffffff; font-size: 16px; font-weight: bold; background: transparent; } )); } void LyricsWidget::setLyrics(const QListLyricLine lines) { m_lines lines; m_currentIndex -1; m_lastShownIndex -1; clear(); for (const LyricLine line : m_lines) { addItem(line.content.isEmpty() ? QStringLiteral( ) : line.content); } setCurrentRow(-1); }这里有几个细节值得说。第一加setFocusPolicy(Qt::NoFocus)是为了防止焦点边框否则高亮行周围会出现一个虚线框。第二空歌词行我会塞一个空格占位不然 QListWidget 的高度计算在空字符串上会出问题显示起来会有跳动。第三当前行用::item:selected样式来做高亮因为 NoSelection 模式下 item 实际上不会被选中需要设置 selection mode 配合——这段样式里运用的是视觉上的“选择感”不要被界面状态迷惑。滚动到中间用了前面代码里的scrollToItem(currentItem(), QAbstractItemView::PositionAtCenter)这句是歌词跟随滚动最核心的一行代码能让当前行始终保持在可视区域正中间看起来就像市面上播放器的效果。6.2 自绘控件的进阶姿势QListWidget 方案能跑但有几个天然限制字体大小没法在当前行和普通行之间平滑过渡没法做上一句淡出、下一句淡入也不方便实现真正“居中放大”的 KTV 效果。如果你的目标是做一个观感更好的歌词屏第二版可以考虑直接上自绘控件。自绘的核心是重写paintEvent。思路很简单记录当前行索引计算每一行歌词在垂直方向上的坐标然后逐行绘制。当前行用大号字体、高亮色其他行用小号字体、深灰色。为了让当前行始终居中绘制偏移量用一个固定的数值当前行的中心 y 坐标永远等于控件高度的一半。void LyricsPaintWidget::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter p(this); p.setRenderHint(QPainter::Antialiasing); const int lineHeight 34; const int centerY height() / 2; // 当前行的中心对准控件中心所有行整体平移 const int offset centerY - m_currentIndex * lineHeight - lineHeight / 2; for (int i 0; i m_lines.size(); i) { int y offset i * lineHeight; if (y -lineHeight || y height()) continue; // 视口外的不画省一点也是省 QFont font p.font(); if (i m_currentIndex) { font.setPointSize(16); font.setBold(true); p.setPen(QColor(255, 255, 255)); } else { font.setPointSize(12); p.setPen(QColor(120, 120, 120)); } p.setFont(font); p.drawText(rect(), Qt::AlignHCenter | Qt::AlignTop, m_lines[i].content, rect); } }我这里为了演示把代码故意简化了实际项目里你还会想加渐变、加动画偏移、加触摸滑动。但核心技术点就一个用一个线性映射把“行索引”映射成“屏幕 y 坐标”然后只画落在视口范围内的行。听起来简单做起来也不复杂而且一旦从这个角度理解以后做逐词高亮也好、做歌词滚动进度条也好都是在这个坐标映射上做文章。6.3 布局与交互细节歌词控件放进主窗口的布局也有讲究。简单播放器最省事的做法是把它放在封面图旁边用 QSplitter 或者简单 QHBoxLayout 并排放置。我自己的布局习惯是左侧保留原来的音乐信息和控制区右侧放歌词区宽度给到 360 到 420 像素左右太窄了中文长句显示不下太宽了界面比例失调。另外一个很容易被忽略的交互细节歌词跟随滚动时如果用户自己用鼠标滚轮去翻看歌词播放器又在同一时间把滚动位置强制拽回中线体验会非常糟糕。处理方法是记录用户是否正在手动浏览只有当前行变化且用户没有手动滚动时才执行 scrollToItem。这个功能实现起来就是在控件上重写 wheelEvent设置一个标志位过几秒没滚动再恢复自动跟随。细节虽小但对用户体验的提升非常明显。7. 高频踩坑与排查实录7.1 歌词乱码GBK 和 UTF-8 的漂移LRC 文件是老格式中文歌词大部分是 GBK 编码存的。你的程序如果一律按 UTF-8 去解码解析出来的中文就会变成一个个问号。这是中文歌词显示里出现频率最高的问题没有之一。我的处理方案是读文件时先尝试 UTF-8解码出来如果包含替换字符就回退用 GBK 系列编码再解一次。Qt 6 里可以用 QStringDecoder#include QStringDecoder QString readLyricsText(const QByteArray data) { // 先按 UTF-8 解合法且没有替换符就直接用 QString utf8Text QString::fromUtf8(data); if (!utf8Text.contains(QChar::ReplacementCharacter)) return utf8Text; // UTF-8 出现替换符多半是 GBK 编码回退 QStringDecoder decoder(GB18030); return decoder.decode(data); }GB18030 是 GBK 的超集兼容性更好连生僻字也能塞下。如果你的项目用的是 Qt 5要使用 QTextCodec记得手动加核心兼容模块。还有更省事的办法很多歌词文件自带 BOM 头可以用QTextCodec::codecForUtfText自动识别但实际测试里发现的 BOM 识别率并不高最后还是得手动双路解码。7.2 正则不匹配的 90% 原因“我这个正则明明在在线测试工具里能匹配为什么 Qt 里 match 出来是空的”这是我在群里被问得最多的问题。排除正则本身写错剩下的原因几乎是同一个C 字符串的转义把正则吃掉了。比如你在测试工具里写\[(\d):(\d)\]复制到 C 代码里如果直接粘贴就成了\[(\d):(\d)\]。编译器看到\[会报错看到\d会把它当成 d 处理最终传给 QRegularExpression 的正则全是错的。你用原生字符串字面量R(...)包一层这个问题就从根上消失了。我在自己的项目里已经把所有正则都改成了原生字符串半年多没再因为转义问题折腾过。另一个容易踩的是QRegularExpressionMatch::hasMatch()和captured(i)的配合。globalMatch 遍历出来的 Match 对象如果用完再访问 captured 会得到空字符串。确保你在迭代循环内部把需要的值都提取出来别等循环结束之后再去取。上面 parseLrc 里我就是每拿到一个 match 立刻计算 ms就是这个道理。7.3 边界情况无歌词、纯音乐、变速播放歌词功能还会遇到一堆播放场景的边界问题我整理成了一张速查表场景现象处理方案歌曲目录下没有 .lrc歌词区一片空白显示“暂无歌词”别直接隐藏控件LRC 文件为空或解析后列表为空有文件但没内容同上统一走空状态界面前奏很长第一句时间戳在 30 秒以后开头 30 秒歌词区是空的保持空状态不显示任何当前行歌词最后一句播完当前行停在最后一句可以继续显示最后一句也可以播完后清空用户拖进度条来回切歌词行跳变onPositionChanged 里做了回退重扫没问题变速播放或循环单曲位置信号仍然单调或重播重播时位置变小同样走回退重扫天然兼容这些边界情况每一个都值得写代码前想清楚因为它们直接决定程序是“能用”还是“好用”。我的经验是空状态一定要明确提示不要让用户以为功能坏了。另外多说一句LRC 文件查找路径不要写死。我最后的实现是依次查找歌曲文件同目录的同名 lrc、程序运行目录下的 lrc、用户文档目录下的 lrc。三个路径找不到再认为无歌词。这个顺序不是拍脑袋定的而是符合实际使用习惯最常见的场景就是歌词文件跟着歌曲放一起。7.4 排查工具与方法论歌词模块的调试我的方法论是“分工到层”。如果歌词没显示先别急着怀疑界面按这个顺序排查文件有没有找到、路径对不对——打日志看文件大小。文件编码对不对——把读出来的字符串前几十个字符 qDebug 出来乱不乱一眼可见。解析对不对——看解析出的 LyricLine 列表数量和首尾时间戳。同步对不对——播放时故意把 position 信号接一个 qDebug看它有没有在走。界面对不对——确认 setLyrics 被调用了、setCurrentRow 有没有生效。每一层都能用 qDebug 验证找到出错层之后再深入那一层。绝大多数问题都集中在第二、三步也就是编码和解析所以代码里我每一处关键节点都留了 qDebug 输出。我见过朋友排查歌词问题一上来就改界面样式忙活一下午发现是文件编码全错从源头换了个编码一切正常。分层验证是这类问题效率最高的解法。8. 最后再聊几句实在的写到这里歌词功能的主干已经齐了取文件、解析 LRC、同步播放位置、显示当前行。按这个顺序做下来最后的代码量其实不大核心解析函数不到五十行同步逻辑也就二十行左右。但这里面的功夫都在“想清楚再动手”上——格式变体有哪些、正则怎么设计才兼容、同步怎么避免界面卡顿这些想明白了写代码只是顺水推舟。我个人实际开发中的一个体会不要把歌词显示当成一个可有可无的玩具功能。它背后牵扯到文件编码、正则表达式、数据排序、信号同步、界面重绘几乎把 Qt 客户端开发的主要知识面都过了一遍。把这一篇吃透不只是多了一个功能你处理其他文本类功能比如字幕解析、配置解析、日志分析的路子也会一下子清晰很多。最后再分享一个小技巧解析器写完之后一定要用一个从网上随便找的真实歌词文件做验收别只用你自己编的规整测试文本。真实文件里的乱七八糟写法才是正则表达式设计得健不健壮的真正试金石。我那次就是拿了一个带三四种变体混排的歌词文件测试才发现自己第一版的正则漏了冒号小数分隔符的情况。这种细节不跑真实数据根本发现不了。
阅读完成 · 觉得有帮助?
咨询建站