Android歌词渐变高亮:LinearGradient自定义View实现 简介这份代码包面向Android开发初学者和需要实现歌词逐字变色效果的开发者提供基于Gradient渐变的TextView示例。它用双色线性渐变控制文字着色可用于音乐播放页歌词高亮、KTV逐字演唱提示等场景帮助理解自定义View绘制流程与渐变画笔用法。压缩包共39个文件约101KB含6个Java源文件、13个XML布局与配置、4个Gradle构建脚本以及图片、属性和说明文档分别承载核心控件、示例界面、依赖配置与辅助资源目录按app与mylibrary模块组织便于对照阅读和运行。已有495人学习。读者可获得可运行的GradientTextView工程、JitPack依赖接入方式、XML布局声明与代码调用示例并据此改造成逐字歌词高亮控件减少从零搭建自定义View的时间。1. 歌词风格的高亮文字一个 Gradient 就够别急着拆 TextView想在 Android 上实现歌词风格的高亮文字最常见的做法是把文字拆成一堆 TextView或者用 Spannable 逐字修改前景色。前者会导致换行错位、居中对不齐字号一旦缩放就全乱后者每一帧都要重建 span列表一滑动就掉帧。这份 Android 代码换了个思路用一个继承 TextView 的自定义 View把“高亮”交给 Paint 的 LinearGradient 渐变去画一次 onDraw 就能画出歌词风格的渐变文字进度给 0.5 就亮一半给 1.0 就整行亮完中间那条过渡带还是柔的。它适合正在做音频播放器、跟读打分、课程字幕、语音合成高亮这几类需求的 Android 开发也很适合当作第一个真正能上线的自定义 View 练手代码量不大但把测量、绘制、着色器、动画、状态恢复这几件正经事都摸了一遍。下文从工程结构一路拆到二次开发能照着跑也能照着改。2. 工程结构一个自定义 View 模块拆成哪几层在你们整套代码结构里这个控件属于“能独立拆出去发布”的那一类不依赖业务、不依赖播放器、只有一个 View 加一份属性表。因此我一般会把它放在一个独立 module 或者 widget 包里后面几个页面复用同一份改一处能全局生效。下面这份目录是我在这个项目里实际用的结构把可直接复制的部分和业务胶水层分开放。2.1 目录与文件清单app/src/main/java/com/example/lyricview/ ├── widget/ │ ├── LyricGradientTextView.java # 核心继承 AppCompatTextView持有 Shader │ └── LyricProgressDriver.java # 可选把播放器进度 / 动画统一成 0~1 ├── ui/ │ └── PlayerActivity.java # 业务页只负责调 setProgress └── res/ ├── layout/activity_player.xml # 布局里直接用属性走 app: 命名空间 └── values/attrs.xml # declare-styleable暴露 5 个可配属性widget包里不要出现任何业务字段LyricProgressDriver存在的意义就是把“进度从哪来”与“怎么画”解耦以后换成在线流、换成 TTS 回调都只改 driver控件一行不动。ui层只拿到一个 0~1 的 float负责调setProgress这样控件的可测试性也好不接播放器也能单独看效果。2.2 方案对比为什么把高亮交给 Shader 而不是逐字改色选型这件事我踩过两轮第一轮用 Spannable第二轮用两层文字加 clipRect 裁剪最后才换到 Shader。三种做法在真实页面里的差异比想象中大方案实现成本换行与对齐每帧开销适用场景Spannable 逐字改色低会被拆字影响断行中文问题不大英文单词会被拆开每帧重建 span触发重新布局静态高亮、字数很少双层文字 clipRect 裁剪中完全正常底层用原 TextView 排版一次裁剪 两次绘制开销稳定需要硬边缘的进度条式高亮Paint LinearGradient中完全正常排版交给系统只改一个 Matrix无重建需要柔和过渡带的歌词、朗读关键在于Shader 不影响测量。TextView 的onMeasure、断行、ellipsize、maxLines全部照旧走系统逻辑你只是换了一种“上色方式”。这一点决定了它比前两种方案更稳代价是要理解 Shader 的坐标系。而柔和过渡带是 clipRect 方案做不出来的裁剪出来的是硬边眼睛看着像进度条不像歌词。2.3 三个刷新时机onSizeChanged 建、setProgress 移、onDraw 挂这个控件能不能稳定八成取决于三个时机的分工有没有分清onSizeChangedView 宽度变了渐变必须重建。因为渐变的长度依赖像素宽度提前建出来的东西尺寸是错的。setProgress只更新一个 float然后postInvalidateOnAnimation()请求重绘。绝不在这里 new 对象。onDraw把 Shader 挂到getPaint()上然后交给super.onDraw(canvas)。Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); gradient null; // 尺寸变了旧的着色器作废 }这里有个很实在的经验构造LinearGradient时如果起点和终点完全重合宽或高为 0部分机型会直接抛IllegalArgumentException日志是 “width and height must be 0”。所以我把“构造”放在onDraw的开头做懒加载而不是在构造方法里一次搞定宁可多一次判空也不要上线后在低端机上出现崩溃报告。3. 核心绘制链路一个 LinearGradient 从构造到贴到文字上的全过程很多教程教的是“每帧根据进度 new 一个四段式的 LinearGradient”颜色数组写成{高亮, 高亮, 底色, 底色}位置数组算成{0, p-δ, pδ, 1}。这个写法能跑但每帧新建对象会让 GC 在快速滑动时明显抖动而且位置数组必须严格单调递增p靠近 0 或 1 时很容易算出负数或者超过 1于是过渡带被压成一条硬边——这就是很多人反馈“渐变看起来像刀切的”的原因。换个思路渐变本体固定不动只让它整条平移。3.1 把渐变缩成一条“会走路的过渡带”渐变只有过渡带那么宽两端用Shader.TileMode.CLAMP自动向外延伸过渡带左边一律是高亮色右边一律是底色。于是整段文字的颜色分布就是“左亮右暗中间一条柔和过渡”而“进度”变成了“把这条带子往右推多少像素”。好处是LinearGradient只 new 一次之后每帧只调setLocalMatrix对象复用、没有分配、没有 GC。位置数组也永远只有{0f, 1f}两个元素不会出现非单调的边界问题。3.2 onDraw 的完整实现package com.example.lyricview.widget; import android.content.Context; import android.graphics.Canvas; import android.graphics.LinearGradient; import android.graphics.Matrix; import android.graphics.Shader; import android.text.Layout; import android.util.AttributeSet; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatTextView; public class LyricGradientTextView extends AppCompatTextView { private final Matrix shaderMatrix new Matrix(); private LinearGradient gradient; // 全生命周期只建一次靠 Matrix 移动 private float progress 0f; // 0f ~ 1f外部唯一需要关心的输入 private float feather; // 过渡带宽度px private int highlightColor 0xFF19D3FF; private int baseColor 0x66FFFFFF; public LyricGradientTextView(Context context, Nullable AttributeSet attrs) { super(context, attrs); // 过渡带跟着字号走字体放大时过渡带同比放大观感才一致 feather getTextSize() * 0.6f; setWillNotDraw(false); } Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); gradient null; // 尺寸变化后必须重建 } private void ensureShader() { if (gradient ! null) return; if (getWidth() 0 || feather 0f) return; // 渐变长度 过渡带宽度CLAMP 让两侧自动延伸为纯色 gradient new LinearGradient(0f, 0f, feather, 0f, new int[]{highlightColor, baseColor}, new float[]{0f, 1f}, Shader.TileMode.CLAMP); } Override protected void onDraw(Canvas canvas) { ensureShader(); if (gradient ! null) { Layout layout getLayout(); // 用 Layout 宽度而不是 View 宽度居中/右对齐时文字实际占的宽度更小 float contentWidth layout ! null ? layout.getWidth() : getWidth() - getPaddingLeft() - getPaddingRight(); // 过渡带的左边缘 当前进度对应的 x 坐标 shaderMatrix.setTranslate(progress * contentWidth, 0f); gradient.setLocalMatrix(shaderMatrix); // 必须在 super.onDraw 之前挂上去否则画的是上一次的 Shader getPaint().setShader(gradient); } super.onDraw(canvas); } public void setProgress(float p) { float clamped p 0f ? 0f : (p 1f ? 1f : p); if (Math.abs(clamped - progress) 0.0005f) return; // 过滤无意义重绘 progress clamped; postInvalidateOnAnimation(); // 跟随 VSYNC比 invalidate 稳 } public float getProgress() { return progress; } public void setColors(int highlight, int base) { this.highlightColor highlight; this.baseColor base; this.gradient null; // 颜色变了旧的着色器作废 invalidate(); } }逐段说明一下。ensureShader()里判断getWidth() 0是在躲前面说的那个构造异常feather用getTextSize() * 0.6f而不是写死 dp是因为字号是可变的22sp 和 36sp 用同一个像素宽度的过渡带前者看着糊、后者看着硬。onDraw里取layout.getWidth()而不是 View 宽度是为了照顾居中对齐——如果按钮宽 1080px、文字只占 600px 且居中你按 1080 算进度文字亮完要到 60% 之后才开始动观感很差。shaderMatrix.setTranslate(progress * contentWidth, 0f)这一行是全部逻辑的核心setLocalMatrix定义的是“着色器坐标 → 画布坐标”的映射所以平移量就是过渡带左边缘落在哪个 x 上。progress 0时过渡带从 x0 开始左侧没有内容看起来就是全暗progress 1时过渡带从文字末尾开始左侧全部落在 CLAMP 区整行全亮。参数上还有两个小开关值得留着想要过渡带居中于光标而不是在光标之后把平移量改成progress * contentWidth - feather / 2f想要硬边进度条手感把feather设成 1f 就行不用改代码。3.3 属性与参数颜色、过渡带宽、进度分别怎么调attrs.xml里我只暴露五个属性多一个都是维护负担resources declare-styleable nameLyricGradientTextView attr nameltvHighlightColor formatcolor / !-- 已唱到的颜色 -- attr nameltvBaseColor formatcolor / !-- 未唱到的颜色 -- attr nameltvFeatherWidth formatdimension / !-- 过渡带宽度 -- attr nameltvProgress formatfloat / !-- 初始进度 0~1 -- attr nameltvRunning formatboolean / !-- 是否自动跑一遍 -- /declare-styleable /resources布局里用起来是这样的注意maxLines1和ellipsize可以放心加Shader 不参与测量断行和省略号仍然由系统算com.example.lyricview.widget.LyricGradientTextView android:idid/tv_lyric android:layout_widthmatch_parent android:layout_heightwrap_content android:paddingHorizontal20dp android:paddingVertical12dp android:textSize22sp android:textStylebold android:maxLines1 android:ellipsizeend android:text夜色里只剩这一句还没唱完 app:ltvHighlightColor#FF19D3FF app:ltvBaseColor#66FFFFFF app:ltvFeatherWidth14dp /ltvBaseColor用带透明度的颜色比用纯灰更好看因为底色是压在背景图上的纯灰会显脏。ltvFeatherWidth一般取字号的一半到一倍之间超过字号本身就会显得“糊成一片”失去了歌词的节奏感。4. 进度驱动让高亮跟上播放器、动画和多行歌词控件本身只管“给我一个 0~1”但真实项目里进度来源有两类一类是自己跑的动画比如朗读示范、动效预览一类是外部播放器的真实进度。这两条路要分开写混在一起最容易出现“暂停了字还在跑”这种明显的问题。4.1 内部驱动ValueAnimator 自己跑完一句private ValueAnimator animator; public void startOnce(long durationMs) { cancelAnimation(); animator ValueAnimator.ofFloat(0f, 1f); animator.setDuration(Math.max(1L, durationMs)); animator.setInterpolator(new LinearInterpolator()); // 歌词必须线性别用默认减速 animator.addUpdateListener(a - setProgress((float) a.getAnimatedValue())); animator.start(); } public void cancelAnimation() { if (animator ! null) { animator.cancel(); animator null; } }这里唯一需要强调的是插值器。ValueAnimator默认是AccelerateDecelerateInterpolator起手慢、中间快、收尾慢拿它跑歌词观众会觉得“前面拖、后面赶”。歌词、朗读高亮这类跟时间轴绑定的动效一律换成LinearInterpolator让进度和时间严格成正比才能和音频对得上。setDuration传 0 或负数在部分系统版本上会直接抛异常所以这里包了一层Math.max(1L, durationMs)。另外animator.cancel()之后要记得置空否则下次重进页面拿到的是已经 cancel 的实例isRunning()返回 false动画永远起不来。4.2 外部驱动跟着播放器采样接播放器的时候不要试图从播放器回调里直接拿“进度百分比”事件很多播放器根本没有这种回调只有getCurrentPosition()。我的做法是用 Handler 定频采样把播放位置换算成 0~1private final Handler handler new Handler(Looper.getMainLooper()); private Runnable tick; public void bindProgressSource(ProgressSupplier supplier) { unbindProgressSource(); tick new Runnable() { Override public void run() { long pos supplier.positionMs(); // 当前播放位置 long dur supplier.durationMs(); // 总时长可能是 0 if (dur 0) { setProgress(pos / (float) dur); // 用 float 除别先算百分比整数 } handler.postDelayed(this, 33); // ≈30fps够用且不烧电 } }; handler.post(tick); } public void unbindProgressSource() { if (tick ! null) { handler.removeCallbacks(tick); tick null; } } public interface ProgressSupplier { long positionMs(); long durationMs(); }采样间隔 33ms 对上 30fps 屏幕刚好50ms 能明显看到跳格16ms 又没必要——文字高亮不是游戏画面。pos / (float) dur这里的 float 强转是血泪经验如果写成pos * 100 / dur再除 100进度只有 1% 的粒度长音频看着还好十几秒的短句就会一格一格地跳。4.3 多行歌词按 Layout 的行宽拆开算单行歌词用前面的实现就够了。但如果是三行以内的短段落比如朗读稿一条横贯整个 View 的渐变会让每一行在同一个 x 位置同时亮起来短行会“瞬间全亮”很怪。这时候要按行拆用Layout拿到每行的宽度把总进度按行宽加权分配每行单独裁切、单独设矩阵。Override protected void onDraw(Canvas canvas) { Layout layout getLayout(); ensureShader(); if (layout null || gradient null) { super.onDraw(canvas); return; } final int lineCount layout.getLineCount(); float totalWidth 0f; for (int i 0; i lineCount; i) { totalWidth layout.getLineWidth(i); // 用行宽近似“这一行有多少字” } float consumed progress * totalWidth; // 已经点亮的总宽度 final int padLeft getPaddingLeft(); final int padTop getExtendedPaddingTop(); // 含 fontPadding比 getPaddingTop 准 for (int i 0; i lineCount; i) { float lineWidth layout.getLineWidth(i); float done Math.max(0f, Math.min(lineWidth, consumed)); consumed - lineWidth; if (done 0f) break; // 后面的行还没轮到直接收工 canvas.save(); canvas.clipRect(padLeft, padTop layout.getLineTop(i), padLeft lineWidth, padTop layout.getLineBottom(i)); shaderMatrix.setTranslate(done, 0f); // 行内坐标下的过渡带位置 gradient.setLocalMatrix(shaderMatrix); getPaint().setShader(gradient); canvas.translate(padLeft, padTop); layout.draw(canvas); // 整段重画一遍clip 之外被裁掉 canvas.restore(); } }这段逻辑的风险点在于“整段 Layout 重画 N 次”。三行以内完全没问题行数一多比如整屏的滚动歌词就得换方案要么先画到底层 Bitmap 上做缓存要么退回单行控件一行一个。layout.getLineWidth(i)是像素宽度中文里大致等价于字数但英文和标点会有偏差要更准可以用layout.getPrimaryHorizontal()按字符边界换算代价是每帧多算几次一般不值。getExtendedPaddingTop()这个细节别省用getPaddingTop()在有includeFontPadding的情况下会让裁切框整体上移几个像素表现为文字顶部被切掉一丝非常难查。4.4 生命周期与状态恢复控件里挂了 Handler 和 Animator就得负责收尾Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); cancelAnimation(); unbindProgressSource(); } Override protected Parcelable onSaveInstanceState() { Bundle bundle new Bundle(); bundle.putParcelable(super_state, super.onSaveInstanceState()); bundle.putFloat(progress, progress); return bundle; } Override protected void onRestoreInstanceState(Parcelable state) { if (state instanceof Bundle) { Bundle bundle (Bundle) state; progress bundle.getFloat(progress); super.onRestoreInstanceState(bundle.getParcelable(super_state)); return; } super.onRestoreInstanceState(state); }onDetachedFromWindow里必须停掉采样和动画否则页面退出去了 Handler 还在跑一边持有 Activity 引用一边调setProgressLeakCanary 会在第二天早上给你发通知。状态恢复这块要注意 View 必须有android:id才会走onSaveInstanceState没 id 的 View 系统根本不存。5. 避坑与排查渐变不显示、进度抖动、内存曲线的现场记录这个控件不大但坑都挺隐蔽我自己和协作同事加起来踩了七八次。下面五条是复现率最高的按“现象 → 原因 → 解决”记下来。现象一整段文字只有一个颜色渐变完全没出现。原因有两个都见过。其一是getPaint().setShader(gradient)写在了super.onDraw(canvas)后面——那一帧用的还是旧 Paint而下一帧因为progress没变又不会重绘于是永远停留在这个状态。其二是业务代码里在setText()之后调用了setTextColor(ColorStateList)某些定制 ROM 的 TextView 实现会把mTextPaint的 Shader 一起重置。解决Shader 的设置固定放在super.onDraw之前如果确实需要动态改色走我们自己的setColors()别去碰setTextColor。现象二一进页面就崩堆栈指向LinearGradient构造日志写着 “width and height must be 0”。原因是getWidth()返回 0 的时候就把渐变建了——典型场景是在onCreate里直接setProgress(0.3f)此时 View 还没测量。解决渐变改成懒加载ensureShader()里判getWidth() 0直接返回setProgress只存值等真正onDraw时再生效顺序自然就对了。现象三进度跟着播放器走但高亮一卡一卡像网络不好。原因通常是采样间隔太大100ms 以上加上用整数百分比做的换算。解决采样降到 33ms进度全程用 float 传递重绘用postInvalidateOnAnimation()而不是invalidate()让绘制对齐 VSYNC。另外setProgress里那个0.0005f的阈值别删它挡掉的是大量“进度没变但播放器仍然回调”的无效重绘。现象四给文字加发光效果后字的边缘和光晕被切掉一块。Paint.setShadowLayer的阴影是画在文字边界之外的而 View 的绘制区域就等于它自己的边界超出的部分被裁。解决给控件加android:padding至少一个 blur 半径让文字内缩、把空间留给光晕纯描边方案则用Paint.Style.STROKE配一个略大的strokeWidth同样需要 padding 兜底。别去动父容器的clipChildren那会影响整个布局。现象五列表里滚两下就开始掉帧Memory Profiler 里LinearGradient的实例数一直涨。这是“每帧 new 一个渐变”的标准症状通常出现在从教程里抄来的四段式写法上。解决渐变只建一次位置靠setLocalMatrix平移颜色变了就整体置空重建。改完之后实例数应该稳定在一两个GC 曲线也平了。6. 二次开发从“整行渐变”到“逐字填充 描边发光”真正要上线产品十有八九会再提两个需求一是高亮要“逐字填充”字与字之间不能有半个字的模糊二是高亮那部分要有点发光或者描边看起来才够抓眼。这两件事在这个结构上都只需要改onDraw不用动测量、不用动进度接口。逐字填充的做法是把进度从“宽度比例”换成“字符比例”用getLayout()遍历每一个字符的边界横坐标找到“已唱到第几个字”再把过渡带对齐到那个字的右边界两侧各留 1~2px 做柔化。这样每一帧要么停在字边界上要么在两个字之间做很短的一段过渡视觉上就是“一个字一个字点亮”而不是“一条边扫过去”。中文等宽字体效果尤其好英文要按单词边界取整否则w-o-r-d会一个字一个字亮读起来别扭。发光和描边是两层绘制第一层用Paint.Style.STROKE加setShadowLayer画一遍粗的作为光晕第二层照旧画填充。光晕层不要用同一个 Shader直接给它一个纯高亮色、透明度 40% 左右就够了跟着progress一起用同一个 Matrix 平移颜色分布才和本体一致。几个常调的参数和它们的推荐区间供你起手改参数含义推荐值调过头会怎样feather过渡带宽度字号的 0.5~1.0 倍超过 1.5 倍像加了个模糊滤镜baseColor透明度未唱部分的可见度30%~45%太低看不见太高没有主次采样间隔外部进度频率33ms100ms 以上明显跳格strokeWidth描边粗细字号的 4%~6%太粗会糊掉笔画细节进度阈值触发重绘的最小变化0.0005~0.002太小空转重绘太大肉眼能看到跳改到这里我建议做一次反向验证把feather临时设成 1pxprogress用 0.1 的步长手动推一遍看每一次是不是都恰好落在预期的位置。这一步能抓出 90% 的“进度对不上”问题——因为一旦过渡带足够窄任何坐标系的偏移都会变成肉眼可见的错位而柔和过渡带会把这些错误全都糊过去等到用户反馈“字和声音差半句”时你已经很难定位了。我自己这套流程是从一次线上事故里长出来的。那次是朗读页的高亮比声音慢了将近半秒排查半天发现是中英混排里layout.getLineWidth()和字符边界差了十几个像素加上过渡带足够宽测试环境里根本看不出来。从那以后我每次接这类渐变文字的需求都会先切到 1px 的硬边模式手动扫一遍进度确认坐标对得上了再调回过柔和的过渡带。希望帮到你。本文还有配套的精品资源点击获取