第39篇|拍摄模式切换:单拍、双拍、顺序拍的 UI 逻辑

从这一篇开始,我们进入拍摄体验的“用户选择层”。相机能力检测、双预览、单拍和顺序双拍前面都已经拆过,但真正交给用户时,界面不能把所有工程分支都摊开。用户只需要知道当前是在单拍还是双拍,至于设备是否支持前后摄并发、是否需要降级到顺序拍,应该由工程内部完成。

双镜记忆相机里的模式切换有两个核心目标:第一,按钮选中态必须和真实会话一致;第二,拍摄按钮被点击时必须进入正确链路。否则用户以为自己在双拍,最后却只得到一张单拍照片,或者旧的双摄会话没有释放,导致切回单拍后预览异常。

这一篇继续围绕 21 天「智能相机开发实战」训练营展开。内容只使用当前项目里的 ArkTS、服务层代码和真实页面截图来讲,不把封面图放进正文。阅读时可以先看截图理解用户侧效果,再顺着函数名回到工程定位实现。

本篇目标

  • 理解 UI 模式和内部拍摄路径的区别。
  • 掌握 selectCaptureMode 如何切换状态并重新准备预览。
  • 读懂 triggerCameraCapture 如何集中路由单拍、双拍和顺序拍。
  • 把模式按钮、确认按钮和最终入库流程串成闭环。

对应源码位置

  • entry/src/main/ets/pages/Index.ets

一、用户只选模式,工程负责选择路径

页面上展示的是“单拍”和“双拍”,但工程内部还存在 sequence 这个 pending 状态。它不是给用户选择的第三种模式,而是在双摄并发不可用时,用两次单摄完成双镜作品的降级路径。这样做能保持产品表达简单,同时保留设备适配弹性。

模式切换时要避免只改一个字符串。真正可靠的切换需要刷新预览会话、重置提示文本、清理浮层,并让后续拍摄入口读取最新状态。

图1 拍摄模式切换在真实相机页上的效果和内部流向

图1 拍摄模式切换在真实相机页上的效果和内部流向

二、selectCaptureMode:切换状态后立刻准备对应预览

selectCaptureMode 首先处理重复点击:如果用户点击的是当前模式,并且预览已经 live,就不重复准备会话。否则它会隐藏刚拍预览、更新 selectedCaptureMode,再根据单拍或双拍模式进入对应的预览准备函数。

这一步的价值在于让模式状态和相机资源保持同步。相机类项目最怕“UI 已切换、底层还停留在旧会话”,后面任何拍照回调都可能落到错误路径。

图2 selectCaptureMode 切换模式并重新准备预览

图2 selectCaptureMode 切换模式并重新准备预览

  private async selectCaptureMode(mode: CaptureMode): Promise<void> {
    if (this.captureBusy) {
      return;
    }
    if (this.selectedCaptureMode === mode && this.hasCameraPreviewLive()) {
      return;
    }

    this.selectedCaptureMode = mode;
    this.refreshCaptureOutputReadyState();
    const preferredBackDevice = this.getSingleCameraDevice('back');
    if (preferredBackDevice && this.getCameraRole(preferredBackDevice) === 'back') {
      this.singleCameraRole = 'back';
      this.singleCameraDevice = preferredBackDevice;
      this.syncBackLensChoice();
    }
    if (this.activeTab !== 'camera' || this.cameraPreparing || this.cameraSessionPreparing) {
      return;
    }

    await this.teardownDualPreview();
    await this.ensureCameraPreview();
  }

这里没有把双拍是否可用的判断散落到每个按钮里,而是在模式切换和拍摄入口集中处理。页面组件只负责表达用户意图,能力细节交给函数内部。

三、triggerCameraCapture:把一次点击路由到正确链路

拍摄按钮最终调用 triggerCameraCapture。它先处理确认逻辑和忙碌状态,再根据 selectedCaptureModedualCameraSupported、预览 live 状态,决定触发单拍、双拍,还是顺序双拍。

这个函数是拍摄链路的交通枢纽。新能力不要随意绕过它,例如以后加入“连拍”或“定时拍”,也应该从这里统一分派,否则状态判断会分裂。

图3 triggerCameraCapture 集中处理单拍、双拍和顺序拍路由

图3 triggerCameraCapture 集中处理单拍、双拍和顺序拍路由

  private async triggerCameraCapture(confirmed: boolean = false): Promise<void> {
    this.logCaptureTrace(
      'trigger-camera-capture-enter',
      `confirmed=${confirmed} selectedMode=${this.selectedCaptureMode} dualSupported=${this.dualCameraSupported}`
    );
    if (!confirmed) {
      return;
    }
    if (!this.cameraCapabilityChecked && !this.cameraPreparing) {
      await this.prepareCameraCapability();
    }
    if (this.cameraSessionPreparing && this.isSequentialCaptureWaitingForNextShot()) {
      this.sequentialCaptureQueued = false;
      this.cameraStatusText = '请等副图画面稳定后再拍';
      return;
    }
    if (this.cameraPreparing || this.cameraSessionPreparing) {
      return;
    }
    this.refreshCaptureOutputReadyState();
    if (!this.captureOutputReady) {
      if (this.isSequentialCaptureWaitingForNextShot()) {
        this.sequentialCaptureQueued = false;
        this.cameraStatusText = '请等副图画面稳定后再拍';
        return;
      }
      this.cameraStatusText = '相机正在收尾,请稍候';
      return;
    }
    this.hideCameraCapturePreview();

    if (this.selectedCaptureMode === 'single') {
      if (!this.singlePhotoOutput || !this.singlePreviewLive) {
        await this.switchSingleCameraTo(this.singleCameraRole);
      }
      this.logCaptureTrace('trigger-camera-capture-branch-single');
      await this.triggerSingleCapture();
      return;
    }

    if (this.shouldUseDualCapture()) {
      this.logCaptureTrace('trigger-camera-capture-branch-dual');
      await this.triggerDualCapture();
      return;
    }

    if (this.dualCameraSupported) {
      const fallbackRole: CameraLensRole = this.backPreviewLive
        ? 'back'
        : (this.frontPreviewLive ? 'front' : 'back');
      const singleFallbackReady = this.singleCameraSupported &&
        ((this.singlePhotoOutput !== undefined && this.singlePreviewLive && this.singleCameraRole === fallbackRole) ||
          await this.switchSingleCameraTo(fallbackRole));
      if (singleFallbackReady) {
        this.logCaptureTrace('trigger-camera-capture-branch-dual-fallback-single', `fallbackRole=${fallbackRole}`);
        this.cameraStatusText = '';
        this.lastCaptureSummary = this.cameraStatusText;
        await this.triggerSingleCapture();
        return;
      }
      this.cameraStatusText = '';
      this.lastCaptureSummary = this.cameraStatusText;
      return;
    }
    this.logCaptureTrace('trigger-camera-capture-branch-sequence');
    await this.triggerSequentialCapture();
  }

注意 triggerSequentialCapture 出现在双拍不可直接完成的分支里。用户选择的是双拍,工程实际执行顺序拍,这是典型的体验目标和设备能力分离。

四、模式 UI:按钮只读 selectedCaptureMode

按钮层不要做复杂业务判断,只根据 selectedCaptureMode 显示选中态,并把点击交回 selectCaptureMode。这样 UI 的职责非常清楚:显示当前状态、接收用户动作、把动作交给业务函数。

如果你在按钮里直接调用 ensureDualPreviewtriggerDualCapture,后续维护会非常痛苦,因为 UI 层开始同时掌握模式状态、相机资源、降级策略和错误恢复。

图4 模式按钮根据 selectedCaptureMode 呈现选中态

图4 模式按钮根据 selectedCaptureMode 呈现选中态

  private buildCaptureModeChoice(label: string, mode: CaptureMode) {
    Text(label)
      .fontSize(18)
      .fontWeight(this.selectedCaptureMode === mode ? FontWeight.Bold : FontWeight.Medium)
      .fontColor(this.selectedCaptureMode === mode ? '#050809' : '#FFF7E6')
      .textAlign(TextAlign.Center)
      .width('100%')
      .height(56)
      .backgroundColor(this.selectedCaptureMode === mode ? '#FFF7E6' : '#66111317')
      .border({
        width: 1,
        color: this.selectedCaptureMode === mode ? '#FFF7E6' : '#33FFFFFF'
      })
      .borderRadius(18)
      .layoutWeight(1)
      .onClick(() => {
        void this.selectCaptureMode(mode);
      })
  }

  @Builder
  private buildCaptureConfirmButton() {
    Stack({ alignContent: Alignment.Center }) {
      Circle()
        .width(84)
        .height(84)
        .fill('#F7F0E6')

      Circle()
        .width(98)
        .height(98)
        .stroke(this.canTapCaptureButton() ? '#FFF1D2' : '#FFB86B')
        .strokeWidth(4)
        .fillOpacity(0)
    }
      .width(104)
      .height(104)
      .enabled(this.canTapCaptureButton())
      .onClick(() => {
        void this.triggerCameraCapture(true);
      })
  }

  @Builder
  private buildCameraModeStripItem(label: string, mode: CaptureMode) {
    Column({ space: 6 }) {
      Text(label)
        .fontSize(18)
        .fontWeight(this.selectedCaptureMode === mode ? FontWeight.Bold : FontWeight.Regular)
        .fontColor(this.selectedCaptureMode === mode ? '#FFF1D2' : '#66F6F8FF')

      Circle()
        .width(12)
        .height(12)
        .fill(this.selectedCaptureMode === mode ? '#FFB86B' : '#00000000')
    }
    .width(88)
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      void this.selectCaptureMode(mode);
    })

这一层的代码越薄,越容易保证页面样式修改不会影响拍摄行为。前端控件和相机链路之间应该隔着清晰的业务入口。

工程检查清单

  • 模式按钮只改变用户选择,不直接操作底层相机会话。
  • 切换模式后要隐藏旧的拍摄预览浮层。
  • 单拍和双拍都从 triggerCameraCapture 进入,不绕过统一入口。
  • 双拍不可用时允许进入顺序拍,但不要把顺序拍暴露成第三个用户模式。
  • 按钮选中态必须只依赖 selectedCaptureMode。

今日练习

  1. 在工程中搜索 selectedCaptureMode,标出哪些代码属于 UI 展示,哪些属于业务分派。
  2. 真机上从双拍切到单拍,再切回双拍,观察预览是否重新准备。
  3. 尝试把 triggerCameraCapture 的三个路径画成时序图,确认每条路径最终都能进入相册记录。

训练营后面的内容会继续按“真实页面效果 → 源码定位 → 状态闭环 → 可验证结果”的节奏推进。每一篇都尽量让你能拿着代码直接回到项目里复现,而不是只停留在概念说明。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐