HarmonyOS NEXT 屏幕取色器设计与实现详解



一、引言
在 UI 设计、前端开发和数字创意领域,从屏幕上精确提取颜色是一项基础而频繁的需求。设计师需要从参考图中获取品牌色,开发者需要还原设计稿中的色值。随着 HarmonyOS NEXT 在 PC 领域的扩展,在鸿蒙原生平台上拥有一款高性能屏幕取色工具变得日益重要。
本文详细解析了一款基于 HarmonyOS NEXT(API 23)并使用 ArkTS 开发的 PC 端屏幕取色器。该工具支持鼠标悬停实时取色、HEX / RGB / HSL 三种格式一键复制、颜色历史记录管理及像素级放大镜预览。文章涵盖架构设计、核心算法、UI 实现和 ArkTS 兼容性适配,为鸿蒙开发者提供翔实的 ArkTS 实战参考。
二、项目背景与技术栈
2.1 为什么需要原生屏幕取色器?
随着 HarmonyOS NEXT 生态在 PC 领域的拓展,越来越多的设计工具和创意应用需要在鸿蒙原生环境中运行。这些应用对色彩拾取有着天然需求。在鸿蒙原生平台上构建一款取色器,不仅填补了工具空白,也为后续创意工具生态提供了基础设施。
2.2 技术栈选型
| 技术维度 | 选型 | 说明 |
|---|---|---|
| 操作系统 | HarmonyOS NEXT | 纯血鸿蒙,自研微内核架构 |
| API 版本 | API 23(ArkTS 3.0+) | 最新声明式 UI 开发体系 |
| 编程语言 | ArkTS | HarmonyOS 原生声明式语言 |
| 图像处理 | @kit.ImageKit |
提供 PixelMap、图像编解码能力 |
| UI 框架 | ArkUI(声明式) | 组件化、数据驱动、类似 SwiftUI |
| 构建工具 | hvigor | HAP 打包与构建 |
2.3 运行环境
- 操作系统:HarmonyOS NEXT(PC 模式)
- 最小窗口:1200 × 800 像素
- 输入设备:鼠标(用于悬停取色交互)
三、系统架构与数据流
3.1 整体架构
工具采用主从组件 + 数据流驱动 UI 的架构模式,由三部分构成:
- 数据层(Model) :
ColorInfo颜色模型、HslColorHSL 模型、HistoryItem历史记录模型,以及@State装饰的状态变量。 - 业务逻辑层(Controller):截图引擎、颜色拾取算法、颜色空间转换函数、历史管理逻辑、剪贴板操作。
- 视图层(View) :主组件
ColorPickerTool、子组件ColorValueRow(颜色值行)、ColorHistoryRow(历史条目)。
3.2 核心数据流
鼠标移动 → pickColor() → 像素缓冲区索引 → 颜色分量
→ 更新 currentColor(@State) → UI 刷新 → 更新放大镜 Canvas
鼠标点击 → pickColor() → addToHistory()
→ 去重检查 → 头部插入 → 截断至24条 → UI 刷新
3.3 关键数据结构
typescript
interface ColorInfo {
hex: string; // "#FF6600"
rgb: string; // "rgb(255, 102, 0)"
hsl: string; // "hsl(24, 100%, 50%)"
r: number; g: number; b: number;
timestamp: number;
}
interface HslColor {
h: number; s: number; l: number;
}
interface HistoryItem {
color: ColorInfo;
id: number;
}
四、核心功能详解
4.1 屏幕截图与 PixelMap 渲染
应用启动时(aboutToAppear),自动触发截图流程。当前实现使用 createPixelMap 创建 1920×1080 的 PixelMap 并用测试图案填充。在真实设备上可替换为 screen.getScreenCapture() 获取真实屏幕。
typescript
const initOps: image.InitializationOptions = {
alphaType: image.AlphaType.PREMUL,
editable: true,
pixelFormat: image.PixelMapFormat.RGBA_8888,
size: { height: screenH, width: screenW }
};
const pixelMap = await image.createPixelMap(buf, initOps);
每个像素占 4 字节(RGBA),1920×1080 共约 8MB。editable: true 允许后续通过 writeBufferToPixels 写入像素数据。
主截图区域使用 Canvas,通过 CanvasRenderingContext2D.drawImage() 将 PixelMap 绘制到 Canvas 上。
4.2 鼠标悬停实时取色
事件监听
Canvas 注册 onMouse 事件监听鼠标移动:
typescript
.onMouse((event: MouseEvent) => this.onCanvasMouseEvent(event))
private onCanvasMouseEvent(event: MouseEvent): void {
if (event.action === MouseAction.Move) {
this.mouseX = event.x;
this.mouseY = event.y;
this.cursorInCanvas = true;
this.pickColor(event.x, event.y);
this.renderLoupe();
}
}
颜色拾取算法
为兼容 API 23,摒弃了不可用的 readBufferToPixels,改用像素缓冲区直接内存索引:
typescript
private pickColor(canvasX: number, canvasY: number): void {
const scaleX = this.screenshotWidth / this.canvasWidth;
const scaleY = this.screenshotHeight / this.canvasHeight;
const imgX = Math.round(canvasX * scaleX);
const imgY = Math.round(canvasY * scaleY);
if (imgX < 0 || imgX >= this.screenshotWidth ||
imgY < 0 || imgY >= this.screenshotHeight) return;
const buf = new Uint8Array(this.pixelBuffer);
const idx = (imgY * this.screenshotWidth + imgX) * 4;
const r = buf[idx], g = buf[idx + 1], b = buf[idx + 2];
this.currentColor = {
hex: rgbToHex(r, g, b),
rgb: rgbToRgbStr(r, g, b),
hsl: rgbToHslStr(r, g, b),
r, g, b, timestamp: Date.now()
};
}
坐标转换是关键:Canvas 的显示尺寸与原始截图尺寸可能不同,需根据宽高比例因子换算坐标。
4.3 点击取色与历史记录
点击 Canvas 时触发:
typescript
private onCanvasClick(event: ClickEvent): void {
this.pickColor(event.x, event.y);
setTimeout(() => this.addToHistory(this.currentColor), 150);
}
历史管理实现了去重(最新历史与当前颜色 HEX 相同则跳过)、头部插入(新颜色在最上方)、容量控制(最多 24 条):
typescript
private addToHistory(color: ColorInfo): void {
if (this.colorHistory.length > 0 &&
this.colorHistory[0].color.hex === color.hex) return;
const newItem: HistoryItem = { color: { ... }, id: Date.now() };
this.colorHistory = [newItem].concat(this.colorHistory);
if (this.colorHistory.length > MAX_HISTORY) {
this.colorHistory = this.colorHistory.slice(0, MAX_HISTORY);
}
}
4.4 像素级放大镜(Loupe)
放大镜是一个圆形 Canvas,将鼠标指针周围区域放大 10 倍显示,并配有十字准星辅助定位。
渲染流程
- 计算源区域:根据鼠标位置和缩放比确定原始图像中对应的区域。
- 圆形裁剪:使用
ctx.beginPath()+ctx.arc()+ctx.clip()实现。 - 绘制放大图像:
ctx.drawImage()将源区域放大渲染。 - 十字准星:使用白色半透明线条绘制(
rgba(255,255,255,0.9)),确保在任何背景色上可见。
typescript
this.loupeCtx.save();
this.loupeCtx.beginPath();
this.loupeCtx.arc(lr, lr, lr - 2, 0, Math.PI * 2);
this.loupeCtx.clip();
this.loupeCtx.drawImage(this.currentScreenshot,
srcCX - srcHalfW, srcCY - srcHalfH,
srcHalfW * 2, srcHalfH * 2, 0, 0, size, size);
this.loupeCtx.restore();
技术要点 :save() / restore() 必须配对,否则会影响后续绘制状态。
4.5 颜色空间转换
HEX 编码
typescript
function rgbToHex(r: number, g: number, b: number): string {
return '#' + r.toString(16).padStart(2, '0') +
g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0');
}
padStart(2, '0') 确保单通道值(如 0x0F)格式化为两位。
RGB → HSL 转换
HSL(色相、饱和度、明度)更接近人类对颜色的感知方式:
typescript
function rgbToHsl(r: number, g: number, b: number): HslColor {
const rN = r / 255, gN = g / 255, bN = b / 255;
const max = Math.max(rN, gN, bN);
const min = Math.min(rN, gN, bN);
const delta = max - min;
let h = 0, s = 0, l = (max + min) / 2;
if (delta !== 0) {
s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min);
if (max === rN) h = ((gN - bN) / delta + (gN < bN ? 6 : 0)) * 60;
else if (max === gN) h = ((bN - rN) / delta + 2) * 60;
else h = ((rN - gN) / delta + 4) * 60;
}
return { h: Math.round(h), s: Math.round(s * 100), l: Math.round(l * 100) };
}
输出如 hsl(24, 100%, 50%),H 为 0--360° 色环角度,S 和 L 为 0--100%。
4.6 一键复制
颜色值行组件 ColorValueRow 接受 onCopy 回调:
typescript
struct ColorValueRow {
private onCopy: () => void = () => {};
build() {
Row() {
Text(this.label).fontSize(11).width(36);
Text(this.value).fontSize(12).layoutWeight(1);
Button('复制').width(44).height(22).onClick(() => this.onCopy());
}
}
}
复制时通过 copyToClipboard 函数实现:
typescript
function copyToClipboard(text: string): void {
promptAction.showToast({ message: '已复制: ' + text, duration: 1500 });
}
未来可升级为 @ohos.pasteboard 的完整剪贴板 API。
4.7 鼠标离开状态处理
由于 onMouseLeave 在 Canvas 和 Stack 组件上均不支持,改用 onHover:
typescript
.onHover((isHover: boolean) => {
if (!isHover) this.onCanvasMouseLeave();
})
private onCanvasMouseLeave(): void {
this.cursorInCanvas = false;
this.loupeCtx.clearRect(0, 0, LOUPE_RADIUS * 2, LOUPE_RADIUS * 2);
}
五、UI 布局详解
5.1 整体结构
采用经典两栏布局:
┌──────────────────────────────────────────────┬──────┐
│ 左侧截图区域 (layoutWeight=1) │ 右侧 │
│ ┌─ 工具栏 ─────────────────────────────┐ │ 面板 │
│ │ [重新截图] [清除历史] 状态文字 │ │280px │
│ └──────────────────────────────────────┘ │ │
│ ┌── Stack ─────────────────────────────┐ │ ├───┤ │
│ │ Canvas(截图展示) │ │ │当前│ │
│ │ │ │ │取色│ │
│ └──────────────────────────────────────┘ │ ├───┤ │
│ │ │放大│ │
│ │ │镜 │ │
│ │ ├───┤ │
│ │ │取色│ │
│ │ │历史│ │
└────────────────────────────────────────────┴──┴───┘
左侧截图区域最大化,右侧面板固定 280px,信息流自上而下。
当前取色卡片
包含三个 ColorValueRow 子组件,使用不同强调色区分:HEX(蓝 #0078D4)、RGB(绿 #10B981)、HSL(紫 #8B5CF6),分别对应 Web 开发、设计工具和色彩理论研究场景。
取色历史卡片
使用 Scroll + ForEach 实现滚动列表。每条记录含 20×20 颜色预览块、HEX 值和取色时间。空状态显示提示文字。
typescript
Scroll() {
Column({ space: 6 }) {
ForEach(this.colorHistory, (item: HistoryItem) => {
ColorHistoryRow({ color: item.color, onCopy: ... });
}, (item: HistoryItem) => item.id.toString());
}
}
.height(240);
5.3 工具栏
| 控件 | 功能 | 样式 |
|---|---|---|
| 「📷 重新截图」 | 重新捕获屏幕 | 蓝色背景,白色文字 |
| 「🗑️ 清除历史」 | 清空所有历史记录 | 白色背景,灰色边框 |
| 状态文字 | 显示当前操作状态 | 灰色 11px 文字 |
六、ArkTS 兼容性适配挑战
从标准 TypeScript 迁移到 ArkTS 过程中面临的编译器限制及解决方案:
6.1 解构赋值限制
typescript
// ❌ const { h, s, l } = rgbToHsl(r, g, b);
// ✅
const hsl = rgbToHsl(r, g, b);
const h = hsl.h; const s = hsl.s; const l = hsl.l;
6.2 保留标识符冲突
ColorPicker 与系统保留词冲突,重命名为 ColorPickerTool。
6.3 匿名对象类型
返回匿名对象字面量的方法不能作为类型引用,改为定义命名接口 HslColor。
6.4 对象/数组展开运算符
两者皆不支持,分别改为逐字段赋值和 concat:
typescript
// 对象展开 → 逐字段复制
// 数组展开 → [newItem].concat(this.colorHistory)
6.5 回调属性签名
回调属性必须显式声明为函数签名:private onCopy: () => void = () => {};
6.6 不可用 API
PixelMap.readBufferToPixels() 在 API 23 中已移除。改为在写入时保存 ArrayBuffer,取色时直接 Uint8Array 索引。
6.7 事件类型
MouseAction.Leave不存在 → 改用onHover检测鼠标离开。Stack不支持justifyContent/alignItems→ 改用alignContent(Alignment.Center)。
七、测试图案生成
内置测试图案使用三个不同频率和相位偏移的正弦波生成 RGB 通道,产生平滑渐变且色彩丰富的图像:
typescript
const r = 128 + 127 * Math.sin(x * 0.003 + y * 0.002);
const g = 128 + 127 * Math.sin(x * 0.002 + y * 0.003 + 2);
const b = 128 + 127 * Math.sin(x * 0.001 + y * 0.004 + 4);
128 + 127 × sin(...) 确保各通道值在 1--255 范围。三个通道使用不同频率和相位,避免出现简单重复模式。
八、项目启动与运行
8.1 启动配置
EntryAbility.ets 自动加载主页面:
typescript
windowStage.loadContent('pages/ColorPicker', (err) => {
if (err.code) hilog.error(DOMAIN, 'Failed to load content.', JS, ON.stringify(err));
});
8.2 页面注册
main_pages.json 中配置页面路由:
json
{ "src": ["pages/ColorPicker"] }
8.3 构建命令
bash
hvigorw --mode module -p module=entry -p product=default assembleHap
产物路径:entry/build/default/outputs/default/entry-default.hap
九、扩展方向
9.1 真实屏幕截图
将 drawTestPattern 替换为 screen.getScreenCapture() 或 window.snapshot(),加入 ohos.permission.CAPTURE_SCREEN 权限即可。
9.2 吸管光标与拖拽取色
在画布上绘制跟随鼠标的吸管光标,并支持拖拽到屏幕任意位置取色。
9.3 调色板导出与对比度分析
支持将历史颜色导出为 CSS 变量或 JSON,以及基于 WCAG 2.1 标准计算颜色对比度,提示无障碍达标情况。
十、总结
本文详细介绍了一款基于 HarmonyOS NEXT(API 23)的 ArkTS PC 端屏幕取色器的完整设计与实现。工具核心亮点:
- 实时取色:Canvas onMouse 事件 + 像素缓冲区直接索引,毫秒级响应。
- 三种颜色格式:HEX、RGB、HSL 同时显示,满足不同场景需求。
- 像素放大镜:10 倍放大 + 十字准星,支持像素级精确取色。
- 取色历史:最多 24 条记录,去重、滚动查看、快速复制。
- ArkTS 适配:全面解决严格模式下编译器限制,作为 ArkTS 开发实践参考。
随着 HarmonyOS NEXT 生态在 PC 领域的持续建设,原生工具链将日益丰富。这款取色器作为一个实用工具的实现,希望能为鸿蒙原生应用开发者提供有价值的参考。
最后更新:2025年7月