简介面向Android开发者的高级颜色选择组件资源仿照Photoshop调色板实现色轮取色、RGB/HSV切换、滑块调节、透明度控制与颜色历史记录等能力适合需要在App中集成专业取色器或学习自定义View与颜色算法的开发人员。压缩包共58个文件以Java源码和class编译文件为主辅有XML界面布局、PNG切图资源、可直接安装的APK以及JAR依赖库既能查看工程结构也能运行体验整体仅330KB。已有826人学习下载沉淀了较广泛的使用反馈。资源内含ColorPanel自定义组件及完整工程阅读源码可掌握多色彩模式转换、触摸事件实时更新与UI渲染性能优化等关键实现同时体会桌面级专业工具在移动平台上的复刻思路适合作为进阶Android开发或作品集项目的实用案例。 我们直接在 Android 里把一个很“PS”的东西做出来仿 PhotoShop 的调色板。这里说的调色板不是挑选前景色那个色盘而是指图像后期里最核心的那套色彩调整能力——色相、饱和度、明度、对比度、色温、高光阴影这些滑块拖动时图片实时跟着变。很多 App 里“滤镜”之外的自定义调节面板本质就是这东西。这篇文章没有任何“从零开始搭建一个 PS”的夸张想法而是把它们抽象成一个可复用的 Android 图像处理组件。我会从颜色模型选型讲到逐像素处理的性能优化从自绘色相环讲到撤销重做的数据设计代码以 Kotlin 为主核心思路同样适用于 Java 工程。如果你正准备做图片编辑类功能或者想在自定义 View 和图像处理上扎扎实实进阶一把这篇应该能帮你把路子理顺。1. 整体设计PS 的调色面板落到 Android 上要怎么拆1.1 核心需求拆解先说清楚“仿 PhotoShop 调色板”到底仿哪些东西。PS 的“调整”面板里有一堆工具比如色阶、曲线、可选颜色、色彩平衡、照片滤镜等要把它们全部搬上手机既不现实也没必要。真正日常使用频率最高、而且是后期基础的是这几项色相Hue整体转动色轮比如把偏红的图调成偏黄饱和度Saturation让颜色更鲜艳或更灰明度Lightness / Value整体变亮或变暗对比度Contrast拉开明暗层次色温Temperature偏冷蓝或偏暖黄高光 / 阴影Highlight / Shadow分别调整亮部和暗部细节。我的做法是把它们统一成一个AdjustParams数据类每个字段对应一个调节参数。UI 层只负责收集参数图像处理层只消费参数互不干扰。这样后续想增加“颗粒感”“暗角”这类效果只需在数据类里加字段、在渲染管线里加处理函数不动整体架构。1.2 技术选型为什么不用 ColorMatrix 一把梭Android 里自带ColorMatrix和ColorMatrixColorFilter可以支持饱和度、对比度、色彩旋转等操作代码量极少。但真做起来会发现两个硬伤一是色温、高光阴影这类非线性调节很难用 4x5 颜色矩阵优雅表达二是 PS 的调色是累积式的多个矩阵相乘后要确保每步的中间结果可预测ColorMatrix 底层是浮点运算顺序调换后效果会有细微差异对“仿 PS”这种追求可控性的场景不够友好。所以我最终选择逐像素处理把 Bitmap 的像素数组捞出来按 HSV、RGB、YUV 等模型计算每个像素的新颜色值再写回 Bitmap。这条路代码量更大但可控性极强而且能顺带把图像处理的基本功练扎实。为了避免性能问题我在预览阶段对图片做降采样处理拖动滑块时用低分辨率图实时预览松手后再对原图做全量渲染。具体优化细节放到第 3 节讲。2. 颜色模型与调色原理为什么改 RGB 不如转 HSV2.1 RGB 直调的限制RGB 模型是从“显示硬件”角度描述颜色的R、G、B 三个通道互相耦合。对一般人来说“让这张图更红一点”可以直接把 R 通道乘一个大于 1 的系数这没错但“让色相偏转 30 度”这种操作在 RGB 空间里就需要同时调整 R、G、B 三个通道的权重而且不同颜色区域偏移方向还不一样非常容易调出脏色。HSV色相 Hue、饱和度 Saturation、明度 Value模型把颜色的“色彩倾向”和“明亮程度”解耦了。色相是一个 0~360 度的环饱和度是离中心轴的远近明度是轴向高度。在这种模型下“整体色相偏转 30 度”就是所有像素的 H 值统一加 30再包一层越界回绕既不脏也不乱。2.2 RGB 与 HSV 互转的落地公式RGB 转 HSV 的公式是图像处理的入门必背之一这里我给一份可直接用的 Kotlin 实现输入是 0~255 的 RGB 三通道输出是 H0~360、S0~1、V0~1。fun rgbToHsv(r: Int, g: Int, b: Int): FloatArray { val rn r / 255f val gn g / 255f val bn b / 255f val max maxOf(rn, gn, bn) val min minOf(rn, gn, bn) val delta max - min var h 0f when { delta 0f - h 0f max rn - h 60f * (((gn - bn) / delta) % 6f) max gn - h 60f * (((bn - rn) / delta) 2f) max bn - h 60f * (((rn - gn) / delta) 4f) } if (h 0) h 360f val s if (max 0f) 0f else delta / max return floatArrayOf(h, s, max) } fun hsvToRgb(h: Float, s: Float, v: Float): IntArray { val c v * s val x c * (1f - abs(((h / 60f) % 2f) - 1f)) val m v - c val (r1, g1, b1) when { h 60f - Triple(c, x, 0f) h 120f - Triple(x, c, 0f) h 180f - Triple(0f, c, x) h 240f - Triple(0f, x, c) h 300f - Triple(x, 0f, c) else - Triple(c, 0f, x) } return intArrayOf( ((r1 m) * 255).toInt().coerceIn(0, 255), ((g1 m) * 255).toInt().coerceIn(0, 255), ((b1 m) * 255).toInt().coerceIn(0, 255) ) }这段代码的边界条件我踩过不少坑比如 H 值等于 360 时正好落在色环的红色起点H 为负时要回绕加 360S 为 0 时是灰度图此时 H 任意值都不影响结果这些在写调色逻辑时都要考虑到。2.3 色相旋转、饱和度与明度的具体实现拿到 HSV 之后调色逻辑就非常直接了。色相旋转就是 H 加一个偏移量饱和度调节是 S 乘以系数明度调节是 V 乘以系数然后再加一个偏移控制“曝光补偿”式的明暗变化。fun adjustPixelHsv( hsv: FloatArray, hueOffset: Float, // -180f ~ 180f saturationFactor: Float, // 0f ~ 2f valueFactor: Float, // 0f ~ 2f valueOffset: Float // -100f ~ 100f ): FloatArray { hsv[0] (hsv[0] hueOffset 360f) % 360f hsv[1] (hsv[1] * saturationFactor).coerceIn(0f, 1f) hsv[2] (hsv[2] * valueFactor valueOffset / 255f).coerceIn(0f, 1f) return hsv }这套逻辑写起来简单但调色效果和 PS 的“色相/饱和度”面板已经很接近了。PS 里的“色相偏移”本质上就是色环上的旋转角“饱和度”就是放大或缩小 S 通道“明度”对应 V 通道的变化。如果你还想加“着色”功能把图片统一变成某一种色相的色调那直接强制把 H 设为固定值、S 拉到指定档位即可这也是滤镜系统的常见实现思路。3. 像素级图像处理Bitmap 读写、LUT 与异步优化3.1 高效读取和写回像素Android 里对 Bitmap 做逐像素操作最常见也最坑的做法是循环调bitmap.getPixel(x, y)。这个方法每次调用都有 JNI 开销和边界检查一张 1080x1920 的图200 多万次调用下来卡顿是必然的。正确姿势是一次性把像素数组取出来处理完再写回。val width bitmap.width val height bitmap.height val pixels IntArray(width * height) bitmap.getPixels(pixels, 0, width, 0, 0, width, height) for (i in pixels.indices) { val color pixels[i] val r (color shr 16) and 0xFF val g (color shr 8) and 0xFF val b color and 0xFF // 进行调色处理 pixels[i] (0xFF shl 24) or (newR shl 16) or (newG shl 8) or newB } bitmap.setPixels(pixels, 0, width, 0, 0, width, height)还有一点容易忽略确保 Bitmap 的配置是ARGB_8888。如果用RGB_565颜色深度直接砍掉一截调色后容易出现色带断层尤其在天空、皮肤这类渐变色区域效果惨不忍睹。加载图片时用BitmapFactory.Options里的inPreferredConfig显式指定一下省得后面返工。3.2 预计算 LUT把重复计算干掉逐像素处理的性能瓶颈不只是遍历本身更是每个像素内部的那一串三角函数、浮点乘法和 RGB/HSV 转换。一段标准的 HSV 调色流程单个像素要执行“RGB→HSV→调整→HSV→RGB”两步转换其中 RGB→HSV 里又有大量max/min/除法CPU 开销非常大。我的优化方案是做查找表LUT。对于 8bit 颜色R、G、B 各有 256 个取值理论上可以预计算一张 256³ 的映射表但 256³ 1677 万条记录每条存一个 Int差不多 64MB 内存移动端直接爆。实用的折中是拆通道缓存对于“亮度/对比度/色温”这类对 R、G、B 独立操作的处理分别缓存三张 256 长度的映射表查表替换浮点计算对于“饱和度/色相”这类需要跨通道操作的至少把rgbToHsv的结果按R, G, B组合缓存成哈希表图片像素颜色种类远小于像素总数实际命中率很高。实际操作中我更喜欢朴素的“预览图降采样”方案先把 Bitmap 按比例缩到宽度不超过 540 像素在这个小图上做实时预览拖到满意后把调色参数应用到原图。这样单帧计算量降到原来的 1/10 左右手机端拖动滑块能稳定在 30fps 以上体验比花哨的 LUT 方案更直接有效。3.3 异步处理与任务取消调色是典型的 CPU 密集型任务绝对不能跑在主线程。我用 Kotlin 协程来管理异步任务核心点在“拖动滑块时旧任务要能立刻取消”不然滑块拖了两下前面几次处理还在排队最后结果会闪跳。private var processJob: Job? null fun applyAdjust(params: AdjustParams, source: Bitmap) { processJob?.cancel() processJob viewModelScope.launch(Dispatchers.Default) { val result processBitmap(source, params) withContext(Dispatchers.Main) { imageView.setImageBitmap(result) } } }比较容易被忽视的是协程取消的协作性。如果你的处理函数里没有检查isActivecancel()只是把协程标记成取消状态循环照样跑完白取消。所以像素遍历循环里每处理完一定的行数就要检查一次取消状态或者干脆每行判断一次付出可以忽略的性能成本换来“指哪打哪”的响应手感。4. 交互与 UI色相环、滑块组、撤销重做4.1 参数面板布局与滑块设计调色板的面板我用了纵向滑杆列表每一项包含一个名称标签、一个当前值显示、一个 SeekBar。SeekBar 的范围直接映射成业务参数的真实范围比如色相的是 -180 到 180饱和度是 50% 到 200%这样 UI 层和图像处理层之间不需要做多余的数值换算代码更干净。一个小细节是 SeekBar 的“中点吸附”处理。像色温、对比度这类以 0 为中间值的参数用户拖到中间时应该默认回 0效果和照片原始状态一致。我给 SeekBar 加了setOnSeekBarChangeListener在onProgressChanged里判断 progress 与中间值的距离小于某个阈值时显示“0”但 CPU 处理仍然用真实的 progress避免松手后数值跳动带来的视觉闪烁。4.2 自绘色相环Canvas 与 Touch 事件的配合PS 里调色相有个很直观的操作——拖动色相环。Android 原生控件里没有这个东西需要自绘。色相环本身不难画用一个 SweepGradient 渐变填充一个圆渐变的颜色从 0 度到 360 度依次是红、黄、绿、青、蓝、品红、红这样自然形成一个色相环。难点在触摸交互。要让用户能点住环上的“指示器”拖拽旋转需要把触摸坐标映射成角度值再把角度变化量映射成色相偏移量。override fun onTouchEvent(event: MotionEvent): Boolean { val dx event.x - centerX val dy event.y - centerY val touchRadius sqrt(dx * dx dy * dy) if (touchRadius outerRadius touchRadius innerRadius) { var angle Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat() if (angle 0) angle 360f onHueChanged(angle / 360f * 360f) invalidate() return true } return super.onTouchEvent(event) }注意atan2返回的是 -180 到 180 度要转成 0 到 360 度的色相值。还有一个细节是判断触摸点是否落在色相环的有效区域内防止用户点在圆心里也触发旋转。我把圆心的半径保护设为色相环内圆半径非环区域的触摸直接忽略避免误触。4.3 撤销 / 重做的数据栈设计调色面板如果没有撤销调错了只能手动拉回滑块体验比较糟。实现撤销重做我用了一个很传统的命令模式把每次调节的参数快照AdjustParams压入撤销栈调节过程中实时预览不记录只在松手时记录一份避免一堆中间状态把栈塞满。private val undoStack ArrayDequeAdjustParams() private val redoStack ArrayDequeAdjustParams() fun pushHistory(params: AdjustParams) { undoStack.push(params) redoStack.clear() } fun undo(): AdjustParams? { val current undoStack.pop() ?: return null redoStack.push(current) return undoStack.peek() }这里有个经验之谈存入栈的一定要是不可变对象。如果在后续调节里复用同一个 AdjustParams 实例并修改字段之前压入栈的数据也会被改掉撤销就全乱了。Kotlin 的data class配合val字段天然满足这一点算是比 Java 省心的地方。5. 常见问题与排查技巧实录这一节我把自己开发过程中踩过比较典型的坑整理出来按“症状-原因-解法”的方式列成速查表遇到问题时可以对着查。症状原因解法调色后图片整体发灰、颜色断层Bitmap 配置是 RGB_565加载时指定 ARGB_8888再用hasAlpha判断是否需要保留 Alpha 通道调明度时图片直接全黑明度调节是把 V 乘以系数系数为 0 时 V 全变 0加入最小阈值保护或改用“V 加偏移”而非纯乘法拖动滑块明显卡顿、掉帧像素处理跑在主线程 或 没做降采样预览处理放到协程/线程池预览图先缩到小尺寸拖动太快时图片回跳乱闪异步任务没有取消旧任务结果覆盖新任务每次新任务前 cancel 旧 Job循环里检查 isActive色相环指针角度不对atan2返回负值未做归一化统一转成 0~360 度且注意坐标系 Y 轴方向撤销后图片比预期多跳好几步预览过程把中间状态也压栈了只在用户松手、一次调节结束时记录历史5.1 排查技巧把处理链路打出来有一个排查调色问题非常有效的办法写一个小的调试入口把中间过程的 Bitmap 保存到本地文件分别看“HSV 转换后”“调节后”“RGB 写回后”三张图的效果。当时我做色温调节时发现高光区域偏紫就是这个方法定位到问题出在 RGB 的绿色通道增益系数算错了而不是整体转换逻辑有 bug。5.2 性能调优的实测数据用一张 1080x1920 的图做测试不做任何优化直接在主线程全像素遍历并做 RGB/HSV 转换单帧耗时约 380ms基本没法用降采样到 540 宽后单帧耗时降到 40ms 左右加上多行分段检查和协程调度实际拖动过程中稳定在 25~30fps。这个数据说明在移动端做“仿 PS”的实时调色降采样预览才是真正的关键优化比任何花哨的算法技巧都更要紧。6. 结尾一点不吐不快的经验调色板这个项目做完我最大的感受是图像处理领域的知识密度确实高但真正卡住进度的往往不是算法本身而是工程细节。比如 Bitmap 的像素格式、协程取消的协作性、触摸事件的坐标映射每一个单独拿出来都不难但串在一起就会互相牵扯出问题时排查起来特别费劲。如果后续想继续扩展我建议可以往两个方向走一是把调色参数序列化成 JSON做成预设滤镜的导入导出功能这样用户调好的风格可以分享出去二是尝试接入 CameraX 的实时预览流把这一套逐像素处理逻辑用到视频数据上体验会比静态图更惊艳。当然这就涉及 YUV 到 RGB 的转换与 OpenGL 渲染了是另一个大坑有兴趣的话我们下次慢慢聊。本文还有配套的精品资源点击获取
