大家好,我是鸿蒙Jack。本期以我的《时光旅记》APP 为例,聊一下我是怎么把 Camera Kit 接到"海拔打卡"这个真实业务场景里的。
这个功能的表面体验很简单:用户打开"海拔打卡",APP 自动拿到当前位置、海拔、气压、坐标和地点名;用户可以分享当前打卡卡片,也可以进入水印拍照,把现场照片和海拔信息合成一张带地点信息的打卡图,最后保存到系统相册。
真正做起来,难点不在"调用一次相机"。如果只拿一张照片,用系统相机选择器就够了。但《时光旅记》的水印拍照需要自己的取景页、自己的水印层、横竖屏适配、拍照后预览、EXIF 写入和相册保存,所以我同时用了 Camera Kit 的两种能力:
cameraPicker.pick() 用在"更换背景图"这种轻量入口;camera.CameraManager + XComponent + PhotoOutput 用在"水印拍照"这种需要自定义预览和自定义拍照流程的入口。

官方文档我主要参考了这几块:
- 通过系统相机拍照和录像 CameraPicker
- Camera Kit 拍照 ArkTS
- Camera Kit 拍照实践 ArkTS
- Map Kit 地理编码
- Sensor Service Kit 传感器开发指导
技术栈怎么分工
我这个海拔打卡不是 Camera Kit 单点能力,而是一条"拍摄 + 环境数据 + 图片合成 + 媒体保存"的链路。
Camera Kit 负责两件事。第一,cameraPicker 拉起系统相机,适合拿一张临时背景图。第二,camera 模块自建相机会话,适合自己控制预览、拍照输出、旋转和 JPEG buffer 处理。
ArkUI 的 XComponent 用来承载相机预览 Surface。自建相机时,PreviewOutput 需要绑定这个 surfaceId,否则相机可以打开,但页面上看不到取景画面。
Location Kit 负责高精度定位。我这里调用 geoLocationManager.getCurrentLocation(),读取 latitude、longitude、altitude 和 altitudeAccuracy。当 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_ORIGINAL、GPS_LATITUDE、GPS_LONGITUDE、GPS_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 输出到 XComponent,PhotoOutput 监听 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.altitude 和 altitudeAccuracy 受卫星信号影响;气压计又不是所有设备都有,也会受天气气压影响。所以《时光旅记》的策略是:
先请求高精度定位,拿经纬度和卫星海拔;如果 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;然后创建 CameraInput、PreviewOutput 和 PhotoOutput;接着 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,预览流也要正确绑定 XComponent 的 surfaceId。surfaceId 一般在 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 调用,而是变成了旅行记录场景里真正可用的一条能力链路。