
圖片視頻區 AddMedia 詳解引言前兩篇分別講了本地選圖和跨設備拉取媒體這兩條通路最終都匯入同一個組件——AddMedia。它是發布頁的圖片視頻區橫向滾動的九宮格、虛線加號、長按菜單復制、點擊彈出粘貼、拖拽排序……所有與媒體相關的交互都發生在這個組件里。本文不再重復選圖邏輯而是聚焦AddMedia本身的結構設計與交互實現數據從哪來StorageLink(mediaUriArray)全局媒體列表列表怎么展示List ListItem ForEach圖片/視頻雙分支數量上限如何貫穿所有入口9 的上限與各處 toast復制/粘貼菜單如何實現bindMenu PasteButton虛線加號addDefaultPic如何組裝。知識點講解StorageLink讓媒體列表全局共享AddMedia的媒體數據聲明為StorageLink(mediaUriArray)mediaUriArray: ArrayMediaInfo [];mediaUriArray掛在 AppStorage 的mediaUriArray鍵上。這意味著無論數據是本地選圖、跨設備拉取、剪貼板粘貼還是拖拽落下的圖片 push 進來的UI 都會自動刷新將來發布頁打包接續時也是直接讀這個全局數組。MediaInfo接口entry/src/main/ets/model/ContentInfo.ets有三個關鍵字段export interface MediaInfo { imagePixelMap?: PixelMap;//圖片PixelMap 縮略圖圖片分支優先判斷 mediaName: string;//媒體名展示名或 UUID mediaType: MediaType;//image|videovideoUri?: string;//視頻uri視頻分支判斷 }List 橫向滾動 ForEachArkUI 的List默認縱向滾動設置.listDirection(Axis.Horizontal)即橫向。ForEach(this.mediaUriArray, (item) {...}, keyGenerator)遍歷數據渲染ListItem第三個參數是鍵生成器——本項目用JSON.stringify(item) index保證每個條目有穩定唯一鍵避免復用錯亂。為什么九宮格用List而不是Grid因為需求是一行橫向滾動數量從 0 到 9 動態變化List的懶加載與滾動語義正好契合Grid更擅長多行多列的靜態網格。選型原則很簡單數據結構決定布局組件。另外ForEach的鍵生成器不能省略或偷懶用裸index——一旦列表增刪錯位的鍵會導致條目復用錯誤、圖片閃爍。本項目用JSON.stringify(item) index把內容 序號都編進鍵里雖然略重但保證了任何數據變化都能被正確識別。bindMenu長按/點擊彈出菜單bindMenu可以把一個Menu或Builder構建的菜單內容綁定到組件上。bindMenu(this.copy(...))表示長按該組件彈出復制菜單bindMenu(isShown, menuContent, options)是帶顯隱控制的重載第一個參數為 true 時菜單彈出options.onDisappear在菜單消失時回調——本項目用它做粘貼超量的延遲提示。PasteButton安全粘貼控件PasteButton是 HarmonyOS 的安全控件之一它看起來是個按鈕但點擊后由系統代為執行讀取剪貼板動作應用不需要也無法直接讀剪貼板從而規避了剪貼板權限。這類安全控件還有保存、麥克風、相機等的共同特征是系統替應用完成敏感操作應用只在回調里拿結果用戶對什么時候讀了什么有完全知情權。本項目用它在菜單里提供粘貼入口——注意安全控件在某些場景下有使用限制這正是項目同時保留代碼級剪貼板讀取getPasteDataTest的原因之一系統控件受限時如某些浮窗場景仍有兜底路徑。StorageLink 數組增刪自動刷新mediaUriArray是StorageLink綁定的數組。當代碼執行this.mediaUriArray.push(...)時ArkUI 的響應式系統能感知到數組長度變化并觸發依賴該數組的 UIForEach重建——不需要手動 setState 或重新賦值整個數組。這是 ArkUI 狀態管理最小粒度刷新的體現。不過要提醒如果直接替換數組的某個下標元素arr[0] x部分版本需要先取出再放回才能觸發刷新穩妥的做法是整體this.mediaUriArray [...this.mediaUriArray]——本工程代碼統一用push恰好避開了這個坑。結合本項目源碼分析組件外殼接收跨設備數據 整體拖拽文件路徑entry/src/main/ets/view/contentEditor/AddMedia.ets。build() {Column() {// 跨設備媒體接收器上一篇已講CollaborationServiceStateDialog({onState: (stateCode:number,bufferType:string,buffer:ArrayBuffer):voidthis.doInsertMedia(stateCode, bufferType, buffer) })this.addMedia();// 圖片視頻展示區}// 整個區域可拖出且允許接收圖片/PixelMap 落下.draggable(true) .allowDrop([uniformTypeDescriptor.UniformDataType.IMAGE, uniformTypeDescriptor.UniformDataType.OPENHARMONY_PIXEL_MAP]) .onDrop((dragEvent?: DragEvent) { ... })// 拖拽圖片落入的處理... }draggable(true)讓整個媒體區可以被拖起allowDrop聲明能接收哪些類型的數據系統 IMAGE 與 PixelMaponDrop處理拖入。跨設備拖拽的完整機制在模塊五詳解這里只需知道AddMedia 既是媒體展示區也是圖片拖放的接盤俠。展示區addMedia BuilderBuilderaddMedia() { 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))// 96vp 方格子.height($r(app.integer.add_image_area_size)) .borderRadius($r(app.integer.border_radius3)) .visibility(item ? Visibility.Visible : Visibility.None) .draggable(true)// 單張圖可拖出.onDragEnd((event) {// 拖出后對方未接收失敗則提示if(event.getResult() DragResult.DRAG_FAILED) {this.getUIContext().getPromptAction().showToast({ duration:100, message:Drag failed}); } }) .bindMenu(this.copy(item.imagePixelMap))// 長按 → 復制菜單}elseif(item.videoUri) {// —— 視頻分支 ——Video({ src: item.videoUri, posterOptions: { showFirstFrame:true}// 封面顯示首幀}) .width($r(app.integer.add_image_area_size)) .height($r(app.integer.add_image_area_size)) .borderRadius($r(app.integer.border_radius3)) .draggable(true) .controls(false)// 隱藏系統控制條格子太小.autoPlay(true)// 自動播放.loop(true)// 循環播放.bindMenu(this.copy(item.videoUri))// 長按 → 復制菜單} } }, (item: MediaInfo, index: number) JSON.stringify(item) index)// 列表末尾追加虛線加號this.addDefaultPic() } .listDirection(Axis.Horizontal)// 橫向滾動.scrollBar(BarState.Off)// 隱藏滾動條.width(CommonConstants.FULL_PERCENT) .height(CommonConstants.FULL_PERCENT) } .height($r(app.integer.add_image_area_size))// 96vp.width(CommonConstants.FULL_PERCENT) .alignItems(VerticalAlign.Center) .padding({ left: ..., right: ... })// 斷點響應式左右 padding.onClick(() {// 點擊媒體區先關再開確保每次點擊都彈出粘貼菜單this.pasteShow false;this.pasteShow true; }) .bindMenu(this.pasteShow,this.paste(), { onDisappear: () {// 菜單消失時若曾觸發超量提示補一個 toastif(this.toastShow) {this.getUIContext().getPromptAction().showToast({ message: $r(app.string.add_picture_prompt) });this.toastShow false; } } }) }這段代碼信息量很大拆開看圖片/視頻雙分支用if (item.imagePixelMap)/else if (item.videoUri)區分兩種媒體。圖片用Image顯示 PixelMap視頻用Video組件showFirstFrame顯示首幀封面controls(false)隱藏控制條、autoPlay(true)loop(true)讓格子里的視頻自動循環播放——在 96vp 的小格子里營造輕預覽體驗。點擊彈粘貼菜單onClick里先 false 后 true的寫法是刻意為之——如果連續兩次點擊都保持 true第二次不會重新觸發菜單先置 false 再置 true 保證每次點擊都能彈出。菜單內容由this.paste()構建onDisappear兜底處理粘貼超量的提示時機。拖動反饋每張圖的onDragEnd檢查event.getResult()如果接收方返回DRAG_FAILED比如目標不可接收彈 100ms 的 Drag failed toast 提醒用戶。復制菜單copy BuilderBuilder copy(data: image.PixelMap|string) {Row(){Text($r(app.string.copy))//Copy .fontSize(16).fontWeight(FontWeight.Medium)} .padding({ left: 16 }) .height(48) .width(224) .onClick((){ this.setPasteDataTest(data);// 寫入系統剪貼板}) }長按圖片彈出Copy菜單點擊后把數據寫入剪貼板。setPasteDataTest按數據類型走不同 MIMEasync setPasteDataTest(data:image.PixelMap |string): Promisevoid {letpasteData: pasteboard.PasteData;if(typeof datastring) {// 視頻以 URI 文本形式寫入剪貼板pasteData pasteboard.createData(pasteboard.MIMETYPE_TEXT_URI,data); }else{// 圖片以 PixelMap 形式寫入剪貼板pasteData pasteboard.createData(pasteboard.MIMETYPE_PIXELMAP,data); }letsystemPasteBoard: pasteboard.SystemPasteboard pasteboard.getSystemPasteboard(); await systemPasteBoard.setData(pasteData).catch((err: BusinessError) {...}); }這樣復制出的數據可以在本應用內粘貼也可以借助跨設備剪貼板在另一臺設備上粘貼——這正是跨設備互通的一個縮影。粘貼菜單paste Builder 與 PasteButtonBuilder paste(){ Row(){//系統安全控件點擊由系統代為讀取剪貼板 PasteButton({ text: PasteDescription.PASTE }).fontSize(16).fontColor(Color.Black).fontWeight(FontWeight.Medium).backgroundColor(Color.White).padding({ right: 176 }).width(100%).onClick(() { this.getPasteDataTest();//讀取并處理剪貼板內容 this.pasteShow false;//關閉菜單 }) }.height(48).width(224)}PasteButton點擊后由系統讀取剪貼板隨后應用的onClick里調用getPasteDataTest主動獲取剪貼板數據。getPasteDataTest內部分兩類處理MIMETYPE_PIXELMAP是圖片取出 PixelMap 加入列表、MIMETYPE_TEXT_URI是視頻 URI寫分布式文件 加入列表若列表已滿則置toastShow true由菜單的onDisappear統一彈提示。虛線加號addDefaultPic BuilderBuilder addDefaultPic(){Row(){Column(){// 加號圖標系統 SymbolSymbolGlyph($r(sys.symbol.camera_badge_plus)) .fontSize($r(app.integer.add_icon_size))// 26vp.fontColor([$r(sys.color.font_primary)])// 文案隨已添加數量三態切換Text(this.mediaUriArray.length 0 ? $r(app.string.add_high_img):// 0 張Add Image(this.mediaUriArray.length CommonConstants.MAX_ADD_MEDIA_NUM ?$r(app.string.continue_add):// 未滿 9Continue Add$r(app.string.image_add)))// 已滿 9Upper Limit.fontSize($r(app.integer.text_size_body3)) .fontColor($r(app.color.add_image_background)) .margin({ top:$r(app.integer.comm_padding_margin_mini)}) } .justifyContent(FlexAlign.Center).width(CommonConstants.FULL_PERCENT) } .borderStyle(BorderStyle.Dashed)// 虛線邊框.borderWidth($r(app.integer.default_border_width)) .borderColor($r(app.color.add_image_border_color)) .borderRadius($r(app.integer.border_radius3)) .width($r(app.integer.add_image_area_size)) .height($r(app.integer.add_image_area_size)) .margin({ right:$r(app.integer.add_default_pic_right), bottom:$r(app.integer.add_default_pic_bottom)}) .bindMenu(this.MyTestMenu())// 點加號 → 彈出本地設備 跨設備菜單}三個細節值得一提三態文案mediaUriArray.length為 0 顯示 Add Image首圖引導、未滿 9 顯示 Continue Add鼓勵繼續加、滿 9 顯示 Upper Limit明示封頂。一行三元表達式把狀態機表達得清清楚楚。虛線加號視覺BorderStyle.Dashed 1vp 虛線邊框 圓角配合SymbolGlyph系統圖標構成標準的添加占位樣式96vp 與圖片格子完全一致保證九宮格對齊。入口聚合bindMenu(this.MyTestMenu())把本地設備 跨設備拉取兩個入口都掛在這一個加號上菜單內容見上一篇用戶心智單一。兩個補充通路粘貼與拖拽的數據處理除了復制菜單AddMedia 還有兩條加圖通路值得掃一眼它們的共同點是把各種來源的數據統一還原成 PixelMap 或 URI。粘貼通路getPasteDataTest從剪貼板讀PasteDatagetPrimaryMimeType()判斷類型——MIMETYPE_PIXELMAP直接取出getPrimaryPixelMap()加入列表MIMETYPE_TEXT_URI則把 URI 當作視頻源writeDistributedFile復制 加入列表。注意它用getRecordCount()和getPrimaryMimeType()先看一眼再決定怎么處理避免對未知類型做錯誤假設。拖拽通路onDropuri2pixelMap拖入的數據可能是OPENHARMONY_PIXEL_MAP類型的記錄跨進程傳輸的 PixelMap也可能是Image類型只有 imageUri。前者需要按details里的寬高/像素格式信息重建 PixelMap// 從拖拽記錄里解析像素圖元數據constmWidth: number (pixelMapRecord.details?.width??-1) as number;constmPixelFormat:image.PixelMapFormat (pixelMapRecord.details?.[pixel-format] ??image.PixelMapFormat.UNKNOWN) asimage.PixelMapFormat;// 用這些信息初始化一張新 PixelMapconstopts:image.InitializationOptions { srcPixelFormat: mPixelFormat, pixelFormat: mPixelFormat,size: {width: mWidth,height: mHeight } };constpixelMap awaitimage.createPixelMap(buffer, opts);后者Image記錄走uri2pixelMapfileIo.openSync打開 URI →image.createImageSource(fd)解碼 → 縮放到 100×100 的小圖 → 加入列表。這條打開→解碼→縮放的管線與第 16 篇本地選圖的編碼管線正好互為鏡像——一個負責把外部數據變成 PixelMap一個負責把 PixelMap 變成文件字節流。數量上限如何貫穿全局MAX_ADD_MEDIA_NUM 9CommonConstants.ets在每個加媒體入口都有一道閘門形成多層防護selectImage截斷超出部分 toastMyTestMenu的本地設備入菜空前校驗doInsertMedia跨設備入數據前校驗getPasteDataTest粘貼入數據前校驗toastShow延遲提示uri2pixelMap拖拽 URI 轉圖入數據前校驗 toastonDrop拖拽 PixelMap入數據前校驗 toastaddDefaultPic文案三態 滿 9 時菜單仍可彈出但不放行。這種處處設防的寫法對演示工程來說略顯冗余但對真實產品而言是必要的健壯性——任何一條通路漏了校驗都可能讓數組突破 9 而撐爆九宮格布局。小結AddMedia是發布頁最多功能的子組件它的設計哲學可以概括為**入口多樣、出口統一**數據出口統一無論來自本地、遠端、剪貼板還是拖拽最終都 push 進mediaUriArray由同一個橫向 List 渲染交互出口統一復制、粘貼、拖拽、跨設備最終都產出MediaInfo約束統一MAX_ADD_MEDIA_NUM 9在每條通路上設卡保證九宮格永不超限。對初級開發者建議動手復現時重點體會三點StorageLink數組驅動 UI 刷新的爽快感、bindMenu的兩種用法內容綁定/顯隱控制、以及PasteButton安全控件與普通按鈕在權限語義上的差異。此外AddMedia 還示范了一個組件邊界的設計判斷它既是數據源選圖、粘貼、拖拽、跨設備拉取都匯聚于此又是展示區九宮格還是拖放交互區draggable/allowDrop/onDrop。一個組件同時承擔三條職責看起來重但仔細想想這些職責都圍繞媒體這一個主題把它們內聚在一起反而讓發布頁的其他部分文本區、工具欄保持純凈。當多個職責高度耦合于同一份數據時內聚優于拆分——這是組件設計里高內聚的正面案例。下一篇輪到文字區——標題與正文的EditorComponent。本文引用源碼entry/src/main/ets/view/contentEditor/AddMedia.ets、entry/src/main/ets/model/ContentInfo.ets、entry/src/main/ets/constants/CommonConstants.ets