做跨應用資料流轉,最容易寫歪。
我之前接過一個看起來挺小的需求:在一個資料管理類應用裡,使用者長按一張資料卡片,可以把標題、摘要、來源連結帶到另一個應用裡;如果接收方是富文字編輯器,就盡量保留連結;如果只是普通輸入框,至少要落成一段可讀文字。產品說得很輕鬆,「就跟複製貼上差不多」。真寫起來才發現,複製一段字串只是最粗糙的那種做法。
一開始我們做得也簡單:把業務物件轉成 JSON,再塞到剪貼簿或者路由參數裡。自己應用內跳轉沒問題,一跨應用就開始出狀況:有的地方只能拿到純文字,有的地方把 JSON 原樣貼出來,檔案路徑到了接收方讀不了,使用者一取消操作,頁面狀態還以為已經分享成功。更麻煩的是,後來又加了拖曳入口,同一份資料要走複製、拖曳、分享面板幾套邏輯,越寫越像補丁。
這類場景就不太適合繼續「拼字串」。HarmonyOS 裡 UDMF(Unified Data Management Framework,統一資料管理框架)真正有價值的地方,不是讓你少寫幾行程式碼,而是把跨應用流轉這件事變成一套標準化資料契約:資料是什麼類型、裡面有哪些記錄、接收方怎麼識別、失敗時怎麼回退,都可以在一條鏈路裡管住。

很多人第一次看 UDMF,會把它理解成「一個跨應用暫存區」。這個理解不算完全錯,但會把工程設計帶偏。
如果只是暫存,那就很容易寫成這樣:
ts 體驗AI程式碼助手 程式碼解讀複製程式碼// 不建議:把業務物件直接塞成一段 JSON 字串
const text = JSON.stringify(card)
然後接收方再嘗試 JSON.parse。自己家的兩個應用也許能跑,換成系統輸入框、文件應用、備忘錄、第三方編輯器,就完全沒法保證體驗。對方不關心你內部的欄位名,它只關心「這是不是一段純文字」「這是不是一個連結」「這是不是一個檔案」。
UDMF 要解決的是這個問題:用統一資料物件 UnifiedData 承載一組標準化記錄,比如純文字、超連結、檔案、圖片等。資料提供方負責把業務物件翻譯成這些標準記錄;資料存取方按統一資料類型去識別,而不是按業務欄位硬猜。
工程上我更願意把它看成四層:
ShareCard、FileMeta、ContactBrief 這種自己應用內部的資料。這幾個層次分開以後,後面加拖曳、複製、貼上、跨應用讀取,才不會每個入口都重新寫一套轉換邏輯。
我不太建議一上來就寫 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、內部權限位、完整編輯歷史。跨應用流轉的資料,預設都要按「別人可能看見」處理。就算目前只是同公司兩個應用之間共享,也別把登入態、手機號、身分證號這種東西混進去。後面排查問題時,你會感謝現在的克制。
下面這段是我在專案裡會放到 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" } 強太多。
頁面裡直接調 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 是資料通路,不是權限魔法。你傳出去的東西,接收方有沒有資格讀,還是要你自己設計清楚。
我後來給團隊裡定了個小規矩:凡是跨應用資料流轉,都要畫一個狀態圖。不用很複雜,至少把準備、寫入、成功、失敗、清理這幾個狀態列出來。

實際程式碼裡可以對應成這樣:
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') 裡猜。
UDMF 適合跨應用流轉,不適合承載你自己的長期業務資料。應用內部狀態還是該放 Preferences、關聯式資料庫、檔案系統或者伺服器。UDMF 裡只放「需要交給別人的」那一份資料,而且要有清理策略。
很多問題發送方看不出來。你寫入成功了,不代表別人能讀懂。至少要準備幾個接收場景:
能貼成自然文字,基本盤就穩了;能識別連結和檔案,是增強體驗。
前面也提過,別寫 records[0]。標準化資料物件是一組記錄,接收方應該按類型和業務標籤識別。今天順序對,不代表下個版本還對。
底層錯誤對使用者沒意義。使用者要知道的是「是不是沒權限」「是不是內容太大」「是不是稍後再試」。錯誤碼和原始 message 留日誌,前台提示做一次翻譯。
跨應用流轉不是越全越好。幾十 KB 的文字摘要和一個連結,體驗很好;幾 MB 的 JSON、幾十張圖片中繼資料、完整編輯歷史,一旦失敗很難補救。大物件要麼拆,要麼走檔案,要麼只傳索引和摘要。
如果你需要更新或刪除寫入的資料,就要保存 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,我一般看兩個問題:這份資料是不是要離開當前應用?接收方是不是可能不止一個?只要兩個答案都是「是」,就別再只想著字串拼接了。
UDMF 這類 API,最怕寫成「我會呼叫 insertData 了」。呼叫成功只是第一步,真正要考慮的是:使用者看到的是什麼,接收方能不能理解,失敗時怎麼降級,敏感欄位有沒有出去,暫存資料誰來清理。
我的經驗是,先把業務物件收窄,再轉成標準化記錄;先保證純文字兜底,再做連結、檔案、圖片這些增強;先把 key、狀態、錯誤、清理鏈路想清楚,再把入口掛到按鈕、拖曳、貼上裡。這樣寫出來的程式碼不一定最炫,但上線後少出奇怪問題。
鴻蒙的高階 API 很多,UDMF 算是比較容易被低估的一個。它不只是「跨應用共享資料」,更像是給應用之間約了一套聽得懂的話。這個約定做紮實了,後面做拖曳、富文字、檔案流轉,才不會每加一個入口就重寫一遍膠水程式碼。