HarmonyOS ArkTS 把组件截图保存成 PNG 再调系统分享面板的做法

大家好啊,前段时间做了个 MBTI 梗化测试 app,想加个"结果分享"功能------用户测完 16 人格,截图给朋友看。听起来简单,真做下去发现要串三段 API:componentSnapshot 拿 PixelMap → ImagePacker 打 PNG → startAbilityByType('share') 调系统面板。每一段都有坑:fd 要显式关、PixelMap 要 release、分享的 wantParam 结构官方文档没细说。记录一下这条胶水链,顺便分享给有需要的朋友。

一、componentSnapshot:把组件转成 PixelMap

鸿蒙的 @ohos.arkui.componentSnapshot 能把任意 ArkUI 组件(挂着 id 属性的)渲染成一张 PixelMap:

typescript 复制代码
import componentSnapshot from '@ohos.arkui.componentSnapshot'

const snapshot = await componentSnapshot.get('poster-card')
const info = snapshot.getImageInfoSync()
console.log(`size=${info.size.width}x${info.size.height}`)

几个前置条件:

组件要有 id 属性。写法:

typescript 复制代码
Column() {
  // 海报内容
  Text('你的人格: 微笑卷王')
    .fontSize(32)
  // ...
}
.id('poster-card')      // 👈 关键
.width(1080)
.height(1080)

组件可以不在屏幕上 。可以用 .position({ x: -9999 }) 挂到屏幕外做"离屏画布":画一个 1080×1080 的海报组件放到屏外,等需要截图的时候再 componentSnapshot.get(id) 拉数据。这个 trick 让海报的分辨率不受屏幕限制。

返回的是 PixelMap。PixelMap 是鸿蒙的原生图像类型,内存里的像素数据。要存成 PNG 还要走 ImagePacker。

二、ImagePacker:PixelMap → PNG 文件

把 PixelMap 写成 PNG:

typescript 复制代码
import image from '@ohos.multimedia.image'
import fileIo from '@ohos.file.fs'

async function packSnapshotToFile(
  snapshot: image.PixelMap,
  outputPath: string,
): Promise<void> {
  // 1. 打开文件(READ_WRITE + CREATE + TRUNC 覆盖写)
  const file = fileIo.openSync(
    outputPath,
    fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC,
  )

  // 2. 创建 ImagePacker
  const imagePacker = image.createImagePacker()

  try {
    // 3. packToFile(压到 PNG,质量 100)
    await imagePacker.packToFile(snapshot, file.fd, {
      format: 'image/png',
      quality: 100,
    })
  } finally {
    // 4. 释放 ImagePacker + 关文件
    await releasePacker(imagePacker)
    try { fileIo.closeSync(file) } catch (_) {}
  }
}

async function releasePacker(imagePacker: image.ImagePacker): Promise<void> {
  try {
    await imagePacker.release()
  } catch (_) {}
}

要点一堆:

OpenMode 组合 :READ_WRITE | CREATE | TRUNC。三个 flag 加起来是"读写模式、文件不存在就创建、存在就清空"。这是覆盖写的标准组合。少 TRUNC 的话,第二次写文件时会在旧内容上面覆盖------如果新文件更短,老内容的尾巴会残留,PNG 坏掉。

packToFile 接的是 file.fd,不是文件路径。fd 是 fileIo.openSync 返回的 file descriptor,int 类型。

format 是 'image/png'(MIME type 字符串,不是 'png')。JPEG 对应 'image/jpeg'。

quality 对 PNG 无效 。PNG 是无损压缩,quality 参数被忽略。但传 100 也不会报错。真正影响 PNG 大小的是像素尺寸 和图像复杂度,不是 quality。

资源释放顺序 :先 release ImagePacker,再 close File。倒过来容易出 "packer still has reference to fd" 的错。都包在 try { } finally { } 里,确保就算 packToFile 抛错也能清理。

release 用 try/catch 吞错。关资源的错不要再往上抛,否则主逻辑的异常就被覆盖了。鸿蒙的 release 偶尔会抛 "already released" 这种无害错,吞掉继续走。

三、startAbilityByType('share') 调系统面板

拿到 PNG 文件之后,调系统分享面板让用户选目标 app:

typescript 复制代码
import { common } from '@kit.AbilityKit'

function invokeSharePanel(
  context: common.UIAbilityContext,
  fileUri: string,
): void {
  const wantParam: Record<string, Object> = {
    'ability.params.stream': [fileUri] as Object,
    'ability.want.params.uriType': 'general.image' as Object,
  }
  const cb: common.AbilityStartCallback = {
    onError: (_code, _name, _msg) => {
      console.log(`invokeSharePanel err: ${_code} ${_msg}`)
    },
  }
  context.startAbilityByType('share', wantParam, cb)
    .catch((err) => {
      console.log(`startAbilityByType rejected: ${JSON.stringify(err)}`)
    })
}

wantParam 的结构:

  • ability.params.stream:要分享的文件数组 。注意是数组,就算一个文件也要包成 [fileUri]。元素是完整 uri (带 file:// 前缀,不是裸路径)
  • ability.want.params.uriType:资源类型,general.image / general.text / general.file 等

调用方式 :context.startAbilityByType('share', wantParam, cb)。第一个参数固定是 'share'(告诉系统"启动分享类型的 ability"),系统会弹出分享面板让用户选目标 app(微信 / 邮件 / 保存到相册等)。

cb 的 onError :真的用户没装任何支持分享的 app 时会触发。模拟器上也会触发(模拟器没安装社交 app),所以日志里 "silent no-op on emulator" 是预期行为。

fire-and-forget :startAbilityByType 返回一个 Promise,我们不等它 resolve(用户选哪个 app、什么时候关闭面板都和当前流程无关)。但要 .catch 兜 reject,不然 unhandled promise 会污染 hilog。

四、全流程串起来

typescript 复制代码
export async function exportResultPoster(
  context: Context,
  componentId: string,
  personalityCode: string,
): Promise<ExportPosterResult> {
  // 1. 截图
  const snapshot = await componentSnapshot.get(componentId)
  const info = snapshot.getImageInfoSync()
  console.log(`snapshot ${info.size.width}x${info.size.height}`)

  // 2. 文件路径(带 timestamp 防重名)
  const filePath = `${context.filesDir}/nmti-${personalityCode.toLowerCase()}-${Date.now()}.png`

  try {
    // 3. 写 PNG
    await packSnapshotToFile(snapshot, filePath)

    // 4. 调分享面板
    invokeSharePanel(context as common.UIAbilityContext, 'file://' + filePath)

    return { filePath }
  } finally {
    // 5. 释放 PixelMap
    await releaseSnapshot(snapshot)
  }
}

async function releaseSnapshot(pixelMap: image.PixelMap): Promise<void> {
  try {
    await pixelMap.release()
  } catch (_) {}
}

三件事串成一个流:

  1. componentSnapshot.get → PixelMap(在内存里)
  2. packSnapshotToFile → PNG(在 files 目录)
  3. invokeSharePanel → 系统分享面板(用户选目标 app)

三类资源需要三次释放:

  • ImagePacker(在 packSnapshotToFile 内部 finally 里)
  • File fd(在 packSnapshotToFile 内部 finally 里)
  • PixelMap(在 exportResultPoster 的 finally 里)

整体用 try/finally 包裹。截图成功但分享失败,PixelMap 也要释放------finally 保证不漏。

五、文件路径的时间戳 + 小写化

context.filesDir 是沙箱内目录,别的 app 读不到(也就是分享文件只能通过系统面板走 "分享" 流程,不能让别的 app 直接 open path)。

文件名加 timestamp 的原因:同一个用户可能连续分享同一个结果多次,不加 timestamp 两次 open 同一个路径会 TRUNC 掉第一次写的数据,如果第一次的分享面板还开着,用户点击的是老文件但内容已经被覆盖------边界 case 下会出现"分享出去的是半张图"的 bug。timestamp 保证每次写一个新文件。

personalityCode 小写化:'INTJ' → 'intj'。路径里的大小写在不同文件系统上有不同行为(macOS 默认大小写不敏感,Linux 敏感),同一化成小写能避免在跨平台开发时撞坑。

踩坑清单

  • 组件要加 .id('xxx') 才能被 componentSnapshot 定位
  • 离屏画布 trick:.position({ x: -9999 }) 把高分辨率海报组件挂到屏幕外,截图不受屏幕限制
  • fileIo.openSync 的 OpenMode 覆盖写用 READ_WRITE | CREATE | TRUNC 组合
  • packToFile 接 fd ,不是 path。format 是 MIME 字符串 'image/png'
  • 资源释放顺序:ImagePacker release → File close → PixelMap release。全部 try/catch 吞错
  • 分享的 wantParam:ability.params.stream 是数组 ,元素是 file:// 完整 uri
  • uriType 用 general.image / general.text / general.file
  • startAbilityByType 的 cb.onError 在模拟器上必然触发(没分享目标),是正常现象
  • 文件名加 timestamp + 小写化 防止覆盖冲突

尾声

这条链路里最容易忘的是资源释放 。鸿蒙的 ImagePacker / PixelMap / fd 都是对系统资源 的持有(底层是 native 对象),不像纯 JS 的变量 GC 掉就完了。写代码时养成"每个 create / open 都对应一个 release / close"的习惯,不然 app 跑久了会内存持续涨,用户发现"打开几次分享就卡顿",查起来又累又没线索。

finally 包裹 + 吞错的 release,看着啰嗦,但稳。

相关推荐
Swot16 分钟前
SvelteKit SSG 发布后,为什么老访客非要强刷才能看到新内容?
前端
鸿蒙开发25 分钟前
小程序存档写到一半怎么办?双槽保存与损坏恢复实战
前端·javascript·react.js
卸任2 小时前
AI 编程工具越用越多,模型配置越来越乱:用 Magpie 把它们管起来
前端·ai编程
前端一课2 小时前
凝神诀
前端
前端双越老师2 小时前
程序员 1v1 访谈系列第十三期:大龄已育空窗一年如何破局
前端·ai编程·全栈
恋猫de小郭3 小时前
Flutter 大小核优化又被回滚,然后又开新提案?
android·前端·flutter
MyBili3 小时前
Chrome扩展开发入门:从零打造实用浏览器插件
前端·chrome·谷歌浏览器·效率工具·浏览器插件·前端入门·扩展开发
FungLeo3 小时前
成为全栈·Vue 管理后台篇·Vue3 + Vite + Naive UI:后台工程基座怎么搭
前端·vue.js·ui·typescript·vite·前端工程化·biome