写到第六篇,这个“用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++ 的字符串转义和正则转义叠加。正则里要写\d,C++ 普通字符串里就必须写成"\\d"。如果你在 Qt 里把正则写成"\[(\d{1,3}):(\d{2})\]",不是正则错了,而是 C++ 编译器已经把\[当成了非法转义序列,程序根本编译不过。
一个非常实用的技巧是用 C++11 的原生字符串字面量,前后加括号,里面的反斜杠不用再双写:
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,全程类型统一可以少写很多隐式转换,也避免极端情况下的溢出问题。容器直接用QList<LyricLine>,后续用下标访问某个歌词行非常方便。
解析器对外只提供一个函数:传进来 LRC 文件的完整文本,返回一个排好序的 LyricLine 列表。界面层和同步层都不需要关心文本是怎么解出来的,只管拿这个列表用。
4.2 解析函数逐行拆解
下面是完整的解析函数,我加了不少注释,方便你对着前面的正则设计看:
#include <QRegularExpression> #include <QStringList> #include <algorithm> #include <QDebug> QList<LyricLine> parseLrc(const QString &rawText) { QList<LyricLine> 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; // 收集这一行里的所有时间点 QList<qint64> 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); QList<LyricLine> 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 QList<LyricLine> &lines); void onPositionChanged(qint64 pos); private: QList<LyricLine> 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 QList<LyricLine> &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 客户端开发的主要知识面都过了一遍。把这一篇吃透,不只是多了一个功能,你处理其他文本类功能(比如字幕解析、配置解析、日志分析)的路子也会一下子清晰很多。
最后再分享一个小技巧:解析器写完之后,一定要用一个从网上随便找的真实歌词文件做验收,别只用你自己编的规整测试文本。真实文件里的乱七八糟写法,才是正则表达式设计得健不健壮的真正试金石。我那次就是拿了一个带三四种变体混排的歌词文件测试,才发现自己第一版的正则漏了冒号小数分隔符的情况。这种细节不跑真实数据根本发现不了。