完整源码:ArtCamera
相机开发涉及大量细节,为避免篇幅过长,本文将专注于应用的架构设计、模块划分和核心功能实现,按照开发顺序逐步讲解:从权限申请、XComponent 创建、相机初始化,到预览、拍照、保存相册。关键代码附详细注释。完整项目包含所有工具类、组件和详细注释,可直接克隆运行。
下一篇将专门讲解开发中遇到的六大典型问题及其解决方案。
一、项目目标
开发一款鸿蒙原生相机应用,具备以下功能:
- 实时预览,画面按用户选择的比例(1:1、3:4、9:16)显示,无黑边、无拉伸(采用 cover 裁剪模式)
- 拍摄照片比例与预览一致
- 支持前后摄像头切换
- 支持闪光灯(后置)
- 拍照后底部小圆框立即显示最新缩略图
- 横竖屏拍照方向自动正确
运行效果
完整的gif质量太大,拍照保存以后相册出现同等比例的照片。

二、整体架构
采用 MVVM 模式,模块划分如下:
entry/src/main/ets/
├── pages/Index.ets # 主页面,状态管理,权限协调
├── components/
│ ├── CameraPreview.ets # 预览组件(cover裁剪模式)
│ ├── Toolbar.ets # 底部工具栏(比例、拍照、切换摄像头)
│ ├── TopBar.ets # 顶部栏(闪光灯)
│ └── ThumbnailView.ets # 缩略图组件(点击放大查看待实现)
├── utils/
│ ├── CameraController.ets # 相机核心:初始化、会话、拍照、切换
│ ├── CameraUtils.ets # Profile匹配、比例转换工具
│ ├── PhotoSaveHelper.ets # 保存照片、获取最近缩略图
│ ├── PermissionHelper.ets # 权限申请与二次引导
│ └── SensorHelper.ets # 重力传感器(仅UI方向,不用于拍照)
├── constants/CameraConstants.ets # 比例常量、闪光灯模式数组
└── model/ # 类型定义(Resolution、FlashModeItem等)
核心流程:
启动 → 请求相机权限 → XComponent创建surface → 初始化相机 → 匹配Profile → 启动会话 → 预览显示
拍照 → capture → 收到photoAsset → 提取缩略图&临时文件 → 更新UI缩略图 → 保存到相册
切换比例/摄像头 → 重新初始化相机 → 重新匹配Profile → 预览自动适配
三、逐步实现过程
3.1 配置权限
在 src/main/module.json5 中声明相机权限:
json
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.CAMERA",
"reason": "$string:permission_camera_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
同时在 src/main/resources/base/element/string.json 中添加权限说明文本。
3.2 定义常量与工具函数
文件:constants/CameraConstants.ets
定义支持的比例类型、闪光灯模式等常量,供全局使用。
javascript
import { camera } from '@kit.CameraKit';
import { FlashModeItem } from '../model/FlashModeItem';
export type AspectRatio = '1:1' | '3:4' | '9:16';
export const ASPECT_RATIOS: AspectRatio[] = ['1:1', '3:4', '9:16'];
export const FLASH_MODES: FlashModeItem[] = [
{ mode: camera.FlashMode.FLASH_MODE_CLOSE, icon: $r('app.media.flash_off') },
{ mode: camera.FlashMode.FLASH_MODE_OPEN, icon: $r('app.media.flash_on') },
{ mode: camera.FlashMode.FLASH_MODE_AUTO, icon: $r('app.media.flash_auto') }
];
文件:utils/CameraUtils.ets
实现比例转换和 Profile 匹配算法。注意:相机 Profile 是横屏宽高比,UI 比例是竖屏,需要取倒数匹配。
javascript
import { camera } from '@kit.CameraKit';
import { AspectRatio } from '../constants/CameraConstants';
export function getRatioValue(ratio: AspectRatio): number {
if (ratio === '1:1') return 1;
if (ratio === '3:4') return 3 / 4;
return 9 / 16;
}
export function findBestProfile(
profiles: camera.Profile[],
targetRatio: number,
minWidth: number,
minHeight?: number
): camera.Profile | undefined {
if (!profiles.length) return undefined;
// 1. 筛选比例完全相同(误差<0.01)
const exactMatch = profiles.filter(p => Math.abs(p.size.width / p.size.height - targetRatio) < 0.01);
if (exactMatch.length === 0) {
// 降级:选比例最接近的
let best: camera.Profile | undefined;
let minDiff = Infinity;
for (const p of profiles) {
const diff = Math.abs(p.size.width / p.size.height - targetRatio);
if (diff < minDiff) {
minDiff = diff;
best = p;
}
if (diff < 0.001) break;
}
return best;
}
// 2. 比例匹配中,优先选宽高都满足的最小分辨率(性能最优)
if (minHeight !== undefined) {
const sufficient = exactMatch.filter(p => p.size.width >= minWidth && p.size.height >= minHeight);
if (sufficient.length) {
sufficient.sort((a, b) => a.size.width - b.size.width);
return sufficient[0];
}
}
// 3. 只满足宽度(高度可能小于屏幕高度,但预览时通过surfaceRect裁剪)
const widthEnough = exactMatch.filter(p => p.size.width >= minWidth);
if (widthEnough.length) {
widthEnough.sort((a, b) => a.size.width - b.size.width);
return widthEnough[0];
}
// 4. 选分辨率最大的兜底
exactMatch.sort((a, b) => b.size.width - a.size.width);
return exactMatch[0];
}
3.3 创建预览组件 CameraPreview
预览依赖 XComponent,我们需要在组件中动态设置 surfaceRect 以实现无拉伸显示。采用 cover 裁剪模式:使画面填满容器,超出部分裁剪。
javascript
@Component
export struct CameraPreview {
@Prop @Watch('onProfileChanged') previewProfileWidth: number = 0;
@Prop @Watch('onProfileChanged') previewProfileHeight: number = 0;
onSurfaceReady?: (surfaceId: string) => void;
private xComponentController: XComponentController = new XComponentController();
private containerWidth: number = 0;
private containerHeight: number = 0;
private onProfileChanged() {
if (this.containerWidth > 0 && this.containerHeight > 0) {
this.updateSurfaceRect(this.containerWidth, this.containerHeight);
}
}
private updateSurfaceRect(containerWidthPx: number, containerHeightPx: number) {
if (!this.previewProfileWidth || !this.previewProfileHeight) {
this.xComponentController.setXComponentSurfaceRect({
surfaceWidth: containerWidthPx,
surfaceHeight: containerHeightPx
});
return;
}
const profileRatio = this.previewProfileWidth / this.previewProfileHeight;
const containerRatio = containerWidthPx / containerHeightPx;
let drawWidth: number, drawHeight: number;
let offsetX = 0, offsetY = 0;
// cover算法:宽度或高度填满,超出的部分通过offset居中裁剪
if (profileRatio >= containerRatio) {
drawWidth = containerWidthPx;
drawHeight = drawWidth / profileRatio;
offsetY = (containerHeightPx - drawHeight) / 2;
} else {
drawHeight = containerHeightPx;
drawWidth = drawHeight * profileRatio;
offsetX = (containerWidthPx - drawWidth) / 2;
}
this.xComponentController.setXComponentSurfaceRect({
surfaceWidth: drawWidth,
surfaceHeight: drawHeight,
offsetX: offsetX,
offsetY: offsetY
});
}
build() {
XComponent({ type: XComponentType.SURFACE, controller: this.xComponentController })
.onAttach(() => {
console.info('CameraPreview onAttach');
const surfaceId = this.xComponentController.getXComponentSurfaceId();
if (surfaceId && this.onSurfaceReady) {
this.onSurfaceReady(surfaceId);
}
})
.onAreaChange((oldValue: Area, newValue: Area) => {
const widthVp = newValue.width as number;
const heightVp = newValue.height as number;
if (widthVp > 0 && heightVp > 0) {
const widthPx = this.getUIContext().vp2px(widthVp);
const heightPx = this.getUIContext().vp2px(heightVp);
this.updateSurfaceRect(widthPx, heightPx);
}
})
.width('100%')
.height('100%')
}
}
3.4 实现相机控制器 CameraController
控制器负责所有相机底层操作:初始化、拍照、切换摄像头/比例、释放资源。核心是 setupCamera 方法,步骤包括:
- 获取
CameraManager - 选择相机设备(后置/前置)
- 创建
CameraInput并打开 - 获取输出能力
CameraOutputCapability,得到预览和拍照的 Profile 列表 - 转换 UI 比例为横屏比例
targetProfileRatio = 1 / getRatioValue(currentRatio) - 匹配最佳预览 Profile
- 使用预览 Profile 的宽高比匹配拍照 Profile
- 创建
PreviewOutput和PhotoOutput - 创建
PhotoSession,配置输入输出,提交并启动会话 - 注册
photoAssetAvailable回调,生成缩略图和临时文件
关键代码片段:
javascript
private async setupCamera(): Promise<boolean> {
if (!this.context || !this.surfaceId) return false;
await this.release();
try {
this.cameraManager = camera.getCameraManager(this.context);
const cameras = this.cameraManager.getSupportedCameras();
const cameraDevice = cameras[this.currentCameraPos];
this.cameraInput = this.cameraManager.createCameraInput(cameraDevice);
await this.cameraInput.open();
const outputCap = this.cameraManager.getSupportedOutputCapability(cameraDevice, camera.SceneMode.NORMAL_PHOTO);
const previewProfiles = outputCap.previewProfiles;
const photoProfiles = outputCap.photoProfiles;
const uiRatio = getRatioValue(this.currentRatio);
const targetProfileRatio = 1 / uiRatio;
const screenWidth = display.getDefaultDisplaySync().width;
const screenHeight = display.getDefaultDisplaySync().height;
const previewProfile = findBestProfile(previewProfiles, targetProfileRatio, screenWidth, screenHeight);
if (!previewProfile) throw new Error('no preview profile');
if (this.onPreviewProfileSelected) {
this.onPreviewProfileSelected(previewProfile.size.width, previewProfile.size.height);
}
const targetPhotoRatio = previewProfile.size.width / previewProfile.size.height;
let photoProfile = findBestProfile(photoProfiles, targetPhotoRatio, screenWidth, screenHeight);
if (!photoProfile && photoProfiles.length) photoProfile = photoProfiles[0];
if (!photoProfile) throw new Error('no photo profile');
this.previewOutput = this.cameraManager.createPreviewOutput(previewProfile, this.surfaceId);
this.photoOutput = this.cameraManager.createPhotoOutput(photoProfile);
this.setupPhotoOutputCallback();
this.session = this.cameraManager.createSession(camera.SceneMode.NORMAL_PHOTO) as camera.PhotoSession;
this.session.beginConfig();
this.session.addInput(this.cameraInput);
this.session.addOutput(this.previewOutput);
this.session.addOutput(this.photoOutput);
await this.session.commitConfig();
await this.session.start();
return true;
} catch (err) {
await this.release();
return false;
}
}
拍照回调中生成缩略图和临时文件:
javascript
private setupPhotoOutputCallback(): void {
if (!this.photoOutput) return;
const context = this.context;
this.photoOutput.on('photoAssetAvailable', async (err: BusinessError, photoAsset: photoAccessHelper.PhotoAsset) => {
if (err) {
console.error(`photoAssetAvailable error: ${JSON.stringify(err)}`);
return;
}
try {
const srcUri = photoAsset.uri;
if (!srcUri) throw new Error('photoAsset.uri is empty');
if (!context) throw new Error('context is null');
const tempDir = context.tempDir;
const fileName = `temp_${Date.now()}.jpg`;
const tempFilePath = `${tempDir}/${fileName}`;
// 复制到临时文件中,用完关闭
const srcFile = fs.openSync(srcUri, fs.OpenMode.READ_ONLY);
const destFile = fs.openSync(tempFilePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
fs.copyFileSync(srcFile.fd, destFile.fd);
fs.closeSync(srcFile);
fs.closeSync(destFile);
try {
fs.accessSync(tempFilePath);
} catch (e) {
console.error(`temp file not accessible after copy: ${JSON.stringify(e)}`);
}
if (this.onPhotoCaptured) {
// 缩略图两种方式都可以
// const fileImageSource = image.createImageSource(tempFilePath);
// const thumbPixelMap = await fileImageSource.createPixelMap({
// desiredSize: { width: 200, height: 200 },
// desiredPixelFormat: image.PixelMapFormat.RGBA_8888
// });
// this.onPhotoCaptured(thumbPixelMap, tempFilePath);
const thumbnail = await photoAsset.getThumbnail();
this.onPhotoCaptured(thumbnail, tempFilePath);
}
} catch (e) {
console.error(`process photo asset error: ${JSON.stringify(e)}`);
}
});
}
拍照时固定使用 ROTATION_0,让系统自动处理方向:
javascript
public async takePhoto(rotation = camera.ImageRotation.ROTATION_0): Promise<void> {
const settings = {
quality: camera.QualityLevel.QUALITY_LEVEL_HIGH,
rotation: rotation,
mirror: this.currentCameraPos === 1
};
await this.photoOutput.capture(settings);
}
3.5 实现权限申请与页面生命周期
主页面 Index.ets 负责权限申请、状态管理、协调子组件。
权限申请 :使用自定义的 PermissionHelper,它封装了首次请求和二次引导(弹窗提示用户去设置中开启)。
javascript
aboutToAppear() {
PermissionHelper.requestWithFallback(
this.context,
REQUIRED_PERMISSIONS,
() => {
this.hasPermission = true;
this.loadLatestThumbnail(); // 异步加载相册最新缩略图(仅展示,不影响相机)
this.initCamera();
},
() => {
this.hasPermission = false;
this.showToast('未获得相机权限,部分功能无法使用');
}
);
}
XComponent 的 surface 就绪回调:
javascript
private onSurfaceReady = (surfaceId: string) => {
this.surfaceId = surfaceId;
this.initCamera(); // 如果权限已授予且未初始化,会触发相机初始化
}
相机初始化方法(双重保险,避免因执行顺序导致黑屏):
javascript
private async initCamera() {
if (!this.hasPermission || !this.surfaceId || this.isCameraInitialized) return;
const pos = this.isFrontCamera ? 1 : 0;
const success = await this.cameraController.init(this.surfaceId, this.context, pos, this.currentRatio);
if (success) {
this.isCameraInitialized = true;
this.cameraController.setFlashMode(this.flashMode);
}
this.cameraController.setPhotoCapturedCallback(async (thumbnail, tempFilePath) => {
this.thumbnailPixelMap = thumbnail;
await PhotoSaveHelper.saveWithDialog(tempFilePath, this.context);
});
this.cameraController.setPreviewProfileCallback((w, h) => {
this.previewProfileWidth = w;
this.previewProfileHeight = h;
});
}
拍照与缩略图更新:
javascript
private async takePhoto() {
if (!this.hasPermission) return;
await this.cameraController.takePhoto(camera.ImageRotation.ROTATION_0);
}
3.6 缩略图显示的关键:@Link 引用传递
在 Toolbar 组件中必须使用 @Link 接收缩略图 PixelMap,父组件可以直接传递 this.thumbnailPixelMap 或使用 $thumbnailPixelMap(两者均可)。注意:不能使用 @Prop ,因为 PixelMap 是 C++ 原生句柄,@Prop 的深拷贝会导致数据丢失。
javascript
// 父组件 Index.ets
@State thumbnailPixelMap: PixelMap | undefined;
Toolbar({ thumbnailPixelMap: this.thumbnailPixelMap }) // 或使用 $thumbnailPixelMap
// 子组件 Toolbar.ets
@Link thumbnailPixelMap: PixelMap | undefined;
Image(this.thumbnailPixelMap)
.width(56).height(56).borderRadius(28).objectFit(ImageFit.Cover)
实测结论 :无论是相机生成的
PixelMap还是从本地资源加载的PixelMap,都无法通过@Prop传递,必须使用@Link或AppStorage。
3.7 保存照片到相册
使用 photoAccessHelper.showAssetsCreationDialog 弹出系统保存窗口,用户确认后自动复制文件到相册,无需额外申请读写权限。
javascript
public static async saveWithDialog(tempFilePath: string, context: common.UIAbilityContext): Promise<boolean> {
const srcUris = [fileUri.getUriFromPath(tempFilePath)];
const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
const photoCreationConfigs = [{
fileNameExtension: 'jpg',
photoType: photoAccessHelper.PhotoType.IMAGE,
subtype: photoAccessHelper.PhotoSubtype.DEFAULT
}];
const desUris = await phAccessHelper.showAssetsCreationDialog(srcUris, photoCreationConfigs);
if (desUris.length === 0) return false;
// 分块复制文件到目标URI,然后删除临时文件
// ...(具体实现见仓库)
return true;
}
四、总结
通过以上步骤完成了相机应用的基础功能:权限申请、预览显示、拍照、保存相册、缩略图更新。
在开发过程中,我遇到了不少问题,例如预览拉伸、照片比例错误、方向错乱、黑屏、缩略图不显示等。这些问题将在下一篇中详细分析原因并提供解决方案。
自定义相机一定会遇到的问题总结:
- 预览画面严重拉伸(比例转换 + cover 裁剪)
- 拍出的照片是正方形(拍照 Profile 与预览同比例)
- 斜着拍照方向错误(固定
ROTATION_0) - 动态切换比例后预览不更新(清空预览尺寸 +
@Watch) - 首次安装同意权限后黑屏(权限回调和
onSurfaceReady双保险) - 拍照后缩略图无法显示(
@Link引用传递代替@Prop深拷贝)
如果你觉得本文有帮助,请点赞、收藏、转发支持!