HarmonyOS ArkTS 颜色提取器的色彩算法与 Builder 复用
引子:取色不难,配色才难
颜色提取器有两件事:一是让用户手动调 RGB 滑块选颜色,二是根据当前颜色自动生成配色方案。前者是交互问题,后者是算法问题。
手动调滑块谁都会做,但配色方案涉及色彩理论——互补色、类似色、三等分色,每个概念背后都是 HSL 色彩模型的数学计算。今天就从代码出发,拆解 RGB 和 HSL 的互转、配色方案的生成逻辑、@Builder 的组件复用。
完整效果


数据模型:三个 @State 管理颜色
@State red: number = 175;
@State green: number = 82;
@State blue: number = 222;
@State hex: string = '#AF52DE';
@State extractedColors: string[] = [];
@State harmonies: string[] = [];

为什么 RGB 和 hex 都要存
RGB 用于滑块显示(Slider 的值是 0-255),hex 用于显示和存储(用户看到的是 #AF52DE)。两者是同一个颜色的不同表示,需要同步更新。
extractedColors 和 harmonies 的区别
extractedColors:随机色盘生成的 5 个颜色harmonies:基于当前颜色的配色方案(互补、类似、三等分)
两者独立计算,互不影响。
RGB 转 hex:进制转换
updateHex(): void {
const r = this.red.toString(16).padStart(2, '0').toUpperCase();
const g = this.green.toString(16).padStart(2, '0').toUpperCase();
const b = this.blue.toString(16).padStart(2, '0').toUpperCase();
this.hex = '#' + r + g + b;
this.genHarmonies();
}
toString(16) 的作用
把数字转成 16 进制字符串。比如 175 → 'af',82 → '52',222 → 'de'。
padStart(2, ‘0’) 的作用
如果 16 进制只有一位(比如 10 → 'a'),前面补 0 变成 '0a'。确保每个颜色分量都是两位。
toUpperCase() 的作用
16 进制默认小写(af52de),转成大写(AF52DE)更符合设计规范。
为什么每次 updateHex 都要 genHarmonies
RGB 变了,hex 就变了,配色方案也要重新计算。保证颜色和配色方案始终同步。
RGB 转 HSL:色彩模型的转换
hexToHsl(hex: string): [number, number, number] {
const r = parseInt(hex.slice(1,3), 16) / 255;
const g = parseInt(hex.slice(3,5), 16) / 255;
const b = parseInt(hex.slice(5,7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
else if (max === g) h = ((b - r) / d + 2) * 60;
else h = ((r - g) / d + 4) * 60;
}
return [Math.round(h), Math.round(s * 100), Math.round(l * 100)];
}

什么是 HSL
HSL 是另一种表示颜色的方式:
- H(Hue,色相):0-360 度,表示颜色种类(0=红,120=绿,240=蓝)
- S(Saturation,饱和度):0-100%,表示颜色的鲜艳程度
- L(Lightness,亮度):0-100%,表示颜色的明暗程度
转换算法的原理
- 先把 RGB 归一化到 0-1 范围(除以 255)
- 找到最大值和最小值
- 亮度 L = (max + min) / 2
- 如果 max ≠ min(不是灰色),计算饱和度 S 和色相 H
色相的计算
色相根据最大值是 R、G 还是 B 来决定:
- 最大值是 R:H = ((G - B) / d) × 60
- 最大值是 G:H = ((B - R) / d + 2) × 60
- 最大值是 B:H = ((R - G) / d + 4) × 60
d 是最大值和最小值的差。如果 G < B,H 要加 360(确保 H 在 0-360 范围内)。
饱和度的计算
S = l > 0.5 ? d / (2 - max - min) : d / (max + min)
亮度高时(l > 0.5),用 d / (2 - max - min);亮度低时,用 d / (max + min)。这样能保证 S 始终在 0-1 范围内。
HSL 转 RGB:逆向转换
hslToHex(h: number, s: number, l: number): string {
s /= 100; l /= 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
return Math.round((l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))) * 255);
};
return '#' + [f(0), f(8), f(4)]
.map(v => v.toString(16).padStart(2, '0').toUpperCase())
.join('');
}

算法的核心
这个算法比 RGB→HSL 简洁得多。核心公式:
k = (n + h/30) % 12
RGB = l - a × max(-1, min(k-3, 9-k, 1))
其中 a = s × min(l, 1-l)。
为什么用 f(0)、f(8)、f(4)
这三个值分别对应 R、G、B 三个通道。通过不同的 n 值,让三个通道在色轮的不同位置取值,实现颜色的转换。
为什么不直接用 RGB 公式
标准的 RGB→HSL 转换需要 6 个 if-else 分支(根据色相所在的区间)。这个算法用一个公式覆盖了所有区间,代码更简洁。
配色方案:基于色轮的数学
genHarmonies(): void {
const hslArr = this.hexToHsl(this.hex);
const h = hslArr[0], s = hslArr[1], l = hslArr[2];
const harm: string[] = [];
harm.push(this.hslToHex((h + 180) % 360, s, l)); // 互补色
harm.push(this.hslToHex((h + 30) % 360, s, l)); // 类似色
harm.push(this.hslToHex((h + 60) % 360, s, l)); // 类似色+
harm.push(this.hslToHex((h + 120) % 360, s, l)); // 三等分
harm.push(this.hslToHex((h + 240) % 360, s, l)); // 三等分+
this.harmonies = harm;
}

五种配色方案的原理
| 方案 | 色相偏移 | 原理 |
|---|---|---|
| 互补色 | +180° | 色轮对面的颜色,对比最强 |
| 类似色 | +30° | 色轮相邻的颜色,和谐自然 |
| 类似色+ | +60° | 稍远的相邻色,有变化但不冲突 |
| 三等分 | +120° | 色轮三分之一处,平衡感强 |
| 三等分+ | +240° | 色轮三分之二处,和三等分对称 |
饱和度和亮度保持不变
所有配色方案都保持原色的 S 和 L 不变,只改变 H。这样生成的颜色在视觉上是"同一色调的不同方向",不会出现"一个鲜艳一个灰暗"的不协调。
取余 360 的作用
色相是 0-360 度的循环。偏移后可能超过 360(比如 h=350,偏移 30 度变成 380),取余后回到 20 度。
随机色盘:基于色相的随机生成
pickPalette(): void {
const baseH = Math.floor(Math.random() * 360);
const cols: string[] = [];
cols.push(this.hslToHex(baseH, 70, 55));
cols.push(this.hslToHex((baseH + 30) % 360, 60, 50));
cols.push(this.hslToHex((baseH + 120) % 360, 65, 55));
cols.push(this.hslToHex((baseH + 180) % 360, 55, 45));
cols.push(this.hslToHex((baseH + 240) % 360, 70, 50));
this.extractedColors = cols;
}
随机的策略
不是完全随机——先随机一个基础色相(0-360),然后基于这个色相生成 5 个颜色。5 个颜色的色相偏移分别是 0°、30°、120°、180°、240°,覆盖了色轮的主要区域。
饱和度和亮度的微调
每个颜色的 S 和 L 略有不同(比如 70/55、60/50、65/55),让色盘看起来更自然。如果所有颜色的 S 和 L 都一样,会显得单调。
点击色块的效果
.onClick(() => {
this.red = parseInt(c.slice(1,3), 16);
this.green = parseInt(c.slice(3,5), 16);
this.blue = parseInt(c.slice(5,7), 16);
this.updateHex();
})
点击随机色盘的颜色,会把 RGB 滑块设成对应值,用户可以在此基础上微调。
@Builder:可复用的滑块组件
@Builder
sliderRed() {
Row() {
Text('R').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#FF2D55').width(20)
Slider({ value: this.red, min: 0, max: 255, style: SliderStyle.InSet })
.blockColor('#FF2D55').trackColor('#2A2A3E').selectedColor('#FF2D55')
.onChange((v: number) => { this.red = v; this.updateHex(); })
.layoutWeight(1).margin({left:10,right:10})
Text(this.red.toFixed(0)).fontSize(12).fontColor('#FFFFFF').width(30).textAlign(TextAlign.End)
}.width('100%').margin({bottom:14})
}
@Builder 的作用
@Builder 定义了一个可复用的 UI 片段。三个滑块(R、G、B)的结构完全一样,只有颜色和绑定的变量不同。用 @Builder 避免了三份重复代码。
为什么不用 @Component
@Component 是独立的组件,有自己的生命周期和状态。滑块不需要独立的状态(RGB 值由父组件管理),用 @Builder 更轻量。
Slider 的样式
SliderStyle.InSet:滑块嵌入轨道内,更紧凑blockColor:滑块的颜色trackColor:轨道的颜色(深色背景)selectedColor:已选部分的颜色
三个滑块分别用红、绿、蓝色,视觉上直观。
配色方案的展示
ForEach(this.harmonies, (c: string, idx: number) => {
Row() {
Column()
.width(40).height(40).borderRadius(8).backgroundColor(c).margin({right:12})
Column() {
Text(this.harmonyLabel(idx)).fontSize(13).fontColor('#FFFFFF')
Text(c).fontSize(11).fontColor('#888')
}
Blank()
Button('用这个').fontSize(11).height(28).borderRadius(8)
.backgroundColor('#2A2A3E').fontColor('#AF52DE')
.onClick(() => { ... })
}.width('100%').padding({top:8,bottom:8})
.border({width:{bottom:0.5}, color:'#333'})
})
每行的结构
- 左边:40×40 的颜色方块
- 中间:配色方案名称 + hex 值
- 右边:"用这个"按钮
"用这个"按钮的功能
点击后把当前配色方案的颜色设为 RGB 值,用户可以在此基础上继续调整。
底部分割线
.border({width:{bottom:0.5}, color:'#333'}) 给每行加了 0.5px 的底部边框,作为行与行之间的分隔。
踩坑记录
坑 1:hexToHsl 的精度
RGB 转 HSL 再转回 RGB,可能有微小的精度误差。比如 (175, 82, 222) → HSL → hex 可能变成 #AF51DE 而不是 #AF52DE。这是因为浮点数计算的舍入误差。
坑 2:padStart 的类型
toString(16).padStart(2, '0') 必须先 toString(16) 再 padStart。如果顺序反了,padStart 会报错。
坑 3:Slider 的值类型
Slider 的 onChange 回调参数是 number,不是 string。如果用字符串接收会报错。
坑 4:@Builder 的 this 指向
@Builder 里的 this 指向父组件,可以直接访问 this.red、this.updateHex() 等。但 @Builder 不能有自己的 @State。
坑 5:ForEach 的 key
ForEach 遍历配色方案时,用索引 idx 作为隐式 key。如果数组顺序不变,不会有问题。但如果数组会动态增删,需要显式指定 key。
代码改进建议
1. 图片取色
当前只有手动调滑块和随机色盘。可以加图片取色功能——用户选择图片,点击某个像素获取颜色。
2. 颜色历史
保存最近使用的颜色,方便用户回溯。
3. 导出配色方案
把配色方案导出为 JSON 或图片,方便设计师使用。
4. 色盲友好
当前配色方案基于正常视觉。可以加色盲模拟功能,让色弱用户也能看清。
5. 渐变生成
根据两个颜色生成渐变色,用于 UI 设计。
总结
颜色提取器的核心是"色彩模型的转换"——RGB 用于显示和交互,HSL 用于计算配色方案。配色方案基于色轮的数学关系(互补、类似、三等分),保持饱和度和亮度不变,只改变色相。
适用边界:这个部分适合用作 ArkUI 颜色处理和 @Builder 复用的学习案例,涵盖了 RGB/HSL 互转、配色方案生成、Slider 组件、@Builder 用法、ForEach 渲染等核心知识点。但如果要上架应用商店,还需要补充图片取色、颜色历史、导出色板、色盲友好、渐变生成等内容。
更多推荐


所有评论(0)