【Jack实战】如何在《时光旅记》中用 Camera Kit 实现海拔水印打卡

大家好,我是鸿蒙Jack。本期以我的《时光旅记》APP 为例,聊一下我是怎么把 Camera Kit 接到"海拔打卡"这个真实业务场景里的。

这个功能的表面体验很简单:用户打开"海拔打卡",APP 自动拿到当前位置、海拔、气压、坐标和地点名;用户可以分享当前打卡卡片,也可以进入水印拍照,把现场照片和海拔信息合成一张带地点信息的打卡图,最后保存到系统相册。

真正做起来,难点不在"调用一次相机"。如果只拿一张照片,用系统相机选择器就够了。但《时光旅记》的水印拍照需要自己的取景页、自己的水印层、横竖屏适配、拍照后预览、EXIF 写入和相册保存,所以我同时用了 Camera Kit 的两种能力:

cameraPicker.pick() 用在"更换背景图"这种轻量入口;camera.CameraManager + XComponent + PhotoOutput 用在"水印拍照"这种需要自定义预览和自定义拍照流程的入口。

官方文档我主要参考了这几块:

技术栈怎么分工

我这个海拔打卡不是 Camera Kit 单点能力,而是一条"拍摄 + 环境数据 + 图片合成 + 媒体保存"的链路。

Camera Kit 负责两件事。第一,cameraPicker 拉起系统相机,适合拿一张临时背景图。第二,camera 模块自建相机会话,适合自己控制预览、拍照输出、旋转和 JPEG buffer 处理。

ArkUI 的 XComponent 用来承载相机预览 Surface。自建相机时,PreviewOutput 需要绑定这个 surfaceId,否则相机可以打开,但页面上看不到取景画面。

Location Kit 负责高精度定位。我这里调用 geoLocationManager.getCurrentLocation(),读取 latitudelongitudealtitudealtitudeAccuracy。当 altitudeAccuracy 为 0 时,我把它视作卫星高度精度不可靠,再看气压计是否能兜底。

Sensor Service Kit 负责气压和磁场。气压计用 sensor.SensorId.BAROMETER 订阅,再用 sensor.getDeviceAltitude(1013.25, pressure) 推算海拔。磁场用 sensor.SensorId.MAGNETIC_FIELD 订阅,展示当前磁场强度,给户外场景多一个环境参考。

Map Kit 的 site.reverseGeocode() 负责把经纬度转成省市、街道和 POI 名称。海拔水印里只显示数字会很冷,补上地点名才像一条真正的旅行记录。

MediaLibraryKit 负责从系统图库选照片,以及最终把合成后的水印图写入系统相册。保存相册时我配合 SaveButton 安全控件,用户授权成功后再创建媒体资源。

Image Kit 负责读取、解码、合成和写 EXIF。拍照页拿到的是 JPEG buffer,预览页要把照片和水印条合成一张图,再写入 DATE_TIME_ORIGINALGPS_LATITUDEGPS_LONGITUDEGPS_ALTITUDE 等 EXIF 字段。

Core File Kit 负责应用沙箱临时文件。自建相机回调的是内存里的 JPEG buffer,我会先写到 filesDir/time-imprint/watermark-camera,预览页再从这个 file:// URI 读取。

整体结构如下:
#mermaid-svg-N125zDZUJdyuGlmh{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-N125zDZUJdyuGlmh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-N125zDZUJdyuGlmh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-N125zDZUJdyuGlmh .error-icon{fill:#552222;}#mermaid-svg-N125zDZUJdyuGlmh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-N125zDZUJdyuGlmh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-N125zDZUJdyuGlmh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-N125zDZUJdyuGlmh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-N125zDZUJdyuGlmh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-N125zDZUJdyuGlmh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-N125zDZUJdyuGlmh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-N125zDZUJdyuGlmh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-N125zDZUJdyuGlmh .marker.cross{stroke:#333333;}#mermaid-svg-N125zDZUJdyuGlmh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-N125zDZUJdyuGlmh p{margin:0;}#mermaid-svg-N125zDZUJdyuGlmh .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-N125zDZUJdyuGlmh .cluster-label text{fill:#333;}#mermaid-svg-N125zDZUJdyuGlmh .cluster-label span{color:#333;}#mermaid-svg-N125zDZUJdyuGlmh .cluster-label span p{background-color:transparent;}#mermaid-svg-N125zDZUJdyuGlmh .label text,#mermaid-svg-N125zDZUJdyuGlmh span{fill:#333;color:#333;}#mermaid-svg-N125zDZUJdyuGlmh .node rect,#mermaid-svg-N125zDZUJdyuGlmh .node circle,#mermaid-svg-N125zDZUJdyuGlmh .node ellipse,#mermaid-svg-N125zDZUJdyuGlmh .node polygon,#mermaid-svg-N125zDZUJdyuGlmh .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-N125zDZUJdyuGlmh .rough-node .label text,#mermaid-svg-N125zDZUJdyuGlmh .node .label text,#mermaid-svg-N125zDZUJdyuGlmh .image-shape .label,#mermaid-svg-N125zDZUJdyuGlmh .icon-shape .label{text-anchor:middle;}#mermaid-svg-N125zDZUJdyuGlmh .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-N125zDZUJdyuGlmh .rough-node .label,#mermaid-svg-N125zDZUJdyuGlmh .node .label,#mermaid-svg-N125zDZUJdyuGlmh .image-shape .label,#mermaid-svg-N125zDZUJdyuGlmh .icon-shape .label{text-align:center;}#mermaid-svg-N125zDZUJdyuGlmh .node.clickable{cursor:pointer;}#mermaid-svg-N125zDZUJdyuGlmh .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-N125zDZUJdyuGlmh .arrowheadPath{fill:#333333;}#mermaid-svg-N125zDZUJdyuGlmh .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-N125zDZUJdyuGlmh .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-N125zDZUJdyuGlmh .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-N125zDZUJdyuGlmh .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-N125zDZUJdyuGlmh .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-N125zDZUJdyuGlmh .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-N125zDZUJdyuGlmh .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-N125zDZUJdyuGlmh .cluster text{fill:#333;}#mermaid-svg-N125zDZUJdyuGlmh .cluster span{color:#333;}#mermaid-svg-N125zDZUJdyuGlmh 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-N125zDZUJdyuGlmh .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-N125zDZUJdyuGlmh rect.text{fill:none;stroke-width:0;}#mermaid-svg-N125zDZUJdyuGlmh .icon-shape,#mermaid-svg-N125zDZUJdyuGlmh .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-N125zDZUJdyuGlmh .icon-shape p,#mermaid-svg-N125zDZUJdyuGlmh .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-N125zDZUJdyuGlmh .icon-shape .label rect,#mermaid-svg-N125zDZUJdyuGlmh .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-N125zDZUJdyuGlmh .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-N125zDZUJdyuGlmh .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-N125zDZUJdyuGlmh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 发现页功能中心
AltitudeCheckInPage 海拔打卡页
Location Kit getCurrentLocation
Map Kit site.reverseGeocode
Sensor Service Kit 气压计和磁场
CameraPicker 更换背景图
自建 Camera Kit 水印拍照页
XComponent Surface 预览
CameraManager / CameraInput
PreviewOutput
PhotoOutput
Core File Kit 写入临时 JPG
水印预览页
Image Kit 合成 PixelMap / 写 EXIF
MediaLibraryKit 保存到相册

再看一次水印拍照时序:
MediaLibraryKit AltitudeWatermarkPreviewPage Core File Kit Camera Kit AltitudeWatermarkCameraPage AltitudeCheckInPage 用户 MediaLibraryKit AltitudeWatermarkPreviewPage Core File Kit Camera Kit AltitudeWatermarkCameraPage AltitudeCheckInPage 用户 #mermaid-svg-X7gYl9kz2PN0OlhK{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-X7gYl9kz2PN0OlhK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-X7gYl9kz2PN0OlhK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-X7gYl9kz2PN0OlhK .error-icon{fill:#552222;}#mermaid-svg-X7gYl9kz2PN0OlhK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-X7gYl9kz2PN0OlhK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-X7gYl9kz2PN0OlhK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-X7gYl9kz2PN0OlhK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-X7gYl9kz2PN0OlhK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-X7gYl9kz2PN0OlhK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-X7gYl9kz2PN0OlhK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-X7gYl9kz2PN0OlhK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-X7gYl9kz2PN0OlhK .marker.cross{stroke:#333333;}#mermaid-svg-X7gYl9kz2PN0OlhK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-X7gYl9kz2PN0OlhK p{margin:0;}#mermaid-svg-X7gYl9kz2PN0OlhK .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-X7gYl9kz2PN0OlhK text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-X7gYl9kz2PN0OlhK .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-X7gYl9kz2PN0OlhK .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-X7gYl9kz2PN0OlhK .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-X7gYl9kz2PN0OlhK .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-X7gYl9kz2PN0OlhK #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-X7gYl9kz2PN0OlhK .sequenceNumber{fill:white;}#mermaid-svg-X7gYl9kz2PN0OlhK #sequencenumber{fill:#333;}#mermaid-svg-X7gYl9kz2PN0OlhK #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-X7gYl9kz2PN0OlhK .messageText{fill:#333;stroke:none;}#mermaid-svg-X7gYl9kz2PN0OlhK .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-X7gYl9kz2PN0OlhK .labelText,#mermaid-svg-X7gYl9kz2PN0OlhK .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-X7gYl9kz2PN0OlhK .loopText,#mermaid-svg-X7gYl9kz2PN0OlhK .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-X7gYl9kz2PN0OlhK .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-X7gYl9kz2PN0OlhK .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-X7gYl9kz2PN0OlhK .noteText,#mermaid-svg-X7gYl9kz2PN0OlhK .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-X7gYl9kz2PN0OlhK .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-X7gYl9kz2PN0OlhK .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-X7gYl9kz2PN0OlhK .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-X7gYl9kz2PN0OlhK .actorPopupMenu{position:absolute;}#mermaid-svg-X7gYl9kz2PN0OlhK .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-X7gYl9kz2PN0OlhK .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-X7gYl9kz2PN0OlhK .actor-man circle,#mermaid-svg-X7gYl9kz2PN0OlhK line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-X7gYl9kz2PN0OlhK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击水印拍照 固化当前海拔、坐标、地点和时间 打开自定义相机页 createCameraInput / createPreviewOutput / createPhotoOutput createSession / addInput / addOutput / start 点击快门 photoOutput.capture() photoAvailable(JPEG buffer) 写入 watermark-camera 临时 JPG onCaptured(fileUri) 打开水印预览页 点击保存 合成照片和水印条,写入 GPS EXIF createAsset 并复制文件到系统相册

为什么要同时用 CameraPicker 和自建相机

我在《时光旅记》里保留了 CameraPicker,是因为它很适合"拍一张图回来"这类轻交互。比如海拔主卡片的"更改背景(拍照)",用户只是想换一张背景图,没必要让我重新做一套拍照页。

但水印拍照不能只用 CameraPicker。因为我要在取景时就看到底部水印条,要根据横竖屏压缩水印布局,还要控制照片旋转、输出尺寸、临时文件和后续预览。这个场景必须用 Camera Kit 自建相机链路:CameraManager 选设备,CameraInput 打开后置摄像头,PreviewOutput 输出到 XComponentPhotoOutput 监听 photoAvailable 拿 JPEG buffer。
#mermaid-svg-1aa0F2kMUP3j5bSQ{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-1aa0F2kMUP3j5bSQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1aa0F2kMUP3j5bSQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1aa0F2kMUP3j5bSQ .error-icon{fill:#552222;}#mermaid-svg-1aa0F2kMUP3j5bSQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1aa0F2kMUP3j5bSQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1aa0F2kMUP3j5bSQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1aa0F2kMUP3j5bSQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1aa0F2kMUP3j5bSQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1aa0F2kMUP3j5bSQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1aa0F2kMUP3j5bSQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1aa0F2kMUP3j5bSQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1aa0F2kMUP3j5bSQ .marker.cross{stroke:#333333;}#mermaid-svg-1aa0F2kMUP3j5bSQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1aa0F2kMUP3j5bSQ p{margin:0;}#mermaid-svg-1aa0F2kMUP3j5bSQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1aa0F2kMUP3j5bSQ .cluster-label text{fill:#333;}#mermaid-svg-1aa0F2kMUP3j5bSQ .cluster-label span{color:#333;}#mermaid-svg-1aa0F2kMUP3j5bSQ .cluster-label span p{background-color:transparent;}#mermaid-svg-1aa0F2kMUP3j5bSQ .label text,#mermaid-svg-1aa0F2kMUP3j5bSQ span{fill:#333;color:#333;}#mermaid-svg-1aa0F2kMUP3j5bSQ .node rect,#mermaid-svg-1aa0F2kMUP3j5bSQ .node circle,#mermaid-svg-1aa0F2kMUP3j5bSQ .node ellipse,#mermaid-svg-1aa0F2kMUP3j5bSQ .node polygon,#mermaid-svg-1aa0F2kMUP3j5bSQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1aa0F2kMUP3j5bSQ .rough-node .label text,#mermaid-svg-1aa0F2kMUP3j5bSQ .node .label text,#mermaid-svg-1aa0F2kMUP3j5bSQ .image-shape .label,#mermaid-svg-1aa0F2kMUP3j5bSQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-1aa0F2kMUP3j5bSQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1aa0F2kMUP3j5bSQ .rough-node .label,#mermaid-svg-1aa0F2kMUP3j5bSQ .node .label,#mermaid-svg-1aa0F2kMUP3j5bSQ .image-shape .label,#mermaid-svg-1aa0F2kMUP3j5bSQ .icon-shape .label{text-align:center;}#mermaid-svg-1aa0F2kMUP3j5bSQ .node.clickable{cursor:pointer;}#mermaid-svg-1aa0F2kMUP3j5bSQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1aa0F2kMUP3j5bSQ .arrowheadPath{fill:#333333;}#mermaid-svg-1aa0F2kMUP3j5bSQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1aa0F2kMUP3j5bSQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1aa0F2kMUP3j5bSQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1aa0F2kMUP3j5bSQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1aa0F2kMUP3j5bSQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1aa0F2kMUP3j5bSQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1aa0F2kMUP3j5bSQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1aa0F2kMUP3j5bSQ .cluster text{fill:#333;}#mermaid-svg-1aa0F2kMUP3j5bSQ .cluster span{color:#333;}#mermaid-svg-1aa0F2kMUP3j5bSQ 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-1aa0F2kMUP3j5bSQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1aa0F2kMUP3j5bSQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-1aa0F2kMUP3j5bSQ .icon-shape,#mermaid-svg-1aa0F2kMUP3j5bSQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1aa0F2kMUP3j5bSQ .icon-shape p,#mermaid-svg-1aa0F2kMUP3j5bSQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1aa0F2kMUP3j5bSQ .icon-shape .label rect,#mermaid-svg-1aa0F2kMUP3j5bSQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1aa0F2kMUP3j5bSQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1aa0F2kMUP3j5bSQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1aa0F2kMUP3j5bSQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 业务需求
只要拿到一张现场照片
需要自定义取景页和水印层
cameraPicker.pick
返回 resultUri
作为打卡卡片背景图
CameraManager 自建会话
photoAvailable 返回 JPEG buffer
进入水印预览和保存链路

权限配置

CameraPicker 本身由系统相机完成拍照确认,轻量使用时可以少关心相机会话权限。但我这里有自建相机页,所以 CAMERA 必须声明并动态申请。海拔打卡还依赖定位和媒体位置信息,module.json5 里相关配置如下:

json5 复制代码
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.CAMERA",
        "reason": "$string:permission_camera_reason",
        "usedScene": {
          "abilities": [
            "EntryAbility"
          ],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.MEDIA_LOCATION",
        "reason": "$string:permission_media_location_reason",
        "usedScene": {
          "abilities": [
            "EntryAbility"
          ],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.APPROXIMATELY_LOCATION",
        "reason": "$string:permission_location_reason",
        "usedScene": {
          "abilities": [
            "EntryAbility"
          ],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.LOCATION",
        "reason": "$string:permission_location_reason",
        "usedScene": {
          "abilities": [
            "EntryAbility"
          ],
          "when": "inuse"
        }
      }
    ]
  }
}

权限申请我没有散落在页面里,而是封了一个小工具。这个工具先查当前授权状态,没授权再调 requestPermissionsFromUser()

arkts 复制代码
import { abilityAccessCtrl, bundleManager, Context, Permissions } from '@kit.AbilityKit';

let cachedAccessTokenId: number = -1;

async function getSelfAccessTokenId(): Promise<number> {
  if (cachedAccessTokenId > 0) {
    return cachedAccessTokenId;
  }
  const bundleInfo = await bundleManager.getBundleInfoForSelf(
    bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION
  );
  cachedAccessTokenId = bundleInfo.appInfo.accessTokenId;
  return cachedAccessTokenId;
}

export async function arePermissionsGranted(permissions: Array<Permissions>): Promise<boolean> {
  if (permissions.length === 0) {
    return true;
  }
  try {
    const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
    const accessTokenId: number = await getSelfAccessTokenId();
    for (let i: number = 0; i < permissions.length; i++) {
      const grantStatus: abilityAccessCtrl.GrantStatus =
        await atManager.checkAccessToken(accessTokenId, permissions[i]);
      if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
        return false;
      }
    }
    return true;
  } catch (_error) {
    return false;
  }
}

export async function ensurePermissionsGranted(context: Context, permissions: Array<Permissions>): Promise<boolean> {
  if (await arePermissionsGranted(permissions)) {
    return true;
  }
  try {
    await abilityAccessCtrl.createAtManager().requestPermissionsFromUser(context, permissions);
  } catch (_error) {
  }
  return await arePermissionsGranted(permissions);
}

海拔数据怎么取

我这里没有只相信一个数据源。真实户外场景里,Location.altitudealtitudeAccuracy 受卫星信号影响;气压计又不是所有设备都有,也会受天气气压影响。所以《时光旅记》的策略是:

先请求高精度定位,拿经纬度和卫星海拔;如果 altitudeAccuracy 不为 0,就使用定位海拔;如果 altitudeAccuracy 为 0,说明高度精度不可靠,此时优先用气压计推算海拔;如果气压计也没有,再退回系统缓存海拔。

arkts 复制代码
import { common, Context, Permissions } from '@kit.AbilityKit';
import { geoLocationManager } from '@kit.LocationKit';
import { site } from '@kit.MapKit';
import { sensor } from '@kit.SensorServiceKit';
import { ensurePermissionsGranted } from '../utils/PermissionUtil';

export class AltitudeResolvedLocation {
  locationName: string = '';
  locationAddress: string = '';
}

export class AltitudeSnapshot {
  altitude: number = 0;
  latitude: number = 0;
  longitude: number = 0;
  pressure: number = 0;
  barometerAltitude: number = 0;
  altitudeAccuracy: number = 0;
  magneticField: number = 0;
  isWeakSatelliteSignal: boolean = false;
  isBarometerUnsupported: boolean = false;
  locationName: string = '定位中...';
  locationAddress: string = '';
  isLoading: boolean = true;
}

export class JackAltitudeCheckInController {
  private context: common.UIAbilityContext;
  private snapshot: AltitudeSnapshot = new AltitudeSnapshot();
  private onChanged: (snapshot: AltitudeSnapshot) => void = (_snapshot: AltitudeSnapshot): void => {};

  constructor(context: common.UIAbilityContext, onChanged: (snapshot: AltitudeSnapshot) => void) {
    this.context = context;
    this.onChanged = onChanged;
  }

  public async start(): Promise<void> {
    this.subscribeBarometerSensor();
    this.subscribeMagneticFieldSensor();
    await this.fetchLocation(true);
  }

  public stop(): void {
    try {
      sensor.off(sensor.SensorId.BAROMETER);
      sensor.off(sensor.SensorId.MAGNETIC_FIELD);
    } catch (_error) {
    }
  }

  public async fetchLocation(needGeocode: boolean): Promise<void> {
    if (needGeocode) {
      this.snapshot.isLoading = true;
      this.emit();
    }

    let hasPermission: boolean = await this.requestPermissionList(
      this.context,
      ['ohos.permission.APPROXIMATELY_LOCATION', 'ohos.permission.LOCATION']
    );
    if (!hasPermission) {
      this.snapshot.locationName = '无定位权限';
      this.snapshot.isLoading = false;
      this.emit();
      return;
    }

    try {
      let currentLocation: geoLocationManager.Location = await geoLocationManager.getCurrentLocation({
        priority: geoLocationManager.LocationRequestPriority.ACCURACY,
        scenario: geoLocationManager.LocationRequestScenario.NAVIGATION,
        maxAccuracy: 0
      });

      this.snapshot.altitudeAccuracy = currentLocation.altitudeAccuracy || 0;
      this.snapshot.latitude = currentLocation.latitude;
      this.snapshot.longitude = currentLocation.longitude;

      if (this.snapshot.altitudeAccuracy === 0) {
        this.snapshot.isWeakSatelliteSignal = true;
        if (this.snapshot.barometerAltitude !== 0) {
          this.snapshot.altitude = this.snapshot.barometerAltitude;
        } else if (currentLocation.altitude !== 0) {
          this.snapshot.altitude = currentLocation.altitude;
        }
      } else {
        this.snapshot.isWeakSatelliteSignal = false;
        this.snapshot.altitude = currentLocation.altitude;
      }

      if (needGeocode) {
        let resolved: AltitudeResolvedLocation =
          await this.reverseGeocodeLocation(this.snapshot.latitude, this.snapshot.longitude);
        this.snapshot.locationName = resolved.locationName.length > 0 ? resolved.locationName : '未知位置';
        this.snapshot.locationAddress = resolved.locationAddress;
      }
    } catch (_error) {
      if (needGeocode) {
        this.snapshot.locationName = '定位失败';
      }
    }

    if (needGeocode) {
      this.snapshot.isLoading = false;
    }
    this.emit();
  }

  public async reverseGeocodeLocation(latitude: number, longitude: number): Promise<AltitudeResolvedLocation> {
    let resolvedLocation: AltitudeResolvedLocation = new AltitudeResolvedLocation();
    try {
      let params: site.ReverseGeocodeParams = {
        location: {
          latitude: latitude,
          longitude: longitude
        },
        language: 'zh'
      };
      let result = await site.reverseGeocode(this.context, params);
      if (result && result.addressComponent) {
        let ac = result.addressComponent;
        let provinceName = ac.adminLevel1 || '';
        let cityName = ac.locality || ac.city?.cityName || ac.adminLevel2 || '';
        resolvedLocation.locationName = `${provinceName} ${cityName}`.trim() || '未知城市';
        resolvedLocation.locationAddress = ac.streetNumber?.formatAddress || result.addressDescription || '未知地址';
        if (result.pois && result.pois.length > 0) {
          resolvedLocation.locationAddress = result.pois[0].name || resolvedLocation.locationAddress;
        }
      }
    } catch (_error) {
    }
    return resolvedLocation;
  }

  private subscribeBarometerSensor(): void {
    try {
      sensor.getSingleSensorSync(sensor.SensorId.BAROMETER);
      this.snapshot.isBarometerUnsupported = false;
    } catch (_error) {
      this.snapshot.isBarometerUnsupported = true;
      this.emit();
      return;
    }

    sensor.on(
      sensor.SensorId.BAROMETER,
      (data: sensor.BarometerResponse) => {
        this.snapshot.pressure = data.pressure;
        sensor.getDeviceAltitude(1013.25, data.pressure).then((alt: number) => {
          this.snapshot.barometerAltitude = alt;
          if (this.snapshot.altitudeAccuracy === 0 && alt !== 0) {
            this.snapshot.altitude = alt;
            this.snapshot.isWeakSatelliteSignal = true;
          }
          this.emit();
        });
      },
      { interval: 1000000000 }
    );
  }

  private subscribeMagneticFieldSensor(): void {
    try {
      sensor.getSingleSensorSync(sensor.SensorId.MAGNETIC_FIELD);
    } catch (_error) {
      return;
    }
    sensor.on(
      sensor.SensorId.MAGNETIC_FIELD,
      (data: sensor.MagneticFieldResponse) => {
        this.snapshot.magneticField = Math.sqrt(data.x * data.x + data.y * data.y + data.z * data.z);
        this.emit();
      },
      { interval: 1000000000 }
    );
  }

  private async requestPermissionList(context: Context, permissions: Array<Permissions>): Promise<boolean> {
    return ensurePermissionsGranted(context as common.UIAbilityContext, permissions);
  }

  private emit(): void {
    let copy: AltitudeSnapshot = Object.assign(new AltitudeSnapshot(), this.snapshot);
    this.onChanged(copy);
  }
}

轻量拍照:CameraPicker 更换背景图

《时光旅记》的海拔主卡片可以自定义背景。这里我用了 cameraPicker.pick(),因为用户只是拍一张背景,不需要自定义相机界面。

arkts 复制代码
import { Context, Permissions } from '@kit.AbilityKit';
import { camera, cameraPicker } from '@kit.CameraKit';
import { ensurePermissionsGranted } from '../utils/PermissionUtil';

export async function captureBackgroundPhoto(context: Context): Promise<string> {
  let hasPermission: boolean =
    await ensurePermissionsGranted(context, ['ohos.permission.CAMERA'] as Array<Permissions>);
  if (!hasPermission) {
    return '';
  }

  try {
    let pickerProfile: cameraPicker.PickerProfile = {
      cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK
    };
    let result: cameraPicker.PickerResult = await cameraPicker.pick(
      context,
      [cameraPicker.PickerMediaType.PHOTO],
      pickerProfile
    );
    if (result.resultCode === 0 && result.resultUri.length > 0) {
      return result.resultUri;
    }
  } catch (_error) {
  }
  return '';
}

页面里拿到 URI 后直接给背景图即可:

arkts 复制代码
let photoUri: string = await captureBackgroundPhoto(hostContext);
if (photoUri.length > 0) {
  this.backgroundImageUri = photoUri;
}

水印条组件

水印条是一个普通 ArkUI 组件。它既显示在相机预览页底部,也会在预览页被 componentSnapshot.getSync() 截成图片,拼到最终导出的 JPEG 下方。

arkts 复制代码
import { deviceInfo } from '@kit.BasicServicesKit';

@Component
export struct AltitudeWatermarkStrip {
  appName: string = '时光旅记';
  altitude: number = 0;
  latitude: number = 0;
  longitude: number = 0;
  locationName: string = '';
  locationAddress: string = '';
  isLoading: boolean = false;
  compactMode: boolean = false;
  captureTime: string = '';

  build(): void {
    Row({ space: this.compactMode ? 6 : 12 }) {
      Image($r('app.media.icon_startwindow'))
        .width(this.compactMode ? 16 : 24)
        .height(this.compactMode ? 16 : 24)
        .borderRadius(6)

      Column({ space: 2 }) {
        Text(this.appName)
          .fontSize(this.compactMode ? 8 : 12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#111111')
        Text(this.getCaptureInfoLine())
          .fontSize(this.compactMode ? 5 : 8)
          .fontColor('#6B7280')
      }
      .alignItems(HorizontalAlign.Start)

      Column({ space: 2 }) {
        Text(this.getAltitudeLine())
          .fontSize(this.compactMode ? 11 : 16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#111111')
          .textAlign(TextAlign.End)
          .width('100%')
        Text(this.getLocationLine())
          .fontSize(this.compactMode ? 5 : 8)
          .fontColor('#4B5563')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .textAlign(TextAlign.End)
          .width('100%')
        Text(this.getCoordinateLine())
          .fontSize(this.compactMode ? 5 : 8)
          .fontColor('#4B5563')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .textAlign(TextAlign.End)
          .width('100%')
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .padding({
      left: this.compactMode ? 4 : 10,
      right: this.compactMode ? 4 : 10,
      top: this.compactMode ? 2 : 6,
      bottom: this.compactMode ? 2 : 6
    })
    .backgroundColor('#FFFFFF')
    .alignItems(VerticalAlign.Center)
  }

  private getAltitudeLine(): string {
    if (this.isLoading && this.altitude === 0) {
      return '-- m';
    }
    return `${Math.round(this.altitude)} m`;
  }

  private getLocationLine(): string {
    let primary: string = this.locationAddress.trim();
    if (primary.length > 0 && primary !== '未知地址') {
      return primary;
    }
    primary = this.locationName.trim();
    return primary.length > 0 ? primary : '未知位置';
  }

  private getCoordinateLine(): string {
    if (this.latitude === 0 && this.longitude === 0) {
      return this.isLoading ? '坐标获取中' : '未知坐标';
    }
    return `${Math.abs(this.latitude).toFixed(4)}°${this.latitude >= 0 ? 'N' : 'S'}  ` +
      `${Math.abs(this.longitude).toFixed(4)}°${this.longitude >= 0 ? 'E' : 'W'}`;
  }

  private getCaptureInfoLine(): string {
    let timeText: string = this.captureTime.length > 0 ? this.captureTime : this.formatDateTime(new Date());
    let modelText: string = `${deviceInfo.marketName ?? ''}`.trim();
    return modelText.length === 0 ? timeText : `${timeText} · ${modelText}`;
  }

  private formatDateTime(date: Date): string {
    let month: string = `${date.getMonth() + 1}`.padStart(2, '0');
    let day: string = `${date.getDate()}`.padStart(2, '0');
    let hour: string = `${date.getHours()}`.padStart(2, '0');
    let minute: string = `${date.getMinutes()}`.padStart(2, '0');
    return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}`;
  }
}

自建相机页:CameraManager + XComponent + PhotoOutput

下面这份是水印拍照页的核心代码。关键点有四个:先用 XComponent 拿到 surfaceId;然后创建 CameraInputPreviewOutputPhotoOutput;接着 PhotoSession.beginConfig()addInput()addOutput()commitConfig()start();最后监听 photoOutput.on('photoAvailable'),把 JPEG buffer 写到应用沙箱临时文件。

arkts 复制代码
import { common, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { camera } from '@kit.CameraKit';
import { display } from '@kit.ArkUI';
import { fileIo } from '@kit.CoreFileKit';
import { util } from '@kit.ArkTS';
import { image } from '@kit.ImageKit';
import { AltitudeWatermarkStrip } from '../components/AltitudeWatermarkStrip';
import { ensurePermissionsGranted } from '../utils/PermissionUtil';

@Component
export struct AltitudeWatermarkCameraPage {
  altitude: number = 0;
  latitude: number = 0;
  longitude: number = 0;
  locationName: string = '';
  locationAddress: string = '';
  isLoading: boolean = false;
  captureTime: string = '';
  onClose: () => void = () => {};
  onCaptured: (photoUri: string) => void = (_photoUri: string): void => {};

  private readonly cameraPermission: Permissions = 'ohos.permission.CAMERA';
  private xComponentController: XComponentController = new XComponentController();
  private xComponentOptions: XComponentOptions = {
    type: XComponentType.SURFACE,
    controller: this.xComponentController
  };
  private cameraManager?: camera.CameraManager;
  private cameraInput?: camera.CameraInput;
  private previewOutput?: camera.PreviewOutput;
  private photoOutput?: camera.PhotoOutput;
  private photoSession?: camera.PhotoSession;
  private surfaceId: string = '';
  private tempPhotoDirectory: string = '';
  private currentDisplayRotation: number = 0;

  @State hasCameraPermission: boolean = false;
  @State isCameraReady: boolean = false;
  @State isCapturing: boolean = false;
  @State isLandscape: boolean = false;

  aboutToAppear(): void {
    void this.requestCameraPermission();
    this.syncDisplayState();
    display.on('change', () => {
      this.syncDisplayState();
      void this.updatePreviewRotation();
    });
  }

  aboutToDisappear(): void {
    display.off('change');
    void this.releaseCamera();
  }

  build(): void {
    Stack({ alignContent: Alignment.Bottom }) {
      if (this.hasCameraPermission) {
        XComponent(this.xComponentOptions)
          .width('100%')
          .height('100%')
          .backgroundColor(Color.Black)
          .renderFit(RenderFit.RESIZE_FILL)
          .onLoad(() => {
            this.surfaceId = this.xComponentController.getXComponentSurfaceId();
            void this.initCamera();
          })
      } else {
        Button('重新授权相机')
          .onClick(() => {
            void this.requestCameraPermission();
          })
      }

      Button('拍照')
        .width(88)
        .height(88)
        .enabled(this.isCameraReady && !this.isCapturing)
        .onClick(() => {
          void this.capturePhoto();
        })
        .position({ bottom: 120 })

      AltitudeWatermarkStrip({
        altitude: this.altitude,
        latitude: this.latitude,
        longitude: this.longitude,
        locationName: this.locationName,
        locationAddress: this.locationAddress,
        isLoading: this.isLoading,
        compactMode: this.isLandscape,
        captureTime: this.captureTime
      })
      .width('100%')
      .position({ bottom: 0 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0F172A')
  }

  private async requestCameraPermission(): Promise<void> {
    const hostContext = this.getUIContext().getHostContext();
    if (!hostContext) {
      return;
    }
    this.hasCameraPermission = await ensurePermissionsGranted(hostContext, [this.cameraPermission]);
  }

  private async initCamera(): Promise<void> {
    if (!this.hasCameraPermission || this.surfaceId.length === 0 || this.isCameraReady) {
      return;
    }
    const hostContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
    try {
      this.cameraManager = camera.getCameraManager(hostContext);
      let cameras: Array<camera.CameraDevice> = this.cameraManager.getSupportedCameras();
      let selectedCamera: camera.CameraDevice = this.getPreferredCamera(cameras);
      this.cameraInput = this.cameraManager.createCameraInput(selectedCamera);
      await this.cameraInput.open();

      let capability = this.cameraManager.getSupportedOutputCapability(selectedCamera, camera.SceneMode.NORMAL_PHOTO);
      let previewProfile: camera.Profile = capability.previewProfiles[0];
      let photoProfile: camera.Profile = capability.photoProfiles[0];
      this.previewOutput = this.cameraManager.createPreviewOutput(previewProfile, this.surfaceId);
      this.photoOutput = this.cameraManager.createPhotoOutput(photoProfile);
      this.registerPhotoOutputCallback(this.photoOutput);

      this.photoSession = this.cameraManager.createSession(camera.SceneMode.NORMAL_PHOTO) as camera.PhotoSession;
      this.photoSession.beginConfig();
      this.photoSession.addInput(this.cameraInput);
      this.photoSession.addOutput(this.previewOutput);
      this.photoSession.addOutput(this.photoOutput);
      await this.photoSession.commitConfig();
      await this.updatePreviewRotation();
      await this.photoSession.start();
      this.isCameraReady = true;
    } catch (_error) {
      this.getUIContext().getPromptAction().showToast({ message: '相机启动失败' });
      await this.releaseCamera();
    }
  }

  private getPreferredCamera(cameraDevices: Array<camera.CameraDevice>): camera.CameraDevice {
    for (let index = 0; index < cameraDevices.length; index++) {
      if (cameraDevices[index].cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK) {
        return cameraDevices[index];
      }
    }
    return cameraDevices[0];
  }

  private registerPhotoOutputCallback(output: camera.PhotoOutput): void {
    output.on('photoAvailable', (err: BusinessError, photo: camera.Photo): void => {
      if ((err && err.code !== 0) || !photo || !photo.main) {
        this.isCapturing = false;
        this.getUIContext().getPromptAction().showToast({ message: '拍照失败' });
        return;
      }
      let imageObj: image.Image = photo.main;
      imageObj.getComponent(image.ComponentType.JPEG, (componentError: BusinessError, component: image.Component): void => {
        void this.handlePhotoComponent(componentError, component, imageObj);
      });
    });
  }

  private async handlePhotoComponent(
    componentError: BusinessError,
    component: image.Component,
    imageObj: image.Image
  ): Promise<void> {
    try {
      if ((componentError && componentError.code !== 0) || !component || !component.byteBuffer) {
        throw new Error('jpeg buffer is empty');
      }
      let photoUri: string = await this.writeCapturedBufferToTempFile(component.byteBuffer);
      this.onCaptured(photoUri);
    } finally {
      imageObj.release();
      this.isCapturing = false;
    }
  }

  private async capturePhoto(): Promise<void> {
    if (!this.photoOutput || this.isCapturing) {
      return;
    }
    this.isCapturing = true;
    try {
      let captureSetting: camera.PhotoCaptureSetting = {
        quality: camera.QualityLevel.QUALITY_LEVEL_MEDIUM,
        rotation: await this.getPhotoRotation()
      };
      await this.photoOutput.capture(captureSetting);
    } catch (_error) {
      this.isCapturing = false;
    }
  }

  private async releaseCamera(): Promise<void> {
    this.isCameraReady = false;
    await this.photoSession?.stop().catch((_error: BusinessError) => {});
    await this.cameraInput?.close().catch((_error: BusinessError) => {});
    await this.previewOutput?.release().catch((_error: BusinessError) => {});
    await this.photoOutput?.release().catch((_error: BusinessError) => {});
    await this.photoSession?.release().catch((_error: BusinessError) => {});
    this.cameraInput = undefined;
    this.previewOutput = undefined;
    this.photoOutput = undefined;
    this.photoSession = undefined;
  }

  private syncDisplayState(): void {
    try {
      this.currentDisplayRotation = display.getDefaultDisplaySync().rotation;
    } catch (_error) {
      this.currentDisplayRotation = 0;
    }
    this.isLandscape = this.currentDisplayRotation === 1 || this.currentDisplayRotation === 3;
  }

  private async updatePreviewRotation(): Promise<void> {
    if (!this.previewOutput) {
      return;
    }
    let imageRotation = this.currentDisplayRotation * camera.ImageRotation.ROTATION_90;
    let previewRotation = this.previewOutput.getPreviewRotation(imageRotation);
    this.previewOutput.setPreviewRotation(previewRotation, false);
  }

  private async getPhotoRotation(): Promise<camera.ImageRotation> {
    if (!this.photoOutput) {
      return camera.ImageRotation.ROTATION_0;
    }
    let degree = this.currentDisplayRotation * 90;
    return this.photoOutput.getPhotoRotation(degree);
  }

  private async writeCapturedBufferToTempFile(buffer: ArrayBuffer): Promise<string> {
    const hostContext = this.getUIContext().getHostContext();
    if (!hostContext) {
      throw new Error('host context unavailable');
    }
    if (this.tempPhotoDirectory.length === 0) {
      this.tempPhotoDirectory = `${hostContext.filesDir}/time-imprint/watermark-camera`;
      if (!fileIo.accessSync(this.tempPhotoDirectory)) {
        await fileIo.mkdir(this.tempPhotoDirectory, true);
      }
    }
    const filePath = `${this.tempPhotoDirectory}/${util.generateRandomUUID(false)}.jpg`;
    const file = fileIo.openSync(
      filePath,
      fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC
    );
    try {
      fileIo.writeSync(file.fd, buffer);
    } finally {
      fileIo.closeSync(file);
    }
    return `file://${filePath}`;
  }
}

这里有个坑必须说一下:photoAvailable 回调里拿到 imageObj 后,处理完一定要 imageObj.release()。不释放的话,后续拍照可能拿不到新的 buffer,调试时很容易误以为是相机会话问题。

预览、合成、写 EXIF、保存相册

拍完之后我没有立刻保存到相册,而是先进入预览页。用户确认后,我再做三件事:把原图解码成 PixelMap;把 AltitudeWatermarkStrip 截图成水印 PixelMap;用 OffscreenCanvas 把照片和水印条拼成一张新图,写入 EXIF 后复制到系统相册。

arkts 复制代码
import { componentSnapshot, LengthMetricsUnit } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { fileIo } from '@kit.CoreFileKit';
import { image } from '@kit.ImageKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { AltitudeWatermarkStrip } from '../components/AltitudeWatermarkStrip';

const STRIP_ID: string = 'altitudeWatermarkPreviewStrip';

@Component
export struct AltitudeWatermarkPreviewPage {
  photoUri: string = '';
  altitude: number = 0;
  latitude: number = 0;
  longitude: number = 0;
  locationName: string = '';
  locationAddress: string = '';
  isLoading: boolean = false;
  captureTime: string = '';
  onClose: () => void = () => {};

  @State isSaving: boolean = false;

  build(): void {
    Column({ space: 16 }) {
      Image(this.photoUri)
        .width('100%')
        .objectFit(ImageFit.Contain)

      AltitudeWatermarkStrip({
        altitude: this.altitude,
        latitude: this.latitude,
        longitude: this.longitude,
        locationName: this.locationName,
        locationAddress: this.locationAddress,
        isLoading: this.isLoading,
        captureTime: this.captureTime
      })
      .id(STRIP_ID)
      .width('100%')

      SaveButton({ icon: SaveIconStyle.FULL_FILLED, text: SaveDescription.SAVE })
        .height(48)
        .enabled(!this.isSaving && this.photoUri.length > 0)
        .onClick(async (_event: ClickEvent, result: SaveButtonOnClickResult) => {
          if (result === SaveButtonOnClickResult.SUCCESS) {
            await this.savePreviewToAlbum();
          }
        })
    }
    .padding(20)
    .width('100%')
    .height('100%')
  }

  private async savePreviewToAlbum(): Promise<void> {
    if (this.isSaving || this.photoUri.length === 0) {
      return;
    }
    this.isSaving = true;
    try {
      let pixelMap: image.PixelMap = await this.composeFinalPixelMap();
      try {
        await this.saveComposedPixelMapToAlbum(pixelMap);
      } finally {
        pixelMap.release();
      }
      this.getUIContext().getPromptAction().showToast({ message: '水印打卡图已保存到相册' });
      this.onClose();
    } finally {
      this.isSaving = false;
    }
  }

  private async composeFinalPixelMap(): Promise<image.PixelMap> {
    let sourceFile = this.openPhotoFileForRead(this.photoUri);
    let imageSource = image.createImageSource(sourceFile.fd);
    let sourcePixelMap: image.PixelMap = await imageSource.createPixelMap({
      desiredPixelFormat: image.PixelMapFormat.RGBA_8888
    });
    fileIo.closeSync(sourceFile);
    await imageSource.release();

    try {
      let sourceInfo = sourcePixelMap.getImageInfoSync();
      let stripPixelMap = componentSnapshot.getSync(STRIP_ID, { waitUntilRenderFinished: true });
      try {
        let stripInfo = stripPixelMap.getImageInfoSync();
        let finalWidth = sourceInfo.size.width;
        let finalHeight = sourceInfo.size.height + stripInfo.size.height;
        let canvas = new OffscreenCanvas(finalWidth, finalHeight, LengthMetricsUnit.PX);
        let ctx = canvas.getContext('2d');
        ctx.fillStyle = '#FFFFFF';
        ctx.fillRect(0, 0, finalWidth, finalHeight);
        ctx.drawImage(sourcePixelMap, 0, 0, finalWidth, sourceInfo.size.height);
        ctx.drawImage(stripPixelMap, 0, sourceInfo.size.height, finalWidth, stripInfo.size.height);
        return ctx.getPixelMap(0, 0, finalWidth, finalHeight);
      } finally {
        stripPixelMap.release();
      }
    } finally {
      sourcePixelMap.release();
    }
  }

  private openPhotoFileForRead(uri: string): fileIo.File {
    try {
      return fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
    } catch (_error) {
      if (uri.startsWith('file://') && !uri.startsWith('file://media/')) {
        return fileIo.openSync(uri.replace('file://', ''), fileIo.OpenMode.READ_ONLY);
      }
      throw new Error('open photo file failed');
    }
  }

  private async saveComposedPixelMapToAlbum(pixelMap: image.PixelMap): Promise<string> {
    let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
    let tempPath = `${context.filesDir}/time-imprint/altitude-export-cache/altitude_${Date.now()}.jpg`;
    await this.packPixelMapToTempFile(pixelMap, tempPath);
    await this.writeExportExifToFile(tempPath);
    try {
      let helper = photoAccessHelper.getPhotoAccessHelper(context);
      let uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg');
      let sourceFile = fileIo.openSync(tempPath, fileIo.OpenMode.READ_ONLY);
      let targetFile = await fileIo.open(uri, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
      await fileIo.copyFile(sourceFile.fd, targetFile.fd);
      fileIo.closeSync(sourceFile);
      await fileIo.close(targetFile);
      return uri;
    } finally {
      try {
        fileIo.unlinkSync(tempPath);
      } catch (_error) {
      }
    }
  }

  private async packPixelMapToTempFile(pixelMap: image.PixelMap, filePath: string): Promise<void> {
    let dir = filePath.substring(0, filePath.lastIndexOf('/'));
    if (!fileIo.accessSync(dir)) {
      await fileIo.mkdir(dir, true);
    }
    let file = fileIo.openSync(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
    let packer = image.createImagePacker();
    try {
      await packer.packToFile(pixelMap, file.fd, {
        format: 'image/jpeg',
        quality: 96,
        needsPackProperties: true
      });
    } finally {
      await packer.release();
      fileIo.closeSync(file);
    }
  }

  private async writeExportExifToFile(filePath: string): Promise<void> {
    let file = fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE);
    let imageSource = image.createImageSource(file.fd);
    try {
      let records: Record<image.PropertyKey, string | null> = {} as Record<image.PropertyKey, string | null>;
      records[image.PropertyKey.DATE_TIME_ORIGINAL] = this.toExifDateTime();
      records[image.PropertyKey.DATE_TIME] = this.toExifDateTime();
      if (Math.abs(this.latitude) > 0.000001 && Math.abs(this.longitude) > 0.000001) {
        records[image.PropertyKey.GPS_LATITUDE_REF] = this.latitude >= 0 ? 'N' : 'S';
        records[image.PropertyKey.GPS_LONGITUDE_REF] = this.longitude >= 0 ? 'E' : 'W';
        records[image.PropertyKey.GPS_LATITUDE] = this.toExifGpsCoordinate(this.latitude);
        records[image.PropertyKey.GPS_LONGITUDE] = this.toExifGpsCoordinate(this.longitude);
      }
      if (!(this.isLoading && this.altitude === 0)) {
        records[image.PropertyKey.GPS_ALTITUDE_REF] = this.altitude < 0 ? '1' : '0';
        records[image.PropertyKey.GPS_ALTITUDE] = `${Math.round(Math.abs(this.altitude) * 100)}/100`;
      }
      await imageSource.modifyImageProperties(records);
    } finally {
      await imageSource.release();
      fileIo.closeSync(file);
    }
  }

  private toExifDateTime(): string {
    let now = new Date();
    let month = `${now.getMonth() + 1}`.padStart(2, '0');
    let day = `${now.getDate()}`.padStart(2, '0');
    let hour = `${now.getHours()}`.padStart(2, '0');
    let minute = `${now.getMinutes()}`.padStart(2, '0');
    let second = `${now.getSeconds()}`.padStart(2, '0');
    return `${now.getFullYear()}:${month}:${day} ${hour}:${minute}:${second}`;
  }

  private toExifGpsCoordinate(value: number): string {
    let absoluteValue = Math.abs(value);
    let degrees = Math.floor(absoluteValue);
    let minutesFloat = (absoluteValue - degrees) * 60;
    let minutes = Math.floor(minutesFloat);
    let seconds = (minutesFloat - minutes) * 60;
    return `${degrees}/1,${minutes}/1,${Math.round(seconds * 10000)}/10000`;
  }
}

这里我把保存动作放在 SaveButton 后面,是因为保存到系统相册属于用户敏感操作。用户明确点击保存并授权后,应用再创建相册资源,这条链路更符合真实应用的权限边界。

页面怎么串起来

最后看宿主页面。AltitudeCheckInPage 做的事情是把"海拔数据"和"拍照结果"串起来:进入页面开始定位和订阅传感器;点击水印拍照时固化当前海拔、坐标、地点和时间;拍照页回传临时照片 URI 后,打开预览页。

arkts 复制代码
import { common } from '@kit.AbilityKit';
import { AltitudeSnapshot, JackAltitudeCheckInController } from '../altitude/JackAltitudeCheckInController';
import { AltitudeWatermarkCameraPage } from './AltitudeWatermarkCameraPage';
import { AltitudeWatermarkPreviewPage } from './AltitudeWatermarkPreviewPage';

@Component
export struct AltitudeCheckInPage {
  onClose: () => void = () => {};

  @State snapshot: AltitudeSnapshot = new AltitudeSnapshot();
  @State showWatermarkCamera: boolean = false;
  @State showWatermarkPreview: boolean = false;
  @State watermarkPhotoUri: string = '';
  @State watermarkCaptureTime: string = '';

  private controller?: JackAltitudeCheckInController;
  private locationUpdateInterval: number = -1;

  aboutToAppear(): void {
    let hostContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
    this.controller = new JackAltitudeCheckInController(hostContext, (next: AltitudeSnapshot) => {
      this.snapshot = next;
    });
    void this.controller.start();
    this.locationUpdateInterval = setInterval(() => {
      void this.controller?.fetchLocation(false);
    }, 5000);
  }

  aboutToDisappear(): void {
    if (this.locationUpdateInterval !== -1) {
      clearInterval(this.locationUpdateInterval);
    }
    this.controller?.stop();
  }

  build(): void {
    if (this.showWatermarkCamera) {
      AltitudeWatermarkCameraPage({
        altitude: this.snapshot.altitude,
        latitude: this.snapshot.latitude,
        longitude: this.snapshot.longitude,
        locationName: this.snapshot.locationName,
        locationAddress: this.snapshot.locationAddress,
        isLoading: this.snapshot.isLoading,
        captureTime: this.watermarkCaptureTime,
        onClose: () => {
          this.showWatermarkCamera = false;
        },
        onCaptured: (photoUri: string) => {
          this.watermarkPhotoUri = photoUri;
          this.watermarkCaptureTime = this.formatCaptureTime(new Date());
          this.showWatermarkCamera = false;
          this.showWatermarkPreview = true;
        }
      })
    } else if (this.showWatermarkPreview) {
      AltitudeWatermarkPreviewPage({
        photoUri: this.watermarkPhotoUri,
        altitude: this.snapshot.altitude,
        latitude: this.snapshot.latitude,
        longitude: this.snapshot.longitude,
        locationName: this.snapshot.locationName,
        locationAddress: this.snapshot.locationAddress,
        isLoading: this.snapshot.isLoading,
        captureTime: this.watermarkCaptureTime,
        onClose: () => {
          this.showWatermarkPreview = false;
        }
      })
    } else {
      Column({ space: 18 }) {
        Text('海拔打卡')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
        Text(`${Math.round(this.snapshot.altitude)} m`)
          .fontSize(64)
          .fontWeight(FontWeight.Bold)
        Text(this.snapshot.locationName)
          .fontSize(16)
        Text(`气压 ${this.snapshot.pressure.toFixed(1)} hPa`)
          .fontSize(14)
        if (this.snapshot.isWeakSatelliteSignal) {
          Text('卫星信号弱,当前由气压推算')
            .fontSize(13)
            .fontColor('#B45309')
        }
        Button('水印拍照')
          .height(48)
          .onClick(() => {
            this.watermarkCaptureTime = this.formatCaptureTime(new Date());
            this.showWatermarkCamera = true;
          })
      }
      .width('100%')
      .height('100%')
      .padding(24)
    }
  }

  private formatCaptureTime(date: Date): string {
    let month: string = `${date.getMonth() + 1}`.padStart(2, '0');
    let day: string = `${date.getDate()}`.padStart(2, '0');
    let hour: string = `${date.getHours()}`.padStart(2, '0');
    let minute: string = `${date.getMinutes()}`.padStart(2, '0');
    return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}`;
  }
}

在我的完整项目里,这个页面还接了 HdsNavigation、分享菜单、从图库生成水印图、读取照片 EXIF 自动回填海拔和坐标等能力。核心链路就是上面这几段:数据先固化,拍照只负责产出照片,预览页再负责合成和保存。这样分层后,任何一个环节出问题都比较好定位。

从图库生成水印图时怎么处理 EXIF

还有一个小细节值得讲。如果用户不是现场拍照,而是从图库选一张过去的旅行照片,我不会直接套当前海拔。更合理的做法是读取这张照片自己的 EXIF:拍摄时间、GPS 坐标、GPS 海拔。如果读取到了坐标,再用 site.reverseGeocode() 反查地点。

这段工具在《时光旅记》里是共用的:

arkts 复制代码
import { fileIo } from '@kit.CoreFileKit';
import { image } from '@kit.ImageKit';

const EXIF_PROPERTY_OPTIONS: image.ImagePropertyOptions = {
  index: 0,
  defaultValue: ''
};

export class PhotoExifMetadata {
  occurredAtDateTime: string = '';
  latitude: number = 0;
  longitude: number = 0;
  altitude: number = 0;
  hasAltitude: boolean = false;
}

export async function readPhotoExifMetadata(imageUri: string): Promise<PhotoExifMetadata> {
  let metadata: PhotoExifMetadata = new PhotoExifMetadata();
  let file: fileIo.File = fileIo.openSync(imageUri, fileIo.OpenMode.READ_ONLY);
  let imageSource = image.createImageSource(file.fd);
  try {
    let occurredAtRaw = await safeReadImageProperty(imageSource, image.PropertyKey.DATE_TIME_ORIGINAL);
    if (occurredAtRaw.length === 0) {
      occurredAtRaw = await safeReadImageProperty(imageSource, image.PropertyKey.DATE_TIME);
    }
    metadata.occurredAtDateTime = normalizeExifDateTime(occurredAtRaw);
    metadata.latitude = parseExifGpsCoordinate(
      await safeReadImageProperty(imageSource, image.PropertyKey.GPS_LATITUDE),
      await safeReadImageProperty(imageSource, image.PropertyKey.GPS_LATITUDE_REF)
    );
    metadata.longitude = parseExifGpsCoordinate(
      await safeReadImageProperty(imageSource, image.PropertyKey.GPS_LONGITUDE),
      await safeReadImageProperty(imageSource, image.PropertyKey.GPS_LONGITUDE_REF)
    );
    let altitudeRaw = await safeReadImageProperty(imageSource, image.PropertyKey.GPS_ALTITUDE);
    metadata.altitude = parseExifAltitude(
      altitudeRaw,
      await safeReadImageProperty(imageSource, image.PropertyKey.GPS_ALTITUDE_REF)
    );
    metadata.hasAltitude = altitudeRaw.trim().length > 0 && isFinite(parseExifRational(altitudeRaw));
  } finally {
    await imageSource.release();
    fileIo.closeSync(file);
  }
  return metadata;
}

export function hasUsablePhotoExifCoordinates(metadata: PhotoExifMetadata): boolean {
  return Math.abs(metadata.latitude) > 0.000001 && Math.abs(metadata.longitude) > 0.000001;
}

async function safeReadImageProperty(imageSource: image.ImageSource, key: image.PropertyKey): Promise<string> {
  try {
    return await imageSource.getImageProperty(key, EXIF_PROPERTY_OPTIONS);
  } catch (_error) {
    return '';
  }
}

function normalizeExifDateTime(rawValue: string): string {
  let matched = rawValue.trim().match(/^(\d{4})[:\-\/](\d{2})[:\-\/](\d{2})(?:[ T](\d{2}):(\d{2}))?.*$/);
  if (matched === null) {
    return '';
  }
  if (matched[4] !== undefined && matched[5] !== undefined) {
    return `${matched[1]}-${matched[2]}-${matched[3]} ${matched[4]}:${matched[5]}`;
  }
  return `${matched[1]}-${matched[2]}-${matched[3]}`;
}

function parseExifGpsCoordinate(rawValue: string, ref: string): number {
  let parts = rawValue.trim().match(/-?\d+(?:\.\d+)?(?:\/-?\d+(?:\.\d+)?)?/g);
  if (parts === null || parts.length === 0) {
    return 0;
  }
  let value = parseExifRational(parts[0]);
  if (parts.length > 1) {
    value += parseExifRational(parts[1]) / 60;
  }
  if (parts.length > 2) {
    value += parseExifRational(parts[2]) / 3600;
  }
  let normalizedRef = ref.trim().toUpperCase();
  return normalizedRef === 'S' || normalizedRef === 'W' ? -Math.abs(value) : Math.abs(value);
}

function parseExifAltitude(rawValue: string, ref: string): number {
  let altitude = parseExifRational(rawValue);
  if (!isFinite(altitude)) {
    return 0;
  }
  return ref.trim() === '1' ? -Math.abs(altitude) : altitude;
}

function parseExifRational(rawValue: string): number {
  let trimmed = rawValue.trim();
  let slashIndex = trimmed.indexOf('/');
  if (slashIndex < 0) {
    return Number(trimmed);
  }
  let numerator = Number(trimmed.substring(0, slashIndex));
  let denominator = Number(trimmed.substring(slashIndex + 1));
  return denominator === 0 ? NaN : numerator / denominator;
}

这也是我建议大家做海拔水印时保留的一条分支:现场拍照用当前环境数据,历史照片优先尊重照片自己的 EXIF。否则用户拿一张几年前在雪山拍的照片,结果被盖上今天办公室的海拔,就很出戏。

我踩过的几个点

自建相机页不要只看 PhotoOutput,预览流也要正确绑定 XComponentsurfaceIdsurfaceId 一般在 onLoad 后才能拿到,拿到之前初始化相机会失败。

旋转要分两层处理。预览用 previewOutput.getPreviewRotation()setPreviewRotation();拍照输出用 photoOutput.getPhotoRotation(deviceDegree),再放进 PhotoCaptureSetting.rotation。只处理其中一个,横屏拍照时很容易出现预览正常但成片方向不对。

相机会话释放要完整。页面退出时依次 stop()cameraInput.close()previewOutput.release()photoOutput.release()photoSession.release()。只关输入流不释放输出流,下一次进入页面可能启动失败。

海拔不要只看一个字段。Location.altitude 有时能给值,但 altitudeAccuracy 为 0 时我不会直接当成高可信数据;气压计也不是所有设备都有,所以 UI 上必须展示弱信号和不支持气压计的状态。

EXIF 写入要做降级。modifyImageProperties() 批量写入失败时,可以逐个 modifyImageProperty() 尝试,至少把能写进去的时间、坐标或海拔写进去。

小结

如果只是拍照拿图,用 CameraPicker 就够,代码少、体验稳定。如果要做《时光旅记》这种"取景时就叠水印、拍完再合成、保存时写 EXIF"的功能,就要走 Camera Kit 自建相机链路。

我最后把它拆成了四层:海拔数据层负责定位、气压和逆地理编码;拍照层负责预览、会话和 JPEG buffer;水印层负责显示和截图;保存层负责合成、EXIF 和相册写入。这样拆完之后,Camera Kit 不再是孤立的一次 API 调用,而是变成了旅行记录场景里真正可用的一条能力链路。