卡拉OK歌詞漸變TextView:Shader與Xfermode核心原理)
簡介這是一份面向Android初、中級開發(fā)者的自定義View示例代碼基于Gradient實現(xiàn)歌詞風(fēng)格文字漸變效果。核心GradientTextView利用android.graphics.LinearGradient創(chuàng)建線性漸變著色器配合矩陣平移讓文字顏色持續(xù)流動代碼量少且注釋清晰非常適合學(xué)習(xí)Canvas繪制、Paint著色器與自定義View的測量流程項目涵蓋了從Gradle依賴配置、XML布局引用到Java代碼調(diào)用的完整鏈路通過JitPack倉庫即可快速引入依賴。壓縮包內(nèi)共有39個文件包含13個xml界面布局、6個java核心源碼、5個png演示圖另有g(shù)radle構(gòu)建腳本、proguard規(guī)則、README說明等整體僅101KB輕量易下載。當(dāng)前已有494人學(xué)習(xí)適用于音樂播放器歌詞展示、字幕滾動等場景可直接導(dǎo)入Android Studio運行也可作為自定義View入門練手或二次開發(fā)的基礎(chǔ)模板。 卡拉OK字幕那個漸變色TextView很多播放器App里都有這種效果歌詞逐行點亮或者整行文字從左到右掃過一道彩色光。我以前以為這種效果得用第三方庫或者寫一堆動畫后來自己動手用Android原生的Gradient漸變實現(xiàn)了一遍發(fā)現(xiàn)核心邏輯其實沒有想象中復(fù)雜關(guān)鍵就集中在TextView的繪制環(huán)節(jié)和Paint的Shader運用上。這篇博文就把我踩過的坑和最終的實現(xiàn)方案完整拆開講適合已經(jīng)能熟練寫自定義View、但還沒怎么碰過Shader和Xfermode的Android開發(fā)同學(xué)。不管你是想在播放器頁面做歌詞滾動還是只想給標題加一點掃光動態(tài)效果這套思路都能直接遷移過去用。1. 項目核心需求拆解與方案選型先把這個效果到底要什么說清楚。所謂歌詞風(fēng)格的TextView拆開來看其實有三個層次的需求最基礎(chǔ)的是整行文字要能顯示出一種“漸變”的色彩過渡不是純白也不是純黑而是比如從半透明到純白、或者從灰色到高亮色。進階一點的是這種漸變要能跟著歌詞播放進度移動唱到哪個字哪個字后面的顏色就點亮形成一種“逐句甚至逐字掃過”的視覺反饋。再往深一層為了保證顯示質(zhì)量文字邊緣必須清晰銳利不能因為加了漸變就出現(xiàn)明顯的鋸齒或模糊。我當(dāng)時在項目里把這三個需求都拉通做了一遍。最省事的方式當(dāng)然是讓UI切圖但歌詞是動態(tài)文本字體、字號、換行都可能變切圖根本接不住。自己寫自定義View是唯一靠譜的路徑。在技術(shù)選型上當(dāng)時擺在面前的有三條路方案核心思路優(yōu)點缺點方案A直接改TextView背景在drawable里搞漸變實現(xiàn)最簡單漸變只作用在背景上文字顏色不跟著變效果假方案B自定義View重寫onDraw全方位控制繪制靈活度最高效果可控需要自己處理測量、基線、多行等邏輯方案CShader遮罩混合借助Paint的Shader繪制漸變文字文字邊緣銳利性能高需要理解Xfermode或Clip原理有一定門檻我最后選了方案C的變種不需要完全重寫TextView而是子類化TextView在onDraw里攔截繪制邏輯用Shader配合Canvas的裁剪/混合模式來實現(xiàn)漸變文字。為什么不用純背景漸變方案因為歌詞場景里很多是純色背景你背景再花哨文字本身不變色根本達不到“歌詞點亮”的效果。而Glide這種圖片庫管不了文字繪制的細顆粒度問題。具體到實現(xiàn)方式Shader配Xfermode是最終的殺招。LinearGradient負責(zé)生成從左到右的顏色過渡Xfermode負責(zé)把漸變色和文字字形做交集混合。這樣漸變只出現(xiàn)在文字筆畫內(nèi)部背景干干凈凈。2. 核心繪制原理與關(guān)鍵細節(jié)要徹底理解這個實現(xiàn)得先把Android自定義View繪制文字的幾個關(guān)鍵知識點拉通。2.1 Shader與LinearGradientShader直譯過來就是著色器在Android繪圖體系里就是告訴Paint“你畫畫的時候用哪種顏色填充規(guī)則”。最常用的是LinearGradient線性漸變由起點、終點和中間幾個顏色節(jié)點定義。LinearGradient linearGradient new LinearGradient( startX, startY, endX, endY, new int[]{Color.GRAY, Color.WHITE}, new float[]{0f, 1f}, Shader.TileMode.CLAMP );這里要特別注意TileMode參數(shù)它決定了漸變范圍超出起點終點之后的行為。CLAMP是取邊緣顏色繼續(xù)填充REPEAT是重復(fù)漸變MIRROR是鏡像漸變。歌詞效果里只會用到CLAMP因為我們的漸變只在一行文字范圍內(nèi)移動。直觀理解一下假設(shè)你在圖上畫了一條線作為漸變軸線性漸變就是沿著這條軸線把顏色從一種過渡到另一種。漸變軸以外的區(qū)域CLAMP模式就用軸兩端的顏色“硬撐”REPEAT就不斷重復(fù)漸變周期。2.2 Xfermode與SRC_INXfermode全稱TransferMode解決的是“將要繪制的內(nèi)容”和“已經(jīng)繪制的內(nèi)容”之間怎么混合的問題。它的名字可能有點嚇人但你只要知道一件事SRC_IN就是“只保留源內(nèi)容與目標內(nèi)容重疊的部分”。套到我們場景里目標內(nèi)容已經(jīng)畫上去的黑色文字源內(nèi)容從左到右的漸變色帶混合結(jié)果漸變色只出現(xiàn)在文字筆畫區(qū)域背景透明還有幾個替代方案可以不用Xfermode直接用Shader畫筆drawText效果就是整行文字全部變成漸變但這種情況下等于說文字被漸變完全覆蓋背景空白區(qū)域也被“染色”了——正因如此才需要混合模式來框定范圍。注意Xfermode在硬件加速下需要配合離屏緩沖使用否則可能失效這個我們在代碼實現(xiàn)部分會專門處理。2.3 onDraw的繪制階段TextView的onDraw是自繪效果的主戰(zhàn)場。我們攔截它在super.onDraw之前準備好Shader和混合模式繪制流程如下先用普通Paint畫一遍完整文字通常是半透明白色作為底色。通過canvas.saveLayer開啟離屏緩沖在緩沖層上繪制漸變。設(shè)置Xfermode為SRC_IN再次drawText讓漸變色從緩沖層穿透到文字筆畫內(nèi)?;謴?fù)畫布把Xfermode清空避免影響后續(xù)繪制。離屏緩沖的關(guān)鍵點在于它讓后續(xù)繪制先進入一個獨立圖層完成混合后再一次性貼回主畫布。這樣SRC_IN只會影響當(dāng)前這一輪繪制不會污染其他UI元素。3. 完整實現(xiàn)用LinearGradient實現(xiàn)歌詞進度漸變的TextView下面給出一個可直接運行的實現(xiàn)類核心邏輯集中在onDraw方法里。public class LyricGradientTextView extends AppCompatTextView { // 0f ~ 1f表示歌詞點亮位置的進度 private float progress 0f; // 漸變起始和結(jié)束位置基于控件寬度計算 private float gradientStartX 0f; private float gradientEndX 0f; // 保存上一次的寬度用來判斷是否需要重建Shader private int lastWidth -1; private Paint paint new Paint(Paint.ANTI_ALIAS_FLAG); private LinearGradient gradientShader; public LyricGradientTextView(Context context) { this(context, null); } public LyricGradientTextView(Context context, Nullable AttributeSet attrs) { super(context, attrs); // TextView自身的文字渲染交給paint保證和父類邏輯一致 setLayerType(LAYER_TYPE_HARDWARE, null); // 硬件加速 } Override protected void onDraw(Canvas canvas) { // 關(guān)鍵步驟1繪制文字使用普通顏色 int currentWidth getWidth(); if (currentWidth 0 currentWidth ! lastWidth) { lastWidth currentWidth; // 漸變軸從控件最左到最右 gradientStartX 0f; gradientEndX currentWidth; // 注意這里不能用getTextSize()作為漸變高度因為漸變是作用于整行文字框的 gradientShader new LinearGradient( gradientStartX, 0, gradientEndX, 0, new int[]{ Color.parseColor(#60FFFFFF), // 未點亮部分半透明白 Color.parseColor(#FFFFFF) // 點亮部分純白 }, new float[]{0f, 1f}, Shader.TileMode.CLAMP ); } // 繪制文字底色 / 基礎(chǔ)色 paint.setShader(null); paint.setColor(Color.parseColor(#60FFFFFF)); paint.setTextSize(getTextSize()); paint.setTypeface(getTypeface()); paint.setTextAlign(Paint.Align.LEFT); // 這里直接用TextView自身的padding和高度計算baseline int baseline getBaseline(); canvas.drawText(getText().toString(), getPaddingLeft(), baseline, paint); // 關(guān)鍵步驟2開啟離屏緩沖繪制漸變層 int saveCount canvas.saveLayer( getPaddingLeft(), 0, getWidth() - getPaddingRight(), getHeight(), null ); // 繪制漸變色塊覆蓋整個文字區(qū)域 paint.setShader(gradientShader); paint.setColor(Color.WHITE); // Shader會覆蓋color所以color值不重要 canvas.drawRect( getPaddingLeft(), 0, getWidth() - getPaddingRight(), getHeight(), paint ); // 關(guān)鍵步驟3用SRC_IN模式只保留漸變與文字重疊的部分 paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); // 再次繪制文字 canvas.drawText(getText().toString(), getPaddingLeft(), baseline, paint); // 清理 paint.setXfermode(null); canvas.restoreToCount(saveCount); // 注意不再調(diào)用 super.onDraw(canvas)避免文字重復(fù)繪制 // super.onDraw(canvas); } public void setProgress(float progress) { this.progress Math.max(0f, Math.min(1f, progress)); // 移動漸變軸的位置 if (gradientShader ! null) { gradientShader.setLocalMatrix( MatrixUtils.createTranslateMatrix( this.progress * getWidth() - gradientStartX, 0 ) ); } postInvalidateOnAnimation(); // 兼容動畫場景比invalidate更高效 } }這段代碼看起來不長但里面藏著幾個極其容易翻車的細節(jié)我一個個拆開講。3.1 為什么漸變軸要覆蓋整個控件寬度在LinearGradient構(gòu)造時我把漸變起點設(shè)在x0終點設(shè)在x控件寬度。這樣漸變在整行范圍內(nèi)均勻展開從半透明白到純白。當(dāng)progress0時漸變軸在最左邊整個文字都呈現(xiàn)半透明白色progress1時漸變軸移動到最右端整行文字幾乎全變純白。如果把漸變軸只設(shè)成文字寬度的占比在繪制多行或不等寬文字時會出現(xiàn)漸變斷層看起來就像“亮光被切斷”了。讓漸變走滿全控件寬度再通過矩陣平移來控制“當(dāng)前亮到哪”邏輯更通順。3.2 getBaseline()的妙用很多自定義TextView的教程都直接用控件高度的一半加textSize的一小段來算baseline這是不精確的。TextView內(nèi)部已經(jīng)幫我們算好了getBaseline()繼承之后直接調(diào)用即可既準確又省心。但這里有一個細節(jié)父類的onMeasure和onLayout會正確處理TextView的padding、gravity等屬性baseline也是基于這些計算好的。我們自己在onDraw里drawText時必須用這個值否則在多行或padding不為0時文字會飄。3.3 為什么不調(diào)用super.onDraw如果調(diào)用super.onDrawTextView會先用內(nèi)部邏輯繪制一遍文字。而我們自己又畫了一遍shader文字結(jié)果就是文字被疊加繪制兩遍。半透明底色的地方會出現(xiàn)顏色加深漸變色區(qū)域也可能出現(xiàn)描邊重影。所有核心效果都在onDraw里自己畫就必須屏蔽父類繪制。屏蔽之后TextView原有的文字顏色、字體設(shè)置全部失效這些都要我們自己畫。代碼里已經(jīng)做了處理setTextSize、setTypeface、getText()都取自TextView的公共API所以XML里配置的字體大小、字體樣式依然有效只有android:textColor這個屬性變成無效了。想讓顏色生效就得通過擴大顏色數(shù)組并重新構(gòu)造Shader來實現(xiàn)。4. 實操過程中的三個大坑與排查實錄整個效果寫完了不意味著就完事實際跑到界面上的時候各種鬼畜問題全冒出來了。我按踩坑順序記錄一下省得你們再走一遍。4.1 寬度不夠200px的鬧劇第一次寫完我興沖沖地放到布局里com.example.widget.LyricGradientTextView android:layout_widthwrap_content android:layout_heightwrap_content android:text 測試歌詞 /結(jié)果文字完全不顯示。搗鼓了半天發(fā)現(xiàn)當(dāng)wrap_content且文字特別短時控件寬度大概只有幾十像素而我的LinearGradient定義了起點0終點200pxShader默認按整行200px的寬度漸變。文字只有20px寬漸變軸卻遠在200px的位置于是文字區(qū)域取到的顏色可能一直是接近漸變起始端的顏色如果起始顏色是透明的或者和背景同色就直接“消失”了。后來我把漸變軸改成寬度動態(tài)變化的每次寬度變化就重建Shader問題就解了。后來還加了一層保護把漸變終點固定為Math.max(getWidth(), 200px)防止極端情況下面Shader寬度為0導(dǎo)致繪制異常。4.2 硬件加速帶來的離屏緩沖失效真機調(diào)試的時候Android 9以上的設(shè)備一切正常但Android 7的老設(shè)備上SRC_IN直接不生效漸變變成了整塊矩形蓋在文字上非常難看。查了資料才確認Xfermode在硬件加速下如果目標圖層是獨立的View層級在很多老版本ROM上會退化為不支持。解決方案就是在自定義View構(gòu)造時調(diào)用setLayerType(LAYER_TYPE_HARDWARE, null)把整個View設(shè)成硬件圖層這樣Canvas內(nèi)部的saveLayer就會正確地生成離屏緩沖Xfermode才能正?;旌稀_@里額外提醒一句如果保存到Bitmap再貼回來也可以避坑但性能會差不少而且Bitmap格式處理不當(dāng)還會出現(xiàn)顏色偏差。硬件圖層是性價比最高的方案。4.3 動畫卡頓和Shader重建有位朋友照著寫完之后跟我說卡頓明顯我一看代碼他把setProgress改成直接invalidate()而onDraw每次都會重建LinearGradient對象。高頻動畫下老設(shè)備GC壓力很大肯定卡。優(yōu)化策略Shader只有在控件寬度變化時才重建動畫期間寬度不變Shader就不會重復(fù)構(gòu)造。更新進度時不要invalidate()用postInvalidateOnAnimation()它會等待下一個vsync信號跟Choreographer對齊比invalidate刷新時機更平滑。顏色數(shù)組和顏色節(jié)點是固定的提前定義成常量避免onDraw里創(chuàng)建對象。實測優(yōu)化后在低端機上連續(xù)拖動SeekBar幀率也能穩(wěn)在50幀以上不會掉到20幀那種肉眼可見的卡頓。5. 進階玩法從單行歌詞到多行歌詞與滾動聯(lián)動上面的代碼處理的是單行文字但真實歌詞場景是整屏歌詞列表還要跟隨播放進度滾動。這種一般是RecyclerView或ScrollView嵌套多行LyricGradientTextView。每一行設(shè)置一個獨立的progress值按當(dāng)前行的播放進度來點亮。不過多行歌詞有個新問題我們的漸變色Shader是基于控件整體寬度均勻過渡的在一行比較短時漸變軸的一端可能超出文字末尾。這時候最好還是讓Shader寬度匹配整行文字的實際寬度也就是重寫onMeasure里的getTextWidth動態(tài)計算最長一行的寬度把它作為漸變終點。具體在多行歌詞列表中的應(yīng)用我建議兩種做法方案實現(xiàn)方式適用場景整行漸變每行一個LyricGradientTextView播放進度在行內(nèi)移動單行歌詞或當(dāng)前播放行逐字漸變拆成單個字符的View每個字符各自控制Shader偏移桌面歌詞、卡拉OK逐字效果逐字漸變的實現(xiàn)其實就是拿到每個字的文字寬度按字設(shè)置Shader的LocalMatrix把漸變軸平移到對應(yīng)位置??雌饋盱趴岬阅荛_銷大一些歌詞多幀滾動時會對每一幀做大量矩陣計算開發(fā)時要注意做緩存。5.1 卡拉OK逐字點亮效果如果想把效果做得更加精細可以做逐字點亮。核心思路是不再用整個LinearGradient覆蓋全行而是把每個字符當(dāng)成獨立的繪制單元在onDraw里循環(huán)drawText依次畫出每個字符并根據(jù)當(dāng)前進度決定該字符的Shader偏移量。偽代碼如下float charStartX getPaddingLeft(); for (int i 0; i text.length(); i) { String ch text.substring(i, i 1); float charWidth paint.measureText(ch); float charProgressStart (float) i / text.length(); float charProgressEnd (float) (i 1) / text.length(); // 判斷當(dāng)前字符是否被點亮 if (progress charProgressStart progress charProgressEnd) { float innerProgress (progress - charProgressStart) / (1.0f / text.length()); // 利用innerProgress控制Shader平移 gradientShader.setLocalMatrix(MatrixUtils.createTranslateMatrix( charStartX - gradientStartX innerProgress * charWidth, 0 )); paint.setShader(gradientShader); } else if (progress charProgressEnd) { // 已經(jīng)唱過直接白色 paint.setShader(whiteShader); } else { // 還沒唱到灰色 paint.setShader(grayShader); } canvas.drawText(ch, charStartX, baseline, paint); charStartX charWidth; }這種寫法簡單的效果測試沒問題但實際用在一整屏歌詞上因為有大量drawText調(diào)用字符越界繪制需要額外處理幀率壓力會比較大。開發(fā)時優(yōu)選方案是直接把一行的“點亮比例”算好用一個LinearGradient搞定犧牲一點逐字的精細度換取穩(wěn)定流暢。5.2 與SeekBar聯(lián)動把setProgress方法開放出去之后和播放器SeekBar聯(lián)動就是一行代碼的事seekBar.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() { Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { lyricTextView.setProgress(progress / 1000f); // 按播放總時長映射 } });注意進度映射SeekBar的max通常是播放總時長毫秒progress是當(dāng)前播放位置。歌詞漸進的progress值應(yīng)該是0到1之間的浮點數(shù)映射關(guān)系是當(dāng)前進度除以總時長。這部分的細節(jié)不算復(fù)雜但很影響使用體驗寫的時候務(wù)必保持一致性。真正接播放器的時候還需要考慮音頻焦點變化、播放暫停、seek穩(wěn)定等因素這些就超出TextView本身的范圍了。6. 常見問題速查與避坑清單把最常被問到的問題整理成一張表方便后面排查問題現(xiàn)象原因解決方案文字顏色被漸變蓋住看不到字paint.setColor和Shader都是設(shè)置顏色的Shader優(yōu)先級更高如果要整體單色就別設(shè)Shader要有漸變就只靠Shader漸變彩色出現(xiàn)花屏/色塊Shader的TileMode設(shè)置成REPEAT并且寬度小于控件寬度按控件寬度動態(tài)設(shè)置Shader終點用CLAMP文字有殘影/重影onDraw里畫了兩遍文字屏蔽super.onDraw只繪制一次自定義View在列表里卡頓Shader頻繁重建寬度不變時復(fù)用Shader動畫用postInvalidateOnAnimationAndroid 7設(shè)備上漸變不生效硬件加速下Xfermode不兼容構(gòu)造時setLayerType(LAYER_TYPE_HARDWARE, null)wrap_content時文字消失漸變軸超出文字范圍文字區(qū)域顏色透明漸變軸寬度取控件的最大寬度并做空值保護點了進度但顏色沒變化progress改了但沒有觸發(fā)重繪在setProgress里調(diào)用postInvalidateOnAnimation()多行文本左右不對齊出問題自定義View忽略了TextView原生的lineSpacing等屬性盡量用單行做歌詞多行場景自行處理Layout對象另外說一個多數(shù)人不知道的小細節(jié)TextView內(nèi)部繪制文字用的是TextLayout支持復(fù)雜排版、行間距等特性。一旦我們跳過super.onDraw這些排版邏輯全部失效。如果只是想給整行加漸變單行是最穩(wěn)的場景非要支持多行時就得考慮用StaticLayout自己排版這會讓繪制邏輯復(fù)雜很多但功能才能完整。我個人在項目里的選擇是單行歌詞效果直接上這個自定義View多行歌詞列表就直接交給RecyclerView處理整行的漸變狀態(tài)這算是工程效率和視覺效果的平衡點。7. 最終心得一次繪制重構(gòu)帶來的連鎖收益做這個LyricGradientTextView的過程我重新把View的measure、draw、Shader、Xfermode、硬件加速這些概念串成了一條線。以前寫自定義控件總是照著別人的代碼抄一版能跑就完事這次因為要摳文字的邊緣銳利度和動畫流暢度被迫把底層機制弄了個明明白白。最后分享一個控制漸變方向的小技巧如果想讓文字從上到下漸變只需把LinearGradient構(gòu)造方法里的startY和endY改成0和控件高度坐標軸方向就變了其他邏輯一行都不用改。同理斜向漸變就是startX, startY, endX, endY四個值都自定義。這樣一套代碼歌詞掃描、標題流光、進度著色都能覆蓋。以后你再看到播放器里的歌詞一行行依次亮起來就可以跟同事說一句這效果我寫過一個TextView加一個Shader就能搞定。本文還有配套的精品資源點擊獲取