別再拼 JSON 了:HarmonyOS UDMF 跨應用資料流轉實踐

做跨應用資料流轉,最容易寫歪。

我之前接過一個看起來挺小的需求:在一個資料管理類應用裡,使用者長按一張資料卡片,可以把標題、摘要、來源連結帶到另一個應用裡;如果接收方是富文字編輯器,就盡量保留連結;如果只是普通輸入框,至少要落成一段可讀文字。產品說得很輕鬆,「就跟複製貼上差不多」。真寫起來才發現,複製一段字串只是最粗糙的那種做法。

一開始我們做得也簡單:把業務物件轉成 JSON,再塞到剪貼簿或者路由參數裡。自己應用內跳轉沒問題,一跨應用就開始出狀況:有的地方只能拿到純文字,有的地方把 JSON 原樣貼出來,檔案路徑到了接收方讀不了,使用者一取消操作,頁面狀態還以為已經分享成功。更麻煩的是,後來又加了拖曳入口,同一份資料要走複製、拖曳、分享面板幾套邏輯,越寫越像補丁。

這類場景就不太適合繼續「拼字串」。HarmonyOS 裡 UDMF(Unified Data Management Framework,統一資料管理框架)真正有價值的地方,不是讓你少寫幾行程式碼,而是把跨應用流轉這件事變成一套標準化資料契約:資料是什麼類型、裡面有哪些記錄、接收方怎麼識別、失敗時怎麼回退,都可以在一條鏈路裡管住。

image.png

為什麼 UDMF 值得單獨拿出來講

很多人第一次看 UDMF,會把它理解成「一個跨應用暫存區」。這個理解不算完全錯,但會把工程設計帶偏。

如果只是暫存,那就很容易寫成這樣:

ts 體驗AI程式碼助手 程式碼解讀複製程式碼// 不建議:把業務物件直接塞成一段 JSON 字串
const text = JSON.stringify(card)

然後接收方再嘗試 JSON.parse。自己家的兩個應用也許能跑,換成系統輸入框、文件應用、備忘錄、第三方編輯器,就完全沒法保證體驗。對方不關心你內部的欄位名,它只關心「這是不是一段純文字」「這是不是一個連結」「這是不是一個檔案」。

UDMF 要解決的是這個問題:用統一資料物件 UnifiedData 承載一組標準化記錄,比如純文字、超連結、檔案、圖片等。資料提供方負責把業務物件翻譯成這些標準記錄;資料存取方按統一資料類型去識別,而不是按業務欄位硬猜。

工程上我更願意把它看成四層:

  • 業務物件層:ShareCardFileMetaContactBrief 這種自己應用內部的資料。
  • 標準化記錄層:把業務物件拆成 PlainText、Hyperlink、File 等接收方能理解的記錄。
  • 資料通路層:透過 UDMF 寫入、查詢、更新、刪除。
  • UI 狀態層:只關心「正在準備、已寫入、失敗、已清理」,不要直接抱著業務物件亂傳。

這幾個層次分開以後,後面加拖曳、複製、貼上、跨應用讀取,才不會每個入口都重新寫一套轉換邏輯。

先定一份業務側的資料契約

我不太建議一上來就寫 UDMF API。先把你真正要流轉的業務資料收窄。跨應用資料不是資料庫同步,別想著把整個詳情頁物件都丟出去。

比如資料卡片可以壓成這樣:

ts 體驗AI程式碼助手 程式碼解讀複製程式碼export interface ShareCard {
  id: string
  title: string
  summary: string
  sourceUrl?: string
  sourceName?: string
  createdAt: number
}

export interface ShareResult {
  key: string
  exportedAt: number
}

這裡故意沒有放使用者 token、內部權限位、完整編輯歷史。跨應用流轉的資料,預設都要按「別人可能看見」處理。就算目前只是同公司兩個應用之間共享,也別把登入態、手機號、身分證號這種東西混進去。後面排查問題時,你會感謝現在的克制。

把業務物件轉成 UnifiedData

下面這段是我在專案裡會放到 adapter 層的寫法。它不直接碰頁面狀態,只做一件事:把業務物件翻譯成 UDMF 認識的資料。

ts 體驗AI程式碼助手 程式碼解讀複製程式碼// common/udmf/CardUdmfAdapter.ets
import { unifiedDataChannel } from '@kit.ArkData'

export interface ShareCard {
  id: string
  title: string
  summary: string
  sourceUrl?: string
  sourceName?: string
  createdAt: number
}

export class CardUdmfAdapter {
  static toUnifiedData(card: ShareCard): unifiedDataChannel.UnifiedData {
    const text = new unifiedDataChannel.PlainText()

    // 給普通輸入框、備忘錄、IM 輸入框一個可讀兜底。
    // 這裡不要塞一坨 JSON,使用者真的可能直接看到它。
    text.textContent = [
      card.title,
      card.summary,
      card.sourceUrl ? `來源:${card.sourceUrl}` : ''
    ].filter((item: string) => item.length > 0).join('\n')

    const data = new unifiedDataChannel.UnifiedData(text)

    // 如果專案 API 版本支援更多標準化記錄,可以繼續追加 Hyperlink / File 等。
    // 實際落地時建議保留 PlainText 作為兜底記錄,接收方能力弱也能拿到內容。
    return data
  }
}

有些同學會嫌這個 PlainText 太保守,覺得都上高階 API 了,怎麼還寫純文字。恰恰相反,純文字兜底是跨應用體驗裡最穩的一層。你可以在支援的版本裡追加更豐富的記錄,但不要把唯一出口做成內部 JSON。使用者把內容拖到一個普通文字框裡,能看到一段自然文字,比看到 { "id": "xxx" } 強太多。

封一層 Repository,別讓頁面直接調 insertData

頁面裡直接調 unifiedDataChannel.insertData,短 demo 沒問題,專案裡很快就亂。我的習慣是單獨封一個 UdmfRepository,把回呼、例外、key 管理都收進去。

ts 體驗AI程式碼助手 程式碼解讀複製程式碼// common/udmf/UdmfRepository.ets
import { unifiedDataChannel, uniformTypeDescriptor } from '@kit.ArkData'
import { BusinessError } from '@kit.BasicServicesKit'
import { CardUdmfAdapter, ShareCard } from './CardUdmfAdapter'

export class UdmfRepository {
  private lastSharedKey: string = ''

  async shareCard(card: ShareCard): Promise<string> {
    const unifiedData = CardUdmfAdapter.toUnifiedData(card)

    const options: unifiedDataChannel.Options = {
      intention: unifiedDataChannel.Intention.DATA_HUB
    }

    return new Promise((resolve, reject) => {
      try {
        unifiedDataChannel.insertData(options, unifiedData, (err: BusinessError | undefined, key: string) => {
          if (err) {
            reject(err)
            return
          }

          this.lastSharedKey = key
          resolve(key)
        })
      } catch (e) {
        reject(e as BusinessError)
      }
    })
  }

  async queryPlainTexts(): Promise<string[]> {
    const options: unifiedDataChannel.Options = {
      intention: unifiedDataChannel.Intention.DATA_HUB
    }

    return new Promise((resolve, reject) => {
      try {
        unifiedDataChannel.queryData(options, (err: BusinessError | undefined, dataList: unifiedDataChannel.UnifiedData[]) => {
          if (err) {
            reject(err)
            return
          }

          const result: string[] = []

          dataList.forEach((data: unifiedDataChannel.UnifiedData) => {
            const records = data.getRecords()
            records.forEach((record: unifiedDataChannel.UnifiedRecord) => {
              // 接收方不要假設第 0 條就是純文字,按類型拿。
              if (record.getType() === uniformTypeDescriptor.UniformDataType.PLAIN_TEXT) {
                const plainText = record as unifiedDataChannel.PlainText
                result.push(plainText.textContent)
              }
            })
          })

          resolve(result)
        })
      } catch (e) {
        reject(e as BusinessError)
      }
    })
  }

  async deleteLastShared(): Promise<void> {
    if (this.lastSharedKey.length === 0) {
      return
    }

    const options: unifiedDataChannel.Options = {
      key: this.lastSharedKey
    }

    return new Promise((resolve, reject) => {
      try {
        unifiedDataChannel.deleteData(options, (err: BusinessError | undefined) => {
          if (err) {
            reject(err)
            return
          }

          this.lastSharedKey = ''
          resolve()
        })
      } catch (e) {
        reject(e as BusinessError)
      }
    })
  }
}

這裡有兩個細節我會比較堅持。

一個是保存 insertData 返回的 key。很多 demo 只展示寫入和查詢,沒強調這個 key。真實專案裡,沒有 key 就很難做更新、刪除、清理,也不好定位日誌。資料一旦進入通路,後續生命週期就不能靠「我覺得它應該沒了」。

另一個是查詢時按 getType() 過濾。不要偷懶寫 records[0] as PlainText。你今天只放一條純文字,明天就可能加一條連結記錄、一條圖片記錄。陣列順序一變,接收方就出錯。跨應用資料最怕這種隱式約定。

頁面只管理狀態,不參與資料拼裝

頁面層最好別知道 UDMF 裡面到底塞了什麼。它只負責觸發動作、展示狀態、處理失敗提示。

ts 體驗AI程式碼助手 程式碼解讀複製程式碼// pages/ShareCardPage.ets
import { UdmfRepository } from '../common/udmf/UdmfRepository'
import { ShareCard } from '../common/udmf/CardUdmfAdapter'
import { BusinessError } from '@kit.BasicServicesKit'

@Entry
@Component
struct ShareCardPage {
  private udmfRepo: UdmfRepository = new UdmfRepository()

  @State card: ShareCard = {
    id: 'doc_20260430_001',
    title: 'HarmonyOS 圖片處理鏈路復盤',
    summary: '一張圖從相簿進來,到預覽、壓縮、匯出,中間其實有不少記憶體坑。',
    sourceUrl: 'https://juejin.cn/',
    sourceName: '技術筆記',
    createdAt: Date.now()
  }

  @State sharing: boolean = false
  @State message: string = '未共享'
  @State lastKey: string = ''

  private async share(): Promise<void> {
    if (this.sharing) {
      return
    }

    this.sharing = true
    this.message = '正在準備資料...'

    try {
      const key = await this.udmfRepo.shareCard(this.card)
      this.lastKey = key
      this.message = '已寫入標準化資料通路'
    } catch (e) {
      const err = e as BusinessError
      this.message = this.toUserMessage(err)
      console.error(`[UDMF] share failed, code=${err.code}, message=${err.message}`)
    } finally {
      this.sharing = false
    }
  }

  private async cleanup(): Promise<void> {
    try {
      await this.udmfRepo.deleteLastShared()
      this.lastKey = ''
      this.message = '已清理上一次共享資料'
    } catch (e) {
      const err = e as BusinessError
      this.message = '清理失敗,稍後再試'
      console.error(`[UDMF] cleanup failed, code=${err.code}, message=${err.message}`)
    }
  }

  private toUserMessage(err: BusinessError): string {
    // 這裡別把底層錯誤直接丟給使用者。
    // 錯誤碼留日誌,前台給能理解的話。
    if (err.code === 401) {
      return '目前介面權限或能力不可用'
    }
    return '共享失敗,請稍後重試'
  }

  build() {
    Column({ space: 16 }) {
      Text(this.card.title)
        .fontSize(22)
        .fontWeight(FontWeight.Bold)

      Text(this.card.summary)
        .fontSize(15)
        .fontColor('#666666')

      Button(this.sharing ? '處理中...' : '寫入 UDMF 資料通路')
        .enabled(!this.sharing)
        .onClick(() => {
          this.share()
        })

      Button('清理上一次共享資料')
        .enabled(this.lastKey.length > 0)
        .onClick(() => {
          this.cleanup()
        })

      Text(this.message)
        .fontSize(14)
        .fontColor('#666666')
    }
    .padding(24)
    .width('100%')
  }
}

這段程式碼看著不複雜,但它把幾個坑避開了:重複點擊、錯誤碼外洩、頁面直接拼資料、失敗後狀態不回收。很多線上問題不是 API 不會用,而是這些邊角沒收住。

檔案類資料別直接丟沙箱路徑

UDMF 做文字和連結比較直觀,到了檔案、圖片,坑會多一些。

有些專案會把應用沙箱裡的路徑直接塞出去,接收方拿到之後發現讀不了。這個問題不是 UDMF 的鍋,是權限和存取邊界沒想清楚。跨應用資料流轉時,要確認接收方拿到的是它有能力存取的資料,不能把自己應用私有目錄裡的路徑當成公共檔案位址。

我的處理方式一般是:

  • 能用文字、連結解決的,不要硬塞檔案。
  • 檔案必須流轉時,先確認檔案來源和存取方式,比如使用者選擇的媒體資源、應用生成的可共享暫存檔。
  • 不把長期私有檔案直接暴露出去,必要時生成一份暫存副本。
  • 資料記錄裡保留標題、大小、類型等中繼資料,接收方即使檔案讀取失敗,也能做友善提示。
  • 分享完成或頁面退出後,清理暫存副本,別讓快取目錄變成垃圾堆。

說白了,UDMF 是資料通路,不是權限魔法。你傳出去的東西,接收方有沒有資格讀,還是要你自己設計清楚。

把生命週期畫出來,問題會少一半

我後來給團隊裡定了個小規矩:凡是跨應用資料流轉,都要畫一個狀態圖。不用很複雜,至少把準備、寫入、成功、失敗、清理這幾個狀態列出來。

image.png

實際程式碼裡可以對應成這樣:

ts 體驗AI程式碼助手 程式碼解讀複製程式碼export enum ShareTaskState {
  IDLE = 'IDLE',
  BUILDING = 'BUILDING',
  INSERTING = 'INSERTING',
  SHARED = 'SHARED',
  CLEANUP = 'CLEANUP',
  FAILED = 'FAILED'
}

export interface ShareTaskSnapshot {
  state: ShareTaskState
  key?: string
  errorCode?: number
  errorMessage?: string
  updatedAt: number
}

頁面和日誌都圍繞這個狀態走,排查問題會輕鬆很多。比如使用者回饋「我點了分享沒反應」,你能從日誌裡看到它到底是構建資料失敗、寫入通路失敗,還是寫入成功但接收方沒有識別。不要等線上問題來了,才從一堆 console.info('success') 裡猜。

常見坑位,我踩過的幾類

1. 把 UDMF 當長期資料庫用

UDMF 適合跨應用流轉,不適合承載你自己的長期業務資料。應用內部狀態還是該放 Preferences、關聯式資料庫、檔案系統或者伺服器。UDMF 裡只放「需要交給別人的」那一份資料,而且要有清理策略。

2. 只做發送方,不做接收方自測

很多問題發送方看不出來。你寫入成功了,不代表別人能讀懂。至少要準備幾個接收場景:

  • 普通文字輸入框。
  • 富文字編輯器。
  • 自家另一個測試應用。
  • 不支援你期望類型的兜底場景。

能貼成自然文字,基本盤就穩了;能識別連結和檔案,是增強體驗。

3. 接收方強依賴記錄順序

前面也提過,別寫 records[0]。標準化資料物件是一組記錄,接收方應該按類型和業務標籤識別。今天順序對,不代表下個版本還對。

4. 錯誤提示直接展示底層 message

底層錯誤對使用者沒意義。使用者要知道的是「是不是沒權限」「是不是內容太大」「是不是稍後再試」。錯誤碼和原始 message 留日誌,前台提示做一次翻譯。

5. 大物件不做預算

跨應用流轉不是越全越好。幾十 KB 的文字摘要和一個連結,體驗很好;幾 MB 的 JSON、幾十張圖片中繼資料、完整編輯歷史,一旦失敗很難補救。大物件要麼拆,要麼走檔案,要麼只傳索引和摘要。

6. 忘了清理 key

如果你需要更新或刪除寫入的資料,就要保存 key。頁面銷毀、使用者撤銷、任務失敗,都要考慮 key 還在不在。別讓「暫時資料」變成沒人管的資料。

性能和穩定性上的幾個取捨

UDMF 鏈路裡,我最關心的不是單次 API 呼叫耗時,而是使用者連續操作時系統是否穩定。

使用者快速點三次分享按鈕,頁面旋轉一次,再從最近任務回來,這些場景比單次 demo 更接近真實情況。建議做幾個小護欄:

ts 體驗AI程式碼助手 程式碼解讀複製程式碼export class ShareActionGate {
  private running: boolean = false
  private lastActionAt: number = 0

  canRun(): boolean {
    const now = Date.now()

    if (this.running) {
      return false
    }

    // 簡單節流,避免使用者連續觸發多次寫入。
    if (now - this.lastActionAt < 800) {
      return false
    }

    this.running = true
    this.lastActionAt = now
    return true
  }

  finish(): void {
    this.running = false
  }
}

別小看這種門閂。很多「偶發重複分享」「偶發狀態錯亂」,最後都和連續觸發有關。你可以做得更細,比如給每次分享分配 taskId,非同步回呼回來時只允許最新 task 更新 UI。這個思路和圖片處理、播放器狀態機是一樣的:非同步任務不要裸奔。

另一個取捨是資料大小。我的建議是:跨應用預設傳輕量內容,重內容只傳可存取引用。比如一篇筆記,給標題、摘要、連結;一個檔案,給可存取 URI 和檔案中繼資料;一批圖片,給數量、封面和入口,不要把所有東西一次性塞進通路裡。

更適合落地的場景

UDMF 不一定適合所有業務,但下面幾類挺典型:

  • 筆記、資料、收藏類應用:把卡片拖到文件或備忘錄,保留標題、摘要、來源。
  • 辦公協作應用:在多個內部應用之間傳遞簽核單摘要、任務連結、檔案引用。
  • 內容生產工具:把素材從素材庫拖到編輯器,接收方按圖片、連結、純文字分別處理。
  • 教育類應用:題目、錯題、講解片段在題庫和筆記之間流轉。
  • 裝置協同入口:同一份標準化資料在不同端上被識別,而不是每個端寫一套欄位解析。

判斷一個場景該不該用 UDMF,我一般看兩個問題:這份資料是不是要離開當前應用?接收方是不是可能不止一個?只要兩個答案都是「是」,就別再只想著字串拼接了。

結尾:跨應用資料流轉,先像個產品能力,再像一個 API 呼叫

UDMF 這類 API,最怕寫成「我會呼叫 insertData 了」。呼叫成功只是第一步,真正要考慮的是:使用者看到的是什麼,接收方能不能理解,失敗時怎麼降級,敏感欄位有沒有出去,暫存資料誰來清理。

我的經驗是,先把業務物件收窄,再轉成標準化記錄;先保證純文字兜底,再做連結、檔案、圖片這些增強;先把 key、狀態、錯誤、清理鏈路想清楚,再把入口掛到按鈕、拖曳、貼上裡。這樣寫出來的程式碼不一定最炫,但上線後少出奇怪問題。

鴻蒙的高階 API 很多,UDMF 算是比較容易被低估的一個。它不只是「跨應用共享資料」,更像是給應用之間約了一套聽得懂的話。這個約定做紮實了,後面做拖曳、富文字、檔案流轉,才不會每加一個入口就重寫一遍膠水程式碼。


原文出處:https://juejin.cn/post/7634480683300028450


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝11   💬8  
237
🥈
我愛JS
💬2  
8
🥉
Gigi
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登