阅读视图

发现新文章,点击刷新页面。

华为跑跑码特——迷你工具

最近,我积极参与了华为精心组织的跑跑码特比赛活动,在这个充满挑战与创新的舞台上,我对自己的元服务迷你工具进行了一系列重要的上架更新。目前,工具已具备十分实用的生成二维码功能;识别二维码功能同样强大,只需对准二维码轻轻一扫,便能快速获取其中信息;还有趣味十足的台词拼接功能。

不仅如此,我还在积极拓展工具的功能边界,图片拼接、长截图和图片马赛克功能正处于紧张的迭代进程中。然而,颇为遗憾的是,受限于元服务的 api,暂时无法将这些功能更新上线。但我仍迫不及待地想和大家详细介绍一番,让大家提前感受它们的魅力。

沉浸式屏幕

沉浸式屏幕是通过隐藏或淡化状态栏、导航栏等不必要界面元素,最大化利用屏幕空间,让用户专注于主要内容的显示模式。其原理是在软件层面处理系统界面元素,调整内容显示区域占满屏幕。广泛应用于视频播放、游戏、阅读等场景,可通过系统自带功能或应用自身设置开启。

代码

FullScreen.ets

import { window } from '@kit.ArkUI';
import { AREA_BOTTOM, AREA_TOP, ENTRY_ABILITY_CONTEXT } from '../constants/Index';

 class FullScreen {
  /**
   * 启动沉浸式
   */
  async enable() {
    // 读取存储起来 数据 上下文
    const ctx = AppStorage.get<Context>(ENTRY_ABILITY_CONTEXT)
    console.log("ctx",JSON.stringify(ctx))

    // 1 获取最新的窗口对象
    let windowClass = await window.getLastWindow(ctx!);
    // 2 设置沉浸式 全屏
    windowClass.setWindowLayoutFullScreen(true)
    //保证安全区域
    //顶部状态栏
    const area = windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM)
    AppStorage.setOrCreate(AREA_TOP, px2vp(area.topRect.height))
    //底部导航栏
    const area2 = windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR)
    AppStorage.setOrCreate(AREA_BOTTOM, px2vp(area2.bottomRect.height))
  }

  // 禁用沉浸式
  async disable() {
    const ctx = AppStorage.get<Context>(ENTRY_ABILITY_CONTEXT)
    // 1 获取最新的窗口对象
    let windowClass = await window.getLastWindow(ctx!);
    // 2 设置沉浸式 全屏
    windowClass.setWindowLayoutFullScreen(false)
  }
}

export const fullScreen = new FullScreen()

使用

EntryAbility.ets

image.png

效果

image.png

保存图片(不同于saveButton安全控件)

保存到沙箱

saveImage = async () => {

  const imagePacker = image.createImagePacker()

  const arrayBuffer = await imagePacker.packToData(this.saveImagePixel, { format: 'image/jpeg', quality: 98 })

  const ctx = getContext(this)

  const imagePath = ctx.cacheDir + '/' + Date.now() + '.jpeg'

  // openSync 获取路径文件的 权限
  const file = fileIo.openSync(imagePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE)
  // 开始写入!!
  fileIo.writeSync(file.fd, arrayBuffer)

  await this.save(imagePath)
  // 关闭权限
  fileIo.closeSync(file.fd)
}

保存到相册

async save(srcFileUri: string) {
  let context = getContext(this);
  let phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
  try {
    let srcFileUris: Array<string> = [
      srcFileUri
    ];
    // 指定待保存照片的创建选项,包括文件后缀和照片类型,标题和照片子类型可选
    let photoCreationConfigs: Array<photoAccessHelper.PhotoCreationConfig> = [
      {
        title: `${Date.now()}`, // 可选
        fileNameExtension: 'jpeg',
        photoType: photoAccessHelper.PhotoType.IMAGE,
        subtype: photoAccessHelper.PhotoSubtype.DEFAULT, // 可选
      }
    ];
    // 基于弹窗授权的方式获取媒体库的目标uri
    let desFileUris: Array<string> = await phAccessHelper.showAssetsCreationDialog(srcFileUris, photoCreationConfigs);
    // 将来源于应用沙箱的内容写入媒体库的目标uri
    let desFile: fileIo.File = await fileIo.open(desFileUris[0], fileIo.OpenMode.WRITE_ONLY);
    let srcFile: fileIo.File = await fileIo.open(srcFileUri, fileIo.OpenMode.READ_ONLY);
    await fileIo.copyFile(srcFile.fd, desFile.fd);
    fileIo.closeSync(srcFile);
    fileIo.closeSync(desFile);
    promptAction.showToast({ message: '下载成功' })
  } catch (err) {
    console.error(`failed to create asset by dialog successfully errCode is: ${err.code}, ${err.message}`);
  }
}

效果

image.png

二维码生成

我利用华为原生的QRCode组件来展示二维码,同时为了增加趣味性,我还写了一个函数来随机生成颜色来填充二维码的背景颜色。

generateSimilarColors()


function generateSimilarColors() {
  // 随机生成一个基础颜色
  const baseColor = Math.floor(Math.random() * 16777215).toString(16);
  const paddedBaseColor = baseColor.padStart(6, '0');

  // 为每个通道生成不同的偏移量,范围在 30 - 100 之间,保证有一定区分度
  const rOffset = Math.floor(Math.random() * 71) + 30;
  const gOffset = Math.floor(Math.random() * 71) + 30;
  const bOffset = Math.floor(Math.random() * 71) + 30;

  // 将基础颜色转换为 RGB 值
  const r = parseInt(paddedBaseColor.slice(0, 2), 16);
  const g = parseInt(paddedBaseColor.slice(2, 4), 16);
  const b = parseInt(paddedBaseColor.slice(4, 6), 16);

  // 应用偏移量生成相似颜色,确保值在 0 - 255 范围内
  const newR = Math.min(255, Math.max(0, r + (Math.random() > 0.5 ? rOffset : -rOffset)));
  const newG = Math.min(255, Math.max(0, g + (Math.random() > 0.5 ? gOffset : -gOffset)));
  const newB = Math.min(255, Math.max(0, b + (Math.random() > 0.5 ? bOffset : -bOffset)));

  // 将新的 RGB 值转换为十六进制字符串
  const similarColor = [newR, newG, newB].map(x => x.toString(16).padStart(2, '0')).join('');

  return [`#${paddedBaseColor}`, `#${similarColor}`];
}

二维码展示

@Builder
QrcodeSheet() {
  Column() {
    Row() {
      Text('点我来抽一下二维码的颜色吧')
        .fontSize(10)
        .fontWeight(FontWeight.Medium)
        .onClick(() => {
          this.colorBan = generateSimilarColors()
        })

      Text('保存')
        .fontColor('#00FFFF')
        .fontWeight(FontWeight.Bolder)
        .onClick(() => {
          this.save()
        })
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
    .padding({ right: 20, left: 20 })
    .margin({ bottom: 20 })

    QRCode(this.text)
      .width('70%')
      .aspectRatio(1)
      .color(this.colorBan[1])
      .backgroundColor(this.colorBan[0])
      .id('Qrcode')
      .margin({ top: 10 })
  }
  .padding({ top: 20 })
  .width('100%')
  .justifyContent(FlexAlign.Start)
  .alignItems(HorizontalAlign.Center)
  .height('100%')
}

效果

image.png

扫描二维码

在使用此功能时,要先检查设备是否设备'SystemCapability.Multimedia.Scan.ScanBarcode'能力

scanCode = async () => {
  if (canIUse('SystemCapability.Multimedia.Scan.ScanBarcode')) {
    const result = await scanBarcode.startScanForResult(getContext(this))
    if (result.originalValue) {
      this.str = result.originalValue
    }
  } else {
    promptAction.showToast({ message: '暂时不具备此能力' })
  }
}

台词拼接

之前网上比较流行的台词拼接在一起,如下图;我在鸿蒙里实现了这项技术;

image.png

拉起相册,获得像素

/**
 * 拉起相册  获取像素
 */
async getPhoto() {

  let PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions();

  PhotoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;

  PhotoSelectOptions.maxSelectNumber = 9;

  let photoPicker = new photoAccessHelper.PhotoViewPicker();

  const PhotoSelectResult = await photoPicker.select(PhotoSelectOptions);

  // `file://media/Photo/1/IMG_1725939294_000/screenshot_20240910_113314.jpg`
  // 假设这里只关心用户选择的第一张图片
  for (let i = 0; i < PhotoSelectResult.photoUris.length; i++) {
    const uri = PhotoSelectResult.photoUris[i];
    //   2 拷贝到沙箱目录 开始 😄

    const originFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY)

    // 获取原图imageSource
    const imageSource = image.createImageSource(originFile.fd);
    // TODO 知识点: 将图片设置为可编辑
    const decodingOptions: image.DecodingOptions = {
      editable: true,
      desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
    }
    // 创建PixelMap
    const pixelMapSrc = await imageSource.createPixelMap(decodingOptions);
    this.imageArr.push({ imagePixel: pixelMapSrc, id: Date.now(), imageSource })
  }

}

调整图片间的距离

/**
 * 图片列表
 */

Column() {
  List({ scroller: this.listScroller, }) {
    ForEach(this.imageArr, (item: ImageType, index: number,) => {
      ListItem() {
        if (index !== 0) {
          Image(item.imagePixel)
            .width('100%')
            .margin({ top: `-${this.jl * 2}` })
            .objectFit(ImageFit.Contain)
            .border({
              width: this.chooseIndex === this.imageArr.findIndex((item1: ImageType) => {
                return item1.id === item.id
              }) ? 1 : 0, color: this.chooseIndex === this.imageArr.findIndex((item1: ImageType) => {
                return item1.id === item.id
              }) ? Color.Blue : Color.White
            })
            .onClick(() => {
              const i = this.imageArr.findIndex((item1: ImageType) => {
                return item1.id === item.id
              })
              this.chooseIndex = i
              this.tuChuLiShow = true
              this.yAddress =
                this.listScroller.getItemRect(this.chooseIndex).y
              if (Math.abs(this.yAddress) > px2vp(this.heightPhone / 2)) {
                this.yAddress = px2vp(this.heightPhone) / 2
              }
            })
        } else {
          Image(item.imagePixel)
            .width('100%')
            .objectFit(ImageFit.Contain)
            .border({
              width: this.chooseIndex === this.imageArr.findIndex((item1: ImageType) => {
                return item1.id === item.id
              }) ? 1 : 0, color: this.chooseIndex === this.imageArr.findIndex((item1: ImageType) => {
                return item1.id === item.id
              }) ? Color.Blue : Color.White
            })
            .onComplete((event) => {
              this.maxHeight = event!.componentHeight / 4
            })
            .onClick(() => {
              const i = this.imageArr.findIndex((item1: ImageType) => {
                return item1.id === item.id
              })
              this.chooseIndex = i
              this.tuChuLiShow = true
              this.yAddress =
                this.listScroller.getItemRect(this.chooseIndex).y
              if (Math.abs(this.yAddress) > px2vp(this.heightPhone / 2)) {
                this.yAddress = px2vp(this.heightPhone) / 2
              }
            })
        }
      }
      .zIndex(this.imageArr.length - index)
    }, (item: ImageType, index: number) => item.id.toString())
  }
  .width('78.67%')
  .scrollBar(BarState.Off)
  .onDidScroll(() => {
    this.tuChuLiShow = false
  })
  .edgeEffect(EdgeEffect.None)
  .id('ImageTai')

}
.width('100%')
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.margin({ bottom: 100 })
.justifyContent(FlexAlign.Start)
.zIndex(1)
Row() {
  Text('调节位置:')
    .fontColor('#00FFFF')
    .fontWeight(FontWeight.Medium)
    .fontSize(13)
  Image($r('app.media.jian'))
    .width(22.2)
    .height(22.2)
    .margin({ left: 16, right: 19.5 })
    .onClick(() => {
      if (this.jl - 10 < 0) {
        this.jl = 0
        return
      }
      this.jl = this.jl - 10
    })
  Slider({ value: this.jl, max: this.maxHeight, min: 0 })
    .onChange((val) => {
      console.log(`当前  ${val}`)
      this.jl = val
    })
    .width(161)
    .height(6)
  Image($r('app.media.jia'))
    .width(22.2)
    .height(22.2)
    .margin({ left: 16, right: 19.5 })
    .onClick(() => {
      console.log(`最大${this.maxHeight}    当前${this.jl}`)
      if (this.jl + 10 > this.maxHeight) {
        this.jl = this.maxHeight
        return
      }
      this.jl = this.jl + 10
    })

}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 23, right: 23, top: 10 })

处理结果

这里利用组件截图来获取完成图片调整之后的结果,获取像素之后再利用上方介绍的方法保存到相册

const pixmap = await componentSnapshot.get('ImageTai')

日历

在其中我利用鸿蒙的第三方库来展示三种日历组件,一个支持三种模式的轻量级日历组件:

  • 支持常规日历,全月日期、切换月份、点击反馈、标注显示、controller控制、选中样式、国际化、深色模式
  • 支持超级迷你日历,全月日期、当前时间
  • 支持单周日历,当周日期、当前时间

安装

ohpm install @ohmos/calendar

常规日历

HmCalendar({
  color: '#00aa00',
  selectedDays: [
    { date: new Date(Date.now()).toLocaleDateString('en-US').replace(///g, '-'), text: '今日' },
  ]
})
  .borderRadius(8)
  .border({ width: 0.5, color: '#ededed' })
  .shadow({ color: '#ededed', radius: 16 })

image.png

迷你日历

HmCalendar({
  type: HmCalendarType.MONTH_DOT,
  gutter: 6,
  color: '#00aa00',
  selectedDays: [
    { date: dayjs().format('YYYY-MM-DD') },
  ]
})

image.png

单周日历

HmCalendar({
  type: HmCalendarType.WEEK,
  color: '#00aa00',
  selectedDays: [
    { date: dayjs().format('YYYY-MM-DD'), text: ' ' },
  ]
})
  .borderRadius(8)
  .border({ width: 0.5, color: '#ededed' })
  .shadow({ color: '#ededed', radius: 16 })

image.png

由于元服务的API限制,以上是我目前可以上架的功能,但是我研究了图片处理的其他功能,也分享给大家

图片马赛克功能

手势交互(打码)

在之前的文章中我也具体介绍过手势交互的用法:

当手势遇见鸿蒙,交互体验竟能如此惊艳(1)

当手势遇见鸿蒙,交互体验竟能如此惊艳(2)

Image(this.pixelMapSrc!.img).objectFit(ImageFit.Fill)
  .id('img_mosaic')
  .width('100%')
  .gesture(
    GestureGroup(GestureMode.Exclusive,
      PanGesture({ distance: 10 })
        .onActionStart((event: GestureEvent) => {
          this.saveHistoryPixel[this.historyIndex++] = this.pixelMapSrc!.img
          const finger: FingerInfo = event.fingerList[0];
          if (finger == undefined) {
            return;
          }
          this.startX = finger.localX;
          this.startY = finger.localY;
        })
        .onActionUpdate(async (event: GestureEvent) => {
          const finger: FingerInfo = event.fingerList[0];
          if (finger == undefined) {
            return;
          }
          this.endX = finger.localX;
          this.endY = finger.localY;
          // 执行马赛克任务
          await this.doMosaicTask(this.startX, this.startY, this.endX, this.endY);
          this.startX = this.endX;
          this.startY = this.endY;
        })
    )
  )

马赛克任务

/**
 * 图片马赛克处理函数
 */
@Concurrent
async function applyMosaic(dataArray: Uint8Array, imageWidth: number, imageHeight: number, blockSize: number,
  offMinX: number, offMinY: number, offMaxX: number, offMaxY: number): Promise<Uint8Array | undefined> {
  try {
    // 计算横排和纵排的块数
    let xBlocks = Math.floor((Math.abs(offMaxX - offMinX)) / blockSize);
    let yBlocks = Math.floor((Math.abs(offMaxY - offMinY)) / blockSize);
    // 不足一块的,按一块计算
    if (xBlocks < 1) {
      xBlocks = 1;
      offMaxX = offMinX + blockSize;
    }
    if (yBlocks < 1) {
      yBlocks = 1;
      offMaxY = offMinY + blockSize;
    }

    // 遍历每个块
    for (let y = 0; y < yBlocks; y++) {
      for (let x = 0; x < xBlocks; x++) {
        const startX = x * blockSize + offMinX;
        const startY = y * blockSize + offMinY;

        // 计算块内的平均颜色
        let totalR = 0;
        let totalG = 0;
        let totalB = 0;
        let pixelCount = 0;
        for (let iy = startY; iy < startY + blockSize && iy < imageHeight && iy < offMaxY; iy++) {
          for (let ix = startX; ix < startX + blockSize && ix < imageWidth && ix < offMaxX; ix++) {
            // TODO 知识点:像素点数据包括RGB通道的分量值及图片透明度
            const index = (iy * imageWidth + ix) * 4; // 4 像素点数据包括RGB通道的分量值及图片透明度
            totalR += dataArray[index];
            totalG += dataArray[index + 1];
            totalB += dataArray[index + 2];
            pixelCount++;
          }
        }
        const averageR = Math.floor(totalR / pixelCount);
        const averageG = Math.floor(totalG / pixelCount);
        const averageB = Math.floor(totalB / pixelCount);
        // TODO 知识点: 将块内平均颜色应用到块内的每个像素
        for (let iy = startY; iy < startY + blockSize && iy < imageHeight && iy < offMaxY; iy++) {
          for (let ix = startX; ix < startX + blockSize && ix < imageWidth && ix < offMaxX; ix++) {
            const index = (iy * imageWidth + ix) * 4; // 4 像素点数据包括RGB通道的分量值及图片透明度
            dataArray[index] = averageR;
            dataArray[index + 1] = averageG;
            dataArray[index + 2] = averageB;
          }
        }
      }
    }
    return dataArray;
  } catch (error) {
    promptAction.showToast({ message: '处理失败' })
    return undefined;
  }
}
/**
 * 执行马赛克任务
 */
async doMosaicTask(offMinX: number, offMinY: number, offMaxX: number, offMaxY: number): Promise<void> {
  // TODO 知识点:将手势移动的起始坐标转换为原始图片中的坐标
  offMinX = Math.round(offMinX * this.imageWidth / this.displayWidth);
  offMinY = Math.round(offMinY * this.imageHeight / this.displayHeight);
  offMaxX = Math.round(offMaxX * this.imageWidth / this.displayWidth);
  offMaxY = Math.round(offMaxY * this.imageHeight / this.displayHeight);
  // 处理起始坐标大于终点坐标的情况
  if (offMinX > offMaxX) {
    const temp = offMinX;
    offMinX = offMaxX;
    offMaxX = temp;
  }
  if (offMinY > offMaxY) {
    const temp = offMinY;
    offMinY = offMaxY;
    offMaxY = temp;
  }
  // 获取像素数据的字节数
  const bufferData = new ArrayBuffer(this.pixelMapSrc!.img!.getPixelBytesNumber());
  await this.pixelMapSrc!.img!.readPixelsToBuffer(bufferData);
  // 将像素数据转换为 Uint8Array 便于像素处理
  let dataArray = new Uint8Array(bufferData);
  // TODO: 性能知识点:使用new taskpool.Task()创建任务项,传入任务执行函数和所需参数
  const task: taskpool.Task =
    new taskpool.Task(applyMosaic, dataArray, this.imageWidth, this.imageHeight, this.MosaicSize,
      offMinX, offMinY, offMaxX, offMaxY);
  try {
    taskpool.execute(task, taskpool.Priority.HIGH).then(async (res: Object) => {
      this.pixelMapSrc!.img = image.createPixelMapSync((res as Uint8Array).buffer, this.opts);
      // this.pixelMapSrc!.img = await this.createPixelMapFromUint8Array(res as Uint8Array)
    })
  } catch (err) {
    console.error("doMosaicTask: execute fail, " + (err as BusinessError).toString());
  }
}

async createPixelMapFromUint8Array(uint8Array: Uint8Array) {
  const arrayBuffer = uint8Array.buffer;
  const imageSource = image.createImageSource(arrayBuffer);
  const pixelMap = await imageSource.createPixelMap(this.opts);
  return pixelMap;
}

完整代码

其中可调整马赛克的大小(有一些小细节可能需要小伙伴自己调整哦)

import { image } from '@kit.ImageKit';
import { componentSnapshot, display, promptAction } from '@kit.ArkUI';
import { taskpool } from '@kit.ArkTS';
import { BusinessError, emitter } from '@kit.BasicServicesKit';
import { resourceManager } from '@kit.LocalizationKit';
import { PixelType } from '../../models';
import { AREA_TOP } from '../../constants/Index';


@Builder
function image_mosaicBuilder() {
  image_mosaic()
}

/**
 * 获取图片内容
 */
@Concurrent
async function getImageContent(imgPath: string, context: Context): Promise<Uint8Array | undefined> {
  // 获取resourceManager资源管理
  const resourceMgr: resourceManager.ResourceManager = context.resourceManager;
  // 获取rawfile中的图片资源
  const fileData: Uint8Array = await resourceMgr.getRawFileContent(imgPath);
  return fileData;
}

/**
 * 图片马赛克处理函数
 */
@Concurrent
async function applyMosaic(dataArray: Uint8Array, imageWidth: number, imageHeight: number, blockSize: number,
  offMinX: number, offMinY: number, offMaxX: number, offMaxY: number): Promise<Uint8Array | undefined> {
  try {
    // 计算横排和纵排的块数
    let xBlocks = Math.floor((Math.abs(offMaxX - offMinX)) / blockSize);
    let yBlocks = Math.floor((Math.abs(offMaxY - offMinY)) / blockSize);
    // 不足一块的,按一块计算
    if (xBlocks < 1) {
      xBlocks = 1;
      offMaxX = offMinX + blockSize;
    }
    if (yBlocks < 1) {
      yBlocks = 1;
      offMaxY = offMinY + blockSize;
    }

    // 遍历每个块
    for (let y = 0; y < yBlocks; y++) {
      for (let x = 0; x < xBlocks; x++) {
        const startX = x * blockSize + offMinX;
        const startY = y * blockSize + offMinY;

        // 计算块内的平均颜色
        let totalR = 0;
        let totalG = 0;
        let totalB = 0;
        let pixelCount = 0;
        for (let iy = startY; iy < startY + blockSize && iy < imageHeight && iy < offMaxY; iy++) {
          for (let ix = startX; ix < startX + blockSize && ix < imageWidth && ix < offMaxX; ix++) {
            // TODO 知识点:像素点数据包括RGB通道的分量值及图片透明度
            const index = (iy * imageWidth + ix) * 4; // 4 像素点数据包括RGB通道的分量值及图片透明度
            totalR += dataArray[index];
            totalG += dataArray[index + 1];
            totalB += dataArray[index + 2];
            pixelCount++;
          }
        }
        const averageR = Math.floor(totalR / pixelCount);
        const averageG = Math.floor(totalG / pixelCount);
        const averageB = Math.floor(totalB / pixelCount);
        // TODO 知识点: 将块内平均颜色应用到块内的每个像素
        for (let iy = startY; iy < startY + blockSize && iy < imageHeight && iy < offMaxY; iy++) {
          for (let ix = startX; ix < startX + blockSize && ix < imageWidth && ix < offMaxX; ix++) {
            const index = (iy * imageWidth + ix) * 4; // 4 像素点数据包括RGB通道的分量值及图片透明度
            dataArray[index] = averageR;
            dataArray[index + 1] = averageG;
            dataArray[index + 2] = averageB;
          }
        }
      }
    }
    return dataArray;
  } catch (error) {
    promptAction.showToast({ message: '处理失败' })
    return undefined;
  }
}

@Component
export struct image_mosaic {
  @StorageProp(AREA_TOP)
  top: number = 0
  @State pixelMapSrc: PixelType | undefined = undefined
  private imageSource: image.ImageSource | undefined = undefined;
  private opts: image.InitializationOptions | undefined = undefined;
  private displayWidth: number = 0; // 图片显示的宽度
  @State displayHeight: number = 1; // 图片显示的高度,根据图片分辨率计算
  private imageWidth: number = 0; // 图片原始宽度
  private imageHeight: number = 0; // 图片原始高度
  // 手势移动位置
  private startX: number = 0;
  private startY: number = 0;
  private endX: number = 0;
  private endY: number = 0;
  @State MosaicSize: number = 50; // 马赛克大小
  @State maImg: PixelMap | undefined = undefined;
  @Consume pageStack: NavPathStack
  @State saveHistoryPixel: PixelMap[] = []
  @State historyIndex: number = 0

  /**
   * 执行马赛克任务
   */
  async doMosaicTask(offMinX: number, offMinY: number, offMaxX: number, offMaxY: number): Promise<void> {
    // TODO 知识点:将手势移动的起始坐标转换为原始图片中的坐标
    offMinX = Math.round(offMinX * this.imageWidth / this.displayWidth);
    offMinY = Math.round(offMinY * this.imageHeight / this.displayHeight);
    offMaxX = Math.round(offMaxX * this.imageWidth / this.displayWidth);
    offMaxY = Math.round(offMaxY * this.imageHeight / this.displayHeight);
    // 处理起始坐标大于终点坐标的情况
    if (offMinX > offMaxX) {
      const temp = offMinX;
      offMinX = offMaxX;
      offMaxX = temp;
    }
    if (offMinY > offMaxY) {
      const temp = offMinY;
      offMinY = offMaxY;
      offMaxY = temp;
    }
    // 获取像素数据的字节数
    const bufferData = new ArrayBuffer(this.pixelMapSrc!.img!.getPixelBytesNumber());
    await this.pixelMapSrc!.img!.readPixelsToBuffer(bufferData);
    // 将像素数据转换为 Uint8Array 便于像素处理
    let dataArray = new Uint8Array(bufferData);
    // TODO: 性能知识点:使用new taskpool.Task()创建任务项,传入任务执行函数和所需参数
    const task: taskpool.Task =
      new taskpool.Task(applyMosaic, dataArray, this.imageWidth, this.imageHeight, this.MosaicSize,
        offMinX, offMinY, offMaxX, offMaxY);
    try {
      taskpool.execute(task, taskpool.Priority.HIGH).then(async (res: Object) => {
        this.pixelMapSrc!.img = image.createPixelMapSync((res as Uint8Array).buffer, this.opts);
        // this.pixelMapSrc!.img = await this.createPixelMapFromUint8Array(res as Uint8Array)
      })
    } catch (err) {
      console.error("doMosaicTask: execute fail, " + (err as BusinessError).toString());
    }
  }

  async createPixelMapFromUint8Array(uint8Array: Uint8Array) {
    const arrayBuffer = uint8Array.buffer;
    const imageSource = image.createImageSource(arrayBuffer);
    const pixelMap = await imageSource.createPixelMap(this.opts);
    return pixelMap;
  }

  /**
   *保存像素
   */
  saveImage = async () => {
    //   1 组件截图
    const pixmap = await componentSnapshot.get('img_mosaic')
    this.maImg = pixmap
  }

  async aboutToAppear(): Promise<void> {
    this.pixelMapSrc = this.pageStack.getParamByName('image_mosaic').pop() as PixelType
    this.saveHistoryPixel[0] = this.pixelMapSrc.img
    // 获取图片参数
    this.opts = {
      editable: true,
      pixelFormat: this.pixelMapSrc!.img!.getImageInfoSync().pixelFormat,
      size: {
        height: this.pixelMapSrc!.img!.getImageInfoSync().size.height,
        width: this.pixelMapSrc!.img!.getImageInfoSync().size.width
      },
      srcPixelFormat: this.pixelMapSrc!.img!.getImageInfoSync().pixelFormat,
      alphaType: this.pixelMapSrc!.img!.getImageInfoSync().alphaType
    };

    // 读取图片信息
    const imageInfo: image.ImageInfo = await this.pixelMapSrc!.img!.getImageInfo();
    // 获取图片的宽度和高度
    this.imageWidth = imageInfo.size.width;
    this.imageHeight = imageInfo.size.height;
    // 获取屏幕尺寸
    const displayData: display.Display = display.getDefaultDisplaySync();
    // 计算图片的显示尺寸
    this.displayWidth = px2vp(displayData.width);
    this.displayHeight = this.displayWidth * this.imageHeight / this.imageWidth;
  }

  build() {
    NavDestination() {
      Column() {
        /**
         * 标题行
         */
        Row() {
          Image($r('app.media.back'))
            .width(16)
            .height(16)
            .position({ x: 15 })
            .onClick(() => {
              this.pixelMapSrc!.img = this.saveHistoryPixel[0]
              this.pageStack.pop()
            })
          Text('打码')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
        .padding({ top: 16.5 })
        .margin({ bottom: 30 })

        List() {
          ListItem() {
            Image(this.pixelMapSrc!.img).objectFit(ImageFit.Fill)
              .id('img_mosaic')
              .width('100%')
              .gesture(
                GestureGroup(GestureMode.Exclusive,
                  PanGesture({ distance: 10 })
                    .onActionStart((event: GestureEvent) => {
                      this.saveHistoryPixel[this.historyIndex++] = this.pixelMapSrc!.img
                      const finger: FingerInfo = event.fingerList[0];
                      if (finger == undefined) {
                        return;
                      }
                      this.startX = finger.localX;
                      this.startY = finger.localY;
                    })
                    .onActionUpdate(async (event: GestureEvent) => {
                      const finger: FingerInfo = event.fingerList[0];
                      if (finger == undefined) {
                        return;
                      }
                      this.endX = finger.localX;
                      this.endY = finger.localY;
                      // 执行马赛克任务
                      await this.doMosaicTask(this.startX, this.startY, this.endX, this.endY);
                      this.startX = this.endX;
                      this.startY = this.endY;
                    })
                )
              )
          }
        }
        .width('100%')
        .layoutWeight(1)
        .alignListItem(ListItemAlign.Center)

        Column() {
          /**
           * 控制马赛克
           */
          Row() {
            Text('马赛克大小:')
              .fontColor('#7B8695')
              .fontWeight(FontWeight.Medium)
              .fontSize(13)
            Image($r('app.media.jian'))
              .width(22.2)
              .height(22.2)
              .margin({ left: 16, right: 19.5 })
              .onClick(() => {
                if (this.MosaicSize <= 0) {
                  return
                }
                this.MosaicSize = this.MosaicSize - 10
              })
            Slider({ value: this.MosaicSize, max: 100, min: 0 })
              .onChange((val) => {
                this.MosaicSize = val
              })
              .width(161)
              .height(6)
            Image($r('app.media.jia'))
              .width(22.2)
              .height(22.2)
              .margin({ left: 16, right: 19.5 })
              .onClick(() => {
                if (this.MosaicSize >= 100) {
                  return
                }
                this.MosaicSize = this.MosaicSize + 10
              })
          }
          .width('100%')
          .padding({ left: 15, top: 10 })

          /**
           *打码时的操作
           */
          Row() {
            this.DiItemBuilder($r('app.media.fanhui'), '返回', () => {
              this.pixelMapSrc!.img = this.saveHistoryPixel[0]
              emitter.emit(`gengxinPixel${this.pixelMapSrc!.id}`, { data: { 'newPixel': this.pixelMapSrc!.img } })
              this.pageStack.pop()
            })
            this.DiItemBuilder($r('app.media.shuaxin'), '撤销', () => {
              this.historyIndex--
              if (this.historyIndex <= 0) {
                this.historyIndex = 0
              }
              this.pixelMapSrc!.img = this.saveHistoryPixel[this.historyIndex]
            })
            this.DiItemBuilder($r('app.media.wancheng'), '完成', () => {
              emitter.emit(`gengxinPixelMo${this.pixelMapSrc!.id}`, { data: { 'newPixel': this.pixelMapSrc!.img } })
              this.pageStack.pop()
            })
          }
          .width('100%')
          .layoutWeight(1)
          .justifyContent(FlexAlign.SpaceAround)
        }
        .width('100%')
        .height(115)
      }
      .width('100%')
      .height('100%')
      .padding({ top: this.top })
    }
    .hideTitleBar(true)
    .onBackPressed(() => {
      this.pixelMapSrc!.img = this.saveHistoryPixel[0]
      this.pageStack.pop()
      return true
    })
  }

  @Builder
  DiItemBuilder(icon: Resource, title: string, doSome?: () => void) {
    Column({ space: 9.5 }) {
      Image(icon)
        .width(23)
        .height(23)
        .objectFit(ImageFit.Contain)
      Text(title)
        .fontColor('#7B8695')
        .fontSize(13)
        .fontWeight(FontWeight.Medium)
    }
    .onClick(doSome)
  }
}

图片裁剪

安装

ohpm install @candies/image_cropper

参数

参数 类型 描述
image image.ImageSource 需要裁剪图片的数据源
config image_cropper.ImageCropperConfig 裁剪的一些参数设置
import * as image_cropper from "@candies/image_cropper";
import { ImageCropper } from "@candies/image_cropper";
import { image } from '@kit.ImageKit';

@Entry
@Component
struct Index {
  @State image: image.ImageSource | undefined = undefined;
  private controller: image_cropper.ImageCropperController = new image_cropper.ImageCropperController();
  @State config: image_cropper.ImageCropperConfig = new image_cropper.ImageCropperConfig(
    {
      maxScale: 8,
      cropRectPadding: image_cropper.geometry.EdgeInsets.all(20),
      controller: this.controller,
      initCropRectType: image_cropper.InitCropRectType.imageRect,
      cropAspectRatio: image_cropper.CropAspectRatios.custom,
    }
  );

  build() {
    Column() {
      if (this.image != undefined) {
        ImageCropper(
          {
            image: this.image,
            config: this.config,
          }
        )
      }
    }
  }
}
裁剪参数
参数 描述 默认
maxScale 最大的缩放倍数 5.0
cropRectPadding 裁剪框跟图片 layout 区域之间的距离。最好是保持一定距离,不然裁剪框边界很难进行拖拽 EdgeInsets.all(20.0)
cornerSize 裁剪框四角图形的大小 Size(30.0, 5.0)
cornerColor 裁剪框四角图形的颜色 'sys.color.brand'
lineColor 裁剪框线的颜色 'sys.color.background_primary' 透明度 0.7
lineHeight 裁剪框线的高度 0.6
editorMaskColorHandler 蒙层的颜色回调,你可以根据是否手指按下来设置不同的蒙层颜色 'sys.color.background_primary' 如果按下透明度 0.4 否则透明度 0.8
hitTestSize 裁剪框四角以及边线能够拖拽的区域的大小 20.0
cropRectAutoCenterAnimationDuration 当裁剪框拖拽变化结束之后,自动适应到中间的动画的时长 200 milliseconds
cropAspectRatio 裁剪框的宽高比 null(无宽高比)
initialCropAspectRatio 初始化的裁剪框的宽高比 null(custom: 填充满图片原始宽高比)
initCropRectType 剪切框的初始化类型(根据图片初始化区域或者图片的 layout 区域) imageRect
controller 提供旋转,翻转,撤销,重做,重置,重新设置裁剪比例,获取裁剪之后图片数据等操作 null
actionDetailsIsChanged 裁剪操作变化的时候回调 null
speed 缩放平移图片的速度 1
cropLayerPainter 用于绘制裁剪框图层 ImageCropperLayerPainter

裁剪框的宽高比

这是一个 number | null 类型,你可以自定义裁剪框的宽高比。 如果为 null,那就没有宽高比限制。 如果小于等于 0,宽高比等于图片的宽高比。 下面是一些定义好了的宽高比

export class CropAspectRatios {
  /// No aspect ratio for crop; free-form cropping is allowed.
  static custom: number | null = null;
  /// The same as the original aspect ratio of the image.
  /// if it's equal or less than 0, it will be treated as original.
  static original: number = 0.0;
  /// Aspect ratio of 1:1 (square).
  static ratio1_1: number = 1.0;
  /// Aspect ratio of 3:4 (portrait).
  static ratio3_4: number = 3.0 / 4.0;
  /// Aspect ratio of 4:3 (landscape).
  static ratio4_3: number = 4.0 / 3.0;
  /// Aspect ratio of 9:16 (portrait).
  static ratio9_16: number = 9.0 / 16.0;
  /// Aspect ratio of 16:9 (landscape).
  static ratio16_9: number = 16.0 / 9.0;
}

裁剪图层 Painter

你现在可以通过覆写 [ImageCropperConfig.cropLayerPainter] 里面的方法来自定裁剪图层.

export class ImageCropperLayerPainter {
  /// Paint the entire crop layer, including mask, lines, and corners
  /// The rect may be bigger than size when we rotate crop rect.
  /// Adjust the rect to ensure the mask covers the whole area after rotation
  public  paint(
    config: ImageCropperLayerPainterConfig
  ): void {

    // Draw the mask layer
    this.paintMask(config);

    // Draw the grid lines
    this.paintLines(config);

    // Draw the corners of the crop area
    this.paintCorners(config);
  }

  /// Draw corners of the crop area
  protected paintCorners(config: ImageCropperLayerPainterConfig): void {

  }

  /// Draw the mask over the crop area
  protected paintMask(config: ImageCropperLayerPainterConfig): void {
 
  }

  /// Draw grid lines inside the crop area
  protected paintLines(config: ImageCropperLayerPainterConfig): void {

  }
}

裁剪控制器

ImageCropperController 提供旋转,翻转,撤销,重做,重置,重新设置裁剪比例,获取裁剪之后图片数据等操作。

翻转
参数 描述 默认
animation 是否开启动画 false
duration 动画时长 200 milliseconds
  export interface FlipOptions {
    animation?: boolean,
    duration?: number,
  }

  flip(options?: FlipOptions)

  controller.flip();
旋转
参数 描述 默认
animation 是否开启动画 false
duration 动画时长 200 milliseconds
degree 旋转角度 90
rotateCropRect 是否旋转裁剪框 true

rotateCropRecttrue 并且 degree90 度时,裁剪框也会跟着旋转。

   export interface RotateOptions {
     degree?: number,
     animation?: boolean,
     duration?: number,
     rotateCropRect?: boolean,
   }

   rotate(options?: RotateOptions)

   controller.rotate();
重新设置裁剪比例

重新设置裁剪框的宽高比

   controller.updateCropAspectRatio(CropAspectRatios.ratio4_3);
撤消

撤销上一步操作

  // 判断是否能撤销
  bool canUndo = controller.canUndo;
  // 撤销
  controller.undo();
重做

重做下一步操作

  // 判断是否能重做
  bool canRedo = controller.canRedo;
  // 重做
  controller.redo();
重置

重置所有操作

   controller.reset();
历史
   // 获取当前是第几个操作
   controller.getCurrentIndex();
   // 获取操作历史
   controller.getHistory();
   // 保存当前状态
   controller.saveCurrentState();
   // 获取当前操作对应的配置
   controller.getCurrentConfig();         
裁剪数据

获取裁剪之后的图片数据, 返回一个 PixelMap 对象

   controller.getCroppedImage();   
获取状态信息
  • 获取当前裁剪信息
   controller.getActionDetails();
  • 获取当前旋转角度
   controller.rotateDegrees;
  • 获取当前裁剪框的宽高比
   controller.cropAspectRatio;
  • 获取初始化设置的裁剪框的宽高比
   controller.originalCropAspectRatio;
  • 获取初始化设置的裁剪框的宽高比
   controller.originalCropAspectRatio;

图片长截图

在之前我们的截图中,我们都是利用componentSnapshot.get对组件进行截图,但是如果组件的长度超过了屏幕的长度,那么这种方式就无法获得完整的结果。研究另一种截图的方式——长截图

代码

参数部分

//屏幕设备高度
heightPhone: number = 0
@State yAddress: number = 0
// list长截图尺寸
@State snapWidth: number = 0;
@State snapHeight: number = 0;
// List组件尺寸 单位: vp
private listComponentWidth: number = 0;
private listComponentHeight: number = 0;
// List组件尺寸 单位: px
private listWidth: number = 0;
private listHeight: number = 0;
// 当前List组件位置
private curXOffset: number = 0;
private curYOffset: number = 0;
// 屏幕尺寸
densityPixels: number = 0; //屏幕像素密度 px = density * vp;
private displayWidth: number = 0;
private displayHeight: number = 0;
// 备份截图前组件位置
private xOffsetBefore: number = 0;
private yOffsetBefore: number = 0;
// 拼接后图片
@State mergedImage: PixelMap | undefined = undefined;
// 待拼接图片
tempPixelMap: PixelMap | undefined = undefined;
// 截图过程中组件覆盖
@State componentMaskImage: PixelMap | undefined = undefined;
private componentMaskImageZIndex: number = -1;
@State snapPopupPosition: Position = { x: 0, y: 0 };
// 是否显示预览窗口
@State isShowSnapPopup: boolean = false;
@State showPreview: boolean = false;
// 指定区域内图片的数据跨距
private stride: number = 0
// 滚动组件
scroller: Scroller = new Scroller();
// 一键截图滚动过程缓存
private areaArray: image.PositionArea[] = [];
// 一键截图滚动过程中每页List组件y方向偏移量
private scrollOffsets: number[] = [];
aboutToAppear() {
  const displayClass = display.getDefaultDisplaySync();
  this.heightPhone = displayClass.height
  // 获取屏幕尺寸
  const displayData = display.getDefaultDisplaySync();
  this.densityPixels = displayData.densityPixels;
  this.displayWidth = px2vp(displayData.width);
  this.displayHeight = px2vp(displayData.height);
}

图片展示部分

List({ scroller: this.scroller, })
{
//这里放需要截图的图片
}
.width('100%')
.scrollBar(BarState.Off)
.onDidScroll(() => {
  this.curYOffset = this.scroller.currentOffset().yOffset;
})
.edgeEffect(EdgeEffect.None)
.id('List_Page')
.onAreaChange((oldValue, newValue) => {
  // TODO: 高性能知识点: onAreaChange为高频回调,组件变动时每帧都会调用,避免冗余和耗时操作。
  this.listComponentWidth = newValue.width as number; // 单位: vp
  this.listComponentHeight = newValue.height as number; // 单位: vp
  // 初始化长截图宽高
  this.snapWidth = this.listComponentWidth;
  this.snapHeight = this.listComponentHeight
})

截图函数

/**
 * 一键截图。
 */
async snapShot() {
  // 截图前状态初始化
  await this.beforeSnapshot();
  // 执行循环滚动截图
  await this.getPixelMapData();
  // 拼接之后修改可动画变量
  await this.afterGeneratorImage();
}
/** ts
 * 截图开始前的操作。
 * - 保存滚动组件当前位置,用于恢复状态
 * - 截图当前页面作为遮罩层,避免用户察觉组件的滚动,提高用户体验
 * - 滚动组件页面滚动到顶部,准备开始截图
 * - 设置截图后小弹窗的位置,提示用户暂时不要操作,等待截图
 * - 开启提示小弹窗
 */
async beforeSnapshot() {
  // 保存组件当前位置,用于恢复
  this.xOffsetBefore = this.curXOffset;
  this.yOffsetBefore = this.curYOffset;
  this.snapHeight = this.curYOffset + Math.ceil(this.listComponentHeight);

  // TODO: 知识点: 使用componentSnapshot.get接口直接获取组件的渲染结果,而不需要将屏幕截图
  // this.componentMaskImage = await componentSnapshot.get(Constants.COMPONENT_ID);
  // this.componentMaskImageZIndex = Constants.MASK_TOP_LAYER;
  this.scroller.scrollTo({ xOffset: 0, yOffset: 0 });
  // 开启提示弹窗
  this.isShowSnapPopup = true;
  // 延时确保已经滚动到了顶部
  await sleep(200);
}
/**
 * 一键截图循环滚动截图。
 */
async getPixelMapData() {
  // 记录滚动量数组
  this.scrollOffsets.push(this.scroller.currentOffset().yOffset);
  // 调用组件截图接口获取当前截图
  componentSnapshot.get('List_Page', async (error: Error, pixmap: PixelMap) => {
    if (this.listWidth === 0) {
      let imageInfo = pixmap.getImageInfoSync();
      this.listWidth = imageInfo.size.width;
      this.listHeight = imageInfo.size.height;
      this.stride = pixmap.getBytesNumberPerRow();
    }
    let bytesNumber = pixmap.getPixelBytesNumber();
    let buffer: ArrayBuffer = new ArrayBuffer(bytesNumber);
    let area: image.PositionArea = {
      pixels: buffer,
      offset: 0,
      stride: this.stride,
      region: { size: { width: this.listWidth, height: this.listHeight }, x: 0, y: 0 }
    }
    // TODO: 知识点: readPixels、readPixelsSync均使用BGRA_8888像素格式,需搭配writePixels、writePixelsSync使用。
    pixmap.readPixelsSync(area);
    this.areaArray.push(area);

    // 循环过程中判断是否到达底部
    if (!this.scroller.isAtEnd()) {
      this.scroller.scrollPage({ next: true });
      await sleep(200);
      await this.getPixelMapData();
    } else { // 滚动到底部后,通过每轮滚动获取的buffer,拼接生成长截图
      this.gitPixelMap();
      await this.afterSnapshot();
    }
  })
}

/**
 * 一键截图拼接函数。
 */
gitPixelMap() {
  let opts: image.InitializationOptions = {
    editable: true,
    pixelFormat: 4,
    size: {
      width: this.listWidth,
      height: this.scrollOffsets[this.scrollOffsets.length - 1] * this.densityPixels + this.listHeight
    }
  };
  this.mergedImage = image.createPixelMapSync(opts);
  this.tempPixelMap = image.createPixelMapSync(opts);
  for (let i = 0; i < this.areaArray.length; i++) {
    let area: image.PositionArea = {
      pixels: this.areaArray[i].pixels,
      offset: 0,
      stride: this.stride,
      region: {
        size: {
          width: this.listWidth,
          height: this.listHeight
        },
        x: 0,
        y: this.scrollOffsets[i] * this.densityPixels
      }
    }
    this.tempPixelMap?.writePixels(area, (error: BusinessError) => {
      if (error) {

      } else {

      }
    })
  }
  this.mergedImage = this.tempPixelMap;
}

总结

以上就是本次关于我参加华为跑跑码特(不知道有什么结果哈哈哈)项目和对图片处理技术的分享,希望这些内容能为你的技术探索之路提供些许帮助。若你在实践中遇到任何问题,或有独特见解,欢迎在评论区留言交流。让我们携手,在技术的海洋中不断探索、共同进步。

❌