4. 内存优化实战:图片内存优化、列表复用优化、对象池模式应用
内存优化这件事,说实话,是鸿蒙开发里最容易被忽视、但坑又最多的地方。我早期带团队做一款资讯类App时,就吃过图片内存的亏——滑动列表越来越卡,最后直接OOM崩溃。后来我花了整整两周,把图片加载、列表复用、对象池这三块彻底重构了一遍,才把内存峰值降了60%。今天我就把这套实战经验拆开来讲。
4.1 图片内存优化:别让一张图吃掉几十MB
图片是内存消耗的大户。一张1920x1080的图片,如果以ARGB_8888格式加载到内存里,占多大空间?算一下:1920 * 1080 * 4字节 ≈ 7.9MB。如果你一次加载20张,那就是160MB。嗯,很多App就是这么崩的。
4.1.1 图片压缩与采样
我个人的习惯是:永远不要直接加载原图。鸿蒙提供了ImageSource和DecodeOptions,可以指定采样率。
// 设置采样率,将图片缩小为1/4
let options = new image.DecodeOptions();
options.desiredSize = { width: 200, height: 200 }; // 指定目标尺寸
options.desiredPixelFormat = image.PixelMapFormat.RGBA_8888;
let imageSource = image.createImageSource(fileUri);
let pixelMap = await imageSource.createPixelMap(options);
这里有个坑:desiredSize不是直接缩放,而是让解码器按最接近的采样因子来解码。比如原图4000x3000,你设200x200,解码器会按20:1采样,内存占用直接降到原来的1/400。我在项目中遇到过有人设了desiredSize但忘记检查实际输出尺寸,结果内存还是爆了——因为采样因子没对齐。
4.1.2 图片缓存策略
图片缓存,说白了就是「用空间换时间」。但空间是有限的,你得定好规则。我一般用两级缓存:内存缓存 + 磁盘缓存。
| 缓存层级 | 容量建议 | 淘汰策略 |
|---|---|---|
| 内存缓存 | App可用内存的1/8 | LRU(最近最少使用) |
| 磁盘缓存 | 50MB ~ 100MB | 按文件最后访问时间淘汰 |
你想想看,如果每次滑动列表都重新解码图片,那性能肯定完蛋。内存缓存命中率如果能到90%以上,列表滑动基本就是秒开。我习惯用LruCache来实现,鸿蒙的@ohos.util.LruCache可以直接用。
import LruCache from '@ohos.util.LruCache';
let imageCache = new LruCache(50); // 最多缓存50张
imageCache.put('key_avatar', pixelMap);
// 获取时
let cached = imageCache.get('key_avatar');
if (cached) {
// 直接使用缓存
} else {
// 重新加载
}
LruCache的getSize()方法,按PixelMap的字节数来计算,而不是按数量。这样更精确。
4.2 列表复用优化:让1000条数据只渲染10个Item
列表复用,说白了就是「循环利用」。你滑动列表时,离开屏幕的Item会被回收,新出现的Item直接复用它的布局。鸿蒙的List组件默认支持复用,但用不好照样卡。
4.2.1 正确使用ListItem复用
我见过很多新手在ListItem里动态创建组件,这是大忌。复用机制要求:布局结构固定,只更新数据。
// 错误示范:每次创建新组件
build() {
List() {
ForEach(this.dataArray, (item) => {
ListItem() {
// 这里动态创建组件,复用失效
this.createItemView(item);
}
})
}
}
// 正确做法:固定布局,只更新数据
build() {
List() {
ForEach(this.dataArray, (item) => {
ListItem() {
ItemView({ data: item }) // ItemView是固定组件
}
})
}
}
为什么会这样?因为鸿蒙的复用机制是基于组件树的。如果你每次返回不同的组件树,系统就没法复用,只能销毁重建。嗯,这里要注意:ForEach的key值也很关键。key要稳定且唯一,我习惯用数据项的id,而不是索引。
4.2.2 图片加载与列表滚动的协调
列表快速滑动时,如果每个Item都去加载图片,会造成「图片风暴」。我个人的解决方案是:滑动停止后再加载。
// 监听列表滚动状态
List() {
// ...
}
.onScroll((xOffset, yOffset, scrollState) => {
if (scrollState === ScrollState.Idle) {
// 停止滑动,开始加载可见区域的图片
this.loadVisibleImages();
} else {
// 滑动中,暂停加载
this.pauseImageLoading();
}
})
这个优化看起来简单,但效果非常明显。我在项目中遇到过列表快速滑动时,图片加载任务堆积,导致UI线程卡顿。加上这个策略后,帧率从20fps直接提升到55fps。
4.3 对象池模式应用:减少频繁创建对象的开销
对象池,说白了就是「用完别扔,洗洗再用」。频繁创建和销毁对象,会触发GC(垃圾回收),GC一多,界面就卡。我习惯在以下场景使用对象池:
- 消息对象:比如自定义事件、通知数据
- 临时数据结构:比如坐标点、矩形区域
- 渲染相关对象:比如画笔、矩阵
4.3.1 实现一个简单的对象池
class ObjectPool<T> {
private pool: T[] = [];
private factory: () => T;
private reset: (obj: T) => void;
constructor(factory: () => T, reset: (obj: T) => void, initialSize: number = 10) {
this.factory = factory;
this.reset = reset;
// 预创建对象
for (let i = 0; i < initialSize; i++) {
this.pool.push(this.factory());
}
}
acquire(): T {
if (this.pool.length > 0) {
return this.pool.pop()!;
}
return this.factory(); // 池为空时创建新对象
}
release(obj: T): void {
this.reset(obj); // 重置状态
this.pool.push(obj);
}
}
使用方式很简单:
// 假设我们频繁创建坐标点
let pointPool = new ObjectPool(
() => { return { x: 0, y: 0 }; },
(p) => { p.x = 0; p.y = 0; },
20
);
// 使用时
let p = pointPool.acquire();
p.x = 100;
p.y = 200;
// 用完后归还
pointPool.release(p);
4.3.2 对象池的避坑指南
我曾经在项目里用过对象池,结果内存泄漏了。原因是:对象归还后,还有外部引用持有它。你想想看,池里的对象被误用了,数据就乱了。
reset函数里强制清空所有字段,才解决这个问题。记住:归还对象时,一定要彻底重置。
4.4 本章小结:内存优化的三个抓手
图片内存优化、列表复用优化、对象池模式,这三块是内存优化的核心。我个人的经验是:先做图片优化,收益最大;再做列表复用,解决滑动卡顿;最后用对象池,压榨极致性能。
你想想看,如果一张图省下7MB,一个列表省下100个Item的创建,一个对象池省下每秒100次的GC——加起来,你的App想不流畅都难。