鸿蒙超分图落地实战:从 PixelMap 到相册与分享的完整链路【鸿蒙心迹】

摘要
本文聚焦超分结果的工程落地:如何把内存中的 PixelMap 变成用户真正能用的资产。全文打通「编码 → 落盘 → 进相册 → 分享」四步链路,给出可直接复制的 TypeScript 代码,并补齐权限声明、动态申请、异常处理与资源释放三块真机必备内容。核心结论:JPEG quality 90 是清晰度与体积的最佳平衡点;沙箱文件必须先落盘再转 URI;存相册走媒体库 createAsset,分享走 fileUri.getUriFromPath;大图批量保存务必串行并及时 release。适合正在做端侧图像增强、却被「存不进相册 / 分享灰掉 / 大图 OOM」卡住的开发者。
目录
一、背景与痛点
上两篇讲了超分接入和踩坑,但用户最终要的是"能用":把超分后的 4 倍高清存进相册、发给朋友、或塞进自己的素材库。可 PixelMap 只是内存里的一张位图,从"算出来"到"落了地"中间还隔着:编码成图片、写沙箱、进媒体库、走分享。任何一步姿势不对,结果就是存不进、分享灰掉、或大图直接 OOM。
本文把超分结果的保存与分享完整链路打通,配可直接用的代码。
为什么这件事值得单独写一篇?因为超分本身只是"把图变清楚",而用户感知到的价值,恰恰发生在保存和分享这两个动作上。一张再清晰、却存不进相册、发不出去的图,对用户来说等于没做。所以这一篇的重点不是算法,而是工程落地:怎么把内存里的 PixelMap 变成用户能看见、能传播的资产。
下面先讲清整条链路的形态变化,再逐段给出可复制的代码,最后把踩过的坑整理成对照表。
二、链路与规格
| 步骤 | 能力 | 要点 |
|---|---|---|
| 编码 | image.ImagePacker | 把 PixelMap 编码为 JPEG/PNG 字节 |
| 落盘 | @kit.CoreFileKit 的 fileIo | 写应用沙箱,先有路径才能分享 |
| 进相册 | photoAccessHelper(媒体库) | 需 READ_IMAGEVIDEO/WRITE_IMAGEVIDEO 权限 |
| 分享 | Want 系统分享面板 | URI 必须是分享可访问的 file:// |
上表四个步骤是串行依赖关系:没有编码就没有字节,没有字节就写不了文件,没有文件路径就转不出 URI。所以排查问题时也建议按这个顺序逐段验证——先确认 packing 返回了非空 ArrayBuffer,再确认沙箱文件确实写成功且大小合理,最后才看媒体库和分享。从后往前猜,往往会把问题归错地方。
关键认知:沙箱文件别的 App 读不到,分享/存相册必须转成媒体库 URI 或分享 URI,不能直接塞沙箱绝对路径。
整条链路可以概括为下图,注意每一步的产物形态在变化:PixelMap(内存)→ ArrayBuffer(字节)→ 沙箱路径(私有文件)→ 媒体库 URI / 分享 URI(跨应用可访问)。
从图里能看出两个关键分叉点:一是编码产物必须先落盘,二是落盘之后要按用途转成不同 URI。很多"分享灰掉"的问题,本质就是跳过了 getUriFromPath 这一步,直接把沙箱绝对路径塞进了 Want.uri。
三、核心代码(编码 + 存相册 + 分享)
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { fileUri } from '@kit.CoreFileKit';
import { Want } from '@kit.AbilityKit';
// 1. PixelMap → JPEG 字节
async function pack(pm: image.PixelMap): Promise<ArrayBuffer> {
const packer = image.createImagePacker();
return await packer.packing(pm, { format: 'image/jpeg', quality: 90 });
}
// 2. 字节 → 沙箱文件
async function saveSandbox(buf: ArrayBuffer, name: string): Promise<string> {
const path = `${getContext().filesDir}/${name}`;
const f = await fileIo.open(path, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY);
await fileIo.write(f.fd, buf); await fileIo.close(f.fd);
return path;
}
// 3. 沙箱 → 媒体库(相册可见)
async function toGallery(ctx: Context, buf: ArrayBuffer): Promise<string> {
const helper = photoAccessHelper.getPhotoAccessHelper(ctx);
const uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpeg');
const f = await fileIo.open(uri, fileIo.OpenMode.WRITE_ONLY);
await fileIo.write(f.fd, buf); await fileIo.close(f.fd);
return uri;
}
// 4. 分享(Want 拉起系统分享面板)
function share(ctx: Context, sandboxPath: string): void {
const want: Want = {
action: 'ohos.want.action.sendData',
type: 'image/jpeg',
uri: fileUri.getUriFromPath(sandboxPath),
};
(ctx as any).startAbility(want);
}
上面这段是最小可运行骨架,四个函数各管一件事,方便单独测试。但真实项目里还有三块必须补上:权限声明、动态申请、异常与资源释放。少了任何一块,代码在真机上大概率跑不通——权限没声明会直接抛错,资源没释放会在批量保存时慢慢把内存吃光。下面 3.1 到 3.3 就是把这三点补齐。
3.1 权限声明(module.json5)
存相册前必须声明媒体库读写权限,否则 createAsset 会直接抛错:
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.WRITE_IMAGEVIDEO",
"reason": "$string:reason_write_image",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.READ_IMAGEVIDEO",
"reason": "$string:reason_read_image",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
3.2 动态申请权限
import { abilityAccessCtrl, common } from '@kit.AbilityKit';
async function ensureGalleryPermission(ctx: common.UIAbilityContext): Promise<boolean> {
const atManager = abilityAccessCtrl.createAtManager();
const perms: Array<Permissions> = [
'ohos.permission.WRITE_IMAGEVIDEO',
'ohos.permission.READ_IMAGEVIDEO',
];
const result = await atManager.requestPermissionsFromUser(ctx, perms);
// 0 表示已授权
return result.authResults.every((code) => code === 0);
}
3.3 完整调用示例(带错误处理与资源释放)
import { image } from '@kit.ImageKit';
import { fileIo, fileUri } from '@kit.CoreFileKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
async function saveAndShare(ctx: Context, pm: image.PixelMap): Promise<void> {
let packer: image.ImagePacker | undefined;
try {
// 1. 编码
packer = image.createImagePacker();
const buf = await packer.packing(pm, { format: 'image/jpeg', quality: 90 });
// 2. 落沙箱
const name = `sr_${Date.now()}.jpg`;
const path = `${(ctx as any).filesDir}/${name}`;
const f = await fileIo.open(path, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY);
try {
await fileIo.write(f.fd, buf);
} finally {
await fileIo.close(f.fd);
}
// 3. 存相册
const helper = photoAccessHelper.getPhotoAccessHelper(ctx);
const uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpeg');
const gf = await fileIo.open(uri, fileIo.OpenMode.WRITE_ONLY);
try {
await fileIo.write(gf.fd, buf);
} finally {
await fileIo.close(gf.fd);
}
// 4. 分享
const want: Want = {
action: 'ohos.want.action.sendData',
type: 'image/jpeg',
uri: fileUri.getUriFromPath(path),
};
(ctx as any).startAbility(want);
} catch (e) {
const err = e as BusinessError;
console.error(`saveAndShare failed: code=${err.code}, msg=${err.message}`);
} finally {
// 5. 释放编码器与 PixelMap,避免内存峰值叠加
packer?.release();
await pm.release();
}
}
3.4 编码格式与体积对比
同一张 4 倍超分图(约 4000×3000)在不同参数下的实测参考:
| 格式 | quality | 体积 | 编码耗时 | 适用场景 |
|---|---|---|---|---|
| JPEG | 100 | ~6.8 MB | ~420 ms | 不推荐,收益极低 |
| JPEG | 90 | ~2.1 MB | ~260 ms | 默认推荐 |
| JPEG | 75 | ~1.2 MB | ~210 ms | 分享/缩略图 |
| PNG | - | ~14 MB | ~900 ms | 需无损、带透明通道 |
结论很直接:JPEG 90 是清晰度与体积的最佳平衡点,PNG 只在必须无损时才用。
需要说明的是,上表数据来自单台设备的实测,不同芯片、不同图片内容(细节多寡)会有浮动,但相对趋势是一致的:quality 从 100 降到 90,体积能砍掉约三分之二,而肉眼几乎看不出差别。所以选参数时不必纠结绝对值,抓住"90 是甜点"这个结论即可。
四、实战要点
要点 1:quality 别拉满,90 足够。
quality: 100 体积翻倍、肉眼难分。4 倍超分图本身已是大图,90 兼顾清晰与体积。
要点 2:存相册先拿媒体库权限。
没声明 WRITE_IMAGEVIDEO 就 createAsset,直接失败。权限在 module.json5 声明并动态申请。
要点 3:分享用 getUriFromPath 转 URI。
直接传沙箱路径,接收方跨应用读不到。必须用 fileUri.getUriFromPath 转成可分享 URI。
要点 4:大图注意内存峰值。
4 倍超分后像素量是原图 16 倍,编码期内存峰值高。超分→编码→释放 PixelMap 要连贯,别同时持有多张。
要点 5:编码器用完要 release。
ImagePacker 持有原生资源,不 release() 会在多次保存后累积内存。配合 PixelMap.release() 一起释放,长列表批量保存时尤其明显。
要点 6:批量保存要串行,别并发。
多张大图同时编码,内存峰值会叠加到 OOM。用 for...of 串行处理,每张处理完立即释放,比 Promise.all 稳得多。
async function saveBatch(ctx: Context, list: image.PixelMap[]): Promise<void> {
for (const pm of list) {
await saveAndShare(ctx, pm); // 内部已 release
}
}
4.1 常见错误对照表
| 现象 | 根因 | 解决 |
|---|---|---|
createAsset 报权限错误 | 未声明/未申请 WRITE_IMAGEVIDEO | 补 module.json5 + 动态申请 |
| 分享面板灰掉 | Want.uri 传了沙箱绝对路径 | 用 fileUri.getUriFromPath 转换 |
| 保存后相册看不到 | 只写了沙箱,没进媒体库 | 走 createAsset 写媒体库 URI |
| 批量保存闪退 | 多张 PixelMap 同时驻留 | 串行处理 + 及时 release |
| 图片体积异常大 | quality: 100 或误用 PNG | 改 JPEG 90 |
五、小结与边界
超分结果的落地三件事:编码选 90、沙箱先落盘、相册/分享走 URI。它把"端侧增强"变成用户真正可用的资产。落地记住:quality 别满、权限先备、URI 必转、大图防 OOM。
回头看,这一篇其实没有涉及任何复杂算法,难点全在边界与细节:权限、URI、内存、资源释放。这些点单看都很小,但任何一个没处理好,用户看到的就是"存不进"“分享不了”“用着用着闪退”。把这几处守住,超分能力才算真正交付到用户手里。
再补一张落地决策图,方便按场景快速选路径:
边界提醒:媒体库写入在部分设备上受用户授权状态影响,用户拒绝后应给出引导而非静默失败;分享 URI 的有效期与沙箱文件生命周期绑定,别在分享后立刻删沙箱文件,否则接收方可能读不到。
六、总结
把全文压缩成一张「落地清单」,方便开发时逐项对照:
| 环节 | 关键动作 | 一句话要点 |
|---|---|---|
| 编码 | ImagePacker.packing + quality: 90 | 别拉满,90 是清晰与体积的甜点 |
| 落盘 | fileIo 写应用沙箱 | 先有文件路径,才谈得上分享 |
| 进相册 | photoAccessHelper.createAsset | 权限先声明再动态申请,否则直接抛错 |
| 分享 | fileUri.getUriFromPath + Want | 沙箱绝对路径跨应用读不到,必须转 URI |
| 资源 | packer.release() + pm.release() | 用完即释放,批量保存尤其关键 |
| 批量 | for...of 串行处理 | 并发编码会让内存峰值叠加到 OOM |
三条最容易被忽略、也最容易出问题的原则:
- 形态要清楚:
PixelMap(内存)→ArrayBuffer(字节)→ 沙箱路径(私有)→ 媒体库/分享 URI(跨应用),每一步产物形态不同,混用就会出错。 - 权限要前置:媒体库读写权限既要在
module.json5声明,也要在运行时动态申请,缺一不可。 - 内存要克制:4 倍超分后像素量是原图 16 倍,编码期是内存峰值高发区,串行 + 及时释放是最稳的姿势。
按这份清单走一遍,超分结果就能从「算得出来」顺利走到「存得进去、发得出去」。
本文为原创首发实战笔记,基于 HarmonyOS 7.0.0(API 26)图像超分 + 媒体库/分享能力,代码示例为调用范式示意,实际接口以华为官方文档为准。
更多推荐




所有评论(0)