HarmonyOS趣味相机实战第1篇:工程结构、相机权限申请与真实拍摄主流程
摘要
相机类 App 的难点不在"页面放一个拍照按钮",而在权限、设备、预览、拍照、资源释放和本地保存这些环节必须连成闭环。D:/APP/1quweixiangji 是一个 HarmonyOS ArkTS 本地水印相机项目,当前已经覆盖相机权限申请、摄像头检测、前后摄像头切换、CameraKit 预览、定时拍照、拍照预览弹窗、本地相册保存和水印信息持久化。
本文作为这个项目系列的第 1 篇,先复盘最核心的拍摄主链路:
module.json5如何声明ohos.permission.CAMERA;EntryAbility为什么要初始化相册和文档服务;CameraPermissionService如何封装运行时权限申请;CameraDeviceService如何发现前后摄像头并选择可用镜头;Index.ets如何维护相机、预览、拍照和相册状态;XComponent如何提供 CameraKit 预览 Surface;CameraPreviewService如何启动PhotoSession、绑定PreviewOutput和PhotoOutput;- 拍照后如何拿到
PixelMap,生成CapturedPhoto,再保存进 Preferences 本地相册; - 最后给出调试排查和发布前验收清单。
这篇文章的目标是把"相机能拍"拆成可验证的工程步骤,而不是停留在 UI 效果图。
工程背景与源码定位
| 文件 | 作用 |
|---|---|
README.md |
项目说明、功能清单和构建命令 |
build-profile.json5 |
Stage 工程、target SDK、构建产物配置 |
AppScope/app.json5 |
应用包名、版本号、图标和应用名 |
entry/src/main/module.json5 |
EntryAbility、页面入口、相机权限声明 |
entry/src/main/ets/entryability/EntryAbility.ets |
应用启动入口,初始化相册和文档服务 |
entry/src/main/ets/pages/Index.ets |
主页面,串起拍摄、相册、文档、设置四个 Tab |
entry/src/main/ets/service/CameraPermissionService.ets |
运行时相机权限申请 |
entry/src/main/ets/service/CameraDeviceService.ets |
前后摄像头发现与镜头切换 |
entry/src/main/ets/service/CameraPreviewService.ets |
CameraKit 预览、拍照、输出释放 |
entry/src/main/ets/service/PhotoAlbumService.ets |
Preferences 本地相册持久化 |
entry/src/test/LocalUnit.test.ets |
水印照片快照和保存状态单元测试 |
环境与版本信息
| 项目 | 当前值 |
|---|---|
| 工程类型 | HarmonyOS Stage 模型 |
modelVersion |
6.0.2 |
| target SDK | 6.0.2(22) |
| compatible SDK | 6.0.2(22) |
| bundleName | com.fun.quweixiangji |
| versionName | 1.0.1 |
| 核心 Kit | @kit.AbilityKit、@kit.CameraKit、@kit.ArkData、@ohos.multimedia.image |
| 测试依赖 | @ohos/hypium: 1.0.25、@ohos/hamock: 1.0.0 |
构建命令可以参考项目 README:
powershell
$env:JAVA_HOME='D:\Program Files\Huawei\DevEco Studio\jbr'
$env:Path="$env:JAVA_HOME\bin;$env:Path"
& 'D:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat' --mode module -p module=entry@default -p product=default assembleHap --no-daemon
注意:正式上架前要检查签名配置、权限用途说明、隐私政策和真机兼容性。文章里不展示本地签名密码、证书路径等敏感配置,只讨论工程结构和运行逻辑。
一、项目为什么适合写成系列
1quweixiangji 不只是一个单页 Demo。README 里列出的已完成功能包括:
| 能力 | 工程意义 |
|---|---|
| 相机权限申请 | 涉及 module.json5 声明和运行时授权 |
| 摄像头检测 | 需要处理前置、后置、无摄像头、检测失败 |
| CameraKit 预览 | 需要 XComponent Surface 和 PhotoSession |
| 前后摄像头切换 | 需要可用镜头解析和重新启动预览 |
| 定时拍照 | 需要防重复点击和倒计时状态 |
| 本地相册 | 需要 Preferences 持久化和数据克隆 |
| 水印模板 | 需要拍照时保存水印快照 |
| 文档转换 | 需要从照片记录生成文档记录 |
| 单元测试 | 可以验证照片快照和保存状态 |
后续系列可以继续写:水印模板、相册持久化、文档转换、人物识别装饰、CoreVision 目标对齐、布局回归截图、设置项持久化等。第 1 篇先把相机主链路讲清楚。
二、相机权限声明
相机权限首先要在 entry/src/main/module.json5 中声明:
json
{
"requestPermissions": [
{
"name": "ohos.permission.CAMERA",
"reason": "$string:camera_permission_reason",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
}
]
}
这里有三个关键点:
| 字段 | 说明 |
|---|---|
name |
使用 ohos.permission.CAMERA |
reason |
指向资源字符串,用来解释权限用途 |
usedScene.when |
当前项目是前台使用相机,因此配置为 inuse |
相机权限不能只在代码里请求。如果 module.json5 没有声明,运行时请求即使写了也不会按预期工作。
三、启动入口初始化本地服务
EntryAbility.ets 在 onCreate 中做了两件事:
ts
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
try {
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
PhotoAlbumService.init(this.context);
PhotoDocumentService.init(this.context);
} catch (err) {
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
}
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
}
}
第一件事是颜色模式跟随系统,不强制亮暗色。第二件事是初始化本地相册和文档服务。这样 Index.ets 进入页面后调用 PhotoAlbumService.listPhotos() 时,Preferences 已经开始加载,页面不用自己处理底层存储初始化细节。
窗口创建时加载主页面:
ts
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
return;
}
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
});
四、主页面状态设计
Index.ets 是拍摄页、相册页、文档页、设置页的总入口。相机主状态集中在组件头部:
ts
@State cameraPermissionPromptVisible: boolean = false;
@State cameraPermissionStatus: CameraPermissionStatus = 'idle';
@State cameraStatusText: string = '相机未启用';
@State cameraAvailable: boolean = false;
@State cameraDeviceText: string = '尚未检测摄像头';
@State cameraTotalCount: number = 0;
@State hasFrontCamera: boolean = false;
@State hasBackCamera: boolean = false;
@State activeCameraPosition: CameraPositionKey = 'back';
@State previewSurfaceId: string = '';
@State previewStatus: CameraPreviewStatus = 'idle';
@State previewStatusText: string = '真实预览未启动';
@State captureStatusText: string = '等待拍照';
@State isCapturing: boolean = false;
这些状态可以分成五组:
| 状态组 | 字段 | 用途 |
|---|---|---|
| 权限 | cameraPermissionStatus、cameraPermissionPromptVisible |
控制授权弹窗和权限提示 |
| 设备 | cameraAvailable、hasFrontCamera、hasBackCamera、activeCameraPosition |
记录可用摄像头 |
| 预览 | previewSurfaceId、previewStatus、previewStatusText |
控制 XComponent Surface 和预览状态 |
| 拍照 | captureStatusText、isCapturing、timerSeconds、countdownText |
控制拍照按钮、倒计时和防重复 |
| 结果 | previewPhoto、previewPhotoPixelMap、album |
控制预览弹窗和保存到相册 |
这种拆分比一个 cameraState 大对象更直观,因为 ArkTS 页面里很多 UI 区块只关心其中一小组状态。
五、运行时权限申请
页面里点击授权后调用:
ts
private async requestCameraPermission(): Promise<void> {
this.cameraPermissionPromptVisible = false;
const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
const result = await CameraPermissionService.requestCamera(context);
this.cameraPermissionStatus = result.status;
this.cameraStatusText = result.message;
if (result.status === 'granted') {
this.refreshCameraDevices(context, this.activeCameraPosition);
} else {
this.cameraAvailable = false;
this.captureStatusText = result.message;
}
}
这里的流程是:
text
用户点击申请权限
-> 关闭自定义提示弹窗
-> 调用系统权限请求
-> 写入权限状态和提示文案
-> 授权成功后发现摄像头
-> 授权失败时禁用相机能力
对应的服务层封装如下:
ts
export class CameraPermissionService {
static async requestCamera(context: common.UIAbilityContext): Promise<CameraPermissionResult> {
try {
const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
const result = await atManager.requestPermissionsFromUser(context, [CAMERA_PERMISSION]);
const granted: boolean = result.authResults.length > 0 && result.authResults[0] === 0;
if (granted) {
return {
status: 'granted',
message: '相机权限已开启,可以接入真实预览'
};
}
return {
status: 'denied',
message: '相机权限未开启,请开启后使用真实取景'
};
} catch (error) {
hilog.error(DOMAIN, TAG, 'request camera permission failed: %{public}s', JSON.stringify(error));
return {
status: 'error',
message: '相机权限申请失败,请稍后重试'
};
}
}
}
服务层没有直接操作 UI,只返回状态和文案。页面决定怎么展示,服务层只处理权限 API 和异常兜底。
六、权限弹窗和页面入口
主预览区域在没有权限时展示遮罩:
ts
if (!this.isCameraGranted()) {
this.CameraPermissionOverlay();
} else if (!this.isRealPreviewRunning()) {
this.CameraDeviceBadge();
}
遮罩里的按钮不是直接调用系统授权,而是先打开自定义说明弹窗:
ts
Button('启用相机')
.fontSize(13)
.fontColor('#FFFFFF')
.backgroundColor('#8D74F7')
.borderRadius(18)
.onClick(() => {
this.openCameraPermissionPrompt();
})
真正确认时:
ts
Button('申请权限')
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor('#8D74F7')
.borderRadius(20)
.layoutWeight(1)
.onClick(() => {
this.confirmCameraPermission();
})
这样做的好处是用户在系统权限弹窗前先看到业务说明,不会突然被系统权限请求打断。
七、摄像头发现
权限通过后,页面调用设备发现:
ts
private refreshCameraDevices(context: common.UIAbilityContext, preferredPosition: CameraPositionKey): void {
const state: CameraDeviceState = CameraDeviceService.discover(context, preferredPosition);
this.cameraAvailable = state.available;
this.cameraTotalCount = state.totalCount;
this.hasFrontCamera = state.hasFront;
this.hasBackCamera = state.hasBack;
this.activeCameraPosition = state.activePosition;
this.cameraDeviceText = state.message;
this.captureStatusText = state.available ? state.message : '相机权限已开,但没有检测到可用摄像头';
if (state.available && this.previewSurfaceId.length > 0) {
this.startCameraPreview();
}
}
CameraDeviceService.discover() 负责和 CameraKit 交互:
ts
const manager: camera.CameraManager = camera.getCameraManager(context);
const supportedDevices: camera.CameraDevice[] = manager.getSupportedCameras();
const hasFront: boolean = CameraDeviceService.hasCamera(
manager,
camera.CameraPosition.CAMERA_POSITION_FRONT
);
const hasBack: boolean = CameraDeviceService.hasCamera(
manager,
camera.CameraPosition.CAMERA_POSITION_BACK
);
const activePosition: CameraPositionKey =
CameraDeviceService.resolveActivePosition(preferredPosition, hasFront, hasBack);
这里没有假设设备一定有前后双摄,而是通过 hasFront 和 hasBack 动态判断。
八、镜头选择策略
镜头选择逻辑:
ts
private static resolveActivePosition(
preferredPosition: CameraPositionKey,
hasFront: boolean,
hasBack: boolean
): CameraPositionKey {
if (preferredPosition === 'front' && hasFront) {
return 'front';
}
if (preferredPosition === 'back' && hasBack) {
return 'back';
}
if (hasBack) {
return 'back';
}
return 'front';
}
这套策略的优先级是:
- 用户偏好的镜头可用,就使用偏好;
- 偏好不可用时,优先后置;
- 后置也不可用时,退到前置。
切换镜头时:
ts
private switchCameraPosition(): void {
if (!this.isCameraGranted()) {
this.openCameraPermissionPrompt();
return;
}
const nextPosition: CameraPositionKey =
CameraDeviceService.nextPosition(this.activeCameraPosition, this.hasFrontCamera, this.hasBackCamera);
const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
this.refreshCameraDevices(context, nextPosition);
}
如果还没授权,切换按钮不会静默失败,而是打开权限说明。
九、XComponent 提供预览 Surface
CameraKit 预览需要一个 Surface。页面使用 XComponent:
ts
@Builder
CameraPreviewSurface() {
XComponent({
id: 'cameraPreviewSurface',
type: XComponentType.SURFACE,
controller: this.previewController
})
.width('100%')
.height('100%')
.onLoad(() => {
this.onPreviewSurfaceReady();
})
.onDestroy(() => {
this.onPreviewSurfaceDestroy();
})
}
Surface 就绪后:
ts
private onPreviewSurfaceReady(): void {
this.previewSurfaceId = this.previewController.getXComponentSurfaceId();
if (this.isCameraGranted() && this.cameraAvailable) {
this.startCameraPreview();
}
}
销毁时释放预览:
ts
private async onPreviewSurfaceDestroy(): Promise<void> {
this.previewSurfaceId = '';
this.previewStatus = 'idle';
this.previewStatusText = '真实预览未启动';
await CameraPreviewService.stopPreview();
}
这一点非常重要:相机预览不是普通 UI 组件,Surface 销毁后必须停止会话并释放输出,否则下次进入页面可能出现相机被占用。
十、启动 CameraKit 预览
页面启动预览:
ts
private async startCameraPreview(): Promise<void> {
if (!this.isCameraGranted() || !this.cameraAvailable || this.previewSurfaceId.length === 0) {
return;
}
this.previewStatus = 'starting';
this.previewStatusText = '正在启动相机预览';
const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
const result: CameraPreviewState = await CameraPreviewService.startPreview(
context,
this.previewSurfaceId,
this.activeCameraPosition
);
this.previewStatus = result.status;
this.previewStatusText = result.message;
if (result.status !== 'running') {
this.captureStatusText = result.message;
} else {
this.captureStatusText = '真实相机预览中';
}
if (result.status === 'running' && this.flashEnabled) {
CameraPreviewService.setFlashEnabled(true);
}
}
这里先做三重判断:
- 权限必须已通过;
- 设备必须可用;
- Surface ID 必须存在。
这三个条件缺一个都不能启动 CameraKit 预览。
十一、预览服务创建会话
CameraPreviewService.startPreview() 的核心流程:
ts
const manager: camera.CameraManager = camera.getCameraManager(context);
const cameraPosition: camera.CameraPosition = position === 'front' ?
camera.CameraPosition.CAMERA_POSITION_FRONT :
camera.CameraPosition.CAMERA_POSITION_BACK;
const cameraDevice: camera.CameraDevice =
manager.getCameraDevice(cameraPosition, camera.CameraType.CAMERA_TYPE_DEFAULT);
const input: camera.CameraInput = manager.createCameraInput(cameraDevice);
await input.open();
const capability: camera.CameraOutputCapability =
manager.getSupportedOutputCapability(cameraDevice, camera.SceneMode.NORMAL_PHOTO);
const profile: camera.Profile | null =
capability.previewProfiles.length > 0 ? capability.previewProfiles[0] : null;
const output: camera.PreviewOutput = profile ?
manager.createPreviewOutput(profile, surfaceId) :
manager.createPreviewOutput(surfaceId);
const photoProfile: camera.Profile | null =
capability.photoProfiles.length > 0 ? capability.photoProfiles[0] : null;
const photoOutput: camera.PhotoOutput = photoProfile ?
manager.createPhotoOutput(photoProfile) :
manager.createPhotoOutput();
然后创建 PhotoSession:
ts
const session: camera.PhotoSession =
manager.createSession<camera.PhotoSession>(camera.SceneMode.NORMAL_PHOTO);
session.beginConfig();
session.addInput(input);
session.addOutput(output);
session.addOutput(photoOutput);
await session.commitConfig();
await session.start();
这个顺序很关键:
text
CameraManager
-> CameraDevice
-> CameraInput.open()
-> PreviewOutput
-> PhotoOutput
-> PhotoSession.beginConfig()
-> addInput/addOutput
-> commitConfig()
-> start()
只创建预览输出还不能拍照,拍照还需要 PhotoOutput。
十二、绑定拍照回调
启动预览时会绑定 photoAvailable 回调:
ts
private static bindPhotoAvailable(photoOutput: camera.PhotoOutput): void {
const callback: AsyncCallback<camera.Photo> = (error: BusinessError, photo: camera.Photo): void => {
if (error) {
hilog.warn(DOMAIN, TAG, 'photo available failed: %{public}s', JSON.stringify(error));
return;
}
CameraPreviewService.lastPhotoAvailableAt = Date.now();
CameraPreviewService.photoEventCount += 1;
const promise: Promise<CameraPhotoVisionResult> =
CameraPreviewService.analyzeCapturedPhoto(photo, CameraPreviewService.shouldAnalyzeTargetsOnCapture);
CameraPreviewService.lastPhotoVisionPromise = promise;
};
CameraPreviewService.photoAvailableCallback = callback;
photoOutput.on('photoAvailable', callback);
}
当前第 1 篇只关注真实照片回调。后续人物识别和装饰对齐可以单独写一篇,因为 CameraPreviewService 已经扩展了 metadata、CoreVision、人物目标映射等逻辑。
十三、闪光灯状态
闪光灯不是简单布尔值。页面切换时调用:
ts
private toggleFlash(): void {
this.flashEnabled = !this.flashEnabled;
const state: CameraPreviewState = CameraPreviewService.setFlashEnabled(this.flashEnabled);
this.captureStatusText = state.message;
if (state.status === 'error') {
this.flashEnabled = false;
}
}
服务层会检查当前会话和设备能力:
ts
if (!session.hasFlash()) {
return {
status: 'error',
message: '当前设备不支持闪光灯'
};
}
const targetMode: camera.FlashMode = enabled ?
camera.FlashMode.FLASH_MODE_OPEN :
camera.FlashMode.FLASH_MODE_CLOSE;
if (!session.isFlashModeSupported(targetMode)) {
return {
status: 'error',
message: enabled ? '当前相机不支持开启闪光灯' : '当前相机不支持关闭闪光灯'
};
}
session.setFlashMode(targetMode);
这种写法避免了"按钮显示已开启,但设备实际不支持"的状态错乱。
十四、拍照按钮和定时拍照
拍照按钮区域:
ts
Button(this.timerSeconds > 0 ? `${this.timerSeconds}s 后拍照` : '拍照')
.fontSize(15)
.fontColor('#FFFFFF')
.backgroundColor('#8D74F7')
.borderRadius(20)
.width('100%')
.height(44)
.onClick(() => {
this.capturePhoto();
})
拍照入口先防重复:
ts
private async capturePhoto(): Promise<void> {
if (this.isCapturing || this.countdownText.length > 0) {
return;
}
if (this.timerSeconds > 0) {
await this.runCountdown();
}
await this.captureNow();
}
倒计时逻辑:
ts
private async runCountdown(): Promise<void> {
for (let seconds: number = this.timerSeconds; seconds > 0; seconds--) {
this.countdownText = `${seconds}`;
this.captureStatusText = `${seconds}s 后拍照`;
await this.delay(1000);
}
this.countdownText = '';
}
这能避免用户连续点击拍照按钮导致多个 photoOutput.capture() 并发。
十五、真实拍照流程
真正拍照在 captureNow():
ts
private async captureNow(): Promise<void> {
if (this.isCapturing) {
return;
}
this.isCapturing = true;
await this.releasePreviewPixelMap();
if (this.shutterSoundEnabled) {
this.triggerShutterFeedback();
}
this.captureStatusText = '正在拍照';
if (!this.isRealPreviewRunning()) {
this.captureStatusText = '请先启用相机预览';
this.isCapturing = false;
return;
}
const captureState: CameraCaptureState = await CameraPreviewService.capturePhoto(this.captureQualityPreference(), false);
this.captureStatusText = captureState.message;
if (!captureState.realPhotoReceived || captureState.photoPixelMap === undefined) {
this.isCapturing = false;
return;
}
this.previewPhotoPixelMap = captureState.photoPixelMap;
this.previewShotIndex += 1;
this.previewPhoto = PhotoAlbumService.createPhoto(
this.previewShotIndex,
[],
'无滤镜',
'无相框',
'标准模式',
'real',
`${captureState.message} · ${this.resolutionLabel()}`,
0,
'标准',
0,
this.resolutionLabel(),
this.watermarkSnapshot()
);
this.isCapturing = false;
}
这里有几个工程细节:
- 拍照前释放上一次
PixelMap; - 未启动真实预览时不拍照;
realPhotoReceived为 false 时不生成照片记录;- 拍照成功后保存
PixelMap给预览弹窗; - 同时创建一条
CapturedPhoto元数据记录; - 水印不是拍完再查 UI,而是拍照时保存快照。
十六、CameraPreviewService 拍照输出
服务层拍照:
ts
static async capturePhoto(
qualityPreference: CameraPhotoQualityPreference = 'high',
analyzeTargets: boolean = true
): Promise<CameraCaptureState> {
if (CameraPreviewService.photoOutput === null) {
return {
success: false,
message: '相机拍照输出尚未就绪',
realPhotoReceived: false,
photoEventCount: CameraPreviewService.photoEventCount,
visionTargetCount: 0
};
}
try {
const captureStartedAt: number = Date.now();
const setting: camera.PhotoCaptureSetting = {
quality: CameraPreviewService.captureQualityLevel(qualityPreference),
mirror: CameraPreviewService.activeCameraPosition === 'front'
};
await CameraPreviewService.photoOutput.capture(setting);
const realPhotoReceived: boolean =
await CameraPreviewService.waitForPhotoAvailable(captureStartedAt, 350);
const visionResult: CameraPhotoVisionResult = realPhotoReceived ?
await CameraPreviewService.waitForPhotoVision(captureStartedAt, 1600) :
{ targets: [] };
return {
success: true,
message: CameraPreviewService.captureMessage(realPhotoReceived, visionResult.targets.length),
realPhotoReceived,
photoEventCount: CameraPreviewService.photoEventCount,
visionTargetCount: visionResult.targets.length,
photoPixelMap: visionResult.pixelMap
};
} catch (error) {
hilog.error(DOMAIN, TAG, 'capture photo failed: %{public}s', JSON.stringify(error));
return {
success: false,
message: '真实拍照失败,已使用当前预览合成',
realPhotoReceived: false,
photoEventCount: CameraPreviewService.photoEventCount,
visionTargetCount: 0
};
}
}
这里通过 captureStartedAt 和 lastPhotoAvailableAt 判断本次拍照是否真的收到了回调,避免把上一次拍照回调误判成这一次。
十七、解析相机照片为 PixelMap
拍照回调里的图片处理:
ts
const mainImage: image.Image = photo.main;
const component: image.Component = await mainImage.getComponent(image.ComponentType.JPEG);
source = image.createImageSource(component.byteBuffer);
pixelMap = await source.createPixelMap();
生成 PixelMap 后,如果不做人像分析,就直接返回:
ts
if (!analyzeTargets) {
keepPixelMap = true;
return {
targets: [],
pixelMap
};
}
资源释放也写在 finally 中:
ts
if (pixelMap !== null && !keepPixelMap) {
await pixelMap.release();
}
if (source !== null) {
await source.release();
}
await photo.release();
这段代码决定了相机页面是否稳定。PixelMap、ImageSource、camera.Photo 都不是普通字符串对象,用完不释放会增加内存压力。
十八、结果预览弹窗
拍照成功后,页面显示结果预览:
ts
if (this.previewPhotoPixelMap !== null) {
Image(this.previewPhotoPixelMap)
.width('100%')
.height(250)
.objectFit(ImageFit.Cover)
.borderRadius(22)
} else {
Column()
.width('100%')
.height(250)
.backgroundColor('#1B1B24')
.borderRadius(22)
}
弹窗底部有两个操作:
ts
Button('转文档')
.onClick(() => {
this.convertPreviewPhotoToDocument();
})
Button('保存到相册')
.onClick(() => {
this.savePreviewPhoto();
})
第 1 篇重点是相册保存,文档转换后续可以单独展开。
十九、照片元数据设计
照片记录由 PhotoAlbumService.createPhoto() 创建:
ts
static createPhoto(
sequence: number,
_layers: DecorationLayer[],
_filterName: string,
_frameName: string,
_beautySummary: string,
captureSource: CaptureSource = 'simulated',
captureSummary: string = '真实相机照片',
_filterIntensity: number = 0,
_beautyFeature: string = '标准',
_beautyIntensity: number = 0,
resolutionLabel: string = '12MP (4:3)',
watermark?: WatermarkSnapshot
): CapturedPhoto {
return {
id: `photo_${Date.now()}_${sequence}`,
title: `水印照片 ${sequence}`,
createdAt: PhotoAlbumService.formatNow(),
layerCount: 0,
layerSummary: PhotoAlbumService.watermarkSummary(watermark),
filterName: '无滤镜',
frameName: '无相框',
beautySummary: '标准模式',
resolutionLabel,
captureSource,
captureSummary,
status: 'preview',
watermark: PhotoAlbumService.cloneWatermark(watermark)
};
}
这里保存的不是原图文件,而是拍照记录和水印快照。它更像相册列表的业务元数据:
| 字段 | 说明 |
|---|---|
id |
照片记录 ID |
title |
展示标题 |
createdAt |
拍照时间 |
captureSource |
real 或模拟来源 |
captureSummary |
本次拍照结果说明 |
resolutionLabel |
分辨率标签 |
watermark |
水印快照 |
status |
preview 或 saved |
二十、保存到本地相册
页面保存预览照片:
ts
private async savePreviewPhoto(): Promise<void> {
if (!this.previewPhoto) {
return;
}
this.album = await PhotoAlbumService.persistPhoto(this.previewPhoto);
this.previewPhoto = null;
await this.releasePreviewPixelMap();
this.activeTab = 1;
this.captureStatusText = '照片已保存到本地相册';
}
服务层持久化:
ts
static async persistPhoto(photo: CapturedPhoto): Promise<CapturedPhoto[]> {
await PhotoAlbumService.waitForInit();
const savedPhoto: CapturedPhoto = PhotoAlbumService.savePhoto(photo);
const nextPhotos: CapturedPhoto[] = [savedPhoto].concat(PhotoAlbumService.cachedPhotos);
PhotoAlbumService.cachedPhotos = nextPhotos.slice(0, 60);
await PhotoAlbumService.flushPhotos();
return PhotoAlbumService.clonePhotos(PhotoAlbumService.cachedPhotos);
}
这个设计有三个点:
- 新照片插到相册最前面;
- 本地最多保留 60 条;
- 返回克隆后的数组,避免页面直接修改内部缓存。
二十一、Preferences 持久化
相册使用 Preferences:
ts
const PREF_NAME: string = 'watermark_camera_album';
const PREF_KEY_PHOTOS: string = 'captured_photos';
初始化时读取:
ts
PhotoAlbumService.prefs = await preferences.getPreferences(context, PREF_NAME);
const raw: preferences.ValueType = PhotoAlbumService.prefs.getSync(PREF_KEY_PHOTOS, '[]');
if (typeof raw === 'string') {
PhotoAlbumService.cachedPhotos = PhotoAlbumService.parsePhotos(raw);
}
写入时:
ts
await PhotoAlbumService.prefs.put(PREF_KEY_PHOTOS, JSON.stringify(PhotoAlbumService.cachedPhotos));
await PhotoAlbumService.prefs.flush();
Preferences 适合保存轻量元数据。真正的原始图片文件如果未来要长期保存,建议接入媒体库或应用沙箱文件存储,文章后续可以单独写"照片文件化存储"。
二十二、页面生命周期释放资源
Index.ets 在页面消失时释放资源:
ts
async aboutToDisappear(): Promise<void> {
await this.releasePreviewPixelMap();
await CameraPreviewService.stopPreview();
}
预览服务释放会话、输出和输入:
ts
CameraPreviewService.session = null;
CameraPreviewService.previewOutput = null;
CameraPreviewService.photoOutput = null;
CameraPreviewService.metadataOutput = null;
CameraPreviewService.cameraInput = null;
CameraPreviewService.photoAvailableCallback = null;
CameraPreviewService.metadataAvailableCallback = null;
然后按顺序释放:
text
session.stop()
session.release()
previewOutput.release()
photoOutput.off('photoAvailable')
photoOutput.release()
metadataOutput.stop()
metadataOutput.release()
cameraInput.close()
CoreVisionHumanService.release()
相机类应用一定要认真处理释放,否则返回页面、切换前后台、切换镜头时都可能出现相机占用或预览黑屏。
二十三、单元测试如何守住照片快照
项目已有本地单元测试:
ts
it('photo snapshot keeps watermark metadata', 0, () => {
const layers: DecorationLayer[] = [];
const photo = PhotoAlbumService.createPhoto(
1,
layers,
'无滤镜',
'无相框',
'标准模式',
'real',
'测试拍照',
0,
'标准',
0,
'12MP (4:3)',
{
enabled: true,
template: 'work',
title: '工作',
locationText: '杭州',
note: '今日拍摄',
timeText: '06-30 19:49'
}
);
expect(photo.title).assertEqual('水印照片 1');
expect(photo.layerCount).assertEqual(0);
expect(photo.watermark?.title).assertEqual('工作');
expect(photo.watermark?.locationText).assertEqual('杭州');
});
保存状态测试:
ts
const saved = PhotoAlbumService.savePhoto(photo);
expect(saved.watermark?.template).assertEqual('check');
expect(saved.watermark?.note).assertEqual('记录一下');
expect(saved.status).assertEqual('saved');
这两条测试没有直接启动相机,但它们守住了拍照后最关键的业务快照:水印信息不能在保存时丢失。
二十四、主流程时序图
可以把第 1 篇的主流程整理成这样:
text
EntryAbility.onCreate
-> PhotoAlbumService.init
-> PhotoDocumentService.init
-> windowStage.loadContent('pages/Index')
Index.aboutToAppear
-> refreshAlbum
-> refreshDocuments
用户点击启用相机
-> CameraPermissionService.requestCamera
-> CameraDeviceService.discover
-> XComponent.onLoad 获取 surfaceId
-> CameraPreviewService.startPreview
-> PhotoSession.start
用户点击拍照
-> capturePhoto
-> runCountdown 可选
-> captureNow
-> CameraPreviewService.capturePhoto
-> photoAvailable 回调
-> ImageSource.createPixelMap
-> PhotoAlbumService.createPhoto
-> ResultPreviewDialog
用户保存
-> PhotoAlbumService.persistPhoto
-> Preferences.flush
-> 切到相册 Tab
二十五、常见问题排查
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 点击拍照提示"请先启用相机预览" | 权限、设备或 Surface 未就绪 | 查 cameraPermissionStatus、cameraAvailable、previewSurfaceId |
| 授权后仍没有预览 | 摄像头发现失败或会话启动失败 | 看 CameraDeviceService.discover() 和 startPreview() 日志 |
| 前置切换失败 | 设备没有前置摄像头 | 查 hasFrontCamera |
| 闪光灯按钮开启后又关闭 | 当前设备不支持闪光灯或模式不支持 | 查 session.hasFlash() 和 isFlashModeSupported() |
| 拍照后没有预览弹窗 | realPhotoReceived 为 false 或没有 PixelMap |
查 photoAvailable 回调是否触发 |
| 多次拍照卡顿 | 上一次 PixelMap 没释放 |
查 releasePreviewPixelMap() |
| 回到页面预览黑屏 | 上次会话没有释放干净 | 查 aboutToDisappear() 和 stopPreview() |
| 相册保存后列表为空 | Preferences 初始化或 flush 失败 | 查 PhotoAlbumService.initInternal() 和 flushPhotos() |
| 水印信息丢失 | 保存时没有克隆水印快照 | 跑 LocalUnit.test.ets |
| 真机和模拟器表现不一致 | CameraKit 能力和权限策略不同 | 用真机验证权限、前后摄像头、闪光灯 |
二十六、发布前验收清单
module.json5已声明ohos.permission.CAMERA;- 权限用途说明资源存在,用户能理解为什么申请相机;
- 未授权时显示自定义权限说明;
- 拒绝授权后不启动 CameraKit;
- 授权成功后能发现可用摄像头;
- 前置或后置不存在时,镜头切换不会误报;
XComponent加载后能拿到surfaceId;- Surface 销毁时调用
CameraPreviewService.stopPreview(); - 预览启动失败时页面有明确提示;
- 拍照时能防止重复点击;
- 定时拍照倒计时能正确清空;
- 拍照成功后
realPhotoReceived为 true; - 预览弹窗能展示
PixelMap; - 关闭或保存后释放
PixelMap; - 保存后照片状态从
preview变为saved; - Preferences 能写入和读取相册记录;
- 本地单元测试能验证水印快照;
- 前后台切换、退出页面、切换镜头后相机资源不残留;
- 不在文章或日志里暴露本地签名密码、证书路径等敏感信息。
总结
1quweixiangji 的相机主链路已经具备真实项目的骨架:权限声明和运行时申请负责合规入口,设备发现负责前后摄像头可用性,XComponent 提供 CameraKit 预览 Surface,CameraPreviewService 管理 PhotoSession、PreviewOutput 和 PhotoOutput,拍照成功后把真实照片转成 PixelMap,页面再生成带水印快照的 CapturedPhoto 并保存到 Preferences 本地相册。
第 1 篇先把这条链路跑通。后续系列可以继续拆解水印模板、相册持久化、文档转换、人物识别装饰、滤镜/拼图模式和布局回归测试。相机项目很容易写成"能拍就行",但真正稳定的相机 App,一定要把权限、预览、拍照、释放和保存这些状态都写清楚、测清楚。