别再拼 JSON 了:HarmonyOS UDMF 跨应用数据流转实践

0 阅读11分钟

做跨应用数据流转,最容易写歪。

我之前接过一个看起来挺小的需求:在一个资料管理类应用里,用户长按一张资料卡片,可以把标题、摘要、来源链接带到另一个应用里;如果接收方是富文本编辑器,就尽量保留链接;如果只是普通输入框,至少要落成一段可读文本。产品说得很轻松,“就跟复制粘贴差不多”。真写起来才发现,复制一段字符串只是最糙的那种做法。

一开始我们做得也简单:把业务对象转成 JSON,再塞到剪贴板或者路由参数里。自己应用内跳转没问题,一跨应用就开始出幺蛾子:有的地方只能拿到纯文本,有的地方把 JSON 原样贴出来,文件路径到了接收方读不了,用户一取消操作,页面状态还以为已经分享成功。更麻烦的是,后来又加了拖拽入口,同一份数据要走复制、拖拽、分享面板几套逻辑,越写越像补丁。

这类场景就不太适合继续“拼字符串”。HarmonyOS 里 UDMF(Unified Data Management Framework,统一数据管理框架)真正有价值的地方,不是让你少写几行代码,而是把跨应用流转这件事变成一套标准化数据契约:数据是什么类型、里面有哪些记录、接收方怎么识别、失败时怎么回退,都可以在一条链路里管住。

image.png

为什么 UDMF 值得单独拿出来讲

很多人第一次看 UDMF,会把它理解成“一个跨应用临时存储区”。这个理解不算完全错,但会把工程设计带偏。

如果只是临时存储,那就很容易写成这样:

// 不推荐:把业务对象直接塞成一段 JSON 字符串
const text = JSON.stringify(card)

然后接收方再尝试 JSON.parse。自己家的两个应用也许能跑,换成系统输入框、文档应用、备忘录、第三方编辑器,就完全没法保证体验。对方不关心你内部的字段名,它只关心“这是不是一段纯文本”“这是不是一个链接”“这是不是一个文件”。

UDMF 要解决的是这个问题:用统一数据对象 UnifiedData 承载一组标准化记录,比如纯文本、超链接、文件、图片等。数据提供方负责把业务对象翻译成这些标准记录;数据访问方按统一数据类型去识别,而不是按业务字段硬猜。

工程上我更愿意把它看成四层:

  • 业务对象层:ShareCardFileMetaContactBrief 这种自己应用内部的数据。
  • 标准化记录层:把业务对象拆成 PlainText、Hyperlink、File 等接收方能理解的记录。
  • 数据通路层:通过 UDMF 写入、查询、更新、删除。
  • UI 状态层:只关心“正在准备、已写入、失败、已清理”,不要直接抱着业务对象乱传。

这几个层次分开以后,后面加拖拽、复制、粘贴、跨应用读取,才不会每个入口都重新写一套转换逻辑。

先定一份业务侧的数据契约

我不太建议一上来就写 UDMF API。先把你真正要流转的业务数据收窄。跨应用数据不是数据库同步,别想着把整个详情页对象都丢出去。

比如资料卡片可以压成这样:

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 认识的数据。

// 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 管理都收进去。

// 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 里面到底塞了什么。它只负责触发动作、展示状态、处理失败提示。

// 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

实际代码里可以对应成这样:

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 更接近真实情况。建议做几个小护栏:

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 算是比较容易被低估的一个。它不只是“跨应用共享数据”,更像是给应用之间约了一套听得懂的话。这个约定做扎实了,后面做拖拽、富文本、文件流转,才不会每加一个入口就重写一遍胶水代码。