Harmony Next 仿微信拍摄功能,自定义相机记录

马上来到了程序员的节日,1024,首先祝贺各位在这个艰难的大环境下熬到了11月份,虽如履薄冰,依然艰难前行,给自己一个大大的赞👍..。

进入正题,最近项目上有一个类似微信聊天页面扩展功能拍摄的需求,要求单击拍照,长按录像且带有圆环倒序进度条,也可以说是自定义相机,网上也有很多例子,本篇文章记录下本人开发过程中遇到的一些问题和细节,希望对阅读者有所帮助。

harmony next 的相机功能,肯定要参考官网的开发步骤了,以下我附上一个链接,里边有具体一些示例代码,建议先浏览一遍,熟悉下大概的流程

HarmonyNext Camera Kit 相机服务

当然想要开发出满足自己需求的功能,只看官网上的例子,会让人抓耳挠腮,所以一并附上自定义相机的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&&params['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)
  }
}

以上为自定义相机全部内容,拿去参考吧。。。如有异议,随时评论优化,不给其他人添麻烦。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值