Android歌词功能实现

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绘制"即可。

相关推荐
恋猫de小郭1 小时前
Flutter 紧急发布 3.47.8 安全升级版本,建议都升级
android·前端·flutter
YB13751 小时前
Jetpack Compose 动画AnimatedContent
android
美狐美颜SDK开放平台12 小时前
准备开发直播APP?视频美颜SDK接入前的十项检查清单
android·ios·音视频·直播美颜sdk·视频美颜sdk
传奇开心果编程12 小时前
【Jetpack Compose娓娓道来】第26课:Repository模式与数据层设计——数据的唯一入口
android·移动开发·1024程序员节·jetpack compose·编程语言理论·编程框架·声明式ui
三少爷的鞋17 小时前
Kotlin 协程,重点就在「协」这个字上
android·kotlin
好的佩奇17 小时前
Dart 之模式
android·flutter·dart
淡淡的香烟19 小时前
Flutter实现多语言切换
android·flutter·1024程序员节
AIsoft_868921 小时前
手机视频转文字工具清单:哪些APP支持录屏识别
android·音视频
_祝你今天愉快1 天前
Paint Cavans高级绘制:[一]Paint画笔高级应用
android