资讯动态

高效歌词高亮:用LinearGradient+Matrix实现Android自定义TextView

发布时间:2026/10/17 8:24:34 来源:尧图企业网站定制
简介在Android播放器开发中歌词同步显示是常见需求。实现歌词高亮通常有SpannableString与Shader两条路线。本文围绕LinearGradient与Matrix矩阵平移原理解析如何在不重建Span对象的前提下通过移动渐变区间实现歌词行的高亮切换。技术核心在于复用Paint的Shader用矩阵变换控制渐变位置从而大幅降低重绘成本避免内存抖动。这种方法适用于歌词滚动、字幕跟随以及文本局部高亮等实时刷新场景。文章还提供完整代码与常见踩坑分析帮助开发者快速打造高性能的歌词TextView。1. 歌词风格 TextView不是动画是 LinearGradient 的矩阵平移常在下载站看到这样一条 Android 代码资源标题写着“歌词风格的 TextView利用 Gradient 渐变实现”。做过播放器的工程师都会多想一步——它是不是用 ObjectAnimator 在逐行变颜色不是。真正的做法是给同一个 Paint 设置一个 LinearGradient然后通过 Matrix 平移渐变区间让当前高亮行始终落在“亮色”段上。这么做的好处是刷新时不重建 SpannableString不重新 layout只是移动 Shader 的矩阵重绘代价很低。适合自己写歌词滚动、字幕跟随或想把文本高亮换一种高效思路的人。下文把原理、代码、参数和实际踩坑完整拆开照着这份实现能直接搬到自己的播放器项目里。2. 实现思路为什么用 Gradient 而不用 SpannableString2.1 高亮整句 vs 高亮逐字两种刷新成本对比歌词界面通常有两条路线。一条是整句高亮当前播放哪句哪句亮其他句暗另一条是逐字高亮类似卡拉 OK每个字随着音频变色。很多工程师第一次都会本能地选 SpannableString毕竟 TextView 自带 Spannable 能力ForegroundColorSpan 可以把任意一段字符染成不同颜色。这个方案最大的坑不在“能不能染色”而在“刷新成本”。播放器进度回调一般每 100ms 一次如果做精细动画甚至每 16ms 一次。每次回调你都要构造一个新的 SpannableStringBuilder往里面塞几个 ForegroundColorSpan然后调 setText。setText 不是只有赋值逻辑它会触发 TextView 重新测量、重新布局、重新计算 spans。歌词文本越长这一步越贵。某开发者在一台低端测试机上跑这个方案一首歌播到一半系统 GC 日志明显变多监控面板里帧率从 60 掉到 40 左右。问题不在主线程有多忙而在于短时间内产生了大量 Span 对象内存抖动直接送到你脸上。Gradient 方案绕开了整套对象创建。Paint 本身可以挂一个 ShaderShader 决定每个像素的颜色行号变化时只需要更新 Shader 的本地矩阵例如 setTranslate 让渐变窗口在屏幕上移动。文本内容从头到尾没变过TextView 的 Layout 也没有失效所以不会触发 measure 和 layout 流程。onDraw 里执行的只是设置矩阵和一次重绘成本约等于普通 View 刷新。所以结论很直接如果只是给整句歌词做高亮Gradient Matrix 比 SpannableString 更适合持续变化的场景。它唯一吃亏的是“逐字变色”做不到因为逐字需要精确控制每个字符的 local matrix用单个 LinearGradient 很难跟每个字的坐标对齐。卡拉 OK 那种逐字效果还是老老实实用 SpannableString 或自定义绘制。2.2 LinearGradient 的坐标、颜色数组和 TileMode 选择LinearGradient 最少需要四个坐标x0、y0 表示渐变起点x1、y1 表示终点。歌词高亮的方向一定是纵向的因此 x0 和 x1 都设为 0只让 y 轴参与渐变。常见做法是让渐变跨三行高度暗色、亮色、暗色。高亮行在中间时前一行和后一行正好落在渐变的暗色段视觉上像舞台追光。这里有几个坐标非常容易混淆。第一个是 Shader 定义时写的坐标它相对于绘制目标区域第二个是 Matrix 里 translate 的距离它作用于 Shader 自己的坐标系第三个是 TextView 内部绘制文本时真实使用的像素坐标。很多人照抄代码后高亮偏位就是把这几个坐标搞混了。颜色数组和位置数组长度必须一致。典型写法是int[] colors {0xFF9E9E9E, 0xFFFFFFFF, 0xFF9E9E9E}; float[] positions {0f, 0.5f, 1f};positions 里的 0.5f 表示从渐变起点到终点的中间位置不是 TextView 高度的中间。如果渐变起点是 -lineHeight终点是 2 * lineHeight中间位置就是 0.5 * lineHeight。算出这个数之后再去看 Matrix 平移量才能对得上高亮中心。TileMode 也影响边界视觉。CLAMP 会保留渐变范围两端的颜色所以高亮行之外的部分保持灰色或者白色适合“当前行永远在渐变窗口内”的场景MIRROR 会让渐变在范围外镜像往复适合你希望高亮窗口跟着歌词列表一起滚动、超出边界还能自然循环的情况。做歌词时我一般选 CLAMP条件更容易控制。2.3 行高计算用 getLineHeight 还是 layout.getLineHeight一个隐藏细节是getLineHeight() 是 TextView 的默认行高受 textSize、setLineSpacing、includeFontPadding 三个因素影响。歌词文本如果全是单行短句这个值够用。但只要有换行、空行、或者某一行设置了不同字号的 spangetLineHeight() 就不可靠了。更稳的做法是用 getLayout().getLineTop(i) 和 getLayout().getLineBottom(i)。这两个值基于 TextView 内部已经完成的文本布局结果能精确反映每一行的顶部和底部在控件内的坐标。虽然多写几行代码但多行歌词、空行、图文混排的情况下不会翻车。本文第 5 章滚动歌词的计算也会用这两个方法。3. 动手实现继承 TextView 重写 onDraw 的完整代码3.1 初始化 Shader把渐变“铺满”三行高度先看代码。假定使用场景是一行一句歌词TextView 显示整个歌词文本待会再扩展滚动。public class GradientLrcTextView extends AppCompatTextView { private LinearGradient highlightGradient; private Matrix shaderMatrix new Matrix(); private int currentLine 0; private int normalColor 0xFF9E9E9E; private int highlightColor 0xFFFFFFFF; public GradientLrcTextView(Context context, AttributeSet attrs) { super(context, attrs); setGravity(Gravity.CENTER_HORIZONTAL); } Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); if (w 0 || h 0) return; int lineHeight getLineHeight(); highlightGradient new LinearGradient( 0, -lineHeight, 0, lineHeight * 2, new int[]{normalColor, highlightColor, normalColor}, new float[]{0f, 0.5f, 1f}, Shader.TileMode.CLAMP ); getPaint().setShader(highlightGradient); updateShaderMatrix(); } }逻辑说明onSizeChanged 是初始化 Shader 的好位置因为只有 View 有真实宽高之后行高才有可信度。getLineHeight() 返回的是当前字体、行间距下的实际行高不是 textSize。渐变起点 y0 设为 -lineHeight终点 y1 设为 2 * lineHeight总跨度是三行高度。颜色数组三色对应暗、亮、暗位置数组 0、0.5、1 让亮色段落在渐变中心。代码里把 normalColor 和 highlightColor 写成固定值实际项目建议放到 attrs 自定义属性里布局文件中用 app:normalColor、app:highlightColor 控制。这样换主题时只改 XML不需要碰 Java 代码。GradientLrcTextView 这个类名内部用对外暴露的还是标准 TextView 接口。3.2 更新高亮行Matrix 平移和 invalidate 时机private void updateShaderMatrix() { if (highlightGradient null) return; shaderMatrix.setTranslate(0, -currentLine * getLineHeight()); highlightGradient.setLocalMatrix(shaderMatrix); getPaint().setShader(highlightGradient); } public void setCurrentLine(int lineIndex) { if (lineIndex currentLine) return; this.currentLine lineIndex; updateShaderMatrix(); postInvalidateOnAnimation(); }逻辑说明这里核心是 setTranslate(0, -currentLine * getLineHeight())。渐变原本的亮色中心在 0.5 * lineHeight 处向上平移 currentLine 个行高之后亮色中心就落到目标行附近。比如 currentLine 2渐变整体上移两行亮色中心点到了 2.5 行高的位置正好对应歌词第三行符合“第 2 行高亮”的视觉。参数说明setCurrentLine 里先判断行号是否变化没有变化就不执行任何更新避免播放器高频回调时的无效重绘。postInvalidateOnAnimation() 会把重绘请求排到下一帧 vsync比直接调 invalidate() 更省电也不会打断当前帧的绘制。如果你在 RecyclerView 里复用这个 TextView记得在 onBindViewHolder 里调 setCurrentLine因为复用可能导致 currentLine 是上一行的值。3.3 暴露 setProgress让播放器进度直接驱动歌词高亮不能只给行号播放器一般只给当前播放时间。所以要提供一个从毫秒时间戳到行号的转换方法private ListLong mLineTimes new ArrayList(); public void setPlayProgress(long positionMs) { if (mLineTimes null || mLineTimes.isEmpty()) return; int line findLineIndex(positionMs); setCurrentLine(line); } private int findLineIndex(long positionMs) { int line 0; for (int i 0; i mLineTimes.size(); i) { if (positionMs mLineTimes.get(i)) { line i; } else { break; } } return line; }逻辑说明mLineTimes 是从 LRC 文件解析出来的每行起始时间按从小到大排序。findLineIndex 从头往后扫只要播放时间大于等于某一行的时间点就把高亮行设为那一行一旦遇到一个还没到的未来时间点就停止。这个写法在几十行歌词内性能足够如果歌词超过几百行可以改成二分查找但实际场景中比较少。参数说明positionMs 是毫秒LRC 时间标签 [00:12.34] 解析后要转成毫秒再存进列表。歌曲前奏阶段 positionMs 小于第一行时间findLineIndex 返回 0默认高亮第一句这是多数播放器的习惯。如果你希望前奏没有高亮可以在 setCurrentLine 之前判断 positionMs 小于第一行时间时返回 -1并让 updateShaderMatrix 处理负数。4. 避坑渐变错位、首行偏移、频繁刷新4.1 现象高亮行总是偏上半行亮色中心对不上文字中心原因直接用 currentLine * getLineHeight() 作为平移量但 Shader 的亮色中心不在 0 位置。LinearGradient 默认把高亮位置放在渐变范围中点具体是多少取决于你设置的 positions。很多人把 y0 写成 0、y1 写成 2 * lineHeight亮色中心落在 1.0 * lineHeight却还用 -currentLine * lineHeight 做平移结果每一行都偏移。解决先把“渐变中心”算出来。y0 -lineHeight、y1 2 * lineHeight 时渐变中心位于 0.5 * lineHeight。translateY 应该为 targetLineTop - 0.5 * lineHeight。如果 targetLineTop 取的是 getLayout().getLineTop(currentLine)那就不依赖 getLineHeight 了。4.2 现象设置了 Shader 后整段文字变成单色没有渐变色原因渐变的 y0 和 y1 坐标重叠LinearGradient 退化成纯色或者渐变范围只有几个像素高亮过渡看不出来。另一个常见原因是 TextView 的 gravity 设置了 center_vertical文字实际绘制区域和 Shader 坐标基准不一致导致渐变窗口根本没有覆盖文本。解决先用固定颜色排查再把渐变范围调大。调试时把 y0、y1 设成 getLayout().getLineTop(0) 和 getLayout().getLineBottom(getLineCount() - 1)确认能正常显示渐变后再缩写为三行高度。4.3 现象进度回调频繁onDraw 里每次 new Shader掉帧明显原因把 LinearGradient 和 Matrix 的创建写在了 onDraw 方法里。进度 60fps 刷新时每帧都在 new 对象Paint.setShader 还会使内部缓存失效垃圾回收变多掉帧是必然的。解决Shader 的创建放在 onSizeChanged 或构造函数中onDraw 只更新 setLocalMatrix 和 setShader。setCurrentLine 里加上行号相等判断减少无效刷新。如果播放器回调特别激进还可以在 setPlayProgress 里加一个时间阈值比如间隔不足 20ms 就丢弃。4.4 现象歌词文本有多行时行高不一致导致高亮错位原因getLineHeight() 算的是默认行高但歌词里如果包含换行、空行或者不同字号的 span每行实际高度不一样。用行号乘行高算平移量天然会越到后面偏得越多。解决不要用 currentLine * getLineHeight()改用 getLayout().getLineTop(currentLine)。这个值来自 TextView 布局结果兼容空行、折行和混合字号。注意 LRC 歌词的“行号”和 Android 文本布局的“行号”未必一致一句长歌词被自动折成两行时布局行数会多解析阶段应把每句歌词强制独立成一行或者关闭自动折行。5. 进阶把静态渐变改成可滚动歌词并与进度联动5.1 计算当前行在控件中的 Y 坐标静态高亮只适合当前句正好固定在屏幕中间的场景。真实播放器一般会像歌词页面一样整个歌词列表随播放滚动当前行始终保持在屏幕中部。实现时不需要引入复杂容器外层用 ScrollView里面放这个 GradientTextView 即可。Rect lineBounds new Rect(); int top mGradientTv.getLayout().getLineTop(currentLine); int bottom mGradientTv.getLayout().getLineBottom(currentLine); int targetScrollY top - (mScrollView.getHeight() - (bottom - top)) / 2; mScrollView.smoothScrollTo(0, targetScrollY);逻辑说明getLineTop 和 getLineBottom 返回的是文本内部的 Y 坐标ScrollView 的 scrollY 是相对内容顶部的偏移所以可以直接相减。目标位置让当前行垂直居中符合多数歌词页面的交互习惯。如果 ScrollView 本身有 padding 或 contentInset计算时要加上对应偏移。5.2 触摸滑动与回弹逻辑歌词页允许用户手动滑动查看前后歌词但用户在拖动时自动滚动不能和手指打架。常见做法是在 onTouch 里记录 isDraggingACTION_UP 之后重新回到当前行中心。mScrollView.setOnTouchListener((v, event) - { if (event.getAction() MotionEvent.ACTION_DOWN) { isDragging true; } else if (event.getAction() MotionEvent.ACTION_UP) { isDragging false; scrollToCurrentLine(); } return false; });逻辑说明ACTION_DOWN 时置为拖动中暂停播放器回调触发的滚动。ACTION_UP 时恢复并强制回到当前行。这样用户滑动后松手歌词会自动吸附回播放进度对应的那一行避免手动滚动和自动滚动互相打架。5.3 联调建议进度回调频率和渐变参数播放器进度回调建议控制在 100ms 一次不要用 60fps。歌词粒度是“行”毫秒级刷新没有意义反而增加 CPU 和内存压力。行切换时是否需要过渡动画可以自己定但渐变跨三行高度时平滑移动会造成“高亮从上一行滑下来”的效果如果不想让歌词看起来在流动就保持直接跳变。资源包里的实现一般会把行号计算和 Shader 更新分两层播放器只负责喂时间戳UI 层负责找行号和绘制。我后来自己的项目里也是照着这个逻辑改的。那次因为渐变中心算错整整排查了一个晚上最后发现只是 y0 少写了负号。从那以后我每次写自定义 TextView都强制走一遍先打印 getLineTop再调 Shader 参数最后才碰 Matrix。这个习惯帮我把这类问题出现概率降到了零。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑