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

GeorgeGcs
• 阅读 9

##鸿蒙开发能力 ##HarmonyOS SDK应用服务##鸿蒙金融类应用 (金融理财# 一、前言 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 = new Array();

// 目前选择的图片 // 使用 @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): 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 { 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 { 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 });
      }
    })

    )) } }

点赞
收藏
评论区
推荐文章
GeorgeGcs GeorgeGcs
16小时前
【 HarmonyOS 5 入门系列 】鸿蒙HarmonyOS示例项目讲解
【HarmonyOS5入门系列】鸿蒙HarmonyOS示例项目讲解\鸿蒙开发能力HarmonyOSSDK应用服务鸿蒙金融类应用(金融理财一、前言:移动开发声明式UI框架的技术变革在移动操作系统的发展历程中,UI开发模式经历了从命令式到声明式的重大变革。根据
GeorgeGcs GeorgeGcs
16小时前
【HarmonyOS 5】AttributeModifier和AttributeUpdater区别详解
【HarmonyOS5】AttributeModifier和AttributeUpdater区别详解\鸿蒙开发能力HarmonyOSSDK应用服务鸿蒙金融类应用(金融理财一、AttributeModifier和AttributeUpdater的定义和作用1
GeorgeGcs GeorgeGcs
10小时前
从“备胎”到领航者,鸿蒙操作系统的传奇进化
鸿蒙开发能力HarmonyOSSDK应用服务鸿蒙金融类应用(金融理财【HarmonyOS5】2019年,在全球科技产业的风云变幻中,华为正式推出了鸿蒙操作系统(HarmonyOS),这一消息如同一颗重磅炸弹,瞬间吸引了全世界的目光。彼时,外界对鸿蒙的诞生背
GeorgeGcs GeorgeGcs
7小时前
【HarmonyOS 5】鸿蒙中Stage模型与FA模型详解
鸿蒙开发能力HarmonyOSSDK应用服务鸿蒙金融类应用(金融理财一、前言在HarmonyOS5的应用开发模型中,featureAbility是旧版FA模型(FeatureAbility)的用法,Stage模型已采用全新的应用架构,推荐使用组件化的上下文
GeorgeGcs GeorgeGcs
7小时前
【HarmonyOS 5】鸿蒙应用px,vp,fp概念详解
鸿蒙开发能力HarmonyOSSDK应用服务鸿蒙金融类应用(金融理财一、前言目前的鸿蒙开发者,大多数是从前端或者传统移动端开发方向,转到鸿蒙应用开发方向。前端开发同学对于开发范式很熟悉,但是对于工作流程和开发方式是会有不适感,其实移动应用开发与前端开发,最
GeorgeGcs GeorgeGcs
7小时前
【HarmonyOS 5】鸿蒙中的UIAbility详解(二)
鸿蒙开发能力HarmonyOSSDK应用服务鸿蒙金融类应用(金融理财一、前言今天我们继续深入讲解UIAbility,根据下图可知,在鸿蒙中UIAbility继承于Ability,开发者无法直接继承Ability。只能使用其两个子类:UIAbility和Ex
GeorgeGcs GeorgeGcs
7小时前
【HarmonyOS 5】桌面快捷方式功能实现详解
鸿蒙开发能力HarmonyOSSDK应用服务鸿蒙金融类应用(金融理财一、前言在移动应用开发中,如何让用户快速触达核心功能,是目前很常见的功能之一。鸿蒙系统提供的桌面快捷方式(Shortcuts)功能,允许开发者为应用内常用功能创建直达入口,用户通过长按应用
GeorgeGcs GeorgeGcs
7小时前
【HarmonyOS 5】鸿蒙中Stage模型与FA模型详解
鸿蒙开发能力HarmonyOSSDK应用服务鸿蒙金融类应用(金融理财一、前言在HarmonyOS5的应用开发模型中,featureAbility是旧版FA模型(FeatureAbility)的用法,Stage模型已采用全新的应用架构,推荐使用组件化的上下文
GeorgeGcs GeorgeGcs
7小时前
【HarmonyOS 5】使用openCustomDialog如何禁止手势关闭的方案
鸿蒙开发能力HarmonyOSSDK应用服务鸿蒙金融类应用(金融理财一、前言在HarmonyOS中使用openCustomDialog自定义弹框时,我们会遇到实现禁止手势关闭弹框的业务场景。虽然在HarmonyOSNext中,自定义Dialog默认可能继承
GeorgeGcs GeorgeGcs
6小时前
【HarmonyOS 5】如何开启DevEco Studio热更新调试应用模式
鸿蒙开发能力HarmonyOSSDK应用服务鸿蒙金融类应用(金融理财一、AttributeModifier和AttributeUpdater的定义和作用1.AttributeModifier是ArkUI组件的动态属性,提供属性设置功能。开发者可使用attr
GeorgeGcs
GeorgeGcs
Lv1
男 · 金融头部企业 · 鸿蒙应用架构师
HarmonyOS认证创作先锋,华为HDE专家,鸿蒙讲师,作者。目前任职鸿蒙应用架构师。 历经腾讯,宝马,研究所,金融。 待过私企,外企,央企。 深耕大应用开发领域十年。 AAE,Harmony(OpenHarmony\HarmonyOS),MAE(Android\IOS),FE(H5\Vue\RN)。
文章
56
粉丝
1
获赞
2