在这里插入图片描述

HarmonyOS技术精讲-Camera Kit(相机服务)第19篇:实战——打造完整相机应用

从触摸区域无效说起

很多人第一次用Camera Kit做拍照功能时,会遇到一个典型问题:触摸预览画面某个区域,摄像头对焦、曝光调节没反应。不是API不对,是触摸事件的传递和相机控制参数之间的同步没处理好。

更隐蔽的问题是:拍照和录像之间的状态切换,处理不好会导致相机崩溃。我见过不止一个项目因为switchTo调用时机不对,在开发板上一跑就黑屏。

这篇实战文章的目标是做一个能用的相机App,包含预览、拍照、录像、参数调节、切换摄像头、媒体库浏览。代码结构按功能模块拆分,每个模块单独能运行,合起来就是一个完整应用。

项目背景:我们做的是什么

目标:一个基于ArkTS的相机应用,能在HarmonyOS NEXT设备上正常跑。

功能清单

  • 实时预览(默认使用后置摄像头)
  • 拍照(全尺寸JPEG输出)
  • 录像(MP4格式,H.264编码)
  • 手动对焦(点触屏幕)
  • 曝光补偿调节(±2EV)
  • 前后摄像头切换
  • 结果媒体文件浏览(调用系统相册)

设计原则

  • 所有相机状态集中管理,避免@State被多处修改
  • 生命周期绑定:页面显示才打开相机,页面隐藏自动释放
  • 异常状态兜底:权限不足、设备不支持、格式错误都要有提示,而不是直接崩溃

不包含:美颜、滤镜、人脸追踪。这些是商业级功能,不适合入门阶段。掌握了基础架构,加这些是逻辑扩展,不是架构重写。

环境说明

DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机(建议真机,模拟器在某些设备上不支持摄像头)

第一步:项目结构

entry/src/main/ets/
├── pages/
│   └── CameraPage.ets          # 主相机页面
├── model/
│   ├── CameraManager.ts        # 相机管理(初始化、配置、释放)
│   ├── PhotoManager.ts         # 拍照逻辑
│   ├── VideoManager.ts         # 录像逻辑
│   └── MediaPreviewManager.ts  # 媒体浏览入口
├── common/
│   └── Constants.ts            # 常量定义
└── resources/
    └── rawfile/                # 资源文件

核心逻辑在model/的三个Manager里。CameraPage.ets只负责UI布局和事件分发。这是ArkTS开发里比较推荐的做法:把业务逻辑和UI分离,方便后续扩展和调试。

第二步:权限配置

module.json5里添加相机、存储、麦克风权限:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.CAMERA"
      },
      {
        "name": "ohos.permission.MICROPHONE"
      },
      {
        "name": "ohos.permission.WRITE_MEDIA"
      },
      {
        "name": "ohos.permission.READ_MEDIA"
      }
    ]
  }
}

注意:MICROPHONE是录像必需的,如果不加,录制时只能得到静音视频。很多人第一次写录像功能会忘加这个权限。

第三步:CameraManager - 相机状态管理核心

// model/CameraManager.ts
import { camera } from '@kit.CameraKit';
import { image } from '@kit.ImageKit';
import { common } from '@kit.AbilityKit';

export class CameraManager {
  private cameraManager: camera.CameraManager;
  private cameraInput: camera.CameraInput | null = null;
  private previewOutput: camera.PreviewOutput | null = null;
  private photoOutput: camera.PhotoOutput | null = null;
  private videoOutput: camera.VideoOutput | null = null;
  private session: camera.Session | null = null;
  private currentCameraIndex: number = 0;  // 0:后置,1:前置
  private cameraDevices: camera.CameraDevice[] = [];

  // 初始化相机管理器
  async init(context: common.Context): Promise<void> {
    try {
      this.cameraManager = camera.getCameraManager(context);
      this.cameraDevices = await this.cameraManager.getSupportedCameras();
      if (this.cameraDevices.length === 0) {
        throw new Error('No camera device found');
      }
    } catch (err) {
      console.error('CameraManager init failed', err);
      throw err;
    }
  }

  // 获取当前使用的摄像头
  getCurrentCamera(): camera.CameraDevice {
    return this.cameraDevices[this.currentCameraIndex];
  }

  // 切换摄像头
  async switchCamera(): Promise<void> {
    this.currentCameraIndex = (this.currentCameraIndex + 1) % this.cameraDevices.length;
    // 切换时需要重新创建session和output
    await this.releaseSession();
    // 注意:外部需要在切换后重新调用createSession和startPreview
  }

  // 创建预览Output
  async createPreviewOutput(surfaceId: string): Promise<camera.PreviewOutput> {
    const cameraDevice = this.getCurrentCamera();
    const previewProfile = this.getPreviewProfile(cameraDevice);
    this.previewOutput = await this.cameraManager.createPreviewOutput(previewProfile, surfaceId);
    return this.previewOutput;
  }

  // 创建拍照Output
  async createPhotoOutput(): Promise<camera.PhotoOutput> {
    const cameraDevice = this.getCurrentCamera();
    const photoProfile = this.getPhotoProfile(cameraDevice);
    this.photoOutput = await this.cameraManager.createPhotoOutput(photoProfile);
    return this.photoOutput;
  }

  // 创建录像Output
  async createVideoOutput(surfaceId: string): Promise<camera.VideoOutput> {
    const cameraDevice = this.getCurrentCamera();
    const videoProfile = this.getVideoProfile(cameraDevice);
    this.videoOutput = await this.cameraManager.createVideoOutput(videoProfile, surfaceId);
    return this.videoOutput;
  }

  // 创建并配置Session
  async createSession(): Promise<void> {
    // 使用PhotoSession(拍照+录像模式)
    this.session = await this.cameraManager.createSession(camera.SceneMode.NORMAL_PHOTO);
    await this.session.beginConfig();
    // 添加输入
    const cameraDevice = this.getCurrentCamera();
    this.cameraInput = await this.cameraManager.createCameraInput(cameraDevice);
    await this.cameraInput.open();
    await this.session.addInput(this.cameraInput);
    // 添加输出(预览先添加,拍照和录像在UI里按需添加)
    if (this.previewOutput) {
      await this.session.addOutput(this.previewOutput);
    }
    await this.session.commitConfig();
    await this.session.start();
  }

  // 释放Session
  async releaseSession(): Promise<void> {
    try {
      await this.session?.stop();
      await this.session?.release();
      await this.cameraInput?.close();
    } catch (err) {
      console.error('releaseSession error', err);
    } finally {
      this.session = null;
      this.cameraInput = null;
    }
  }

  // 释放所有资源
  async release(): Promise<void> {
    await this.releaseSession();
    this.previewOutput?.release();
    this.photoOutput?.release();
    this.videoOutput?.release();
    this.cameraManager = null!;
  }

  // 工具方法:获取支持的预览配置
  private getPreviewProfile(device: camera.CameraDevice): camera.Profile {
    const profiles = this.cameraManager.getSupportedOutputCapability(device);
    return profiles.previewProfiles[0]; // 取第一个可用的
  }

  // 工具方法:获取支持的拍照配置
  private getPhotoProfile(device: camera.CameraDevice): camera.Profile {
    const profiles = this.cameraManager.getSupportedOutputCapability(device);
    return profiles.photoProfiles[0];
  }

  // 工具方法:获取支持的录像配置
  private getVideoProfile(device: camera.CameraDevice): camera.Profile {
    const profiles = this.cameraManager.getSupportedOutputCapability(device);
    return profiles.videoProfiles[0];
  }
}

这段代码的几个关键点

  1. 使用SceneMode.NORMAL_PHOTO创建session,这样可以在同一个session里同时支持拍照和录像。如果用错了SceneMode(比如用了PORTRAIT),录像功能可能不可用。
  2. releaseSession里加了一个finally块,防止报错后状态漏复位。
  3. switchCamera不直接做完整切换,只改变索引。外部调用时需要先释放旧的session,再创建新的。这样做的原因是相机参数校准、output重建需要较多异步操作,放在Manager里会拉长方法长度。实际项目中可以按需调整。
  4. 预览、拍照、录像的profile取了第一个可用的。在商用项目里,应该遍历筛选出匹配当前屏幕分辨率的配置,这里简化处理。

第四步:CameraPage - 主页面UI

// pages/CameraPage.ets
import { CameraManager } from '../model/CameraManager';
import { PhotoManager } from '../model/PhotoManager';
import { VideoManager } from '../model/VideoManager';
import { MediaPreviewManager } from '../model/MediaPreviewManager';
import { image } from '@kit.ImageKit';

@Entry
@Component
struct CameraPage {
  @State isRecording: boolean = false;
  @State currentMode: string = 'photo'; // 'photo' or 'video'
  @State exposureValue: number = 0; // -2~2
  private cameraManager: CameraManager = new CameraManager();
  private photoManager: PhotoManager = new PhotoManager();
  private videoManager: VideoManager = new VideoManager();
  private mediaPreviewManager: MediaPreviewManager = new MediaPreviewManager();
  private previewSurfaceId: string = '';

  aboutToAppear(): void {
    this.initCamera();
  }

  aboutToDisappear(): void {
    this.releaseCamera();
  }

  async initCamera(): Promise<void> {
    try {
      const context = getContext(this);
      await this.cameraManager.init(context);
      // 创建预览Surface
      const surfaceId = await this.createPreviewSurface();
      this.previewSurfaceId = surfaceId;
      await this.cameraManager.createPreviewOutput(surfaceId);
      await this.cameraManager.createPhotoOutput();
      await this.cameraManager.createSession();
      // 注册拍照和录像的监听
      this.registerOutputListeners();
    } catch (err) {
      console.error('Camera init failed', err);
      // 显示错误提示
      AlertDialog.show({ message: 'Camera initialization failed' });
    }
  }

  async releaseCamera(): Promise<void> {
    await this.cameraManager.release();
  }

  // 创建预览Surface(使用XComponent)
  async createPreviewSurface(): Promise<string> {
    // 通过XComponent的surfaceId获取
    return new Promise((resolve) => {
      // 假设有一个ID为'cameraXComponent'的XComponent
      const xcomponent = this.findXComponentById('cameraXComponent');
      if (xcomponent) {
        resolve(xcomponent.surfaceId);
      }
    });
  }

  registerOutputListeners(): void {
    // 注册拍照完成监听
    this.photoManager.setPhotoAvailableCallback((photo: image.PixelMap) => {
      // 将图片保存到媒体库
      this.mediaPreviewManager.savePhoto(photo);
      console.log('Photo saved');
    });
    // 注册录像完成监听
    this.videoManager.setVideoAvailableCallback((videoUri: string) => {
      this.mediaPreviewManager.saveVideo(videoUri);
      console.log('Video saved');
    });
  }

  // 拍照
  async takePhoto(): Promise<void> {
    if (this.currentMode !== 'photo') {
      console.warn('Current mode is not photo');
      return;
    }
    try {
      await this.photoManager.takePicture(this.cameraManager.getPhotoOutput()!);
    } catch (err) {
      console.error('Take photo failed', err);
    }
  }

  // 开始/停止录像
  async toggleRecording(): Promise<void> {
    if (this.isRecording) {
      await this.videoManager.stopRecording();
      this.isRecording = false;
    } else {
      try {
        // 创建录像输出并添加到Session
        const videoOutput = await this.cameraManager.createVideoOutput(this.previewSurfaceId);
        await this.cameraManager.createSession(); // 这里简化处理,实际需要更精细的session管理
        await this.videoManager.startRecording(videoOutput);
        this.isRecording = true;
      } catch (err) {
        console.error('Start recording failed', err);
      }
    }
  }

  // 切换前后摄像头
  async switchCamera(): Promise<void> {
    await this.cameraManager.switchCamera();
    // 重建session和output
    await this.cameraManager.createPreviewOutput(this.previewSurfaceId);
    await this.cameraManager.createPhotoOutput();
    await this.cameraManager.createSession();
  }

  // 调节曝光补偿
  setExposure(value: number): void {
    this.exposureValue = value;
    // 通过session设置曝光补偿(需要确认API接口)
    // 这是一个示例,具体接口可能不同
    // this.cameraManager.getSession()?.setExposureCompensation(value);
  }

  // 点击对焦
  onTapFocus(event: GestureEvent): void {
    // 获取点击坐标,映射到相机对焦点
    const x = event.fingerList[0].localX;
    const y = event.fingerList[0].localY;
    // 设置对焦区域(需要根据预览画面尺寸做归一化处理)
    // this.cameraManager.getSession()?.setFocusPoint({ x: x / previewWidth, y: y / previewHeight });
  }

  build() {
    Column() {
      // 预览区域
      XComponent({
        id: 'cameraXComponent',
        type: 'surface',
        controller: new XComponentController()
      })
        .width('100%')
        .aspectRatio(4 / 3)
        .onClick((event) => {
          this.onTapFocus(event);
        })

      // 模式切换按钮
      Row() {
        Button('📷 拍照')
          .onClick(() => { this.currentMode = 'photo'; })
        Button('🎥 录像')
          .onClick(() => { this.currentMode = 'video'; })
      }
      .padding(10)

      // 拍摄按钮
      Button(this.currentMode === 'photo' ? '拍照' : (this.isRecording ? '停止' : '开始'))
        .width(80).height(80)
        .backgroundColor(Color.Red)
        .borderRadius(40)
        .onClick(() => {
          if (this.currentMode === 'photo') {
            this.takePhoto();
          } else {
            this.toggleRecording();
          }
        })

      // 相机设置栏
      Row() {
        Button('切换镜头').onClick(() => { this.switchCamera(); })
        Slider({
          value: this.exposureValue,
          min: -2,
          max: 2,
          step: 0.5
        })
          .onChange((val) => { this.setExposure(val); })
      }
      .padding(10)

      // 媒体库浏览按钮
      Button('浏览相册')
        .onClick(() => {
          this.mediaPreviewManager.openGallery();
        })
    }
    .width('100%')
    .height('100%')
  }
}

注意:上面的代码中,录像部分的session管理做了简化。在实际项目中,切换到录像模式需要重新创建带有videoOutput的session,并且不能影响预览。比较稳妥的做法是先停止当前session,再建一个新的session。这里为了保持代码可读性,使用了一个createSession的简单调用,生产环境需要更精细的控制。

第五步:PhotoManager - 拍照管理

// model/PhotoManager.ts
import { camera } from '@kit.CameraKit';
import { image } from '@kit.ImageKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { common } from '@kit.AbilityKit';

export class PhotoManager {
  private photoOutput: camera.PhotoOutput | null = null;
  private onPhotoAvailableCallback: ((photo: image.PixelMap) => void) | null = null;

  setPhotoAvailableCallback(callback: (photo: image.PixelMap) => void): void {
    this.onPhotoAvailableCallback = callback;
  }

  async takePicture(output: camera.PhotoOutput): Promise<void> {
    this.photoOutput = output;
    // 注册拍照完成回调
    this.photoOutput.on('photoAvailable', (err, photo) => {
      if (err) {
        console.error('Photo capture failed', err);
        return;
      }
      // 获取图片PixelMap
      const pixelMap = photo.main;
      if (this.onPhotoAvailableCallback) {
        this.onPhotoAvailableCallback(pixelMap);
      }
    });

    // 开始拍照
    await this.photoOutput.capture();
  }
}

关键点

  • photoAvailable回调里拿到的photo.main是完整的JPEG数据。如果只需要缩略图,可以用photo.thumbnail
  • 拍照完成后,需要将PixelMap保存到媒体库。这里注册一个回调,由外部处理,保持Manager的职责单一。

第六步:VideoManager - 录像管理

// model/VideoManager.ts
import { camera } from '@kit.CameraKit';
import { videoAccessHelper } from '@kit.MediaLibraryKit';

export class VideoManager {
  private videoOutput: camera.VideoOutput | null = null;
  private onVideoAvailableCallback: ((uri: string) => void) | null = null;

  setVideoAvailableCallback(callback: (uri: string) => void): void {
    this.onVideoAvailableCallback = callback;
  }

  async startRecording(output: camera.VideoOutput): Promise<void> {
    this.videoOutput = output;
    // 监听录像完成
    this.videoOutput.on('videoAvailable', (err, video) => {
      if (err) {
        console.error('Video capture failed', err);
        return;
      }
      // 获取视频文件的URI
      const videoUri = video.uri;
      if (this.onVideoAvailableCallback) {
        this.onVideoAvailableCallback(videoUri);
      }
    });

    // 开始录像
    await this.videoOutput.start();
    console.log('Recording started');
  }

  async stopRecording(): Promise<void> {
    if (this.videoOutput) {
      await this.videoOutput.stop();
      console.log('Recording stopped');
    }
  }
}

注意:录像结束时,videoAvailable回调会被触发。视频文件已经在系统相册的临时目录生成,回调提供的是这个文件的URI。如果在录像过程中切换摄像头或退出页面,需要先停止录像,否则会生成一个空的或损坏的视频文件。

第七步:MediaPreviewManager - 媒体浏览

// model/MediaPreviewManager.ts
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { common } from '@kit.AbilityKit';
import { Want } from '@kit.AbilityKit';

export class MediaPreviewManager {
  async savePhoto(pixelMap: image.PixelMap): Promise<string> {
    // 使用PhotoAccessHelper保存图片到媒体库
    const context = getContext(this) as common.UIAbilityContext;
    const helper = photoAccessHelper.getPhotoAccessHelper(context);
    const uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg');
    // 将PixelMap写入URI对应的文件
    // 具体写入逻辑略(需要File IO操作)
    return uri;
  }

  saveVideo(videoUri: string): string {
    // 录像完成后,视频文件已在系统相册的临时目录下
    // 如果需要移动到Pictures目录,可以使用MediaLibrary的moveFile方法
    return videoUri;
  }

  openGallery(): void {
    // 打开系统相册
    const context = getContext(this) as common.UIAbilityContext;
    const want: Want = {
      bundleName: 'com.huawei.photos',
      abilityName: 'com.huawei.photos.MainAbility'
    };
    context.startAbility(want).catch((err) => {
      console.error('Failed to open gallery', err);
    });
  }
}

这里有一个常见的坑:用startAbility打开系统相册时,bundleName和abilityName可能会因系统版本不同而不同。更稳妥的方式是使用photoAccessHelperstartPicker方法,或者直接让用户通过FilePicker选择文件。这里做了简化处理。

踩坑章节:几个你一定会遇到的问题

坑1:触摸区域对焦无效

现象:触摸预览画面,肉眼能看到点击事件被触发,但画面不对焦、不改变曝光。

原因:触摸事件获取的坐标是相对于XComponent的坐标,而Camera Kit的对焦API需要归一化的坐标(0.0 - 1.0),并且坐标系原点是预览画面的左上角。如果直接传递像素坐标,相机系统会读取到一个错误的区域。

解法

const normalizedX = touchX / previewWidth; // previewWidth是XComponent的宽度
const normalizedY = touchY / previewHeight;
// 然后调用setFocusPoint({ x: normalizedX, y: normalizedY })

另一个容易忽略的点:setFocusPoint需要在session已经commitConfig之后才能调用。如果session处于Config状态,接口会返回错误。

坑2:切换录像模式后预览黑屏

现象:从拍照模式切换到录像模式,预览画面变黑,但日志没有报错。

原因:Session的配置里,每个output只能属于一个session。当你在拍照模式下创建了PhotoOutput,切换到录像模式时直接添加VideoOutput但不释放旧的session,相机框架会报错(但日志可能不明显)。更常见的做法是切换模式时,先释放当前的session,再创建一个新的session,然后为新的session添加对应的output。

解法

async switchToVideoMode(): Promise<void> {
  await this.cameraManager.releaseSession();
  await this.cameraManager.createVideoOutput(this.previewSurfaceId);
  await this.cameraManager.createSession(); // 这个session只包含preview + videoOutput
}

注意:在重新创建session时,如果要保持预览不断,可以尝试复用同一个previewOutput。但因为createSession的过程会stop之前的output,所以预览会有短暂中断。这是当前API的局限,希望后续版本能优化。

坑3:录像文件大小为0

现象:录制完成后,视频文件大小为0KB,无法播放。

原因:录制过程中,如果应用的UI线程卡顿或出现异常,Camera Kit可能不会正常写入数据。更常见的原因是:录制时没有添加MICROPHONE权限,导致音频流失败,整体录制中止。或者录像output的SurfaceId和预览用的是同一个,导致编码器冲突。

解法:先确认权限列表包含MICROPHONE。录像需要单独的Surface(或编码器Surface),不要直接复用预览的Surface。如果必须共享一个XComponent做显示和录制,需要使用createVideoOutput时传入的是AVCodec创建的编码Surface,而不是显示Surface。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐