在这里插入图片描述

摘要

本文聚焦超分结果的工程落地:如何把内存中的 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(跨应用可访问)。

存相册

分享

超分输出 PixelMap

ImagePacker 编码

ArrayBuffer 字节流

fileIo 写入沙箱

目标用途

photoAccessHelper.createAsset

fileUri.getUriFromPath

媒体库 URI

分享 URI

相册可见 / 可被其他 App 读取

Want 拉起系统分享面板

编码失败 / 内存不足

降级:降低 quality 或分块编码

从图里能看出两个关键分叉点:一是编码产物必须先落盘,二是落盘之后要按用途转成不同 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体积编码耗时适用场景
JPEG100~6.8 MB~420 ms不推荐,收益极低
JPEG90~2.1 MB~260 ms默认推荐
JPEG75~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、内存、资源释放。这些点单看都很小,但任何一个没处理好,用户看到的就是"存不进"“分享不了”“用着用着闪退”。把这几处守住,超分能力才算真正交付到用户手里。

再补一张落地决策图,方便按场景快速选路径:

是

否

是

否

超分完成

要存相册吗

申请媒体库权限

createAsset 写媒体库

要分享吗

写沙箱 + getUriFromPath

仅沙箱留存

完成

边界提醒:媒体库写入在部分设备上受用户授权状态影响,用户拒绝后应给出引导而非静默失败;分享 URI 的有效期与沙箱文件生命周期绑定,别在分享后立刻删沙箱文件,否则接收方可能读不到。

六、总结

把全文压缩成一张「落地清单」,方便开发时逐项对照:

环节关键动作一句话要点
编码ImagePacker.packing + quality: 90别拉满,90 是清晰与体积的甜点
落盘fileIo 写应用沙箱先有文件路径,才谈得上分享
进相册photoAccessHelper.createAsset权限先声明再动态申请,否则直接抛错
分享fileUri.getUriFromPath + Want沙箱绝对路径跨应用读不到,必须转 URI
资源packer.release() + pm.release()用完即释放,批量保存尤其关键
批量for...of 串行处理并发编码会让内存峰值叠加到 OOM

三条最容易被忽略、也最容易出问题的原则:

  1. 形态要清楚:PixelMap(内存)→ ArrayBuffer(字节)→ 沙箱路径(私有)→ 媒体库/分享 URI(跨应用),每一步产物形态不同,混用就会出错。
  2. 权限要前置:媒体库读写权限既要在 module.json5 声明,也要在运行时动态申请,缺一不可。
  3. 内存要克制:4 倍超分后像素量是原图 16 倍,编码期是内存峰值高发区,串行 + 及时释放是最稳的姿势。

按这份清单走一遍,超分结果就能从「算得出来」顺利走到「存得进去、发得出去」。


本文为原创首发实战笔记,基于 HarmonyOS 7.0.0(API 26)图像超分 + 媒体库/分享能力,代码示例为调用范式示意,实际接口以华为官方文档为准。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐