马上来到了程序员的节日,1024,首先祝贺各位在这个艰难的大环境下熬到了11月份,虽如履薄冰,依然艰难前行,给自己一个大大的赞👍..。
进入正题,最近项目上有一个类似微信聊天页面扩展功能拍摄的需求,要求单击拍照,长按录像且带有圆环倒序进度条,也可以说是自定义相机,网上也有很多例子,本篇文章记录下本人开发过程中遇到的一些问题和细节,希望对阅读者有所帮助。
harmony next 的相机功能,肯定要参考官网的开发步骤了,以下我附上一个链接,里边有具体一些示例代码,建议先浏览一遍,熟悉下大概的流程
当然想要开发出满足自己需求的功能,只看官网上的例子,会让人抓耳挠腮,所以一并附上自定义相机的demo,同样是官网推荐:
这个demo是基于HarmonyOS提供的相机开放能力,实现系统相机级别的效果和能力,比如分辨率、动图、视频防抖、连续变焦等。
这个demo相信已经满足一部分人开发的需求了,除了页面不同,其他功能代码,均在里面有详细的调用逻辑。
下面具体讲下仿微信拍摄功能的相关逻辑,先上样例:
两个图片对应长按前和长按时效果(本来是弄了一个gif,奈何太大无法上传),下面直接上干货:
安卓、ios早已有非常成熟的demo了,且能支持全屏拍摄和预览,这里不做过多介绍,需要的自行去找,但是鸿蒙内实现相关功能,需要通过自定义渲染组件XComponent来实现,同步系统相机界面,暂时未发现设置全屏api,如有小伙伴已有相关优化,希望多指教。
demo中的两个类 CameraShooter 和 VideoRecorder 两个相机和录像相关设置可以直接拿过来使用,当然有有一些细化的需求,也可以在这里面进行修改,比如预览文件大小,输出文件大小,格式,路径,保存逻辑(是否保存到相册)等。
其他逻辑均在以下两个页面内,仅是功能实现,并未来得及优化,请谨慎参考并自行优化:
CustomCameraPage页面:
import { display, inspector, router } from '@kit.ArkUI' import { Permissions } from '@ohos.abilityAccessCtrl' import { getPhotoZoom } from '../../utils/CameraShooter'; import { getVideoZoom } from '../../utils/VideoRecorder'; import { setVideoZoom } from '../../utils/VideoRecorder'; import { setPhotoZoom } from '../../utils/CameraShooter'; import { stopRecord } from '../../utils/VideoRecorder'; import { stopRecordPreview } from '../../utils/VideoRecorder'; import { videoRecording } from '../../utils/VideoRecorder'; import { startRecord } from '../../utils/VideoRecorder'; import { releaseCamera } from '../../utils/CameraShooter'; import { cameraShooting } from '../../utils/CameraShooter'; import { capture } from '../../utils/CameraShooter'; let cameraPosition = 0;//前1后2摄像头 let surfaceId = ''; let zoomRatioRange: number[] = []; let isVideo = false; let qualityLevel: number = 0; let videoUri: string; let foldAbleStatus: number = 0; let currentFov: number = 1; @Entry @Component export struct CustomCameraPage { permissions: Array<Permissions> = [ 'ohos.permission.CAMERA', 'ohos.permission.MICROPHONE', 'ohos.permission.MEDIA_LOCATION', 'ohos.permission.READ_IMAGEVIDEO', 'ohos.permission.WRITE_IMAGEVIDEO', ]; currentTime: number = 0 //最大1分钟 maxTime: number = 600 //每份是500毫秒 @State currentProgress: number = 0 maxProgress: number = 100 timer: number = -1 @State firstCircleWidth: number = 60 @State secondCircleWidth: number = 80 @State showProgress: boolean = false @State hasPermission: boolean = false mXComponentController: XComponentController = new XComponentController; @State isFoldAble: boolean = display.isFoldable(); @State zoom: number = 0.8; @State isShowZoom: boolean = false; @State isPhoto: boolean = true; @State isFront: boolean = false; @State currentPic: boolean = true; @State recording: boolean = false; @StorageLink('photoUri') photoUri: string = ''; aboutToAppear(): void { //必须加延迟,否则进入黑屏 setTimeout(async () => { if (this.isFoldAble) { foldAbleStatus = display.getFoldStatus(); this.mXComponentController.setXComponentSurfaceRect({ surfaceWidth: foldAbleStatus === 1 ? 1400 : display.getDefaultDisplaySync().width, surfaceHeight: this.isFoldAble ? 1400 : 1600 }) display.on('foldStatusChange', (foldStatus: display.FoldStatus) => { if (foldStatus === 3) { return; } foldAbleStatus = foldStatus; if (foldStatus === 1) { cameraPosition = cameraPosition === 0 ? 0 : 2; } else { cameraPosition = cameraPosition === 0 ? 0 : 1; } setTimeout(() => { this.mXComponentController.setXComponentSurfaceRect({ surfaceWidth: foldAbleStatus === 1 ? 1400 : display.getDefaultDisplaySync().width, surfaceHeight: this.isFoldAble ? 1400 : 1600 }) cameraShooting(isVideo, cameraPosition, surfaceId, getContext(), foldAbleStatus); }, 500) }) } zoomRatioRange = await cameraShooting(isVideo, cameraPosition, surfaceId, getContext(), foldAbleStatus); }, 200); } startCountdown() { this.timer = setInterval(() => { if (this.currentTime > this.maxTime) { this.showProgress = false this.retParams() this.endVideo() return } this.starAnimation() this.showProgress = true this.currentProgress = this.maxProgress * this.currentTime / this.maxTime this.currentTime++ }, 100) } starAnimation() { animateTo({ duration: 300, playMode: PlayMode.Normal, }, () => { this.firstCircleWidth = 30 this.secondCircleWidth = 95 }) } retParams() { this.currentTime = 0 this.currentProgress = 0 this.showProgress = false this.firstCircleWidth = 60 this.secondCircleWidth = 80 } aboutToDisappear(): void { clearInterval(this.timer) } // Initialize(): void { // this.zoom = 1; // currentFov = 1; // } switchCamera(){ if (this.isFoldAble) { foldAbleStatus = display.getFoldStatus(); if (foldAbleStatus === 1) { cameraPosition = cameraPosition === 0 ? 2 : 0; } else { cameraPosition = cameraPosition === 1 ? 0 : 1; } } else { cameraPosition = cameraPosition === 1 ? 0 : 1 } if (this.isPhoto) { cameraShooting(isVideo, cameraPosition, surfaceId, getContext(), foldAbleStatus); } else { stopRecordPreview(); videoRecording(true, cameraPosition, qualityLevel, surfaceId, getContext(), foldAbleStatus); } // this.Initialize(); this.isFront = cameraPosition !== 0; } async takePhoto() { //系统相机拍照和录像 if (!this.isPhoto) { this.isPhoto = true; isVideo = false; stopRecordPreview(); // this.Initialize(); await cameraShooting(isVideo, cameraPosition, surfaceId, getContext(), foldAbleStatus); } capture(this.isFront); this.currentPic = true; setTimeout(()=>{ if (this.photoUri !== '') { if (this.currentPic) { router.pushNamedRoute({name:"pages/camera/CustomCameraPreviewPage",params:{'isPhoto':this.isPhoto}}) } } },800) } async takeVideo() { if (this.isPhoto) { this.isPhoto = false; await releaseCamera(); // this.Initialize(); await videoRecording(true, cameraPosition, qualityLevel, surfaceId, getContext(), foldAbleStatus); isVideo = true; } await startRecord(); this.startCountdown() this.recording = true; } async endVideo(){ this.recording = false; this.currentPic = false; this.zoom = 1; currentFov = 1; videoUri = await stopRecord(); stopRecordPreview(); videoRecording(true, cameraPosition, qualityLevel, surfaceId, getContext(), foldAbleStatus); setTimeout(()=>{ if (videoUri !== '') { this.photoUri = videoUri router.pushNamedRoute({name:"pages/camera/CustomCameraPreviewPage",params:{'isPhoto':this.isPhoto}}) } },800) } onPageShow(): void { cameraShooting(isVideo, cameraPosition, surfaceId, getContext(), foldAbleStatus); } onPageHide(): void { releaseCamera() } build() { RelativeContainer() { XComponent({ id: '', type: 'surface', controller: this.mXComponentController }) .gesture( PinchGesture({ fingers: 2 }) .onActionUpdate((event: GestureEvent) => { if (event) { this.zoom = currentFov * event.scale; this.isShowZoom = true; if (this.zoom > (this.isPhoto ? zoomRatioRange[1] : 15)) { this.zoom = this.isPhoto ? zoomRatioRange[1] : 15; } else if (this.zoom < zoomRatioRange[0]) { this.zoom = zoomRatioRange[0]; } if (this.isPhoto) { setPhotoZoom(this.zoom); } else { setVideoZoom(this.zoom); } } }) .onActionEnd(() => { if (this.isPhoto) { currentFov = getPhotoZoom(); } else { currentFov = getVideoZoom(); } this.isShowZoom = false }) ) .onLoad(async () => { this.mXComponentController.setXComponentSurfaceRect({ surfaceWidth: foldAbleStatus === 1 ? 1400 : display.getDefaultDisplaySync().width, surfaceHeight: this.isFoldAble ? 1400 : 1600 }); surfaceId = this.mXComponentController.getXComponentSurfaceId(); }) .width('100%') .height('100%') .backgroundColor(Color.Black) Row() .height(80) .alignRules( { top: { anchor: '__container__', align: VerticalAlign.Top }, left: { anchor: '__container__', align: HorizontalAlign.Start }, right: { anchor: '__container__', align: HorizontalAlign.End } }) Row() { Image($r('sys.media.ohos_ic_public_close')) .width(25) .aspectRatio(1) .fillColor(Color.White) .onClick(() => { //关闭 router.back() }) Image($r('app.media.switch_camera')) .width(25) .aspectRatio(1) .fillColor(Color.White) .onClick(() => { //翻转 this.switchCamera() }) } .justifyContent(FlexAlign.SpaceBetween) .padding({ left: 25, right: 25 }) .alignRules({ top: { anchor: '__container__', align: VerticalAlign.Top }, left: { anchor: '__container__', align: HorizontalAlign.Start }, right: { anchor: '__container__', align: HorizontalAlign.End } }) .margin({ top: 45 }) Text('轻触拍照,按住录像') .fontSize(15) .fontColor(Color.White) .textAlign(TextAlign.Center) .alignRules({ bottom: { anchor: 'btnCamera', align: VerticalAlign.Top }, left: { anchor: '__container__', align: HorizontalAlign.Start }, right: { anchor: '__container__', align: HorizontalAlign.End } }) .margin({bottom:10}) Stack() { if (this.showProgress) { Progress({ value: this.currentProgress, total: this.maxProgress, type: ProgressType.Ring }) .color(Color.Green) .width(105) .style({ strokeWidth: 5 }) } Row() .width(this.secondCircleWidth) .aspectRatio(1) .backgroundColor('#dddddd') .borderRadius(this.secondCircleWidth / 2) Row() .width(this.firstCircleWidth) .aspectRatio(1) .backgroundColor(Color.White) .borderRadius(this.firstCircleWidth / 2) } .alignRules({ bottom: { anchor: '__container__', align: VerticalAlign.Bottom }, left: { anchor: '__container__', align: HorizontalAlign.Start }, right: { anchor: '__container__', align: HorizontalAlign.End } }) .id('btnCamera') .width(120) .height(120) .margin({ bottom: 20 }) .gesture( GestureGroup(GestureMode.Exclusive, //长按录像 LongPressGesture({ repeat: false }) .onAction(async (event) => { //检测fingerList>0代表按下 if (event.fingerList.length > 0) { this.takeVideo() } }) .onActionEnd((event) => { //跳转预览页面 clearInterval(this.timer) this.retParams() this.endVideo() }) .onActionCancel(() => { //跳转预览页面 clearInterval(this.timer) this.retParams() this.endVideo() }), //单机拍照 TapGesture({ count: 1 }) .onAction(async () => { this.takePhoto() }) ) ) } } }
CustomCameraPreviewPage页面,即预览页:
import { router } from '@kit.ArkUI' import { fileIo as fs } from '@kit.CoreFileKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { CameraResultHelper } from './CameraResultHelper'; @Entry @Component export struct CustomCameraPreviewPage{ @StorageLink('photoUri') photoUri: string = ''; @State isPhoto: boolean = true; @State previewUri: Resource = $r('app.media.webview_ic_volume_6') @State showControls: boolean = true controller: VideoController = new VideoController() config: ImageAnalyzerConfig = { types: [ImageAnalyzerType.SUBJECT, ImageAnalyzerType.TEXT] } private aiController: ImageAnalyzerController = new ImageAnalyzerController() private options: ImageAIOptions = { types: [ImageAnalyzerType.SUBJECT, ImageAnalyzerType.TEXT], aiController: this.aiController } paramsObject: Record<string, Object> = {} //file://media/Photo/2406/IMG_1729167862_4009/IMG_20241017_202242.jpg //file://media/Photo/2403/VID_1729165786_281/1729165686651.mp4 aboutToAppear() { const params: object = router.getParams() if (params&¶ms['isPhoto'] != null) { this.isPhoto = params['isPhoto'] //转存沙箱 const file = fs.openSync(this.photoUri, fs.OpenMode.READ_ONLY) const newFilePath = getContext().cacheDir + '/' + file.name fs.copyFileSync(file.fd, newFilePath) fs.stat(newFilePath).then((stat: fs.Stat) => { console.error("this.photoUri is " + this.photoUri); console.error("get newFile info succeed, the size of file is " + stat.size); if (stat.size > 0) { //此处可增加buffer或者uint8Array转换输出 this.paramsObject['cameraFilePath'] = newFilePath } }).catch((err: BusinessError) => { console.error("get newFile info failed with error message: " + err.message + ", error code: " + err.code); }); } } build() { Stack() { //预览 if (this.isPhoto) { Image(this.photoUri) .objectFit(ImageFit.Fill) .width('100%') } else { Video({ src: this.photoUri, previewUri: this.previewUri, controller: this.controller, imageAIOptions: this.options }) .width('100%') .controls(false) .enableAnalyzer(true) .autoPlay(true) .loop(true) .analyzerConfig(this.config) .objectFit(ImageFit.Fill) .onStart(() => { console.info('onStart') }) .onPause(() => { console.info('onPause') }) } RelativeContainer() { //关闭 Stack() { Circle() .width(60) .aspectRatio(1) .fill('#80dddddd') Image($r('sys.media.ohos_ic_public_close')) .width(35) .aspectRatio(1) } .onClick(() => { router.back() }) .alignRules({ top: { anchor: '__container__', align: VerticalAlign.Top }, }) .margin({ top: 50, left: 40 }) .visibility(Visibility.Hidden) Row() { //返回 Stack() { Circle() .width(70) .aspectRatio(1) .fill('#80dddddd') Image($r('app.media.ic_camera_cancel')) .width(25) .fillColor(Color.Black) .aspectRatio(1) } .onClick(() => { router.back() }) Stack() { Circle() .width(70) .aspectRatio(1) .fill(Color.White) Image($r('app.media.ic_camera_confirm')) .fillColor('#ff96d034') .width(25) .aspectRatio(1) } .onClick(() => { //确认使用 //todo 此处为使用相片或者视频的下一步逻辑,自行开发 CameraResultHelper.getInstance().cameraResultSuc(this.paramsObject) }) } .alignRules({ bottom: { anchor: '__container__', align: VerticalAlign.Bottom }, left: { anchor: '__container__', align: HorizontalAlign.Start }, right: { anchor: '__container__', align: HorizontalAlign.End } }) .margin({ bottom: 50 }) .padding({ left: 40, right: 40 }) .justifyContent(FlexAlign.SpaceBetween) } .width("100%") .height("100%") } .backgroundColor(Color.Black) } }
以上为自定义相机全部内容,拿去参考吧。。。如有异议,随时评论优化,不给其他人添麻烦。