HarmonyOS社交通讯应用开发16:如何实现本地图库选择
2026/8/25 6:42:20 网站建设 项目流程

本地图库选择

引言

发布内容的第一件事通常是"配图"。在 ContinuePublish 发布页里,点击虚线加号 → 选择"本地设备",会弹出系统图库选择器,选完图片后缩略图直接以九宫格形式出现在编辑区顶部。这条看似简单的链路,背后其实串起了四个系统能力:

  1. PhotoViewPicker:系统相册选择器(免权限、免申请);
  2. photoAccessHelper:媒体库访问助手,用来按 URI 精确查回图片资源;
  3. PhotoAsset.getThumbnail:获取图片缩略图,避免把原图整张加载进内存;
  4. ImagePacker.packToData:把 PixelMap 压缩编码成 JPEG 字节流,为写入分布式文件做准备。

本篇按"选图 → 取缩略图 → 编码落盘"的调用链逐一讲解。

知识点讲解

PhotoViewPicker:系统级图片选择器

HarmonyOS 提供了"安全控件"思路下的系统选择器PhotoViewPicker,开发者只配置选项、调用select(),系统会弹出官方相册界面,用户选完返回一个"只读的 URI 列表"。因为选择过程发生在系统进程里,应用拿到的只是用户明确授权的那几张图的 URI,所以不需要申请READ_IMAGEVIDEO这类敏感权限——这是它最大的价值。同样的"系统代办"思路还体现在粘贴场景的PasteButton(第 18 篇)上,两者都是"把敏感操作交给系统,应用只收结果"。

配置项PhotoSelectOptions里有两个常用字段:

  • MIMEType:允许的文件类型,如IMAGE_TYPE(仅图片)、VIDEO_TYPE(仅视频)、IMAGE_VIDEO_TYPE(图片+视频);
  • maxSelectNumber:最多可选数量。

一个值得记住的细节:选择器返回的photoUrisURI 引用而不是文件内容——读取内容仍需后续步骤,这种"先拿引用、按需读取"的设计,避免了把用户图库整批拷进应用内存。

photoAccessHelper + getAssets:按 URI 查回资源

选择器返回的photoUris是形如file://media/Photo/xxx的 URI 字符串。要拿到这张图的元数据(名字、缩略图),需要用photoAccessHelper.getPhotoAccessHelper(context)拿到媒体库助手,再通过getAssets(fetchOption)查询。

查询条件用FetchOptions描述,其中predicates是 DataSharePredicates(数据共享谓词),可以理解为"数据库 where 条件":

predicates.equalTo('uri',uri);// 只查 uri 等于目标值的资源

fetchColumns声明需要返回哪些元数据列——传空数组表示使用默认列。查询结果FetchResult<PhotoAsset>是一个结果集,用getFirstObject()取第一条(按 uri 精确匹配时只有一条)。注意FetchResult用完后应调用close()释放游标资源,本项目示例省略了这步,属简化处理。

PhotoAsset.getThumbnail:缩略图接口

PhotoAsset是单张媒体资源的抽象,displayName是文件名。getThumbnail(callback)异步返回该资源的缩略图PixelMap——注意是缩略图而不是原图,尺寸远小于原图,直接用于九宫格预览非常合适,内存开销也小。若需原图,另有getImage类接口,但预览场景用缩略图是公认的最佳实践。

PixelMap是 ArkUI/ImageKit 中的像素图对象,既可以直接喂给Image组件显示(本项目Image(item.imagePixelMap)就是这么用的),也可以交给编码器转成字节流。

ImagePacker.packToData:PixelMap 编码

image.createImagePacker()创建图片打包器,packToData(pixelMap, options)把 PixelMap 编码成ArrayBuffer字节流。PackingOption可指定格式(如image/jpeg)与质量(0~100)。本项目用质量 100 的 JPEG 编码缩略图,再交给writeDistributedFile写入分布式文件目录——这是为"应用接续到另一台设备后能恢复图片"做的准备(接续机制在后续模块详解)。

编码接口是异步的(返回 Promise),失败会以BusinessError形式进入.catch。工程里所有异步图片操作都保留了.catch并打 hilog——**"每个异步环节都要有错误出口"** 是这段代码想教给你的第一课。

结合本项目源码分析

第一步:封装选择器——FileUtil.fileSelect

文件路径:entry/src/main/ets/utils/FileUtil.ets

工程把"选图"封装成独立工具函数,返回Promise<Array<string>>(URI 数组):

exportasyncfunctionfileSelect():Promise<Array<string>> {letimgUri:Array<string> = [];letphotoSelectOptions =newphotoAccessHelper.PhotoSelectOptions(); photoSelectOptions.MIMEType= photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;// 仅图片photoSelectOptions.maxSelectNumber=5;// 单次最多选 5 张letphotoPicker =newphotoAccessHelper.PhotoViewPicker();try{letphotoSelectResult =awaitphotoPicker.select(photoSelectOptions);if(photoSelectResult && photoSelectResult.photoUris&& photoSelectResult.photoUris.length>0) { imgUri = photoSelectResult.photoUris;// 用户选中的图片 URI 列表returnimgUri; }else{return[];// 用户取消或未选中} }catch(error) { hilog.error(DOMAIN,TAG,FORMAT,`PhotoViewPicker failed with err:${error.code},${error.message}`);return[]; } }

注意两个细节:maxSelectNumber = 5是"单次选择"的上限,而发布页整体的媒体上限是 9(MAX_ADD_MEDIA_NUM),两者会在上层叠加校验;select()是 Promise 风格,用await等待用户完成选择,异常路径(用户取消、权限异常)都返回空数组,调用方无需感知具体错误。

第二步:数量校验 + 逐张取缩略图——AddMedia.selectImage

文件路径:entry/src/main/ets/view/contentEditor/AddMedia.ets

selectImage():void{fileSelect().then((uri:Array<ResourceStr>) =>{// 已选 + 新选超过上限 9 时,截断到剩余名额if((this.mediaUriArray.length+ uri.length) >CommonConstants.MAX_ADD_MEDIA_NUM) { uri.length=CommonConstants.MAX_ADD_MEDIA_NUM-this.mediaUriArray.length;try{this.getUIContext().getPromptAction().showToast({message: $r('app.string.add_picture_prompt') }); }catch(err) { hilog.error(DOMAIN,TAG,FORMAT,`ShowToast failed. Cause code:${err.code}, message:${err.message}`); } }// 逐张把 URI 转成缩略图并加入列表uri.forEach((item: ResourceStr) =>{this.getThumbnail(itemasstring); }); }) }

这里有一段值得品味的代码:uri.length = 9 - this.mediaUriArray.length;。ArkTS 的数组 length 可以赋值,赋值后数组会被截断——这是利用语言特性快速丢弃超量部分,只保留"放得下"的图,同时弹 Toast 提示用户"数量已达上限"(资源add_picture_prompt的文案是 "The number of added pictures/videos reaches the upper limit.")。

第三步:getAssets 查资源 + 取缩略图——getThumbnail

asyncgetThumbnail(uri:string) {letphAccessHelper = photoAccessHelper.getPhotoAccessHelper(this.context);// 构造查询谓词:只查 uri 匹配的那张图letpredicates: dataSharePredicates.DataSharePredicates=newdataSharePredicates.DataSharePredicates(); predicates.equalTo('uri', uri);letfetchOption: photoAccessHelper.FetchOptions= {fetchColumns: [],// 不额外取字段predicates: predicates };try{letfetchResult: photoAccessHelper.FetchResult<photoAccessHelper.PhotoAsset> =awaitphAccessHelper.getAssets(fetchOption);letasset: photoAccessHelper.PhotoAsset=awaitfetchResult.getFirstObject();// 从资源取缩略图(回调式)asset.getThumbnail((err, pixelMap) =>{if(err ===undefined) { hilog.info(DOMAIN,TAG,FORMAT,'getThumbnail successful '+ pixelMap);// 去掉扩展名,得到纯文件名作为展示名letimageName = asset.displayName.substring(0, (asset.displayName).indexOf('.'));// 编码并写入分布式文件this.PixelMapToBuffer(pixelMap, imageName);// 加入全局媒体列表,UI 立刻刷新this.mediaUriArray.push({imagePixelMap: pixelMap,mediaName: imageName,mediaType:MediaType.MEDIA_IMAGE}); }else{ hilog.error(DOMAIN,TAG,FORMAT,`getThumbnail fail with error:${err.code},${err.message}`); } }); }catch(err) { hilog.error(DOMAIN,TAG,FORMAT,`ShowToast failed. Cause code:${err.code}, message:${err.message}`); } }

this.context是组件里通过this.getUIContext().getHostContext()拿到的 UIAbilityContext,媒体库助手必须用它初始化。取缩略图成功后,mediaUriArray里 push 一条MediaInfo记录——mediaUriArray@StorageLink('mediaUriArray'),所以这行 push 会立刻驱动 AddMedia 的 List 刷新出新的缩略图格子。

第四步:编码落盘——PixelMapToBuffer

PixelMapToBuffer(pixelMap: image.PixelMap,displayName:string):void{constimagePackerApi: image.ImagePacker= image.createImagePacker();letpackOpts: image.PackingOption= {format:'image/jpeg',quality:100};// 把 PixelMap 编码为 JPEG 字节流imagePackerApi.packToData(pixelMap, packOpts).then((data:ArrayBuffer) =>{// 写入分布式文件目录,供跨设备接续时恢复writeDistributedFile(this.context, displayName,MediaType.MEDIA_IMAGE, data); }).catch((err: BusinessError) =>{ hilog.info(DOMAIN,TAG,FORMAT,`PackToData failed. Cause code:${err.code}, message:${err.message}`); }); }

落点:writeDistributedFile 写入分布式目录

writeDistributedFile也在FileUtil.ets,这里顺带看它如何落盘(详细机制在模块四展开):

exportfunctionwriteDistributedFile(context: common.UIAbilityContext, displayName:string, mediaType: MediaType, buf?:ArrayBuffer, uri?:string):void{// 分布式文件目录路径letdistributedDir:string= context.distributedFilesDir;letfileName:string='/'+ displayName;letfilePath:string= distributedDir + fileName;letfile: fileIo.File|undefined=undefined;try{ file = fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE| fileIo.OpenMode.CREATE);if(mediaType ===MediaType.MEDIA_IMAGE&& buf) {// 图片:把编码后的字节流直接写入fileIo.writeSync(file.fd, buf); }// (视频分支:openSync 源文件 + copyFileSync,后文涉及)}catch(error) { ... }finally{if(file) { fileIo.closeSync(file.fd); } } }

关键点:写入的是context.distributedFilesDir(分布式文件目录)。这个目录由系统管理,同账号的设备间可自动同步——这正是"发布内容接续到另一台设备时,图片能跟着过去"的底层保障。也就是说,本地选图看似只做了一件事,实际已经为跨设备接续埋好了伏笔

整条链路串联

把上面的片段连起来,一次"本地选图"的完整数据流是:

PhotoViewPicker.select()→ photoUris: Array<string>// 1. 系统相册选图,免权限→ getThumbnail(uri)× N → getPhotoAccessHelper(context)→ getAssets({predicates:uri})→ asset.getThumbnail()→ PixelMap// 2. 查回资源、取缩略图PixelMapToBuffer(pixelMap,name)ImagePacker.packToData()// 3. 编码为 JPEG 字节流→ writeDistributedFile()// 4. 写入分布式文件目录→ mediaUriArray.push(MediaInfo)// 5. 更新全局状态,UI 刷新

五个步骤职责单一、层层递进,每步的输出恰好是下一步的输入。

值得记录的工程细节

1. context 从哪里来。AddMediathis.context在组件声明处一次性获取:

// Get the UIAbilityContext information of device A.privatecontext =this.getUIContext().getHostContext()ascommon.UIAbilityContext;

getHostContext()返回宿主 UIAbility 的上下文,媒体库助手、文件操作、权限请求都要用它。声明时获取、全组件复用,比每个方法里重复调用更整洁。

2. 数量上限的"双保险"。PhotoSelectOptions.maxSelectNumber = 5只限制单次选择数量,与发布页整体的 9 张上限(MAX_ADD_MEDIA_NUM)是两层独立约束:用户第一次选 5 张、第二次再选 5 张时,第二次的 5 张会被selectImage截断到剩余 4 个名额并 toast 提示。系统限制(单次 5)与业务限制(总 9)各管一段,这个分层思路在做任何"批量选择"功能时都适用。

3. 文件名清洗。asset.displayName.substring(0, displayName.indexOf('.'))去掉扩展名只留纯文件名,作为mediaName存入MediaInfo。这个名字后续会直接用作分布式文件目录里的文件名(writeDistributedFiledisplayName拼路径),所以必须保证不含路径分隔符等危险字符——substring截取正好顺带完成了"清洗"。

4. 图片与视频分支。writeDistributedFile的落盘逻辑按MediaType分流:图片直接把编码后的ArrayBuffer写盘(fileIo.writeSync),视频则打开源 URI 的文件用copyFileSync复制——不同媒体类型的"落盘姿势"不同,但对外暴露的接口签名统一,调用方无需关心内部差异:

if(mediaType===MediaType.MEDIA_IMAGE&&buf) { fileIo.writeSync(file.fd,buf);// 图片:字节流直写}elseif(mediaType===MediaType.MEDIA_VIDEO&&uri) { srcFile = fileIo.openSync(uri,fileIo.OpenMode.READ_ONLY); fileIo.copyFileSync(srcFile.fd,file.fd);// 视频:文件复制}

小结

本地图库选择这条链路的工程价值,主要体现在三点:

  1. 权限策略最优PhotoViewPicker是系统选择器,选图全程由系统完成,应用只拿用户明确授权的 URI,无需申请相册读权限——比直接遍历媒体库的方案更安全、更省事。
  2. 内存友好:全程操作的是缩略图 PixelMap,而不是把原图加载进内存;即便要落盘,也是"缩略图编码"而非原图。
  3. 为接续铺路:选完图立刻写入分布式文件目录,让"本机编辑、他机续写"成为可能。

初级开发者可以沿着AddMedia.selectImage → getThumbnail → PixelMapToBuffer这条路径复现一遍,就会对"系统选择器 + 媒体库助手 + 图片编码"这套组合拳有直观认识。复现时建议刻意练习三件事:给每个异步环节补上错误处理(.catch+ 日志)、注意FetchResult的资源释放、以及用断点观察mediaUriArray.push之后列表是否即时刷新——后者的答案就是@StorageLink的魔法所在。

下一篇,我们把视线从"本机"移到"跨设备"——看看如何从另一台设备上拉取媒体。

(本文引用源码:entry/src/main/ets/utils/FileUtil.etsentry/src/main/ets/view/contentEditor/AddMedia.etsentry/src/main/ets/model/ContentInfo.ets

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询