阅读视图

发现新文章,点击刷新页面。

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

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

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

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

别让一张 12MB 的照片拖垮页面:ImageSource / PixelMap / ImagePacker 的工程化处理链路

前阵子做一个图片标注功能,需求听起来很简单:用户从相册里选一张图,加一层轻量处理,页面上能预览,点保存以后导出一张新图。

刚开始我也没太当回事。图片选择器拿到路径,页面里解码,拿到 PixelMap,再做一点像素改写,最后用 ImagePacker 编码。跑 demo 很顺,换到真机上的 5000px 原图,问题就出来了:预览偶发卡顿,连续点两次保存会生成黑图,页面返回以后内存没有立刻下来,有时日志里还夹着一堆不稳定的 BusinessError。

后来把这块重新拆了一遍,我的感受是:HarmonyOS 上做图片处理,不能把它当成“一个 API 调一下”的事情。它更像一条小型流水线,ImageSource 管解码入口,PixelMap 管内存里的像素对象,ImagePacker 管重新编码。中间任何一步偷懒,页面上看起来就是卡、黑、慢、偶现。

image.png

图片处理不是 UI 逻辑,别直接堆在 Page 里

我见过不少项目这么写:在页面 onClick 里选图,选完直接 createImageSource,然后 createPixelMap,处理完塞给 Image 组件展示。功能能跑,但后面会变得很难维护。

原因很直接:页面关心的是状态,图片链路关心的是资源。

页面需要知道:现在是不是处理中、预览图是什么、保存成功没有、失败原因能不能给用户看。图片链路需要知道:源图尺寸多大、是否需要降采样、像素格式是什么、PixelMap 什么时候释放、编码失败怎么兜底。

这两类事情混在一个 @Component 里,调试时会特别痛苦。尤其是用户连续选择、连续保存、处理中返回页面这几种场景,页面状态和底层对象生命周期很容易错位。

我现在比较习惯把结构拆成这样:

entry/src/main/ets/
├── common/
│   └── image/
│       ├── ImageJob.ets          // 任务参数、状态、错误码
│       ├── ImagePipeline.ets     // 解码、像素处理、编码
│       └── ImageReleaseBag.ets   // 统一释放对象
└── pages/
    └── ImageEditPage.ets         // 只处理 UI 状态

页面不直接碰 ImageSourceImagePackerPixelMap 如果要用于预览,可以短时间交给页面持有,但持有权要说清楚:谁创建,谁释放;谁交给 UI,谁在页面退出时兜底释放。

先把三个角色分清楚

ImageSource 是图片源。它适合做两件事:读图片基本信息、按解码参数创建 PixelMap。这里最值得注意的是,不要上来就把原图完整解到内存里。移动端相机图动不动几千像素宽,真按 RGBA 展开,内存占用不是文件大小那点数。

PixelMap 是内存里的像素对象。它不是普通字符串,也不是轻量 DTO。你可以把它交给 Image 显示,也可以读取像素缓冲区做算法处理,但用完要释放。图片类问题里很多“偶现”都和它有关:重复引用、跨页面持有、失败分支忘了释放、预览图和导出图混用。

ImagePacker 是重新编码。它负责把处理后的 PixelMap 编成 JPEG、PNG、WebP、HEIC 这类可保存、可上传、可分享的数据。这里别只关注 quality,还要考虑输出格式、文件体积、透明通道、保存路径、编码失败后的清理。

这三个角色分清楚以后,代码会自然变成管线,而不是一坨页面回调。

一条更稳的处理链路

我的习惯是把图片任务拆成五步:

输入源 -> 读取图片信息 -> 按目标尺寸解码 -> PixelMap 处理 -> 编码输出

这里有个小取舍:预览和导出不一定要用同一张 PixelMap

用户刚选完图,最重要的是页面别空着。可以先解一张长边 1280 左右的预览图,马上给 UI;用户真正点保存时,再按业务需要解更高质量的版本。很多时候用户只是看一眼效果,并不会保存。为了一个可能不会发生的保存动作,提前把原图完整处理一遍,体验上并不划算。

下面这段是我会放到 ImageJob.ets 里的基础类型。实际项目可以再细分错误码,这里保留核心结构。

// common/image/ImageJob.ets
import { image } from '@kit.ImageKit';

export enum ImageJobState {
  IDLE = 'IDLE',
  DECODING = 'DECODING',
  PROCESSING = 'PROCESSING',
  ENCODING = 'ENCODING',
  DONE = 'DONE',
  FAILED = 'FAILED'
}

export interface ImageProcessOptions {
  // 预览建议 1280~1600,导出按业务再放大
  maxSide: number;
  // 是否允许改写像素
  editable: boolean;
  // 导出质量,JPEG/WebP 有意义
  quality: number;
  // 输出格式,例如 image/jpeg、image/png
  format: string;
}

export interface ImageProcessResult {
  jobId: number;
  width: number;
  height: number;
  data: ArrayBuffer;
}

export interface ImageRuntimeState {
  jobId: number;
  state: ImageJobState;
  message?: string;
  preview?: image.PixelMap;
}

jobId 看着不起眼,实际很有用。用户连续选两张图时,第一张图的任务可能后返回。如果没有 jobId,旧任务会把新页面状态覆盖掉,表现出来就是“明明选了 B 图,预览忽然跳回 A 图”。

解码前先读尺寸,别赌设备内存

下面是管线里最关键的一段:先用 ImageSource 读取图片信息,再决定解码尺寸。

// common/image/ImagePipeline.ets
import { image } from '@kit.ImageKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { ImageProcessOptions, ImageProcessResult } from './ImageJob';

export class ImagePipeline {
  async runToEncodedData(
    jobId: number,
    filePath: string,
    options: ImageProcessOptions
  ): Promise<ImageProcessResult> {
    let source: image.ImageSource | undefined = undefined;
    let pixelMap: image.PixelMap | undefined = undefined;
    let packer: image.ImagePacker | undefined = undefined;

    try {
      source = image.createImageSource(filePath);

      const info = await source.getImageInfo();
      const decodingOptions = this.buildDecodingOptions(info, options);

      pixelMap = await source.createPixelMap(decodingOptions);

      if (options.editable) {
        await this.applySoftGray(pixelMap);
      }

      const imageInfo = await pixelMap.getImageInfo();
      packer = image.createImagePacker();

      const data = await packer.packToData(pixelMap, {
        format: options.format,
        quality: options.quality
      });

      return {
        jobId,
        width: imageInfo.size.width,
        height: imageInfo.size.height,
        data
      };
    } catch (err) {
      const e = err as BusinessError;
      throw new Error(`图片处理失败:${e.code ?? '-'} ${e.message ?? ''}`);
    } finally {
      // 注意:如果 PixelMap 已经交给 UI 展示,不要在这里释放。
      // 本方法返回的是编码数据,PixelMap 只在管线内部使用,所以这里可以释放。
      await this.safeReleasePixelMap(pixelMap);
      await this.safeReleaseImageSource(source);
      await this.safeReleasePacker(packer);
    }
  }

  private buildDecodingOptions(
    info: image.ImageInfo,
    options: ImageProcessOptions
  ): image.DecodingOptions {
    const width = info.size.width;
    const height = info.size.height;
    const maxSide = Math.max(width, height);
    const ratio = maxSide > options.maxSide ? options.maxSide / maxSide : 1;

    return {
      desiredSize: {
        width: Math.max(1, Math.floor(width * ratio)),
        height: Math.max(1, Math.floor(height * ratio))
      },
      desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
      editable: options.editable
    };
  }

  private async safeReleasePixelMap(pixelMap?: image.PixelMap): Promise<void> {
    if (!pixelMap) {
      return;
    }
    try {
      await pixelMap.release();
    } catch (_) {
      // release 失败不再向上抛,避免覆盖主错误
    }
  }

  private async safeReleaseImageSource(source?: image.ImageSource): Promise<void> {
    if (!source) {
      return;
    }
    try {
      await source.release();
    } catch (_) {}
  }

  private async safeReleasePacker(packer?: image.ImagePacker): Promise<void> {
    if (!packer) {
      return;
    }
    try {
      await packer.release();
    } catch (_) {}
  }
}

这段代码有几个点我会坚持保留。

getImageInfo() 要放在真正解码之前。它不是为了“显示图片尺寸”这么简单,而是为了决定这张图该不该被完整解码。只要业务不是专业修图,很多场景根本不需要原图级像素进入页面。

desiredPixelFormat 尽量明确写出来。后面如果要读写像素,像素格式不明确,处理函数就会变成猜谜。你以为自己按 RGBA 读,实际格式不一致,轻则偏色,重则整张图异常。

finally 里做释放。不要只在成功分支释放,也不要只在页面退出时释放。图片处理链路的失败分支很多:源文件不可读、格式不支持、解码失败、像素写回失败、编码失败。每个分支都指望业务代码记得释放,最后一定会漏。

像素改写:少做花活,先把格式和范围管住

下面这个 applySoftGray 只是示例:读取像素缓冲区,把图片轻微降饱和,再写回 PixelMap。实际项目里可以替换成水印、马赛克、局部遮挡、截图隐私高亮等逻辑。

// common/image/ImagePipeline.ets 片段
private async applySoftGray(pixelMap: image.PixelMap): Promise<void> {
  const bytes = pixelMap.getPixelBytesNumber();
  if (bytes <= 0) {
    return;
  }

  const buffer = new ArrayBuffer(bytes);
  await pixelMap.readPixelsToBuffer(buffer);

  const data = new Uint8Array(buffer);

  // 前面解码时指定了 RGBA_8888,这里才敢按 4 字节步长处理。
  for (let i = 0; i + 3 < data.length; i += 4) {
    const r = data[i];
    const g = data[i + 1];
    const b = data[i + 2];

    // 整数近似亮度,少一点浮点运算开销。
    const gray = (r * 77 + g * 150 + b * 29) >> 8;

    // 不做纯灰,保留一点原图色彩,预览观感会自然些。
    data[i] = Math.floor(r * 0.82 + gray * 0.18);
    data[i + 1] = Math.floor(g * 0.82 + gray * 0.18);
    data[i + 2] = Math.floor(b * 0.82 + gray * 0.18);
    // data[i + 3] 是 alpha,这里不动。
  }

  await pixelMap.writeBufferToPixels(buffer);
}

这类代码不要一上来就追求“算法高级”。先把三件事做好:格式明确、边界明确、失败可退。

如果是局部处理,不一定非要整图读出来。能按区域读写就按区域做。整张 4000 × 3000 的 RGBA 图,一次缓冲区就是四十多 MB,用户多点两次,内存曲线立刻难看。

还有一个细节:不要在 UI 线程里连续做重像素循环。轻量预览可以接受,重处理要么降尺寸,要么拆任务,要么把保存动作放到用户真正确认之后。很多图片需求不是不能做,是不该在用户刚进入页面时就全做。

页面侧只订阅状态,不接管管线

页面可以很薄。它负责启动任务、展示状态、处理过期结果。

// pages/ImageEditPage.ets
import { image } from '@kit.ImageKit';
import { ImagePipeline } from '../common/image/ImagePipeline';
import { ImageJobState, ImageRuntimeState } from '../common/image/ImageJob';

@Entry
@Component
struct ImageEditPage {
  private pipeline: ImagePipeline = new ImagePipeline();
  private currentJobId: number = 0;

  @State runtime: ImageRuntimeState = {
    jobId: 0,
    state: ImageJobState.IDLE
  };

  async startExport(filePath: string): Promise<void> {
    const jobId = Date.now();
    this.currentJobId = jobId;
    this.runtime = {
      jobId,
      state: ImageJobState.DECODING,
      message: '正在处理图片...'
    };

    try {
      const result = await this.pipeline.runToEncodedData(jobId, filePath, {
        maxSide: 1920,
        editable: true,
        quality: 88,
        format: 'image/jpeg'
      });

      // 旧任务后返回,直接丢弃,不要覆盖新图状态。
      if (result.jobId !== this.currentJobId) {
        return;
      }

      this.runtime = {
        jobId,
        state: ImageJobState.DONE,
        message: `导出完成:${result.width} × ${result.height}`
      };

      // result.data 可以继续写文件、上传或进入分享链路。
    } catch (err) {
      if (jobId !== this.currentJobId) {
        return;
      }
      this.runtime = {
        jobId,
        state: ImageJobState.FAILED,
        message: `${err}`
      };
    }
  }

  build() {
    Column({ space: 16 }) {
      Text('图片处理示例')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)

      Text(this.runtime.message ?? '请选择图片')
        .fontSize(14)
        .fontColor('#666666')

      Button(this.runtime.state === ImageJobState.DECODING ? '处理中...' : '开始导出')
        .enabled(this.runtime.state !== ImageJobState.DECODING)
        .onClick(() => {
          // 示例里省略选择器代码,真实项目里传入 picker 返回的沙箱路径或文件路径。
          this.startExport('/data/storage/el2/base/haps/entry/files/demo.jpg');
        })
    }
    .width('100%')
    .height('100%')
    .padding(20)
  }
}

这里用了一个很土但很好用的判断:result.jobId !== this.currentJobId 就丢。别觉得它简陋,线上很多“图片串了”的问题,就是没有这个判断。

如果你还要做预览,建议单独做 decodePreview(),返回 PixelMap 给页面持有。页面退出时释放它,不要让预览图跟导出任务共用同一个对象。

// 页面持有预览 PixelMap 时,退出页面要主动释放
aboutToDisappear(): void {
  const preview: image.PixelMap | undefined = this.runtime.preview;
  if (preview) {
    preview.release();
  }
}

常见坑位:不是 API 难,是边界太多

image.png

1. 原图直接解码,内存曲线很快失控

图片文件 12MB,不代表解码后只占 12MB。JPEG 是压缩格式,进到 PixelMap 后按像素展开。粗略估算一下:

4000 × 3000 × 4 ≈ 48MB

再加上缓冲区、编码临时对象、页面预览引用,内存压力很容易上去。预览场景一定要限制长边。导出场景也要问清业务:是真的需要原图尺寸,还是只是“看起来清楚”。

2. ImageSource 复用过头,容易把并发搞乱

ImageSource 适合一次任务内部使用,不建议做成全局单例复用。尤其是同一个页面可能连续处理多张图时,每张图单独创建、单独释放,反而更稳。

如果业务上要做队列,也不要让多个任务同时操作同一个 ImageSource。图片链路里共享对象越少,问题越好定位。

3. PixelMap 给了 UI,就别在管线里顺手 release

这是一个很常见的黑图来源。

有时为了预览,会把 PixelMap 直接赋给 Image 组件。这个时候它的生命周期就已经被页面接管了。管线函数如果在 finally 里顺手 release(),UI 还没来得及渲染,底层资源已经没了。

我的规则是:

返回 ArrayBuffer / 文件路径:管线内部 release PixelMap
返回 PixelMap 给 UI:页面负责 release PixelMap

不要两边都管,也不要两边都不管。

4. 编码格式别乱选

JPEG 适合照片,体积小,但没有透明通道。PNG 适合透明图、截图、图标类内容,但照片体积可能比较大。WebP 适合压缩收益更明显的业务,HEIC 则要看你的分发和兼容要求。

做头像、封面、帖子图片这类业务,我一般会给一层策略:

export function chooseOutputFormat(hasAlpha: boolean, isPhoto: boolean): string {
  if (hasAlpha) {
    return 'image/png';
  }
  if (isPhoto) {
    return 'image/jpeg';
  }
  return 'image/webp';
}

这段只是策略示意,项目里还要看上传服务、审核服务、分享链路是否支持对应格式。

5. 失败提示别把 BusinessError 原样甩给用户

日志里保留错误码,界面上给人话。

export function toUserMessage(err: Error): string {
  const text = `${err.message ?? err}`;
  if (text.includes('decode')) {
    return '图片读取失败,可以换一张图片试试';
  }
  if (text.includes('pack') || text.includes('encode')) {
    return '图片保存失败,请稍后重试';
  }
  return '图片处理失败,请重新选择图片';
}

调试时你当然需要完整堆栈,但用户不需要看到一串模块名。这个细节对工具类应用尤其重要,很多人并不关心你底层用了哪个 API,他只关心这张图为什么没保存上。

稳定性优化:把“能跑”变成“敢上线”

我会给图片链路加几条硬规则。

长边限制要前置。 预览和导出用不同配置。预览不超过 1280 或 1600,导出按业务走 1920、2560 或原图。别用一个配置打天下。

页面状态要可取消。 HarmonyOS 里异步任务返回顺序不可控,用户操作更不可控。jobIdcancelToken、旧结果丢弃,这些东西写起来不高级,但能挡住很多线上问题。

像素处理要有预算。 你处理的是 width × height 的数据,不是一个普通数组。每多一次整图遍历,耗时和耗电都会上去。能局部处理就局部处理,能复用缓冲区就不要重复申请。

释放必须统一。 不要在十几个 catch 里散着写 release()。写一个 ReleaseBag 也行,写 safeReleaseXxx 也行,总之要能保证失败分支不漏。

保存和预览拆开。 用户选图后的第一秒要让他看到东西,不要让完整导出流程挡住首屏。预览可以轻,保存可以慢一点,只要进度提示清楚。

一个 ReleaseBag 的小封装

项目稍微复杂一点,我会用一个小工具收口释放逻辑。它不复杂,但能减少很多漏网之鱼。

// common/image/ImageReleaseBag.ets
export interface Releasable {
  release(): Promise<void>;
}

export class ImageReleaseBag {
  private items: Releasable[] = [];

  add<T extends Releasable | undefined>(item: T): T {
    if (item) {
      this.items.push(item);
    }
    return item;
  }

  async releaseAll(): Promise<void> {
    for (let i = this.items.length - 1; i >= 0; i--) {
      try {
        await this.items[i].release();
      } catch (_) {}
    }
    this.items = [];
  }
}

管线里就可以这样用:

const bag = new ImageReleaseBag();

try {
  const source = bag.add(image.createImageSource(filePath));
  const pixelMap = bag.add(await source.createPixelMap(decodingOptions));
  const packer = bag.add(image.createImagePacker());

  return await packer.packToData(pixelMap, {
    format: 'image/jpeg',
    quality: 88
  });
} finally {
  await bag.releaseAll();
}

但还是那句话:如果 PixelMap 要返回给 UI,就不要放进这个 bag。释放权一定要跟对象去向绑定。

适合落地的场景

这条链路不只适合“图片滤镜”。很多业务都能用上。

比如截图整理工具,导入截图后先生成预览,再做敏感区域遮挡,最后导出一张可分享图。比如医疗、教育、金融类应用,用户上传凭证前需要压缩和脱敏。比如内容社区,发帖前统一限制尺寸和质量,减少上传失败率。再比如元服务或卡片场景,只需要轻量缩略图,完全没必要把原图处理链路塞进去。

我个人最推荐的落地方式是:把图片处理封成一个内部基础能力,不要散落在各个页面。等第二个、第三个页面也要选图压缩时,你会感谢前面那个多写半小时封装的自己。

收个尾

ImageSource / PixelMap / ImagePacker 这套东西并不难用,难的是工程边界。

小 demo 里,选图、处理、保存写在一个按钮回调里,看起来很直观。真到项目里,大图、重复点击、页面返回、编码失败、内存释放、预览和导出的质量差异都会一起冒出来。

我的经验是:别把图片处理写成页面逻辑。把它当成一条管线,输入、解码、像素处理、编码、释放,每一步都有自己的边界。代码不会显得多炫,但上线以后会稳很多。

别把耗时任务都丢进 async:HarmonyOS 里 TaskPool 和 Worker 的边界感

上个月做一个数据整理页,页面本身不复杂:本地库里拉一批记录,按规则清洗,再生成一份可展示的分组列表。逻辑写起来很顺,async/await 一套下来,代码看着也挺规整。

问题是,上真机之后不对劲。

页面第一次进入会有一个很短的卡顿,列表滚动到一半偶尔掉帧,点筛选时按钮反馈慢半拍。最开始我还以为是 ArkUI 列表写得不够克制,后来把日志打细一点才发现,真正拖后腿的是那段“看起来只是处理数组”的同步计算。

async 不是多线程。这个坑,做前端或者移动端的人应该都踩过。它能把异步流程写得舒服一点,但 CPU 真在主线程上跑的时候,UI 该卡还是卡。

后来这块我拆成了两层:短任务走 TaskPool,长活儿交给 Worker。不是为了显得架构高级,纯粹是被卡顿逼出来的。

image.png

为什么这事值得单独拿出来讲

HarmonyOS 里聊并发,很多文章会直接给一个 TaskPool 示例:写一个 @Concurrent 函数,丢给 taskpool.execute(),拿到结果更新 UI。这个例子没问题,但如果项目稍微复杂一点,真正难的不是“怎么调 API”,而是下面几个问题:

  • 哪些任务适合 TaskPool,哪些任务别塞进去;
  • 并发任务里传什么数据,别把 UI 状态、Context、复杂对象乱丢;
  • 任务结果回来时,页面可能已经销毁了,怎么避免回写脏状态;
  • 用户连续点击筛选、搜索、刷新时,旧任务怎么处理;
  • Worker 用完不释放,内存和线程会悄悄把你坑了。

我现在的判断比较简单:

TaskPool 适合“短、散、可切分”的计算任务。Worker 适合“长、独立、有自己状态”的后台任务。

比如:

场景 更合适的方式 原因
列表数据清洗、排序、分组 TaskPool 任务短,输入输出清晰,不想维护线程生命周期
多段文本规则匹配 TaskPool / TaskGroup 可以拆成多份并行处理,再聚合结果
长时间日志解析 Worker 任务持续时间长,可能需要进度、暂停、取消
持续 OCR 队列、文件同步队列 Worker 有队列状态,生命周期独立,不能每次都临时起任务
UI 动画、组件状态修改 主线程 后台线程不要直接碰 UI

这篇不打算写成 API 字典。就按一个“本地数据整理页”的例子,把我最后落地的写法拆出来。

核心思路:别直接把业务对象扔进后台线程

当时页面里的数据大概长这样:

export interface RawRecord {
  id: string;
  title: string;
  type: string;
  createdAt: number;
  rawText: string;
  score?: number;
}

export interface ViewSection {
  groupName: string;
  count: number;
  items: ViewItem[];
}

export interface ViewItem {
  id: string;
  title: string;
  summary: string;
  level: 'low' | 'middle' | 'high';
}

一开始我犯过一个懒:从页面状态里直接拿数组,塞给后台任务。后面越改越别扭,因为页面对象里混进了不少展示状态,比如是否展开、是否选中、临时高亮字段。这些东西对计算没用,传过去还容易把边界搞脏。

后来我改成了三步:

  1. 主线程只准备“纯输入数据”;
  2. TaskPool 只做纯计算,不知道页面存在;
  3. 结果回来后,再由页面决定是否更新状态。

这个拆法有点啰嗦,但后面排问题会轻松很多。

用 TaskPool 处理一次短计算

先看一个最小可用的版本。

// common/model/record.ts
export interface RawRecord {
  id: string;
  title: string;
  type: string;
  createdAt: number;
  rawText: string;
  score?: number;
}

export interface ViewItem {
  id: string;
  title: string;
  summary: string;
  level: string;
}

export interface ViewSection {
  groupName: string;
  count: number;
  items: ViewItem[];
}
// common/worker/record_task.ts
import { RawRecord, ViewItem, ViewSection } from '../model/record';

function buildSummary(text: string): string {
  if (text.length <= 42) {
    return text;
  }
  return `${text.substring(0, 42)}...`;
}

function calcLevel(score: number): string {
  if (score >= 80) {
    return 'high';
  }
  if (score >= 50) {
    return 'middle';
  }
  return 'low';
}

// 注意:TaskPool 执行的函数要标注 @Concurrent。
// 这里尽量保持纯函数:不读页面状态,不操作 UI,不拿 Context。
@Concurrent
export function buildRecordSections(records: RawRecord[]): ViewSection[] {
  const map = new Map<string, ViewItem[]>();

  for (const record of records) {
    const groupName = record.type.length > 0 ? record.type : '未分类';
    const item: ViewItem = {
      id: record.id,
      title: record.title,
      summary: buildSummary(record.rawText),
      level: calcLevel(record.score ?? 0)
    };

    const list = map.get(groupName) ?? [];
    list.push(item);
    map.set(groupName, list);
  }

  const sections: ViewSection[] = [];
  map.forEach((items: ViewItem[], groupName: string) => {
    items.sort((a: ViewItem, b: ViewItem) => a.title.localeCompare(b.title));
    sections.push({
      groupName,
      count: items.length,
      items
    });
  });

  sections.sort((a: ViewSection, b: ViewSection) => b.count - a.count);
  return sections;
}

页面里不要直接到处散落 taskpool.execute()。我一般会再包一层服务类,这样后面做取消、降级、日志都会方便一点。

// common/service/RecordComputeService.ts
import { taskpool } from '@kit.ArkTS';
import { RawRecord, ViewSection } from '../model/record';
import { buildRecordSections } from '../worker/record_task';

export class RecordComputeService {
  async buildSections(records: RawRecord[]): Promise<ViewSection[]> {
    if (records.length === 0) {
      return [];
    }

    // 只传纯数据。这里不要传 this,不要传组件对象,不要传 UI 状态。
    const task = new taskpool.Task('build-record-sections', buildRecordSections, records);
    const result = await taskpool.execute(task, taskpool.Priority.MEDIUM);

    return result as ViewSection[];
  }
}

页面调用时,要特别注意“结果回来时页面还在不在”。这个问题很常见,尤其是用户快速返回、切 tab、重复进入页面的时候。

// pages/RecordPage.ets
import { RecordComputeService } from '../common/service/RecordComputeService';
import { RawRecord, ViewSection } from '../common/model/record';

@Entry
@Component
struct RecordPage {
  private computeService: RecordComputeService = new RecordComputeService();
  private alive: boolean = true;
  private requestSeq: number = 0;

  @State loading: boolean = false;
  @State sections: ViewSection[] = [];
  @State errorText: string = '';

  aboutToDisappear(): void {
    this.alive = false;
  }

  async reload(records: RawRecord[]): Promise<void> {
    const seq = ++this.requestSeq;
    this.loading = true;
    this.errorText = '';

    try {
      const result = await this.computeService.buildSections(records);

      // 页面走了,或者后一次请求已经发出,旧结果就不要回写了。
      if (!this.alive || seq !== this.requestSeq) {
        return;
      }

      this.sections = result;
    } catch (err) {
      if (this.alive && seq === this.requestSeq) {
        this.errorText = `数据整理失败:${JSON.stringify(err)}`;
      }
    } finally {
      if (this.alive && seq === this.requestSeq) {
        this.loading = false;
      }
    }
  }

  build() {
    Column() {
      if (this.loading) {
        Text('整理中...')
          .fontSize(14)
          .opacity(0.7)
      }

      if (this.errorText.length > 0) {
        Text(this.errorText)
          .fontColor(Color.Red)
          .fontSize(13)
      }

      List() {
        ForEach(this.sections, (section: ViewSection) => {
          ListItem() {
            Column() {
              Text(`${section.groupName} · ${section.count}`)
                .fontSize(16)
                .fontWeight(FontWeight.Medium)

              ForEach(section.items, item => {
                Text(`${item.title} - ${item.summary}`)
                  .fontSize(13)
                  .opacity(0.75)
              }, item => item.id)
            }
          }
        }, (section: ViewSection) => section.groupName)
      }
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .padding(16)
  }
}

这段代码看着普通,但有两个点是我后来才养成习惯的:

一个是 requestSeq 只要页面上有搜索、筛选、刷新这种连续触发的入口,就别相信异步返回顺序。旧任务慢一点回来,把新结果覆盖掉,这种 bug 很烦,而且不好复现。

另一个是 alive 页面消失之后继续更新 @State,有时候不会马上炸,但它会把状态链路搞得很脏。尤其在复杂页面里,后面会出现一些莫名其妙的刷新。

多个短任务:TaskGroup 比自己 Promise.all 更稳一点

如果一批数据特别大,我不太建议把整个大数组一次性塞进去。更稳的方式是按业务边界切块,比如按月份、按类型、按文件批次拆开。

// common/worker/record_task.ts
@Concurrent
export function buildRecordSectionsByChunk(records: RawRecord[], chunkName: string): ViewSection[] {
  const sections = buildRecordSections(records);

  // 给结果带一点来源信息,方便聚合和排查。
  return sections.map((section: ViewSection) => {
    return {
      groupName: `${chunkName}/${section.groupName}`,
      count: section.count,
      items: section.items
    } as ViewSection;
  });
}
// common/service/RecordComputeService.ts
import { taskpool } from '@kit.ArkTS';
import { RawRecord, ViewSection } from '../model/record';
import { buildRecordSectionsByChunk } from '../worker/record_task';

export interface RecordChunk {
  name: string;
  records: RawRecord[];
}

export class RecordComputeService {
  async buildSectionsByChunks(chunks: RecordChunk[]): Promise<ViewSection[]> {
    if (chunks.length === 0) {
      return [];
    }

    const group = new taskpool.TaskGroup();

    for (const chunk of chunks) {
      // 每一块都是独立输入,避免任务之间共享可变对象。
      group.addTask(buildRecordSectionsByChunk, chunk.records, chunk.name);
    }

    const result = await taskpool.execute(group, taskpool.Priority.MEDIUM) as Object[];
    const merged: ViewSection[] = [];

    for (const item of result) {
      const sections = item as ViewSection[];
      merged.push(...sections);
    }

    return merged;
  }
}

这里有个小经验:不要为了并发而切得太碎。

我试过把几千条记录拆成几十个小任务,结果并没有更快,调度、序列化、结果聚合的开销反而上来了。后来按“每块几百到一两千条”粗粒度切,整体更稳。

这个数字不是标准答案,要看数据结构、算法复杂度和设备性能。我的习惯是先保守切,真有性能问题再用日志和耗时统计说话。

Worker:别拿它当高级版 setTimeout

TaskPool 用起来省心,但它不适合所有场景。

比如有一个截图整理类功能:用户导入一批图片,后台要做 OCR、规则匹配、去重、写库,还要持续返回进度。这个任务不是“算一下就结束”,它有自己的队列、有状态、有重试,还可能持续几十秒。

这种我会放到 Worker。

目录大概这样:

entry/src/main/ets/
├── pages/
│   └── ImportPage.ets
├── workers/
│   └── ImportWorker.ets
└── common/
    ├── model/
    └── service/

主线程创建 Worker:

// common/service/ImportWorkerClient.ts
import { worker, MessageEvents, ErrorEvent } from '@kit.ArkTS';

export interface ImportJob {
  jobId: string;
  files: string[];
}

export interface ImportProgress {
  jobId: string;
  current: number;
  total: number;
  message: string;
}

export class ImportWorkerClient {
  private threadWorker?: worker.ThreadWorker;
  private currentJobId: string = '';

  start(job: ImportJob, onProgress: (progress: ImportProgress) => void, onDone: () => void, onError: (msg: string) => void): void {
    this.currentJobId = job.jobId;

    // Stage 模型下注意 worker 文件路径,不要写成 src/main/ets 的完整路径。
    this.threadWorker = new worker.ThreadWorker('entry/ets/workers/ImportWorker.ets', {
      name: 'import-worker'
    });

    this.threadWorker.onmessage = (event: MessageEvents) => {
      const data = event.data as Record<string, Object>;
      const type = data['type'] as string;
      const jobId = data['jobId'] as string;

      // 旧任务或者脏消息直接丢掉。
      if (jobId !== this.currentJobId) {
        return;
      }

      if (type === 'progress') {
        onProgress(data['payload'] as ImportProgress);
      } else if (type === 'done') {
        onDone();
        this.release();
      } else if (type === 'error') {
        onError(data['message'] as string);
        this.release();
      }
    };

    this.threadWorker.onerror = (error: ErrorEvent) => {
      onError(`Worker 异常:${error.message}`);
      this.release();
    };

    this.threadWorker.postMessage({
      type: 'start',
      jobId: job.jobId,
      files: job.files
    });
  }

  cancel(): void {
    this.threadWorker?.postMessage({
      type: 'cancel',
      jobId: this.currentJobId
    });
    this.release();
  }

  release(): void {
    this.threadWorker?.terminate();
    this.threadWorker = undefined;
    this.currentJobId = '';
  }
}

Worker 文件里只处理后台逻辑:

// workers/ImportWorker.ets
import { worker, MessageEvents } from '@kit.ArkTS';

const workerPort = worker.workerPort;
let canceled = false;

function postProgress(jobId: string, current: number, total: number, message: string): void {
  workerPort.postMessage({
    type: 'progress',
    jobId,
    payload: {
      jobId,
      current,
      total,
      message
    }
  });
}

async function handleImport(jobId: string, files: string[]): Promise<void> {
  canceled = false;

  for (let i = 0; i < files.length; i++) {
    if (canceled) {
      workerPort.postMessage({
        type: 'error',
        jobId,
        message: '用户取消导入'
      });
      return;
    }

    const file = files[i];
    postProgress(jobId, i + 1, files.length, `正在处理:${file}`);

    // 这里放真正的耗时逻辑:OCR、规则匹配、去重、写临时结果等。
    // 示例里只保留结构,不硬凑一个假的算法。
    await doOneFile(file);
  }

  workerPort.postMessage({
    type: 'done',
    jobId
  });
}

async function doOneFile(file: string): Promise<void> {
  // 实际项目里建议继续拆服务,别把所有逻辑堆在 worker 文件里。
  // 这里可以做文件读取、文本分析、批量写入前的数据准备。
  console.info(`processing file: ${file}`);
}

workerPort.onmessage = (event: MessageEvents) => {
  const data = event.data as Record<string, Object>;
  const type = data['type'] as string;
  const jobId = data['jobId'] as string;

  if (type === 'start') {
    const files = data['files'] as string[];
    handleImport(jobId, files).catch((err: Error) => {
      workerPort.postMessage({
        type: 'error',
        jobId,
        message: err.message
      });
    });
  } else if (type === 'cancel') {
    canceled = true;
  }
};

Worker 的麻烦点不是创建,而是收尾

很多问题都出在“我以为它自己会停”。实际上 Worker 更像一个你手动养出来的后台线程:用完要 terminate(),页面退出要释放,任务取消也要释放。否则看不出明显报错,但内存和线程资源会被占着。

image.png

TaskPool 和 Worker 的边界,我一般这么定

项目里我会用下面这几个问题判断。

任务是不是短时间就能结束?

能结束,优先 TaskPool。比如排序、分组、规则计算、数据压缩前处理。

如果任务天然要跑很久,比如持续同步、批量导入、后台队列,就别硬塞 TaskPool。TaskPool 适合把任务交给系统调度,不适合自己在里面写一个长期循环。

任务有没有自己的状态?

没有状态,或者状态只来自输入参数,TaskPool 很舒服。

如果任务里有队列、重试次数、暂停恢复、进度回调、缓存状态,Worker 更清楚。因为这个时候你已经不是在跑一个函数了,而是在维护一个后台执行单元。

是否需要频繁和主线程通信?

TaskPool 也能做任务和宿主线程通信,但如果是持续进度、阶段回传、用户取消、错误恢复这一类,我更倾向 Worker。写起来没那么“漂亮”,但状态关系比较直。

输入输出是不是干净?

后台线程最怕传一堆复杂对象。我的原则是:

能传 number/string/boolean/普通数组/普通对象,就别传带行为的对象。
能传 id,就别传整个业务实体。
能传快照,就别传还会被 UI 修改的引用。

这不是洁癖,是为了少踩坑。

常见坑位

1. 把 async 当成多线程

async/await 只是让异步代码更像同步流程,它不会自动把 CPU 计算挪到后台线程。你在 async 函数里写一个很重的 for 循环,主线程照样要扛。

我现在看到下面这种代码就会警惕:

async function refresh(): Promise<void> {
  const rows = await queryRows();

  // 这里如果数据量大,本质还是主线程同步计算。
  const sections = buildBigSections(rows);

  this.sections = sections;
}

要么把 buildBigSections 拆到 TaskPool,要么在数据源阶段就减小计算量。

2. 后台任务直接操作 UI

不要在 TaskPool 函数或者 Worker 里直接改 @State,也不要传组件实例进去。后台只负责算,UI 更新回到页面层做。

这个边界一旦破了,后面代码会非常难维护。

3. 任务返回顺序覆盖新状态

搜索框输入、筛选条件切换、下拉刷新,都可能造成多个任务同时在路上。不要假设后发的任务一定后回来。

requestSeq 这种写法虽然土,但好用。

const seq = ++this.requestSeq;
const result = await this.computeService.buildSections(records);
if (seq !== this.requestSeq) {
  return;
}
this.sections = result;

4. Worker 忘记 terminate

Worker 不是临时 Promise。页面消失、任务完成、任务失败、用户取消,都要考虑释放。

aboutToDisappear(): void {
  this.importWorkerClient.cancel();
}

当然,cancel() 里不要只发一个取消消息,最好兜底 terminate(),否则异常路径里很容易漏。

5. 任务切得太碎

并发不是越多越快。移动端尤其明显,调度、通信、数据拷贝都有成本。

我一般先找“业务上天然可切”的边界,比如文件、月份、类型、批次。不要为了追求并发,把 1000 条数据切成 1000 个任务。

6. 错误只打日志,不回传状态

后台任务失败时,页面应该知道失败原因。尤其是批量处理类功能,如果只在 Worker 里 console.error,用户看到的就是一个永远转圈的 loading。

建议统一消息结构:

export interface WorkerMessage<T> {
  type: 'progress' | 'done' | 'error';
  jobId: string;
  payload?: T;
  message?: string;
}

别到处临时拼对象,后期很难查。

性能和稳定性上的几个小取舍

数据先瘦身,再进后台线程

别把数据库查出来的完整对象一股脑传给任务。很多字段后台根本用不上。先在主线程做一层轻量映射,只保留计算必需字段。

const input = rows.map((row): RawRecord => {
  return {
    id: row.id,
    title: row.title,
    type: row.type,
    createdAt: row.createdAt,
    rawText: row.rawText,
    score: row.score
  };
});

看着多写了几行,换来的是任务边界清楚,数据传输也更轻。

大任务分段回传,不要憋到最后

用户不怕等几秒,怕的是不知道你在干嘛。长任务放 Worker 时,阶段性回传进度很有必要。

postProgress(jobId, current, total, '正在分析文本');
postProgress(jobId, current, total, '正在去重');
postProgress(jobId, current, total, '正在写入本地结果');

别小看这几行,体验差很多。

给降级路径留位置

后台任务失败时,能不能退回主线程简化处理?能不能只展示部分结果?能不能让用户重新触发?

我一般会给服务层留一个 fallback:

export class RecordComputeService {
  async safeBuildSections(records: RawRecord[]): Promise<ViewSection[]> {
    try {
      return await this.buildSections(records);
    } catch (err) {
      console.error(`TaskPool failed: ${JSON.stringify(err)}`);

      // 数据量很小时可以退回同步计算,大数据量不要硬退。
      if (records.length <= 100) {
        return this.buildSectionsOnMainThread(records);
      }

      throw err;
    }
  }

  private buildSectionsOnMainThread(records: RawRecord[]): ViewSection[] {
    // 可以复用同一套纯函数,或者做一个简化版本。
    // 注意:这里只适合小数据兜底。
    return [];
  }
}

降级不是为了掩盖 bug,是为了不要让用户卡死在一个失败状态里。

日志要带 jobId / taskName

并发问题最怕日志没上下文。

console.info(`[import:${jobId}] start, total=${files.length}`);
console.info(`[import:${jobId}] progress ${current}/${total}`);
console.error(`[import:${jobId}] failed: ${message}`);

线上排查时,这种日志比“start、done、error”有用太多。

适合落地的场景

我觉得 TaskPool + Worker 最适合下面几类 HarmonyOS 应用:

  • 图片、文本、音频类素材整理工具;
  • 本地知识库、截图管家、笔记分析工具;
  • 大列表筛选、分组、排序较重的业务页;
  • 本地文件批处理、导入导出、格式转换;
  • 不想把所有耗时逻辑都塞进 UIAbility 的中大型应用。

如果你的页面只是发个网络请求、展示个表单,那没必要上来就 Worker。并发能力不是装饰品,用早了反而增加复杂度。

但只要你发现页面卡顿来自 CPU 计算,而不是网络等待、组件绘制或者数据库查询,那就该考虑把计算拆出去了。

结尾

TaskPool 和 Worker 这两个东西,真正用顺之后,会改变一点写 HarmonyOS 页面的习惯。

以前写页面,很容易把数据查询、规则计算、状态更新、错误处理都揉在一个组件里。短期确实快,后面只要数据量一上来,卡顿、竞态、脏状态就会一起冒出来。

现在我更愿意把页面当成“状态展示层”:它发起任务,接收结果,处理用户反馈;至于那些费 CPU、耗时间、还可能失败的活儿,放到 TaskPool 或 Worker 后面去。

这不是为了追求所谓架构感。移动端开发很多时候就是这样,不卡的页面看起来没什么技术含量,真卡起来才知道前面省掉的边界,后面都要还。

❌