Android歌词显示功能实现思路
1. 整体思路
歌词功能看起来复杂,拆开来看就是四件事:解析歌词、定位当前行、高亮显示、居中滚动。
| 步骤 | 做什么 | 核心API |
|---|---|---|
| 解析LRC | 把文本转成带时间的歌词列表 | 正则表达式 |
| 定位当前行 | 根据播放进度找到该唱哪句 | 二分查找 |
| 高亮显示 | 当前行变色放大 | RecyclerView + Adapter |
| 居中滚动 | 当前行滚到屏幕中间 | 重写LinearSmoothScroller |
本文基于MediaPlayer 实现。如果你项目用的是ExoPlayer,只需把mediaPlayer.getCurrentPosition()换成player.getCurrentPosition(),seekTo()换成player.seekTo(),其余代码通用。
2. 数据类设计
只需要两个类:
LyricLine:一行歌词,包含三个字段
time:该行开始时间(毫秒,用long)content:歌词文本words:逐字时间列表(可选,没有逐字需求就设为null)
LyricWord:一个字/词的信息
time:这个字开始唱的时间text:字的内容
为什么用long存毫秒?因为MediaPlayer的getCurrentPosition()返回的就是毫秒,统一单位省去换算。
3. LRC解析器
3.1 要处理的情况
LRC文件看起来简单,实际格式很杂:
- 元信息标签:
[ti:]标题、[ar:]歌手、[al:]专辑、[by:]制作人、[offset:]时间偏移 - 时间标签:
[mm:ss.xx],小数位可能是1位、2位、3位 - 一行多个时间标签:
[00:12.34][01:20.50]同一句歌词 - 逐字标签:
<00:12.34>逐<00:12.50>字 - 格式错误的行、空行、纯注释行
3.2 解析流程
逐行读取
↓
是元信息标签? → 是 → 记录offset,跳过
↓ 否
提取所有时间标签 → 没有时间标签 → 跳过
↓ 有时间标签
取最后一个时间标签后面的文本作为歌词
↓
解析逐字标签(如果有)
↓
每个时间标签生成一条歌词
↓
全部按时间升序排序
3.3 关键代码
时间标签的正则:
java
Pattern TIME_TAG = Pattern.compile("\\[(\\d{1,3}):(\\d{1,2})(?:[.:](\\d{1,3}))?]");
元信息标签的正则:
java
Pattern META_TAG = Pattern.compile("^\\[(ti|ar|al|by|offset):(.*)]$", Pattern.CASE_INSENSITIVE);
时间转换(注意小数位数的兼容):
java
private static long parseTime(String minute, String second, String fraction) {
long m = Long.parseLong(minute);
long s = Long.parseLong(second);
long ms = 0;
if (fraction != null && !fraction.isEmpty()) {
// 1位小数×100,2位小数×10,3位小数直接用
if (fraction.length() == 1) ms = Long.parseLong(fraction) * 100;
else if (fraction.length() == 2) ms = Long.parseLong(fraction) * 10;
else ms = Long.parseLong(fraction);
}
return m * 60 * 1000 + s * 1000 + ms;
}
一行多时间的处理:
java
// 提取所有时间标签
while (timeMatcher.find()) {
times.add(parseTime(...));
lastTagEnd = timeMatcher.end(); // 记录最后一个标签结束位置
}
// 文本 = 最后一个标签之后的内容
String content = line.substring(lastTagEnd).trim();
// 每个时间生成一行
for (long time : times) {
result.add(new LyricLine(time + offset, content));
}
3.4 容错原则
| 错误情况 | 处理方式 |
|---|---|
| 行格式不匹配 | 跳过该行 |
| 时间数字非法 | 跳过这个时间标签 |
| 没有时间标签 | 跳过该行 |
| offset格式错误 | 忽略,按0处理 |
| 文件为空 | 返回空列表 |
关键:解析器永远不抛异常,最差返回空列表。调用方根据空列表决定显示"暂无歌词"。
4. 当前行定位算法
4.1 为什么用二分查找
播放进度每100毫秒刷新一次,如果歌词有1000行,每次for循环最多比较1000次,性能太差。
我们要找的是:最后一个 time <= 当前播放进度 的行。因为歌词时间已经排序,这天然适合二分查找,复杂度O(log n)。
4.2 算法思路
left = 0, right = 最后一行
result = -1(默认没有匹配行)
while left <= right:
mid = (left + right) / 2
if 歌词[mid].time <= 当前进度:
result = mid // 这是一个候选答案
left = mid + 1 // 继续往右找更靠后的
else:
right = mid - 1 // 当前行还没到,往左找
返回 result
4.3 为什么是"最后一个小于等于"而不是"第一个小于等于"
因为歌词是逐行推进的。比如歌词时间是 0s、5s、10s,当前播放到12s,应该高亮的是"10s那行"(正在唱),而不是"0s那行"(已经唱过了)。
4.4 边界情况
| 情况 | 返回值 | UI表现 |
|---|---|---|
| 当前时间在所有歌词之前 | -1 | 不高亮任何行 |
| 当前时间在两行之间 | 前一行索引 | 高亮前一行 |
| 当前时间超过最后一行 | 最后一行索引 | 高亮最后一行 |
4.5 核心代码
java
public static int findLineIndex(List<LyricLine> lines, long currentTime) {
if (lines == null || lines.isEmpty()) return -1;
int left = 0, right = lines.size() - 1, result = -1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (lines.get(mid).getTime() <= currentTime) {
result = mid;
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
5. 居中滚动
5.1 为什么smoothScrollToPosition不行
直接调用recyclerView.smoothScrollToPosition(position),目标Item只会滚到刚好可见的位置(屏幕边缘),不会居中。
歌词需要一个"当前行始终在屏幕中间"的效果,所以必须自定义滚动器。
5.2 重写LinearSmoothScroller
核心是重写calculateDtToFit方法,它返回目标View需要滚动的偏移量:
java
public class CenterSmoothScroller extends LinearSmoothScroller {
public CenterSmoothScroller(Context context) {
super(context);
}
@Override
public int calculateDtToFit(int viewStart, int viewEnd,
int boxStart, int boxEnd, int snapPreference) {
// 目标View中心 → 父容器中心 的距离
return (boxStart + boxEnd) / 2 - (viewStart + viewEnd) / 2;
}
@Override
protected float calculateSpeedPerPixel(DisplayMetrics dm) {
return 60f / dm.densityDpi; // 控制滚动速度,越小越快
}
}
calculateDtToFit的四个参数含义:
viewStart/viewEnd:目标View在RecyclerView中的坐标boxStart/boxEnd:RecyclerView的可见区域坐标- 返回值:需要滚动的距离
公式解释:父容器中心减去目标View中心,得到的就是要把目标View居中需要的偏移量。
5.3 在LayoutManager中启用
java
LinearLayoutManager layoutManager = new LinearLayoutManager(this) {
@Override
public void smoothScrollToPosition(RecyclerView rv, RecyclerView.State state, int position) {
CenterSmoothScroller scroller = new CenterSmoothScroller(rv.getContext());
scroller.setTargetPosition(position);
startSmoothScroll(scroller);
}
};
重写之后,所有调用smoothScrollToPosition的地方都会自动居中。
6. Adapter设计
6.1 状态管理
Adapter只需要维护两个状态:
currentPosition:当前高亮的行索引currentTime:当前播放进度(用于逐字高亮)
6.2 局部刷新
这是性能关键:切换歌词时只刷新旧行和新行 ,不要调用notifyDataSetChanged。
java
public void setCurrentPosition(int newPosition, long currentTime) {
this.currentTime = currentTime;
int oldPosition = currentPosition;
currentPosition = newPosition;
if (oldPosition >= 0) notifyItemChanged(oldPosition);
if (currentPosition >= 0) notifyItemChanged(currentPosition);
}
6.3 高亮样式
在onBindViewHolder中根据位置设置样式:
java
if (position == currentPosition) {
holder.tvLyric.setTextColor(Color.WHITE);
holder.tvLyric.setTextSize(18);
} else {
holder.tvLyric.setTextColor(Color.parseColor("#999999"));
holder.tvLyric.setTextSize(16);
}
6.4 点击跳转
java
holder.itemView.setOnClickListener(v -> {
if (clickListener != null) {
clickListener.onLyricClick(position, line.getTime());
}
});
外部拿到回调后调用mediaPlayer.seekTo(time),然后刷新高亮和滚动。
7. 主界面的核心逻辑
7.1 初始化流程
1. 创建MediaPlayer加载音频
2. 读取raw目录下的.lrc文件,调用解析器
3. 如果解析结果为空 → 显示"暂无歌词",隐藏RecyclerView
4. 初始化Adapter,设置居中LayoutManager
5. 设置点击回调
6. 启动播放,开启定时刷新
7.2 定时刷新
用Handler每100毫秒刷新一次:
java
private final Runnable updateRunnable = new Runnable() {
@Override
public void run() {
updateLyric();
handler.postDelayed(this, 100);
}
};
为什么是100ms?
- 太快(如16ms):频繁刷新UI,性能浪费
- 太慢(如500ms):歌词切换有延迟感
- 100ms:平衡点,人眼几乎感知不到延迟
7.3 updateLyric核心逻辑
java
private void updateLyric() {
if (!mediaPlayer.isPlaying()) return;
long currentTime = mediaPlayer.getCurrentPosition();
int newPosition = LyricFinder.findLineIndex(lyricLines, currentTime);
if (newPosition != currentPosition) {
// 换行:高亮新行 + 居中滚动
currentPosition = newPosition;
adapter.setCurrentPosition(newPosition, currentTime);
if (!userScrolling) {
rvLyric.smoothScrollToPosition(newPosition);
}
} else if (newPosition >= 0) {
// 同一行内:刷新逐字高亮
LyricLine line = lyricLines.get(newPosition);
if (line.getWords() != null) {
adapter.updateWordHighlight(currentTime);
}
}
}
7.4 用户滑动时暂停自动滚动
如果用户正在手动滑歌词(比如想看后面的),自动滚动会打断他,体验很差。
java
rvLyric.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView rv, int newState) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
userScrolling = true; // 用户开始滑动
} else if (newState == RecyclerView.SCROLL_STATE_IDLE) {
// 停止滑动3秒后恢复自动滚动
handler.postDelayed(() -> userScrolling = false, 3000);
}
}
});
8. 逐字歌词(卡拉OK式高亮,加分项)
8.1 LRC逐字格式
不同平台的逐字格式不同,常见的是:
[00:12.34]<00:12.34>逐<00:12.50>字<00:12.80>歌<00:13.10>词
[00:12.34]:整行开始时间<00:12.34>:每个字的开始时间
8.2 实现思路
第一步:解析时收集逐字数据
把每个字的开始时间和内容存进LyricLine.words列表。正则:
java
Pattern WORD_TAG = Pattern.compile("<(\\d{1,3}):(\\d{1,2})(?:[.:](\\d{1,3}))?>");
第二步:定位当前唱到第几个字
用二分查找,跟找当前行一样,找"最后一个 word.time <= 当前进度 的字索引"。
java
public static int findWordIndex(LyricLine line, long currentTime) {
// 同findLineIndex,只是数据源换成line.getWords()
}
第三步:绘制高亮
用SpannableString给已唱的字设白色,未唱的字设灰色:
java
SpannableString spannable = new SpannableString(fullText);
int charIndex = 0;
for (int i = 0; i < words.size(); i++) {
int len = words.get(i).getText().length();
int color = (i <= currentWord) ? Color.WHITE : Color.parseColor("#666666");
spannable.setSpan(new ForegroundColorSpan(color), charIndex, charIndex + len,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
charIndex += len;
}
8.3 进阶:自定义View绘制
SpannableString方案每刷新一次就新建对象,对于逐字这种高频刷新场景,性能一般。如果追求极致流畅,可以用自定义View:
java
@Override
protected void onDraw(Canvas canvas) {
float x = 0;
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
for (LyricWord word : words) {
paint.setColor(word.getTime() <= currentTime ? Color.WHITE : Color.GRAY);
canvas.drawText(word.getText(), x, baseline, paint);
x += paint.measureText(word.getText());
}
}
优势:不创建额外对象,只重绘,性能更好。
9. 降级处理
| 场景 | 处理方式 |
|---|---|
| 歌词文件不存在 | openRawResource抛异常,catch后显示"暂无歌词" |
| 解析结果为空 | 隐藏RecyclerView,显示"暂无歌词"TextView |
| 音频文件不存在 | MediaPlayer.create可能返回null,需判空 |
| 当前时间在所有歌词之前 | findLineIndex返回-1,不高亮 |
| 歌词全部播完 | 高亮最后一行,不滚动 |
| 歌词行内容为空 | 在解析时跳过 |
10. 易错点总结
| 易错点 | 说明 |
|---|---|
| 多时间标签不排序 | 二分查找失效,必须按时间升序排序 |
| 直接用smoothScrollToPosition | 不会居中,必须重写LayoutManager方法 |
| 频繁notifyDataSetChanged | 性能差,只刷新变化的两行 |
| 用户滑动时仍自动滚动 | 体验差,需监听滑动状态暂停 |
| Handler未移除 | Activity销毁后仍回调,内存泄漏 |
| MediaPlayer未释放 | 资源泄漏 |
| 逐字高亮每次重建Spannable | 高频刷新时性能一般 |
| 忽略offset标签 | 歌词整体偏移 |
11. 一句话总结
歌词功能 = LRC解析(多时间+容错) + 二分查找定位当前行 + RecyclerView高亮当前行 + 重写
calculateDtToFit实现居中滚动 + 点击seekTo跳转。逐字高亮在此基础上多一层"二分查找当前字"和"Spannable绘制"即可。