【HarmonyOS 5】鸿蒙用户头像编辑功能实践

一、前言

1、应用背景

在鸿蒙化开发过程中,我们发现最基本常见的功能--用户头像的编辑,实现方式和Android与IOS有极大的不同。

在实际开发和调研的过程中,我们发现并总结了鸿蒙隐私处理与业内Android和IOS的差异性。发现隐私保护对标其他两个,上升了一个大台阶。并且针对开发者来说,也更加人性化,便利化。

2、业务需求拆解
用户头像编辑功能流程图如下所示:

在这里插入图片描述

(1) 用户首先触发头像编辑功能(如用户点击 “编辑头像” 按钮)。

(2) 用户打开设备相册,选择目标图片
此时会获取用户选择的图片,可能没有选择,用户取消,或者用户没有给权限。

(3) 手势裁剪图片:
进入裁剪界面,支持手势缩放、拖动、旋转图片,划定裁剪区域。

(4) 上传图片至服务器:
裁剪完成后,将图片压缩并上传至服务器,等待返回成功响应。

(5) 更新头像显示

3、技术调研目标
经过完整的需求拆解,实际需要调研的功能点只有三个:
(1)鸿蒙中如何获取用户的图片
(2)鸿蒙中如何实现图片的裁剪
(3)鸿蒙中如何实现图片的手势操控

二、用户相册图片获取的三种方式

1、用户相册图片获取功能的行业技术路线方案对比:
在鸿蒙调研过程中,我们发现,相当于Android和IOS的获取用户相册图片的方式,鸿蒙大有不同。

目前Android获取用户相册图片的技术路线有:
(1)调用系统原生相册,选取图片后传递给三方应用进行图片处理。隐式 Intent 调用系统相册或者SAF(Storage Access Framework )。
(2)申请用户相册权限,获取用户相册内所有的图片,在三方应用自定义的相册界面进行展示和图片选择逻辑。MediaStore 直接查询系统相册。

目前IOS获取用户相册图片的技术路线有:
(1)通过系统提供的控制器直接调用相册,UIImagePickerController(快速选择)
(2)申请用户相册权限,获取用户相册内所有的图片,在三方应用自定义的相册界面进行展示和图片选择逻辑。通过 PHPhotoLibrary 框架直接访问相册数据库。

当然Android和IOS集成三方SDK也可实现获取用户相册图片,但是其实最终原理还是以上,所以不单独列出。

目前在鸿蒙中对于用户图片的获取有以下三种方式:
(1)需要三方应用申请受限权限,获取文件读写的权限,(调用需要ohos.permission.READ_IMAGEVIDEO权限),这样就可以读取相册媒体库中的媒体资源。

(2)通过鸿蒙系统提供的安全控件PhotoAccessHelper,用户触发操作后即表示同意授权,不需要三方应用再去授权,可以将图片临时授权给应用处理。

(3)针对高度定制化三方应用的需求,不希望相册界面使用系统组件,保持APP的美观和一致性。鸿蒙提供了AlbumPicker,开发者可以在布局中嵌入AlbumPickerComponent组件,通过此组件,应用无需申请权限,即可访问公共目录中的相册列表。

2、用户相册图片获取功能的技术选项
综上所述,我们可以对比发现。鸿蒙在针对用户隐私保护上,比Android和IOS做的都好。极大的保护了用户的隐私安全。

虽然IOS使用系统控制器的方式也可达到鸿蒙的效果,但是市面上既有的APP几乎都是采用,先进入自己应用的相册,然后调用控制器,逻辑操作繁琐,并且很多APP没有在自己的应用相册界面中添加触发【+】加号入口。目前微信是有做,像饿了么京东都没做。需要去系统设置中自己手动添加可以访问的图片给应用。

在这里插入图片描述

说实话,我在使用IOS手机时,就喜欢权限设置里带的访问选择图片功能。不像安卓一样,获取用户授权后,APP就能访问到用户相册所有的图片。而是用户勾选开发给APP的图片,APP只能访问这些。这是IOS的做法。当然IOS也保留了,和Android类似的所有图片开放权限。

获取相册所有图片,是开发者最常见的操作了,目前华为是不提倡APP访问用户所有相册资源。鸿蒙的隐私保护效果好,但是对于开发者就有点痛苦了,特别是产品的要求要与Android和IOS一致的情况下。

DEMO验证阶段我们发现,鸿蒙方案一,申请读取权限,该权限是管制权限,需要三方应用去通过场景申请,非常严格并且几乎无法申请通过。【申请使用受限权限】 所以PASS。

"requestPermissions": [
  {
    "name": "ohos.permission.READ_IMAGEVIDEO",
    "usedScene": {
      "abilities": [
        "EntryAbility"
      ],
      "when": "inuse"
    },
    "reason": "$string:CAMERA"
  }
]
  //  创建申请权限明细
  async reqPermissionsFromUser(): Promise<number[]> {
    let context = getContext() as common.UIAbilityContext;
    let atManager = abilityAccessCtrl.createAtManager();
    let grantStatus = await atManager.requestPermissionsFromUser(context, ['ohos.permission.READ_IMAGEVIDEO']);
    return grantStatus.authResults;
  }

  // 用户申请权限
  async requestPermission() {
    let grantStatus = await this.reqPermissionsFromUser();
    for (let i = 0; i < grantStatus.length; i++) {
      if (grantStatus[i] === 0) {
        // 用户授权,可以继续访问目标操作
      }
    }
  }

鸿蒙方案二在930阶段启用,使用也很方便,虽然损失了APP的美观和一致性。使用系统提供的Picker组件,以弹框的形式显示,让用户选择图片,点击完成后,自动收起。效果如下图所示:

在这里插入图片描述

/**
 * 相册选择图片
 */
private async getPictureFromAlbum() {
    // 创建一个 PhotoSelectOptions 对象,用于配置相册选择的相关选项
    let PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
    // 设置选择的文件 MIME 类型为图片类型,这样在相册选择时只会显示图片文件
    PhotoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
    // 设置最大选择数量为 1,即只能从相册中选择一张图片
    PhotoSelectOptions.maxSelectNumber = 1;
    // 设置推荐选项,这里指定推荐类型为二维码或条形码,可能会优先展示符合此类型的图片
    PhotoSelectOptions.recommendationOptions = {
        recommendationType: photoAccessHelper.RecommendationType.QR_OR_BAR_CODE
    }
    // 创建一个 PhotoViewPicker 对象,用于启动相册选择器
    let photoPicker = new photoAccessHelper.PhotoViewPicker();
    // 调用 select 方法,传入配置好的选项,等待用户从相册中选择图片
    // 返回一个 PhotoSelectResult 对象,包含用户选择的图片的相关信息
    let photoSelectResult: photoAccessHelper.PhotoSelectResult = await photoPicker.select(PhotoSelectOptions);
    // 从 PhotoSelectResult 对象中获取用户选择的第一张图片的 URI 路径
    let albumPath = photoSelectResult.photoUris[0];
    // 在控制台输出日志,记录获取到的图片路径,方便调试和查看信息
    console.info(this.TAG, 'getPictureFromAlbum albumPath= ' + albumPath);
    // 调用 getImageByPath 方法,传入图片路径,用于根据路径获取图片的具体内容
    await this.getImageByPath(albumPath);
}

方案三是今年系统API升级后公开提供的API,从应用市场下载APP操作对比,使用上看应该是去年给大厂APP,微信微博他们先使用后,才公开的方案。我是比较推荐该方案,搞定定制化,符合APP的整体调性。效果如下图所示:

在这里插入图片描述

// 从 @ohos.file.PhotoPickerComponent 模块导入所需的类和类型
// 这些类和类型用于构建和配置图片选择器组件
import {
  PhotoPickerComponent, // 图片选择器组件类
  PickerController, // 图片选择器控制器类,用于控制组件行为
  PickerOptions, // 图片选择器的配置选项类
  DataType, // 数据类型枚举
  BaseItemInfo, // 基础项信息类
  ItemInfo, // 项信息类,包含更详细的项信息
  PhotoBrowserInfo, // 图片浏览器信息类
  ItemType, // 项类型枚举
  ClickType, // 点击类型枚举
  MaxCountType, // 最大数量类型枚举
  PhotoBrowserRange, // 图片浏览器范围枚举
  ReminderMode, // 提醒模式枚举
} from '@ohos.file.PhotoPickerComponent';
// 导入照片访问辅助工具模块
import photoAccessHelper from '@ohos.file.photoAccessHelper';

// 标记为页面入口组件
@Entry
// 定义一个名为 AlbumTestPage 的组件
@Component
struct AlbumTestPage {
  // 组件初始化时设置参数信息
  // 创建一个 PickerOptions 实例,用于配置图片选择器的各种选项
  pickerOptions: PickerOptions = new PickerOptions();

  // 组件初始化完成后,可控制组件部分行为
  // 使用 @State 装饰器,使 pickerController 成为响应式状态变量
  // 创建一个 PickerController 实例,用于控制图片选择器的行为
  @State pickerController: PickerController = new PickerController();

  // 已选择的图片
  // 使用 @State 装饰器,使 selectUris 成为响应式状态变量
  // 用于存储已选择图片的 URI 数组
  @State selectUris: Array<string> = new Array<string>();

  // 目前选择的图片
  // 使用 @State 装饰器,使 currentUri 成为响应式状态变量
  // 用于存储当前选中图片的 URI
  @State currentUri: string = '';

  // 是否显示大图
  // 使用 @State 装饰器,使 isBrowserShow 成为响应式状态变量
  // 用于控制是否显示图片浏览器(大图模式)
  @State isBrowserShow: boolean = false;

  // 组件即将显示时调用的生命周期函数
  aboutToAppear() {
    // 设置 picker 宫格页数据类型
    // 将选择器的 MIME 类型设置为图片和视频类型,即图片和视频都会在选择器中显示
    this.pickerOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_VIDEO_TYPE; 
    // 最大选择数量
    // 设置图片选择的最大数量为 5 张
    this.pickerOptions.maxSelectNumber = 5;
    // 超出最大选择数量时
    // 当选择数量超过最大限制时,以 Toast 形式提醒用户
    this.pickerOptions.maxSelectedReminderMode = ReminderMode.TOAST;
    // 是否展示搜索框,默认 false
    // 开启选择器中的搜索框功能
    this.pickerOptions.isSearchSupported = true;
    // 是否支持拍照,默认 false
    // 开启选择器中的拍照功能
    this.pickerOptions.isPhotoTakingSupported = true;
  }

  // 资源被选中回调,返回资源的信息,以及选中方式
  // 当图片选择器中的项被点击时触发的回调函数
  private onItemClicked(itemInfo: ItemInfo, clickType: ClickType): boolean {
    // 若传入的项信息为空,则直接返回 false
    if (!itemInfo) {
      return false;
    }
    // 获取项的类型
    let type: ItemType | undefined = itemInfo.itemType;
    // 获取项的 URI
    let uri: string | undefined = itemInfo.uri;
    // 若项类型为相机
    if (type === ItemType.CAMERA) {
      // 点击相机 item
      // 返回 true 则拉起系统相机,若应用需要自行处理则返回 false
      return true; 
    } else {
      // 若点击类型为选中
      if (clickType === ClickType.SELECTED) {
        // 应用做自己的业务处理
        if (uri) {
          // 将选中图片的 URI 添加到已选择数组中
          this.selectUris.push(uri);
          // 更新选择器的预选中 URI 数组
          this.pickerOptions.preselectedUris = [...this.selectUris];
        }
        // 返回 true 则勾选,否则则不响应勾选
        return true; 
      } else {
        if (uri) {
          // 若点击类型为取消选中,从已选择数组中过滤掉该 URI
          this.selectUris = this.selectUris.filter((item: string) => {
            return item != uri;
          });
          // 更新选择器的预选中 URI 数组
          this.pickerOptions.preselectedUris = [...this.selectUris];
        }
      }
      return true;
    }
  }

  // 进入大图的回调
  // 当进入图片浏览器(大图模式)时触发的回调函数
  private onEnterPhotoBrowser(photoBrowserInfo: PhotoBrowserInfo): boolean {
    // 设置显示大图标志为 true
    this.isBrowserShow = true;
    return true;
  }

  // 退出大图的回调
  // 当退出图片浏览器(大图模式)时触发的回调函数
  private onExitPhotoBrowser(photoBrowserInfo: PhotoBrowserInfo): boolean {
    // 设置显示大图标志为 false
    this.isBrowserShow = false;
    return true;
  }

  // 接收到该回调后,便可通过 pickerController 相关接口向 picker 发送数据,在此之前不生效
  // 当图片选择器控制器准备好时触发的回调函数
  private onPickerControllerReady(): void {
    // 这里可以添加向选择器发送数据的逻辑
  }

  // 大图左右滑动的回调
  // 当在图片浏览器(大图模式)中左右滑动图片时触发的回调函数
  private onPhotoBrowserChanged(browserItemInfo: BaseItemInfo): boolean {
    // 更新当前选中图片的 URI
    this.currentUri = browserItemInfo.uri ?? '';
    return true;
  }

  // 已勾选图片被删除时的回调
  // 当已勾选的图片被删除时触发的回调函数
  private onSelectedItemsDeleted(baseItemInfos: Array<BaseItemInfo>): void {
    // 这里可以添加处理已勾选图片被删除的逻辑
  }

  // 超过最大选择数量再次点击时的回调
  // 当选择数量超过最大限制再次点击时触发的回调函数
  private onExceedMaxSelected(exceedMaxCountType: MaxCountType): void {
    // 这里可以添加处理超过最大选择数量的逻辑
  }

  // 当前相册被删除时的回调
  // 当当前选择的相册被删除时触发的回调函数
  private onCurrentAlbumDeleted(): void {
    // 这里可以添加处理当前相册被删除的逻辑
  }

  // 组件构建函数,用于定义组件的 UI 结构
  build() {
    // 创建一个垂直方向的 Flex 布局容器
    Flex({
      direction: FlexDirection.Column,
      alignItems: ItemAlign.Start
    }) {
      // 使用 PhotoPickerComponent 组件
      PhotoPickerComponent({
        pickerOptions: this.pickerOptions, // 传入图片选择器的配置选项
        // 传入项点击回调函数
        onItemClicked: (itemInfo: ItemInfo, clickType: ClickType): boolean => this.onItemClicked(itemInfo, clickType),
        // 传入进入图片浏览器回调函数
        onEnterPhotoBrowser: (photoBrowserInfo: PhotoBrowserInfo): boolean => this.onEnterPhotoBrowser(photoBrowserInfo),
        // 传入退出图片浏览器回调函数
        onExitPhotoBrowser: (photoBrowserInfo: PhotoBrowserInfo): boolean => this.onExitPhotoBrowser(photoBrowserInfo),
        // 传入选择器控制器准备好回调函数
        onPickerControllerReady: (): void => this.onPickerControllerReady(),
        // 传入图片浏览器滑动回调函数
        onPhotoBrowserChanged: (browserItemInfo: BaseItemInfo): boolean => this.onPhotoBrowserChanged(browserItemInfo),
        pickerController: this.pickerController, // 传入图片选择器控制器
      })

      // 这里模拟应用侧底部的选择栏
      // 若处于图片浏览器(大图模式)
      if (this.isBrowserShow) {
        // 已选择的图片缩影图
        // 创建一个水平方向的 Row 布局容器
        Row() {
          // 遍历已选择的图片 URI 数组
          ForEach(this.selectUris, (uri: string) => {
            // 若当前 URI 为当前选中的图片 URI
            if (uri === this.currentUri) {
              // 显示带有红色边框的图片缩略图
              Image(uri).height(50).width(50)
                .onClick(() => {
                })
                .borderWidth(1)
                .borderColor('red')
            } else {
              // 显示普通图片缩略图,点击时设置选择器数据并切换到对应图片
              Image(uri).height(50).width(50).onClick(() => {
                this.pickerController.setData(DataType.SET_SELECTED_URIS, this.selectUris);
                this.pickerController.setPhotoBrowserItem(uri, PhotoBrowserRange.ALL);
              })
            }
          }, (uri: string) => JSON.stringify(uri))
        }.alignSelf(ItemAlign.Center).margin(this.selectUris.length ? 10 : 0)
      } else {
        // 进入大图,预览已选择的图片
        // 创建一个按钮,点击时进入图片浏览器预览已选择的第一张图片
        Button('预览').width('33%').alignSelf(ItemAlign.Start).height('5%').margin(10).onClick(() => {
          if (this.selectUris.length > 0) {
            this.pickerController.setPhotoBrowserItem(this.selectUris[0], PhotoBrowserRange.SELECTED_ONLY);
          }
        })
      }
    }
  }
}

三、头像裁剪实现

裁剪界面,一般是原型或者方形。这个看APP产品的设计来。实现方式从Android和IOS来对比看,有的是提供了系统组件,有的是需要自己使用画布实现。
目前鸿蒙刚刚发展,系统组件也没提供类似完整的组件效果,当然肯定没有开源组件用了。所以这里我们使用画布去实现取景框的效果,如下图所示:

在这里插入图片描述

// 定义一个接口 LoadResult,用于描述图片加载完成后的结果信息
interface LoadResult {
  // 图片的原始宽度
  width: number;
  // 图片的原始高度
  height: number;
  // 组件的宽度
  componentWidth: number;
  // 组件的高度
  componentHeight: number;
  // 加载状态,用数字表示不同的加载状态
  loadingStatus: number;
  // 内容的宽度
  contentWidth: number;
  // 内容的高度
  contentHeight: number;
  // 内容在 X 轴上的偏移量
  contentOffsetX: number;
  // 内容在 Y 轴上的偏移量
  contentOffsetY: number;
}

// 定义一个鸿蒙 ArkTS 组件 CropView
@Component
export struct CropView {
  // 定义组件的日志标签,用于在控制台输出日志时标识该组件
  private TAG: string = "CropView";

  // 创建一个 RenderingContextSettings 对象,用于配置画布渲染上下文的设置
  // 传入 true 表示开启抗锯齿等优化设置
  private mRenderingContextSettings: RenderingContextSettings = new RenderingContextSettings(true);
  // 创建一个 CanvasRenderingContext2D 对象,用于在画布上进行 2D 绘图操作
  // 使用之前创建的渲染上下文设置进行初始化
  private mCanvasRenderingContext2D: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.mRenderingContextSettings);

  // 使用 @Link 装饰器,将 mImg 绑定到外部传入的 PixelMap 对象
  // 当外部的 PixelMap 对象发生变化时,该组件会自动更新
  @Link mImg: PixelMap;

  // 定义一个回调函数,当图片加载完成时调用
  // msg 参数为 LoadResult 类型,包含图片加载完成后的相关信息
  private onLoadImgComplete = (msg: LoadResult) => {
    // 这里可以添加图片加载完成后的处理逻辑,当前为空
  }

  // 定义一个回调函数,当画布准备好进行绘制时调用
  private onCanvasReady = () => {
    // 检查画布渲染上下文对象是否为空
    if (!this.mCanvasRenderingContext2D) {
      // 如果为空,在控制台输出错误日志
      console.error(this.TAG, "onCanvasReady error mCanvasRenderingContext2D null !");
      return;
    }
    // 获取画布渲染上下文对象,方便后续使用
    let cr = this.mCanvasRenderingContext2D;
    // 设置画布的填充颜色,这里是半透明的黑色
    cr.fillStyle = '#AA000000';
    // 获取画布的高度
    let height = cr.height;
    // 获取画布的宽度
    let width = cr.width;
    // 在画布上填充一个矩形,覆盖整个画布区域
    cr.fillRect(0, 0, width, height);

    // 计算圆形的中心点的 X 坐标
    let centerX = width / 2;
    // 计算圆形的中心点的 Y 坐标
    let centerY = height / 2;
    // 计算圆形的半径,取画布宽度和高度的最小值的一半再减去 100 像素
    let radius = Math.min(width, height) / 2 - 100;
    // 设置全局合成操作模式为 'destination-out'
    // 该模式表示在已有内容的基础上,清除与新绘制图形重叠的部分
    cr.globalCompositeOperation = 'destination-out'
    // 设置填充颜色为白色
    cr.fillStyle = 'white'
    // 开始一个新的路径
    cr.beginPath();
    // 在画布上绘制一个圆形
    cr.arc(centerX, centerY, radius, 0, 2 * Math.PI);
    // 填充圆形,由于之前设置了 'destination-out' 模式,会清除圆形区域的内容
    cr.fill();

    // 设置全局合成操作模式为 'source-over'
    // 该模式表示新绘制的图形会覆盖在已有内容之上
    cr.globalCompositeOperation = 'source-over';
    // 设置描边颜色为白色
    cr.strokeStyle = '#FFFFFF';
    // 开始一个新的路径
    cr.beginPath();
    // 在画布上绘制一个圆形
    cr.arc(centerX, centerY, radius, 0, 2 * Math.PI);
    // 关闭路径
    cr.closePath();

    // 设置线条宽度为 1 像素
    cr.lineWidth = 1;
    // 绘制圆形的边框
    cr.stroke();
  }

  // 组件的构建函数,用于定义组件的 UI 结构
  build() {
    // 创建一个 Stack 布局容器,将子组件堆叠在一起显示
    Stack() {
      // 创建一个 Row 组件,作为黑色底图
      // 设置宽度和高度为 100%,背景颜色为黑色
      Row().width("100%").height("100%").backgroundColor(Color.Black)

      // 创建一个 Image 组件,用于显示用户传入的图片
      Image(this.mImg)
        // 设置图片的填充模式为填充整个容器
        .objectFit(ImageFit.Fill)
        // 设置图片的宽度为 100%
        .width('100%')
        // 设置图片的宽高比为 1:1
        .aspectRatio(1)
        // 绑定图片加载完成的回调函数
        .onComplete(this.onLoadImgComplete)

      // 创建一个 Canvas 组件,用于绘制取景框
      Canvas(this.mCanvasRenderingContext2D)
        // 设置画布的宽度为 100%
        .width('100%')
        // 设置画布的高度为 100%
        .height('100%')
        // 设置画布的背景颜色为透明
        .backgroundColor(Color.Transparent)
        // 绑定画布准备好的回调函数
        .onReady(this.onCanvasReady)
        // 开启裁剪功能
        .clip(true)
        // 设置画布的背景颜色为半透明的黑色
        .backgroundColor("#00000080")

    }
    // 设置 Stack 布局容器的宽度和高度为 100%
    .width("100%").height("100%")
  }
}

最终效果演示与DEMO源码分享

在这里插入图片描述


import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { router } from '@kit.ArkUI';
import { cameraPicker as picker } from '@kit.CameraKit';
import { camera } from '@kit.CameraKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { CropView } from './CropView';

@Entry
@Component
struct Index {
  private TAG: string = "imageTest";

  @State mUserPixel: image.PixelMap | undefined = undefined;
  @State mTargetPixel: image.PixelMap | undefined = undefined;

  /**
   * 拍照获取图片
   */
  private async getPictureFromCamera(){
    try {
      let pickerProfile: picker.PickerProfile = {
        // 相机的位置。
        cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK
      };
      let pickerResult: picker.PickerResult = await picker.pick(
        getContext(),
        [picker.PickerMediaType.PHOTO],
        pickerProfile
      );
      console.log(this.TAG, "the pick pickerResult is:" + JSON.stringify(pickerResult));
      // 成功才处理
      if(pickerResult && pickerResult.resultCode == 0){
        await this.getImageByPath(pickerResult.resultUri);
      }
    } catch (error) {
      let err = error as BusinessError;
      console.error(this.TAG, `the pick call failed. error code: ${err.code}`);
    }
  }

  /**
   * 相册选择图片
   */
  private async getPictureFromAlbum() {
    let PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
    PhotoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
    PhotoSelectOptions.maxSelectNumber = 1;

    let photoPicker = new photoAccessHelper.PhotoViewPicker();
    let photoSelectResult: photoAccessHelper.PhotoSelectResult = await photoPicker.select(PhotoSelectOptions);
    let albumPath = photoSelectResult.photoUris[0];
    console.info(this.TAG, 'getPictureFromAlbum albumPath= ' + albumPath);
    await this.getImageByPath(albumPath);
  }

  /**
   * 获取图片pixelMap
   * @param path
   */
  private async getImageByPath(path: string) {
    console.info(this.TAG, 'getImageByPath path: ' + path);
    try {
      // 读取图片为buffer
      const file = fs.openSync(path, fs.OpenMode.READ_ONLY);
      let photoSize = fs.statSync(file.fd).size;
      console.info(this.TAG, 'Photo Size: ' + photoSize);
      let buffer = new ArrayBuffer(photoSize);
      fs.readSync(file.fd, buffer);
      fs.closeSync(file);
      // 解码成PixelMap
      const imageSource = image.createImageSource(buffer);
      console.log(this.TAG, 'imageSource: ' + JSON.stringify(imageSource));
      this.mUserPixel = await imageSource.createPixelMap({});
    } catch (e) {
      console.info(this.TAG, 'getImage e: ' + JSON.stringify(e));
    }
  }

  build() {
    Scroll(){
      Column() {
        Text("点击拍照")
          .fontSize(50)
          .fontWeight(FontWeight.Bold)
          .onClick(() => {
            this.getPictureFromCamera();
          })

        Text("相册选择")
          .fontSize(50)
          .fontWeight(FontWeight.Bold)
          .onClick(() => {
            this.getPictureFromAlbum();
          })

        Image(this.mUserPixel)
          .objectFit(ImageFit.Fill)
          .width('100%')
          .aspectRatio(1)

        Text("图片裁剪")
          .fontSize(50)
          .fontWeight(FontWeight.Bold)
          .onClick(() => {
            this.cropImage();
            // router.pushUrl({
            //   url: "pages/crop"
            // })
          })

        CropView({ mImg: $mUserPixel })
          .width('100%')
          .aspectRatio(1)

        Text("裁剪效果")
          .fontSize(50)
          .fontWeight(FontWeight.Bold)

        Image(this.mTargetPixel)
            .width('100%')
            .aspectRatio(1)
            .borderRadius(200)

      }
      .height(3000)
      .width('100%')
    }
    .height('100%')
    .width('100%')
  }

  private async cropImage(){
    if(!this.mUserPixel){
      return;
    }
    let cp = await this.copyPixelMap(this.mUserPixel);
    let region: image.Region = { x: 0, y: 0, size: { width: 400, height: 400 } };
    cp.cropSync(region);
  }

  async copyPixelMap(pixel: PixelMap): Promise<PixelMap> {
    const info: image.ImageInfo = await pixel.getImageInfo();
    const buffer: ArrayBuffer = new ArrayBuffer(pixel.getPixelBytesNumber());
    await pixel.readPixelsToBuffer(buffer);
    const opts: image.InitializationOptions = {
      editable: true,
      pixelFormat: image.PixelMapFormat.RGBA_8888,
      size: { height: info.size.height, width: info.size.width }
    };
    return image.createPixelMap(buffer, opts);
  }

}
// 导入路由模块,用于页面跳转
import router from '@ohos.router';
// 导入自定义图片工具模块,提供图片处理功能
import { image } from '@kit.ImageKit';
// 导入矩阵变换模块,用于处理图片的平移、缩放等变换
import Matrix4 from '@ohos.matrix4';


// 定义图片加载结果接口,包含图片尺寸、组件尺寸、加载状态等信息
export class LoadResult {
  width: number = 0;            // 图片原始宽度
  height: number = 0;           // 图片原始高度
  componentWidth: number = 0;   // 组件宽度
  componentHeight: number = 0;  // 组件高度
  loadingStatus: number = 0;    // 加载状态(0:未加载, 1:加载中, 2:加载完成等)
  contentWidth: number = 0;     // 内容区域宽度
  contentHeight: number = 0;    // 内容区域高度
  contentOffsetX: number = 0;   // 内容在X轴偏移量
  contentOffsetY: number = 0;   // 内容在Y轴偏移量
}

// 标记为页面入口组件
@Entry
// 定义裁剪页面组件
@Component
export struct CropPage {
  private TAG: string = "CropPage";  // 日志标签,用于控制台输出标识

  // 画布渲染上下文配置(开启抗锯齿)
  private mRenderingContextSettings: RenderingContextSettings = new RenderingContextSettings(true);
  // 画布2D渲染上下文,用于绘制取景框等图形
  private mCanvasRenderingContext2D: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.mRenderingContextSettings);

  // 响应式状态变量:加载的图片像素图(可选类型,初始为undefined)
  @State mImg: PixelMap | undefined = undefined;
  // 响应式状态变量:图片矩阵变换参数(初始为单位矩阵,包含平移和缩放变换)
  @State mMatrix: object = Matrix4.identity()
    .translate({ x: 0, y: 0 })       // 初始平移量为0
    .scale({ x: 1, y: 1});            // 初始缩放比例为1:1

  @State mImageInfo: ImageInfo = new ImageInfo();  // 图片信息对象(包含缩放、偏移等状态)

  private tempScale = 1;           // 临时缩放比例,用于手势缩放过程中保存中间状态
  private startOffsetX: number = 0; // 拖动手势开始时的X轴偏移量
  private startOffsetY: number = 0; // 拖动手势开始时的Y轴偏移量

  // 组件即将显示时的生命周期函数(类似onStart)
  aboutToAppear(): void {
    console.log(this.TAG, "aboutToAppear start");
    let temp = mSourceImg;          // 假设mSourceImg为外部传入的原始图片
    console.log(this.TAG, "aboutToAppear temp: " + JSON.stringify(temp));
    this.mImg = temp;               // 将原始图片赋值给组件状态变量
    console.log(this.TAG, "aboutToAppear end");
  }

  // 获取图片信息的辅助方法
  private getImgInfo(){
    return this.mImageInfo;
  }

  // 取消按钮点击事件处理:返回上一页
  onClickCancel = ()=>{
    router.back();  // 调用路由返回接口
  }

  // 确认按钮点击事件处理(异步函数)
  onClickConfirm = async ()=>{
    if(!this.mImg){
      console.error(this.TAG, " onClickConfirm mImg error null !");
      return;
    }
    // 此处省略图片裁剪保存逻辑(...)
    router.back();  // 处理完成后返回上一页
  }

  /**
   * 复制图片像素图
   * @param pixel 原始像素图对象
   * @returns 复制后的像素图对象(Promise异步返回)
   */
  async copyPixelMap(pixel: PixelMap): Promise<PixelMap> {
    const info: image.ImageInfo = await pixel.getImageInfo();       // 获取图片信息
    const buffer: ArrayBuffer = new ArrayBuffer(pixel.getPixelBytesNumber());  // 创建像素数据缓冲区
    await pixel.readPixelsToBuffer(buffer);                           // 将像素数据读取到缓冲区
    // 初始化选项:可编辑、像素格式、尺寸
    const opts: image.InitializationOptions = {
      editable: true,
      pixelFormat: image.PixelMapFormat.RGBA_8888,
      size: { height: info.size.height, width: info.size.width }
    };
    return image.createPixelMap(buffer, opts);  // 创建并返回新的像素图
  }

  /**
   * 图片加载完成回调函数
   * @param msg 加载结果信息,更新图片信息对象并检查缩放比例
   */
  private onLoadImgComplete = (msg: LoadResult) => {
    this.getImgInfo().loadResult = msg;  // 将加载结果存入图片信息对象
    this.checkImageScale();             // 检查并调整图片缩放比例(代码中未实现,需后续补充)
  }

  /**
   * 画布准备完成回调函数:绘制取景框
   */
  private onCanvasReady = ()=>{
    if(!this.mCanvasRenderingContext2D){
      console.error(this.TAG, "onCanvasReady error mCanvasRenderingContext2D null !");
      return;
    }
    let cr = this.mCanvasRenderingContext2D;
    // 绘制半透明黑色背景
    cr.fillStyle = '#AA000000';         // 设置填充颜色(80%透明度黑色)
    let height = cr.height;             // 获取画布高度
    let width = cr.width;               // 获取画布宽度
    cr.fillRect(0, 0, width, height);   // 填充整个画布

    // 计算圆形取景框参数
    let centerX = width / 2;            // 圆心X坐标(画布中心)
    let centerY = height / 2;           // 圆心Y坐标(画布中心)
    let radius = Math.min(width, height) / 2 - px2vp(100);  // 半径=画布短边的一半减100虚拟像素
    // 设置合成模式:清除圆形区域内的背景(实现镂空效果)
    cr.globalCompositeOperation = 'destination-out';
    cr.fillStyle = 'white';             // 设置填充颜色为白色(用于清除区域)
    cr.beginPath();                     // 开始路径绘制
    cr.arc(centerX, centerY, radius, 0, 2 * Math.PI);  // 绘制圆形路径
    cr.fill();                           // 填充路径,清除圆形区域背景

    // 绘制白色边框
    cr.globalCompositeOperation = 'source-over';  // 恢复正常绘制模式
    cr.strokeStyle = '#FFFFFF';         // 设置边框颜色为白色
    cr.beginPath();                      // 重新开始路径
    cr.arc(centerX, centerY, radius, 0, 2 * Math.PI);  // 绘制圆形路径
    cr.closePath();                      // 闭合路径
    cr.lineWidth = 1;                    // 设置线条宽度
    cr.stroke();                         // 绘制边框
  }

  // 组件UI构建函数
  build() {
    // 相对布局容器(子组件可相对于容器定位)
    RelativeContainer() {
      // 黑色背景层
      Row().width("100%").height("100%").backgroundColor(Color.Black)

      // 图片显示组件
      Image(this.mImg)
        .objectFit(ImageFit.Contain)       // 图片适应容器,保持宽高比
        .width('100%')                     // 宽度占满容器
        .height('100%')                    // 高度占满容器
        .transform(this.mMatrix)            // 应用矩阵变换(平移/缩放)
        .alignRules({                       // 布局对齐规则:水平垂直居中
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onComplete(this.onLoadImgComplete)  // 绑定图片加载完成回调

      // 取景框画布组件
      Canvas(this.mCanvasRenderingContext2D)
        .width('100%')                     // 画布宽度占满容器
        .height('100%')                    // 画布高度占满容器
        .alignRules({                       // 布局对齐规则:水平垂直居中
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .backgroundColor(Color.Transparent) // 画布背景透明
        .onReady(this.onCanvasReady)        // 绑定画布准备完成回调
        .clip(true)                         // 开启裁剪(超出画布的内容隐藏)
        .backgroundColor("#00000080")       // 半透明黑色背景(与画布绘制的镂空区域形成对比)

      // 底部按钮栏(取消/确定按钮)
      Row(){
        Button("取消")                      // 取消按钮
          .size({ width: px2vp(450), height: px2vp(200) })  // 设置按钮尺寸(虚拟像素转换)
          .onClick(this.onClickCancel)       // 绑定取消事件处理
        Blank()                              // 空白间隔
        Button("确定")                      // 确定按钮
          .size({ width: px2vp(450), height: px2vp(200) })
          .onClick(this.onClickConfirm)      // 绑定确定事件处理
      }
      .width("100%")                       // 按钮栏宽度占满容器
      .height(px2vp(200))                  // 按钮栏高度
      .margin({ bottom: px2vp(500) })      // 底部边距
      .alignRules({                         // 布局对齐规则:底部居中
        center: { anchor: '__container__', align: VerticalAlign.Bottom },
        middle: { anchor: '__container__', align: HorizontalAlign.Center }
      })
      .justifyContent(FlexAlign.Center)     // 子组件水平居中排列
    }
    .width("100%").height("100%")         // 容器占满整个页面
    .priorityGesture(                      // 注册优先级手势(双击手势)
      TapGesture({                         // 点击手势配置
        count: 2,                           // 双击触发
        fingers: 1                          // 单指操作
      }).onAction((event: GestureEvent)=>{
        console.log(this.TAG, "TapGesture onAction start");
        if(!event){
          return;
        }
        // 双击时切换缩放比例(1倍和2倍之间切换)
        if(this.getImgInfo().scale != 1){
          this.getImgInfo().scale = 1;      // 恢复1倍缩放
          this.getImgInfo().offsetX = 0;    // 重置X轴偏移
          this.getImgInfo().offsetY = 0;    // 重置Y轴偏移
        }else{
          this.getImgInfo().scale = 2;      // 放大至2倍
        }
        // 更新矩阵变换参数(平移+缩放)
        this.mMatrix = Matrix4.identity()
          .translate({ x: this.getImgInfo().offsetX, y: this.getImgInfo().offsetY })
          .scale({ x: this.getImgInfo().scale, y: this.getImgInfo().scale });
        console.log(this.TAG, "TapGesture onAction end");
      })
    )
    .gesture(GestureGroup(                // 注册手势组(支持并行手势)
      GestureMode.Parallel,               // 手势模式:并行处理(缩放和拖动可同时进行)
      // 双指缩放手势
      PinchGesture({                      // 缩放手势配置
        fingers: 2                         // 双指触发
      })
        .onActionStart(()=>{               // 手势开始时记录当前缩放比例
          this.tempScale = this.getImgInfo().scale;
        })
        .onActionUpdate((event)=>{         // 手势更新时计算新的缩放比例
          if(event){
            this.getImgInfo().scale = this.tempScale * event.scale;  // 基于手势缩放因子更新
            // 更新矩阵变换(保持当前偏移量,应用新的缩放比例)
            this.mMatrix = Matrix4.identity()
              .translate({ x: this.getImgInfo().offsetX, y: this.getImgInfo().offsetY })
              .scale({ x: this.getImgInfo().scale, y: this.getImgInfo().scale });
          }
        })
      ,
      // 单指拖动手势
      PanGesture()                         // 拖动手势配置
        .onActionStart(()=>{               // 手势开始时记录初始偏移量
          this.startOffsetX = this.getImgInfo().offsetX;
          this.startOffsetY = this.getImgInfo().offsetY;
      })
        .onActionUpdate((event)=>{         // 手势更新时计算新的偏移量(考虑缩放比例)
          if(event){
            // 偏移量转换:虚拟像素转物理像素,并除以当前缩放比例
            let distanceX: number = this.startOffsetX + vp2px(event.offsetX) / this.getImgInfo().scale;
            let distanceY: number = this.startOffsetY + vp2px(event.offsetY) / this.getImgInfo().scale;
            this.getImgInfo().offsetX = distanceX;
            this.getImgInfo().offsetY = distanceY;
            // 更新矩阵变换(应用新的平移和缩放)
            this.mMatrix = Matrix4.identity()
              .translate({ x: this.getImgInfo().offsetX, y: this.getImgInfo().offsetY })
              .scale({ x: this.getImgInfo().scale, y: this.getImgInfo().scale });
          }
        })
    ))
  }
}

Logo

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

更多推荐