HarmonyOS APP实战-基于Image Kit的图像处理APP - 第4篇:缩放裁剪功能实现

1. 开篇

第3篇中,我们实现了图片解码与信息提取功能。核心产出了ImageInfoPage组件:它通过image.createImageSource创建图片源,调用imageSource.getImageProperty获取图片宽度、高度等信息,最终将PixelMap和图片元数据展示在界面上。现在我们有了可用的PixelMap像素数据,下一步就是对这张图片进行操作——缩放和裁剪是图片处理中最基础也最常用的两种变换操作。

本篇我们将开发PixelMapUtils工具类,封装PixelMap.scalePixelMap.crop两个核心API。同时构建ScalePage(缩放页面)和CropPage(裁剪页面),分别提供滑块控制缩放比例、矩形选框选择裁剪区域两种交互方式,并实时预览效果。完成本篇后,APP的图片编辑能力将真正可用。


2. 核心实现

2.1 基础配置

pages目录下创建ScalePage.etsCropPage.ets,同时创建utils/PixelMapUtils.ets。本模块需要先配置必要的导入和类型声明。

// utils/PixelMapUtils.ets
// 工具类,封装PixelMap的常用操作方法

import { image } from '@kit.ImageKit';

/**
 * 缩放参数接口
 * 用于传递缩放比例,支持X轴和Y轴独立缩放
 */
export interface ScaleParams {
  scaleX: number; // X轴缩放比例,1.0为原始大小
  scaleY: number; // Y轴缩放比例,1.0为原始大小
}

/**
 * 裁剪区域接口
 * 定义矩形选区的位置和尺寸
 */
export interface CropRegion {
  x: number;      // 起始X坐标
  y: number;      // 起始Y坐标
  width: number;  // 选区宽度
  height: number; // 选区高度
}

关键点说明

  • ScaleParamsCropRegion接口专门为本APP的缩放裁剪功能定义,后续页面组件直接使用
  • 导入image模块是使用PixelMap及其方法的必要条件,在HarmonyOS的ArkTS开发中,所有图片处理API都封装在@kit.ImageKit

2.2 核心逻辑:PixelMapUtils工具类

PixelMapUtils.ets中实现缩放和裁剪两个静态方法,分别调用PixelMap.scalePixelMap.crop。两个方法都返回新的PixelMap对象,不修改原始数据。

// utils/PixelMapUtils.ets(续)
// 完整的PixelMapUtils工具类,提供缩放和裁剪功能

export class PixelMapUtils {

  /**
   * 缩放PixelMap
   * @param pixelMap 原始PixelMap对象
   * @param params 缩放参数(scaleX,scaleY)
   * @returns 缩放后的新PixelMap,失败返回null
   */
  public static async scale(pixelMap: PixelMap, params: ScaleParams): Promise<PixelMap | null> {
    try {
      // 校验输入,缩放比例必须为正数,否则保持原样
      if (params.scaleX <= 0 || params.scaleY <= 0) {
        console.warn('PixelMapUtils.scale: invalid scale params, using default 1.0');
        return pixelMap;
      }

      // 调用PixelMap的scale方法进行缩放
      // 参数:scaleX, scaleY为浮点数,1.0表示原尺寸
      // 注意:scale是直接修改PixelMap对象本身的像素数据
      await pixelMap.scale(params.scaleX, params.scaleY);
      
      // 返回缩放后的PixelMap(原对象已被修改)
      return pixelMap;
    } catch (error) {
      console.error(`PixelMapUtils.scale error: ${JSON.stringify(error)}`);
      return null;
    }
  }

  /**
   * 裁剪PixelMap
   * @param pixelMap 原始PixelMap对象
   * @param region 裁剪区域(x, y, width, height)
   * @returns 裁剪后的新PixelMap,失败返回null
   */
  public static async crop(pixelMap: PixelMap, region: CropRegion): Promise<PixelMap | null> {
    try {
      // 校验裁剪区域的有效性:不能超出原图边界,宽高必须大于0
      const imageInfo = pixelMap.getImageInfoSync();
      if (region.x < 0 || region.y < 0 ||
          region.width <= 0 || region.height <= 0 ||
          region.x + region.width > imageInfo.size.width ||
          region.y + region.height > imageInfo.size.height) {
        console.warn('PixelMapUtils.crop: invalid crop region');
        return null;
      }

      // 调用PixelMap的crop方法进行裁剪
      // 参数为Region对象,包含x, y, width, height四个属性
      // crop会返回一个新的PixelMap,不修改原始对象
      const croppedPixelMap: PixelMap = await pixelMap.crop(region);
      return croppedPixelMap;
    } catch (error) {
      console.error(`PixelMapUtils.crop error: ${JSON.stringify(error)}`);
      return null;
    }
  }
}

关键点说明

  • PixelMap.scale是异步方法,直接修改原始PixelMap的像素数据,因此最终返回是同一个对象,但像素数据已变
  • PixelMap.crop也是异步方法,但它不修改原始对象,而是返回一个全新的PixelMap,原始数据保留
  • 两个方法都有异常捕获,因为图片处理涉及大量内存操作,需要处理可能的失败场景
  • 裁剪区域需要校验合法性:不能为负,不能超出原图尺寸,宽度高度必须为正

在这里插入图片描述


2.3 完整模块:CropPage裁剪页面

裁剪页面提供矩形选框交互:用户通过触摸拖拽选择一个矩形区域,确认后调用PixelMap.crop裁剪并显示结果。缩放页面原理类似但交互更简单(滑块控制比例),这里重点展示裁剪页面的完整代码。

// pages/CropPage.ets
// 图片裁剪页面,支持矩形选框选择裁剪区域

import { image } from '@kit.ImageKit';
import { PixelMapUtils, CropRegion } from '../utils/PixelMapUtils';
import { router } from '@kit.ArkUI';

@Entry
@Component
struct CropPage {
  // 从上一页传递过来的原始PixelMap
  @State private originPixelMap: PixelMap | null = null;
  // 裁剪后生成的PixelMap
  @State private croppedPixelMap: PixelMap | null = null;
  // 选框的起始坐标和尺寸(在图片显示坐标系中)
  @State private rectX: number = 0;
  @State private rectY: number = 0;
  @State private rectWidth: number = 100;
  @State private rectHeight: number = 100;
  // 是否正在拖拽选框
  private isDragging: boolean = false;
  // 图片在屏幕上的实际显示尺寸(用于坐标映射)
  @State private displayWidth: number = 0;
  @State private displayHeight: number = 0;

  // 页面返回,携带裁剪结果回上一页
  private goBackWithResult(): void {
    if (this.croppedPixelMap) {
      router.back({
        url: 'pages/EditPage',
        params: {
          pixelMap: this.croppedPixelMap
        }
      });
    }
  }

  build() {
    Column() {
      // 标题栏
      Row() {
        Button('返回').onClick(() => router.back())
        Text('图片裁剪')
        Button('应用裁剪').onClick(async () => {
          if (this.originPixelMap == null) return;
          // 将屏幕坐标映射到图片真实坐标
          // 假设图片等比例缩放显示,需要计算比例因子
          const imageInfo = this.originPixelMap.getImageInfoSync();
          const scaleW = imageInfo.size.width / this.displayWidth;
          const scaleH = imageInfo.size.height / this.displayHeight;
          // 使用较精确的缩放因子(取平均或各自独立,这里使用各自的)
          const region: CropRegion = {
            x: Math.round(this.rectX * scaleW),
            y: Math.round(this.rectY * scaleH),
            width: Math.round(this.rectWidth * scaleW),
            height: Math.round(this.rectHeight * scaleH)
          };
          const result = await PixelMapUtils.crop(this.originPixelMap, region);
          if (result) {
            this.croppedPixelMap = result;
          }
        })
      }
      .padding(10)
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)

      // 显示图片的区域,作为裁剪框的容器
      Stack() {
        // 原图预览
        Image(this.originPixelMap)
          .width('100%')
          .objectFit(ImageFit.Contain)
          .onAreaChange((_, area) => {
            // 获取图片实际显示区域大小
            this.displayWidth = area.width as number;
            this.displayHeight = area.height as number;
          })

        // 裁剪矩形选框(透明遮罩+边框)
        Stack() {
          // 半透明遮罩覆盖非选区
          Rect()
            .width('100%')
            .height('100%')
            .fill('rgba(0,0,0,0.3)')
          // 选框内部透明,露出图片
          Rect()
            .width(this.rectWidth)
            .height(this.rectHeight)
            .position({ x: this.rectX, y: this.rectY })
            .fill('rgba(255,255,255,0)')
            .strokeWidth(2)
            .stroke(Color.White)
        }

        // 触摸事件处理(拖拽选框或调整选框大小)
        .onTouch((event: TouchEvent) => {
          if (event.touches.length === 0) return;
          const touch = event.touches[0];
          switch (event.type) {
            case TouchType.Down:
              // 点击在选框区域内时,开始拖拽
              if (touch.x >= this.rectX && touch.x <= this.rectX + this.rectWidth &&
                  touch.y >= this.rectY && touch.y <= this.rectY + this.rectHeight) {
                this.isDragging = true;
              }
              break;
            case TouchType.Move:
              if (this.isDragging) {
                // 拖拽选框移动(保持选框大小不变)
                this.rectX = Math.max(0, Math.min(this.displayWidth - this.rectWidth, touch.x - this.rectWidth / 2));
                this.rectY = Math.max(0, Math.min(this.displayHeight - this.rectHeight, touch.y - this.rectHeight / 2));
              } else {
                // 一手指拖拽时,计算新的选框起点(简易实现:从点击点开始)
                this.rectX = Math.min(touch.x, this.rectX + this.rectWidth);
                this.rectY = Math.min(touch.y, this.rectY + this.rectHeight);
                this.rectWidth = Math.abs(touch.x - this.rectX);
                this.rectHeight = Math.abs(touch.y - this.rectY);
              }
              break;
            case TouchType.Up:
              this.isDragging = false;
              break;
          }
        })
        .clip(true)
      }
      .width('100%')
      .height('70%')

      // 底部操作区:显示裁剪后效果缩略图,提供确认按钮
      Row() {
        // 裁剪结果预览
        Image(this.croppedPixelMap)
          .width(120)
          .height(120)
          .objectFit(ImageFit.Contain)
          .border({ width: 1, color: '#cccccc' })
        Column() {
          Button('重新选择').onClick(() => {
            this.croppedPixelMap = null;
          })
          Button('返回编辑').onClick(() => this.goBackWithResult())
        }
      }
      .padding(10)
      .width('100%')
      .justifyContent(FlexAlign.SpaceEvenly)
    }
    .width('100%')
    .height('100%')
    .onPageShow(() => {
      // 从路由参数获取原始PixelMap
      const params = router.getParams() as Record<string, Object>;
      if (params && params['pixelMap']) {
        this.originPixelMap = params['pixelMap'] as PixelMap;
      }
    })
  }
}

关键点说明

  • 裁剪交互分为两种:点击选框内部拖拽移动选框位置,点击选框外部重新绘制选框
  • 屏幕坐标与图片真实坐标的映射计算是通过displayWidth和图片真实尺寸的比例因子实现的,保证了裁剪区域的精确性
  • onAreaChange用于获取Image组件实际显示区域大小,配合objectFitImageFit.Contain时图片可能留有空白边距
  • 裁剪结果通过router.back传回编辑页面,完成闭环操作
  • 本页面使用了PixelMapUtils.crop工具方法,并在UI层完成坐标映射和校验

在这里插入图片描述


3. 运行验证

启动APP后,从首页选择一张图片,进入编辑页面。点击“缩放”按钮进入ScalePage,拖动滑块(滑块值从0.1到3.0),图片实时缩放,缩放后的图片可以保存或返回继续编辑。点击“裁剪”按钮进入CropPage,手指在图片上拖拽绘制矩形选框,选框可以自由移动,点击“应用裁剪”后出现裁剪结果缩略图,确认后携带裁剪结果返回编辑页面。

预期效果:缩放滑块平滑,图片实时响应;裁剪选框边线清晰可拖拽,裁剪边缘精确无偏移。运行过程无崩溃或异常弹窗。

在这里插入图片描述
在这里插入图片描述


4. 小结与预告

本篇完成了缩放和裁剪两大核心交互功能:PixelMapUtils工具类封装了PixelMap.scalePixelMap.crop两个关键API的调用逻辑;CropPageScalePage页面负责接收用户交互参数,调用工具类处理图片,并实时显示处理结果。现在APP可以做到图片缩放(任意比例)和矩形区域裁剪,这两个基础操作是一切图片编辑的基石。

下一篇「旋转翻转效果」将使用PixelMaprotateflip方法,实现图片以90°、180°、270°为单位旋转以及水平/垂直翻转。操作同样支持实时预览,并会加入方向感知的图标按钮交互。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐