做跨应用数据流转,最容易写歪。
我之前接过一个看起来挺小的需求:在一个资料管理类应用里,用户长按一张资料卡片,可以把标题、摘要、来源链接带到另一个应用里;如果接收方是富文本编辑器,就尽量保留链接;如果只是普通输入框,至少要落成一段可读文本。产品说得很轻松,"就跟复制粘贴差不多"。真写起来才发现,复制一段字符串只是最糙的那种做法。
一开始我们做得也简单:把业务对象转成 JSON,再塞到剪贴板或者路由参数里。自己应用内跳转没问题,一跨应用就开始出幺蛾子:有的地方只能拿到纯文本,有的地方把 JSON 原样贴出来,文件路径到了接收方读不了,用户一取消操作,页面状态还以为已经分享成功。更麻烦的是,后来又加了拖拽入口,同一份数据要走复制、拖拽、分享面板几套逻辑,越写越像补丁。
这类场景就不太适合继续"拼字符串"。HarmonyOS 里 UDMF(Unified Data Management Framework,统一数据管理框架)真正有价值的地方,不是让你少写几行代码,而是把跨应用流转这件事变成一套标准化数据契约:数据是什么类型、里面有哪些记录、接收方怎么识别、失败时怎么回退,都可以在一条链路里管住。

为什么 UDMF 值得单独拿出来讲
很多人第一次看 UDMF,会把它理解成"一个跨应用临时存储区"。这个理解不算完全错,但会把工程设计带偏。
如果只是临时存储,那就很容易写成这样:
ts
// 不推荐:把业务对象直接塞成一段 JSON 字符串
const text = JSON.stringify(card)
然后接收方再尝试 JSON.parse。自己家的两个应用也许能跑,换成系统输入框、文档应用、备忘录、第三方编辑器,就完全没法保证体验。对方不关心你内部的字段名,它只关心"这是不是一段纯文本""这是不是一个链接""这是不是一个文件"。
UDMF 要解决的是这个问题:用统一数据对象 UnifiedData 承载一组标准化记录,比如纯文本、超链接、文件、图片等。数据提供方负责把业务对象翻译成这些标准记录;数据访问方按统一数据类型去识别,而不是按业务字段硬猜。
工程上我更愿意把它看成四层:
- 业务对象层:
ShareCard、FileMeta、ContactBrief这种自己应用内部的数据。 - 标准化记录层:把业务对象拆成 PlainText、Hyperlink、File 等接收方能理解的记录。
- 数据通路层:通过 UDMF 写入、查询、更新、删除。
- UI 状态层:只关心"正在准备、已写入、失败、已清理",不要直接抱着业务对象乱传。
这几个层次分开以后,后面加拖拽、复制、粘贴、跨应用读取,才不会每个入口都重新写一套转换逻辑。
先定一份业务侧的数据契约
我不太建议一上来就写 UDMF API。先把你真正要流转的业务数据收窄。跨应用数据不是数据库同步,别想着把整个详情页对象都丢出去。
比如资料卡片可以压成这样:
ts
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
// 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
// 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
// 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
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
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 算是比较容易被低估的一个。它不只是"跨应用共享数据",更像是给应用之间约了一套听得懂的话。这个约定做扎实了,后面做拖拽、富文本、文件流转,才不会每加一个入口就重写一遍胶水代码。