上个月给应用加了"修改头像"的功能。本来觉得这是再基础不过的需求:调起系统相册,选张照片,拿到路径,传给后端,搞定。
结果周一刚上班,测试妹子就跑过来:"你过来看看,我拿我的手机选了一张相机拍的高清大图当头像,刚点确定,整个 App 直接闪退了!"
我拿过手机复现了一下,确实崩了。看崩溃日志,赫然写着:Out of memory (OOM)。
我一开始还不信,不就加载一张图片吗?现在的手机哪个不是 8G、12G 内存,一张图怎么可能把应用撑爆?直到我看到了她的相册截图:那是一张 4800 万像素、大小差不多有 15MB 的原图。
这就是典型的踩了"图像内存解码"的盲点。这篇复盘,记录我是如何把内存占用从 300MB 优化到 10MB 的。
为什么一张 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 秒多缩短到了几十毫秒。
上传前的关键一步:质量重编码
用户在相册里选的图,如果直接传给服务器,既费流量,服务器还要花算力去裁切压缩。
为了极致体验,我决定在客户端就做一次重新编码。把图片质量压缩到 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 的文件以为不大,解压后的原图分分钟教你做人。
-
永远不要直接加载相册原图 ,必定要指定
desiredSize。 -
如果不需要透明度,格式选
RGB_565。 -
把加载、缩放、重编码逻辑,全部丢进
TaskPool里,让主线程安稳地睡大觉。