1. 项目概述与场景价值鸿蒙系统发展到 HarmonyOS 6 这个阶段应用开发早已不是能用就行的逻辑而是讲究一次开发、多端适配、体验一致。这次分享的相机拍照和相册选图实战项目表面上看只是两个最常见的系统能力调用但实际上涉及了权限申请策略、系统级交互方式、文件格式处理、跨设备适配等一系列问题踩坑点相当密集。先说说这个项目能解决什么问题。很多开发者在做应用时都会遇到这样的需求用户需要上传头像、拍摄现场照片、或者从相册里选一张图片作为资料附件。如果在 Android 上做过类似功能你会发现 HarmonyOS 的调用方式虽然思路相近但细节差异很大——比如权限模型不同、返回数据的封装方式不同、URI 的使用规则也不同。如果直接照搬旧思路轻则适配失败重则直接崩溃或者返回空数据。这个实战项目的目标很明确用最规范的方式实现两个经典能力——调起系统相机拍照并拿到原图、调起系统相册选择图片并处理返回结果。同时把权限申请、数据解析、图片显示这几个环节全部打通让读者拿到一套可以直接复用的方案。适合谁来参考第一种是刚接触鸿蒙开发、想找一个完整能力闭环的初学者第二种是从 Android/iOS 转过来、需要快速对齐系统差异的开发者第三种是想把已有应用做鸿蒙适配、正好卡在媒体能力这块的团队。无论哪种情况这篇实战记录都会比官方文档更贴近真实的开发场景因为这里面的每一个代码片段都是在真机上跑过、踩过坑之后沉淀下来的。2. 整体设计思路与方案选型2.1 为什么选择系统级交互而不是直接调底层相机拿到这个需求第一反应可能是去翻相机相关的 SDK想着能不能直接控制摄像头、自己写拍照界面。但从工程角度看绝大多数场景下都不应该这么做。原因有三层。第一HarmonyOS 系统相机应用的成熟度非常高对焦、曝光、防抖、HDR 这些能力系统都已经调优过了自己写一个相机界面很难达到同等效果。第二直接用系统相机意味着代码量大幅缩减不需要处理预览流、不需要管传感器旋转、不需要做拍照音效与隐私合规这些系统全帮你搞定了。第三从用户体验角度用户更习惯系统相机的操作方式学习成本为零。所以方案定下来拍照和选图都通过拉起系统应用来实现我们只负责接收结果和处理数据。2.2 拍照与选图两条技术路线的对比在 HarmonyOS 6 中调起相机拍照主要有两条路线调起相册选图也有两种模式下面把它们的优缺点放在一张表里整体对比能力实现方式优点缺点拍照通过CameraPicker拉起系统相机系统级交互体验稳定代码量极小参数控制有限无法自定义界面拍照通过CameraKit自建相机可以完全控制拍照流程支持定制UI开发成本高适配难度大选图PhotoViewPicker单选/多选模式系统相册原生体验支持选择视频仅能拿到文件URI需要自行处理选图DocumentViewPicker泛文件选择可以选非图片文件适用范围广交互路径多一步相对繁琐这个项目采取的策略是拍照走CameraPicker选图走PhotoViewPicker两条路线都走系统能力保证代码量可控、稳定性最大化。2.3 数据流转链路的设计考量整个项目最核心的设计难点不在调起应用这个动作而在拿回数据之后怎么办。拍照和选图最终产出的都是一个 URI但这个 URI 背后是什么、数据在哪里、怎么读取、怎么显示、要不要持久化这些才是真正决定代码架构的因素。我设计的链路是调起系统应用 → 系统返回 URI → 通过文件描述符读取数据 → 构造PixelMap用于显示 → 按需保存到应用沙箱或媒体库。这一步一层的设计看起来简单实际上每一项都有隐蔽的坑比如 URI 的时效性、权限的传递范围、大图的内存占用等等后面会逐一展开。3. 核心细节解析与实操要点3.1 权限申请的时机与模型理解HarmonyOS 6 的权限模型和 Android 有相似之处但细节上更严格。在这个项目中核心需要处理的权限是相机权限但有一个非常容易踩坑的点调用系统相机应用并不总是需要申请相机权限。这里要展开讲一下。如果你的应用只是通过CameraPicker拉起系统相机系统相机应用本身拥有相机权限你的应用并没有直接访问摄像头硬件所以严格来说不需要申请相机权限。但在实际测试中发现部分机型上如果你完全没有申请相机权限拉起相机时会弹出一个系统级提示交互上不够顺畅。而市面上的主流应用几乎都会申请相机权限用户也早已习惯。所以我的建议是在需要拉起相机之前先通过abilityAccessCtrl检查并申请ohos.permission.CAMERA。即使技术上不是必需的从产品体验角度也是推荐做法。另外还有一个关键注意点相册读取权限。HarmonyOS 6 在选图场景下如果你用的是PhotoViewPicker系统会自动完成授权不需要额外申请相册读取权限。这一点和 Android 13 之前的旧模型差异很大不少跨平台开发者会在这里多申请一个不必要的权限反而引发用户疑虑。原则是能用系统 Picker 解决的不要自己申请权限。3.2 URI 的获取与数据读取机制CameraPicker和PhotoViewPicker返回的结果在 HarmonyOS 6 中统一用 URI 来表示这个设计解决了跨应用数据传递的权限隔离问题。系统通过临时授权机制把你的应用和这个 URI 绑定你的应用可以在有效期内读取这个 URI 对应的数据。实际操作中用得最多的读取方式是fileIo.openSync配合fileIo.readSync拿到文件描述符后就可以按字节读取。有一个细节值得注意URI 的有效期与临时授权绑定不要在拿到 URI 之后拖延太久才去读取。我遇到过的情况是先存到数据库过了几分钟再读结果直接抛异常查了半天才发现是授权过期。对于大图必须分块读取一次性读整个文件很容易内存溢出。特别是现在手机拍照动不动就是几 MB 到十几 MB如果直接readSync一大块Stage 模型下的应用很容易被系统判定为内存占用过高。// 分块读取文件数据避免一次性加载过大导致内存溢出 const file fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY); const stat fileIo.statSync(uri); const size stat.size; const buffer new ArrayBuffer(size); const options: fileIo.ReadOptions { offset: 0, length: size }; fileIo.readSync(file.fd, buffer, options); fileIo.closeSync(file);3.3 图片显示与 PixelMap 构建有了字节数据之后下一步就是显示。HarmonyOS 中图片显示的推荐方式是Image组件配合PixelMap从字节数据构造PixelMap的步骤很固定但有几个细节会影响最终的显示效果。image.createPixelMap接收的是ArrayBuffer而不同的图片来源可能会导致字节数据的格式差异这一步需要做容错处理。如果数据有问题API 会抛异常页面不应该直接白屏而是要有一个降级方案。另外是采样率设置。不要直接用原图尺寸去构造 PixelMap特别是相机拍摄的照片动辄 4000×3000 的分辨率PixelMap 在内存中的占用会非常惊人。正确做法是先读取图片的宽高信息计算出合适的采样率再按采样后的尺寸构造。这相当于 Android 中的inSampleSize概念。// 使用采样率降低大图内存开销 const imageSource image.createImageSource(buffer); const imageInfo await imageSource.getImageInfo(); const sampleSize Math.max(1, Math.floor(Math.max(imageInfo.size.width, imageInfo.size.height) / 1080)); const options: image.DecodingOptions { sampleSize: sampleSize, editable: false, desiredPixelFormat: image.PixelMapFormat.RGBA_8888 }; const pixelMap await imageSource.createPixelMap(options);这里有一个性能优化的经验对于头像类场景采样率可以控制得更狠一些比如直接压到 512 尺寸范围内显示效果几乎无差别但内存占用差距是数量级的。实测下来原图构建 PixelMap 需要约 50MB 内存采样后只需要不到 5MB这种优化在低端机上就是生与死的区别。3.4 持久化与沙箱目录策略读取到的图片数据如果只是在页面里显示那一切都简单。但如果要上传服务器或者需要在下次启动时恢复显示就必须考虑持久化问题。HarmonyOS 6 中每个应用都有自己的沙箱目录写入自己的沙箱是完全不需要权限的。所以在拿到图片数据后建议统一拷贝到应用的沙箱目录下管理后续无论是上传还是展示都用沙箱内的文件路径。有一个容易忽略的问题沙箱目录也会被系统清理。如果产品对图片持久性要求很高比如用户上传的身份证照片、交易凭证等在首次写入沙箱后就应该尽快触发上传逻辑不要把沙箱当作永久存储。另外在应用卸载重装后沙箱数据会完全消失这一点产品设计上要有预期。下面是这个项目采用的目录规范目录用途路径生命周期临时缓存/data/app/el2/100/base/com.example.app/cache/系统可清理持久文件/data/app/el2/100/base/com.example.app/files/卸载时清除数据库/data/app/el2/100/base/com.example.app/database/卸载时清除4. 实操过程与核心环节实现4.1 完整实现调起系统相机并处理返回结果调起系统相机的整体流程分成四步处理权限、构建配置、等待回调、解析数据。下面按顺序拆解。权限处理方面在 EntryAbility 的onWindowStageCreate阶段或者功能入口处统一检查即可。建议放在功能入口处这样用户拒绝了之后还能再次触发体验更友好。import { AbilityConstant, UIAbility, Want } from kit.AbilityKit; import { abilityAccessCtrl, common, Permissions } from kit.AbilityKit; import { BusinessError } from kit.BasicServicesKit; async function checkAndRequestPermission(context: common.UIAbilityContext): Promiseboolean { const atManager abilityAccessCtrl.createAtManager(); const permissions: Permissions[] [ohos.permission.CAMERA]; try { const result await atManager.requestPermissionsFromUser(context, permissions); if (result.authResults.length 0 result.authResults[0] 0) { return true; } return false; } catch (err) { const e err as BusinessError; console.error(权限申请失败: ${e.code} ${e.message}); return false; } }权限就绪后构建CameraPicker的配置。这里有一个很实用的选项CameraPickerPickerProfile可以指定图片的保存格式和位置。实测下来CameraPickerMediaType.PHOTO模式适用于普通拍照如果需求涉及视频拍摄则要选择对应模式。相机参数建议先走默认值跑通流程再按需调整画质。import { cameraPicker } from kit.CameraKit; import { photoAccessHelper } from kit.MediaLibraryKit; async function takePhoto(context: common.UIAbilityContext): Promisestring | null { try { const pickerProfile: cameraPicker.CameraPickerProfile { cameraPosition: cameraPicker.CameraPosition.CAMERA_POSITION_BACK, mediaType: cameraPicker.CameraPickerMediaType.PHOTO, saveUri: undefined // 不指定则保存到系统相册 }; const pickerResult: cameraPicker.CameraPickerResult await cameraPicker.pick(context, [pickerProfile]); if (!pickerResult || !pickerResult.resultUri) { console.info(用户取消了拍照); return null; } // 拿到URI后立即读取数据避免授权过期 const uri pickerResult.resultUri; return uri; } catch (err) { const e err as BusinessError; console.error(拍照失败: ${e.code} ${e.message}); return null; } }注意saveUri参数留空则照片默认存到系统相册由系统自己管理。如果应用需要把照片保存在自己的专属目录中这里有两条路一是拿到 URI 后主动拷贝到沙箱二是在配置阶段指定一个应用沙箱内的 URI 作为保存目标。第二种方式更直接但需要提前构建目标 URI涉及uri工具类的拼接整体逻辑稍复杂一些建议数据敏感型应用采用这种方式。4.2 完整实现调起相册选择并支持多选相册选图通过PhotoViewPicker实现同样是一个系统级的 Picker不需要相册权限。有一个使用经验是在配置PhotoSelectOptions时把MIMEType限定为图片类型可以过滤掉绝大部分干扰项用户操作路径更短。import { picker } from kit.CoreFileKit; async function selectFromAlbum(count: number): Promisestring[] { try { const photoSelectOptions new picker.PhotoSelectOptions(); photoSelectOptions.MIMEType picker.PhotoViewMIMETypes.IMAGE_TYPE; photoSelectOptions.maxSelectNumber count; const photoPicker new picker.PhotoViewPicker(); const photoSelectResult await photoPicker.select(photoSelectOptions); if (!photoSelectResult || !photoSelectResult.photoUris || photoSelectResult.photoUris.length 0) { console.info(用户取消了选择); return []; } return photoSelectResult.photoUris; } catch (err) { const e err as BusinessError; console.error(选图失败: ${e.code} ${e.message}); return []; } }这份代码里photoUris返回的是一个string[]意味着天然支持多选。maxSelectNumber可以直接限制最大可选数量在需求上如果要做一个最多选 9 张的功能这就是最直接的做法。4.3 封装一个通用的图片处理工具类代码写到这里拍照和选图在返回数据之后的处理逻辑是完全相同的拿到 URI 列表 → 逐个读取 → 构造 PixelMap → 显示/上传/保存。所以强烈建议抽一个工具类避免两处逻辑重复。下面这个工具类包含三个核心方法readUriData负责从 URI 读取字节流dataToPixelMap负责按采样率压缩并构造 PixelMapsaveToSandboxFile负责把数据落到沙箱文件。import { fileIo } from kit.CoreFileKit; import { image } from kit.ImageKit; export class ImageHandleHelper { // 从URI读取原始字节数据 static readUriData(uri: string): ArrayBuffer | null { try { const file fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY); const stat fileIo.statSync(uri); const size stat.size; const buffer new ArrayBuffer(size); fileIo.readSync(file.fd, buffer); fileIo.closeSync(file); return buffer; } catch (err) { console.error(读取URI数据失败: ${JSON.stringify(err)}); return null; } } // 按采样率构造PixelMaptargetMax是期望的最大边长度 static async dataToPixelMap(buffer: ArrayBuffer, targetMax: number 1080): Promiseimage.PixelMap | null { try { const imageSource image.createImageSource(buffer); const imageInfo await imageSource.getImageInfo(); const maxSide Math.max(imageInfo.size.width, imageInfo.size.height); const sampleValue Math.max(1, Math.floor(maxSide / targetMax)); const options: image.DecodingOptions { sampleSize: sampleValue, desiredPixelFormat: image.PixelMapFormat.RGBA_8888 }; return await imageSource.createPixelMap(options); } catch (err) { console.error(PixelMap构造失败: ${JSON.stringify(err)}); return null; } } // 保存到应用沙箱file目录 static saveToSandboxFile(buffer: ArrayBuffer, context: common.UIAbilityContext, fileName: string): string { try { const filesDir context.filesDir; const filePath ${filesDir}/${fileName}; const file fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE); fileIo.writeSync(file.fd, buffer); fileIo.closeSync(file); return filePath; } catch (err) { console.error(沙箱文件写入失败: ${JSON.stringify(err)}); return ; } } }有了这份工具类业务代码就非常清爽了。调起拍照后只需三行逻辑读数据 → 转PixelMap → 显示。上传场景则换成读数据 → 存沙箱 → 拿路径去上传。4.4 页面布局与状态管理页面部分用 ArkUI 的声明式语法完成核心逻辑围绕State状态来驱动。定义一个imageUrlState变量存储当前显示的图片 URI配合Image组件实时刷新这是最直接也最稳定的做法。Entry Component struct CameraAlbumDemoPage { State imageUrl: string ; State pixelMap: image.PixelMap | null null; private context getContext(this) as common.UIAbilityContext; build() { Column({ space: 20 }) { // 图片预览区 Column() { if (this.pixelMap) { Image(this.pixelMap) .width(100%) .height(360) .objectFit(ImageFit.Cover) .borderRadius(16) } else { Blank() .width(100%) .height(360) .backgroundColor(#F1F3F5) .borderRadius(16) .overlay(Text(点击下方按钮选择照片).fontSize(14).fontColor(#999999)) } } // 操作按钮组 Row({ space: 12 }) { Button(拍照) .onClick(() this.handleTakePhoto()) Button(相册选图) .onClick(() this.handleSelectPhoto()) } } .padding(16) .width(100%) } async handleTakePhoto() { // 先检查权限 const granted await checkAndRequestPermission(this.context); if (!granted) { // 提示用户授权 return; } const uri await takePhoto(this.context); if (uri) { const buffer ImageHandleHelper.readUriData(uri); if (buffer) { this.pixelMap await ImageHandleHelper.dataToPixelMap(buffer); } } } async handleSelectPhoto() { const uris await selectFromAlbum(1); if (uris.length 0) { const buffer ImageHandleHelper.readUriData(uris[0]); if (buffer) { this.pixelMap await ImageHandleHelper.dataToPixelMap(buffer); } } } }状态管理部分踩过一个具体的坑PixelMap构造完毕后如果直接赋值给State在某些版本上会碰到 UI 线程卡顿甚至无刷新的问题。稳妥起见把PixelMap的构造放在异步函数中并且在赋值前做一次空值判断。经过长时间真机验证这种做法在低端机上也能流畅运行。5. 常见问题与排查技巧实录5.1 拍照返回后图片无法显示这是最高频的问题。现象是拍照成功后页面没有任何变化代码上看流程也走完了就是在Image组件上渲染不出来。排查的第一步是确认 URI 是否为空。如果pickerResult.resultUri已经拿到值再确认读取到的ArrayBuffer是否为空。如果ArrayBuffer是空字节大概率是临时授权过期或者 URI 作用域不匹配。真正的根源往往出在 dataToPixelMap 这一步。image.createImageSource对图片数据格式比较敏感如果字节流本身不完整这里静默失败的概率极大。建议在这个环节加大try...catch的覆盖范围把异常信息完整打印出来再排查。另外调试阶段可以先把sampleSize强制设为 1排除采样算法导致的问题后再做性能优化。有一个我在开发中发现的特殊情况部分系统版本小版本更新后会调整 URI 返回格式从临时路径样式变成 content 样式。如果遇到这种情况直接按标准文档里的示例代码走可能就会翻车。要关注 API 版本差异在代码里做一次 URI 前缀的兼容判断针对不同前缀采用不同的读取策略。5.2 大图导致的内存溢出问题现象是拍照几次之后应用直接闪退Log里能看到OutOfMemoryError之类的字样。这是典型的没有做采样处理造成的。单独一张原图构造 PixelMap 的内存占用可以粗略计算一下假设照片为 4000×3000 像素RGBA_8888 格式每个像素占 4 字节总占用为 4000 × 3000 × 4 48MB。如果连续拍照多张且没有释放旧引用内存压力立刻爆表。解决方式就是本项目中dataToPixelMap里的采样逻辑。把目标尺寸压在 1080 后单张内存占用降到约 4MB数量级上就安全了。还有一个容易被忽略的点不再使用的 PixelMap 要手动调用release()或者至少把引用置空让系统可以尽快回收。在列表类场景中切记这一点否则即使单张占用不高累积起来也会出问题。5.3 权限被用户拒绝后的交互兜底用户首次拒绝权限后再次点击拍照按钮会直接失败。如果此时不做任何提示用户会认为功能坏了。正确做法是检测到权限拒绝后弹窗引导用户去系统设置中手动开启或者用requestPermissionsFromUser再次申请。实测中发现requestPermissionsFromUser在用户勾选了拒绝后不再询问时弹出的授权窗口会失效直接走拒绝路径。这种情况下需要跳转到应用详情页让用户手动开启权限。鸿蒙系统下使用AppStorage或UIAbilityContext.startAbility都可以实现跳转具体方式取决于目标系统版本。还有一个值得注意的交互细节不要在页面加载时立刻触发相机权限申请。用户会感到被打扰拒绝率直线上升。把权限申请绑定到点击拍照按钮的动作上配合一句需要使用相机拍摄照片的说明文案通过率会高很多。5.4 问题排查速查表问题现象可能原因排查方向解决方案拍照返回空URI用户取消检查返回码和用户行为正常处理取消逻辑URI有值但读不出数据临时授权过期检查读取时机拿到后立即读取PixelMap构造失败图片格式不兼容打印异常信息捕获后降级处理图片显示模糊采样率过高检查sampleSize调整目标尺寸参数多次拍照后闪退内存未释放查看内存占用日志手动release并置空引用相册返回空列表类型过滤过于严格检查MIMEType配置放宽或移除过滤条件6. 进阶优化与后续扩展思路基础能力跑通之后可以沿着几个方向继续深化。第一个方向是压缩与上传的配合。实际业务中即使用户拍了原图很多场景也不需要原图上传。常规做法是在客户端先压缩再上传这里有两个选择使用image.packImage把 PixelMap 编码为 JPEG/WebP 格式或者使用image.ImagePacker进行更细粒度的质量参数控制。实测下来把一张 12MB 的照片压缩到 200KB 左右、宽边 1280 的时候肉眼几乎看不出差异但上传速度从几十秒缩短到几秒这是用户能直接感知的优化。第二个方向是图片旋转修正。手机拍照时由于传感器方向问题部分图片会带有 EXIF 旋转信息直接显示会出现横竖颠倒。这个项目没有处理这个问题但实际产品中十有八九会遇到。可以在构造 PixelMap 之前读取图片的方向信息通过image.ImageSource的getImageProperty获取Orientation字段再配合矩阵变换修正过来。第三个方向是多图选择后的批量处理。上面代码中selectFromAlbum(9)可以得到最多 9 张图片的 URI 数组此时要控制好循环读取的并发数不要一次性全量加载。合理做法是先按采样率压缩、压缩完存成临时文件再释放 PixelMap最后统一展示缩略图列表。这中间有一个性能拐点需要自己测是串行处理更稳还是限制并发为 2 到 3 个更快不同机型结论不同。最后提一个容易被忽视的点适配折叠屏和平板。HarmonyOS 6 的多端能力是核心竞争力同一个拍照选图功能在手机、折叠屏、平板上应该有不同表现。折叠屏展开态下分屏调用系统相机时Picker 的界面大小和返回数据不会变但页面布局需要响应式调整。建议组件设计阶段就把图片预览区和按钮区拆成独立组件后续做多端适配时只需要调整布局容器不需要重写业务逻辑。这个项目后续还可以接入分布式能力——比如手机拍的照片直接流转到平板进行编辑展示这属于跨端数据流转的场景。不过那是另一个话题了先把单端的拍照和选图能力吃透后续怎么扩展就都有了扎实的基础。