HarmonyOS 7 CameraManager:折叠态预览重建与会话交接
折叠屏相机页面有一种很典型的“偶尔卡死”:展开、折叠之后布局已经变了,按钮也能点,预览却停在上一帧。重新进入页面又正常。真正麻烦的不是重建预览本身,而是两个信号几乎同时到来——CameraManager 的 foldStatusChange 告诉应用可用相机集合与折叠状态变化,窗口回调又告诉页面 Surface 尺寸变化。若两个回调都各自释放、创建、启动会话,第二次操作很容易踩进第一次尚未结束的资源交接。
本文用演示项目 FoldPreviewRelay 拆解这个问题。固定任务为 CAM-1317-624,页面 FoldCameraPage,13:17 从 FOLDED 切到 EXPANDED,目标 Surface 为 1096×1812,会话代次由 11 切到 12。演示快照停在 17/25,也就是 68%,当前状态 WAIT_SURFACE_STABLE;两次窗口事件被合并,迟到回调丢弃 1 次。图与数字属于设计演示,不冒充真机跑通记录。

一、先把两个事件的职责分开
foldStatusChange 的价值在于相机侧信息。官方 CameraManager 声明的回调返回 FoldStatusInfo,其中包含 foldStatus 与 supportedCameras。因此它适合触发“候选相机是否变化”的判断。窗口尺寸变化则更适合回答 Surface 几何是否稳定、预览比例是否需要重算。两者都只是输入,不应该直接拥有会话生命周期。
官方多设备适配资料也把问题分成两个层面:折叠、旋转或切屏后要重新校正方向与预览比例;形态切换过程中要监听折叠状态并动态重选相机。这里没有承诺事件顺序,也没有应用层的去抖事务。工程上必须假设窗口事件可能连续到达、Surface ID 可能尚未准备好、相机会话的 stop 与 release 仍在异步完成。
FoldPreviewRelay 因此只让事件处理器更新“目标快照”,不碰旧会话。快照包含 foldStatus、候选 cameraId、surfaceId、宽高、方向与事件序号。调度器读取最新快照,等窗口稳定后串行执行一次交接;执行期间若又有新快照,只设置 dirty=true,当前交接结束后再按最新值补跑一次。
这种结构看起来比在回调里直接 restartPreview() 多了一层,实际减少了三类隐式状态:谁正在释放、谁最后写入 Surface、哪个回调属于旧会话。页面只展示协调器状态,不再从多个 Promise 分别改同一个 @State。
二、监听要成对,回调只写入事实
第一段代码解决监听注册与注销。回调对象被保存为字段,off 时传入同一引用;页面离开后通过 viewEpoch 让已排队任务失效。supportedCameras 不直接取第一个,而是交给业务选择器按镜头位置、类型与能力选择;示例省略选择细节,避免把数组顺序写成平台保证。
import { camera } from '@kit.CameraKit';
interface FoldTarget {
foldStatus: camera.FoldStatus;
cameras: ReadonlyArray<camera.CameraDevice>;
seq: number;
}
export class FoldEventRelay {
private seq: number = 0;
private active: boolean = false;
constructor(
private manager: camera.CameraManager,
private onTarget: (target: FoldTarget) => void
) {}
private readonly callback = (info: camera.FoldStatusInfo): void => {
if (!this.active) return;
this.seq += 1;
this.onTarget({
foldStatus: info.foldStatus,
cameras: [...info.supportedCameras],
seq: this.seq
});
};
start(): void {
if (this.active) return;
this.active = true;
this.manager.on('foldStatusChange', this.callback);
}
stop(): void {
if (!this.active) return;
this.active = false;
this.manager.off('foldStatusChange', this.callback);
}
}
active 不是用来替代 off,而是给迟到回调加第二道保险。生命周期必须同时具备“停止来源”和“拒绝旧消息”。实际页面在 aboutToDisappear 里先停止接收,再要求协调器完成或取消后续调度;不能先释放会话、稍后才关监听,否则新折叠事件可能再次启动创建。
三、窗口稳定不是固定睡眠
直接延迟 300 毫秒的问题很明显:设备快时浪费,慢时仍然太早。本文使用“连续两次采样一致”作为应用层稳定条件。窗口每次变化都覆盖 width、height、surfaceId 与 revision。调度器在下一帧或短周期采样中比较 revision;相同尺寸连续出现两次才允许进入相机会话配置。
演示中窗口先上报 1080×1812,随后修正为 1096×1812;两个变化合并为一个目标。第 17/25 步时状态仍是 WAIT_SURFACE_STABLE,所以图中 68% 并不代表系统接口提供进度,而是应用工作流计数。若 Surface 被销毁或 ID 为空,协调器保持等待,不创建 PreviewOutput。
第二段代码给出事件合流器。它不依赖不存在的系统事务,而是应用内的单消费者队列。request() 可以被折叠回调、窗口回调和页面恢复共同调用;drain() 同时只能有一个实例运行。
interface PreviewSnapshot {
fold: string;
cameraId: string;
surfaceId: string;
width: number;
height: number;
revision: number;
}
export class PreviewRebuildQueue {
private latest?: PreviewSnapshot;
private running: boolean = false;
private dirty: boolean = false;
private generation: number = 11;
constructor(private rebuild: (s: PreviewSnapshot, gen: number) => Promise<void>) {}
request(snapshot: PreviewSnapshot): void {
this.latest = snapshot;
this.dirty = true;
if (!this.running) void this.drain();
}
private async drain(): Promise<void> {
this.running = true;
try {
while (this.dirty && this.latest) {
this.dirty = false;
const target = { ...this.latest };
this.generation += 1;
await this.rebuild(target, this.generation);
}
} finally {
this.running = false;
}
}
}
循环不是无限重试。只有新快照到来才重新运行;重建抛错时应进入 FAILED 并退出,由显式恢复动作再次 request。若捕获异常后无条件继续循环,摄像头服务异常会变成高频创建风暴。日志必须记录 target revision、generation 与失败阶段。
项目结构与调试界面如下。左侧是 FoldEventRelay.ets、SurfaceStableGate.ets、PreviewRebuildQueue.ets 和页面文件;中间显示合流与代次判断;右侧模拟器停在 WAIT_SURFACE_STABLE · 68%;底部 HiLog 使用同一任务 CAM-1317-624、Surface 1096×1812、代次 11→12、merged=2、lateDropped=1。该图是白色主题演示图,不是 DevEco Studio 实测截图。

四、旧会话要先停,再释放,最后才替换引用
Camera Session 保存 CameraInput 与 CameraOutput。官方接口提供 beginConfig()、addInput()、addOutput()、commitConfig()、start()、stop() 与 release()。构建新会话前,应用应根据目标相机重新查询能力与预览 Profile,并让预览 Profile 和 Surface 保持相同比例。不能沿用折叠前的 Profile 假定新几何仍兼容。
第三段代码以 PreviewResources 聚合会话、输入和输出。清理顺序写在一个函数里,并用代次决定新资源能否成为 active。不同 API 的同步/异步签名应以项目使用的 SDK 声明为准;示例只采用官方确认的会话配置顺序,业务的 Profile 选择和 Surface 创建留给封装层。
import { camera } from '@kit.CameraKit';
interface PreviewResources {
session: camera.Session;
input: camera.CameraInput;
output: camera.PreviewOutput;
generation: number;
}
async function closeResources(old?: PreviewResources): Promise<void> {
if (!old) return;
try { await old.session.stop(); } finally {
await old.session.release();
await old.input.close();
await old.output.release();
}
}
async function activate(
manager: camera.CameraManager,
device: camera.CameraDevice,
profile: camera.Profile,
surfaceId: string,
generation: number,
isCurrent: (g: number) => boolean
): Promise<PreviewResources | undefined> {
const input = manager.createCameraInput(device);
const output = manager.createPreviewOutput(profile, surfaceId);
const session = manager.createSession(camera.SceneMode.NORMAL_PHOTO);
await input.open();
session.beginConfig();
session.addInput(input);
session.addOutput(output);
await session.commitConfig();
await session.start();
const built = { session, input, output, generation };
if (!isCurrent(generation)) {
await closeResources(built);
return undefined;
}
return built;
}
风险集中在异常路径。input 已打开但 session 配置失败时,也要关闭 input 和 output;commit 成功但 start 失败时,session 仍需 release。实际封装最好用逐阶段标志或资源栈,不要只在成功对象生成后才清理。旧 active 引用要等 closeResources 完成后再清空,避免另一个路径把半释放对象当作可用会话。
五、页面展示的是协调器状态,不是相机真相的猜测
13:17 的运行页显示:FOLDED→EXPANDED,Surface 1096×1812,会话 generation 12,17/25、68%,状态 WAIT_SURFACE_STABLE。顶部完整显示时间、Wi‑Fi、5G、信号和 76% 电量;无手机外壳。红色箭头强调“等待几何稳定”,避免读者把 68% 理解为相机启动进度。

详情页则展示事件时间线:foldStatusChange 到达、windowSizeChange 两次、合并计数 2、旧会话 stop/release、generation 11→12、lateDropped=1。它和首页不是同一布局,承担的是解释串行交接的作用。

六、调试时优先核对五条证据
第一,是否出现两个并行 rebuild。给每次 drain 分配 traceId,若同一页面同时存在两个 active trace,合流器失效。
第二,Surface ID 与宽高是否来自同一 revision。把旧 ID 与新尺寸拼在一起,比例计算再准确也会黑屏。
第三,Profile 是否针对目标 camera 重新选择。supportedCameras 变化不是 UI 提示,它意味着原设备选择可能失效。
第四,旧资源是否完整成对关闭。仅 stop 不 release 会留下会话;只 release session 而不处理 input/output 也会增加后续失败的不确定性。
第五,frameStart 是否属于当前 generation。官方预览输出可以监听帧开始和错误;收到帧并不代表它一定来自当前 UI 想要的会话。日志要带 generation,旧代次事件只计数不改页面。
本文没有给出“折叠后必定在多少毫秒恢复”的数字,因为那需要真机、镜头、Profile、温度和负载测量。演示中的 68%、事件时间与丢弃计数只是用来证明状态关系。真实验收应覆盖展开到折叠、折叠到展开、快速往返、后台回前台、Surface 重建失败和权限撤回。
还要单独验证拍照与录像并存的页面。预览重建不一定意味着拍照任务可以无条件终止;若快门已经提交,业务应先冻结形态切换入口,或把拍照结果交给旧 generation 完成后再切换。直接在 foldStatusChange 中释放所有输出,可能让用户看到“已按下快门却没有照片”。录像更谨慎:正在录制时是否允许换相机、是否先停止录制、如何提示文件分段,都应是产品明确决策,不能由通用预览协调器偷偷处理。
权限变化也要进入状态机。页面从后台回来时,相机权限可能已被撤销。此时 RESUME 事件不能只 request 最新快照,而要先重新核对权限;没有权限就进入 PERMISSION_REQUIRED,关闭残留资源,并禁止创建 input。把权限错误混成 FAILED 会让恢复按钮不断重试相机服务,用户却看不到真正原因。
性能测量应围绕阶段,而不是只看最终首帧。可以分别记录 oldSession.stop、oldSession.release、input.open、commitConfig、session.start 和 frameStart 的耗时,再按 generation 聚合。只有这样,慢在资源回收还是慢在新相机首帧才说得清。日志中的任务 ID、形态、Surface revision 与 cameraId 必须齐全,但不要写入不必要的用户内容。
无障碍与旋转也不能遗漏。重建期间按钮应明确禁用原因,读屏焦点不要因整个页面重建而跳回顶部;方向校正完成前可以显示静态占位,而不是拉伸上一帧。折叠屏适配最终仍是完整页面体验,不能因为相机链路复杂就牺牲交互可解释性。
七、参考边界
- CameraManager 的
foldStatusChange、FoldStatusInfo.foldStatus与supportedCameras依据当前 Camera Kit API 声明。 - Camera Session 的 beginConfig/commitConfig、输入输出配置、start/stop/release 顺序依据华为当前 API 文档。
- 预览 Surface 与 Preview Profile 保持相同比例、折叠形态切换后重选相机与重建预览流,依据相机预览和多设备适配指南。
相机折叠适配不是“收到折叠事件就重启一次”。更可靠的实现,是让折叠事件决定目标相机,让窗口事件决定目标几何,再由唯一协调器串行完成资源交接。事件可以乱序,目标可以覆盖,旧回调可以迟到;只要 generation、revision 和资源所有权清楚,页面就不需要靠退出重进恢复。
更多推荐


所有评论(0)