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

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];
}
}
这段代码的几个关键点:
- 使用
SceneMode.NORMAL_PHOTO创建session,这样可以在同一个session里同时支持拍照和录像。如果用错了SceneMode(比如用了PORTRAIT),录像功能可能不可用。 releaseSession里加了一个finally块,防止报错后状态漏复位。switchCamera不直接做完整切换,只改变索引。外部调用时需要先释放旧的session,再创建新的。这样做的原因是相机参数校准、output重建需要较多异步操作,放在Manager里会拉长方法长度。实际项目中可以按需调整。- 预览、拍照、录像的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可能会因系统版本不同而不同。更稳妥的方式是使用photoAccessHelper的startPicker方法,或者直接让用户通过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。
更多推荐

所有评论(0)