##鸿蒙开发能力 ##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 = {
} // 创建一个 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的整体调性。效果如下图所示:recommendationType: photoAccessHelper.RecommendationType.QR_OR_BAR_CODE
// 从 @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
// 目前选择的图片 // 使用 @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
// 超过最大选择数量再次点击时的回调 // 当选择数量超过最大限制再次点击时触发的回调函数 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 }); } })
)) } }