HarmonyKit | 鸿蒙新特性应用:颜色转换 HEX↔RGB↔HSL 三色空间互转算法

HarmonyKit | 鸿蒙新特性应用:颜色转换 HEX↔RGB↔HSL 三色空间互转算法
引言
在开发者工具箱中,颜色格式转换是一个看似简单实则涉及较多数学细节的功能。前端开发者经常需要在 CSS 的 HEX 表示法、RGB 函数表示法和 HSL 函数表示法之间进行切换。HarmonyKit 项目中的颜色转换工具实现了这三种颜色空间的双向互转,底层依靠纯数学算法,不依赖任何第三方库。本文将从数据结构设计、算法推导到 UI 交互实现,完整解析该工具的开发全过程。
数据结构设计
颜色转换涉及三种表示法,我们定义了两个核心接口来描述中间数据:
export interface RGB {
r: number;
g: number;
b: number;
}
export interface HSL {
h: number;
s: number;
l: number;
}
RGB 接口的三个字段分别代表红、绿、蓝三个通道,取值范围均为 0-255 的整数。HSL 接口中,h 代表色相(Hue),取值范围 0-360 度;s 代表饱和度(Saturation),取值范围 0-100%;l 代表亮度(Lightness),取值范围也是 0-100%。
设计上不定义 HEX 的接口类型,因为 HEX 本质上就是一个字符串(如 #007aff),没有必要为单一的字符串字段创建接口。而 RGB 和 HSL 都需要在多个转换函数之间传递中间结果,定义接口可以让类型系统在编译期就发现字段拼写错误。
ColorUtils 工具类的整体设计
所有颜色转换逻辑封装在 ColorUtils 类中,全部为静态方法。选择静态方法而非实例方法的原因是:颜色转换属于无状态计算,输入确定则输出确定,不存在需要跨方法共享的中间状态。每个方法接受明确的输入参数并返回确定的结果,这是纯函数在面向对象语言中的自然表达。
export class ColorUtils {
static hexToRgb(hex: string): RGB | null { ... }
static rgbToHex(r: number, g: number, b: number): string { ... }
static rgbToHsl(r: number, g: number, b: number): HSL { ... }
static hslToRgb(h: number, s: number, l: number): RGB { ... }
}
四个方法构成了完整的转换矩阵:HEX ↔ RGB、RGB ↔ HSL,而 HEX ↔ HSL 的转换通过 RGB 作为中间桥梁来实现,不需要单独编写直接转换函数。这是经典的"中间表示法"设计模式——有 N 种格式时,只需维护 N 个到中间格式的转换器,而不是 N*(N-1) 个直接转换器。
HEX 到 RGB 的解析算法
hexToRgb 方法需要处理三种合法的 HEX 输入格式:#RGB(三位简写)、#RRGGBB(六位标准)、以及不带 # 前缀的裸字符串。实现中先统一去掉 # 前缀,然后处理三位简写形式:
static hexToRgb(hex: string): RGB | null {
let h = hex.replace('#', '');
if (h.length === 3) {
h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
}
if (h.length !== 6) return null;
let num = parseInt(h, 16);
if (isNaN(num)) return null;
return {
r: (num >> 16) & 0xff,
g: (num >> 8) & 0xff,
b: num & 0xff
};
}
三位简写的处理逻辑是将每个字符复制一次——例如 #abc 展开为 aabbcc,这符合 CSS 颜色规范的语义。接着用 parseInt(h, 16) 将 6 位十六进制字符串转换为整数,再利用位运算提取各通道值:
(num >> 16) & 0xff:右移 16 位取高 8 位,得到红色通道(num >> 8) & 0xff:右移 8 位取中间 8 位,得到绿色通道num & 0xff:取低 8 位,得到蓝色通道
位运算比字符串切片 substring(0,2) 加 parseInt 的方式更简洁高效,在循环或频繁调用场景下有明显的性能优势。这里还包含了多层防御:长度校验(非 6 位返回 null)、数值校验(NaN 返回 null),使得调用方可以安全地使用返回值。
RGB 到 HEX 的格式化算法
反向转换需要将三个 0-255 的整数格式化为 #RRGGBB 形式。关键步骤是将每个通道值转为两位十六进制字符串:
static rgbToHex(r: number, g: number, b: number): string {
let toHex = (n: number): string => {
let v = Math.max(0, Math.min(255, Math.round(n)));
return v.toString(16).padStart(2, '0');
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
}
内部函数 toHex 做了边界保护:Math.max(0, Math.min(255, Math.round(n))) 先将值钳制在 0-255 范围内并进行四舍五入,然后 toString(16) 转为十六进制,padStart(2, '0') 确保始终输出两位。比如 r=0 时输出 00 而非 0,保证 HEX 字符串始终是标准的 6 位格式。
最终字符串统一转为大写(toUpperCase()),因为 CSS 中推荐使用大写 HEX,且在视觉上更统一。同时这也意味着 hexToRgb 必须兼容大写输入——而 parseInt(h, 16) 天然支持大小写不敏感的十六进制解析,所以两个方向的兼容性得到了保证。
RGB 到 HSL:色彩空间的数学转换
RGB 到 HSL 的转换是最复杂的算法部分。首先将 0-255 的整数值归一化到 0-1 的浮点数空间:
static rgbToHsl(r: number, g: number, b: number): HSL {
let rd = r / 255, gd = g / 255, bd = b / 255;
let max = Math.max(rd, gd, bd), min = Math.min(rd, gd, bd);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
let d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case rd: h = ((gd - bd) / d + (gd < bd ? 6 : 0)) / 6; break;
case gd: h = ((bd - rd) / d + 2) / 6; break;
case bd: h = ((rd - gd) / d + 4) / 6; break;
}
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100)
};
}
亮度 L 的计算最简单:取 RGB 最大和最小值的平均值。当 max 等于 min 时,颜色为灰色(无饱和度),色相 h 无意义,直接保持默认值 0。
饱和度 S 的计算分两种情况:当亮度 L > 0.5 时,饱和度较高的颜色会使 max+min 接近 2,公式 d/(2-max-min) 放大了差异;当 L <= 0.5 时,使用 d/(max+min) 计算。这两种情况的区别源于 HSL 模型中饱和度定义的分段性质。
色相 H 的计算通过 switch 判断哪个通道是最大值,然后在色轮上定位。每个 case 计算出色相在 0-6 之间的值(对应六段色轮),然后除以 6 得到 0-1 之间的归一化值。特别注意红色通道中的 gd < bd ? 6 : 0 修正——当绿色的值小于蓝色时,需要加入 6 的偏移来保证色相在正确的位置。
最后将三个归一化值转换为最终输出:h 乘以 360 得到角度值,s 和 l 乘以 100 得到百分比值,全部四舍五入到整数。
HSL 到 RGB:逆变换的数学推导
从 HSL 回到 RGB 需要实现色相到 RGB 分量的映射函数:
static hslToRgb(h: number, s: number, l: number): RGB {
let hd = h / 360, sd = s / 100, ld = l / 100;
if (sd === 0) {
let v = Math.round(ld * 255);
return { r: v, g: v, b: v };
}
let q = ld < 0.5 ? ld * (1 + sd) : ld + sd - ld * sd;
let p = 2 * ld - q;
let hue2rgb = (t: number): number => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
return {
r: Math.round(hue2rgb(hd + 1 / 3) * 255),
g: Math.round(hue2rgb(hd) * 255),
b: Math.round(hue2rgb(hd - 1 / 3) * 255)
};
}
首先将输入参数归一化到 0-1 范围。饱和度为零时,颜色是灰色,三个通道取相同值。核心辅助函数 hue2rgb 接受一个 0-1 的色相偏移量 t,根据 t 落在六段色轮的哪一段,计算对应的 RGB 分量值。
p 和 q 是中间变量:q 代表亮度的临时最大值,p 代表临时最小值。当亮度 < 0.5 时 q = ld*(1+sd),否则 q = ld+sd-ld*sd,这两段线性插值保证了颜色从暗到亮的平滑过渡。
三个通道通过色相偏移来区分:红色偏移 +1/3,绿色无偏移,蓝色偏移 -1/3。因为色轮上 R、G、B 三等分 360 度,每个相差 120 度(即色轮的 1/3)。hue2rgb 内部的六段条件判断对应于将色轮六等分(每段 60 度),在每段内做线性插值。
ColorConverter 页面的交互设计
转换页面支持两种转换路径:HEX→RGB/HSL 和 RGB→HEX/HSL。界面使用 @State 管理多个状态变量:
@State hexInput: string = '#007aff';
@State rgbResult: string = '';
@State hslResult: string = '';
@State rgbInput: string = '';
@State hexResult: string = '';
@State hslResult2: string = '';
@State previewColor: string = '#007aff';
previewColor 是圆形颜色预览组件的填充色,它在两种转换路径中都会被更新,始终反映当前操作的颜色。初始化时设为 #007aff(iOS 风格的系统蓝色),并在 aboutToAppear() 中立即执行一次 HEX 转换来填充初始结果。
圆形预览使用 Circle 组件配合阴影效果:
Circle()
.width(36).height(36)
.fill(this.previewColor)
.shadow({ radius: 4, color: '#20000000', offsetX: 0, offsetY: 2 });
Circle() 是鸿蒙的圆形绘制组件,fill() 接受一个 ResourceColor 类型,直接传入 HEX 字符串即可。阴影参数中 #20000000 是带透明度的黑色——0x20 约 12.5% 的透明度,在白色背景上形成微妙的光影效果。
HEX 输入框的交互细节
HEX 输入采用了前缀分离的设计:前面固定显示 # 字符,输入框本身只接收 6 位十六进制字符串。这样用户无需手动输入 #,也避免了 # 被重复输入的问题:
Row() {
Text('#').fontSize(14).fontFamily('monospace').fontColor('#666');
TextInput({ text: this.hexInput.replace('#', ''), placeholder: '007aff' })
.fontSize(14).fontFamily('monospace').layoutWeight(1)
.backgroundColor('#ffffff').borderRadius(8).padding(10)
.onChange((v: string) => { this.hexInput = '#' + v; });
}
TextInput 的 text 属性绑定到 this.hexInput.replace('#', '') 而非直接绑定 this.hexInput,这样即使在 onChange 中自动拼接 # 前缀,也不会在输入框中显示重复的 #。onChange 回调中 this.hexInput = '#' + v 确保状态变量始终存储带 # 前缀的完整 HEX 值,供后续转换使用。
hexConvert 方法:验证与联动
HEX 转换的核心逻辑:
hexConvert() {
let rgb = ColorUtils.hexToRgb(this.hexInput);
if (!rgb) {
this.rgbResult = '无效的 HEX 颜色';
this.hslResult = '';
return;
}
let hsl = ColorUtils.rgbToHsl(rgb.r, rgb.g, rgb.b);
this.rgbResult = `${rgb.r}, ${rgb.g}, ${rgb.b}`;
this.hslResult = `${hsl.h}°, ${hsl.s}%, ${hsl.l}%`;
this.previewColor = ColorUtils.rgbToHex(rgb.r, rgb.g, rgb.b);
}
当 hexToRgb 返回 null 时,使用用户友好的错误文本代替技术细节。RGB 结果展示为逗号分隔的三元组,HSL 结果带有单位符号(度和百分号),方便直接复制到 CSS rgb() 和 hsl() 函数中使用。
关键的一行:this.previewColor = ColorUtils.rgbToHex(rgb.r, rgb.g, rgb.b)。这里做了一个"往返转换"——将解析出的 RGB 再转回 HEX,目的是规范化颜色值。例如用户输入 #abc(简写)或 #aabbcc(小写),往返转换后会变成 #AABBCC(标准大写六位格式),预览颜色和显示文字保持一致。
rgbConvert 方法:解析与容错
RGB 输入是一个自由文本字段,用户需要手动输入逗号分隔的三个数字。解析时需要处理各种不规范输入:
rgbConvert() {
let parts = this.rgbInput.split(',').map((s: string) => parseInt(s.trim()));
if (parts.length !== 3 || parts.some((n: number) => isNaN(n))) {
this.hexResult = '无效的 RGB 值,格式: 255, 128, 0';
this.hslResult2 = '';
return;
}
let r = parts[0], g = parts[1], b = parts[2];
this.hexResult = ColorUtils.rgbToHex(r, g, b);
let hsl = ColorUtils.rgbToHsl(r, g, b);
this.hslResult2 = `${hsl.h}°, ${hsl.s}%, ${hsl.l}%`;
this.previewColor = this.hexResult;
}
首先按逗号分割字符串,每个部分去空格后用 parseInt 解析。验证逻辑检查两点:分割后必须是恰好三个元素,且每个元素必须能解析为有效数字(isNaN 检查)。注意这里没有检查值是否在 0-255 范围——这个检查委托给了 ColorUtils.rgbToHex 中的 Math.max(0, Math.min(255, ...)),演示了"单一职责"原则:页面层负责格式验证,工具类负责值域钳制。
错误提示中包含了期望的格式示例(255, 128, 0),帮助用户快速纠正输入。这种设计在所有 HarmonyKit 工具中是一致的——错误信息不仅要告知失败,还要指导正确的操作方式。
条件渲染与结果展示
结果区域使用 if 条件渲染,只有在有转换结果时才显示:
if (this.rgbResult) {
Row() {
Text(`RGB: ${this.rgbResult}`).fontSize(13).fontFamily('monospace');
CopyButton({ text: `rgb(${this.rgbResult})` });
}
.width('100%').padding({ left: 16, right: 16, top: 8 });
Row() {
Text(`HSL: ${this.hslResult}`).fontSize(13).fontFamily('monospace');
CopyButton({ text: `hsl(${this.hslResult})` });
}
.width('100%').padding({ left: 16, right: 16, top: 4 });
}
CopyButton 的文本参数不只是结果字符串,而是完整的 CSS 函数写法——rgb(${this.rgbResult}) 和 hsl(${this.hslResult})。这样用户复制后可以直接粘贴到 CSS 代码中使用,无需手动添加 rgb() 或 hsl() 包裹。这种"复制即用"的设计理念贯穿整个 HarmonyKit 项目。
两种转换路径的独立性
页面中 HEX→RGB/HSL 和 RGB→HEX/HSL 是两条独立的转换管线,各自拥有独立的输入状态、输出状态和结果展示区域。虽然它们共享了 previewColor 状态(作为视觉反馈的统一出口),但各自的转换结果不会互相干扰。这种隔离设计意味着用户可以在两个方向同时看到不同的转换结果,例如上面是 #007aff 的 RGB/HSL 解析,下面是 255, 128, 0 的 HEX/HSL 结果。
在这里插入图片描述
然而,这种设计也引入了一个微妙的问题:previewColor 会被两次赋值覆盖。如果用户先转换了 HEX,圆形预览显示蓝色;再转换了 RGB,圆形预览就变成橙色。这符合"预览当前操作的颜色"的交互预期,但如果有用户期望预览始终保持某个颜色,则可能需要独立的锁定机制。当前简化版本满足大多数使用场景。
错误状态的 UI 处理
颜色转换工具中的错误处理分为两个维度。第一个是输入验证维度:hexToRgb 返回 null 时显示"无效的 HEX 颜色",RGB 解析失败时显示详细格式提示。第二个是空状态维度:当验证失败时,对应的次要结果字段(如 HEX 解析失败时的 HSL 结果)被设置为空字符串,利用条件渲染的 if 判断自然隐藏。
这种处理方式的好处是 UI 会"默默地"恢复到初始状态,不会残留上次成功的旧结果来造成困惑。但也意味着:如果用户将一个有效的 HEX 改为无效的 HEX 后点击转换,RGB 和 HSL 结果都会消失——这是一种明确的"此输入无效"的视觉信号。
颜色系统在工具集中的一致性
ColorUtils 中的 rgbToHex 方法被 ColorConverter 页面用来做颜色预览,但它也可以被项目的其他部分复用——例如,如果未来需要一个颜色主题编辑器或配色方案生成器,HEX↔RGB 的互转逻辑可以直接从 ColorUtils 调用。将颜色算法放在 utils 层而非页面层,是对未来扩展的预先投资。
在 HarmonyKit 的 10 个工具中,ColorConverter 属于"计算"类别,与时间戳转换、哈希计算、UUID 生成、进制转换归为一组。它的特点是:即时计算(同步操作)、数学密集型(辗转三种颜色空间的位运算与浮点运算)、结果可量化(数值型输出)。这些特性区别于编解码类工具的文本处理管道和文本类工具的统计聚合。
小结
颜色转换工具的实现展示了几个值得记取的工程实践:一是用接口定义中间数据结构,让类型系统帮你管理字段;二是用位运算代替字符串操作来提高十六进制解析的效率;三是将数学算法与 UI 状态管理解耦,工具类保持纯函数特性;四是"复制即用"的产品思维——输出的不仅是原始数据,而是可直接使用的代码片段。
从架构角度看,ColorUtils 作为工具类完全独立于 ArkUI 框架,可以在任何 JavaScript/TypeScript 运行时中使用(只要提供基本的 String、Math 和 parseInt 即可)。这种框架无关性正是 utils/ 目录的设计目标——将核心逻辑与呈现层分离,使代码既可单独测试,又能在不同 UI 框架间迁移。
更多推荐



所有评论(0)