第29篇单拍按钮背后从点击到 PhotoOutput 回调第 29 篇从最小拍照闭环开始。很多相机教程会把重点放在“调用一次 capture”但真实项目里单拍按钮背后至少有四层状态预览是否活着、PhotoOutput 是否可用、当前是否已有拍摄任务、照片写入后是否进入相册记录。双镜记忆相机把这些状态放在Index.ets中统一管理单拍不是双拍的简化口号而是双拍失败、设备不支持、用户只想快速拍一张时的稳定兜底能力。本文是 21 天「智能相机开发实战」训练营中的一篇实操记录。所有代码片段都来自当前项目配图围绕运行页面和源码关键路径展开读完以后可以直接回到工程里按函数名定位。本篇目标读懂单拍从点击到 JPEG 回调的完整链路。理解为什么拍摄前要先生成 captureId、目标路径、地点快照和水印上下文。把 PhotoOutput 的回调、文件写入和相册入库对应起来。知道哪些状态必须在成功或失败后复位避免下一次拍摄被旧任务污染。代码位置entry/src/main/ets/pages/Index.etsentry/src/main/ets/services/GalleryRecordService.ets一、运行效果先对齐按钮后面是一条链路在拍照页里用户看到的是一个拍照入口和若干模式控件工程里看到的是预览、输出、地点、文件、相册记录之间的传递。单拍入口会先确认captureBusy再检查singlePhotoOutput、cameraSessionActive、singlePreviewLive。这些条件都满足时才会把本次拍照的 id、路径、地点和水印状态一次性写入 pending 字段。这样做的好处是回调晚到、用户连续点击、前摄镜像和保存失败都不会让页面进入说不清的中间态。图1 单拍运行侧链路点击、拍摄上下文、JPEG 回调、文件写入和相册记录二、triggerSingleCapture先武装上下文再触发拍照triggerSingleCapture的关键不是最后一行capture而是前面的准备动作。它先拉取地点快照再使用时间戳生成captureId和本地保存路径同时把经纬度、地点标题、水印样式都放到 pending 状态里。后续photoAvailable回调拿到 JPEG 时不需要再重新推导保存到哪里也不需要猜测这张照片属于哪个地点。图2 triggerSingleCapture 先准备上下文再调用 PhotoOutput.captureconst role this.singleCameraRole; const locationSnapshot await this.buildCaptureLocationSnapshot(); const timestamp ${Date.now()}; const singlePath this.buildCaptureFilePath(role, timestamp); this.captureBusy true; this.pendingCaptureMode single; this.pendingSingleCaptureRole role; this.backCaptureDelivered false; this.frontCaptureDelivered false; this.pendingCaptureId timestamp; this.pendingBackCapturePath singlePath; this.pendingFrontCapturePath singlePath; this.pendingCaptureLatitude locationSnapshot.latitude; this.pendingCaptureLongitude locationSnapshot.longitude; this.pendingCapturePlace locationSnapshot.place; this.pendingCaptureTitle locationSnapshot.memoryTitle; this.armPendingWatermark(parseInt(timestamp, 10), locationSnapshot.place, single); this.logCaptureTrace( trigger-single-capture-armed, role${role} path${singlePath} place${locationSnapshot.place} ); this.cameraStatusText ; this.lastCaptureSummary this.buildCaptureSummary(locationSnapshot); this.setPhotoOutputReady(single, false); const captureSetting: camera.PhotoCaptureSetting { quality: this.captureQualityLevel, rotation: camera.ImageRotation.ROTATION_0, mirror: role front }; try { await this.singlePhotoOutput.capture(captureSetting); this.logCaptureTrace(trigger-single-capture-request-finished, role${role}); } catch (error) { const err error as BusinessError; this.failDualCapture(${this.getCameraRoleLabel(role)}拍照失败 ${err.code}); } }这段代码里有两个容易忽略的点第一pendingBackCapturePath和pendingFrontCapturePath在单拍模式下指向同一张图后续模型仍然可以沿用双图字段第二前摄时把mirror设置为true把用户预览和最终成片保持一致。三、photoAvailable只在有效任务中写入 JPEGPhotoOutput 回调会经历captureStartWithInfo、captureEnd、captureReady和photoAvailable。真正拿到图像数据的是photoAvailable项目会检查错误码、photo.main、pendingCaptureId和captureBusy。如果回调已经不属于当前任务立刻释放 image 对象避免旧回调覆盖新任务。图3 photoAvailable 中读取 JPEG byteBuffer 并写入目标路径photoOutput.on(photoAvailable, (err: BusinessError, photo: camera.Photo): void { this.logCaptureTrace(photo-available-enter, role${role} err${err?.code ?? 0}); if (err err.code ! 0) { this.failDualCapture(${role back ? \u540e\u6444 : \u524d\u6444}\u62cd\u7167\u5931\u8d25 ${err.code}); return; } if (!photo || !photo.main) { this.failDualCapture(); return; } if (!this.captureBusy || this.pendingCaptureId.length 0) { this.logCaptureTrace(photo-available-dropped, role${role}); photo.main.release(); return; } const imageObj: image.Image photo.main; imageObj.getComponent(image.ComponentType.JPEG, (componentErr: BusinessError, component: image.Component): void { if (componentErr componentErr.code ! 0) { imageObj.release(); this.failDualCapture(${role back ? \u540e\u6444 : \u524d\u6444} JPEG \u8bfb\u53d6\u5931\u8d25 ${componentErr.code}); return; } if (!component || !component.byteBuffer) { imageObj.release(); this.failDualCapture(); return; } const targetPath role back ? this.pendingBackCapturePath : this.pendingFrontCapturePath; try { this.logCaptureTrace(write-capture-file-start, role${role} targetPath${targetPath}); const writeSuccess this.writeCaptureFile(targetPath, component.byteBuffer); if (!writeSuccess) { this.failDualCapture(); return; } this.logCaptureTrace(write-capture-file-finished, role${role} targetPath${targetPath}); void this.markCaptureDelivered(role).catch((error: Error) { this.failDualCapture(照片入库失败${error.message}); }); } catch (error) { const err error as BusinessError; this.failDualCapture(${role back ? \u540e\u6444 : \u524d\u6444}\u5199\u5165\u7167\u7247\u5931\u8d25 ${err.code ?? -1}); } finally { imageObj.release(); }这一段体现了相机项目的基本防线任何异步回调都不能默认可信。只有当前确实处于拍摄中并且 pending 上下文存在才会写入文件并继续调用markCaptureDelivered。四、markCaptureDelivered把一张照片变成相册记录单拍模式下交付完成后会创建一条GalleryMoment。记录里不仅有文件路径还有拍摄地点、经纬度、记忆标题、pairIndex 和水印信息。创建完成后页面复位 busy、pending 路径、地点字段和水印上下文再把记录交给appendGalleryRecord。图4 单拍完成后创建 GalleryMoment 并追加到相册const nextPairCount this.capturePairCount 1; const galleryRecord GalleryRecordService.createRecord({ id: captureId, createdAt: createdAt, pairIndex: nextPairCount, place: capturePlace, memoryTitle: captureTitle, latitude: this.pendingCaptureLatitude, longitude: this.pendingCaptureLongitude, backPath: singlePath, frontPath: singlePath, watermarkStyle: this.pendingWatermarkStyle, watermarkText: this.pendingWatermarkText }); this.captureBusy false; this.sequentialCaptureQueued false; this.capturePairCount nextPairCount; this.backCaptureDelivered false; this.frontCaptureDelivered false; this.pendingCaptureMode dual; this.pendingSingleCaptureRole back; this.pendingCaptureId ; this.pendingBackCapturePath ; this.pendingFrontCapturePath ; this.pendingCaptureLatitude 0; this.pendingCaptureLongitude 0; this.pendingCapturePlace ; this.pendingCaptureTitle ; this.clearPendingWatermark(); this.cameraStatusText ; this.lastCaptureSummary ; this.logCaptureTrace( mark-capture-delivered-single-finished, role${role} recordId${galleryRecord.id} pairIndex${galleryRecord.pairIndex} singlePath${singlePath} ); this.logCaptureTrace( mark-capture-delivered-dual-finished, recordId${galleryRecord.id} pairIndex${galleryRecord.pairIndex} ); void this.appendGalleryRecord(galleryRecord); return;这里的状态复位很重要。拍照按钮如果只在成功时改一个提示文案下一次拍摄仍可能带着旧路径或旧地点。项目把复位放在入库前完成保证后续 UI 刷新和存储动作都拿到明确结果。工程检查清单单拍入口必须判断captureBusy防止连续点击触发并发写入。拍照前必须生成路径和地点快照不能等回调里再临时拼。photoAvailable中读取 JPEG 后必须释放 image 对象。单拍记录复用backPath/frontPath双字段便于相册和双拍逻辑统一展示。成功和失败都要清理 pending 状态避免下一次拍摄继承旧上下文。今日练习在工程中搜索triggerSingleCapture标出它设置的所有 pending 字段。真机点击一次单拍观察按钮是否能在回调完成前阻止重复触发。把photoAvailable中的异常分支逐个读一遍写出每个分支保护的风险点。下一篇会继续沿着同一条工程链路往下拆先看用户能看到的效果再回到源码确认状态、文件和服务边界是否闭合。
第29篇|单拍按钮背后:从点击到 PhotoOutput 回调
发布时间:2026/5/31 16:09:22
第29篇单拍按钮背后从点击到 PhotoOutput 回调第 29 篇从最小拍照闭环开始。很多相机教程会把重点放在“调用一次 capture”但真实项目里单拍按钮背后至少有四层状态预览是否活着、PhotoOutput 是否可用、当前是否已有拍摄任务、照片写入后是否进入相册记录。双镜记忆相机把这些状态放在Index.ets中统一管理单拍不是双拍的简化口号而是双拍失败、设备不支持、用户只想快速拍一张时的稳定兜底能力。本文是 21 天「智能相机开发实战」训练营中的一篇实操记录。所有代码片段都来自当前项目配图围绕运行页面和源码关键路径展开读完以后可以直接回到工程里按函数名定位。本篇目标读懂单拍从点击到 JPEG 回调的完整链路。理解为什么拍摄前要先生成 captureId、目标路径、地点快照和水印上下文。把 PhotoOutput 的回调、文件写入和相册入库对应起来。知道哪些状态必须在成功或失败后复位避免下一次拍摄被旧任务污染。代码位置entry/src/main/ets/pages/Index.etsentry/src/main/ets/services/GalleryRecordService.ets一、运行效果先对齐按钮后面是一条链路在拍照页里用户看到的是一个拍照入口和若干模式控件工程里看到的是预览、输出、地点、文件、相册记录之间的传递。单拍入口会先确认captureBusy再检查singlePhotoOutput、cameraSessionActive、singlePreviewLive。这些条件都满足时才会把本次拍照的 id、路径、地点和水印状态一次性写入 pending 字段。这样做的好处是回调晚到、用户连续点击、前摄镜像和保存失败都不会让页面进入说不清的中间态。图1 单拍运行侧链路点击、拍摄上下文、JPEG 回调、文件写入和相册记录二、triggerSingleCapture先武装上下文再触发拍照triggerSingleCapture的关键不是最后一行capture而是前面的准备动作。它先拉取地点快照再使用时间戳生成captureId和本地保存路径同时把经纬度、地点标题、水印样式都放到 pending 状态里。后续photoAvailable回调拿到 JPEG 时不需要再重新推导保存到哪里也不需要猜测这张照片属于哪个地点。图2 triggerSingleCapture 先准备上下文再调用 PhotoOutput.captureconst role this.singleCameraRole; const locationSnapshot await this.buildCaptureLocationSnapshot(); const timestamp ${Date.now()}; const singlePath this.buildCaptureFilePath(role, timestamp); this.captureBusy true; this.pendingCaptureMode single; this.pendingSingleCaptureRole role; this.backCaptureDelivered false; this.frontCaptureDelivered false; this.pendingCaptureId timestamp; this.pendingBackCapturePath singlePath; this.pendingFrontCapturePath singlePath; this.pendingCaptureLatitude locationSnapshot.latitude; this.pendingCaptureLongitude locationSnapshot.longitude; this.pendingCapturePlace locationSnapshot.place; this.pendingCaptureTitle locationSnapshot.memoryTitle; this.armPendingWatermark(parseInt(timestamp, 10), locationSnapshot.place, single); this.logCaptureTrace( trigger-single-capture-armed, role${role} path${singlePath} place${locationSnapshot.place} ); this.cameraStatusText ; this.lastCaptureSummary this.buildCaptureSummary(locationSnapshot); this.setPhotoOutputReady(single, false); const captureSetting: camera.PhotoCaptureSetting { quality: this.captureQualityLevel, rotation: camera.ImageRotation.ROTATION_0, mirror: role front }; try { await this.singlePhotoOutput.capture(captureSetting); this.logCaptureTrace(trigger-single-capture-request-finished, role${role}); } catch (error) { const err error as BusinessError; this.failDualCapture(${this.getCameraRoleLabel(role)}拍照失败 ${err.code}); } }这段代码里有两个容易忽略的点第一pendingBackCapturePath和pendingFrontCapturePath在单拍模式下指向同一张图后续模型仍然可以沿用双图字段第二前摄时把mirror设置为true把用户预览和最终成片保持一致。三、photoAvailable只在有效任务中写入 JPEGPhotoOutput 回调会经历captureStartWithInfo、captureEnd、captureReady和photoAvailable。真正拿到图像数据的是photoAvailable项目会检查错误码、photo.main、pendingCaptureId和captureBusy。如果回调已经不属于当前任务立刻释放 image 对象避免旧回调覆盖新任务。图3 photoAvailable 中读取 JPEG byteBuffer 并写入目标路径photoOutput.on(photoAvailable, (err: BusinessError, photo: camera.Photo): void { this.logCaptureTrace(photo-available-enter, role${role} err${err?.code ?? 0}); if (err err.code ! 0) { this.failDualCapture(${role back ? \u540e\u6444 : \u524d\u6444}\u62cd\u7167\u5931\u8d25 ${err.code}); return; } if (!photo || !photo.main) { this.failDualCapture(); return; } if (!this.captureBusy || this.pendingCaptureId.length 0) { this.logCaptureTrace(photo-available-dropped, role${role}); photo.main.release(); return; } const imageObj: image.Image photo.main; imageObj.getComponent(image.ComponentType.JPEG, (componentErr: BusinessError, component: image.Component): void { if (componentErr componentErr.code ! 0) { imageObj.release(); this.failDualCapture(${role back ? \u540e\u6444 : \u524d\u6444} JPEG \u8bfb\u53d6\u5931\u8d25 ${componentErr.code}); return; } if (!component || !component.byteBuffer) { imageObj.release(); this.failDualCapture(); return; } const targetPath role back ? this.pendingBackCapturePath : this.pendingFrontCapturePath; try { this.logCaptureTrace(write-capture-file-start, role${role} targetPath${targetPath}); const writeSuccess this.writeCaptureFile(targetPath, component.byteBuffer); if (!writeSuccess) { this.failDualCapture(); return; } this.logCaptureTrace(write-capture-file-finished, role${role} targetPath${targetPath}); void this.markCaptureDelivered(role).catch((error: Error) { this.failDualCapture(照片入库失败${error.message}); }); } catch (error) { const err error as BusinessError; this.failDualCapture(${role back ? \u540e\u6444 : \u524d\u6444}\u5199\u5165\u7167\u7247\u5931\u8d25 ${err.code ?? -1}); } finally { imageObj.release(); }这一段体现了相机项目的基本防线任何异步回调都不能默认可信。只有当前确实处于拍摄中并且 pending 上下文存在才会写入文件并继续调用markCaptureDelivered。四、markCaptureDelivered把一张照片变成相册记录单拍模式下交付完成后会创建一条GalleryMoment。记录里不仅有文件路径还有拍摄地点、经纬度、记忆标题、pairIndex 和水印信息。创建完成后页面复位 busy、pending 路径、地点字段和水印上下文再把记录交给appendGalleryRecord。图4 单拍完成后创建 GalleryMoment 并追加到相册const nextPairCount this.capturePairCount 1; const galleryRecord GalleryRecordService.createRecord({ id: captureId, createdAt: createdAt, pairIndex: nextPairCount, place: capturePlace, memoryTitle: captureTitle, latitude: this.pendingCaptureLatitude, longitude: this.pendingCaptureLongitude, backPath: singlePath, frontPath: singlePath, watermarkStyle: this.pendingWatermarkStyle, watermarkText: this.pendingWatermarkText }); this.captureBusy false; this.sequentialCaptureQueued false; this.capturePairCount nextPairCount; this.backCaptureDelivered false; this.frontCaptureDelivered false; this.pendingCaptureMode dual; this.pendingSingleCaptureRole back; this.pendingCaptureId ; this.pendingBackCapturePath ; this.pendingFrontCapturePath ; this.pendingCaptureLatitude 0; this.pendingCaptureLongitude 0; this.pendingCapturePlace ; this.pendingCaptureTitle ; this.clearPendingWatermark(); this.cameraStatusText ; this.lastCaptureSummary ; this.logCaptureTrace( mark-capture-delivered-single-finished, role${role} recordId${galleryRecord.id} pairIndex${galleryRecord.pairIndex} singlePath${singlePath} ); this.logCaptureTrace( mark-capture-delivered-dual-finished, recordId${galleryRecord.id} pairIndex${galleryRecord.pairIndex} ); void this.appendGalleryRecord(galleryRecord); return;这里的状态复位很重要。拍照按钮如果只在成功时改一个提示文案下一次拍摄仍可能带着旧路径或旧地点。项目把复位放在入库前完成保证后续 UI 刷新和存储动作都拿到明确结果。工程检查清单单拍入口必须判断captureBusy防止连续点击触发并发写入。拍照前必须生成路径和地点快照不能等回调里再临时拼。photoAvailable中读取 JPEG 后必须释放 image 对象。单拍记录复用backPath/frontPath双字段便于相册和双拍逻辑统一展示。成功和失败都要清理 pending 状态避免下一次拍摄继承旧上下文。今日练习在工程中搜索triggerSingleCapture标出它设置的所有 pending 字段。真机点击一次单拍观察按钮是否能在回调完成前阻止重复触发。把photoAvailable中的异常分支逐个读一遍写出每个分支保护的风险点。下一篇会继续沿着同一条工程链路往下拆先看用户能看到的效果再回到源码确认状态、文件和服务边界是否闭合。