传张高清头像直接闪退?HarmonyOS 7 图像处理与内存优化

上个月给应用加了"修改头像"的功能。本来觉得这是再基础不过的需求:调起系统相册,选张照片,拿到路径,传给后端,搞定。

结果周一刚上班,测试妹子就跑过来:"你过来看看,我拿我的手机选了一张相机拍的高清大图当头像,刚点确定,整个 App 直接闪退了!"

我拿过手机复现了一下,确实崩了。看崩溃日志,赫然写着:Out of memory (OOM)。

我一开始还不信,不就加载一张图片吗?现在的手机哪个不是 8G、12G 内存,一张图怎么可能把应用撑爆?直到我看到了她的相册截图:那是一张 4800 万像素、大小差不多有 15MB 的原图。

这就是典型的踩了"图像内存解码"的盲点。这篇复盘,记录我是如何把内存占用从 300MB 优化到 10MB 的。

https://placeholder-01.jpg/

为什么一张 15MB 的图能吃掉几百兆内存?

很多人以为,文件在磁盘上是 15MB,加载进内存也就是 15MB。

这完全是两码事。图片在磁盘上(比如 JPG、PNG)是经过压缩的格式 。但当你把它加载到屏幕上渲染时,系统必须把它解压成未压缩的位图(PixelMap)。

一张 4800 x 3600 像素的图片,如果用 RGBA_8888 的格式(红绿蓝透明度各占 8 位,即 4 个字节),它在内存里的真实大小是:

4800 * 3600 * 4 bytes ≈ 69,120,000 bytes ≈ 65.9 MB

如果你在同一个页面里多加载了几张大图,或者对同一张图进行了多次拷贝,内存瞬间飙升到几百兆,不闪退才怪。

我一开始的代码,犯了一个极其低级的错误,直接用 Image 组件去绑定原图路径:

typescript

复制代码
// 错误示范:直接加载原图
Image(this.selectedImageUri)
  .width(100)
  .height(100)

虽然你在 UI 上把尺寸限制在了 100x100,但底层解码时,依然是把原图 4800x3600 的完整位图解码到了内存里,然后才压缩。这简直是拿大炮打蚊子,不仅慢,还极其吃内存。

Image Kit 与采样率降采样

唯一的解法是:在解码阶段就把图片按需缩小。 绝对不能把它完整解压出来再缩。

在 HarmonyOS 7 里,我们可以用 @ohos.multimedia.image (Image Kit) 来处理。关键在于 ImageSource 的 createPixelMap 方法里,设置 desiredSize(期望尺寸)或者 desiredPixelFormat(期望格式)。

重构后的代码:

typescript

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

async function loadAndCompressImage(uri: string, targetSize: number): Promise<image.PixelMap> {
  // 1. 打开文件
  const file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
  
  // 2. 创建 ImageSource
  const imageSource = image.createImageSource(file.fd);
  
  // 3. 核心:在解码时直接指定目标尺寸,底层解码器会自动做降采样(Downsampling)
  const decodingOptions: image.DecodingOptions = {
    desiredSize: { width: targetSize, height: targetSize },
    desiredPixelFormat: image.PixelMapFormat.RGB_565 // 如果不需要透明度,RGB_565 比 RGBA_8888 省一半内存!
  };

  const pixelMap = await imageSource.createPixelMap(decodingOptions);
  
  // 4. 务必释放资源
  await imageSource.release();
  fs.closeSync(file);
  
  return pixelMap;
}

体验大反转: 通过设置 desiredSize,这张原图在底层根本不会被完全展开。如果目标尺寸是 300x300,内存占用瞬间降到了几百 KB。而且解码速度也从原来的 1 秒多缩短到了几十毫秒。

https://placeholder-02.png/

上传前的关键一步:质量重编码

用户在相册里选的图,如果直接传给服务器,既费流量,服务器还要花算力去裁切压缩。

为了极致体验,我决定在客户端就做一次重新编码。把图片质量压缩到 80%,并保存为 WebP 或 JPEG 格式。

typescript

复制代码
async function packImageToFile(pixelMap: image.PixelMap, targetUri: string) {
  const imagePacker = image.createImagePacker();
  
  // 配置打包参数:格式为 JPEG,质量为 80%
  const packingOptions: image.PackingOption = {
    format: 'image/jpeg',
    quality: 80
  };

  const file = fs.openSync(targetUri, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
  
  // 将 PixelMap 重新编码并写入文件
  await imagePacker.packToFile(pixelMap, file.fd, packingOptions);
  
  await imagePacker.release();
  fs.closeSync(file);
}

从 15MB 的原图,到提取并缩放为 300x300 的位图,再重编码成 50KB 的缩略图,整个过程在 TaskPool 里执行不到 100 毫秒。

PixelMap 的手动释放

这是极其容易忽略的一点。PixelMap 是跨语言(ArkTS 和 C++ 渲染层)的对象,它占用的内存不在 ArkTS 的 GC(垃圾回收)堆里。

如果你的页面频繁更换头像,每次都会产生一个新的 PixelMap。如果你仅仅是把它传给 UI 就不管了,内存很快就会耗尽。

typescript

复制代码
// 每次替换前,手动释放旧的
if (this.avatarPixelMap) {
  this.avatarPixelMap.release(); 
}
this.avatarPixelMap = newPixelMap;

图像处理是移动端开发的深水区。别看到 15MB 的文件以为不大,解压后的原图分分钟教你做人。

  1. 永远不要直接加载相册原图 ,必定要指定 desiredSize。

  2. 如果不需要透明度,格式选 RGB_565。

  3. 把加载、缩放、重编码逻辑,全部丢进 TaskPool 里,让主线程安稳地睡大觉。