折叠屏相机预览比例异常:从窗口变化定位 Camera 适配问题【鸿蒙心迹】

👋 你好,欢迎来到我的博客!我是【菜鸟学鸿蒙】
我是一名在路上的移动端开发者,正从传统“小码农”转向鸿蒙原生开发的进阶之旅。为了把学习过的知识沉淀下来,也为了和更多同路人互相启发,我决定把探索 HarmonyOS 的过程都记录在这里。
🛠️ 主要方向:ArkTS 语言基础、HarmonyOS 原生应用(Stage 模型、UIAbility/ServiceAbility)、分布式能力与软总线、元服务/卡片、应用签名与上架、性能与内存优化、项目实战,以及 Android → 鸿蒙的迁移踩坑与复盘。
🧭 内容节奏:从基础到实战——小示例拆解框架认知、专项优化手记、实战项目拆包、面试题思考与复盘,让每篇都有可落地的代码与方法论。
💡 我相信:写作是把知识内化的过程,分享是让生态更繁荣的方式。
如果你也想拥抱鸿蒙、热爱成长,欢迎关注我,一起交流进步!🚀
前言
折叠屏上的相机页面,最容易出现的问题不是功能报错,而是看起来没崩、但预览画面完全不对。展开设备后预览区域拉伸变形,或者出现明显黑边,甚至方向和实际持机方向不符。这类问题单看日志发现不了,单看布局代码也找不到原因,因为根源往往在"窗口尺寸变了,但相机预览没有随之重建"这个环节。
这篇文章从窗口变化这个切入点,梳理折叠屏上相机预览适配的核心逻辑。
一、为什么相机页面比普通 ArkUI 页面更难适配
普通页面做折叠屏适配,主要处理布局层:响应式断点、栅格组件、条件渲染,大多数情况改一改 .width() 或者换一套 GridRow 就够了。
相机页面不一样。它包含两个相互独立的尺寸系统:
- UI 容器尺寸:XComponent 在 ArkUI 布局树里占的像素区域,由窗口宽高和布局参数决定。
- 相机预览尺寸(Profile):相机 Session 里 PreviewOutput 使用的
profile,来自设备支持的固定分辨率列表,由CameraOutputCapability.previewProfiles枚举得到。
这两个尺寸没有联动关系。XComponent 的尺寸变了,Preview 流不会自动换分辨率;Preview 流的分辨率变了,XComponent 也不会自动伸缩。如果只改了其中一个,预览就会出现拉伸或裁切。
折叠屏展开/折叠时,UI 容器尺寸会发生一次明显跳变——屏幕比例可能从接近 1:1(折叠态内屏)变到 4:3 或 16:9(展开态),这时候如果 PreviewOutput 还在使用原来的 profile,比例失配就直接可见了。
二、先把官方规则弄清楚
2.1 Camera Kit 版本支持
Camera Kit(@kit.CameraKit)的 ArkTS 接口从 API 10 开始提供。HarmonyOS 7 对应 API Level 17,本文涉及的接口均在 API 10 ~ API 12 之间首次引入,HarmonyOS 7 下均可使用。
核心接口:
| 接口 | 所属模块 | 首次支持 |
|---|---|---|
camera.getCameraManager() | @ohos.multimedia.camera | API 10 |
cameraManager.getSupportedOutputCapability() | @ohos.multimedia.camera | API 10 |
cameraManager.createPreviewOutput() | @ohos.multimedia.camera | API 10 |
captureSession.addOutput() / removeOutput() | @ohos.multimedia.camera | API 10 |
previewOutput.release() | @ohos.multimedia.camera | API 10 |
2.2 折叠状态检测
折叠屏形态监听依赖 @ohos.display 模块(API 10 起支持),主要使用:
display.on('foldStatusChange', callback)
回调中携带 display.FoldStatus 枚举,主要值:
| 枚举值 | 含义 |
|---|---|
FoldStatus.FOLD_STATUS_UNKNOWN | 状态未知 |
FoldStatus.FOLD_STATUS_EXPANDED | 展开态 |
FoldStatus.FOLD_STATUS_FOLDED | 折叠态 |
FoldStatus.FOLD_STATUS_HALF_FOLDED | 半折叠态 |
判断当前设备是否可折叠可以用 display.isFoldable()(API 10)。
2.3 窗口尺寸变化
window.Window 提供:
windowObj.on('windowSizeChange', (size: window.Size) => { ... })
window.Size 包含 width 和 height(单位:px)。这个事件在折叠/展开、旋转、多窗口拖拽时都会触发,是比 foldStatusChange 更通用的监听点。
2.4 权限
使用相机必须在 module.json5 中声明:
"requestPermissions": [
{
"name": "ohos.permission.CAMERA"
}
]
并在运行时通过 abilityAccessCtrl 动态申请。这是使用 Camera Kit 的前置条件,和折叠屏适配无关,但容易被遗漏。
三、折叠展开后,预览区域实际发生了什么
以一台典型折叠屏为例,折叠态时内屏比例接近 1:1,展开态比例接近 4:3。
当用户展开设备:
- 系统触发
foldStatusChange,FoldStatus变为FOLD_STATUS_EXPANDED; - 应用窗口尺寸发生变化,触发
windowSizeChange; - ArkUI 布局系统重新计算,XComponent 组件根据新的窗口宽高拿到新的渲染区域;
- 但相机 Session 里的 PreviewOutput 仍然保持原来的 Profile,比如
960x960或1280x960; - 渲染系统把 PreviewOutput 的帧拉伸到 XComponent 新的区域,比例失配,拉伸出现。
这个流程说明:适配折叠屏的关键不是改 XComponent 的尺寸,而是在窗口变化后,重新选一个与新窗口比例匹配的 previewProfile,然后重建 PreviewOutput。
四、核心代码实现
下面按照官方文档接口,组织一个最小示例,演示如何监听窗口变化、重新选取 Profile、重建预览输出。
4.1 获取并持久化窗口对象
在 onWindowStageCreate 阶段拿到 Window 对象,后续监听需要用到它。
// EntryAbility.ets
import { window } from '@kit.ArkUI';
import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
export default class EntryAbility extends UIAbility {
private mainWindow: window.Window | undefined = undefined;
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.getMainWindow((err, win) => {
if (err.code !== 0 || !win) return;
this.mainWindow = win;
// 传递到页面,或通过 AppStorage / GlobalContext 管理
AppStorage.setOrCreate<window.Window>('mainWindow', win);
windowStage.loadContent('pages/CameraPage', (e) => {});
});
}
}
4.2 XComponent 与 surfaceId 的绑定
这段代码解决的问题是:把 XComponent 暴露的 surfaceId 和相机 PreviewOutput 绑定起来。
// CameraPage.ets(片段)
import { camera } from '@kit.CameraKit';
@Entry
@Component
struct CameraPage {
private surfaceId: string = '';
private xComponentController: XComponentController = new XComponentController();
private cameraManager?: camera.CameraManager;
private previewOutput?: camera.PreviewOutput;
private captureSession?: camera.CaptureSession;
build() {
Stack() {
XComponent({
id: 'cameraPreview',
type: XComponentType.SURFACE,
controller: this.xComponentController
})
.onLoad(() => {
// XComponent 加载完成后才能拿到 surfaceId
this.surfaceId = this.xComponentController.getXComponentSurfaceId();
this.initCamera();
})
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
}
}
关键点:surfaceId 必须在 onLoad 回调里获取,在此之前 XComponent 的 Surface 还没有创建完成,调用会返回空字符串。
4.3 选取与窗口比例匹配的 previewProfile
这是整个适配逻辑的核心。
// 根据当前窗口宽高,从设备支持的 previewProfiles 中挑选最合适的分辨率
function selectBestProfile(
profiles: camera.Profile[],
targetWidth: number,
targetHeight: number
): camera.Profile | undefined {
// 目标宽高比(注意:窗口尺寸单位是 px)
const targetRatio = targetWidth / targetHeight;
let bestProfile: camera.Profile | undefined = undefined;
let minRatioDiff = Number.MAX_VALUE;
for (const profile of profiles) {
// profile.size.width / profile.size.height 是相机输出的物理分辨率比例
const profileRatio = profile.size.width / profile.size.height;
const ratioDiff = Math.abs(profileRatio - targetRatio);
if (ratioDiff < minRatioDiff) {
minRatioDiff = ratioDiff;
bestProfile = profile;
}
}
return bestProfile;
}
真正需要关注的是:previewProfiles 里的 size.width 和 size.height 是传感器/编码器层面的物理分辨率,单位是像素,与 UI 坐标系的 vp 无关。比较时要用比例,不要用绝对大小。
4.4 构建相机会话(首次启动)
async initCamera(): Promise<void> {
const context = getContext(this);
this.cameraManager = camera.getCameraManager(context);
const cameras = this.cameraManager.getSupportedCameras();
if (cameras.length === 0) return;
// 取后置摄像头
const cameraDevice = cameras.find(
c => c.cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK
) ?? cameras[0];
const capability = this.cameraManager.getSupportedOutputCapability(
cameraDevice,
camera.SceneMode.NORMAL_PHOTO
);
// 拿当前窗口尺寸,选最合适的 profile
const win = AppStorage.get<window.Window>('mainWindow');
const size = win?.getWindowProperties().windowRect;
const w = size?.width ?? 1080;
const h = size?.height ?? 1920;
const profile = selectBestProfile(capability.previewProfiles, w, h);
if (!profile) return;
// 用选好的 profile 和 surfaceId 创建 PreviewOutput
this.previewOutput = this.cameraManager.createPreviewOutput(profile, this.surfaceId);
const input = this.cameraManager.createCameraInput(cameraDevice);
await input.open();
this.captureSession = this.cameraManager.createCaptureSession();
this.captureSession.beginConfig();
this.captureSession.addInput(input);
this.captureSession.addOutput(this.previewOutput);
await this.captureSession.commitConfig();
await this.captureSession.start();
}
4.5 监听窗口变化,触发预览重建
这段代码解决的问题是:折叠/展开后自动重建 PreviewOutput,匹配新的窗口比例。
// 在 aboutToAppear 里注册监听
aboutToAppear(): void {
const win = AppStorage.get<window.Window>('mainWindow');
win?.on('windowSizeChange', async (size: window.Size) => {
// 防抖:连续触发时只处理最后一次
this.debouncedRebuildPreview(size.width, size.height);
});
}
aboutToDisappear(): void {
const win = AppStorage.get<window.Window>('mainWindow');
win?.off('windowSizeChange');
}
// 重建预览流(不重建整个 Session,只换 PreviewOutput)
async rebuildPreview(newWidth: number, newHeight: number): Promise<void> {
if (!this.captureSession || !this.cameraManager || !this.previewOutput) return;
const cameras = this.cameraManager.getSupportedCameras();
const cameraDevice = cameras.find(
c => c.cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK
) ?? cameras[0];
const capability = this.cameraManager.getSupportedOutputCapability(
cameraDevice,
camera.SceneMode.NORMAL_PHOTO
);
const newProfile = selectBestProfile(capability.previewProfiles, newWidth, newHeight);
if (!newProfile) return;
// 停止当前 session,移除旧的 PreviewOutput,加入新的
await this.captureSession.stop();
this.captureSession.beginConfig();
this.captureSession.removeOutput(this.previewOutput);
await this.previewOutput.release();
this.previewOutput = this.cameraManager.createPreviewOutput(newProfile, this.surfaceId);
this.captureSession.addOutput(this.previewOutput);
await this.captureSession.commitConfig();
await this.captureSession.start();
}
注意:removeOutput 和 addOutput 必须在 beginConfig / commitConfig 之间调用,这是官方文档明确的约束。不能在 Session 运行中直接替换输出。
4.6 折叠状态变化的补充监听
在某些场景,windowSizeChange 触发的时机可能比 foldStatusChange 略滞后,或者尺寸变化量很小但比例实际已经发生了变化(半折叠态)。可以同时监听折叠状态:
import { display } from '@ohos.display';
// 仅当设备支持折叠时才注册
if (display.isFoldable()) {
display.on('foldStatusChange', (foldStatus: display.FoldStatus) => {
// 拿最新窗口尺寸,强制触发一次重建
const win = AppStorage.get<window.Window>('mainWindow');
const rect = win?.getWindowProperties().windowRect;
if (rect) {
this.debouncedRebuildPreview(rect.width, rect.height);
}
});
}
两个监听配合使用,能覆盖更多边缘情况。
五、几个关键点拆开看
5.1 UI 容器尺寸和 Profile 尺寸不必完全一致
选 Profile 的目标是宽高比接近,不是分辨率相等。XComponent 会把 PreviewOutput 的帧缩放到自身的渲染区域,如果比例相同,缩放是等比的,画面不变形。如果强求分辨率完全匹配,设备支持的 Profile 列表通常不包含与窗口像素分辨率一模一样的条目,会找不到合适的 Profile。
5.2 防抖处理不能省
windowSizeChange 在折叠屏展开/折叠的过程中可能触发多次(动画帧级别)。如果每次都立刻执行重建,会导致 Session 频繁 stop/start,出现卡顿甚至相机报错。建议用简单的 setTimeout 防抖:
private rebuildTimer: number = -1;
debouncedRebuildPreview(w: number, h: number): void {
if (this.rebuildTimer !== -1) {
clearTimeout(this.rebuildTimer);
}
this.rebuildTimer = setTimeout(async () => {
await this.rebuildPreview(w, h);
this.rebuildTimer = -1;
}, 300); // 300ms 防抖窗口
}
5.3 方向变化的特殊情况
设备旋转(横竖屏切换)同样会触发 windowSizeChange,宽高会互换。相机传感器方向和屏幕方向有固定的偏移关系,通常由 CameraInput 的 setVideoStabilizationMode 或 Session 的方向补偿来处理,和 PreviewOutput 的 Profile 选取逻辑是分开的。
如果旋转后预览方向仍然异常,需要检查的是相机传感器方向(sensorOrientation)与窗口旋转角度之间的差值补偿,这是独立于 Profile 选取之外的另一个问题,不要和 Profile 比例问题混在一起排查。
5.4 半折叠态需要单独考虑
FoldStatus.FOLD_STATUS_HALF_FOLDED 对应的窗口可能只有部分屏幕区域(取决于厂商实现)。这种形态下,XComponent 的实际渲染高度可能小于全屏,需要在 selectBestProfile 时使用 XComponent 的实际渲染尺寸,而不是窗口的 windowRect 全尺寸。
获取 XComponent 的实际渲染尺寸可以通过组件的 .onAreaChange 回调:
XComponent({ ... })
.onAreaChange((_, newArea) => {
// newArea.width / newArea.height 单位是 vp,需要转 px
const density = display.getDefaultDisplaySync().densityPixels;
const pxWidth = Math.round((newArea.width as number) * density);
const pxHeight = Math.round((newArea.height as number) * density);
this.debouncedRebuildPreview(pxWidth, pxHeight);
})
这个方式比直接用 windowRect 更准确,因为它反映的是相机预览控件实际占用的区域。
六、哪些情况需要重新建立整个 Session
不是所有情况都只需要换 PreviewOutput。以下情况需要重建整个 CaptureSession:
| 场景 | 是否需要重建 Session |
|---|---|
| 折叠/展开导致预览比例变化 | 不需要,只换 PreviewOutput |
| 从内屏摄像头切换到外屏摄像头 | 需要,CameraInput 本身变化 |
| 切换拍照模式(从照片切换到录像) | 视 SceneMode 而定,通常需要重建 |
| 旋转导致方向变化 | 通常不需要,处理方向补偿即可 |
| 应用退后台再返回 | 需要重新建立,Camera 资源已释放 |
这里比较容易出现的理解偏差:把"需要换 PreviewOutput"和"需要重建 Session"混为一谈。只换 PreviewOutput 代价小,不会导致闪黑屏;重建整个 Session 代价大,用户体验上有明显的预览中断。能局部替换就不做整体重建。
七、排查顺序
遇到折叠屏上相机预览异常,建议按下面顺序逐步排查:
第一步:确认问题发生的时机
- 首次打开就异常,还是折叠/展开后才出现?首次就异常,优先排查 Profile 选取逻辑和 surfaceId 获取时机。展开后才出现,排查窗口变化监听。
第二步:检查 windowSizeChange 是否注册成功
- 在回调里加 log,确认折叠/展开时是否有触发。如果没有触发,检查
Window对象是否正确传递,监听是否在 Surface 创建前就取消了。
第三步:打印当前使用的 Profile 和当前窗口尺寸
- 比较
profile.size.width / profile.size.height和windowRect.width / windowRect.height的比例是否接近。如果差距超过 0.1,就是比例失配。
第四步:确认 beginConfig / commitConfig 包围了 removeOutput 和 addOutput
- 遗漏这两个调用是运行时报错的常见原因。
第五步:确认防抖逻辑没有阻断正常重建
- 防抖时间窗口设置过长(比如 2 秒),会导致用户展开设备后要等很久才恢复正确预览。300ms 是相对合适的值。
第六步:检查半折叠态
- 如果只在半折叠态下出现,换用
onAreaChange拿 XComponent 实际尺寸,重新选 Profile。
开发经验总结
-
折叠屏相机适配的核心矛盾不在布局,而在"UI 容器尺寸"和"PreviewOutput Profile"这两个独立尺寸体系的比例同步。布局改了,Preview 流不会自动跟着变。
-
windowSizeChange是最通用的触发点,折叠/展开、旋转、窗口拖拽都能覆盖。foldStatusChange可以作为补充,用于半折叠态这类窗口尺寸变化不明显但形态实质改变的情况。 -
Profile 选取用比例匹配,不用分辨率匹配。
previewProfiles提供的是传感器支持的固定分辨率列表,和 UI 坐标系无关,强求完全一致没有意义。 -
防抖是必要的。
windowSizeChange在形态切换动画过程中可能高频触发,不加防抖会导致 Session 频繁重建,产生卡顿或错误。 -
方向异常和比例异常是两类问题,排查时要先区分清楚,不要用同一套方法混在一起处理。
如果你正在做折叠屏相机适配,可以先看一下自己的 PreviewOutput 是在哪里创建的、什么时候重建的——很多情况下预览只在首次打开时建了一次,之后形态怎么变都没有重建逻辑。这类问题不会报错,只会静默地拉伸画面,很容易被当成布局问题排查很久。
📝 写在最后
如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!
我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!
感谢你的阅读,我们下篇文章再见~👋
✍️ 作者:菜鸟不学编程
🧵 本文原创,转载请注明出处。
更多推荐




所有评论(0)