【鸿蒙HarmonyOS Next App实战开发】开发一款精准图片取色器

背景与功能

在设计开发过程中,从图片中提取精确颜色值是一个常见需求。本文介绍如何在HarmonyOS中开发一款功能完整的图片取色器,支持:

  • 从相册选择任意图片
  • 移动十字准星精确定位像素点
  • 实时显示十六进制色值和RGB值
  • 一键复制颜色信息
  • 响应式布局适配不同设备

核心技术实现

1. 图片选择与处理

通过PhotoViewPicker选择系统图片,使用ImageKit创建PixelMap获取像素数据:

async selectImage() {
  const PhotoSelectOptions = new picker.PhotoSelectOptions();
  PhotoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
  
  const photoPicker = new picker.PhotoViewPicker();
  photoPicker.select(PhotoSelectOptions).then((result) => {
    const file = fs.openSync(result.photoUris[0], fs.OpenMode.READ_ONLY);
    const imageSource = image.createImageSource(file.fd);
    
    const decodingOptions: image.DecodingOptions = {
      editable: true,
      desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
    };
    
    imageSource.createPixelMap(decodingOptions, (err, pixelMap) => {
      if (!err) {
        this.imagePixelMap = pixelMap;
        const { width, height } = pixelMap.getImageInfoSync().size;
        this.imageWidth = width;
        this.imageHeight = height;
      }
    });
  });
}
2. 坐标转换与颜色计算

通过比例换算实现显示坐标与原始像素坐标的双向转换:

convertToImageCoordinates(displayX: number, displayY: number) {
  return {
    x: Math.floor((displayX - this.imageOffsetX) * 
                  (this.imageWidth / this.imageDisplayWidth)),
    y: Math.floor((displayY - this.imageOffsetY) * 
                  (this.imageHeight / this.imageDisplayHeight))
  };
}

像素数据读取采用RGBA_8888格式,高效准确:

getColorAtPosition(x: number, y: number) {
  const area: image.PositionArea = {
    pixels: new ArrayBuffer(4),
    region: { size: { height: 1, width: 1 }, x, y }
  };
  
  this.imagePixelMap.readPixels(area).then(() => {
    const [r, g, b] = new Uint8Array(area.pixels);
    this.selectedColor = `#${r.toString(16)}${g.toString(16)}${b.toString(16)}`;
    this.rgbColor = `RGB(${r}, ${g}, ${b})`;
  });
}
3. 手势交互实现

使用PanGesture实现流畅的十字准星拖动体验:

.gesture(
  PanGesture()
    .onActionStart(() => this.getColorAtPosition(...))
    .onActionUpdate((event) => {
      const clamped = this.clampCoordinates(...);
      this.crosshairX = clamped.x;
      this.crosshairY = clamped.y;
      this.getColorAtPosition(...);
    })
)
4. UI关键组件

通过组合布局实现专业取色界面:

Stack() {
  Image(this.imagePixelMap)
    .objectFit(ImageFit.Contain)
    .onAreaChange((_, newArea) => 
      this.calculateImageDisplaySize(newArea.width, newArea.height))
  
  // 十字准星组件
  Column() {
    Row() {
      Divider().width(100)  // 横线
      Divider().height(100) // 竖线
    }
  }.position({x: this.crosshairX, y: this.crosshairY})
}
5. 剪贴板功能

封装系统剪贴板操作:

function copyText(text: string) {
  const pasteboardData = pasteboard.createData(
    pasteboard.MIMETYPE_TEXT_PLAIN, text
  );
  pasteboard.getSystemPasteboard().setData(pasteboardData);
}

开发要点总结

  1. ​图片适配策略​
    通过objectFit: ImageFit.Contain+坐标转换算法,完美解决不同分辨率图片的显示适配问题

  2. ​性能优化实践​

  • 使用onAreaChange替代全局重绘
  • 像素读取使用1x1最小区域
  • 手势操作节流控制

  • 3.​​用户体验增强​
  • 十字准星阴影增强识别度
  • 复制成功toast反馈
  • 边界检测避免选取无效区域

效果演示

应用商店下载【图影工具箱】,点击取色器进入该功能。
(用户选择图片→移动十字准星→复制颜色值)

总结

本文详细讲解了在HarmonyOS中开发图片取色器的完整流程,涵盖了从图片选择、像素解析到手势交互等关键技术的实现方案。开发者可基于此扩展出更专业的色彩工具,为HarmonyOS生态贡献更多实用的视觉开发工具。

完整代码:

import { image } from '@kit.ImageKit';
import { fileIo as fs, picker } from '@kit.CoreFileKit';
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { promptAction } from '@kit.ArkUI';
import { TitleBar } from '../components/TitleBar';

interface GeneratedTypeLiteralInterface_1 {
  x: number;
  y: number;
}

interface GeneratedTypeLiteralInterface_2 {
  x: number;
  y: number;
}

@Extend(Row)
function settingsCard() {
  .width('100%')
  .padding(20)
  .borderRadius(16)
  .backgroundColor($r('sys.color.ohos_id_color_sub_background'))
  .shadow({
    radius: 8,
    color: '#1A000000',
    offsetX: 2,
    offsetY: 4
  })
}

// 定义方法
function copyText(text: string) {
  // 创建剪贴板内容对象
  const pasteboardData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
  // 获取系统剪贴板对象
  const systemPasteboard = pasteboard.getSystemPasteboard();
  systemPasteboard.setData(pasteboardData); // 将数据放入剪贴板
  systemPasteboard.getData().then((data) => { // 读取剪贴板内容
    if (data) {
      promptAction.showToast({ message: '复制成功' });
    } else {
      promptAction.showToast({ message: '复制失败' });
    }
  })
}

@Entry
@Component
struct ColorPickerPage {
  @State private imageWidth: number = 0;
  @State private imageHeight: number = 0;
  @State private crosshairX: number = 0;
  @State private crosshairY: number = 0;
  @State private lastCrosshairX: number = 0;
  @State private lastCrosshairY: number = 0;
  @State private selectedColor: string = '#FFFFFF';
  @State private rgbColor: string = 'RGB(255, 255, 255)';
  @State private imagePixelMap: PixelMap | null = null;
  @State private imageDisplayWidth: number = 0;
  @State private imageDisplayHeight: number = 0;
  @State private imageOffsetX: number = 0;
  @State private imageOffsetY: number = 0;
  lineLength: number = 100;
  pixelArray = new Uint8Array();
  r: number = 0;
  g: number = 0;
  b: number = 0;

  aboutToAppear() {
    // 加载示例图片
    // this.selectImage();
  }

  async selectImage() {
    try {
      let uris: Array = [];
      let PhotoSelectOptions = new picker.PhotoSelectOptions();
      PhotoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
      PhotoSelectOptions.maxSelectNumber = 1;
      let photoPicker = new picker.PhotoViewPicker();
      photoPicker.select(PhotoSelectOptions).then((PhotoSelectResult: photoAccessHelper.PhotoSelectResult) => {
        uris = PhotoSelectResult.photoUris;
        let file = fs.openSync(uris[0], fs.OpenMode.READ_ONLY);
        console.info('file fd: ' + file.fd);
        let buffer = new ArrayBuffer(4096);
        let readLen = fs.readSync(file.fd, buffer);
        console.info('readSync data to file succeed and buffer size is:' + readLen);
        const imageSource: image.ImageSource = image.createImageSource(file.fd);
        let decodingOptions: image.DecodingOptions = {
          editable: true,
          desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
        }
        imageSource.createPixelMap(decodingOptions, (err: BusinessError, pixelMap: image.PixelMap) => {
          if (err !== undefined) {
            console.error(`Failed to create pixelMap.code is ${err.code},message is ${err.message}`);
          } else {
            this.imagePixelMap = pixelMap;
            // 获取图片原始尺寸
            const imageInfo = pixelMap.getImageInfoSync();
            this.imageWidth = imageInfo.size.width;
            this.imageHeight = imageInfo.size.height;
            // 重置十字架位置
            this.crosshairX = 0;
            this.crosshairY = 0;
            this.lastCrosshairX = 0;
            this.lastCrosshairY = 0;
            console.info('Succeeded in creating pixelMap object.');
          }
          fs.closeSync(file);
        })
      }).catch((err: BusinessError) => {
        console.error(`Invoke photoPicker.select failed, code is ${err.code}, message is ${err.message}`);
      })
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      console.error('photoPicker failed with err: ' + JSON.stringify(err));
    }
  }

  // 计算图片在容器中的实际显示尺寸和偏移量
  calculateImageDisplaySize(containerWidth: number, containerHeight: number) {
    const imageRatio = this.imageWidth / this.imageHeight;
    const containerRatio = containerWidth / containerHeight;

    if (imageRatio > containerRatio) {
      // 图片更宽,以容器宽度为基准
      this.imageDisplayWidth = containerWidth;
      this.imageDisplayHeight = containerWidth / imageRatio;
      this.imageOffsetX = 0;
      this.imageOffsetY = (containerHeight - this.imageDisplayHeight) / 2;
    } else {
      // 图片更高,以容器高度为基准
      this.imageDisplayHeight = containerHeight;
      this.imageDisplayWidth = containerHeight * imageRatio;
      this.imageOffsetX = (containerWidth - this.imageDisplayWidth) / 2;
      this.imageOffsetY = 0;
    }

    // 初始化十字架位置到图片中心
    this.crosshairX = this.imageOffsetX + this.imageDisplayWidth / 2;
    this.crosshairY = this.imageOffsetY + this.imageDisplayHeight / 2;
    this.lastCrosshairX = this.crosshairX;
    this.lastCrosshairY = this.crosshairY;
  }

  // 将显示坐标转换为图片原始坐标
  convertToImageCoordinates(displayX: number, displayY: number): GeneratedTypeLiteralInterface_1 {
    const x = Math.floor((displayX - this.imageOffsetX) * (this.imageWidth / this.imageDisplayWidth));
    const y = Math.floor((displayY - this.imageOffsetY) * (this.imageHeight / this.imageDisplayHeight));
    return { x, y };
  }

  // 限制坐标在图片显示范围内
  clampCoordinates(x: number, y: number): GeneratedTypeLiteralInterface_2 {
    return {
      x: Math.max(this.imageOffsetX, Math.min(x, this.imageOffsetX + this.imageDisplayWidth)),
      y: Math.max(this.imageOffsetY, Math.min(y, this.imageOffsetY + this.imageDisplayHeight))
    };
  }

  getColorAtPosition(x: number, y: number) {
    if (!this.imagePixelMap) {
      return;
    }

    try {
      // 转换坐标
      const imageCoords = this.convertToImageCoordinates(x, y);

      // 确保坐标在图片范围内
      if (imageCoords.x < 0 || imageCoords.x >= this.imageWidth ||
        imageCoords.y < 0 || imageCoords.y >= this.imageHeight) {
        return;
      }

      const area: image.PositionArea = {
        pixels: new ArrayBuffer(4), // RGBA 格式,每个像素4字节
        offset: 0,
        stride: 4,
        region: {
          size: { height: 1, width: 1 },
          x: imageCoords.x,
          y: imageCoords.y
        }
      }

      this.imagePixelMap.readPixels(area).then(() => {
        this.pixelArray = new Uint8Array(area.pixels);
        this.r = this.pixelArray[2];
        this.g = this.pixelArray[1];
        this.b = this.pixelArray[0];
        console.log('xxx123 changeColor')
        this.selectedColor =
          `#${this.r.toString(16).padStart(2, '0')}${this.g.toString(16).padStart(2, '0')}${this.b.toString(16)
            .padStart(2, '0')}`;
        this.rgbColor = `RGB(${this.r}, ${this.g}, ${this.b})`;
      }).catch((error: BusinessError) => {
        console.error('Failed to read pixel data:', error);
      });
    } catch (error) {
      console.error('Failed to read pixel data:', error);
    }
  }

  build() {
    Column() {
      // 顶部标题区域
      TitleBar({
        title: '图片取色器'
      })

      if (!this.imagePixelMap) {
        // 选择图片按钮
        Button('选择图片')
          .width('90%')
          .height(50)
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .borderRadius(25)
          .margin({ top: 20, bottom: 10 })
          .onClick(() => {
            this.selectImage();
          })
      }
      // 图片显示区域
      Stack() {
        if (this.imagePixelMap) {
          Image(this.imagePixelMap)
            .width('100%')
            .height(400)
            .objectFit(ImageFit.Contain)
            .backgroundColor($r('app.color.index_tab_bar'))
            .onAreaChange((oldValue: Area, newValue: Area) => {
              this.calculateImageDisplaySize(newValue.width as number, newValue.height as number);
            })

          // 十字标线
          Column() {
            Row() {
              // 横线
              Divider()
                .width(this.lineLength)
                .height('2px')
                .offset({ x: -this.lineLength / 2, y: -this.lineLength / 2 })
                .backgroundColor($r('app.color.index_tab_bar'))
                .shadow({
                  radius: 2,
                  color: '#40000000',
                  offsetX: 0,
                  offsetY: 1
                })
              // 竖线
              Divider()
                .width('2px')
                .height(this.lineLength)
                .offset({ x: -this.lineLength, y: -this.lineLength / 2 })
                .backgroundColor($r('app.color.index_tab_bar'))
                .shadow({
                  radius: 2,
                  color: '#40000000',
                  offsetX: 0,
                  offsetY: 1
                })
            }
          }
          .position({ x: this.crosshairX, y: this.crosshairY })
        } else {
          // 未选择图片时的提示
          Column() {
            Image($r('app.media.select_empty'))
              .width(48)
              .height(48)
              .margin({ bottom: 16 })
            Text('请选择一张图片')
              .fontSize(18)
          }
          .width('100%')
          .height(300)
          .justifyContent(FlexAlign.Center)
          .onClick(async () => {
            await this.selectImage();
          })
        }
      }.gesture(
        PanGesture()
          .onActionStart((event: GestureEvent) => {
            this.crosshairX = this.lastCrosshairX;
            this.crosshairY = this.lastCrosshairY;
            this.getColorAtPosition(this.crosshairX, this.crosshairY);
          })
          .onActionUpdate((event: GestureEvent) => {
            const clampedCoords = this.clampCoordinates(
              this.lastCrosshairX + event.offsetX,
              this.lastCrosshairY + event.offsetY
            );
            this.crosshairX = clampedCoords.x;
            this.crosshairY = clampedCoords.y;
            this.getColorAtPosition(this.crosshairX, this.crosshairY);
          })
          .onActionEnd((event: GestureEvent) => {
            this.lastCrosshairX = this.crosshairX;
            this.lastCrosshairY = this.crosshairY;
          })
      )
      .width('100%')
      .height(400)
      .margin({ top: 10, bottom: 20 })

      // 颜色信息显示区域
      Column() {
        // 颜色预览和颜色值
        Row() {
          // 颜色预览区域 - 固定宽度
          Column() {
            Text('颜色预览')
              .fontSize(16)
              .fontColor('#666666')
              .margin({ bottom: 10 })
            Column()
              .width(60)
              .height(60)
              .backgroundColor(this.selectedColor)
              .borderRadius(8)
              .border({ width: 1, color: $r('app.color.border_color') })
          }
          .width(100) // 固定宽度
          .alignItems(HorizontalAlign.Center)
          .margin({ right: 20 })

          // 颜色值区域 - 自适应宽度
          Column() {
            Text('颜色值')
              .fontSize(16)
              .fontColor('#666666')
              .margin({ bottom: 10 })
            // Hex颜色值
            Row() {
              Text(this.selectedColor)
                .fontSize(18)
                .fontWeight(FontWeight.Medium)
                .layoutWeight(1)
              Button() {
                Image($r('app.media.copy'))
                  .width(20)
                  .height(20)
              }
              .width(36)
              .height(36)
              .borderRadius(18)
              .onClick(() => {
                copyText(this.selectedColor);
              })
            }
            .width('100%')
            .margin({ bottom: 12 })
            .padding({ left: 4, right: 4 })
            .backgroundColor($r('app.color.index_tab_bar'))
            .borderRadius(8)
            .height(48)
            .alignItems(VerticalAlign.Center)

            // RGB颜色值
            Row() {
              Text(this.rgbColor)
                .fontSize(16)
                .layoutWeight(1)
              Button() {
                Image($r('app.media.copy'))
                  .width(20)
                  .height(20)
              }
              .width(36)
              .height(36)
              .borderRadius(18)
              .onClick(() => {
                copyText(this.rgbColor);
              })
            }
            .width('100%')
            .padding({ left: 4, right: 4 })
            .backgroundColor($r('app.color.index_tab_bar'))
            .borderRadius(8)
            .height(48)
            .alignItems(VerticalAlign.Center)
          }
          .layoutWeight(1) // 自适应宽度
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%')
        .settingsCard()
        .justifyContent(FlexAlign.Start) // 改为左对齐
      }
      .width('100%')
      .padding({ left: 20, right: 20, bottom: 20 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.index_tab_bar'))
  }
} 

你可能感兴趣的:(鸿蒙应用开发,深度学习,人工智能)