时间轨迹第 3 篇:相机页源代码拆解,拍照、录像、定位、手势与水印合成

时间轨迹第 3 篇:相机页源代码拆解,拍照、录像、定位、手势与水印合成

这一篇讲的是 CameraPage.ets。它是整套应用里最重的一页,也是最能体现产品完成度的一页。

因为这里要同时处理权限、相机预览、定位、水印、拍照、录像、保存和回流。

系列导航

摘要

CameraPage 不是"点一下拍照按钮"这么简单,而是一个持续运行的工作流:

  • 先申请权限
  • 再初始化相机
  • 再启动定位和传感器监听
  • 再根据当前模式合成水印
  • 最后决定保存到本地还是系统相册

如果把这页做顺,整个应用的核心体验就顺了。

目录

  1. 整体工作流
  2. 权限与生命周期
  3. 定位与水印输入
  4. 手势和传感器
  5. 拍照与录像分支
  6. 保存与回流
  7. 实操检查清单
  8. 参考资料

一、先看整体工作流

#mermaid-svg-00PhKvvpG0INsL3T{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-00PhKvvpG0INsL3T .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-00PhKvvpG0INsL3T .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-00PhKvvpG0INsL3T .error-icon{fill:#552222;}#mermaid-svg-00PhKvvpG0INsL3T .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-00PhKvvpG0INsL3T .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-00PhKvvpG0INsL3T .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-00PhKvvpG0INsL3T .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-00PhKvvpG0INsL3T .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-00PhKvvpG0INsL3T .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-00PhKvvpG0INsL3T .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-00PhKvvpG0INsL3T .marker{fill:#333333;stroke:#333333;}#mermaid-svg-00PhKvvpG0INsL3T .marker.cross{stroke:#333333;}#mermaid-svg-00PhKvvpG0INsL3T svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-00PhKvvpG0INsL3T p{margin:0;}#mermaid-svg-00PhKvvpG0INsL3T .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-00PhKvvpG0INsL3T .cluster-label text{fill:#333;}#mermaid-svg-00PhKvvpG0INsL3T .cluster-label span{color:#333;}#mermaid-svg-00PhKvvpG0INsL3T .cluster-label span p{background-color:transparent;}#mermaid-svg-00PhKvvpG0INsL3T .label text,#mermaid-svg-00PhKvvpG0INsL3T span{fill:#333;color:#333;}#mermaid-svg-00PhKvvpG0INsL3T .node rect,#mermaid-svg-00PhKvvpG0INsL3T .node circle,#mermaid-svg-00PhKvvpG0INsL3T .node ellipse,#mermaid-svg-00PhKvvpG0INsL3T .node polygon,#mermaid-svg-00PhKvvpG0INsL3T .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-00PhKvvpG0INsL3T .rough-node .label text,#mermaid-svg-00PhKvvpG0INsL3T .node .label text,#mermaid-svg-00PhKvvpG0INsL3T .image-shape .label,#mermaid-svg-00PhKvvpG0INsL3T .icon-shape .label{text-anchor:middle;}#mermaid-svg-00PhKvvpG0INsL3T .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-00PhKvvpG0INsL3T .rough-node .label,#mermaid-svg-00PhKvvpG0INsL3T .node .label,#mermaid-svg-00PhKvvpG0INsL3T .image-shape .label,#mermaid-svg-00PhKvvpG0INsL3T .icon-shape .label{text-align:center;}#mermaid-svg-00PhKvvpG0INsL3T .node.clickable{cursor:pointer;}#mermaid-svg-00PhKvvpG0INsL3T .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-00PhKvvpG0INsL3T .arrowheadPath{fill:#333333;}#mermaid-svg-00PhKvvpG0INsL3T .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-00PhKvvpG0INsL3T .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-00PhKvvpG0INsL3T .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-00PhKvvpG0INsL3T .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-00PhKvvpG0INsL3T .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-00PhKvvpG0INsL3T .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-00PhKvvpG0INsL3T .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-00PhKvvpG0INsL3T .cluster text{fill:#333;}#mermaid-svg-00PhKvvpG0INsL3T .cluster span{color:#333;}#mermaid-svg-00PhKvvpG0INsL3T div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-00PhKvvpG0INsL3T .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-00PhKvvpG0INsL3T rect.text{fill:none;stroke-width:0;}#mermaid-svg-00PhKvvpG0INsL3T .icon-shape,#mermaid-svg-00PhKvvpG0INsL3T .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-00PhKvvpG0INsL3T .icon-shape p,#mermaid-svg-00PhKvvpG0INsL3T .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-00PhKvvpG0INsL3T .icon-shape .label rect,#mermaid-svg-00PhKvvpG0INsL3T .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-00PhKvvpG0INsL3T .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-00PhKvvpG0INsL3T .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-00PhKvvpG0INsL3T :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 进入 CameraPage
申请相机 / 存储 / 定位权限
初始化 CameraService
启动定位监听
启动加速度传感器监听
更新地点与坐标
更新横竖屏角度
拍照 / 录像
合成水印
保存到本地或系统相册
写入工作记录 / 历史地址

这张图说明了一件事:

CameraPage 不是单点功能,而是一条连续工作流。

图 1 说明

这张图适合放在正文开头,因为它会让读者先理解:相机页不是拍照按钮,而是整条拍摄链路的协调中心。

二、权限必须前置

1. 进页面先申请权限

ts 复制代码
private async requestPermissionsEarly(): Promise<void> {
  const ctx = getContext(this) as common.UIAbilityContext;
  const results = await Promise.all([
    PermissionUtil.requestCameraPermissions(ctx),
    PermissionUtil.requestMediaPermissions(ctx),
  ]);
  this.permissionGranted = results[0];
}

这一步很关键,因为后面所有能力都依赖权限:

  • 相机权限
  • 存储权限
  • 定位权限

如果权限没先处理好,后面的水印、保存、回流逻辑都会断。

2. 生命周期里要能释放资源

ts 复制代码
private onLifecycleChange(): void {
  if (this.appInBackground) {
    if (this.isRecording) {
      this.cameraService.stopRecording(ctx, this.saveMode).then(() => {});
      this.isRecording = false;
    }
    this.cameraReady = false;
    this.cameraService.release();
  } else {
    if (this._resuming || this.surfaceId === '') { return; }
    this._resuming = true;
    this.initCamera().finally(() => { this._resuming = false; });
  }
}

这段逻辑说明了相机页的真实约束:

  • 切到后台时要及时释放
  • 返回前台时要重新初始化
  • 录像状态不能在后台悬空

这类代码写好了,页面才不会"看起来能用,实际很脆"。

三、定位是水印主输入之一

1. 启动定位监听

ts 复制代码
private async startLocationWatch(): Promise<void> {
  const ctx = getContext(this) as common.UIAbilityContext;
  const granted = await PermissionUtil.requestLocationPermissions(ctx);
  if (!granted) {
    this.locationStr = '定位权限未授予';
    return;
  }
  LocationService.startLocationWatch((loc: LocationInfo) => {
    this.locationStr = loc.address || LocationService.formatLatLng(loc.latitude, loc.longitude);
    this.coordStr = LocationService.formatLatLng(loc.latitude, loc.longitude);
  }, (status: string) => {
    this.locationStr = status;
  });
}

这里最重要的不是"拿到地址",而是"持续维护拍摄上下文"。

因为拍照时真正写入水印的地址,应该尽量是当前可用的最新值,而不是拍完再补。

2. 手动地址和实时定位有优先级

ts 复制代码
private getWatermarkLocation(): string {
  if (this.wmManualLocation !== '') { return this.wmManualLocation; }
  if (this.locationStr !== '' && this.locationStr !== '定位权限未授予') {
    return this.locationStr;
  }
  if (this.wmSelectedTemplateName !== '') { return this.wmSelectedTemplateDesc; }
  return this.locationStr;
}

这段优先级很实用:

  1. 手动地址优先
  2. 实时定位其次
  3. 模板描述兜底

它保证了不同场景都能拿到一个可用地点,而不会因为定位失败直接让水印空掉。

四、传感器是用户体验的一部分

ts 复制代码
private accelCallback = (data: sensor.AccelerometerResponse): void => {
  const raw = Math.atan2(data.x, data.y) * 180 / Math.PI;
  const angle = ((Math.round(raw / 90) * 90) % 360 + 360) % 360;
  if (angle !== this.deviceAngle) {
    this.deviceAngle = angle;
    this.cameraService.setDeviceAngle(angle);
  }
};

很多相机页只关心"能不能拍",但这里还要关心"拍出来的方向对不对"。

加速度传感器的作用就是:

  • 帮助判断横竖屏
  • 让水印和预览更贴近真实方向
  • 避免用户旋转设备后画面和 UI 不一致

这类细节会直接影响专业感。

五、拍照和录像是两条分支

1. 录像模式

ts 复制代码
if (this.currentMode === '视频') {
  if (this.isRecording) {
    const ok = await this.cameraService.stopRecording(ctx, this.saveMode, { ...wmInfo });
    this.isRecording = false;
    if (ok && this.saveMode === 'local') {
      this.onGoVideoAlbum?.();
    }
  } else {
    await this.cameraService.startRecording();
    this.isRecording = true;
  }
  return;
}

录像模式里最重要的是状态切换要清楚:

  • 进入录制
  • 结束录制
  • 保存模式切换
  • 保存后是否跳转相册

2. 拍照模式

ts 复制代码
const wmInfo: WatermarkBurnInfo = {
  enabled: this.wmAutoWatermark && this.currentMode !== '证件',
  style: 'card',
  templateIndex: this.watermarkTemplate,
  time: this.timeStr,
  date: this.dateStr,
  location: this.getWatermarkLocation(),
  coords: this.coordStr,
  customText: this.wmCustomText,
  showTime: this.wmShowTime,
  showAddress: this.wmShowAddress,
  cropRatio: this.photoRatio
};

这段信息非常关键,因为它决定了最终照片怎么生成。

你可以把它理解成一个"拍照前的水印配置包":

  • 时间
  • 日期
  • 地址
  • 坐标
  • 模板
  • 自定义文本
  • 显示开关
  • 裁剪比例

拍照时先准备好这些信息,再把结果交给保存逻辑。

六、保存策略决定产品体验

ts 复制代码
if (this.saveMode === 'local') {
  this.cameraService.saveToAppStorage(ctx, wmInfo).then((path: string) => {
    if (path) {
      if (this.wrAutoRecord) { this.appendWorkRecord(path); }
      this.recordLocationToHistory();
    }
  });
} else {
  this.cameraService.saveToSystemAlbum(ctx, wmInfo).then((ok: boolean) => {
    if (ok) {
      this.recordLocationToHistory();
    }
  });
}

这里体现的是两个完全不同的产品方向:

  • local:更偏应用内闭环,方便后续回看和管理
  • systemAlbum:更偏系统级保存,用户更熟悉

这类分支写得清楚,产品定位也会清楚。

七、拍照后还要回流到工作记录

ts 复制代码
private appendWorkRecord(photoPath: string): void {
  const now = new Date();
  const list = JSON.parse(this.wrRecords) as WorkRecord[];
  const rec: WorkRecord = {
    id: Date.now().toString(),
    type: this.wrCurrentType,
    date: dateStr,
    location: this.locationStr,
    photoPath: photoPath
  };
  list.unshift(rec);
  this.wrRecords = JSON.stringify(list.slice(0, 100));
}

这一步的意义很大:

  • 照片不是终点
  • 它会进入工作记录页
  • 最终形成拍摄 -> 归档 -> 检索的链路

如果没有这一步,拍照只是拍照;有了这一步,照片就变成了业务记录。

八、保留记录次数不是细节,而是产品控制

ts 复制代码
private async onPhotoSavedForReview(): Promise<void> {
  if (this.reviewCompleted) { return; }
  this.reviewPhotoCount = this.reviewPhotoCount + 1;
  if (this.reviewPhotoCount >= 5) {
    await this.showCommentDialogBeforeContinue();
  }
}

这种提示逻辑的价值在于:

  • 避免用户长时间连拍后完全没有反馈
  • 给"继续拍"这个动作增加轻量引导
  • 把拍摄行为和产品评价、提示节奏连起来

九、这页最值得写进文章的 3 个点

  1. 权限、生命周期、传感器和定位是相机页的前置条件
  2. 水印信息不是拍完再补,而是在拍前就准备好
  3. 拍照后会回流到工作记录,整个链路才算闭环

小结表

模块 作用
权限 保证相机、存储、定位可用
生命周期 后台释放、前台恢复
定位 为水印提供实时地址
传感器 让预览方向更稳定
保存 决定本地还是系统相册
回流 让图片成为业务记录

问题 - 方案 - 验证

问题

相机页最容易出问题的不是"能不能拍",而是"拍出来的数据是否完整、资源是否能恢复"。

方案

把权限、定位、传感器、水印合成和保存分成几个固定步骤,按顺序执行。

验证

后台切换后相机能恢复、拍照后能生成记录、地址能同步到历史,说明这条链路是闭环的。

十、实操检查清单

  • 相机权限拒绝后是否有兜底提示
  • 后台切换后相机是否能正确释放并恢复
  • 拍照时水印信息是否完整
  • 手动地址是否能覆盖实时定位
  • 录像结束后是否能正确保存并跳转
  • 拍照后是否自动写入工作记录

结语

CameraPage 是这套应用里最能体现完成度的一页,因为它把"拍摄"从一个动作变成了一套流程。

把这页写顺了,应用就不只是能拍,而是真的能用。

参考资料

常见问题

1. 为什么水印要在拍照前准备

因为真正拍照时需要的是一个完整的上下文包,而不是拍完再补信息。前置准备可以避免拍后补录造成的信息不同步。

2. 为什么定位监听要常驻

因为地址会变化,拍照点和进入页面时的地址未必一致。常驻监听能让水印信息更接近真实拍摄时刻。

3. 为什么拍照后要写工作记录

因为单张图片只能说明"拍了",工作记录才能说明"在什么场景、什么时间、什么地点拍的"。它把图片变成业务数据。

发布前检查

  • 拍照、录像、定位三条链路是否讲清楚
  • 是否有一段代码专门解释水印合成
  • 是否说明了保存到本地和系统相册的区别
  • 是否写出了后台释放和前台恢复
  • 是否有拍后回流到工作记录的闭环