ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从语义到色值:Flutter构建PaletteX颜色分类选择器

从语义到色值:Flutter构建PaletteX颜色分类选择器 PaletteX 是我业余写的一个调色板应用基于 Flutter 和 HarmonyOS 6.0 构建最核心的功能模块是一个颜色分类选择器。字面上看它不过是一个选色工具但做完之后我才意识到这个项目真正在解决的是人和颜色之间那条长期被忽略的语义鸿沟。日常工作里我们遇到最多的情况不是找不到颜色而是说不清楚要哪种颜色。设计师在评审会上说这个按钮要暖一点的红产品经理补一句但不要太艳等你回到工位打开取色器面对的是色环上几十万种色值——什么是暖一点什么算太艳这些描述在自然语言里非常具体但在 RGB 数字里没有任何直接对应关系。PaletteX 的出发点很简单把人对颜色的模糊描述翻译成可复现的精准色值。这篇文章不是一份完整 App 的源码逐行解析而是把两个层面的经验写出来一是颜色分类选择器的算法设计与交互逻辑二是 Flutter 项目在 HarmonyOS 6.0 真机上的适配过程。无论你是被 UI 细节折磨的客户端开发还是想做一个垂直工具的独立开发者这篇文章应该都能给你一点参考。1. 选色工具最痛的地方语义和色值之间缺一座桥1.1 暖一点的红到底是多少号色先还原一个我在之前的电商项目里反复遇到的场景。支付按钮的颜色上线前要调设计师的原始需求是一个有信任感的红开发随手填了#D32F2F。设计师看了一眼截图说太冷了开发换成#C62828设计师说还是偏暗。两个人来回改了七八轮最后设计师从 Figma 里截了一张色块发过来这场拉锯才结束。问题不在沟通态度而在于我们用语义词汇讨论一个数值空间里的事物。暖冷暗艳这些词在每个人的大脑里对应的是不同的色值区间中间没有任何公共参照系。选色工具在这个环节几乎没起到作用因为传统取色器让你做的是一道终极选择题请直接给出最终色值。可你心里只有一个模糊区间根本还没想好具体终点。所以我把 PaletteX 的核心交互从取色改成了分类。用户进来先选的是方向暖色系还是冷色系红色系还是大地色系鲜亮还是柔和。每选一个分类候选颜色范围就被大幅收窄剩下的只是在同类里挑一个微调。这个过程更像在逛一个有导购的实体店而不是面对一堵没有贴标签的色号墙。1.2 PaletteX 的定位分类优先而不是取色优先PaletteX 的中文名叫色域正好点出这个项目的两个重点一是颜色空间的域二是探索色值边界的域。它不是一个试图替代所有专业调色软件的工具它的细分定位是快速从语义出发找到可用颜色并把满意的颜色组织成配色方案。我给应用定了三个核心能力按优先级排序按分类浏览和筛选颜色比如暖色、冷色、红色系、莫兰迪灰调用色相环和三个颜色通道滑杆做微调选中后立即看到 HEX、RGB、HSL 值把多次选中的颜色保存为配色方案支持本地存储和云端同步。可以看到颜色分类选择器并不是一个单纯的 UI 组件它是整个应用的信息入口。所有选色行为都从分类开始而不是从一个随机色块开始。1.3 为什么不能直接拿现成 picker 库改造第一版我确实动过这个心思。Flutter 社区有flutter_colorpicker这类库色盘、滑杆、十六进制输入基本都有拿来改改似乎能省一半工作量。但深入后发现两件事让这条路走不通第一这类库的交互逻辑是先给你一个颜色你自己拖到满意它没有分类学习的完整闭环我想要的选分类 - 看推荐 - 微调流程需要从零写第二也是更现实的问题这套库的底层对 Android/iOS 的通道依赖比较重放到 HarmonyOS 6.0 的 Flutter 通道上插件层的兼容性要一个个踩。与其在别人的框架上打补丁不如自己掌控核心逻辑。颜色分类的算法其实不复杂复杂的是交互设计和平台适配这两块自己写反而心里有底。2. 颜色分类选择器的底层逻辑HSL 映射与色差判断2.1 RGB 不适合做分类的原因任何分类算法都要选对坐标空间。最直觉的方案是用 RGB毕竟每个颜色在代码里就是三个 0 到 255 的整数。但真正做了分类才发现RGB 空间里数值距离和人类感知距离完全不重合。举个例子RGB(255, 0, 0)和RGB(200, 0, 0)在数值上差了 55肉眼看起来确实有差异但RGB(0, 80, 0)和RGB(0, 90, 0)同样差 10肉眼几乎分不出来。这不是视觉错觉而是 RGB 是面向显示设备的颜色空间它把人眼的非线性感知硬塞进了线性数值里。如果在 RGB 空间直接做聚类或者划分子空间结果会和直觉严重偏离。这也是为什么我做分类时把所有颜色先做一次空间转换转进 HSL 再判断。HSL 把颜色的三个感知维度拆开色相Hue是 0 到 360 度的角度饱和度Saturation是颜色鲜艳程度亮度Lightness是明暗程度。这个坐标系本身就更接近人脑理解颜色的方式。2.2 用 HSL 色相区间切分的分类规则在 HSL 空间里做分类就直观多了。色相角 360 度绕一圈基本锚点是这样0 度附近是红色60 度是黄色120 度是绿色180 度是青色240 度是蓝色300 度是洋红。我给 PaletteX 设计了八套色相分类加上一套中性色分类切分规则如下分类色相范围备注红色系345-15包含偏暖的品红边橙色系15-45朱砂、橘色都在这里黄色系45-75金色、土黄也靠它绿色系75-150覆盖从草绿到墨绿青色系150-195蓝绿、青碧蓝色系195-260从天蓝到藏蓝紫色系260-300含紫罗兰、深紫洋红系300-345偏粉的玫红中性色任意饱和度低于阈值时进入中性色单独拎出来很重要。很多人找灰色、米白、燕麦色的时候是不会去想这是什么色相的它们视觉上是没有彩色倾向的颜色。算法上我取饱和度低于 0.15 作为分界线低于这条线统一归为中性色否则按色相区间走。这样分类的误判率会低很多。核心函数用 Dart 写并不长enum ColorFamily { red, orange, yellow, green, cyan, blue, purple, magenta, neutral } ColorFamily classifyColor(Color color) { final hsl HSLColor.fromColor(color); final h hsl.hue; final s hsl.saturation; if (s 0.15) return ColorFamily.neutral; if (h 15 || h 345) return ColorFamily.red; if (h 45) return ColorFamily.orange; if (h 75) return ColorFamily.yellow; if (h 150) return ColorFamily.green; if (h 195) return ColorFamily.cyan; if (h 260) return ColorFamily.blue; if (h 330) return ColorFamily.purple; return ColorFamily.magenta; }色相分类只能回答这是什么色系不能满足我要柔和一点的红这种需求。所以我又在分类内部加了两个维度按饱和度分鲜亮和柔和按亮度分明亮和暗沉。这两个维度其实也是来自日常语言的直觉做出来之后选色效率高了很多。提示如果只是做个人工具开头不要贪多先跑通色相分类就够了。饱和度、亮度子分类等数据量上来之后再慢慢加不然样本颜色和分类标签的维护量会很可观。2.3 相似颜色的量化色差公式怎么选分类做出来后紧接着遇到一个需求用户选中一个颜色希望看到和它最像的另外几个颜色。这个需求在配色场景里非常实用但要实现它需要一个量化两个颜色视觉差异的函数。最粗糙的算法是 RGB 空间欧氏距离sqrt((r1-r2)^2 (g1-g2)^2 (b1-b2)^2)。前面已经解释过RGB 数值差跟视觉差不成正比算出来的相似色经常让人莫名其妙。正规做法是转 CIE Lab 颜色空间再算距离更讲究一点用 CIEDE2000 公式它考虑了人眼在不同色相、饱和度和亮度下的感知差异。CIEDE2000 的公式包含好几个修正项写出来非常长调色板应用用它做了一个月之后我的结论是大部分场景用 Lab 空间的欧氏距离也叫 CIE76已经完全满足需求。只有做纺织品、印刷品这种对色差极度敏感的专业场景才需要上 CIEDE2000。double colorDistance(Color a, Color b) { final labA rgbToLab(a); final labB rgbToLab(b); return sqrt(pow(labA.l - labB.l, 2) pow(labA.a - labB.a, 2) pow(labA.b - labB.b, 2)); }从 sRGB 转到 Lab 需要经过 XYZ 中间空间公式是固定的网上很容易找到。我现在把它封装在color_math.dart里随手取用。还有一个意外收获色距函数不只是用来找相似色它还能做防重复校验。用户在保存自定义颜色到方案里的时候我会拿新颜色和方案里已有颜色跑一遍色距如果某个已有颜色距离小于阈值就提示这个颜色和已保存的某某颜色几乎一样避免一个配色方案里塞进五个肉眼难分的重复色。3. Flutter × HarmonyOS 6.0 的工程准备与数据建模3.1 SDK 安装与鸿蒙平台目录的初始化标题里写的 HarmonyOS 6.0指的是应用要真机跑在鸿蒙系统上。准备工作比纯 Android 开发多一步除了 Flutter SDK还要装 DevEco Studio 和对应的鸿蒙 SDK工程结构上也需要为鸿蒙初始化平台目录。我个人的流程是这样先确保 Flutter 本身能跑配置好本机 PATH 后用flutter doctor看一眼基础环境然后打开 DevEco Studio 把 SDK 组件补齐最后回到 Flutter 工程里执行flutter create . --platformsandroid,ios,ohos让工具生成鸿蒙平台目录。这里要特别注意ohos平台目录并不是所有版本的 Flutter SDK 都默认支持有些版本需要切换到社区维护的鸿蒙分支或者通过额外的构建工具链支持。稳妥的做法是建完工程后立即跑一次空白构建确认脚手架真的能出包再开始写业务代码。网络上有很多一键安装脚本实测下来部分在鸿蒙新版本上会失败比如某些 brew 脚本至今还会有部署报错。我的建议是不要在一键脚本上耗时间按官方文档走一遍手动安装弄清楚每一步在做什么后面遇到问题才知道去哪查。这个教训是我在鸿蒙真机调试第一天用三个小时换来的。3.2 状态管理选型Riverpod 处理高频颜色状态颜色选择器的状态变化非常高频拖一下色相环角度变了、滑一下亮度条亮度变了、选中一个分类要刷新列表、从图库里提取主色要异步更新预览区。这种高频状态如果全部塞进setState一会儿组件树就要爆炸。我选了 Riverpod理由有两条一是它和 Flutter 的解耦做得干净状态逻辑可以单独写成 Provider 层方便单元测试二是它的ref.watch可以精确监听某一个字段色值变化时只有真正依赖该字段的组件会重建性能上比整棵子树刷新好很多。PaletteX 里我分了三个核心 Providerfinal colorFamilyProvider StateProviderColorFamily((ref) ColorFamily.red); final selectedColorProvider StateProviderColor((ref) const Color(0xFFD32F2F)); final paletteListProvider StreamProviderListPalette((ref) { return ref.watch(appDatabaseProvider).watchAllPalettes(); });前两个管用户的即时选中状态第三个直接从数据库监听配色方案的更新。拖色环的时候只触发selectedColorProvider刷新列表数据完全不受影响实测在低端鸿蒙真机上也能保持 60 帧左右的流畅度。3.3 配色方案与颜色条目的模型设计数据建模我一开始想简单了以为一个调色板应用存几个字符串就够了。真正做到保存配色方案的时候才发现要设计的字段比想象多。配色方案是一对多的关系一个方案包含多个颜色每个颜色在方案内还要有顺序、备注名称。用 Drift 定义的表结构大概是这样的class Palettes extends Table { IntColumn get id integer().autoIncrement()(); TextColumn get name text()(); TextColumn get coverHex text()(); DateTimeColumn get updatedAt dateTime()(); } class PaletteColors extends Table { IntColumn get id integer().autoIncrement()(); IntColumn get paletteId integer().references(Palettes, #id)(); TextColumn get hex text()(); TextColumn get label text().nullable()(); IntColumn get orderIndex integer()(); }coverHex存方案封面色列表页渲染时用一张大卡片直接展示orderIndex控制方案内颜色排序。所有颜色值统一存 HEX 字符串而不是整数色值因为 HEX 可读性好日志里一眼能看懂转回Color也就一行代码。字段设计不必第一次就完美但关系要理清。我在第一版把颜色直接塞成一个 JSON 字符串数组存进 Palettes 表后来做给每个颜色起名字、调整顺序的时候被迫重构了一次。所以模型上宁可多建一张表也比留一个 JSON 字段灵活。4. 颜色分类选择器的实现从交互到代码4.1 分类标签与色值的双向映射分类选择器的核心其实是一张映射表分类标签可以映射到一组推荐颜色一个具体色值也能映射回它的分类标签。这是一个双向映射两个方向都要用到。正向方向用得最多。用户点了橙色系-柔和分类界面就列出所有被标记为柔和橙的颜色。我维护了一份颜色样本库每个样本包含 HEX、所属分类、人工标注名称。比如#E8A87C会被标注为暖杏仁分类是橙/柔和。这份样本库是应用最初体验的基石我花了整整两个晚上从各类设计系统和中国传统色里整理并校准了三百多个常用颜色。反向映射则依赖第 2 节写的classifyColor。用户手动拖一个色环应用实时算出它的分类分类标签跟着更新页面顶部的色卡名称也随之切换。这样做的好处是用户不会被固定样本库限制住自由调节的结果同样能获得文字标签。两个方向的结合还能做出一个贴心功能用户在一个分类里选中一个颜色后点查看类似色应用会拿当前色值去样本库里跑色距筛选按距离从近到远展示同分类的候选颜色。这相当于给每个颜色装了一个找亲戚的入口。4.2 色相环 三通道滑杆的手势实现色相环是颜色分类选择器里最有手感的部分。我实现的是一个圆环加上内圈饱和度/亮度选择区域。手指在圆环上滑动时根据手指位置和圆心的夹角算出当前色相角度再映射成色值。角度计算逻辑不复杂用atan2double _angleFromOffset(Offset offset, Offset center) { final dx offset.dx - center.dx; final dy offset.dy - center.dy; return (atan2(dy, dx) * 180 / pi 360) % 360; }拿到角度后转成HSLColor.fromAHSL(1, angle, saturation, lightness)再设置到状态里。细节在体验上很关键手指按下时就要立刻响应不能等移动才开始变化另外每帧都更新状态的话颜色预览区和十六进制值文本要轻量更新不能做重布局。三通道滑杆我没有做成常见的 R/G/B 三根而是按 H/S/L 来。因为 H 通道的变化对颜色感知影响最大S 和 L 是微调。每根滑杆的渐变背景是用LinearGradient实时生成的例如色相滑杆的渐变是从 0 度到 360 度均匀铺开的一排颜色。这个渐变我预计算了一次避免拖动时重复生成大数组毕竟低端机上的垃圾回收压力能省则省。色相环加三滑杆可以精确命中任何颜色但对普通用户来说它本质上还是个微调工具。所以界面的交互路径设计成先点分类 chips 看推荐色集合选中一个最近似的再进色相环微调。这样既保留精度又不强迫用户一开始就面对数学坐标系。4.3 关键词搜索把日常词汇映射到分类做搜索的时候我发现一个有意思的事用户会直接搜莫兰迪雾霾蓝奶茶色这类词而不是搜低饱和度蓝色。这让我决定维护一份语义词典把常见颜色词汇映射到具体的筛选条件或色值。每种词条在代码里就是一个映射项。比如莫兰迪会自动应用饱和度 0.25、亮度在 0.3 到 0.7 之间这样的约束再配合用户输入的其他颜色词比如绿组合成筛选条件奶茶色更直接我把它指向#D2B48C附近的色距搜索返回三十个距离最近的候选色。class SemanticColorEntry { final String keyword; final ColorFamily? family; final double? maxSaturation; final Range? lightnessRange; final Color? targetColor; }这套词典的价值在于产品开始有人味。第一次做的时候我只有三十来个词条后来根据用户提问逐步补。日常使用中搜索入口和分类入口是互补的分类适合浏览搜索适合目标明确但表达模糊的场景。提示语义词典要设计成可扩展的数据结构不要写死在 switch-case 里。做成本地 JSON 文件加载后续加词条不用发版也可以在运营后台维护。5. 配色方案的内嵌存储与多端同步5.1 选择 Drift 而不是手写 SQLite调色板应用能不能用简单键值存储能Hive 甚至 SharedPreferences 都能跑但做到多个配色方案、方案内多个颜色、每个颜色还要排序和命名这个复杂度键值存储的代码会比 SQL 混乱得多。我最后选了 Drift原因是它在 Flutter 生态里算是关系型数据库的最优解底层是 SQLite但编译期生成强类型代码查询返回Stream天然支持 UI 响应式刷新。这意味着我在 3.2 节里写的StreamProvider可以直接监听数据库表任何增删改查都会自动流到界面上不需要手动刷新。唯一要提前验证的是鸿蒙平台的支持情况。Drift 依赖 sqflite 这类原生数据库插件如果插件没有适配 HarmonyOS整个数据层就要换方案。我在项目一开始就先把数据库读写跑通在真机上而不是等界面写完再接入避免最后一刻推倒重来。5.2 表结构设计与流式查询除了前面提到的 Palettes 和 PaletteColors我还加了一张预置色样本表SeedColors里面存了我手动整理的几百个分类颜色字段。这张表的查询不走流式因为它基本是静态的编译期导入一次就够了。方案列表页用流式查询的好处很明显我在调试时给某方案追加一个颜色返回上一页列表自动出现新封面不需要手动setState或触发refresh。Drift 的 watch 查询底层通过 SQLite 的invalidate机制实现数据量小的时候几乎无感。如果用一句话总结我对流式查询的建议凡是用户可操作变更的数据都用流式查询只有纯静态的词典数据才用一次性查询。这样代码里数据变更后手动刷 UI的代码几乎可以全部删除。5.3 本地优先的增量同步策略同步这个话题很敏感容易做重。我的方案是本地优先所有读写先落在本地数据库界面上永远从本地读后台同步只是一条上次修改时间 待同步操作队列的异步流程。具体做法是给每张业务表加一个updatedAt时间戳同时在本地维护一张SyncQueue表记录未同步的增删改操作。用户离线时正常创建/编辑配色方案操作写入 SyncQueue网络恢复后按队列顺序推给后端成功后删除对应队列记录。后端返回数据时带上服务端时间戳如果本地更新的时间更晚就以本地为准避免覆盖用户最新操作。这套东西代码量大概三百到四百行价值巨大。我用一个简单的 REST API 做后端第一次在小规模测试机群里用的时候就发现用户最在意的不是实时多端同步而是换手机之后我的配色方案还在。本地优先的策略能保证离线体验和在线体验一致同步只是后台默默的加分项。注意同步的真正常见 bug 不是推送失败而是对账逻辑没写。我的队列里存的不只是操作类型还存操作前后的完整数据快照这样即使推送接口幂等实现得不好也可以用快照手动恢复状态。6. HarmonyOS 6.0 构建适配与真机踩坑记录6.1 从 flutter build 到 hap 包的全流程在鸿蒙 6.0 真机上跑 Flutter 应用构建链路比 Android 多一环先由 Flutter 构建出平台的产物再用 DevEco Studio 打包成 hap 安装包。这一步如果没跑通后面的业务代码再正确也白搭。我的第一轮构建是很朴素的流程flutter build hap --debug看是否产出 hap 文件然后在 DevEco Studio 里打开工程根目录用自带的签名配置跑一次真机安装。这里面最容易忽略的是签名配置。DevEco Studio 新建工程时会自动生成调试签名但如果你像我一样是先有 Flutter 工程再补鸿蒙平台目录签名文件可能压根没有安装到真机上会直接报权限错误。去项目build-profile.json5里确认签名配置指向有效文件比重新创建工程省事得多。另外我还踩过一个小坑Flutter 构建出的 hap 包默认可能包含的是 debug 签名无法直接上架应用市场。上架前需要切换到正式签名重新打 release 包这个流程要提前给发布负责人讲清楚避免临近发布才手忙脚乱。6.2 Gradle 插件命令式 apply 报错的修法构建过程中最典型的报错信息大概是 You are applying Flutters main Gradle plugin imperatively using the apply method...。这个报错我刚看到时愣了一下因为同样的工程在 Android 平台构建是正常的说明问题出在鸿蒙构建链路的 Gradle 插件装载方式上。报错的意思是Flutter 主 Gradle 插件被用apply plugin: ...这种命令式方式加载但新版构建工具要求使用 plugins DSL 方式声明。修法也很直接在项目的settings.gradle或根build.gradle里把插件改到plugins {}块里声明plugins { id com.flutter.gradle version x.y.z apply false }然后在模块里引用plugins { id com.flutter.gradle }而不是apply plugin: com.flutter.gradle这个报错对构建工具版本号非常敏感不同 SDK 版本对应插件版本也不同。我的经验是不要试图手动改版本号强行绕过去官方发布说明里找到当前 Flutter 鸿蒙分支对应的插件版本一次性写对。6.3 用 bridge 调起鸿蒙相册提取图片主色PaletteX 里有从图片提取主色的需求这就绕不开调用鸿蒙系统的相册。Flutter 社区常见的image_picker插件针对 Android/iOS 实现在鸿蒙上虽然部分可用但底层走的还是套壳逻辑稳定性不好。我最终选择自己写一层桥接在 Dart 侧注册一个MethodChannel在鸿蒙原生侧调用系统的相册选择器把选中图片写入临时目录把文件路径回传给 Dart再在 Dart 侧用颜色量化算法提取主色。static const _channel MethodChannel(palettex/gallery); final String? path await _channel.invokeMethod(pickImage);原生侧的代码量不大核心就是调用 PhotoPicker 并返回 URI。由于这里要处理异步回调和页面跳转代码里最容易踩的坑是 Context 生命周期问题选择器回调回来时如果 Activity 已经重建所持有的 Context 会失效。解决方案是不要长期持有 Activity 引用用 ApplicationContext 来获取系统服务。提取图片主色的算法我用了简单的颜色直方图聚类。先把图片缩小到 64x64采样像素点按第 2 节的颜色分类函数归入各个分类统计每个分类的像素占比输出占比最高的三到五个代表色。效果比专业取色器略糙但作为给配色方案找灵感的入口完全够用。6.4 真机性能表现与包体瘦身最后说性能和包体。PaletteX 在鸿蒙 6.0 真机上跑下来整体流畅度符合预期。颜色预览区的刷新频率很高我做了两个优化一是色相环画布用了CustomPaint并启用了isComplex提示二是滑杆渐变色做预计算避免拖动时计算渐变数组。包体方面release 包在鸿蒙上大概是 65MB 左右对一个 Flutter 应用来说不算离谱但仍有优化空间。我做了两件事瘦身一是启用--tree-shake-icons并严格区分MaterialIcons和自定义 SVG 图标不用的字体图标不再编译进包里二是把样本颜色库从 Dart 静态常量改成首次启动时加载压缩 JSON这避免了大量硬编码数据撑起代码段。提示调色板应用本质上对图形渲染性能敏感不要只看模拟器表现务必借一台中低端鸿蒙真机验证。色相环拖动这种高频率手势操作在高端机上的帧率参考意义有限。做这个项目给我最大的体会是工具类 App 的工具二字不只体现在功能强弱上更体现在它是否真的顺着人的思维路径去设计选择器。颜色分类选择器看起来只是一个小小的交互组件但它背后牵扯到颜色科学、数据建模、平台适配三层问题。PaletteX 现在还谈不上完美至少它让我自己从和设计师来回拉扯的状态里解放了出来。下一步我准备把语义词典扩充到覆盖更细的中文颜色词汇再顺手把中国传统的四十八色做成一整套可浏览的分类色卡这个方向应该会让调色板应用更有自己的气质。
返回列表