HarmonyOS趣味相机实战第19篇:CameraKit输出Profile协商、宽高比评分与会话提交
摘要
CameraKit 返回了多个 previewProfiles 和 photoProfiles 时,直接取第一个元素虽然代码最短,却把画面比例、性能和设备排序规则交给了底层实现。不同设备上的第一个 Profile 可能宽高比不同、分辨率过高,甚至与页面 316 x 390 的竖屏 Cover 预览不匹配,最终造成裁剪过多、识别坐标难对齐或会话配置失败。
本文基于 D:/APP/1quweixiangji 趣味相机工程,复盘 CameraPreviewService.startPreview() 的输出能力协商。我们把"取第一个"升级为可测试的候选过滤、宽高比评分、分辨率预算、预览与拍照组合校验,并保留无 Profile 重载时的兼容降级。最后通过 PhotoSession 的 beginConfig/commitConfig 建立原子会话,避免半配置状态。
工程背景与源码定位
| 文件 | 当前责任 | 本文关注点 |
|---|---|---|
entry/src/main/ets/service/CameraPreviewService.ets |
创建 CameraInput、PreviewOutput、PhotoOutput 与 PhotoSession | Profile 选择和配置顺序 |
entry/src/main/ets/service/CameraDeviceService.ets |
发现前后镜头并选择设备 | 每个镜头独立协商能力 |
entry/src/main/ets/pages/Index.ets |
316 x 390 预览和三档照片质量 |
目标比例与业务偏好 |
entry/src/main/ets/service/CoreVisionHumanService.ets |
将图像坐标映射到预览 | Profile 尺寸和旋转输入 |
entry/src/main/module.json5 |
CAMERA 权限和手机设备声明 | 运行边界 |
build-profile.json5 |
target/compatible SDK | 编译和 API 基线 |
环境与能力边界
| 项目 | 当前值 | 说明 |
|---|---|---|
| 应用版本 | 1.0.4 |
AppScope/app.json5 |
| target SDK | 6.0.2(22) |
当前工程配置 |
| 场景模式 | NORMAL_PHOTO |
普通拍照会话 |
| 预览逻辑尺寸 | 316 x 390 |
ArkUI 工作台 |
| 页面照片档位 | high/medium/low | 映射 QualityLevel,不是 Profile 尺寸 |
| 当前设备类型 | phone | 模块配置 |
| 当前选择策略 | 数组第一个 Profile | 需要增强为确定性评分 |

一、当前实现为什么能用
项目获取设备与能力:
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 capability: camera.CameraOutputCapability =
manager.getSupportedOutputCapability(
cameraDevice,
camera.SceneMode.NORMAL_PHOTO
);
随后选择第一个:
ts
const profile: camera.Profile | null =
capability.previewProfiles.length > 0
? capability.previewProfiles[0]
: null;
const photoProfile: camera.Profile | null =
capability.photoProfiles.length > 0
? capability.photoProfiles[0]
: null;
并兼容不传 Profile 的重载:
ts
const output: camera.PreviewOutput = profile
? manager.createPreviewOutput(profile, surfaceId)
: manager.createPreviewOutput(surfaceId);
const photoOutput: camera.PhotoOutput = photoProfile
? manager.createPhotoOutput(photoProfile)
: manager.createPhotoOutput();
这段代码具备两个优点:流程完整,空数组时仍有降级路径。但"第一个"没有表达产品偏好,无法保证跨设备一致。
二、Profile选择需要回答四个问题
对每个候选至少评估:
- 宽高比与预览容器是否接近。
- 像素数量是否落在设备性能预算内。
- 前置或后置镜头是否需要不同上限。
- 预览 Profile 与拍照 Profile 是否能组成当前场景的有效会话。
预览追求稳定帧率和低延迟,拍照追求细节。两者不能用同一个"分辨率越高越好"规则。
三、不要混淆Profile与照片质量档位
页面提供:
ts
private resolutionLabels: string[] = [
'12MP (4:3)',
'8MP (4:3)',
'5MP (4:3)'
];
private resolutionQualities: CameraPhotoQualityPreference[] = [
'high',
'medium',
'low'
];
拍照时映射:
ts
const setting: camera.PhotoCaptureSetting = {
quality: captureQualityLevel(qualityPreference),
mirror: activeCameraPosition === 'front'
};
QualityLevel 是编码质量偏好,不等于选择 12MP、8MP、5MP 的 Photo Profile。若 UI 明确展示像素级分辨率,就必须让选项与实际 photoProfile.size 对应;否则应把文案改为"高清/标准/省空间"。
四、先把候选转换成可评分数据
ts
interface ProfileCandidate {
profile: camera.Profile;
width: number;
height: number;
longEdge: number;
shortEdge: number;
pixels: number;
aspect: number;
}
function toCandidate(profile: camera.Profile): ProfileCandidate {
const width = profile.size.width;
const height = profile.size.height;
return {
profile,
width,
height,
longEdge: Math.max(width, height),
shortEdge: Math.min(width, height),
pixels: width * height,
aspect: Math.max(width, height) / Math.min(width, height)
};
}
使用长短边计算比例,可以避免候选以横向尺寸表示,而页面实际竖屏显示时误判。
五、目标比例来自产品容器
页面容器为 316 x 390:
ts
const targetAspect = 390 / 316;
这个比例约为 1.234,不是常见 4:3 的 1.333。相机图像通过 Cover 填充后一定会裁剪一部分。选择时既要尊重实际容器,也要考虑常见相机输出比例。
可把产品目标设为 4:3,并让 UI 容器承担少量裁剪;也可以严格按 390:316 评分。关键是产品、识别映射和测试使用同一规则。
六、宽高比评分用相对误差
ts
function aspectPenalty(
candidateAspect: number,
targetAspect: number
): number {
return Math.abs(candidateAspect - targetAspect) / targetAspect;
}
相对误差比绝对差更容易跨比例比较。误差越小越好。
可计算 Cover 裁剪比例:
ts
function coverCropRatio(
sourceAspect: number,
targetAspect: number
): number {
if (sourceAspect > targetAspect) {
return 1 - targetAspect / sourceAspect;
}
return 1 - sourceAspect / targetAspect;
}
裁剪比例直接表达丢失的边缘区域,更贴近用户感知和坐标对齐风险。
七、预览分辨率要设置性能预算
预览 Profile 过高会增加:
- Surface 带宽。
createPixelMapFromSurface()成本。- CoreVision 采样分析成本。
- 图像旋转和坐标映射压力。
- 部分设备的会话启动时间。
可以设置软目标与硬上限:
ts
interface PreviewBudget {
preferredPixels: number;
maxPixels: number;
minShortEdge: number;
}
const phoneBudget: PreviewBudget = {
preferredPixels: 1280 * 720,
maxPixels: 1920 * 1080,
minShortEdge: 640
};
这些数值是策略示例,必须用项目目标设备测试确定,不应写死为所有 HarmonyOS 设备的通用结论。
八、组合一个确定性评分函数
ts
function previewScore(
candidate: ProfileCandidate,
targetAspect: number,
budget: PreviewBudget
): number {
if (candidate.shortEdge < budget.minShortEdge) {
return Number.POSITIVE_INFINITY;
}
if (candidate.pixels > budget.maxPixels) {
return Number.POSITIVE_INFINITY;
}
const crop = coverCropRatio(candidate.aspect, targetAspect);
const pixelDistance = Math.abs(
candidate.pixels - budget.preferredPixels
) / budget.preferredPixels;
return crop * 1000 + pixelDistance * 100;
}
权重明确后,候选顺序变化也不会改变结果。分数相同时再按像素、宽度、格式等稳定字段排序。
九、过滤失败时要有二级降级
如果所有候选都超过硬上限,不能直接返回 null。降级顺序:
text
满足比例和预算的候选
-> 忽略 preferredPixels,只保留 maxPixels
-> 选择像素最小的有效候选
-> 使用 createPreviewOutput(surfaceId) 重载
-> 返回明确启动错误
实现:
ts
function choosePreviewProfile(
profiles: camera.Profile[],
targetAspect: number,
budget: PreviewBudget
): camera.Profile | null {
if (profiles.length === 0) {
return null;
}
const candidates = profiles.map(toCandidate);
const ranked = candidates
.map(candidate => ({
candidate,
score: previewScore(candidate, targetAspect, budget)
}))
.filter(item => Number.isFinite(item.score))
.sort((a, b) => a.score - b.score ||
a.candidate.pixels - b.candidate.pixels);
if (ranked.length > 0) {
return ranked[0].candidate.profile;
}
candidates.sort((a, b) => a.pixels - b.pixels);
return candidates[0].profile;
}
十、拍照Profile使用不同目标
拍照输出可以更高,但仍不应盲目选择最大值。需要考虑:
- 用户选择的业务档位。
- 可接受的 JPEG 大小。
- ImageSource 与 PixelMap 解码内存。
- CoreVision 拍照后分析耗时。
- 保存和转文档的处理时间。
可以按设备候选动态生成三档:
text
high -> 同比例候选中较高分辨率
medium -> 同比例候选中位数
low -> 满足最低清晰度的较低分辨率
这样 UI 显示的是设备真实支持的尺寸,而不是固定假设。
十一、建立实际分辨率选项模型
ts
interface PhotoResolutionOption {
key: 'high' | 'medium' | 'low';
label: string;
profile: camera.Profile;
megapixels: number;
}
function profileLabel(profile: camera.Profile): string {
const pixels = profile.size.width * profile.size.height;
const megapixels = Math.round(pixels / 100000) / 10;
return `${megapixels}MP (${profile.size.width}x${profile.size.height})`;
}
选项应在镜头切换后重新生成。前置镜头可能没有后置镜头的高分辨率档位,页面不能继续展示旧选项。
十二、预览与拍照Profile必须按同一设备重新获取
切换镜头后,当前流程会:
text
stopPreview
-> getCameraDevice(newPosition)
-> getSupportedOutputCapability(newDevice, NORMAL_PHOTO)
-> choose previewProfile
-> choose photoProfile
-> create outputs
不要缓存后置镜头的 Profile 给前置镜头。Profile 的有效性属于设备、场景模式和输出类型组合。
十三、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);
if (metadataOutput !== null) {
session.addOutput(metadataOutput);
}
await session.commitConfig();
await session.start();
正确原则:
- 在
beginConfig()与commitConfig()之间完成输入输出组合。 commitConfig()成功后再start()。- 只有启动成功才把局部资源发布到静态字段。
- 任一失败进入统一清理。
十四、局部资源失败时的所有权风险
当前资源在启动成功后才赋值:
ts
CameraPreviewService.cameraInput = input;
CameraPreviewService.previewOutput = output;
CameraPreviewService.photoOutput = photoOutput;
CameraPreviewService.session = session;
如果 commitConfig() 之前抛错,stopPreview() 看不到这些局部资源,可能无法释放。可以使用启动上下文:
ts
interface OpeningResources {
input?: camera.CameraInput;
preview?: camera.PreviewOutput;
photo?: camera.PhotoOutput;
metadata?: camera.MetadataOutput;
session?: camera.PhotoSession;
}
在 catch/finally 中释放局部资源,或创建后立即登记到可清理字段并用 generation 防止并发污染。
十五、MetadataOutput是可选能力
项目先检查:
ts
const supported: boolean =
capability.supportedMetadataObjectTypes.some(type => {
return type === camera.MetadataObjectType.FACE_DETECTION;
});
不支持人脸 Metadata 时,预览和拍照仍应启动,只是识别链路降级到定时 Surface 采样。可选输出不能让基础会话失败。
如果加入 MetadataOutput 后 commitConfig() 失败,可以尝试一次不含 MetadataOutput 的组合,而不是立即判定整个相机不可用。
十六、记录选型结果而不是输出完整设备信息
建议日志:
ts
interface ProfileDecisionLog {
cameraPosition: 'front' | 'back';
previewWidth: number;
previewHeight: number;
photoWidth: number;
photoHeight: number;
previewCandidateCount: number;
photoCandidateCount: number;
usedFallback: boolean;
}
日志不需要包含用户图像、设备序列标识或照片内容。只记录能力数量、选中尺寸和降级原因即可。
十七、纯函数测试
构造 Profile 大小夹具:
ts
const sizes = [
{ width: 640, height: 480 },
{ width: 1280, height: 720 },
{ width: 1440, height: 1080 },
{ width: 1920, height: 1080 },
{ width: 4000, height: 3000 }
];
断言:
- 候选数组顺序变化不影响最终选择。
- 4:3 目标优先 4:3 候选。
- 超过
maxPixels的候选被过滤。 - 全部超预算时选择最小可用候选。
- 空数组返回 null 并进入重载降级。
- 横向尺寸和竖向尺寸得到相同长短边比例。
- 前后镜头分别生成自己的照片档位。
十八、真机兼容矩阵
| 场景 | 操作 | 验收 |
|---|---|---|
| 单后摄设备 | 启动预览 | 正常选中 Profile |
| 前后摄设备 | 连续切换 20 次 | 每次重新协商且无黑屏 |
| 低端设备 | 开启实时识别 | 预览帧率稳定 |
| 高像素设备 | 选择 high | 解码内存不过量 |
| 不支持人脸 Metadata | 启动会话 | 预览可用、识别降级 |
| 候选顺序变化 | 重启应用 | 选型结果确定 |
| Surface 重建 | 前后台切换 | 新 Surface 使用新 Output |
十九、常见问题排查
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 某些设备预览裁剪很多 | 第一个 Profile 比例不匹配 | 输出候选比例和评分 |
| 识别耗时明显升高 | 预览 Profile 像素过高 | 设置预览像素预算 |
| UI 显示12MP但实际不是 | 把 QualityLevel 当分辨率 | 根据 Photo Profile 生成文案 |
| 前置切换后档位错误 | 复用后置候选 | 每个设备重新获取 capability |
| 加 Metadata 后会话失败 | 输出组合不兼容 | 无 Metadata 重试一次 |
| commit失败后相机仍占用 | 局部资源未登记 | 引入 OpeningResources 清理 |
| 同机重启选择结果变化 | 依赖数组原始顺序 | 使用确定性评分和稳定排序 |
| 空 Profile 数组直接崩溃 | 未检查长度 | 使用重载或明确错误降级 |
二十、上线前验收清单
- 不再默认依赖 Profile 数组第一个元素。
- 预览和拍照使用不同评分目标。
- 宽高比按长短边和 Cover 裁剪计算。
- 预览 Profile 有像素预算和最小清晰度。
- UI 分辨率标签来自真实 Photo Profile。
- QualityLevel 文案不会伪装成具体 MP。
- 前后镜头切换后重新获取能力。
- 候选排序具有确定性。
- 空候选和全部超预算都有降级路径。
- MetadataOutput 失败不影响基础拍照会话。
- beginConfig/commitConfig/start 顺序正确。
- 启动中途失败能释放所有局部资源。
- 日志记录尺寸和降级,不记录用户图像。
- 多机型完成切镜头、前后台和识别压力测试。
总结
CameraKit 的输出能力协商不是"取一个能用的 Profile",而是把产品容器、设备性能、照片清晰度和会话兼容性统一到确定性策略中。预览优先稳定和低延迟,拍照优先真实分辨率档位,MetadataOutput 则作为可选增强能力。
当 Profile 选择变成可测试的纯函数,并在 PhotoSession 提交前完成组合验证,趣味相机才能在候选顺序、镜头差异和设备能力变化下保持一致。页面显示的分辨率也会与真实输出对应,不再把编码质量与像素尺寸混为一谈。