欢迎光临
我们一直在努力

相机分辨率适配踩坑记:宽高比对不上、录像格式不支持,我花了一天才搞定

文章目录

      • 效果预览
      • 先弄清楚三路数据流是什么
      • 为什么会有宽高比问题?
      • 三路分辨率的选择策略
      • 完整流程图
      • 三个核心分辨率选择函数
        • 预览分辨率选择:宽高比匹配 + 高度最大
        • 拍照分辨率选择:精确匹配
        • 录像分辨率选择:宽高比匹配 + 宽度最接近
      • 预设分辨率配置表
      • 支持分辨率切换的完整 CameraService
      • 分辨率切换 UI:带下拉选择的主页面
      • 不同场景推荐哪种分辨率?
      • 4 个必须注意的坑
      • 写在最后

preconfig 用不了的时候,就得手动选分辨率。 这件事比你想象的麻烦——预览、拍照、录像是三条独立的数据流,各自有各自支持的分辨率列表,而且三路还要保持宽高比一致,否则预览就会拉伸变形。 花了好几个小时翻文档、试了各种组合,终于把整个选择算法搞清楚了。这篇从头讲一遍。

效果预览

在这里插入图片描述

先弄清楚三路数据流是什么

很多人不清楚"三路"是什么意思,先解释一下。

相机工作时,数据会流向三个不同的地方:

  • 预览流(PreviewOutput):摄像头数据实时输出到屏幕,用户看到的就是这路
  • 拍照流(PhotoOutput):按快门时捕获一帧保存成 JPEG
  • 录像流(VideoOutput):录制视频时持续输出数据,配合 AVRecorder 编码成视频文件
  • 这三路数据流完全独立,每路都有自己支持的分辨率和格式列表。

    为什么会有宽高比问题?

    举个具体例子:

    假设设备支持的预览分辨率有 1920×1440(4:3)和 1920×1080(16:9)。 如果你的拍照分辨率选了 1920×1080(16:9),但预览却用了 1920×1440(4:3)……

    用户在预览里看到的是方方正正的 4:3 画面,但拍出来的照片却是宽扁的 16:9,上下的内容都不见了。这种体验非常糟糕。

    所以三路分辨率的宽高比必须统一,用户选了什么比例,三路都要用相同比例的分辨率。

    三路分辨率的选择策略

    数据流格式选择策略说明
    预览 PreviewOutput YUV (format=1003) 宽高比匹配 + 高度最大优先 高度越大预览越清晰
    拍照 PhotoOutput JPEG (format=2000) 精确匹配目标宽高 用户选什么就是什么
    录像 VideoOutput YUV (format=1003) 宽高比匹配 + 宽度最接近目标 录像要接近目标大小

    几个关键数字要记牢:

    • format=1003:YUV/NV21 格式,预览和录像都用这个
    • format=2000:JPEG 格式,只有拍照用这个
    • 宽高比容差:±0.2,超过这个误差认为是不同比例

    完整流程图

    三个核心分辨率选择函数

    这三个函数是整篇文章最核心的代码,直接可以拿去用。

    预览分辨率选择:宽高比匹配 + 高度最大

    // 预览分辨率选择
    // targetRatio: 目标宽高比,如 16/9 = 1.778 或 4/3 = 1.333
    // fallbackHeight: 找不到匹配的时候,退回到最接近这个高度的分辨率
    function selectPreviewProfile(
    capability: camera.CameraOutputCapability,
    targetRatio: number,
    fallbackHeight: number = 720
    ): camera.Profile | undefined {
    const YUV_FORMAT = 1003; // YUV 格式编号
    let result: camera.Profile | undefined;
    let maxHeight = 0;

    // 第一轮:严格匹配宽高比(允许 ±0.2 的误差)
    for (const p of capability.previewProfiles) {
    if (p.format !== YUV_FORMAT) continue; // 必须是 YUV 格式
    const ratio = p.size.width / p.size.height;
    if (Math.abs(ratio – targetRatio) > 0.2) continue; // 宽高比不匹配跳过
    if (p.size.height >= maxHeight) {
    result = p;
    maxHeight = p.size.height; // 记录最大高度
    }
    }

    // 第二轮:没找到宽高比匹配的,选最接近 fallbackHeight 的
    // 这种情况很少见,但不处理可能导致预览黑屏
    if (!result) {
    let minDiff = Number.MAX_VALUE;
    for (const p of capability.previewProfiles) {
    if (p.format !== YUV_FORMAT) continue;
    const diff = Math.abs(p.size.height – fallbackHeight);
    if (diff < minDiff) {
    result = p;
    minDiff = diff;
    }
    }
    }

    return result;
    }

    拍照分辨率选择:精确匹配

    // 拍照分辨率选择——精确匹配
    // 用户选了 1920×1080,就必须找到 1920×1080 的 JPEG 格式
    function selectPhotoProfile(
    capability: camera.CameraOutputCapability,
    targetWidth: number,
    targetHeight: number
    ): camera.Profile | undefined {
    const JPEG_FORMAT = 2000; // JPEG 格式编号

    const profile = capability.photoProfiles.find(p =>
    p.format === JPEG_FORMAT &&
    p.size.width === targetWidth &&
    p.size.height === targetHeight
    );

    if (!profile) {
    // 精确匹配失败说明设备不支持这个分辨率,需要提示用户
    console.error(`[CameraKit] Photo profile ${targetWidth}×${targetHeight} not found`);
    console.info(`[CameraKit] Available photo profiles:`);
    capability.photoProfiles
    .filter(p => p.format === JPEG_FORMAT)
    .forEach(p => console.info(` ${p.size.width}×${p.size.height}`));
    }

    return profile;
    }

    录像分辨率选择:宽高比匹配 + 宽度最接近

    // 录像分辨率选择——宽高比匹配 + 宽度最接近目标值
    function selectVideoProfile(
    capability: camera.CameraOutputCapability,
    targetRatio: number,
    targetWidth: number
    ): camera.VideoProfile | undefined {
    const YUV_FORMAT = 1003;

    // 先筛选宽高比匹配的,再按宽度距离排序,取最近的
    return capability.videoProfiles
    .filter(p => {
    const ratio = p.size.width / p.size.height;
    return p.format === YUV_FORMAT && Math.abs(ratio – targetRatio) <= 0.2;
    })
    .sort((a, b) =>
    Math.abs(a.size.width – targetWidth) – Math.abs(b.size.width – targetWidth)
    )[0]; // 取距离最近的第一个
    }

    预设分辨率配置表

    分辨率不用每次手动写数字,定义一个配置表,切换时查表就行:

    // 分辨率配置接口
    interface ResolutionConfig {
    label: string; // 显示给用户的名称
    photoWidth: number; // 拍照宽度
    photoHeight: number; // 拍照高度
    ratio: number; // 宽高比(用来选预览和录像分辨率)
    }

    // 常用分辨率预设表
    const RESOLUTION_OPTIONS: ResolutionConfig[] = [
    { label: '4:3 480P', photoWidth: 640, photoHeight: 480, ratio: 4/3 },
    { label: '16:9 720P', photoWidth: 1280, photoHeight: 720, ratio: 16/9 },
    { label: '4:3 1080P', photoWidth: 1440, photoHeight: 1080, ratio: 4/3 },
    { label: '16:9 1080P', photoWidth: 1920, photoHeight: 1080, ratio: 16/9 },
    { label: '4:3 2K', photoWidth: 2560, photoHeight: 1920, ratio: 4/3 },
    { label: '16:9 4K', photoWidth: 3840, photoHeight: 2160, ratio: 16/9 },
    ];

    支持分辨率切换的完整 CameraService

    把上面的三个选择函数整合进 CameraService,实现可以动态切换分辨率的完整服务:

    // model/CameraServiceH.ets
    import { camera } from '@kit.CameraKit';
    import { display } from '@kit.ArkUI';
    import { BusinessError } from '@kit.BasicServicesKit';

    // ===== 接口和常量定义 =====
    interface ResolutionConfig {
    label: string;
    photoWidth: number;
    photoHeight: number;
    ratio: number;
    }

    const RESOLUTION_OPTIONS: ResolutionConfig[] = [
    { label: '16:9 720P', photoWidth: 1280, photoHeight: 720, ratio: 16/9 },
    { label: '4:3 1080P', photoWidth: 1440, photoHeight: 1080, ratio: 4/3 },
    { label: '16:9 1080P', photoWidth: 1920, photoHeight: 1080, ratio: 16/9 },
    { label: '4:3 2K', photoWidth: 2560, photoHeight: 1920, ratio: 4/3 },
    { label: '16:9 4K', photoWidth: 3840, photoHeight: 2160, ratio: 16/9 },
    ];

    // ===== 分辨率选择函数 =====
    function selectPreviewProfile(
    capability: camera.CameraOutputCapability,
    targetRatio: number,
    fallbackHeight: number = 720
    ): camera.Profile | undefined {
    const YUV_FORMAT = 1003;
    let result: camera.Profile | undefined;
    let maxHeight = 0;

    for (const p of capability.previewProfiles) {
    if (p.format !== YUV_FORMAT) continue;
    const ratio = p.size.width / p.size.height;
    if (Math.abs(ratio – targetRatio) > 0.2) continue;
    if (p.size.height >= maxHeight) {
    result = p;
    maxHeight = p.size.height;
    }
    }

    if (!result) {
    let minDiff = Number.MAX_VALUE;
    for (const p of capability.previewProfiles) {
    if (p.format !== YUV_FORMAT) continue;
    const diff = Math.abs(p.size.height – fallbackHeight);
    if (diff < minDiff) {
    result = p;
    minDiff = diff;
    }
    }
    }

    return result;
    }

    function selectPhotoProfile(
    capability: camera.CameraOutputCapability,
    targetWidth: number,
    targetHeight: number
    ): camera.Profile | undefined {
    const JPEG_FORMAT = 2000;
    const profile = capability.photoProfiles.find(p =>
    p.format === JPEG_FORMAT &&
    p.size.width === targetWidth &&
    p.size.height === targetHeight
    );
    if (!profile) {
    console.error(`[CameraService] Photo profile ${targetWidth}×${targetHeight} not found`);
    }
    return profile;
    }

    // ===== CameraService 主类 =====
    export class CameraServiceH {
    private systemCameraManager: camera.CameraManager;
    private cameraInput?: camera.CameraInput;
    private previewOutput?: camera.PreviewOutput;
    private photoOutput?: camera.PhotoOutput;
    private session?: camera.PhotoSession;

    // 保存当前 surfaceId,切换分辨率时重用
    private surfaceId: string = '';

    constructor(context: Context) {
    this.systemCameraManager = camera.getCameraManager(context);
    }

    /**
    * 初始化相机(同时支持首次初始化和切换分辨率)
    * @param surfaceId XComponent 的 surfaceId
    * @param resolutionIndex 分辨率配置下标,对应 RESOLUTION_OPTIONS
    */

    async initCamera(surfaceId: string, resolutionIndex: number = 1): Promise<void> {
    // 切换分辨率前必须先完整释放旧资源,否则相机会卡死
    await this.releaseAll();

    this.surfaceId = surfaceId;
    const config = RESOLUTION_OPTIONS[resolutionIndex];
    if (!config) {
    console.error(`[CameraService] Invalid resolutionIndex: ${resolutionIndex}`);
    return;
    }

    // 1. 获取后置摄像头设备
    const cameras = this.systemCameraManager.getSupportedCameras();
    const device = cameras.find(
    d => d.cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK
    );
    if (!device) {
    console.error('[CameraService] No back camera found');
    return;
    }

    // 2. 查询设备输出能力(必须指定 SceneMode)
    const capability = this.systemCameraManager.getSupportedOutputCapability(
    device,
    camera.SceneMode.NORMAL_PHOTO // 拍照模式
    );

    // 3. 分别为三路流选分辨率
    const previewProfile = selectPreviewProfile(capability, config.ratio);
    const photoProfile = selectPhotoProfile(capability, config.photoWidth, config.photoHeight);

    if (!previewProfile || !photoProfile) {
    console.error('[CameraService] Failed to select profiles');
    return;
    }

    console.info(`[CameraService] Preview: ${previewProfile.size.width}×${previewProfile.size.height}`);
    console.info(`[CameraService] Photo: ${photoProfile.size.width}×${photoProfile.size.height}`);

    // 4. 打开摄像头输入
    this.cameraInput = this.systemCameraManager.createCameraInput(device);
    await this.cameraInput.open();

    // 5. 使用选好的 profile 创建输出流
    this.previewOutput = this.systemCameraManager.createPreviewOutput(previewProfile, surfaceId);
    this.photoOutput = this.systemCameraManager.createPhotoOutput(photoProfile);

    // 6. 创建并配置 Session
    const session = this.systemCameraManager.createSession(
    camera.SceneMode.NORMAL_PHOTO
    ) as camera.PhotoSession;

    session.beginConfig();
    session.addInput(this.cameraInput);
    session.addOutput(this.previewOutput);
    session.addOutput(this.photoOutput);
    await session.commitConfig();

    // 7. 设置预览旋转(保证预览方向正确)
    this.initPreviewRotation();

    // 8. 启动
    await session.start();

    // 9. 设置默认对焦和曝光模式
    try {
    if (session.isFocusModeSupported(camera.FocusMode.FOCUS_MODE_AUTO)) {
    session.setFocusMode(camera.FocusMode.FOCUS_MODE_AUTO);
    }
    if (session.isExposureModeSupported(camera.ExposureMode.EXPOSURE_MODE_AUTO)) {
    session.setExposureMode(camera.ExposureMode.EXPOSURE_MODE_AUTO);
    }
    } catch (e) {
    console.warn('[CameraService] Set focus/exposure mode failed');
    }

    this.session = session;
    console.info(`[CameraService] Camera started with "${config.label}"`);
    }

    // 初始化预览旋转方向
    private initPreviewRotation(): void {
    if (!this.previewOutput) return;
    try {
    const rot = display.getDefaultDisplaySync().rotation;
    // rotation 是 0/1/2/3,乘以 90 得到角度
    const imageRot = (rot * camera.ImageRotation.ROTATION_90) as camera.ImageRotation;
    const previewRot = this.previewOutput.getPreviewRotation(imageRot);
    this.previewOutput.setPreviewRotation(previewRot, false);
    } catch (e) {
    console.error('[CameraService] setPreviewRotation failed');
    }
    }

    // 切换分辨率(外部调用)
    async switchResolution(resolutionIndex: number): Promise<void> {
    await this.initCamera(this.surfaceId, resolutionIndex);
    }

    // 拍照
    async capturePhoto(): Promise<void> {
    if (!this.photoOutput) return;
    try {
    await this.photoOutput.capture({
    quality: camera.QualityLevel.QUALITY_LEVEL_HIGH,
    rotation: camera.ImageRotation.ROTATION_0
    });
    } catch (e) {
    console.error(`[CameraService] capture failed: ${JSON.stringify(e)}`);
    }
    }

    // 变焦范围
    getZoomRange(): [number, number] {
    try {
    const range = this.session?.getZoomRatioRange() ?? [];
    if (range.length >= 2) return [range[0], range[range.length – 1]];
    } catch (e) {}
    return [1, 1];
    }

    // 平滑变焦(适合滑动条)
    setSmoothZoom(ratio: number): void {
    try { this.session?.setSmoothZoom(ratio); }
    catch (e) { console.error('[CameraService] setSmoothZoom failed'); }
    }

    // 立即变焦(适合按钮点击)
    setZoomRatio(ratio: number): void {
    try { this.session?.setZoomRatio(ratio); }
    catch (e) { console.error('[CameraService] setZoomRatio failed'); }
    }

    // 获取支持的分辨率标签列表(用来构建 UI)
    getSupportedResolutionLabels(): string[] {
    return RESOLUTION_OPTIONS.map(c => c.label);
    }

    // 完整释放(每次切换分辨率或退出时调用)
    async releaseAll(): Promise<void> {
    try { await this.session?.stop(); } catch (e) {}
    try { await this.previewOutput?.release(); } catch (e) {}
    try { await this.photoOutput?.release(); } catch (e) {}
    try { await this.cameraInput?.close(); } catch (e) {}
    try { await this.session?.release(); } catch (e) {}

    // 清空引用,避免使用已释放的对象
    this.session = undefined;
    this.previewOutput = undefined;
    this.photoOutput = undefined;
    this.cameraInput = undefined;
    }
    }

    export default CameraServiceH;

    分辨率切换 UI:带下拉选择的主页面

    // pages/Index.ets
    import { camera } from '@kit.CameraKit';
    import CameraServiceH from '../model/CameraServiceH';

    @Entry
    @Component
    struct CameraServiceHPage {
    private CameraServiceH: CameraServiceH = new CameraServiceH(getContext(this));
    private mXComponentController: XComponentController = new XComponentController();

    @State currentResolutionIndex: number = 2; // 默认 16:9 1080P
    @State resolutionLabels: string[] = [];
    @State zoomRatio: number = 1.0;
    @State minZoom: number = 1.0;
    @State maxZoom: number = 6.0;

    // 分辨率选项数据
    private resolutionOptions: SelectOption[] = [];

    aboutToAppear(): void {
    this.resolutionLabels = this.CameraServiceH.getSupportedResolutionLabels();
    this.resolutionOptions = this.resolutionLabels.map(label => {
    const opt: SelectOption = { value: label };
    return opt;
    });
    }

    async aboutToDisappear(): Promise<void> {
    await this.CameraServiceH.releaseAll();
    }

    // XComponent 加载完成后初始化相机
    async onXComponentLoad(): Promise<void> {
    const surfaceId = this.mXComponentController.getXComponentSurfaceId();
    await this.CameraServiceH.initCamera(surfaceId, this.currentResolutionIndex);

    // 初始化变焦范围
    const zoomRange = this.CameraServiceH.getZoomRange();
    this.minZoom = zoomRange[0];
    this.maxZoom = zoomRange[1];
    }

    // 切换分辨率
    async onResolutionChange(index: number): Promise<void> {
    this.currentResolutionIndex = index;
    await this.CameraServiceH.switchResolution(index);

    // 重新获取变焦范围(不同分辨率可能不同)
    const zoomRange2 = this.CameraServiceH.getZoomRange();
    this.minZoom = zoomRange2[0];
    this.maxZoom = zoomRange2[1];
    this.zoomRatio = Math.max(this.minZoom, 1.0);
    }

    build() {
    Stack({ alignContent: Alignment.TopStart }) {
    // 相机预览
    XComponent({
    type: XComponentType.SURFACE,
    controller: this.mXComponentController
    })
    .width('100%').height('100%')
    .onLoad(async () => {
    await this.onXComponentLoad();
    })

    // 顶部工具栏:分辨率选择 + 当前分辨率显示
    Column() {
    Row({ space: 12 }) {
    Text('分辨率').fontSize(14).fontColor(Color.White)
    Select(this.resolutionOptions)
    .selected(this.currentResolutionIndex)
    .value(this.resolutionLabels[this.currentResolutionIndex] ?? '')
    .font({ size: 14, weight: FontWeight.Normal })
    .fontColor(Color.White)
    .selectedOptionFont({ size: 14 })
    .optionFont({ size: 14 })
    .onSelect(async (index) => {
    await this.onResolutionChange(index);
    })
    }
    .padding({ left: 16, right: 16, top: 16 })
    }
    .width('100%')
    .backgroundColor('rgba(0,0,0,0.4)')

    // 变焦控制区域(底部)
    Column({ space: 12 }) {
    // 当前倍率显示
    Text(`${this.zoomRatio.toFixed(1)}×`)
    .fontSize(18).fontColor(Color.White).fontWeight(FontWeight.Bold)

    // 滑动条
    Slider({
    value: this.zoomRatio,
    min: this.minZoom,
    max: this.maxZoom,
    step: 0.1,
    style: SliderStyle.InSet
    })
    .width('80%')
    .trackColor('rgba(255,255,255,0.3)')
    .selectedColor(Color.White)
    .onChange((value) => {
    this.zoomRatio = value;
    this.CameraServiceH.setSmoothZoom(value);
    })

    // 快速切换按钮
    Row({ space: 16 }) {
    ForEach([1, 2, 3, 5], (preset: number) => {
    Button(`${preset}×`)
    .fontSize(13)
    .width(48).height(36)
    .borderRadius(18)
    .backgroundColor(
    Math.abs(this.zoomRatio – preset) < 0.1
    ? Color.White
    : 'rgba(255,255,255,0.2)'
    )
    .fontColor(
    Math.abs(this.zoomRatio – preset) < 0.1
    ? Color.Black
    : Color.White
    )
    .onClick(() => {
    const clamped = Math.min(Math.max(preset, this.minZoom), this.maxZoom);
    this.zoomRatio = clamped;
    this.CameraServiceH.setSmoothZoom(clamped);
    })
    })
    }

    // 拍照按钮
    Button()
    .width(72).height(72)
    .borderRadius(36)
    .backgroundColor(Color.White)
    .border({ width: 3, color: 'rgba(255,255,255,0.6)' })
    .onClick(async () => {
    await this.CameraServiceH.capturePhoto();
    })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Center)
    .padding({ bottom: 48 })
    .position({ x: 0, y: '75%' })
    }
    .width('100%').height('100%')
    .backgroundColor(Color.Black)
    }
    }

    不同场景推荐哪种分辨率?

    选分辨率不只是看"越大越好",要结合实际使用场景:

    场景推荐分辨率理由
    日常拍照 16:9 1920×1080 文件大小适中,兼容性好,分享方便
    社交头像 4:3 1440×1080 竖向内容多,自拍用
    短视频/直播 16:9 1920×1080 全屏播放适配最好
    专业摄影/后期 4:3 最大 JPEG 后期裁剪空间大,信息量多
    扫码/AI分析 16:9 640×480 小分辨率省资源,精度完全够用
    文档拍摄 4:3 中等分辨率 A4 文档接近 4:3 比例

    4 个必须注意的坑

    坑 1:预览有黑边或画面被裁切

    原因:预览分辨率和拍照分辨率宽高比不一致。

    解决:用 Math.abs(ratio – targetRatio) > 0.2 严格过滤掉不匹配的预览分辨率。

    坑 2:切分辨率时相机卡死或黑屏

    原因:没有在切换前完整释放旧的 Session 和输出流。

    解决:切换分辨率前必须先调 releaseAll(),这个函数里每一步都要 try/catch。

    坑 3:createPreviewOutput 报错

    原因:把 JPEG 格式(format=2000)的 profile 用来创建预览输出流了。预览流只支持 YUV(format=1003)。

    解决:选预览分辨率时必须加 p.format !== YUV_FORMAT 的过滤。

    坑 4:录像模式 4:3 4K 报错

    原因:很多手机录像模式不支持 4:3 的 4K 分辨率,即使查到了也可能运行时失败。

    解决:录像分辨率做 fallback,找不到合适的就降到 1920×1080。

    写在最后

    说实话,如果 preconfig 能满足需求,真的别手动选分辨率。手动选的麻烦之处不在于代码逻辑复杂,而在于不同手机的支持列表差异太大,要做很多兼容处理。 但如果你的场景确实需要精确控制(比如 AI 模型要求特定尺寸的输入、或者需要同时支持 4:3 和 16:9 切换),这篇里的选择算法可以直接用。核心就是"查能力 → 筛宽高比 → 选最优的"这三步,不复杂。

    赞(0)
    未经允许不得转载:171主机测评 » 相机分辨率适配踩坑记:宽高比对不上、录像格式不支持,我花了一天才搞定
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址