做米家插件开发的时候接到一个挺有意思的需求自定义场景面板里要加一个灯光颜色选择器用户得能在一块圆形的色盘上直接滑动取色。当时脑子里第一反应是“这东西不是有现成的库吗”结果在 React Native 生态里翻了半天还真没有能直接塞进米家插件工程、又满足定制外观的取色组件。最后只能自己动手基于图片和手势搓了一个自定义色盘取色组件。这篇文章就把整个设计和踩坑过程记录下来包括 HSV 色彩模型的底层逻辑、图片像素读取的方案选型、PanResponder 手势处理的细节、以及米家 App 宿主环境下的特殊问题给后面要做类似功能的同学一个完整的参考。先说一个结论在米家插件这种相对封闭的 RN 环境里自定义高精度取色组件选型优先级应该是“图片 手势”而不是纯代码绘制色盘也不是在 JS 层用 Canvas 之类的方案。原因后面慢慢展开但核心在于设计师给的色盘源图本身就是一张带光影细节的 PNG如果用数学公式去算颜色最终呈现会和设计稿产生肉眼可见的偏差只有直接读取图片像素才能做到“手指点到哪颜色就是图片上那个点的颜色”。1. 为什么米家插件需要自研色盘取色组件需求复盘与选型1.1 需求复盘一个自定义场景面板里的“灯光颜色”交互这个需求的实际场景是用户配置“回家模式”时需要从色盘上选一个灯光颜色比如暖黄色、日落橙或者冷白色。色调范围要从 2700K 到 6500K 甚至更宽的可调 RGB 色域。产品给的设计稿里色盘是一张圆形图片外圈是纯色相环往圆心方向颜色逐渐变浅变白同时还有一个独立的亮度滑轨用来控制最终亮度。如果把色盘拆解成技术要素其实就三件事展示一张色盘图片并允许用户在图片上通过手势选择一个点。根据手势位置从图片上取出对应像素的颜色值。把取到的颜色转成设备能识别的格式RGB、HSV 或者色温值实时反馈给设备或 UI。听起来很简单但真正动手做的时候每一步都有坑。1.2 原生 ColorPicker 轮子为什么不能直接搬我第一反应是去 npm 上看有没有现成的 React Native 取色轮。当时找到的库大概有这么几个库名优点致命问题react-native-color-picker自带色相环、手势处理外观是固定的不能替换成设计师给的源图体积也不小react-native-wheel-color-picker交互完整样式同样绑死HSL 模型和设计稿对不上react-native-svg 自绘灵活度最高性能需要自己做优化而且 SVG 绘制出来的渐变和位图有差异这几个库的问题不是功能不行而是“不能定制外观”。在米家插件这种 to C 的 App 场景里UI 必须和整体设计语言统一不可能在一个自定义场景面板里放一个和米家设计风格完全不同的第三方色盘。这时候自研就是唯一的选择。另外还有一个隐蔽的问题这些库大多维护状态的方式是维护一个 HSV 色值再通过 setState 触发重渲染来画 UI。但在插件场景里如果有频繁的手势滑动会出现比较明显的掉帧原因是 JS 线程在同时处理手势事件、状态更新、UI 重渲染负载太高。这也是我后来选择在原生层做位图驻留的原因之一。1.3 为什么最终选择“图片 手势”而不是纯数学渲染这里要展开说一下。实现一个色盘纯数学渲染其实非常简单拿到圆心的坐标和半径用 atan2 计算角度得到色相 H用距离除以半径得到饱和度 S再用 HSL 转 RGB 公式算出来。这样的成本极低也不依赖任何图片资源理论上完全可行。但实际项目里有两个问题会推翻这个方案。第一设计稿的色盘不是标准的 HSV 色环。设计师出的图里色环的外圈可能不是纯红黄绿青蓝紫而是经过品牌调色的“高级感”颜色饱和度向圆心过渡的曲线也不是线性的而是有精心调整过的渐变层次还叠加了一层微妙的径向光照质感。这种情况下公式算出来的颜色和图片对应位置的颜色有色差。第二纯公式方案无法处理“图片里既有色相又有纹理”的情况。如果未来产品经理想换一张暖色系圈、冷色系圈的差异化色盘或者加一些图案点缀公式方案就完全失效了需要重新开发。而基于图片取色只需要设计师换一张 PNG 资源组件代码一行都不用改。所以最后确定的技术路线是色盘本质上是一张静态图片手势负责定位像素读取负责取色。这既满足了设计还原度也为后续换肤留下了空间。2. HSV 色彩模型与色盘图片的标定逻辑决定取色的最终精度2.1 标准色相环的参数约定先建立一个共同语言色盘上用到的色彩空间是 HSV不是 RGB。原因很简单HSV 的三个分量色相 H、饱和度 S、明度 V天然对应了人眼对颜色的感知维度设计师在标注“高饱和橙色”这句话时约等于“H30°、S80%”。而 RGB 值无法直观地表达这种语义。标准色相环的参数约定如下色相 H0° 到 360°对应彩虹色环红色在 0°经过黄、绿、青、蓝、品红回到红色。饱和度 S0% 到 100%外圈最饱和圆心趋向白色对于“白底彩心”式色盘。明度 V这个组件里不放在色盘上单独用滑轨控制因为明度和色相叠在一起会让手势定位变得混乱。如果色盘图片是按照标准 HSV 色相环生成的那么理论上可以直接通过角度和半径算出 HSV 值不需要读像素。但正如前面所说实际项目并不会用标准色环所以我们依然以图片像素为准。2.2 设计师给的源图和 HSV 理想位置之间的对齐这一步是很多人会忽略的坑设计师导出的色盘图片不一定是严格居中的甚至不一定是一个正圆。如果图片中色盘区域的圆心在 (px, py)半径是 pr而我们按图片尺寸的中心去计算那取到的颜色就会整体偏移。解决方法是做一个“标定步骤”在组件初始化的时候允许开发者在调试模式下指定圆心和半径的标定值。因为二倍图、三倍图的存在px、py、pr 这些值直接写死成逻辑像素不靠谱要按图片实际像素尺寸做归一化。// 归一化的圆心和半径取值范围 0~1 const calibration { cx: 0.5, // 色盘圆心在图片水平方向的相对位置 cy: 0.5, // 色盘圆心在图片垂直方向的相对位置 radius: 0.48 // 色盘半径相对图片宽度的比例 };组件在计算手势位置时先把手指坐标换算到图片像素坐标系再用归一化圆心和半径做一次坐标平移判断是否落在圆内。这个细节直接决定了取色精度不要在标定上省事。2.3 亮度与透明度怎么放进同一个取色交互很多取色组件会把 HSV 的明度 V 也放进色盘做法是在色环上叠加一层从白到黑的线性渐变。但这样做有个体验问题用户想要一个亮黄色时他必须在色环上先找到黄色区域再微调径向位置同时兼顾饱和度和明度非常考验手指精度误操作率很高。所以这个项目里采用“色盘 独立亮度滑轨”的方案色盘负责 H 和 S亮度条单独负责 V。取色时先读出色盘上手指位置的像素颜色得到 RGB 值再转成 HSV用亮度滑轨的值替换 V再转回 RGB。这样既保留了一指的直观性又避免了二指操作的复杂度。色彩格式转换代码可以考虑直接在 JS 层内置一份避免在滑动过程中频繁调用原生桥function rgbToHsv(r, g, b) { r / 255; g / 255; b / 255; const max Math.max(r, g, b), min Math.min(r, g, b); const d max - min; let h 0; if (d ! 0) { if (max r) h ((g - b) / d) % 6; else if (max g) h (b - r) / d 2; else h (r - g) / d 4; h * 60; if (h 0) h 360; } const s max 0 ? 0 : d / max; return { h, s: s * 100, v: max * 100 }; }这里补充一句色盘读出的像素颜色在叠加亮度滑轨之后最终下发给设备。如果后续想支持透明度同理可以在 HSV 外面套一层 alpha逻辑完全一致。3. 位图读取方案选型读像素还是算像素这是最先要定的架构决策3.1 RN 环境下的像素读取限制在普通 Web 开发里读取图片某个位置的像素颜色是一件非常自然的事canvas.drawImage getImageData 一行搞定。但在 React Native 里没有 Canvas 这种可以直接操作位图的 API。RN 的图片组件本质上是原生视图JS 层拿不到图片的像素数据。这就导致了一个技术路线分叉。方案无非三种把色盘图片解码成像素数组整张放进内存。通过原生模块暴露一个接口每次手势移动时调用原生方法去图片上取一个像素的颜色。不读像素用数学公式计算颜色这个方案前面已经推翻了。3.2 方案 A原生模块暴露 getPixel 接口当时先试了方案 A。思路是在 Android 端写一个原生模块用 BitmapFactory 解码色盘资源然后暴露一个方法传入手指在图片上的像素坐标返回该坐标的 RGB 值ReactMethod public void getPixelColor(String imagePath, int x, int y, Promise promise) { Bitmap bitmap decodeBitmap(imagePath); int pixel bitmap.getPixel(x, y); int r Color.red(pixel); int g Color.green(pixel); int b Color.blue(pixel); // ... }这套方案完全可行在 iOS 端用 CGContext 也能拿到同样的数据。但实际压测时发现一个问题手势滑动是一个高频事件流如果每帧都通过 bridge 走一次 Native - JS 的路由延迟和损耗都非常可观。特别是米家插件的宿主 App 里JS 线程本来就在跑业务逻辑画中画、推送、动画都在抢资源滑动取色偶尔会有“跟不上手指”的感觉。注意这里不是完全否定原生接口方案它最大的优点是内存占用低。但取色性能要求决定了我们不能高频调用它。3.3 方案 B预生成颜色查找表 LUT我最终采用的路径既然高频调用原生接口不可行那就反过来在组件初始化阶段把色盘图片中所有像素的颜色一次性读取出来存成一个二维数组即颜色查找表 LUT后续手势滑动时直接在 JS 层查表一次原生调用都不需要。具体做法是原生模块在初始化时把解压后的位图转成一个整型数组int[] pixels new int[width * height]; bitmap.getPixels(pixels, 0, width, 0, 0, width, height); // 转成 compact 的 JS 数组后传给 JS 层这里有个内存优化点如果图片是 750x750 像素每个像素用 4 字节表示 RGBA整张图大约是 2.2MB。直接用 JS 数组存会有额外开销所以我把数组转成 base64 编码的字符串或者直接存成 Uint8Array 的 base64 形式传到 JS 层用 ArrayBuffer 包装在 JS 里按索引读取每次取色只做一次数组读操作。取色函数简化后是function getColorFromLUT(lut, width, x, y) { const idx (y * width x) * 4; const r lut[idx]; const g lut[idx 1]; const b lut[idx 2]; return { r, g, b }; }方案 B 的优点显而易见滑动过程的每一帧都不产生原生调用JS 线程只做一次数组索引取值 颜色格式转换性能完全可控。缺点是初始化阶段要把整张位图的数据搬进内存这在米家插件这种内存敏感的宿主环境里需要谨慎评估但一张 750x750 的色盘图其实只占 2MB 左右属于可接受范围。最终我选择的就是方案 B。这一条是整个组件设计的基石后面所有性能优化都基于这个 LUT 结构展开。4. PanResponder 手势轨迹与坐标换算让手指位置和取色点严格对应4.1 手势响应链配置的细节React Native 生态里做触摸跟踪最常用的就是 PanResponder。它本质上是在原生手势系统上包了一层 JS 响应链能够统一处理触摸开始、移动、结束三个核心回调。配置时我踩了几个细节先说正确的核心参数const panResponder PanResponder.create({ onStartShouldSetPanResponder: () true, onMoveShouldSetPanResponder: () true, onPanResponderGrant: handleGrant, onPanResponderMove: handleMove, onPanResponderRelease: handleRelease, });这里onStartShouldSetPanResponder和onMoveShouldSetPanResponder一定要返回 true否则父级 ScrollView 或者外层容器会抢走手势。在米家插件里色盘组件往往处于一个可以上下滚动的面板内如果不把这两个回调设为 true用户滑动色盘时会同时触发外层滚动取色和滚动冲突体验直接崩掉。另外一个细节是要让手势事件不进入父级响应链需要在最外层 View 上设置onMoveShouldSetResponderCapture或者在 PanResponder 的 config 里返回 false。但更稳妥的方式是在色盘容器 View 上绑定onStartShouldSetPanResponderCapture: () true直接截获。4.2 locationX/locationY 的几个坑点PanResponder 的移动事件里nativeEvent.locationX和nativeEvent.locationY表示触摸点相对于当前响应视图的位置。理论上用来换算手指在图片上的坐标很方便但实际测试中发现几个问题第一locationX/locationY 在 Android 的部分 ROM 上有兼容性问题。某些定制系统米家用户设备五花八门从千元机到旗舰机都有在快速滑动时locationX 会偶尔跳变成相对于手势起点的偏移而不是相对于目标 View 的位置。解决办法是不依赖 locationX而是用pageX/pageY减去容器在页面中的测量位置。// 在 onLayout 中缓存容器的位置 let containerLayout null; onLayout{(e) { containerLayout e.nativeEvent.layout; }} const getTouchPosition (evt) { const { pageX, pageY } evt.nativeEvent; if (!containerLayout) return null; const x pageX - containerLayout.x; const y pageY - containerLayout.y; return { x, y }; };虽然这样要多做一次坐标转换但稳定性大幅提升。第二图片的实际显示坐标和 View 的坐标不一定完全重合。如果用 Image 的默认样式图片会跟随 View 的尺寸拉伸但为了保持宽高比一般会设置resizeModecontain这时候图片实际显示的区域和 View 的区域会不同步必须把手势坐标再经过一次“图片内容显示区域”的映射才能正确换算到像素坐标。function mapViewCoordsToImageCoords(viewX, viewY, viewWidth, viewHeight, imgWidth, imgHeight) { const scale Math.min(viewWidth / imgWidth, viewHeight / imgHeight); const displayW imgWidth * scale; const displayH imgHeight * scale; const offsetX (viewWidth - displayW) / 2; const offsetY (viewHeight - displayH) / 2; return { x: (viewX - offsetX) / scale, y: (viewY - offsetY) / scale, }; }这个映射如果不做会出现“手指明明点在图上的红色区域取出来的却是旁边黄色区域的颜色”这种严重偏差。4.3 边界吸附与中心盲区处理色盘中心区域有一个细节接近圆心时颜色趋近于白色或者浅色此时饱和度的变化非常不明显用户会感觉自己怎么滑都是同一个颜色。而色盘外圈如果手指滑出圆的范围取色点会直接越界。针对这两点加了两层容错逻辑。中心盲区当手指位置和圆心的距离小于半径的 8% 时强制按圆心颜色处理避免因为空气手指抖动导致颜色来回跳变。边界吸附当手指滑出圆的范围取色点不直接丢弃而是沿着圆心到手指位置的连线上取离手指最近的圆边界点颜色。这样用户快速滑过边缘时颜色变化依然连续不会突然跳到默认色。核心的坐标判断逻辑如下function normalizeToCircle(x, y, cx, cy, radius) { const dx x - cx; const dy y - cy; const dist Math.sqrt(dx * dx dy * dy); if (dist radius * 0.08) { return { x: cx, y: cy, inCircle: true }; } if (dist radius) { return { x, y, inCircle: true }; } const ratio radius / dist; return { x: cx dx * ratio, y: cy dy * ratio, inCircle: false }; }这些处理虽然只是边缘情况但在真机上的体验差异非常大。没有边界吸附时用户手指稍微移出色盘颜色会突然变成初始态视觉上就是“闪了一下白色”很典型的问题反馈。5. 从卡顿到跟手实时取色链路的性能调优路径5.1 性能瓶颈到底在哪一端色盘取色的性能链路可以简化成三段手势事件到达原生线程/UI 线程JS 层读取 LUT 并计算出颜色状态更新并重渲染 UI在这三段里最容易卡顿的是第三段。如果每帧移动都 setState会导致整个组件树重渲尤其是取色组件和亮色背景、色值文案、预览圆点一起联动时频繁的 setState 会直接打满 JS 线程。所以第一步优化就是减少 setState 的触发频率并且缩小状态更新的作用范围。具体做法是滑动过程中颜色值变化用 ref 直接保存只更新一个轻量的预览圆点的位置不触发组件树整体重渲等手势释放后才 setState 更新正式的颜色状态并回调给外部业务。// 滑动中只做轻量定位不 setState const previewRef useRef(null); const handleMove (evt) { const { x, y } getTouchPosition(evt); // 换算成图片坐标后读取 LUT const color getColorFromLUT(lut, width, imgX, imgY); previewRef.current.setNativeProps({ left: x - CIRCLE_SIZE / 2, top: y - CIRCLE_SIZE / 2 }); currentColorRef.current color; }; // 手势结束时统一回调 const handleRelease () { setSelectedColor(currentColorRef.current); onColorChange(currentColorRef.current); };这里用到了setNativeProps它跳过 React 的重渲染过程直接操作原生视图属性是最轻量的更新方式。预览圆点的位置更新频率可以到 60fps而整个 JS 层渲染线程几乎不受影响。5.2 节流策略与状态更新阈值即使有了 setNativeProps读取 LUT 的 JS 计算在低端机上也可能吃不消尤其是色盘图片像素很多、LUT 体积大时每次取色都要从 ArrayBuffer 里按位读取并做格式转换。这里有两个优化方向。第一个是节流。PanResponder 的移动事件频率和屏幕刷新率基本一致也就是 60Hz但在连续多点触控事件中相邻两帧的坐标差异其实非常小颜色很可能没变。所以在 handleMove 里加一个简单的比较如果本次取到的 RGB 值和上次完全一样就直接跳过后续处理。const lastColorRef useRef({ r: -1, g: -1, b: -1 }); const handleMove (evt) { // 读取颜色 ... const { r, g, b } color; if (lastColorRef.current.r r lastColorRef.current.g g lastColorRef.current.b b) { return; } lastColorRef.current color; // 后续更新逻辑 };第二个是 LUT 内存布局。前面提到用 ArrayBuffer 存像素这里再减少一次数据转换不要每次取色都做一次 base64 解包或者字符串切分而是初始化时就把 ArrayBuffer 直接留在闭包变量里取色时只做索引计算和位运算。5.3 低端 Android 实机验证结果调优完成后我在几台测试机上做了压测包括一台 2GB 内存的旧款千元机结果如下测试项优化前优化后滑动取色平均帧率约 28 fps有明显卡顿45~60 fps基本跟手单次取色耗时JS 层角度约 12ms约 1ms初始化 LUT 构建耗时约 480ms约 120ms异步分片构建LUT 构建耗时差距比较大的原因是在优化后把“图片解码 数组传输 JS 层组装”改成了原生模块直接返回 ArrayBufferJS 层只做一次包装省掉了逐元素传输的开销。有一点要多说一句如果只做原生 getPixel 方案初始化很快但滑动过程中的原生调用损耗会被无限放大而 LUT 方案把代价前置到初始化阶段滑动过程零原生调用。从实际产品体验角度说初始化多花 100ms 用户基本无感知但滑动掉帧用户会立刻发现所以这个取舍非常划算。6. 真机环境里的坑米家 App 宿主、资源加载与设备联动6.1 启动白屏与图片资源加载路径米家插件运行在米家 App 宿主环境里和独立 RN 应用有一些差异最典型的坑是图片资源加载。插件打包后的图片路径在本地调试和真机运行两种场景下可能不一样一个不小心就会导致色盘图片加载失败整个面板白屏。我的做法是把色盘图片放进插件包的 assets 目录下并在 JS 层用Image.resolveAssetSource来解析资源路径拿到 uri 后再传给原生模块做解码const source Image.resolveAssetSource(require(../assets/color-wheel.png)); // source.uri 就是原生模块可以用的绝对路径这里有个特别容易踩的坑如果在原生模块里用的是相对路径或者加载的是 drawable 资源在部分 Android 机型上图片因为资源重名被系统覆盖取出来的像素颜色会错乱。最好的方案是只认source.uri不要自己去拼路径。6.2 宿舍 App 宿主环境下的内存和定时器限制米家作为一个宿主 App它的生命周期和普通 App 不太一样。插件可能随时被切到后台内存紧张时宿主会回收 WebView 或者销毁插件实例。如果你的组件在初始化时构建了 2MB 的 LUT但没有做生命周期监听很可能出现“切后台再回来LUT 已经变成 undefined”的情况。解决方法是把 LUT 缓存到组件的 ref 上并且监听 AppState 变化在回到前台时检查 LUT 是否还存在不存在就重新构建AppState.addEventListener(change, (state) { if (state active !lutRef.current) { initLUT(); } });顺便提一个非常隐蔽的问题不要在组件挂载时用 setTimeout 去做初始化 LUT 这种重活米家插件的宿主环境可能会在 App 进入后台时暂停 JS 定时器导致回调迟迟不执行。如果一定要用定时器做分片优化建议配合 AppState 做恢复触发。6.3 设备联动与属性下发的时机控制取色组件的最终目的不是自己看到颜色而是把颜色下发给智能设备。在米家插件里通常是通过设备属性协议发送 RGB 值或者色温值。这里有一个非常影响体验的时机问题高频滑动过程中不应该每帧都向设备下发属性否则设备端会频繁响应、状态闪烁很耗电。处理方式是做一层“滑动中不发送、松手后发送”的策略滑动中onPanResponderMove只更新本地预览颜色。松手时onPanResponderRelease把最终颜色一次性写入设备属性并展示一个“已生效”的反馈。如果需要实时预览的效果可以在滑动过程中用节流比如 200ms 一次向设备下发预览值但正式写入还是要等松手后。这样既保证了操作流畅度又避免了对设备造成过多控制请求。这个设计在真实体验中的感受是用户滑动色盘时灯光可能不会立刻跟着变化如果用节流则会有轻微延迟但一旦松手颜色就立刻到位用户能很清楚地知道“我的选择已经生效了”。7. 组件整体结构与复用建议把整个组件抽出来看它的结构是清晰的分层ColorWheel ├── TouchLayerPanResponder 手势层 ├── ImageLayer色盘图片展示 ├── LUTManager位图数据加载与颜色查询 ├── ColorSpaceUtilsRGB/HSV/色温转换 └── PreviewDot跟随手指的预览圆点这个组件可以很好地复用。后续如果要加新色盘图片只要把设计稿的资源替换掉重新生成 LUT组件代码完全不用改。如果后续还要支持透明度调整只需要在 LUT 里多存一个 alpha 通道然后在 UI 上增加一个透明通道滑轨即可整体架构允许平滑扩展。有一点值得强调这个组件适合的取色场景是“静态图片上的颜色选取”。如果你的色盘需要动态改变形态比如根据冷暖模式实时切换色环颜色那 LUT 方案就需要重新设计比如改成读两张图或者动态生成位图。但我目前遇到的场景静态色盘已经是绝大多数。最后分享一个小技巧色盘图源文件的尺寸不需要太大750x750 在大多数屏幕上已经足够。更大的图虽然取色精度更高但像素差异人眼根本分辨不出来反而白白增加内存和初始化耗时。合理控制位图尺寸是成本最低的性能优化。希望这篇记录能帮你少走一些弯路。如果后续你也做了色盘取色相关的东西欢迎一起交流这里面更细的交互和性能问题。
