简介:这是一份面向Android中高级开发者的UI组件学习资源,聚焦移动端专业级颜色选择器的实现原理与工程实践,解决移动应用中缺乏Photoshop式精细调色能力的痛点,适用于图像编辑、设计工具类App开发及自定义视图进阶学习。压缩包共58个文件,含21个class字节码与8个java源码(核心为ColorPanel自定义控件)、7个xml布局与资源定义、8个png图标素材,以及APK可运行演示、proguard混淆配置、Git工程元数据等,整体仅330KB,轻量易集成。已有831人学习下载,说明其在小而精的UI组件实践中具备较强参考价值。读者可直接复用ColorPanel模块,深入理解HSV色轮交互、RGB/HSV实时转换算法、滑块与触摸事件协同响应机制,并通过源码掌握Android自定义View的onDraw与onTouchEvent深度定制技巧,是提升色彩处理能力与UI工程化水平的优质范例。
1. 为什么一个“仿PhotoShop调色板”的Android组件,比写十个列表页更考验工程直觉?
你有没有试过在Android App里加一个「饱和度滑块」——用户拖动时UI要实时响应、数值要精确映射到图像处理管线、还要兼容低端机不卡顿?结果发现:滑块一动,预览图延迟半秒;换台旧手机,颜色直接偏绿;导出图片后,HSL值和滑块位置对不上……这不是玄学,是调色逻辑没闭环。
“Android 仿PhotoShop调色板”不是做个带红绿蓝滑块的ViewGroup就完事。它本质是一个轻量级图像处理控制中枢:前端要承载HSV/HSL/RGB多维参数交互(含色相环、吸管、预设滤镜入口),中层要桥接Android原生ColorMatrix、RenderScript或CameraX的YUV/RGB转换链路,后端还得确保参数变更能原子化同步到渲染线程——漏掉任一环,用户就会觉得“这调色器不跟手”“颜色不准”“点一下变三样”。
适合谁?不是刚学SeekBar的新手,而是已做过Camera预览、自定义View、至少一次Bitmap内存优化的开发者。如果你正为某图像编辑Demo卡在“调色功能像PPT翻页”,或想把离线滤镜能力封装成可复用模块,这篇就是你该停下来的实操笔记。我们不造轮子,只拆解怎么让轮子咬合得严丝合缝。
2. 从零搭起调色板骨架:View层级设计与参数模型选型
2.1 为什么放弃ConstraintLayout嵌套,而用自定义ViewGroup承载所有控件?
常见误区是用一堆SeekBar+TextView堆出RGB滑块,再加个ImageView做预览。问题立刻暴露:
- 滑块拖动时
onProgressChanged频繁触发,若每次都在主线程setImageBitmap(),低端机直接掉帧; - RGB三通道独立滑块无法表达色相环旋转这种极坐标操作;
- 用户调完亮度又去调对比度,两次
ColorMatrix.setScale()叠加顺序错乱,结果不可逆。
我的做法:用继承ViewGroup的ColorPalettePanel作为根容器,内部只持有一个ColorPreviewView(继承SurfaceView或TextureView)和一个ColorControlLayer(继承View,负责绘制色相环、滑块轨道、吸管图标等)。所有交互事件(触摸、拖拽、点击)由ColorControlLayer捕获并转换为统一参数对象,再通过接口回调通知ColorPreviewView重绘。
// ColorPalettePanel.java public class ColorPalettePanel extends ViewGroup { private ColorPreviewView previewView; private ColorControlLayer controlLayer; public ColorPalettePanel(Context context, AttributeSet attrs) { super(context, attrs); init(); } private void init() { // 预览视图必须用SurfaceView/TextureView,避免Bitmap频繁GC previewView = new ColorPreviewView(getContext()); controlLayer = new ColorControlLayer(getContext()); // 关键:预览视图占满父容器,控制层覆盖其上(z-order) addView(previewView, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); addView(controlLayer, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { // 强制两个子View尺寸完全一致 int width = r - l; int height = b - t; previewView.layout(0, 0, width, height); controlLayer.layout(0, 0, width, height); } }提示:
SurfaceView比TextureView更适合实时预览——它拥有独立Surface,渲染线程可直接写入,避免主线程lockCanvas()阻塞。但需手动管理SurfaceHolder.Callback生命周期,这点后面避坑章节会深挖。
2.2 参数模型必须用Immutable类,而非一堆float字段
很多项目用float brightness, float contrast, float saturation...散列字段管理状态,后果是:
- 多线程修改时需
synchronized锁整个View,拖动滑块瞬间卡死; - 无法做撤销/重做(undo/redo),因为没有状态快照;
- 导出配置时要手动拼JSON,字段名硬编码易错。
正确姿势:定义不可变参数类ColorAdjustment,所有调色操作都生成新实例:
// ColorAdjustment.java public final class ColorAdjustment { public final float brightness; // [-100, 100] public final float contrast; // [0.1f, 3.0f] public final float saturation; // [0.0f, 2.0f] public final float hue; // [-180, 180] 度 public final float temperature; // [-100, 100] 色温偏移 public final float tint; // [-100, 100] 绿品偏移 private ColorAdjustment(Builder builder) { this.brightness = clamp(builder.brightness, -100, 100); this.contrast = clamp(builder.contrast, 0.1f, 3.0f); this.saturation = clamp(builder.saturation, 0.0f, 2.0f); this.hue = clamp(builder.hue, -180, 180); this.temperature = clamp(builder.temperature, -100, 100); this.tint = clamp(builder.tint, -100, 100); } public static Builder newBuilder() { return new Builder(); } public static final class Builder { private float brightness = 0; private float contrast = 1.0f; private float saturation = 1.0f; private float hue = 0; private float temperature = 0; private float tint = 0; public Builder brightness(float v) { this.brightness = v; return this; } public Builder contrast(float v) { this.contrast = v; return this; } // ... 其他setter public ColorAdjustment build() { return new ColorAdjustment(this); } } private static float clamp(float value, float min, float max) { return Math.max(min, Math.min(max, value)); } }为什么有效?
ColorAdjustment无setter,线程安全;- 每次拖动滑块,
controlLayer生成新ColorAdjustment实例,通过previewView.updateAdjustment(newAdjustment)传递; previewView内部用AtomicReference<ColorAdjustment>存储当前状态,updateAdjustment()方法内仅替换引用,毫秒级完成;- 撤销栈只需存
List<ColorAdjustment>,undo()就是adjustments.get(--index)。
2.3 色相环(Hue Wheel)的触摸坐标到角度映射:别信atan2(y,x),要校准中心偏移
PhotoShop色相环是圆形,用户手指在圆内滑动时,需将(x,y)坐标转为[0,360)度角。看似Math.toDegrees(Math.atan2(y - centerY, x - centerX))就行,但实际有3个坑:
- 屏幕坐标系Y轴向下,而数学极坐标Y轴向上,
atan2参数顺序易反; - View可能因padding/margin导致
getLeft()/getTop()与实际绘制区域中心不重合; - 手指按压点不在圆心时,初始角度需归零(否则一碰就跳变)。
可靠方案:在ColorControlLayer.onTouchEvent()中,先计算触摸点相对于色相环绘制矩形的相对坐标,再用atan2并手动归一化:
// ColorControlLayer.java private RectF hueWheelBounds = new RectF(); // 色相环绘制区域 private float lastHueAngle = 0f; @Override public boolean onTouchEvent(MotionEvent event) { float x = event.getX(); float y = event.getY(); // 1. 判断是否在色相环区域内 if (!hueWheelBounds.contains(x, y)) return false; // 2. 计算相对于圆心的向量 float centerX = hueWheelBounds.centerX(); float centerY = hueWheelBounds.centerY(); float dx = x - centerX; float dy = centerY - y; // 注意:y轴翻转!屏幕y向下,数学y向上 // 3. atan2返回[-π, π],转为[0, 2π),再转为[0,360) double angleRad = Math.atan2(dy, dx); if (angleRad < 0) angleRad += 2 * Math.PI; float angleDeg = (float) Math.toDegrees(angleRad); // 4. 首次按下时记录初始角度,后续滑动以之为基准(防跳变) if (event.getAction() == MotionEvent.ACTION_DOWN) { lastHueAngle = angleDeg; // 存储初始偏移,用于ACTION_MOVE时计算delta initialHueOffset = angleDeg; } else if (event.getAction() == MotionEvent.ACTION_MOVE) { // 计算角度差,避免跨0度时突变(如从359°滑到1°应为+2°而非-358°) float delta = angleDeg - lastHueAngle; if (delta > 180) delta -= 360; if (delta < -180) delta += 360; float newHue = currentAdjustment.hue + delta; notifyAdjustmentChange(ColorAdjustment.newBuilder() .copyFrom(currentAdjustment) .hue(newHue) .build()); lastHueAngle = angleDeg; } return true; }关键细节说明:
dy = centerY - y是核心修正,不加此行色相环会镜像翻转;initialHueOffset用于首次按下时锁定基准,否则手指刚触屏就触发大角度跳变;delta校准逻辑(if (delta > 180) delta -= 360)解决跨0度边界问题,这是PhotoShop级体验的分水岭。
3. 图像实时渲染引擎:ColorMatrix vs RenderScript vs GPU Shader选型实战
3.1 为什么ColorMatrix是入门首选,但必须知道它的3个硬伤
ColorMatrix是Android SDK内置的CPU图像处理工具,用法简单:
// 在ColorPreviewView.draw()中 ColorMatrix matrix = new ColorMatrix(); matrix.setSaturation(saturation); // 0=灰度,1=原色,2=增强 matrix.postScale(1f, 1f, 1f, brightness / 100f + 1f); // 亮度 // ... 设置对比度、色相等 Paint paint = new Paint(); paint.setColorFilter(new ColorMatrixColorFilter(matrix)); canvas.drawBitmap(sourceBitmap, null, dstRect, paint);但它有3个致命缺陷,必须提前认知:
- 不支持色相旋转(Hue Shift):
ColorMatrix只能做线性变换(缩放、平移),而色相是极坐标旋转,需用Rotate矩阵配合HSV转RGB,SDK无现成API; - 精度丢失严重:
ColorMatrix内部用float[20]数组,但Android 7.0以下版本对负数处理有bug,brightness=-50可能导致绿色通道溢出; - 无法并行加速:纯CPU执行,1080p图单帧处理常超16ms(60fps阈值),低端机必掉帧。
适用场景:仅用于原型验证、小图(<480p)快速调试、或作为降级兜底方案。生产环境必须升级。
3.2 RenderScript:在Android 5.0+上跑出稳定60fps的秘诀
RenderScript是Android官方推荐的高性能计算框架,专为图像处理优化。它天然支持GPU加速(当设备支持时),且API比OpenGL ES友好得多。
第一步:创建RenderScript实例与Allocation
// ColorPreviewView.java private RenderScript rs; private ScriptC_color_adjust script; private Allocation inAllocation; private Allocation outAllocation; private void initRenderScript() { rs = RenderScript.create(getContext()); script = new ScriptC_color_adjust(rs); // 自定义脚本,见下文 // 创建输入输出Allocation(内存分配) Bitmap.Config config = sourceBitmap.getConfig() != null ? sourceBitmap.getConfig() : Bitmap.Config.ARGB_8888; Type.Builder typeBuilder = new Type.Builder(rs, Element.RGBA_8888(rs)); typeBuilder.setX(sourceBitmap.getWidth()).setY(sourceBitmap.getHeight()); inAllocation = Allocation.createTyped(rs, typeBuilder.create(), Allocation.MipmapControl.MIPMAP_NONE, Allocation.USAGE_SCRIPT); outAllocation = Allocation.createTyped(rs, typeBuilder.create()); // 将Bitmap数据拷贝到inAllocation inAllocation.copyFrom(sourceBitmap); }第二步:编写RenderScript内核(color_adjust.rs)
// color_adjust.rs #pragma version(1) #pragma rs java_package_name(com.example.colorpalette) // 全局变量,由Java端传入 float3 brightness = {0.0f, 0.0f, 0.0f}; float contrast = 1.0f; float saturation = 1.0f; float hue = 0.0f; // degree float temperature = 0.0f; float tint = 0.0f; // HSV转RGB辅助函数(标准算法) static float3 hsv2rgb(float3 c) { float3 rgb; float h = c.x, s = c.y, v = c.z; float i = floor(h * 6.0f); float f = h * 6.0f - i; float p = v * (1.0f - s); float q = v * (1.0f - f * s); float t = v * (1.0f - (1.0f - f) * s); if (i == 0.0f) rgb = (float3){v, t, p}; else if (i == 1.0f) rgb = (float3){q, v, p}; else if (i == 2.0f) rgb = (float3){p, v, t}; else if (i == 3.0f) rgb = (float3){p, q, v}; else if (i == 4.0f) rgb = (float3){t, p, v}; else rgb = (float3){v, p, q}; return rgb; } // RGB转HSV辅助函数 static float3 rgb2hsv(float3 c) { float r = c.x, g = c.y, b = c.z; float min = fmin(r, fmin(g, b)); float max = fmax(r, fmax(g, b)); float delta = max - min; float3 hsv; hsv.y = max != 0.0f ? delta / max : 0.0f; // S hsv.z = max; // V if (max == min) { hsv.x = 0.0f; // H } else if (r == max) { hsv.x = (g - b) / delta + (g < b ? 6.0f : 0.0f); } else if (g == max) { hsv.x = (b - r) / delta + 2.0f; } else { hsv.x = (r - g) / delta + 4.0f; } hsv.x /= 6.0f; // 归一化到[0,1) return hsv; } // 主处理函数:每个像素调用一次 void __attribute__((kernel)) root(const uchar4 *in, uchar4 *out) { float4 f4 = rsUnpackFloat4(*in); float3 rgb = (float3){f4.r, f4.g, f4.b}; // 1. RGB -> HSV float3 hsv = rgb2hsv(rgb); // 2. 应用色相旋转(核心!ColorMatrix做不到) hsv.x += hue / 360.0f; // 转为[0,1)区间 hsv.x = fmod(hsv.x + 1.0f, 1.0f); // 处理越界 // 3. 应用饱和度 hsv.y = saturate(hsv.y * saturation); // 4. 应用色温/色调(简化模型:在HSV空间微调) if (temperature != 0.0f) { // 温度:+值偏黄,-值偏蓝 → 调整H和S hsv.x += temperature * 0.005f; hsv.y *= (1.0f + temperature * 0.002f); } if (tint != 0.0f) { // 色调:+值偏绿,-值偏品红 → 调整H hsv.x += tint * 0.003f; } // 5. HSV -> RGB rgb = hsv2rgb(hsv); // 6. 应用亮度、对比度(在RGB空间) rgb += brightness; rgb = (rgb - 0.5f) * contrast + 0.5f; // 7. 输出 *out = rsPackColorTo8888(rgb.r, rgb.g, rgb.b, f4.a); }第三步:Java端调用脚本并同步结果
// ColorPreviewView.java public void updateAdjustment(ColorAdjustment adj) { // 更新脚本全局变量 script.set_brightness(new Float3(adj.brightness/100f, adj.brightness/100f, adj.brightness/100f)); script.set_contrast(adj.contrast); script.set_saturation(adj.saturation); script.set_hue(adj.hue); script.set_temperature(adj.temperature); script.set_tint(adj.tint); // 执行脚本:inAllocation -> outAllocation script.forEach_root(inAllocation, outAllocation); // 同步结果回Bitmap(注意:必须在主线程调用) Bitmap result = Bitmap.createBitmap(sourceBitmap.getWidth(), sourceBitmap.getHeight(), sourceBitmap.getConfig()); outAllocation.copyTo(result); // 更新预览 previewBitmap = result; invalidate(); // 触发onDraw() }为什么RenderScript能稳60fps?
forEach_root()自动并行化,GPU核心数决定线程数;Allocation内存由系统统一管理,避免Java层Bitmap频繁GC;rsUnpackFloat4/rsPackColorTo8888是硬件优化指令,比手动位运算快3倍以上。
血泪经验:RenderScript在Android 5.0+稳定,但必须在onDetachedFromWindow()中调用rs.destroy(),否则内存泄漏。我曾在线上版本漏掉这句,导致用户连续编辑10张图后OOM。
3.3 GPU Shader进阶:当RenderScript也扛不住时,用OpenGL ES 3.0写Fragment Shader
当你的App需支持4K视频实时调色,或要接入第三方美颜SDK(如某主流美颜库要求GL纹理输入),RenderScript的Allocation模型就不够用了。此时必须切到OpenGL ES 3.0,用Fragment Shader直接操作纹理。
核心流程:
- 创建
GLSurfaceView,实现GLSurfaceView.Renderer; - 加载顶点/片元Shader,编译链接成
Program; - 将原始Bitmap转为
GL_TEXTURE_2D,绑定到Sampler2D; - 在
onDrawFrame()中,将ColorAdjustment参数传入Shader Uniform; - 绘制全屏四边形(Quad),Shader对每个像素采样并计算。
片元Shader关键代码(color_adjust.frag):
#version 300 es precision highp float; in vec2 v_TexCoord; out vec4 fragColor; uniform sampler2D s_Texture; uniform vec3 u_Brightness; // r,g,b分量 uniform float u_Contrast; uniform float u_Saturation; uniform float u_Hue; // degree uniform float u_Temperature; uniform float u_Tint; // HSV转换函数(同RenderScript版,此处省略) void main() { vec4 color = texture(s_Texture, v_TexCoord); vec3 rgb = color.rgb; // RGB -> HSV vec3 hsv = rgb2hsv(rgb); // 色相旋转 hsv.x += u_Hue / 360.0; hsv.x = mod(hsv.x + 1.0, 1.0); // 饱和度 hsv.y = clamp(hsv.y * u_Saturation, 0.0, 1.0); // 色温/色调(同RenderScript逻辑) if (u_Temperature != 0.0) { hsv.x += u_Temperature * 0.005; hsv.y *= (1.0 + u_Temperature * 0.002); } if (u_Tint != 0.0) { hsv.x += u_Tint * 0.003; } // HSV -> RGB rgb = hsv2rgb(hsv); // 亮度、对比度 rgb += u_Brightness; rgb = (rgb - 0.5) * u_Contrast + 0.5; fragColor = vec4(rgb, color.a); }Java端传参示例:
// OpenGLRenderer.java private int mBrightnessLoc; private int mContrastLoc; private int mSaturationLoc; private int mHueLoc; private int mTemperatureLoc; private int mTintLoc; @Override public void onSurfaceCreated(GL10 gl, EGLConfig config) { // 编译Shader,获取Uniform位置 mProgram = createProgram(vertexShader, fragmentShader); mBrightnessLoc = GLES30.glGetUniformLocation(mProgram, "u_Brightness"); mContrastLoc = GLES30.glGetUniformLocation(mProgram, "u_Contrast"); // ... 其他Uniform } @Override public void onDrawFrame(GL10 gl) { GLES30.glUseProgram(mProgram); // 传入参数(注意:u_Brightness是vec3,需传3个float) GLES30.glUniform3f(mBrightnessLoc, adj.brightness/100f, adj.brightness/100f, adj.brightness/100f); GLES30.glUniform1f(mContrastLoc, adj.contrast); GLES30.glUniform1f(mSaturationLoc, adj.saturation); GLES30.glUniform1f(mHueLoc, adj.hue); GLES30.glUniform1f(mTemperatureLoc, adj.temperature); GLES30.glUniform1f(mTintLoc, adj.tint); // 绑定纹理、绘制 GLES30.glActiveTexture(GLES30.GL_TEXTURE0); GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, mTextureId); GLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, 4); }何时必须上GPU Shader?
- 输入源是
SurfaceTexture(如CameraX预览流); - 需叠加多层滤镜(如先锐化+再调色+最后加光晕),RenderScript难以链式调用;
- 目标设备明确支持OpenGL ES 3.0+(市占率>95%,可放心用)。
警告:OpenGL ES开发调试成本高。glGetError()必须每帧检查,否则黑屏无声。我建议:先用RenderScript跑通全部逻辑,再用GPU Shader替换性能瓶颈模块,而非一上来就啃OpenGL。
4. 避坑指南:那些让调色器“看起来像PhotoShop”却“用起来像PPT”的5个致命细节
4.1 现象:滑块拖动时预览图闪烁,尤其在低端机上;原因:Bitmap复用不当导致GC抖动;解决:用BitmapPool预分配+inMutable=true
现象还原:用户拖动饱和度滑块,预览图每0.5秒闪一下黑边,Logcat刷GC freed 12MB。
根本原因:每次updateAdjustment()都调用Bitmap.createBitmap()新建Bitmap,而Android 7.0以下Bitmap内存不在Java堆,但创建/销毁仍触发底层内存管理,频繁GC导致SurfaceView缓冲区断连。
解决方案:预分配Bitmap池,复用同一块内存:
// ColorPreviewView.java private LruCache<String, Bitmap> bitmapPool; private Bitmap previewBitmap; // 指向池中某Bitmap private void initBitmapPool() { final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); final int cacheSize = maxMemory / 8; // 取1/8作缓存 bitmapPool = new LruCache<String, Bitmap>(cacheSize) { @Override protected int sizeOf(String key, Bitmap bitmap) { return bitmap.getByteCount() / 1024; } }; } private Bitmap getOrCreateBitmap(int width, int height, Bitmap.Config config) { String key = width + "x" + height + "_" + config.name(); Bitmap bitmap = bitmapPool.get(key); if (bitmap == null) { bitmap = Bitmap.createBitmap(width, height, config); bitmapPool.put(key, bitmap); } return bitmap; } // 在updateAdjustment()中: Bitmap result = getOrCreateBitmap(sourceBitmap.getWidth(), sourceBitmap.getHeight(), sourceBitmap.getConfig()); outAllocation.copyTo(result); previewBitmap = result; // 复用,不新建注意:
Bitmap.Config.ARGB_8888必须显式指定,否则某些机型默认ARGB_4444(已废弃),导致颜色失真。
4.2 现象:色相环拖动时角度跳变,从10°突然跳到350°;原因:未处理跨0度边界;解决:用模运算归一化delta
现象还原:手指从色相环右侧(0°)顺时针滑到左侧(180°),再继续滑到顶部(270°),当经过0°线时,角度从359°跳回0°,delta = 0 - 359 = -359,导致色相疯狂反转。
错误代码(常见于StackOverflow答案):
float delta = newAngle - oldAngle; // 未校准!正确解法(已在2.3节给出,此处强调):
float delta = newAngle - oldAngle; if (delta > 180) delta -= 360; // 359→1 变成 -2 if (delta < -180) delta += 360; // 1→359 变成 +2原理:色相是环形空间,最短路径距离才是真实拖动量。Math.abs(delta)最小化即为正确方向。
4.3 现象:导出图片后,用Photoshop打开发现HSL值与滑块位置不符;原因:sRGB与Adobe RGB色彩空间混淆;解决:强制使用sRGB并禁用ICC配置文件
现象还原:App里把饱和度调到150%,导出PNG用Photoshop的“信息”面板读取HSL,显示饱和度仅120%。
真相:Android默认Bitmap是sRGB色彩空间,但部分设备厂商(尤其某国产旗舰)在BitmapFactory.decodeResource()时注入了设备专属ICC配置文件,导致颜色值被二次映射。
铁律:所有图像处理环节必须锁定sRGB:
- 创建Bitmap时指定
Bitmap.Config.ARGB_8888(隐含sRGB); ColorMatrix/RenderScript/Shader中所有计算基于sRGB伽马曲线(无需额外gamma校正);- 导出时禁用ICC:
// 导出PNG Bitmap result = ...; FileOutputStream fos = new FileOutputStream(file); result.compress(Bitmap.CompressFormat.PNG, 100, fos); fos.close(); // 关键:不要用BitmapFactory.Options.inPreferredConfig,它可能被系统篡改 // 不要调用bitmap.setHasAlpha(true),这会触发ICC重写提示:用
adb shell dumpsys SurfaceFlinger可查看当前Surface色彩空间,确认是否为sRGB。
4.4 现象:开启硬件加速后,ColorPreviewView预览图全黑;原因:SurfaceView与硬件加速冲突;解决:为SurfaceView关闭硬件加速
现象还原:AppApplication或Activity开启了android:hardwareAccelerated="true",ColorPreviewView(SurfaceView)一片漆黑,但Logcat无报错。
原因:SurfaceView的Surface是独立于View树的,硬件加速的OpenGL上下文无法直接绘制到其Surface,必须用SurfaceHolder的lockCanvas()(软件绘制)或eglMakeCurrent()(手动EGL绑定)。
解决:在ColorPreviewView构造函数中强制关闭硬件加速:
public ColorPreviewView(Context context, AttributeSet attrs) { super(context, attrs); // 关键:SurfaceView必须关硬件加速 setLayerType(View.LAYER_TYPE_NONE, null); }补充:若用TextureView替代SurfaceView,则需开启硬件加速,但TextureView的getBitmap()是主线程阻塞调用,性能不如SurfaceView。权衡取舍。
4.5 现象:多指操作时,第二个手指触发色相环误判;原因:未过滤非主指针事件;解决:用MotionEvent.getPointerId()锁定主指针
现象还原:用户左手按住亮度滑块,右手去拖色相环,色相环突然跳到随机角度。
原因:onTouchEvent()收到的是多指事件,event.getX()/getY()默认返回第一个指针(Index 0)坐标,但当左手先按下滑块,右手再触屏时,右手成为Index 0,坐标被误认为色相环操作。
正确做法:记录首个按下的指针ID,在ACTION_MOVE中只处理该ID:
private int primaryPointerId = -1; @Override public boolean onTouchEvent(MotionEvent event) { int action = event.getActionMasked(); int pointerIndex = event.getActionIndex(); if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) { if (primaryPointerId == -1) { primaryPointerId = event.getPointerId(pointerIndex); } } else if (action == MotionEvent.ACTION_MOVE) { // 只处理主指针 int targetIndex = event.findPointerIndex(primaryPointerId); if (targetIndex == -1) return false; float x = event.getX(targetIndex); float y = event.getY(targetIndex); // ... 处理x,y } else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) { if (event.getPointerId(pointerIndex) == primaryPointerId) { primaryPointerId = -1; } } return true; }5. 调色参数持久化与跨设备一致性:如何让一张图在华为Mate60和小米Redmi Note上颜色完全一致
5.1 为什么SharedPreferences存float不靠谱?用Protocol Buffers序列化ColorAdjustment
很多项目把brightness=20.5f直接存SharedPreferences,上线后发现:
- Android 12设备读取的
20.5f在Android 8.0上变成20.499998f,多次保存加载后漂移; float二进制表示在不同CPU架构(ARMv7 vs ARM64)有微小差异;- 无法扩展新参数(如新增
vignetteIntensity),旧版本App读取新配置会崩溃。
终极方案:用Protocol Buffers定义.proto文件,生成Java类,序列化为字节数组存储:
// color_adjustment.proto syntax = "proto3"; message ColorAdjustment { float brightness = 1; // [-100, 100] float contrast = 2; // [0.1, 3.0] float saturation = 3; // [0.0, 2.0] float hue = 4; // [-180, 180] float temperature = 5; // [-100, 100] float tint = 6; // [-100, 100] int64 timestamp = 7; // 保存时间,用于版本追踪 }生成Java类后,存取代码:
// 保存 ColorAdjustment proto = ColorAdjustment.newBuilder() .setBrightness(adj.brightness) .setContrast(adj.contrast) .setSaturation(adj.saturation) .setHue(adj.hue) .setTemperature(adj.temperature) .setTint(adj.tint) .setTimestamp(System.currentTimeMillis()) .build(); byte[] data = proto.toByteArray(); // 存入SharedPreferences(key为"color_config_v1") prefs.edit().putString("color_config_v1", Base64.encodeToString(data, Base64.NO_WRAP)).apply(); // 读取 String encoded = prefs.getString("color_config_v1", ""); if (!encoded.isEmpty()) { byte[] decoded = Base64.decode(encoded, Base64.NO_WRAP); ColorAdjustment loaded = ColorAdjustment.parseFrom(decoded); // 构建ColorAdjustment对象 ColorAdjustment adj = ColorAdjustment.newBuilder() .brightness(loaded.getBrightness()) .contrast(loaded.getContrast()) <p> <a href="https://download.csdn.net/download/t12x3456/6246509" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>