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

2025-09-28 17:16:04

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

##鸿蒙开发能力 ##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 {

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 });

}

})

))

}

}