HarmonyOS 相机拍摄治理实战:权限、预览、拍照与异常恢复

相机能力常用于头像、证件、报修照片、路线封面。它的风险点也很集中:没有权限时页面黑屏,预览会话没有释放导致再次进入失败,拍照文件为空,压缩后上传失败。相机治理不是只把系统相机拉起来,而是把权限、预览、拍照、输出文件和异常恢复做成闭环。

请添加图片描述

本文会讲清楚:相机权限如何解释,预览会话如何管理,拍照输出如何校验,页面退出和异常时如何释放资源。

1. 相机场景先分清入口

头像、工单照片、路线封面、扫码识别对相机的要求不同。头像重视裁剪,工单重视原图证据,路线封面重视压缩和尺寸。

请添加图片描述

场景 重点 失败兜底
头像 裁剪和压缩 从相册选择
工单照片 清晰度和原图 重新拍摄
路线封面 尺寸和比例 使用默认封面
扫码 实时预览 手动输入

2. Camera Kit 资料边界和目录

HarmonyOS 提供相机能力和媒体能力,应用需要处理权限、生命周期和资源释放。

资料入口 工程落点
Camera Kit 相机能力整体说明
相机开发指导 设备打开、会话和输出
应用权限 相机权限申请和说明
entry/src/main/ets/common/camera/
  CameraGate.ets
  PreviewSession.ets
  CaptureTask.ets
  ImageOutputGuard.ets
  CameraRecovery.ets

3. CameraGate 判断权限和入口

权限模块只给出下一步,不直接打开相机。

export type CameraScene = 'avatar' | 'work_order' | 'route_cover'
export type CameraGateState = 'ALLOW' | 'NEED_PERMISSION' | 'UNAVAILABLE'

export interface CameraGateDecision {
  state: CameraGateState
  reason: string
  fallbackAction: string
}

export class CameraGate {
  decide(scene: CameraScene, permissionGranted: boolean, deviceReady: boolean): CameraGateDecision {
    if (!deviceReady) {
      return { state: 'UNAVAILABLE', reason: '当前设备相机不可用', fallbackAction: '从相册选择' }
    }
    if (!permissionGranted) {
      return { state: 'NEED_PERMISSION', reason: `需要相机权限用于${scene === 'avatar' ? '拍摄头像' : '拍摄业务照片'}`, fallbackAction: '去授权' }
    }
    return { state: 'ALLOW', reason: '可以进入拍摄', fallbackAction: '继续' }
  }
}

权限拒绝时要给替代入口,例如相册选择或手动上传。

4. PreviewSession 管理预览生命周期

预览会话必须跟页面生命周期绑定。页面退出、应用切后台或拍照结束,都要释放资源。

export type PreviewState = 'IDLE' | 'STARTING' | 'RUNNING' | 'STOPPED' | 'FAILED'

export class PreviewSession {
  private state: PreviewState = 'IDLE'

  async start(surfaceId: string): Promise<PreviewState> {
    if (surfaceId.length === 0) {
      this.state = 'FAILED'
      return this.state
    }
    this.state = 'STARTING'
    // 实际项目中这里创建相机会话并绑定预览输出。
    this.state = 'RUNNING'
    return this.state
  }

  async stop(): Promise<void> {
    this.state = 'STOPPED'
  }
}

不要让预览会话成为页面里的临时变量,否则异常路径很容易漏释放。

5. CaptureTask 输出拍照结果

拍照结果要校验 URI、大小、格式和时间。只要输出为空,就不能进入上传流程。

export interface CapturedImage {
  uri: string
  mimeType: 'image/jpeg' | 'image/png'
  sizeBytes: number
  width: number
  height: number
  capturedAt: number
}

export class CaptureTask {
  async capture(scene: CameraScene): Promise<CapturedImage> {
    return {
      uri: `file://cache/${scene}_${Date.now()}.jpg`,
      mimeType: 'image/jpeg',
      sizeBytes: 2 * 1024 * 1024,
      width: 1920,
      height: 1080,
      capturedAt: Date.now()
    }
  }
}

拍照任务只负责生成图片,不负责上传和业务提交。

6. ImageOutputGuard 校验图片边界

不同场景允许的大小不同。头像可以小一些,工单照片可以更大。

请添加图片描述

export class ImageOutputGuard {
  accept(image: CapturedImage, scene: CameraScene): string[] {
    const errors: string[] = []
    const maxSize = scene === 'work_order' ? 8 * 1024 * 1024 : 3 * 1024 * 1024
    if (!image.uri.startsWith('file://')) errors.push('图片 URI 不合法')
    if (image.sizeBytes <= 0 || image.sizeBytes > maxSize) errors.push('图片大小超出限制')
    if (image.width < 300 || image.height < 300) errors.push('图片分辨率过低')
    return errors
  }
}

校验失败时应让用户重拍或重新选择,不要把坏文件交给上传模块。

7. CameraRecovery 处理异常恢复

相机占用、权限变化、页面切后台都可能中断拍摄。

export interface CameraRecoveryPlan {
  message: string
  action: 'retry' | 'choose_album' | 'close'
}

export class CameraRecovery {
  plan(errorCode: 'PERMISSION_LOST' | 'DEVICE_BUSY' | 'OUTPUT_EMPTY'): CameraRecoveryPlan {
    const map: Record<string, CameraRecoveryPlan> = {
      PERMISSION_LOST: { message: '相机权限已关闭,请重新授权', action: 'close' },
      DEVICE_BUSY: { message: '相机被占用,请稍后重试', action: 'retry' },
      OUTPUT_EMPTY: { message: '照片保存失败,请重新拍摄', action: 'retry' }
    }
    return map[errorCode]
  }
}

异常恢复要给出明确动作,不能只弹一个失败提示。

8. 页面状态构建

export interface CameraViewState {
  title: string
  shutterEnabled: boolean
  actionText: string
}

export function buildCameraView(preview: PreviewState): CameraViewState {
  if (preview === 'RUNNING') return { title: '请对准拍摄对象', shutterEnabled: true, actionText: '拍照' }
  if (preview === 'FAILED') return { title: '预览启动失败', shutterEnabled: false, actionText: '重试' }
  return { title: '正在启动相机', shutterEnabled: false, actionText: '等待' }
}

按钮状态要跟预览状态绑定,预览未就绪时不要允许拍照。

9. 相机验收动作

场景 操作 预期结果
未授权 打开拍照页 展示用途并引导授权
预览失败 模拟设备占用 提示重试或相册
拍照成功 点击快门 输出 URI、大小、分辨率合法
页面退出 拍照页返回 释放会话资源
大图上传 工单照片超限 提示压缩或重拍
export function assertCapturedImage(image: CapturedImage): void {
  if (!image.uri || image.sizeBytes <= 0) throw new Error('拍照输出文件无效')
  if (!['image/jpeg', 'image/png'].includes(image.mimeType)) throw new Error('图片格式不支持')
}

10. 相机异常排查表

相机问题要从权限、设备、会话、输出四层定位。只看拍照按钮日志通常不够。

现象 优先查看 处理建议
黑屏 预览会话状态 失败后释放并重建
拍照无文件 输出 URI 和大小 阻止上传并提示重拍
再次进入失败 资源释放 页面退出必须 stop
权限拒绝 权限文案 给相册兜底
上传失败 图片大小格式 拍照后先校验

拍摄链路建议记录会话状态变化。尤其是“第一次能打开,第二次黑屏”这类问题,通常不是拍照失败,而是上一次预览会话没有释放。

export interface CameraStateRecord {
  scene: CameraScene
  from: PreviewState
  to: PreviewState
  reason: string
  at: number
}

export class CameraStateLog {
  private readonly records: CameraStateRecord[] = []

  append(scene: CameraScene, from: PreviewState, to: PreviewState, reason: string): void {
    this.records.push({ scene, from, to, reason, at: Date.now() })
  }

  recent(): CameraStateRecord[] {
    return this.records.slice(-20)
  }
}

有了状态记录,开发可以确认页面退出时是否走到 STOPPED,也能区分设备占用和输出文件异常。

相机异常恢复复现场景:给读者一组可执行核验

相机能力要验证权限拒绝、预览失败、拍照失败和资源释放。能拍一张照片,不代表页面生命周期安全。

核验维度 读者需要准备的证据
输入 页面入口、用户动作、关键参数
过程 日志、状态变化、异常分支
输出 UI 表现、回调结果、持久化结果
回归 同场景重复执行后的结果
interface CameraReplayCase {
  cameraId: any
  previewReady: any
  captureResult: any
  released: any
}

const replay83: CameraReplayCase = {
  cameraId: 'sample',
  previewReady: 'sample',
  captureResult: 'sample',
  released: 'sample',
}

function assertReplay83(item: CameraReplayCase): void {
  if (item.captureResult === 'success' && !item.released) throw new Error('拍照后资源未释放')
}

这组核验把相机预览、拍照和资源释放放在一起,适合发现页面退出后的设备占用问题。

相机生命周期回放表:把文章方法变成可复现动作

相机页面要重点验证资源释放。进入预览后返回、拍照后返回、权限弹窗后返回、切后台后返回,这四类动作都可能暴露资源占用问题。

回放动作 核验方式
预览退出释放 准备输入、执行操作、记录结果、给出结论
拍照后释放 准备输入、执行操作、记录结果、给出结论
拒绝授权不残留 准备输入、执行操作、记录结果、给出结论
切后台恢复正常 准备输入、执行操作、记录结果、给出结论

相机能力的重点是生命周期。读者应反复执行进入预览、拍照、返回、切后台、再进入这几步,并观察相机资源是否被释放、预览是否重新初始化、失败后按钮是否恢复可点。拍照成功只是链路的一部分,资源释放和异常恢复才决定页面是否稳定。

相机页面的落地边界:不要把边界留给读者猜

相机页面要把预览、拍照、裁剪、上传拆开。预览失败不应该影响相册选择,拍照失败不应该丢失页面状态,上传失败不应该占用相机资源。读者落地时把这些阶段分开,问题定位会清楚很多。

落地项 处理要求
预览只管理设备资源 需要有明确输入、处理边界和失败兜底
拍照只产出本地文件 需要有明确输入、处理边界和失败兜底
裁剪只处理图片尺寸 需要有明确输入、处理边界和失败兜底
上传只处理网络队列 需要有明确输入、处理边界和失败兜底

这类边界写清楚后,读者不需要猜哪些逻辑属于页面、哪些属于服务、哪些属于发布前验收。文章的价值也会从“讲了一个功能”变成“给了一套可迁移的工程判断”。

11. 小结:相机能力要重视释放和兜底

相机治理的关键是把权限、预览、拍照、输出校验和异常恢复分开。页面只展示状态,底层模块负责资源生命周期,失败时提供相册或重试入口。

Logo

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

更多推荐