HarmonyOS社交通讯应用开发 30 :跨设备拖拽图片

📅 2026/8/25 6:04:06
HarmonyOS社交通讯应用开发 30 :跨设备拖拽图片
跨设备拖拽图片引言在 PC 与手机、平板组成的多设备环境中把一张图直接从 A 设备拖到 B 设备是效率极高的交互方式。本项目ContinuePublish就实现了这个能力在内容发布页的图片列表区域长按本端图片即可触发拖拽将其拖拽至对端设备的图片列表区域松开鼠标图片便出现在对端列表中README 使用说明第 4 条需连接同一华为账号的设备打开键鼠共享并且必须包含一台电脑。跨设备拖拽看似神奇拆开来看不过是三个环节源端声明我可以被拖、目标端声明我可以接收什么、落点处把 UDMF 携带的数据解析成图片。本文结合entry/src/main/ets/view/contentEditor/AddMedia.ets的真实代码把这三个环节完整讲透。一、知识点讲解拖拽的 API 原理1. 源端draggable(true) 与 onDragEnddraggable(true)声明组件可被拖拽。系统内置组件Image、TextInput、TextArea 等开启后长按即进入拖拽态系统会自动把组件内容封装成 UnifiedData。onDragEnd((event: DragEvent) void)拖拽结束无论成功失败时触发。事件对象里的getResult()返回DragResult枚举可据此判断本次拖拽是否成功。注意一个关键设计拖拽的最终结果成功/失败由目标端决定。目标端在onDrop中调用event.setResult(DragResult.DRAG_SUCCESSFUL)或DRAG_FAILED这个结果会被系统回传给源端的onDragEnd。2. 目标端allowDrop 与 onDropallowDrop(types: ArrayUniformDataType)声明组件能接收的数据类型白名单。拖拽过程中系统会实时计算当前悬停组件是否可接收只有类型匹配时才允许落下。onDrop((dragEvent?: DragEvent) void)数据落下的回调。在这里通过event.getData()拿到 UDMF 数据上一篇文章已详解解析后写入业务数据。3. 图片数据的两种载体拖拽图片时源端系统相册应用、本应用图片等可能以两种 UDMF 记录类型封装数据OPENHARMONY_PIXEL_MAP直接携带 PixelMap 像素数据。记录类为SystemDefinedPixelMapdetails中存有width、height、pixel-format等元数据rawData中是像素字节Uint8Array。优点是免去文件 IO缺点是数据量较大。IMAGE只携带图片地址。记录类为ImageimageUri字段是图片 URI接收方需要自行打开文件并解码。两种类型都需要接收方做还原成 PixelMap的工作只是数据来源不同前者从内存字节直接构建后者从文件 URI 解码。本项目对两类都做了兼容。二、结合本项目源码分析1. 源端图片/视频组件可拖拽AddMedia.ets的addMedia()Builder 中图片列表项是这样的BuilderaddMedia() { Row() { List({ space: CommonConstants.LIST_COMM_SPACE }) { ForEach(this.mediaUriArray, (item: MediaInfo) {ListItem() { if (item.imagePixelMap) {Image(item.imagePixelMap) .width($r(app.integer.add_image_area_size)) .height($r(app.integer.add_image_area_size)) .borderRadius($r(app.integer.border_radius3)) .visibility(item ? Visibility.Visible : Visibility.None) .draggable(true) .onDragEnd((event) { // The result value obtained from onDragEnd is set by the recipient onDrop. if (event.getResult() DragResult.DRAG_FAILED) { try { this.getUIContext().getPromptAction().showToast({ duration:100, message:Drag failed}); } catch (err) { hilog.error(DOMAIN, TAG, FORMAT, ShowToast failed. Causecode: ${err.code}, message: ${err.message}); } } }).bindMenu(this.copy(item.imagePixelMap)) } // ... videoUri 分支结构相同 } }, (item: MediaInfo, index: number) JSON.stringify(item) index) this.addDefaultPic() } // ... } }分析要点.draggable(true)让每张图片都能被长按拖起。源端是 Image 组件拖拽数据由系统自动封装成 UDMF 的OPENHARMONY_PIXEL_MAP记录。onDragEnd里通过event.getResult()检查结果结果为DragResult.DRAG_FAILED时弹出 Drag failed 提示。而成功的结果则是由对端设备的onDrop里setResult(DRAG_SUCCESSFUL)回传的——这就是跨设备场景下拖拽是否成功的闭环机制本端拖、对端接、结果回传本端提示。.bindMenu(this.copy(item.imagePixelMap))与拖拽无关是点击图片弹出复制菜单见《系统剪贴板》一文这里一并提到是为了说明同一张图片既支持拖拽也支持复制。视频项item.videoUri分支结构完全相同同样draggable(true)onDragEnd检查结果。2. 目标端图片列表区域接收拖入整个图片列表区域所在的 Column 被声明为接收端AddMedia.ets的build().draggable(true) .allowDrop([uniformTypeDescriptor.UniformDataType.IMAGE, uniformTypeDescriptor.UniformDataType.OPENHARMONY_PIXEL_MAP]) .onDrop((dragEvent?: DragEvent) {// The logic behind the image falling in, achieving image writing.this.getDataFromUdmf((dragEventasDragEvent),async(event:DragEvent) {try{letrecords:ArrayunifiedDataChannel.UnifiedRecord event.getData().getRecords();for(leti 0; i records.length; i) {// PixelMap converted from image to pixelMap in the image system.if(records[i].getType() uniformTypeDescriptor.UniformDataType.OPENHARMONY_PIXEL_MAP) {letpixelMapRecord records[i]asunifiedDataChannel.SystemDefinedPixelMap;// ...}else{// Convert the image from imageUri to PixelMap.this.uri2pixelMap((records[i]asunifiedDataChannel.Image).imageUri); } } event.useCustomDropAnimationfalse;// If the result is explicitly set to successful, pass this value to the onDragEnd of the dragging party.event.setResult(DragResult.DRAG_SUCCESSFUL); }catch(err) { hilog.error(DOMAIN,TAG,FORMAT,GetData failed. Cause code:${err.code}, message:${err.message}); } }) })三个设计点值得学习白名单只开图片类allowDrop只接收IMAGE与OPENHARMONY_PIXEL_MAP文字、文件等类型根本落不进来从入口就过滤掉了无效数据。遍历全部记录一次拖拽可能携带多条记录例如从相册一次拖多张图所以用for循环逐个处理而不是只取records[0]。显式回传结果event.setResult(DragResult.DRAG_SUCCESSFUL)配合注释pass this value to the onDragEnd of the dragging party明确了源端onDragEnd结果的来源。另外event.useCustomDropAnimation false关闭系统默认的落点动画避免与自定义交互冲突。3. OPENHARMONY_PIXEL_MAP 分支从内存字节还原 PixelMap这是本项目最精细的一段代码。拿到SystemDefinedPixelMap记录后需要从details读出尺寸和像素格式从rawData读出像素字节再交给image.createPixelMap重建letpixelMapRecord records[i]asunifiedDataChannel.SystemDefinedPixelMap;constmWidth:number (pixelMapRecord.details?.width?? -1)asnumber;constmHeight:number (pixelMapRecord.details?.height?? -1)asnumber;constmPixelFormat: image.PixelMapFormat (pixelMapRecord.details?.[pixel-format] ?? image.PixelMapFormat.UNKNOWN)asimage.PixelMapFormat;constmItemPixelMapData:Uint8Array pixelMapRecord.rawData;constopts: image.InitializationOptions {srcPixelFormat: mPixelFormat,pixelFormat: mPixelFormat,size: {width: mWidth,height: mHeight } };constbuffer:ArrayBuffer mItemPixelMapData.buffer.slice(mItemPixelMapData.byteOffset, mItemPixelMapData.byteOffset mItemPixelMapData.byteLength);constpixelMap awaitimage.createPixelMap(buffer, opts);if(this.mediaUriArray.lengthCommonConstants.MAX_ADD_MEDIA_NUM) {letuuid util.generateRandomUUID();this.PixelMapToBuffer(pixelMap, uuid);this.mediaUriArray.push({imagePixelMap: pixelMap,mediaName: uuid,mediaType:MediaType.MEDIA_IMAGE}); }else{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}); } }逐行解读元数据读取details?.width、details?.height取出宽高details?.[pixel-format]取出像素格式键名带连字符所以用方括号访问取不到时回退为PixelMapFormat.UNKNOWN。用??空值合并保证容错。字节切片rawData是Uint8Array它可能只是底层 ArrayBuffer 的一个视图。buffer.slice(byteOffset, byteOffset byteLength)把有效区间精确切出来生成独立的新 ArrayBuffer——这一步很关键直接拿rawData.buffer可能带上多余字节导致解码失败。重建 PixelMapimage.createPixelMap(buffer, opts)使用异步接口srcPixelFormat与pixelFormat都沿用源数据的格式尺寸保持原样避免二次转换损耗。容量控制CommonConstants.MAX_ADD_MEDIA_NUM值为 9定义在entry/src/main/ets/constants/CommonConstants.ets限制图片总数量超出后弹add_picture_prompt提示而不是无限堆积。持久化生成 UUID 作为文件名先PixelMapToBuffer把图片写入分布式文件目录便于后续跨设备分发再把{ imagePixelMap, mediaName, mediaType: MEDIA_IMAGE }推入mediaUriArrayMediaInfo结构定义在entry/src/main/ets/model/ContentInfo.ets页面随即通过StorageLink(mediaUriArray)自动刷新。PixelMapToBuffer的实现PixelMapToBuffer(pixelMap: image.PixelMap, displayName: string): void { const imagePackerApi: image.ImagePacker image.createImagePacker();letpackOpts: image.PackingOption { format: image/jpeg, quality:100}; imagePackerApi.packToData(pixelMap, packOpts).then((data:ArrayBuffer) {writeDistributedFile(this.context,displayName,MediaType.MEDIA_IMAGE,data); }).catch((err:BusinessError) {hilog.info(DOMAIN,TAG,FORMAT, PackToDatafailed.Causecode: ${err.code}, message: ${err.message});}); }即image.createImagePacker()创建打包器 →packToData(pixelMap, { format: image/jpeg, quality: 100 })把 PixelMap 编码成 JPEG 字节流 →writeDistributedFile定义在entry/src/main/ets/utils/FileUtil.ets写入context.distributedFilesDir分布式文件目录。这样图片既显示在本端也进入了分布式文件系统后续接续、分发时可直接使用。4. IMAGE 分支从 URI 解析图片如果拖入的数据是IMAGE类型只带imageUri则走uri2pixelMapuri2pixelMap(uri:string):void{letfile: fileIo.File|undefinedundefined;try{letimageUri uri; file fileIo.openSync(imageUri, fileIo.OpenMode.READ_ONLY);constimageSourceApi image.createImageSource(file.fd);constimagePackApi image.createImagePacker();letpackOpts: image.PackingOption {format:image/png,quality:100}; imagePackApi.packToData(imageSourceApi, packOpts).then(asyncreadBuffer {letimageSource: image.ImageSource image.createImageSource(readBufferasArrayBuffer);letoption: image.InitializationOptions {pixelFormat:3,size: {width:100,height:100} }; imageSource.createPixelMap(option).then((pixelMap: PixelMap) {if(this.mediaUriArray.lengthCommonConstants.MAX_ADD_MEDIA_NUM) {letuuid util.generateRandomUUID();this.PixelMapToBuffer(pixelMap, uuid);this.mediaUriArray.push({imagePixelMap: pixelMap,mediaName: uuid,mediaType:MediaType.MEDIA_IMAGE}); }else{// showToast 提示已满} imageSource.release(); }); }).catch((err: BusinessError) { hilog.info(DOMAIN,TAG,FORMAT,PackToData failed. Cause code:${err.code}, message:${err.message}); }); imageSourceApi.release(); }catch(err) { hilog.error(DOMAIN,TAG,FORMAT,OpenSync failed. Cause code:${(errasBusinessError).code}, message:${(errasBusinessError).message}); }finally{if(file) { fileIo.closeSync(file.fd); } } }流程fileIo.openSync(uri, READ_ONLY)打开文件 →image.createImageSource(file.fd)从文件描述符创建图像源 → 用 ImagePacker 统一转码这里转成 png→ 再从转码后的字节重建 ImageSource →createPixelMap({ pixelFormat: 3, size: { 100, 100 } })生成100x100 的缩略图。发布页图片列表只需小图预览缩略图策略既省内存又省传输开销。注意finally中fileIo.closeSync(file.fd)保证文件句柄必然释放imageSource.release()释放图像源资源避免内存泄漏。5. 异步重试与结果回传onDrop里的getDataFromUdmf封装AddMedia.ets 第 127~153 行与上一篇文章介绍的一致先立即尝试读取 UDMF 数据失败则 1500ms 后重试一次。跨设备拖拽时数据经网络传输到达时间不确定这个重试机制保证了绝大多数情况下都能稳定取到数据。6. 跨设备拖拽的前置条件与体验细节结合 README 使用说明第 4 条跨设备拖拽有两个硬性前置条件连接同一华为账号的设备、打开键鼠共享且必须包含一台电脑。原因是跨设备拖拽依赖键鼠共享建立的虚拟输入通道与分布式软总线鼠标在 PC 上拖起图片后系统把拖拽会话通过软总线同步到对端设备对端设备的窗口实时响应悬停与落下。手机、平板等设备不满足该形态时跨设备拖拽不可用但同设备拖拽不受影响——这正是draggable(true)、allowDrop声明式能力的好处底层自动降级应用层代码完全不用改。体验细节上还有两点值得注意。一是拖拽过程中的视觉反馈目标端allowDrop白名单决定了悬停时是否出现可落下的光标状态类型不匹配的拖拽会在悬停时被系统直接拒绝二是event.useCustomDropAnimation false的用意——系统默认在数据落下时播放一段落点动画但本项目收到图片后立即 push 进列表、界面即时刷新默认动画反而会造成视觉上的二次跳变因此显式关闭让列表增项的过渡更干净。7. 与剪贴板路径的互补拖拽不是图片进入列表的唯一通路。同一张图片在本项目中还有另外两条入口一是点击图片弹出复制菜单后在对端设备通过 PasteButton 粘贴getPasteDataTest路径二是手机端选择图库图片后轻触 PC 屏幕通过碰一碰接收dataReceiveListeningPC路径见本模块后续文章。三条路径最终都汇聚到mediaUriArray.push({ imagePixelMap, mediaName, mediaType: MEDIA_IMAGE })这一步——入口多样、落点统一后续的展示、发布、接续逻辑完全复用这是本应用在跨设备数据接入上最重要的架构决策。三、小结跨设备拖拽图片的完整链路在本项目中可以概括为源端Image 组件.draggable(true)允许被拖起onDragEnd中根据event.getResult()判断成败并提示结果由对端回传。目标端接收区域.allowDrop([IMAGE, OPENHARMONY_PIXEL_MAP])声明白名单onDrop中getDataFromUdmf异步取数。数据解析OPENHARMONY_PIXEL_MAP记录从details读尺寸/格式、从rawData切出字节image.createPixelMap重建IMAGE记录则通过uri2pixelMap从 URI 解码出 100x100 缩略图。容量与持久化受MAX_ADD_MEDIA_NUM9 张约束超限弹提示图片经PixelMapToBuffer写入分布式文件目录并推入mediaUriArray驱动界面刷新。结果闭环目标端event.setResult(DragResult.DRAG_SUCCESSFUL)回传源端源端onDragEnd感知结果。掌握这套源端声明 → 目标端白名单 → UDMF 解析 → 结果回传的模式跨设备拖拽视频、文件等场景也能照葫芦画瓢。