大家好啊,前段时间做了个 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 (_) {}
}
三件事串成一个流:
- componentSnapshot.get → PixelMap(在内存里)
- packSnapshotToFile → PNG(在 files 目录)
- 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,看着啰嗦,但稳。