这篇不是写折叠屏布局。真正让我停下来排查的是一个更隐蔽的问题:自定义相机在直板机上一直正常,到了折叠屏,展开、合上连续操作几次以后,页面还在,按钮也能点,预览却停在最后一帧。

我把 Demo 叫 FoldCamGuard,会话编号固定为 cam_fold_20261001_02。问题复现时,foldStatusChange 和 windowSizeChange 在很短时间内连续到达,旧实现分别在两个回调里重建 PreviewOutput,导致一次折叠动作可能触发两到四次 CameraSession 停止、释放、重新配置。最后的修复不是“给某个回调加延时”这么简单,而是把相机恢复改成一个有状态、可合并、串行执行的事务。

这次的关键判断只有一句:折叠状态变化负责告诉我“相机能力和设备形态可能变了”,窗口尺寸变化负责告诉我“预览承载区域变了”,真正的 CameraSession 重建只能由一个协调器执行。

一、先确认不是 Surface 本身坏了

最初我怀疑的是 XComponent 的 Surface,因为画面停住后 UI 仍然正常。后来把日志补齐才发现,折叠一次会看到类似这样的顺序:

foldStatusChange: EXPANDED -> FOLDED

windowSizeChange: 840x1136 -> 720x1136

紧接着又来一次尺寸稳定后的 windowSizeChange。旧代码在第一个回调里 stop + release,在第二个回调里又执行一次相同流程。两个 Promise 没有串行,谁先创建新 PreviewOutput、谁先释放旧 Session 完全取决于当时调度。

Camera Kit 的 Session 本身有清晰的配置边界:beginConfig()、addInput()、addOutput()、commitConfig(),然后 start();结束时 stop()、release()。真正危险的不是这些 API,而是把同一套资源生命周期同时交给多个事件回调。

二、foldStatusChange 和 windowSizeChange 我不再做同一件事

CameraManager 可以监听 foldStatusChange,回调里能拿到 FoldStatusInfo,其中包括当前折叠状态以及该状态下支持的相机列表。窗口侧则通过 windowSizeChange 得到窗口尺寸变化。

这两个事件的语义其实并不一样。

foldStatusChange 里我只做两件事:记录 FoldStatus;检查当前 CameraDevice 是否仍出现在 supportedCameras。如果当前设备已经不适合新的折叠形态,我标记 cameraSelectionDirty = true,告诉后面的重建流程需要重新选设备。

windowSizeChange 里只更新预览区域和布局参数,重新计算 Surface 对应尺寸,不直接碰 CameraSession。

下面这段代码解决的是“两个事件都能触发恢复,但谁也不直接恢复”。

private foldCallback = (err: BusinessError | undefined,
  info: camera.FoldStatusInfo): void => {
  if (err) {
    hilog.error(0x0000, 'FoldCamGuard', `fold change failed: ${err.code}`)
    return
  }

  this.foldStatus = info.foldStatus
  this.cameraSelectionDirty = !info.supportedCameras.some((item) => {
    return item.cameraId === this.currentCameraId
  })

  this.coordinator.request(RebuildReason.FOLD_CHANGE)
}

private sizeCallback = (size: window.Size): void => {
  this.previewWidthVp = this.toVp(size.width)
  this.previewHeightVp = this.toVp(size.height)
  this.coordinator.request(RebuildReason.WINDOW_RESIZE)
}

这里最重要的变化,是回调本身不再出现 session.stop() 和 session.release()。事件只“描述变化”,不“执行资源销毁”。一旦把职责收窄,后面才能做合并和串行。

三、180 ms 不是为了等系统,而是为了合并一组动作

我在 Demo 里用了 180 ms 防抖。这个数字不是官方推荐值,也不是 HarmonyOS 的固定折叠时序,只是结合当前 Demo 的事件密度选的观察值。

为什么不是 500 ms?因为相机预览恢复对体感很敏感,半秒已经明显。为什么不是 16 ms?因为一次折叠动作通常会伴随不止一个窗口尺寸变化,16 ms 很容易只吃掉相邻一帧,后面还是会再重建。

协调器里我保留最后一次 reason 集合,并生成一个 token。新的事件到来时只刷新 token 和计时器,真正的 rebuild 进入 Promise 链。

这段代码解决的是“多个触发合并成一次,并且同一时刻只跑一个 rebuild”。

export class RebuildCoordinator {
  private debounceTimer: number = -1
  private rebuildChain: Promise<void> = Promise.resolve()
  private token: number = 0
  private readonly delayMs: number = 180

  constructor(private runtime: CameraRuntime) {}

  request(reason: RebuildReason): void {
    this.runtime.markPending(reason)
    this.token++
    const currentToken = this.token

    if (this.debounceTimer >= 0) {
      clearTimeout(this.debounceTimer)
      this.runtime.stats.droppedEvents++
    }

    this.debounceTimer = setTimeout(() => {
      this.rebuildChain = this.rebuildChain.then(async () => {
        if (currentToken !== this.token) {
          return
        }
        await this.runtime.rebuildOnce(currentToken)
      })
    }, this.delayMs)
  }
}

这里的 droppedEvents 指的是被同一次动作合并掉的重复触发,不是 Camera Kit 丢事件。最终运行里这个值是 3,所以截图中会看到“丢弃的重复事件 3”。这个命名如果放到正式项目,我更愿意写成 coalescedEvents,避免误解成系统漏通知。

四、相机重建必须先把旧状态收干净

最容易写错的是 rebuildOnce()。很多示例关注“如何创建预览”,但折叠恢复真正难的是旧资源还处在什么状态。

我把运行状态固定成:

PREVIEWING → RESIZE_PENDING → REBUILDING → PREVIEWING

只要收到折叠或尺寸事件,就进入 RESIZE_PENDING;180 ms 合并结束后才进入 REBUILDING。在重建期间再收到新事件,不直接打断当前释放流程,而是让 token 失效,等当前链结束后再处理最新一次。

这段代码解决“停止、释放、重建、启动必须在同一条串行链上”的问题。

public async rebuildOnce(token: number): Promise<void> {
  const startedAt = Date.now()
  this.state = CameraRuntimeState.REBUILDING

  try {
    if (this.session) {
      await this.session.stop()
      await this.session.release()
      this.session = undefined
    }

    if (this.cameraSelectionDirty || !this.cameraDevice) {
      this.cameraDevice = this.selectCameraForCurrentFold()
      this.cameraSelectionDirty = false
    }

    const input = this.cameraManager.createCameraInput(this.cameraDevice!)
    const preview = this.cameraManager.createPreviewOutput(
      this.previewProfile,
      this.surfaceId
    )

    const session = this.cameraManager.createSession<camera.PhotoSession>(
      camera.SceneMode.NORMAL_PHOTO
    )
    session.beginConfig()
    session.addInput(input)
    session.addOutput(preview)
    await session.commitConfig()
    await session.start()

    this.cameraInput = input
    this.previewOutput = preview
    this.session = session
    this.state = CameraRuntimeState.PREVIEWING
    this.stats.rebuildCount++
    this.stats.lastCostMs = Date.now() - startedAt
  } catch (err) {
    this.state = CameraRuntimeState.ERROR
    hilog.error(0x0000, 'FoldCamGuard', `rebuild failed: ${JSON.stringify(err)}`)
  }
}

Demo 为了把重点放在并发恢复上,省略了 CameraInput.open()、旧 Input/Output 的细粒度释放和 Profile 重新选择代码,这些我封装在 CameraRuntime 内部。正式工程不能把上面这段当成“完整相机初始化模板”直接复制,而要沿用项目原本已经验证过的 CameraInput、PreviewOutput、权限和 Surface 创建流程。

真正要保留的是生命周期顺序和唯一入口:任何事件都不能绕过 rebuildOnce() 私自释放 Session。

图里我故意截在 REBUILDING:设备形态已经是 FOLDED,窗口稳定到 720 × 1136 vp,防抖 180 ms,重建次数 1,合并掉的重复事件是 3。底部日志能看到从 PREVIEWING 到 RESIZE_PENDING 再到 REBUILDING,而不是两条回调各跑一遍自己的生命周期。

五、只串行还不够,还要防“旧重建覆盖新状态”

Promise 链能防并发,但不自动保证“最后一次事件”生效。比如用户刚折叠,重建开始;200 ms 后又立刻展开。第一个 rebuild 可能已经进入 commitConfig(),这时候不能强行从中间打断,但完成以后也不能把 UI 当成最终状态。

我做了两层保护。

第一层是 token。request() 每次递增,进入重建前如果发现 token 已过期,直接跳过。

第二层是 rebuild 完成后再比较当前 foldStatus、窗口尺寸和当时快照。如果在执行期间发生新变化,状态先回到 RESIZE_PENDING,然后由队列里下一次合并任务继续恢复。这样宁愿多做一次完整重建,也不让两个重建交叉。

对于相机这类强状态资源,这种“可重复、可串行”的恢复比追求极端快更重要。尤其折叠屏上,硬件可用列表可能跟形态变化有关,FoldStatusInfo.supportedCameras 本身就提示我们:设备状态不是纯 UI 参数。

六、windowSizeChange 只负责 Surface 几何,不负责选择相机

旧实现还有一个问题:窗口尺寸变化时顺手重新 getSupportedCameras(),然后总是取数组第一个。直板机上没事,折叠后可能拿到与之前不同的 CameraDevice,表现就是预览方向、焦距甚至前后摄切换异常。

现在窗口变化只做三件事:

  1. 把 px 转成 vp,更新页面布局;
  2. 更新预览容器宽高,等待 XComponent Surface 稳定;
  3. 通知协调器“Surface 几何变了”。

只有 foldStatusChange 告诉我当前相机不在 supportedCameras 时,才把 cameraSelectionDirty 置为 true。这样“布局变化”和“硬件能力变化”不会再混在一起。

另外,windowSizeChange 的监听也必须在生命周期结束时解绑。页面反复进出,如果 on() 注册多次而 off() 没有对称执行,最终一次折叠可能收到多份尺寸回调,防抖只能缓解,不能从根上解决重复监听。

七、我把 frameStart 当成恢复完成的证据之一

Session start() resolve 不等于用户已经看到新画面。为了验收,我又监听了 PreviewOutput 的 frameStart。只有新 PreviewOutput 开始产出帧,我才记录 frameRestoredAt。

private bindPreviewEvents(preview: camera.PreviewOutput): void {
  preview.on('frameStart', () => {
    if (this.state === CameraRuntimeState.PREVIEWING) {
      this.stats.frameRestoredAt = '01:46:22'
      hilog.info(0x0000, 'FoldCamGuard', 'preview frame restored')
    }
  })

  preview.on('error', (err) => {
    hilog.error(0x0000, 'FoldCamGuard', `preview error: ${err.code}`)
  })
}

代码里固定时间是为了让 Demo 截图和文章数据完全一致,正式项目当然应该记录真实时间戳。

这一步让我把“恢复成功”从一个模糊感觉变成可验证状态:Session 创建成功、start 成功、新 PreviewOutput 有 frameStart,三个条件都成立以后,UI 才显示绿色 PREVIEWING。

最终页面上 Session ID 为 cam_fold_20261001_02,Fold 状态 FOLDED,窗口 720 × 1136 vp,状态已经恢复为 PREVIEWING。这次折叠动作只重建 1 次,合并 3 个重复事件,重建耗时 176 ms,画面恢复时间是 01:46:22。

八、真正要避免的是“事件回调拥有资源”

这次问题看起来属于折叠屏,实际上根因很通用:事件回调直接持有并修改复杂资源生命周期。

窗口旋转、分屏、前后台、Surface 重建、权限变化,未来都可能触发相机恢复。如果每个地方都写一套 stop → release → create → start,只要两个事件靠得够近,就会重新遇到今天的问题。

我最后把工程分成四层:

  • EntryAbility 只提供 Window 和页面环境;
  • FoldCameraPage 负责 UI 与监听注册;
  • RebuildCoordinator 负责合并、token 和串行 Promise 链;
  • CameraRuntime 独占 CameraSession / PreviewOutput 生命周期。

这样做以后,折叠态只是众多“需要恢复”的原因之一,而不是一套特殊分支。

九、这个 180 ms 不能写进团队规范

文章里的 180 ms 很容易被误抄成“折叠屏相机建议防抖 180 ms”,我想特别把这件事说清楚:它只是 FoldCamGuard 当前 Demo 的调试参数。

正式项目更应该记录设备、窗口变化频率、从最后一次尺寸变化到 Surface 稳定的耗时,再决定 120、180 还是 240 ms。甚至有些产品不需要固定防抖,可以通过 Surface 可用事件和状态机来判断真正的重建时机。

可以沉淀成规范的不是数字,而是三条原则:

第一,foldStatusChange 与 windowSizeChange 各自表达自己的状态,不直接抢 CameraSession;第二,相机资源重建必须通过唯一协调器串行执行;第三,完成标准要包含真实预览帧恢复,而不只是 Promise resolve。

把这三条守住以后,折叠屏开合不再是“碰运气看预览会不会回来”,而是一套可以从日志、状态和次数上验收的恢复链路。

十、前后台切换和折叠同时发生时,不能把旧 Surface 当成最终目标

只测“页面一直在前台时折叠”还不够。我后来又做了一个组合场景:预览过程中按 Home,让应用进入后台,再展开设备,然后回到前台。这个时候即使没有业务代码主动重建,相机资源和 Surface 的可用时机也可能和之前不同。

我的处理方式不是在 onBackground() 里再复制一套重建,而是让 CameraRuntime 增加 foregroundReady 和 surfaceReady 两个条件。后台只负责停止继续使用预览资源并标记状态;回到前台后,页面拿到新的 Surface,再把 APP_FOREGROUND 和 SURFACE_READY 两个原因交给同一个 RebuildCoordinator。只有这两个条件都成立,协调器才允许真正进入 REBUILDING。

这一步很重要,因为“折叠完成”并不意味着“预览 Surface 已经稳定”。如果抢在 Surface 更新前创建 PreviewOutput,后面尺寸变化仍然可能再触发一次恢复。把前后台、折叠、窗口和 Surface 都收敛为协调器输入后,资源层只面对一件事:当前条件是否足够构建一套新的预览链路。

页面退出时也要对称处理监听。cameraManager.off('foldStatusChange', this.foldCallback)、Window 的 off('windowSizeChange', this.sizeCallback)、PreviewOutput 的事件解绑,都应该和注册位置一一对应。这里如果遗漏,最典型的现象不是立刻崩,而是第二次进入页面以后重建次数变成 2,第三次进入变成 3,最终看起来像“折叠事件越来越多”。

十一、错误恢复我分成“可重试”和“必须重新选设备”两类

重建失败以后如果无脑再跑一次,可能把真正的设备问题变成重试风暴。我在 CameraRuntime 里没有只保留一个 ERROR,而是给异常再分一层。

第一类是当前条件稍后可能恢复的,例如 Surface 尚未准备好、窗口仍在变化。这类不立即弹错误页,只保留 RESIZE_PENDING,等待下一次稳定事件。

第二类是当前 CameraDevice 已经不适合新的折叠形态。由于 FoldStatusInfo 能提供当前状态下的 supportedCameras,我会先把 cameraSelectionDirty 置为 true,下一次重建重新选设备,而不是继续拿旧 CameraDevice 创建输入。

第三类才是相机服务、权限或者配置本身失败。它进入明确的 ERROR,停止自动循环重试,让页面给出“重新初始化”按钮,同时把错误码、当前 fold 状态、窗口尺寸、surfaceId 是否存在、最近一次 rebuild token 一起写入日志。

这个分级给我的收益很直接。以前看到“预览黑屏”,只能猜是 Surface、Session 还是 CameraDevice;现在 UI 状态和日志会告诉我失败发生在 WAIT_SURFACE、RESELECT_CAMERA 还是 SESSION_ERROR。对于折叠屏这种多事件叠加场景,能把失败原因缩小到一个阶段,比单纯多打一堆 API 调用日志更有效。

最后我还加了一条简单规则:自动恢复最多连续执行两次。两次都失败以后停止自动重建,等待用户重新进入页面或点击“重新初始化”。相机属于高资源、强状态能力,失败时保持可解释和可控,比无限重试更重要。

十二、Preview Profile 也要跟着新设备重新确认

还有一个我一开始忽略的点:如果折叠以后重新选择了 CameraDevice,旧的 previewProfile 不一定应该继续沿用。不同形态下可用相机集合变化时,我会重新调用 getSupportedOutputCapability(),从新设备的 previewProfiles 里按当前 Surface 比例选一档最接近的 Profile,而不是拿旧分辨率硬塞给新输入。

这里同样不能只追求“分辨率最大”。FoldCamGuard 更关注恢复速度和稳定性,所以优先保证宽高比匹配,再考虑像素数量。Profile 选定后才创建新的 PreviewOutput,并在 frameStart 到来后更新页面。这样窗口变化、相机变化和 PreviewOutput 三者的关系是顺着一条链完成的,不会出现 UI 已经是折叠态,底层却还拿着展开态设备和旧 Profile 的组合。

我还会把最终选中的 Camera ID、Profile 宽高和 Surface ID 摘要写进一次 rebuild 日志。以后遇到某台设备只在特定折叠姿态黑屏,可以直接比较“重建前后到底换了什么”,而不是只看一个笼统的 Camera reinitialized success。

最后还有一个很朴素的验收动作:连续执行十次展开、折叠、回到前台,再看 rebuildCount 是否和真实动作一致。如果一次动作稳定只增加 1,droppedEvents 有变化但不会把重建次数一起抬高,说明合并逻辑确实生效。相反,如果次数逐轮增加,优先检查监听是否重复注册,而不是继续调防抖时间。

参考接口:Camera Kit CameraManager.on('foldStatusChange')、Session.beginConfig/commitConfig/start/stop/release、PreviewOutput 事件;ArkUI Window windowSizeChange。在这里插入图片描述

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐