完整源码:ArtCamera 从一脸懵逼到流畅运行,我用两天时间踩遍了鸿蒙相机开发的坑,最终逐一攻克了预览拉伸、照片比例错误、斜拍方向乱、切换比例失效、首次安装黑屏、缩略图不显示六大难题。 本文完整记录问题现象、原因分析和解决方案,关键代码附详细注释。完整源码见仓库。
一、背景与需求
开发一款鸿蒙原生相机应用,功能包括:
- 实时预览,画面按用户选择的比例(1:1、3:4、9:16)显示,无黑边、无拉伸(采用 cover 裁剪模式)
- 拍摄照片比例与预览一致
- 支持前后摄像头切换、闪光灯调节
- 拍照后底部小圆框立即显示缩略图
- 横竖拍照片方向自动正确
看似简单,实际开发中却接连踩坑。本文将逐一复盘,并给出最终稳定的解决方案。
二、整体架构
采用 MVVM 模式,模块划分如下:
entry/src/main/ets/
├── pages/
│ └── Index.ets # 主页面,状态管理,权限协调
├── components/
│ ├── CameraPreview.ets # 封装 XComponent,动态计算 surfaceRect
│ ├── Toolbar.ets # 底部工具栏(比例、拍照、切换摄像头)
│ ├── TopBar.ets # 顶部栏(闪光灯)
│ └── ThumbnailView.ets # 查看缩略图组件(待实现)
├── utils/
│ ├── CameraController.ets # 相机核心:初始化、会话、拍照、切换
│ ├── CameraUtils.ets # Profile 匹配、比例转换工具
│ ├── PhotoSaveHelper.ets # 保存照片、获取缩略图
│ ├── PermissionHelper.ets # 权限申请与二次引导
│ └── SensorHelper.ets # 重力传感器(方向感知)
├── constants/
│ └── CameraConstants.ets # 比例常量、闪光灯模式数组
└── model/
├── Resolution.ets
├── FlashModeItem.ets
└── RotationResult.ets
核心流程:
启动 → 请求相机权限 → XComponent 创建 surface → 初始化相机 → 匹配 Profile → 会话启动 → 预览显示
拍照 → capture → 收到 photoAsset → 提取缩略图 → 同步到子组件(@Link) → 保存到相册
切换比例/摄像头 → 重新初始化相机 → 重新匹配 Profile → 预览自动适配
三、六大技术难点与解决方案
难点 1:预览画面严重拉伸
现象
选择 9:16 比例,预览图像被横向拉宽,人物变形。
定位
打印相机返回的 previewProfile,发现实际选中的是 1440x1440(1:1)。原因是 UI 比例 9:16 对应竖屏宽高比 9/16 = 0.5625,而相机硬件输出的 PreviewProfile 列表全是横屏宽高比(例如 1920x1080 比例 16/9≈1.777)。用 0.5625 去匹配横屏列表,找不到相近项,fallback 选了宽度>=屏幕宽度的最小分辨率,结果选到 1440x1440。
解决方案
将 UI 比例(竖屏)转换为相机需要的横屏比例:targetProfileRatio = 1 / uiRatio。例如 9:16 → 16:9 ≈1.777,这样就能准确匹配到 1920x1080 等正确 Profile。
核心代码(CameraController.ets 中的 setupCamera 方法):
javascript
private async setupCamera(): Promise<boolean> {
// ... 省略:获取 cameraManager、cameraDevice、创建 cameraInput 并打开等 ...
const previewProfiles = outputCap.previewProfiles;
const photoProfiles = outputCap.photoProfiles;
// 关键:将 UI 竖屏比例转换为相机横屏比例
const uiRatio = getRatioValue(this.currentRatio); // 9:16 -> 0.5625
const targetProfileRatio = 1 / uiRatio; // 16:9 ≈1.777
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');
// ... 省略:回调通知上层预览尺寸 ...
// 拍照 Profile 使用与预览相同的比例
const targetPhotoRatio = previewProfile.size.width / previewProfile.size.height; // 16:9
let photoProfile = findBestProfile(photoProfiles, targetPhotoRatio, screenWidth, screenHeight);
// ... 省略:创建 previewOutput、photoOutput、session,配置并启动会话等 ...
return true;
}
同时,为了让预览画面填满 UI 容器且不变形,在 CameraPreview 组件内使用 cover 裁剪模式 (类似 CSS object-fit: cover):
javascript
// CameraPreview.ets 中的 updateSurfaceRect 方法
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 模式:surface 完全覆盖容器,超出部分裁剪
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, offsetY
});
}
难点 2:拍出的照片是正方形
现象
预览比例 9:16 正常,但拍摄出来的照片却是 1:1 的正方形。
原因
选择 photoProfile 时没有与预览比例保持一致,可能 fallback 选中了 1:1 的拍照分辨率。
解决方案
使用预览 Profile 的实际宽高比来匹配拍照 Profile(已在上面 setupCamera 中体现)。
核心代码(同上 setupCamera 中的关键行):
javascript
const targetPhotoRatio = previewProfile.size.width / previewProfile.size.height; // 16:9
let photoProfile = findBestProfile(photoProfiles, targetPhotoRatio, screenWidth, screenHeight);
确保预览和拍照输出比例完全一致。
难点 3:斜着拍照方向错误
现象
竖拍正常,横拍或倒置手机时,拍出的照片方向旋转了 90° 或 180°。
原因
手动传入传感器计算的 rotation 角度给 capture 方法。但 HarmonyOS 相机 API 会自动根据设备方向将正确的方向信息写入 EXIF,手动传入会导致双重旋转。
解决方案
拍照时固定使用 camera.ImageRotation.ROTATION_0,完全依赖系统自动修正。
核心代码(Index.ets 中的 takePhoto 方法):
javascript
private async takePhoto(): Promise<void> {
if (!this.hasPermission) return;
await this.cameraController.takePhoto(camera.ImageRotation.ROTATION_0);
}
CameraController.ets 中的 takePhoto 方法:
javascript
public async takePhoto(rotation: camera.ImageRotation = camera.ImageRotation.ROTATION_0): Promise<void> {
if (!this.photoOutput) return;
const settings: camera.PhotoCaptureSetting = {
quality: camera.QualityLevel.QUALITY_LEVEL_HIGH,
rotation: rotation, // 传入 0
mirror: this.currentCameraPos === 1
};
await this.photoOutput.capture(settings);
}
难点 4:动态切换比例后预览不更新
现象
点击 1:1、3:4、9:16 按钮,相机虽然重新初始化了,但预览画面仍停留在旧比例。
原因
CameraPreview 组件中绑定的 previewProfileWidth/Height 没有及时清空,组件沿用旧值计算 surfaceRect。
解决方案
在切换比例时主动清空这两个变量,等待相机回调重新赋值。
核心代码(Index.ets 中的 handleChangeRatio 方法):
javascript
private async handleChangeRatio(ratio: AspectRatio): Promise<void> {
if (!this.hasPermission || this.currentRatio === ratio) return;
this.currentRatio = ratio;
this.previewAspectRatio = getRatioValue(ratio);
// 清空预览尺寸,等待相机重新回调
this.previewProfileWidth = 0;
this.previewProfileHeight = 0;
this.isCameraInitialized = false;
const success = await this.cameraController.changeRatio(ratio);
if (success) {
this.isCameraInitialized = true;
setTimeout(() => this.cameraController.setFlashMode(this.flashMode), 200);
}
}
同时在 CameraPreview 中使用 @Watch('onProfileChanged') 监听属性变化,一旦更新立即重新计算布局。
难点 5:首次安装同意权限后黑屏
现象
首次打开 App,弹出相机权限,点击同意后预览区域一片黑。
原因
XComponent 的 onAttach 可能在权限弹窗前就执行了,当时 hasPermission 为 false,没有调用 initCamera。用户同意权限后,onAttach 不会再触发,导致相机永远不会初始化。
解决方案
双重保险:在权限回调中主动调用 initCamera(),同时在 onSurfaceReady 中也调用一次。
核心代码(Index.ets 中的 aboutToAppear 和 onSurfaceReady):
javascript
aboutToAppear() {
this.sensorHelper.startListening((rotation: camera.ImageRotation) => {
this.currentRotation = rotation;
});
PermissionHelper.requestWithFallback(
this.context,
REQUIRED_PERMISSIONS,
() => {
this.hasPermission = true;
this.loadLatestThumbnail();
this.initCamera();
},
() => {
this.hasPermission = false;
this.showToast('未获得相机权限,部分功能无法使用');
}
);
}
private onSurfaceReady = (surfaceId: string): void => {
this.surfaceId = surfaceId;
this.initCamera();
};
initCamera 内部会检查所有条件(权限、surfaceId、已初始化标志),多次调用是安全的。
难点 6:拍照后缩略图无法显示(核心原因:@Prop 深拷贝导致 Native 句柄丢失)
现象
拍照回调中拿到的 thumbnail (PixelMap) 非空,能打印出宽高。但通过 @Prop 传递给子组件 Toolbar 后,Image 始终不显示。
尝试了 clone()、从文件重新生成、直接用 @State 在父组件显示------父组件正常,子组件不行。
关键实验:
- 从本地资源加载的
PixelMap通过@Prop传给子组件 → ❌ 不显示 - 相机回调中得到的
thumbnail通过@Prop传给子组件 → ❌ 不显示 - 将
@Prop改为@Link传递同一个thumbnail→ ✅ 正常显示
根本原因
通过断点发现 PixelMap 是 C++ 原生对象句柄,其内部结构包含一个 _napiwrapper 和 Symbol(Symbol.nativeBinding) = [External: 0x...]。
@Prop采用深拷贝/序列化方式传递数据,对于普通 JS 对象没问题,但对于这种原生句柄,深拷贝无法复制底层像素缓冲区,强制序列化拷贝后原生句柄丢失,PixelMap 失效。@Link采用引用传递(浅拷贝) ,父子组件共享同一个对象实例,因此原生句柄有效。或者你也可以采用AppStorage全局应用共享,但不如@Link便捷。
结论 PixelMap 对象不能使用 @Prop 深拷贝,必须使用 @Link 或 AppStorage。
核心代码(父组件 Index.ets):
javascript
@State thumbnailPixelMap: string | Resource | PixelMap | undefined = undefined;
// 拍照回调中直接赋值
this.cameraController.setPhotoCapturedCallback(async (thumbnail: image.PixelMap, tempFilePath: string) => {
const saved = await PhotoSaveHelper.saveWithDialog(tempFilePath, this.context);
this.thumbnailPixelMap = thumbnail;
this.showToast(saved ? '已保存到相册' : '保存失败');
});
子组件 Toolbar.ets:
javascript
@Link thumbnailPixelMap: string | Resource | PixelMap; // 注意是 @Link 不是 @Prop
build() {
Row() {
Image(this.thumbnailPixelMap)
.width(56).height(56).borderRadius(28).border({ width: 1, color: Color.White })
.onClick(() => this.onThumbnailPress?.())
// ... 其他按钮
}
}
父组件传值 (在 Index.ets 的 build 中):
javascript
Toolbar({
currentRatio: this.currentRatio,
thumbnailPixelMap: this.thumbnailPixelMap, // 注意:此处直接传递,未使用 $ 符号
// ... 其他参数
})
核心规则总结:
@Prop适合:string/number/boolean/简单可序列化对象@Link适合:引用类型、原生句柄、需要实时共享的数据- 相机回调中的
PixelMap属于生命周期敏感的原生句柄 ,必须用@Link
四、核心技术选型与工具函数
4.1 Profile 匹配算法(CameraUtils.ets)
完整实现
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. 只满足宽度
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];
}
4.2 照片保存到相册(PhotoSaveHelper)
使用 showAssetsCreationDialog 弹窗授权保存临时文件:
javascript
public static async saveWithDialog(tempFilePath: string, context: common.UIAbilityContext): Promise<boolean> {
try {
fs.accessSync(tempFilePath);
const srcFileUris: string[] = [fileUri.getUriFromPath(tempFilePath)];
const photoCreationConfigs: photoAccessHelper.PhotoCreationConfig[] = [{
fileNameExtension: 'jpg',
photoType: photoAccessHelper.PhotoType.IMAGE,
subtype: photoAccessHelper.PhotoSubtype.DEFAULT
}];
const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
const desFileUris = await phAccessHelper.showAssetsCreationDialog(srcFileUris, photoCreationConfigs);
if (desFileUris.length === 0) return false;
// 分块复制文件到目标URI,然后删除临时文件
// ... 实现细节见仓库
return true;
} catch (err) {
console.error(`saveWithDialog error: ${JSON.stringify(err)}`);
return false;
}
}
4.3 传感器方向监听(用于预览画面跟随设备旋转)
完整 calculateRotation 函数实现:
javascript
// SensorHelper.ets
private calculateRotation(x: number, y: number, z: number): RotationResult {
let degree: number = -1;
// 如果设备平放(z轴分量过大),不改变方向
if ((x * x + y * y) * 3 < z * z) {
return { rotation: this.currentRotation, orientation: this.currentOrientation };
}
// 计算角度(0~360)
degree = 90 - Math.round(Math.atan2(y, -x) * 180 / Math.PI);
degree = degree >= 0 ? degree % 360 : degree % 360 + 360;
let rotation: camera.ImageRotation;
let orientation: DeviceOrientation;
if (degree >= 0 && (degree <= 45 || degree >= 315)) {
rotation = camera.ImageRotation.ROTATION_0;
orientation = 'portrait';
} else if (degree >= 45 && degree <= 135) {
rotation = camera.ImageRotation.ROTATION_270;
orientation = 'landscapeLeft';
} else if (degree >= 135 && degree <= 225) {
rotation = camera.ImageRotation.ROTATION_180;
orientation = 'portraitUpsideDown';
} else {
rotation = camera.ImageRotation.ROTATION_90;
orientation = 'landscapeRight';
}
return { rotation, orientation };
}
public startListening(onChange: (rotation: camera.ImageRotation, orientation: DeviceOrientation) => void): void {
if (this.isListening) return;
this.callback = onChange;
try {
sensor.on(sensor.SensorId.GRAVITY, (data: sensor.GravityResponse) => {
const result = this.calculateRotation(data.x, data.y, data.z);
if (this.currentRotation !== result.rotation || this.currentOrientation !== result.orientation) {
this.currentRotation = result.rotation;
this.currentOrientation = result.orientation;
if (this.callback) this.callback(result.rotation, result.orientation);
}
});
this.isListening = true;
} catch (err) {
console.error(`SensorHelper startListening error: ${JSON.stringify(err)}`);
}
}
注意 :预览画面的旋转仅影响显示效果,不影响最终照片的方向 。拍照时仍使用固定的
ROTATION_0,照片的方向由系统自动写入 EXIF 修正。
五、运行效果
经过上述修改,应用达到以下效果:
- 预览:9:16 时完全填满屏幕,比例准确,人物正常。
- 拍照:输出照片与预览比例一致,横竖拍方向自动正确。
- 切换比例:1:1、3:4、9:16 平滑切换,无残留画面。
- 缩略图 :拍照后底部圆框立即显示最新照片(
@Link双向同步,无延迟)。 - 首次安装:同意权限后正常预览,无黑屏。

六、踩坑总结与建议
- 比例转换:UI比例(竖)与相机Profile(横)互为倒数。
- 预览与拍照同比例:用预览的实际宽高比选拍照Profile。
- 拍照方向 :固定
ROTATION_0,让系统EXIF自动修正。 - Native对象跨组件传递 :
PixelMap必须使用@Link(引用传递),不能用@Prop(深拷贝会丢失句柄)。 - XComponent生命周期 :权限回调和
onSurfaceReady双保险调用initCamera。 - 日志要充足:打印选中的Profile、初始化结果、回调触发等,快速定位问题。
七、结语
两天的时间,从一脸迷茫到跑通所有功能,收获的不只是代码,更是对鸿蒙相机 API、状态管理、Native 对象传递机制的深刻理解。希望这篇总结能帮助更多开发者绕过这些坑,快速实现自己的相机应用。
如果你在开发中也遇到了奇怪的问题,欢迎在评论区交流。觉得有用的话,别忘了点赞、收藏、转发~