現(xiàn)仿PhotoShop調(diào)色板:HSV取色器實(shí)戰(zhàn))
簡(jiǎn)介面向Android開(kāi)發(fā)者的高級(jí)顏色選擇器實(shí)現(xiàn)資源旨在將Photoshop中專業(yè)調(diào)色板交互移植至移動(dòng)端。項(xiàng)目覆蓋色輪選擇、RGB/HSV色彩模式切換、滑塊控制、Alpha透明度調(diào)整及色樣管理等核心功能同時(shí)包含UI布局優(yōu)化與觸摸事件處理的技術(shù)細(xì)節(jié)適合想深入理解自定義View與顏色處理的中高級(jí)開(kāi)發(fā)者。資源包共58個(gè)文件以Java源碼、編譯后的class、XML布局、PNG資源為主另含測(cè)試APK、JAR庫(kù)及配置文件便于直接運(yùn)行與二次開(kāi)發(fā)。已有826人學(xué)習(xí)瀏覽壓縮包僅330KB結(jié)構(gòu)緊湊但內(nèi)容完整。資源中的ColorPanel組件從布局繪制、事件分發(fā)到顏色空間換算均給出完整示例通過(guò)閱讀源碼可掌握色彩轉(zhuǎn)換算法、觸控反饋與性能優(yōu)化的實(shí)現(xiàn)鏈路既可作為組件集成到實(shí)際項(xiàng)目也能作為系統(tǒng)學(xué)習(xí)移動(dòng)端圖像處理組件的參考案例對(duì)提升自定義控件能力有切實(shí)幫助。1. 為什么要親手?jǐn)]一個(gè)仿PhotoShop調(diào)色板先交代下背景。我之前在做一個(gè)偏向設(shè)計(jì)工具類的App里面有個(gè)很核心的訴求用戶需要像在PhotoShop里那樣自由地取色、調(diào)色。最初圖省事接了個(gè)開(kāi)源的顏色選擇器庫(kù)但實(shí)際用起來(lái)非常難受——交互太簡(jiǎn)陋只有色相條加一個(gè)方塊的模式放大取色時(shí)連基本的微調(diào)都不支持更別提煉色器、最近顏色、Hex實(shí)時(shí)聯(lián)動(dòng)這些習(xí)慣了。后來(lái)在技術(shù)社區(qū)看到很多人在討論類似的痛點(diǎn)Android原生自帶的ColorPickerDialog丑到?jīng)]法直視第三方庫(kù)的自定義程度又有限。于是我決定自己動(dòng)手基于Android的自繪體系實(shí)現(xiàn)一個(gè)仿PhotoShop調(diào)色板。這個(gè)項(xiàng)目不依賴任何第三方渲染框架核心就是自定義View加Canvas繪制純Kotlin實(shí)現(xiàn)最終完整支持色相滑動(dòng)條、飽和度/亮度二維取色面板、RGB分量調(diào)節(jié)、Hex實(shí)時(shí)輸入、放大微調(diào)以及屏幕取色器。這篇文章適合誰(shuí)看如果你正準(zhǔn)備給App加一個(gè)專業(yè)級(jí)的調(diào)色面板或者你想搞懂PhotoShop那種取色交互背后的色值計(jì)算邏輯與手勢(shì)處理方案那這篇實(shí)戰(zhàn)記錄應(yīng)該能讓你少走很多彎路。我會(huì)從色彩模型的選擇、核心View的繪制、手勢(shì)交互設(shè)計(jì)到大面板的性能優(yōu)化把整個(gè)項(xiàng)目的關(guān)鍵環(huán)節(jié)拆開(kāi)講清楚。別人可能覺(jué)得調(diào)色板就是個(gè)換個(gè)顏色的小功能但真做起來(lái)你會(huì)發(fā)現(xiàn)它涉及ColorSpace轉(zhuǎn)換、Canvas繪制效率、觸摸事件分發(fā)、View局部刷新、位圖像素讀取等多塊知識(shí)。正因?yàn)椴攘瞬簧倏游也畔氚淹暾?jīng)驗(yàn)沉淀下來(lái)。2. 核心算法先行HSV模型是PhotoShop取色的地基在動(dòng)手寫(xiě)任何View之前必須先想明白一個(gè)本質(zhì)問(wèn)題為什么PhotoShop的調(diào)色板長(zhǎng)得是一個(gè)色相條一個(gè)大方塊而不是一堆紅綠藍(lán)數(shù)字滑桿2.1 為什么選HSV而不是RGBRGB是一種面向設(shè)備的顏色模型紅綠藍(lán)三原色疊加表達(dá)顏色。它的問(wèn)題是不直觀你想讓顏色更青一點(diǎn)需要同時(shí)調(diào)整G和B普通人根本轉(zhuǎn)不過(guò)彎。而HSV把顏色分解為色相Hue0-360度、飽和度Saturation0-1、明度Value0-1三個(gè)維度剛好對(duì)應(yīng)人類的直覺(jué)感知這是什么顏色、顏色正不正、顏色亮不亮。PhotoShop經(jīng)典取色界面就是圍繞HSV設(shè)計(jì)的色相條負(fù)責(zé)選H值二維方塊的橫向是S、縱向是V。這個(gè)交互最大的好處是當(dāng)你把H固定在某個(gè)色相后方塊的左邊緣是純灰階S0右邊緣是純色S1上邊緣是最亮V1下邊緣是黑色V0一屏之內(nèi)就能看全所有明暗和濃淡的可能組合。2.2 HSV與RGB轉(zhuǎn)換的降級(jí)方案Android的Color.colorToHSV()和Color.HSVToColor()其實(shí)已經(jīng)提供了現(xiàn)成的轉(zhuǎn)換方法底層計(jì)算我第一版也直接用了。但把玩下來(lái)發(fā)現(xiàn)有兩點(diǎn)不滿意一是大色塊逐像素轉(zhuǎn)換場(chǎng)景下反復(fù)調(diào)用系統(tǒng)方法會(huì)有額外開(kāi)銷(xiāo)二是HSVToColor對(duì)參數(shù)合法性的隱含約束不夠靈活比如H值越界時(shí)會(huì)算出讓人意外的顏色。所以我在項(xiàng)目中單獨(dú)實(shí)現(xiàn)了一組轉(zhuǎn)換函數(shù)核心邏輯保持和Android實(shí)現(xiàn)一致但保證輸入H在0-360區(qū)間、S和V在0-1區(qū)間時(shí)一定返回預(yù)期的ARGBfun hsvToColor(h: Float, s: Float, v: Float): Int { val hue ((h % 360) 360) % 360 val sat s.coerceIn(0f, 1f) val value v.coerceIn(0f, 1f) val c value * sat val x c * (1 - kotlin.math.abs((hue / 60) % 2 - 1)) val m value - c val (r, g, b) when { hue 60 - Triple(c, x, 0f) hue 120 - Triple(x, c, 0f) hue 180 - Triple(0f, c, x) hue 240 - Triple(0f, x, c) hue 300 - Triple(x, 0f, c) else - Triple(c, 0f, x) } return Color.rgb( ((r m) * 255).toInt().coerceIn(0, 255), ((g m) * 255).toInt().coerceIn(0, 255), ((b m) * 255).toInt().coerceIn(0, 255) ) }這段邏輯本質(zhì)上是把HSV色環(huán)切成六個(gè)60度的扇區(qū)每個(gè)扇區(qū)RGB三通道的變化規(guī)律是不同的線性插值。理解了這個(gè)公式后面做色相條漸變和方塊面板漸變就都順了。2.3 PhotoShop取色交互的分解動(dòng)作PhotoShop的調(diào)色板看起來(lái)很復(fù)雜實(shí)際拆解下來(lái)就是三個(gè)動(dòng)作的疊加拖動(dòng)色相條改變H值S保持1、V保持1時(shí)得到當(dāng)前色相的頂點(diǎn)純色這個(gè)純色是二維面板漸變的基準(zhǔn)色。在二維面板點(diǎn)擊或拖動(dòng)以基準(zhǔn)色為基礎(chǔ)橫向線性減小S飽和度為0時(shí)變灰縱向線性減小V明度為0時(shí)變黑。微調(diào)RGB或Hex把HSV結(jié)果和RGB結(jié)果做雙向同步任意一側(cè)改動(dòng)都會(huì)實(shí)時(shí)刷新另一側(cè)的顯示與色塊預(yù)覽。只要把這三個(gè)動(dòng)作的狀態(tài)機(jī)和回調(diào)設(shè)計(jì)好整體功能就算成立了。我的實(shí)現(xiàn)里使用一個(gè)ColorState數(shù)據(jù)類維護(hù)當(dāng)前色值它同時(shí)保存hsv和rgb兩套表示任何UI變更都走同一個(gè)applyChange入口保證所有關(guān)聯(lián)控件同步刷新。3. 色相條與二維取色面板的繪制看著像PhotoShop只是第一步這部分是視覺(jué)效果的核心。PhotoShop的色相條和二維面板都帶有非常順滑的漸變過(guò)渡想在Android里復(fù)現(xiàn)這種質(zhì)感關(guān)鍵是用好SweepGradient和LinearGradient。3.1 色相條的兩種畫(huà)法色相條常見(jiàn)的表現(xiàn)有兩種一種是直線條從左到右漸變另一種是圓環(huán)環(huán)繞一周。PhotoShop桌面端常用的是直線豎向色相條。我實(shí)現(xiàn)的是直線橫向色相條視覺(jué)效果穩(wěn)定手勢(shì)也只在一維方向做。線性色相條直接用LinearGradient生成360度的連續(xù)色相漸變val colors IntArray(360) { i - hsvToColor(i.toFloat(), 1f, 1f) } val gradient LinearGradient( 0f, 0f, width.toFloat(), 0f, colors, null, Shader.TileMode.CLAMP )這里colors數(shù)組生成360個(gè)顏色每個(gè)顏色對(duì)應(yīng)一個(gè)色相角度的純色S1, V1。LinearGradient默認(rèn)會(huì)在這些顏色之間做線性插值視覺(jué)上就是一條順滑的彩虹漸變。其實(shí)更省內(nèi)存的方式是只用幾個(gè)關(guān)鍵顏色讓漸變庫(kù)自動(dòng)插值但實(shí)測(cè)360個(gè)顏色在色相過(guò)渡上更接近PhotoShop的連續(xù)感且只初始化一次性能完全可接受。3.2 二維飽和度明度面板的疊加漸變二維面板的核心技巧在于兩層漸變疊加第一層以當(dāng)前色相純色為基準(zhǔn)從左到右從飽和到不飽和即從純色漸變?yōu)榘咨诙訌纳系较聫耐该鳚u變到黑色。PhotoShop里這個(gè)面板的左上角是白色S0, V1右上角是當(dāng)前純色S1, V1左下角是黑色S0, V0右下角是黑色S1, V0。理解了這四個(gè)角你就知道漸變?cè)撛趺磳?xiě)了// 第一層橫向飽和度漸變 val saturationShader LinearGradient( 0f, 0f, width.toFloat(), 0f, Color.WHITE, currentPureColor, Shader.TileMode.CLAMP ) // 第二層縱向明度漸變 val valueShader LinearGradient( 0f, 0f, 0f, height.toFloat(), Color.TRANSPARENT, Color.BLACK, Shader.TileMode.CLAMP )繪制時(shí)先畫(huà)橫向飽和度漸變?cè)侬B加縱向明度漸變。這里有個(gè)必須注意的地方千萬(wàn)不要用Paint的setAlpha去處理明度層因?yàn)槟鞘钦w半透明會(huì)把底下漸變的邊界也弄糊。正確做法是讓LinearGradient自身在TRANSPARENT到BLACK之間插值這樣顏色空間才是按比例壓暗而不是蒙一層玻璃紙。3.3 指示器找準(zhǔn)位置要比畫(huà)得好看更重要漸變畫(huà)完之后還需要畫(huà)取色指示器。PhotoShop里是一個(gè)圓環(huán)加十字準(zhǔn)星。我實(shí)現(xiàn)的是一個(gè)帶白邊的小圓private fun drawIndicator(canvas: Canvas, cx: Float, cy: Float, color: Int) { val outer Paint(Paint.ANTI_ALIAS_FLAG).apply { style Paint.Style.STROKE strokeWidth 6f color Color.WHITE } val inner Paint(Paint.ANTI_ALIAS_FLAG).apply { style Paint.Style.STROKE strokeWidth 2f color if (isColorLight(color)) Color.BLACK else Color.WHITE } canvas.drawCircle(cx, cy, 18f, outer) canvas.drawCircle(cx, cy, 14f, inner) }isColorLight這一步很重要如果當(dāng)前點(diǎn)顏色是淺色比如黃色白色圓環(huán)幾乎看不見(jiàn)得疊加一個(gè)深色內(nèi)圈保證對(duì)比度。計(jì)算公式用的是相對(duì)亮度(0.299*R 0.587*G 0.114*B)大于180就算淺色。有不少教程只講畫(huà)個(gè)圓環(huán)但實(shí)際使用中指示器的可見(jiàn)性和準(zhǔn)確位置直接決定調(diào)色體驗(yàn)。我第一版沒(méi)加isColorLight判斷結(jié)果取到亮黃色時(shí)圓環(huán)完全隱形調(diào)試了很久才發(fā)現(xiàn)是對(duì)比度問(wèn)題。4. 手勢(shì)交互讓拖動(dòng)、微調(diào)、實(shí)時(shí)預(yù)覽都按PhotoShop的邏輯走繪制層面搞定后更大的挑戰(zhàn)是交互。PhotoShop的調(diào)色板在主面板取色和色相條選色之間切換非常順手而且支持點(diǎn)擊直接跳轉(zhuǎn)、拖動(dòng)連續(xù)變化。這些體驗(yàn)背后是觸摸事件處理和坐標(biāo)映射的精細(xì)設(shè)計(jì)。4.1 觸摸事件的命中區(qū)域劃分我的調(diào)色板是三個(gè)區(qū)域組合在一個(gè)自定義ViewGroup里頂部是二維面板底部是色相條中間留著固定間距。為了讓觸摸邏輯清晰我在onTouchEvent里先判斷觸摸點(diǎn)落在哪個(gè)區(qū)域override fun onTouchEvent(event: MotionEvent): Boolean { val x event.x val y event.y when { y panelBottom - handlePanelTouch(event, x, y) y hueBarTop - handleHueTouch(event, x, y) } return true }注意actionDown時(shí)也要更新否則只靠actionMove會(huì)導(dǎo)致用戶點(diǎn)一下沒(méi)有任何反饋。而且返回true后整個(gè)View會(huì)接管手勢(shì)流后續(xù)的move、up都會(huì)持續(xù)回調(diào)這點(diǎn)對(duì)連續(xù)拖動(dòng)很重要。4.2 從觸摸坐標(biāo)反推HSV值不管用戶點(diǎn)在哪個(gè)位置最終都需要把坐標(biāo)變成顏色值。色相條坐標(biāo)換算hue (x / hueBarWidth) * 360f然后clamp到0-360之間。面板坐標(biāo)換算橫向算飽和度縱向算明度val s (x / panelWidth).coerceIn(0f, 1f) val v (1f - y / panelHeight).coerceIn(0f, 1f)這里有個(gè)新手特別容易搞錯(cuò)的地方屏幕坐標(biāo)的y軸向下增長(zhǎng)但HSV的V值越大越亮、越靠上。因此從y坐標(biāo)映射到V值時(shí)一定要用1 - y / height否則上下顛倒了用戶往上拖反而變暗。拿到s和v之后再結(jié)合當(dāng)前色相值h調(diào)用前面實(shí)現(xiàn)的hsvToColor(h, s, v)得到最新的顏色刷新指示器位置和外部回調(diào)。4.3 放大微調(diào)撐起專業(yè)感的細(xì)節(jié)PhotoShop用戶最喜歡的操作之一是放大取色區(qū)域做精細(xì)調(diào)整。在移動(dòng)端上我用雙指縮放實(shí)現(xiàn)了這個(gè)需求當(dāng)用戶雙指在面板區(qū)域捏合時(shí)面板會(huì)以兩指中心點(diǎn)為錨點(diǎn)逐漸放大再配合單指拖動(dòng)來(lái)微調(diào)顏色。原理并不復(fù)雜維護(hù)一個(gè)scaleFactor浮點(diǎn)變量雙指間距變化時(shí)更新它繪制時(shí)把Canvas按錨點(diǎn)做縮放canvas.save() canvas.scale(scaleFactor, scaleFactor, anchorX, anchorY) // 繪制面板和指示器 canvas.restore()但觸摸坐標(biāo)的映射也要同步變化單指拖動(dòng)時(shí)實(shí)際顏色坐標(biāo) 錨點(diǎn) (觸摸坐標(biāo) - 錨點(diǎn)) / scaleFactor。這樣即使面板放大了2倍手指動(dòng)1像素仍然只會(huì)讓顏色移動(dòng)半像素的效果精細(xì)度翻倍。實(shí)測(cè)下來(lái)雙指縮放加單指取色的組合在圖片精確取色時(shí)特別好用。之前我們的設(shè)計(jì)師直接在手機(jī)上調(diào)出和PS完全一致的顏色靠的就是這個(gè)放大微調(diào)能力。4.4 取色器從Bitmap里讀取像素顏色調(diào)色板還附帶了一個(gè)屏幕取色器從相冊(cè)選一張圖片點(diǎn)擊圖片任意位置提取顏色。實(shí)現(xiàn)上核心是Bitmap.getPixel(x, y)。但這里有個(gè)大坑如果直接加載一張高分辨率圖片并顯示縮放后的Bitmap手指點(diǎn)擊的坐標(biāo)和原圖坐標(biāo)會(huì)有偏差。所以我的實(shí)現(xiàn)分兩步走先加載原圖并統(tǒng)一縮放到屏幕能顯示的尺寸保留采樣率記憶觸摸時(shí)記錄的是顯示圖上相對(duì)坐標(biāo)拿顏色時(shí)用相對(duì)坐標(biāo)乘回原圖縮放比例val srcX (touchX / displayWidth * originalWidth).toInt() val srcY (touchY / displayHeight * originalHeight).toInt() val color bitmap.getPixel(srcX, srcY)這個(gè)大圖采樣用BitmapFactory.Options的inSampleSize來(lái)處理避免直接加載原圖導(dǎo)致OOM。實(shí)際項(xiàng)目里選了一張8000x6000的照片測(cè)試顯示圖只有1500x1100左右取色依然準(zhǔn)確。5. 聯(lián)動(dòng)與細(xì)節(jié)打磨Hex輸入、RGB滑桿、最近顏色與狀態(tài)同步如果說(shuō)上面這些是骨架那聯(lián)動(dòng)和細(xì)節(jié)就是血肉。調(diào)色板真正好用的狀態(tài)是用戶拖動(dòng)色相條面板顏色跟著變指示器跟著移Hex輸入框?qū)崟r(shí)跳動(dòng)RGB滑桿同步刷新預(yù)覽色塊無(wú)延遲更新。這個(gè)多向同步的架構(gòu)是非常值得展開(kāi)的。5.1 統(tǒng)一狀態(tài)入口避免多個(gè)控件互相刷新的死循環(huán)我設(shè)計(jì)了一個(gè)ColorStateListener接口任何UI控件的變更都會(huì)以參數(shù)形式傳給一個(gè)中心調(diào)度器ColorPickerMediatorinterface ColorPickerMediator { fun onColorChanged(color: Int, source: ChangeSource) }ChangeSource是一個(gè)枚舉標(biāo)記這次變化來(lái)自哪個(gè)控件HUE_BAR、SAT_VAL_PANEL、RGB_SLIDER、HEX_INPUT、EYE_DROPPER。中心調(diào)度器拿到新顏色后先更新內(nèi)部的currentColor再通知除變更來(lái)源之外的所有控件刷新。這樣做的好處是Hex輸入框觸發(fā)顏色變化時(shí)不會(huì)再反向去更新Hex輸入框自身避免輸入-刷值-觸發(fā)監(jiān)聽(tīng)-再輸入的無(wú)限循環(huán)問(wèn)題。這個(gè)設(shè)計(jì)我是在第一版被死循環(huán)折磨了兩次之后才想明白的強(qiáng)烈建議有類似需求的朋友直接采用。5.2 RGB滑桿與Hex輸入的聯(lián)動(dòng)細(xì)節(jié)RGB滑桿比色相條簡(jiǎn)單本質(zhì)是三個(gè)0-255范圍的SeekBar。但設(shè)置滑桿進(jìn)度時(shí)有個(gè)順序問(wèn)題如果先設(shè)置R再設(shè)置G每次設(shè)置都會(huì)觸發(fā)一次onProgressChanged導(dǎo)致中間態(tài)顏色閃爍。解法是加一個(gè)isProgrammaticUpdate布爾標(biāo)志在代碼批量設(shè)置進(jìn)度時(shí)掛起監(jiān)聽(tīng)等三個(gè)滑桿都設(shè)置完再恢復(fù)fun updateSliders(color: Int) { isProgrammaticUpdate true redSlider.progress Color.red(color) greenSlider.progress Color.green(color) blueSlider.progress Color.blue(color) isProgrammaticUpdate false }Hex輸入框則要處理兩件事合法性校驗(yàn)和輸入中不全局刷新。我的做法是監(jiān)聽(tīng)TextWatcher只在文本長(zhǎng)度等于6且全是十六進(jìn)制字符時(shí)才解析并觸發(fā)onColorChanged否則只改輸入框的字體顏色提示用戶格式不對(duì)。這樣用戶在輸入F、FF這些中間狀態(tài)時(shí)不會(huì)看到整個(gè)界面顏色亂跳。5.3 最近使用顏色與透明度棋盤(pán)格PhotoShop有最近使用顏色的小色塊列表我也做了類似功能。實(shí)現(xiàn)時(shí)用一個(gè)LinkedHashSetInt保存歷史顏色新顏色加入時(shí)如果已存在就先移除再插入保證最新顏色排在最前最多保留12個(gè)。每次取色完成時(shí)把顏色寫(xiě)入這個(gè)集合并刷新色塊網(wǎng)格。透明度棋盤(pán)格是另一個(gè)容易被忽略的細(xì)節(jié)。調(diào)色板輸出的ARGB顏色可能帶透明通道如果預(yù)覽色塊直接畫(huà)一個(gè)純色用戶根本看不出透明效果。因此我在預(yù)覽色塊下方先畫(huà)一個(gè)灰白相間的小棋盤(pán)格再在上面疊加顏色private val checkerPaint Paint().apply { shader BitmapShader(checkerBitmap, REPEAT, REPEAT) } canvas.drawRect(rect, checkerPaint) canvas.drawColor(color)棋盤(pán)格的實(shí)現(xiàn)是預(yù)先創(chuàng)建一個(gè)8x8的小Bitmap左半邊灰右半邊白然后用BitmapShader的REPEAT模式無(wú)限平鋪。這個(gè)小技巧在很多圖片編輯App里都很常見(jiàn)但自己實(shí)現(xiàn)調(diào)色板時(shí)容易漏。5.4 深淺色模式適配App一般都會(huì)支持Dark Mode調(diào)色板也不能例外。我的做法是給面板和色相條的背景色做主題感知淺色模式下外邊框用淺灰深色模式下用深灰指示器的陰影色也分別適配。如果你的主界面跟隨系統(tǒng)主題這一步就一定要做否則深色模式下調(diào)色板會(huì)顯得特別突兀。除了顏色字體、滑桿的thumb和track資源也建議用?attr/colorControlNormal這類主題屬性能省下大量適配代碼。6. 踩坑記錄與性能調(diào)優(yōu)從能用到好用這個(gè)項(xiàng)目看著不大實(shí)際開(kāi)發(fā)過(guò)程中踩了很多坑。我把幾個(gè)典型的、網(wǎng)上資料少的坑記錄在這里希望對(duì)后面做類似控件的朋友有幫助。6.1 重繪范圍過(guò)大導(dǎo)致卡頓第一版實(shí)現(xiàn)里每次顏色變化我都直接調(diào)用invalidate()讓整個(gè)ViewGroup重繪。在低端機(jī)上快速拖動(dòng)色相條時(shí)幀率明顯下降面板漸變填充、指示器、預(yù)覽色塊全部重繪CPU占滿。優(yōu)化思路是把復(fù)雜的漸變層做成靜態(tài)緩存色相條漸變整個(gè)條不變只有指示器在動(dòng)。所以把漸變部分單獨(dú)渲染到一個(gè)Bitmap每次只重繪指示器區(qū)域。二維面板漸變漸變與當(dāng)前色相相關(guān)色相不變時(shí)漸變不變。因此色相變化時(shí)才重新生成面板的漸變Bitmap拖動(dòng)取色時(shí)只重繪指示器。預(yù)覽色塊和Hex文本獨(dú)立的View用invalidate只刷它們自身。這樣一改快速拖動(dòng)時(shí)絕大多數(shù)幀只需要重繪兩個(gè)指示器CPU占用直接降了60%以上。代碼結(jié)構(gòu)上我用了一個(gè)GradientCache對(duì)象來(lái)緩存面板Bitmapclass GradientCache { private var hueKey: Int -1 private var bitmap: Bitmap? null fun getPanelBitmap(hue: Int, width: Int, height: Int): Bitmap { if (bitmap null || hueKey ! hue) { bitmap renderPanelBitmap(hue, width, height) hueKey hue } return bitmap!! } }6.2LinearGradient起點(diǎn)坐標(biāo)的坑面板漸變的起點(diǎn)坐標(biāo)有人喜歡寫(xiě)(0f, 0f, width.toFloat(), height.toFloat())做對(duì)角線漸變但這不是我們要的效果。二維面板必須嚴(yán)格橫向一層、縱向一層所以起點(diǎn)終點(diǎn)必須控制在水平或垂直方向上// 橫向飽和度 LinearGradient(0f, 0f, width.toFloat(), 0f, ...) // 縱向明度 LinearGradient(0f, 0f, 0f, height.toFloat(), ...)我之前有一版把兩個(gè)漸變的終點(diǎn)都寫(xiě)成了(width, height)結(jié)果面板出現(xiàn)了詭異的斜向色帶怎么調(diào)都不是PhotoShop那種四角分明的效果。排查了很久才意識(shí)到是坐標(biāo)寫(xiě)錯(cuò)了。6.3 觸摸邊界溢出的處理當(dāng)指示器位于面板最邊緣時(shí)它的中心點(diǎn)已經(jīng)貼到邊界畫(huà)外圈圓環(huán)時(shí)會(huì)有半個(gè)圓被裁掉。如果Canvas沒(méi)裁剪圓環(huán)會(huì)超出View邊界如果裁剪了看起來(lái)就很別扭。我的處理是不改變指示器的繪制位置而是把面板邊界留出indicatorRadius的padding。也就是說(shuō)實(shí)際可觸摸區(qū)域比視覺(jué)面板大一圈繪制時(shí)把面板整體內(nèi)縮指示器在視覺(jué)邊界上移動(dòng)時(shí)依然完整可見(jiàn)。這屬于比較偏門(mén)的UI細(xì)節(jié)但做出來(lái)觀感會(huì)精致很多設(shè)計(jì)師驗(yàn)收的時(shí)候一定會(huì)注意到。6.4 像素顏色讀取時(shí)ARGB順序的坑從Bitmap取色時(shí)getPixel返回的是一個(gè)Int高8位是Alpha然后依次是R、G、B。有的老機(jī)型上尤其是Android 7以下某些廠商ROMBitmap默認(rèn)的像素格式是RGB_565而不是ARGB_8888此時(shí)getPixel得到的顏色會(huì)丟失Alpha信息且R、G、B的精度只有5-6位。我的處理是在創(chuàng)建Bitmap時(shí)顯式設(shè)置inPreferredConfig Bitmap.Config.ARGB_8888再加上異常保護(hù)val options BitmapFactory.Options().apply { inJustDecodeBounds true } BitmapFactory.decodeFile(path, options) options.inSampleSize calculateSampleSize(options, targetWidth, targetHeight) options.inJustDecodeBounds false options.inPreferredConfig Bitmap.Config.ARGB_8888 val bitmap BitmapFactory.decodeFile(path, options)如果沒(méi)有這個(gè)設(shè)置某些ROM上取到的顏色會(huì)明顯偏綠因?yàn)镽GB_565對(duì)綠色的位數(shù)分配最多印象很深。6.5 性能建議匯總對(duì)自定義View的性能優(yōu)化我最后總結(jié)出幾條規(guī)律放這里供參考盡量不在onDraw里創(chuàng)建Paint和Shader這些對(duì)象要在初始化時(shí)創(chuàng)建好繪制時(shí)只改變參數(shù)。漸變類是重量級(jí)對(duì)象能用緩存就緩存不要在每幀里new。觸摸事件里不要做耗時(shí)操作或日志輸出Log.d在快速拖動(dòng)時(shí)會(huì)刷屏并影響性能。如果實(shí)時(shí)刷新預(yù)覽涉及位圖縮放建議用Canvas.drawBitmap配合Matrix比重新加載Bitmap高效得多。低端機(jī)上如果還是卡可以把色相條漸變色的采樣數(shù)從360降到180或120視覺(jué)差異小性能提升明顯。這個(gè)項(xiàng)目最終的整體效果已經(jīng)非常接近PhotoShop的基礎(chǔ)取色體驗(yàn)了橫向色相條順滑切換二維面板四角顏色分明放大微調(diào)精細(xì)取色RGB/Hex雙向同步屏幕取色器也從實(shí)際照片中準(zhǔn)確取到了目標(biāo)色值。我把它集成到了App里設(shè)計(jì)師、產(chǎn)品、測(cè)試用下來(lái)都反饋比很多成熟App的取色器還好用。最后再分享一個(gè)通用經(jīng)驗(yàn)任何自定義View開(kāi)發(fā)的流程都是先定數(shù)據(jù)模型再定繪制方案最后做手勢(shì)交互。數(shù)據(jù)模型正確了HSV轉(zhuǎn)RGB、坐標(biāo)映射這些計(jì)算就不會(huì)出大問(wèn)題繪制方案定了性能優(yōu)化點(diǎn)在哪里心里有數(shù)手勢(shì)交互最后加調(diào)試起來(lái)反而最順暢。如果你也想做類似的專業(yè)級(jí)調(diào)色板強(qiáng)烈建議按照這個(gè)順序推進(jìn)少走很多彎路。本文還有配套的精品資源點(diǎn)擊獲取