HarmonyOS应用《民族图鉴》开发第52篇:图片加载与缓存——网络图片、三级缓存、占位图与错误图

📖 引言
图片是移动应用中最重要的视觉元素之一。
从应用图标、启动页,到列表中的封面图、详情页的大图……一个 App 里,图片占了 70% 以上的视觉空间。图片加载的体验好不好,直接决定了用户对整个应用的印象——是"丝滑流畅",还是"卡成 PPT"。
但很多开发者对图片加载的理解,停留在"写个 Image 组件,传个 url"的层面。结果就是:
- 列表滑动时,图片一闪一闪的(闪烁)
- 划过去再划回来,图片又要重新加载(没缓存)
- 图片太大,加载慢,还耗流量
- 网络不好时,图片加载不出来,一片空白
- 列表里的图片错位,明明是 A 的图,显示成 B 的
一个好的图片加载库(或方案),应该具备这些能力:
- 三级缓存(内存 → 磁盘 → 网络)
- 占位图、错误图
- 图片压缩、尺寸优化
- 列表滑动时暂停加载
- 预加载、懒加载
- 渐进式加载
- ……
ArkUI 的 Image 组件提供了基础的图片加载能力,但要做出"丝滑"的图片加载体验,还需要自己做很多事情。
本文我们就从图片加载的完整流程讲起,深入到三级缓存、占位图、尺寸优化、列表性能优化等各个环节,结合「民族图鉴」项目的封面图加载实战,带你系统性地掌握鸿蒙图片加载与缓存。
🎯 学习目标
完成本文后,你将能够:
- ✅ 掌握 Image 组件的进阶用法
- ✅ 理解网络图片加载的完整流程
- ✅ 掌握三级缓存策略:内存缓存 → 磁盘缓存 → 网络
- ✅ 学会占位图、错误图、加载动画的实现
- ✅ 理解图片的尺寸优化与压缩策略
- ✅ 掌握列表图片的性能优化技巧
- ✅ 避开图片加载的常见坑:闪烁、OOM、列表错位
💡 需求分析
图片加载的完整流程
一张网络图片,从 URL 到显示在屏幕上,经历了哪些步骤?
用户看到图片位置
↓
有没有占位图? → 有 → 显示占位图
↓
内存缓存里有没有? → 有 → 直接显示
↓
磁盘缓存里有没有? → 有 → 读出来,解码,显示,存内存
↓
网络请求下载 → 下载成功 → 存磁盘,解码,存内存,显示
↓
下载失败? → 显示错误图
看起来简单,但每一步都有很多细节:
- 占位图不能和目标图差太多,不然会"闪烁"
- 内存缓存不能太大,不然会 OOM
- 磁盘缓存要管理大小,不然会占满用户存储空间
- 解码要考虑采样率,不然大图会占很多内存
- 列表里的图片要考虑复用问题,不然会"错位"
三级缓存策略
三级缓存是图片加载的经典策略:
| 层级 | 速度 | 容量 | 生命周期 |
|---|---|---|---|
| 内存缓存 | 极快(几毫秒) | 小(几 MB ~ 几十 MB) | 应用被杀就没了 |
| 磁盘缓存 | 较快(几十毫秒) | 中等(几十 MB ~ 几百 MB) | 卸载 App 才没 |
| 网络 | 慢(几百毫秒 ~ 几秒) | 无限 | 永久 |
核心思想:
- 优先从最快的地方拿(内存)
- 没有再从次快的地方拿(磁盘)
- 都没有才从网络下载
- 下载后存到磁盘和内存,下次就快了
这样设计的原因:
- 内存最快,但容量有限,不能什么都存
- 磁盘容量大一些,但速度比内存慢
- 网络最慢,还耗流量,尽量少用
「民族图鉴」的图片场景
「民族图鉴」项目中有很多图片加载场景:
| 场景 | 图片类型 | 特点 | 优化重点 |
|---|---|---|---|
| 民族列表封面 | 小图 | 数量多、滚动频繁 | 缓存、占位图、列表复用 |
| 民族详情页封面 | 大图 | 单张、尺寸大 | 渐进式加载、内存优化 |
| 民族服饰/美食图 | 多图 | 数量多、需要预览 | 缩略图 + 原图 |
| 音乐封面 | 小图 | 圆形、固定尺寸 | 缓存、圆角优化 |
| 用户头像 | 小图 | 数量少、经常用 | 强缓存 |
🛠️ 核心实现
步骤1:Image 组件进阶用法
Image 组件是 ArkUI 中加载图片的基础组件。它的能力比你想象的要强。
1.1 基本用法
// 本地资源图片
Image($r('app.media.logo'))
.width(100)
.height(100)
// rawfile 图片
Image($rawfile('coverImage/01_han.jpg'))
.width('100%')
.height(200)
// 网络图片
Image('https://example.com/image.jpg')
.width(100)
.height(100)
1.2 重要属性
| 属性 | 说明 | 用途 |
|---|---|---|
objectFit |
图片填充模式 | Cover / Contain / Fill / None / ScaleDown |
objectRepeat |
重复模式 | 平铺背景 |
interpolation |
插值效果 | 图片缩放时的质量 |
renderMode |
渲染模式 | Original / Template(着色) |
imageFill |
填充颜色 | renderMode 为 Template 时的颜色 |
borderRadius |
圆角 | 圆形头像、圆角卡片 |
alt |
占位图 | 加载中显示的图片 |
onComplete |
加载完成回调 | 加载成功时触发 |
onError |
加载失败回调 | 加载失败时触发 |
onFinish |
加载结束回调 | 成功失败都触发 |
1.3 占位图(alt)
Image 组件有个 alt 属性,可以设置占位图——图片加载过程中显示的图。
Image('https://example.com/image.jpg')
.width(100)
.height(100)
.alt($r('app.media.placeholder')) // 加载中显示占位图
.objectFit(ImageFit.Cover)
alt 的好处是:
- 图片加载过程中不是空白,有个占位
- 加载成功后,平滑过渡(不会太突兀)
但 alt 也有局限:
- 只能是本地资源,不能是网络图片
- 只有一张,不能区分"加载中"和"加载失败"
- 过渡效果比较简单
1.4 加载状态回调
可以通过回调函数监听图片加载状态:
@State isLoading: boolean = true;
@State isError: boolean = false;
Image('https://example.com/image.jpg')
.width(100)
.height(100)
.onComplete(() => {
console.info('图片加载成功');
this.isLoading = false;
this.isError = false;
})
.onError(() => {
console.error('图片加载失败');
this.isLoading = false;
this.isError = true;
})
通过这些回调,我们可以自己控制加载中、加载失败的显示。
步骤2:三级缓存的实现
三级缓存是图片加载的核心。让我们一步步实现一个简单的三级缓存系统。
2.1 内存缓存
内存缓存是最快的一级。图片解码后,存在内存里,下次用直接拿。
内存缓存的实现思路:
- 用一个 Map 存,key 是图片 URL,value 是图片数据
- 有容量限制,超过了就淘汰一些旧的
- 常用的淘汰策略:LRU(最近最少使用)
// utils/ImageCache.ets
import image from '@ohos.multimedia.image';
interface MemoryCacheEntry {
pixelMap: image.PixelMap;
size: number;
lastUsedTime: number;
}
export class MemoryCache {
private cache: Map<string, MemoryCacheEntry> = new Map();
private maxSize: number = 10 * 1024 * 1024; // 最大 10MB
private currentSize: number = 0;
constructor(maxSize?: number) {
if (maxSize) {
this.maxSize = maxSize;
}
}
get(key: string): image.PixelMap | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
// 更新最后使用时间
entry.lastUsedTime = Date.now();
return entry.pixelMap;
}
put(key: string, pixelMap: image.PixelMap, size: number): void {
// 如果已经有了,先删掉旧的
const existing = this.cache.get(key);
if (existing) {
this.currentSize -= existing.size;
this.cache.delete(key);
}
// 如果放不下了,先淘汰一些
while (this.currentSize + size > this.maxSize && this.cache.size > 0) {
this.evictOldest();
}
// 存入
this.cache.set(key, {
pixelMap,
size,
lastUsedTime: Date.now()
});
this.currentSize += size;
}
private evictOldest(): void {
let oldestKey: string = '';
let oldestTime: number = Infinity;
this.cache.forEach((entry, key) => {
if (entry.lastUsedTime < oldestTime) {
oldestTime = entry.lastUsedTime;
oldestKey = key;
}
});
if (oldestKey) {
const entry = this.cache.get(oldestKey);
if (entry) {
this.currentSize -= entry.size;
entry.pixelMap.release(); // 释放内存
}
this.cache.delete(oldestKey);
}
}
clear(): void {
this.cache.forEach(entry => {
entry.pixelMap.release();
});
this.cache.clear();
this.currentSize = 0;
}
}
内存缓存的大小设置:
内存缓存设多大合适?这取决于设备和应用:
- 太小:命中率低,很多图要从磁盘/网络读
- 太大:占用内存多,可能导致 OOM
一般经验:
- 低端机:5-10 MB
- 中端机:10-20 MB
- 高端机:20-40 MB
也可以根据可用内存动态计算,比如取最大可用内存的 1/8。
2.2 磁盘缓存
磁盘缓存是第二级。图片下载后存在本地文件里,下次用直接读文件,不用再下载。
磁盘缓存的实现思路:
- 把图片 URL 哈希后作为文件名
- 存在应用的缓存目录里
- 有容量限制,超过了淘汰旧的
- 可以设置过期时间
import fs from '@ohos.file.fs';
export class DiskCache {
private cacheDir: string = '';
private maxSize: number = 100 * 1024 * 1024; // 最大 100MB
constructor(cacheDir: string, maxSize?: number) {
this.cacheDir = cacheDir;
if (maxSize) {
this.maxSize = maxSize;
}
// 确保目录存在
this.ensureDir();
}
private ensureDir(): void {
try {
if (!fs.accessSync(this.cacheDir)) {
fs.mkdirSync(this.cacheDir);
}
} catch (e) {
console.error('[DiskCache] create dir failed:', JSON.stringify(e));
}
}
private hashKey(key: string): string {
// 简单的哈希,实际可以用 MD5/SHA 等
let hash = 0;
for (let i = 0; i < key.length; i++) {
const char = key.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16);
}
private getCachePath(key: string): string {
return this.cacheDir + '/' + this.hashKey(key);
}
get(key: string): ArrayBuffer | null {
const path = this.getCachePath(key);
try {
if (!fs.accessSync(path)) {
return null;
}
const file = fs.openSync(path, fs.OpenMode.READ_ONLY);
const stat = fs.statSync(path);
const buffer = new ArrayBuffer(stat.size);
fs.readSync(file.fd, buffer);
fs.closeSync(file);
return buffer;
} catch (e) {
console.error('[DiskCache] get failed:', JSON.stringify(e));
return null;
}
}
put(key: string, data: ArrayBuffer): void {
const path = this.getCachePath(key);
try {
const file = fs.openSync(path, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
fs.writeSync(file.fd, data);
fs.closeSync(file);
// 检查容量,超过了就清理
this.trimIfNeeded();
} catch (e) {
console.error('[DiskCache] put failed:', JSON.stringify(e));
}
}
private trimIfNeeded(): void {
// 简化实现:实际项目中需要计算目录大小,超过了就淘汰旧的
// 可以用 LRU、FIFO 等策略
}
clear(): void {
try {
const files = fs.listFileSync(this.cacheDir);
files.forEach(file => {
fs.unlinkSync(this.cacheDir + '/' + file);
});
} catch (e) {
console.error('[DiskCache] clear failed:', JSON.stringify(e));
}
}
}
磁盘缓存的大小:
磁盘缓存一般设 50-200 MB,取决于图片的数量和大小。图片多的 App 可以设大一点,少的设小一点。
2.3 网络加载
第三级就是从网络下载图片。
import http from '@ohos.net.http';
export class ImageDownloader {
async download(url: string): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const httpRequest = http.createHttp();
httpRequest.request(url, {
method: http.RequestMethod.GET,
expectDataType: http.HttpDataType.ARRAY_BUFFER,
}, (err, data) => {
if (err) {
reject(err);
} else {
if (data.responseCode === 200 && data.result) {
resolve(data.result as ArrayBuffer);
} else {
reject(new Error('HTTP ' + data.responseCode));
}
}
httpRequest.destroy();
});
});
}
}
2.4 整合:ImageLoader
把三级缓存整合起来,做成一个统一的 ImageLoader:
import image from '@ohos.multimedia.image';
import { MemoryCache } from './MemoryCache';
import { DiskCache } from './DiskCache';
import { ImageDownloader } from './ImageDownloader';
export class ImageLoader {
private static instance: ImageLoader | null = null;
private memoryCache: MemoryCache;
private diskCache: DiskCache;
private downloader: ImageDownloader;
private constructor(cacheDir: string) {
this.memoryCache = new MemoryCache(20 * 1024 * 1024); // 20MB 内存缓存
this.diskCache = new DiskCache(cacheDir, 100 * 1024 * 1024); // 100MB 磁盘缓存
this.downloader = new ImageDownloader();
}
static getInstance(cacheDir?: string): ImageLoader {
if (!ImageLoader.instance && cacheDir) {
ImageLoader.instance = new ImageLoader(cacheDir);
}
return ImageLoader.instance!;
}
async loadImage(url: string): Promise<image.PixelMap> {
// 1. 先查内存缓存
const memoryResult = this.memoryCache.get(url);
if (memoryResult) {
console.info('[ImageLoader] hit memory cache:', url);
return memoryResult;
}
// 2. 再查磁盘缓存
const diskResult = this.diskCache.get(url);
if (diskResult) {
console.info('[ImageLoader] hit disk cache:', url);
const pixelMap = await this.decodeImage(diskResult);
// 存到内存缓存
const size = diskResult.byteLength;
this.memoryCache.put(url, pixelMap, size);
return pixelMap;
}
// 3. 都没有,从网络下载
console.info('[ImageLoader] download from network:', url);
const imageData = await this.downloader.download(url);
// 存到磁盘缓存
this.diskCache.put(url, imageData);
// 解码
const pixelMap = await this.decodeImage(imageData);
// 存到内存缓存
this.memoryCache.put(url, pixelMap, imageData.byteLength);
return pixelMap;
}
private async decodeImage(data: ArrayBuffer): Promise<image.PixelMap> {
const imageSource = image.createImageSource(data);
const pixelMap = await imageSource.createPixelMap();
return pixelMap;
}
clearMemoryCache(): void {
this.memoryCache.clear();
}
clearDiskCache(): void {
this.diskCache.clear();
}
clearAllCache(): void {
this.clearMemoryCache();
this.clearDiskCache();
}
}
使用流程:
调用 loadImage(url)
↓
内存缓存有? → 直接返回
↓ 没有
磁盘缓存有? → 读出来 → 解码 → 存内存 → 返回
↓ 没有
网络下载 → 存磁盘 → 解码 → 存内存 → 返回
💡 以上是简化版的实现,用于理解原理。
实际项目中,一个完整的图片加载库要考虑的东西多得多:
- 图片解码的采样率(inSampleSize)
- 下载的并发控制
- 列表滑动时暂停加载
- 预加载
- 渐进式加载
- 图片变换(圆角、模糊等)
- 等等……
推荐使用成熟的开源库,不要自己造轮子。
步骤3:占位图与错误图
占位图和错误图是图片加载的"底线体验"——图片加载成功了大家都一样,加载中、加载失败时,才能看出用不用心。
3.1 占位图的设计
占位图(Placeholder)是图片加载过程中显示的图。
好的占位图应该:
- 和目标图尺寸一致:避免加载成功后"跳一下"
- 颜色接近:避免加载成功后"闪一下"
- 有品牌感:用品牌色、Logo 等,强化品牌印象
- 不喧宾夺主:只是占位,不要太花哨
常见的占位图方案:
| 方案 | 说明 | 适用场景 |
|---|---|---|
| 纯色占位 | 纯色背景 | 简单、性能好 |
| 品牌 Logo | 居中的小 Logo | 有品牌感 |
| 骨架图 | 灰色的形状占位 | 列表、详情页 |
| 缩略图 | 低分辨率的模糊图 | 有缩略图时(渐进式) |
3.2 错误图的设计
错误图(Error Image)是图片加载失败时显示的图。
好的错误图应该:
- 告诉用户"加载失败了":不是白屏,不是空白
- 不突兀:和整体风格协调
- 可以点击重试(可选):给用户一个补救的机会
3.3 实战:封装一个带占位和错误的图片组件
我们来封装一个 SmartImage 组件,支持:
- 加载中显示占位图
- 加载失败显示错误图
- 失败可以点击重试
- 淡入动画
@Component
export struct SmartImage {
@Prop src: string = '';
@Prop width: number | string = '100%';
@Prop height: number | string = '100%';
@Prop placeholder: ResourceStr = $r('app.media.img_placeholder');
@Prop errorImage: ResourceStr = $r('app.media.img_error');
@Prop borderRadius: number = 0;
@Prop objectFit: ImageFit = ImageFit.Cover;
@Prop onRetry?: () => void;
@State isLoading: boolean = true;
@State isError: boolean = false;
@State imageOpacity: number = 0;
private loadKey: number = 0;
aboutToAppear(): void {
this.startLoad();
}
aboutToUpdate(): void {
// src 变化时重新加载
// 注意:这里简化了,实际需要比较 src 是否真的变了
}
private startLoad(): void {
this.isLoading = true;
this.isError = false;
this.imageOpacity = 0;
this.loadKey++; // 改变 key,触发 Image 重新加载
}
build() {
Stack({ alignContent: Alignment.Center }) {
// 占位图 / 错误图
if (this.isLoading || this.isError) {
Image(this.isError ? this.errorImage : this.placeholder)
.width(this.width)
.height(this.height)
.borderRadius(this.borderRadius)
.objectFit(ImageFit.Contain)
.opacity(0.6)
}
// 真实图片
if (!this.isError) {
Image(this.src)
.width(this.width)
.height(this.height)
.borderRadius(this.borderRadius)
.objectFit(this.objectFit)
.opacity(this.imageOpacity)
.onComplete(() => {
this.isLoading = false;
// 淡入动画
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.imageOpacity = 1;
});
})
.onError(() => {
this.isLoading = false;
this.isError = true;
})
}
// 加载失败时,点击重试
if (this.isError) {
Column({ space: 4 }) {
Text('\u{1F504}')
.fontSize(20)
Text('点击重试')
.fontSize(12)
.fontColor('#999999')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.startLoad();
if (this.onRetry) {
this.onRetry();
}
})
}
}
.width(this.width)
.height(this.height)
.borderRadius(this.borderRadius)
.clip(true)
.backgroundColor('#F5F5F5')
}
}
使用示例:
SmartImage({
src: ethnic.coverImageUrl,
width: 80,
height: 80,
borderRadius: 12,
objectFit: ImageFit.Cover
})
3.4 淡入动画
图片加载成功后,不要直接"蹦"出来,而是淡入——体验会好很多。
@State imageOpacity: number = 0;
Image(src)
.opacity(this.imageOpacity)
.onComplete(() => {
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.imageOpacity = 1;
});
})
为什么用 300ms?
- 太短(<100ms):感觉不到,和直接显示差不多
- 太长(>500ms):用户会觉得慢
- 300ms 是比较舒服的时长——能感觉到"渐入",但不会觉得慢
步骤4:图片的尺寸优化与压缩
图片是内存大户。一张 1080x1920 的图片,解码后要占多少内存?
算一下:
- ARGB_8888 格式:每个像素 4 字节
- 1080 × 1920 = 2,073,600 像素
- 2,073,600 × 4 = 8,294,400 字节 ≈ 8 MB
一张图就 8MB! 一个列表有十几张图,分分钟就 OOM 了。
所以,图片的尺寸优化非常重要。
4.1 图片的内存占用
图片在内存中的大小,和文件大小(KB/MB)没关系,只和这几个因素有关:
内存占用 = 宽度 × 高度 × 每个像素的字节数
| 像素格式 | 每个像素字节数 | 质量 | 适用场景 |
|---|---|---|---|
| ARGB_8888 | 4 字节 | 最高 | 大多数场景 |
| RGB_565 | 2 字节 | 中等 | 不需要透明度的图 |
| ARGB_4444 | 2 字节 | 较差 | 对质量要求不高的 |
| ALPHA_8 | 1 字节 | 只有透明度 | 遮罩、纯文字图 |
一张 100x100 的图片:
- ARGB_8888:100 × 100 × 4 = 40,000 字节 ≈ 40 KB
- RGB_565:100 × 100 × 2 = 20,000 字节 ≈ 20 KB
差了一倍!
4.2 采样率压缩(inSampleSize)
如果一张原图是 1000x1000,但显示的位置只有 100x100,那完全没有必要把整张大图都解码出来。
可以用采样率(inSampleSize),每隔几个像素取一个,这样解码出来的图就小了。
| inSampleSize | 解码后尺寸 | 内存占用 |
|---|---|---|
| 1 | 1000x1000 | 1x |
| 2 | 500x500 | 1/4 |
| 4 | 250x250 | 1/16 |
| 8 | 125x125 | 1/64 |
inSampleSize 是 2 的幂次方(1, 2, 4, 8, 16…),不是随便的数字。
怎么计算合适的 inSampleSize:
function calculateInSampleSize(
srcWidth: number,
srcHeight: number,
targetWidth: number,
targetHeight: number
): number {
let inSampleSize = 1;
if (srcWidth > targetWidth || srcHeight > targetHeight) {
const halfWidth = srcWidth / 2;
const halfHeight = srcHeight / 2;
// 只要缩小一半后还比目标大,就继续除 2
while (
(halfWidth / inSampleSize) >= targetWidth &&
(halfHeight / inSampleSize) >= targetHeight
) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
例子:
- 原图:1000x1000
- 目标:100x100
- 计算:1000/2=500, 500/2=250, 250/2=125, 125/2=62.5(不够了)
- 结果:inSampleSize = 8
- 解码后尺寸:125x125,内存是原来的 1/64
这样就从 8MB 变成了 125KB,差了 64 倍!
4.3 图片尺寸优化的最佳实践
1. 服务端提供多尺寸图片
最好的方式是服务端提供不同尺寸的图片,需要多大就请求多大的。
// 原图
https://example.com/image.jpg
// 小图(缩略图)
https://example.com/image.jpg?w=200&h=200
// 中图(列表用)
https://example.com/image.jpg?w=400&h=400
// 大图(详情页用)
https://example.com/image.jpg?w=800&h=800
列表用小图,详情页用大图。既省流量,又省内存。
2. 列表用缩略图,点击查看原图
列表里的图片小,用缩略图;用户点进去看大图,再加载原图。
3. 选择合适的图片格式
| 格式 | 透明 | 动画 | 压缩率 | 适用场景 |
|---|---|---|---|---|
| JPG | ❌ | ❌ | 高 | 照片、复杂图片 |
| PNG | ✅ | ❌ | 中 | 图标、透明图 |
| WebP | ✅ | ✅ | 很高 | 推荐,比 JPG/PNG 小 25-50% |
| GIF | ✅ | ✅ | 低 | 简单动画 |
WebP 是目前推荐的格式,同样质量下,体积比 JPG 小 30% 左右,比 PNG 小 50% 左右。
步骤5:列表图片的性能优化
列表是图片性能问题的重灾区。几十上百张图片,快速滚动,很容易出现卡顿、闪烁、错位等问题。
5.1 列表图片的常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 滑动卡顿 | 滑动时图片解码、加载,占用主线程 | 异步加载、列表复用、滑动时暂停加载 |
| 图片闪烁 | 列表项复用,旧图还在,新图还没加载好 | 占位图、淡入动画 |
| 图片错位 | 列表项复用,前一个请求回来了,显示到后一个项上 | 设置 tag、取消旧请求 |
| 内存占用高 | 图片太多太大,占满内存 | 三级缓存、采样率压缩、滑动时释放 |
| 流量消耗大 | 每次都从网络下载 | 磁盘缓存、预加载缩略图 |
5.2 列表复用与图片错位
列表的核心优化是复用——滑动出去的项,会被复用给新滑进来的项。
但这也带来了问题:
- 第 1 项开始加载图片 A
- 用户往下滑,第 1 项滑出去,被复用到第 10 项
- 第 10 项开始加载图片 B
- 这时候图片 A 加载好了,显示到了第 10 项上(错位!)
- 过了一会儿图片 B 加载好了,又变成 B(闪烁!)
这就是"图片错位"问题。
解决方案:
方案1:给 Image 设置 tag,加载完成后检查
@State currentUrl: string = '';
aboutToAppear(): void {
this.currentUrl = this.src;
// 开始加载...
}
onImageLoadComplete(url: string): void {
// 加载完成后,检查是不是当前需要的 url
if (url === this.currentUrl) {
// 是,显示
}
// 不是,丢掉,不显示
}
方案2:列表项滑出时取消加载
// 列表项滑出可视区域时,取消图片加载
// (实际实现取决于具体的列表组件)
方案3:使用成熟的图片库
成熟的图片加载库(比如 Glide、Picasso、Fresco)都已经处理好这些问题了,不用自己操心。
5.3 滑动时暂停加载
快速滑动时,用户其实看不清图片,这时候加载图片意义不大,反而占用资源,导致滑动卡顿。
所以,很多图片库都有"滑动时暂停加载,停止滑动后再加载"的策略:
- 快速滑动:暂停加载新图片,只显示已经加载好的
- 停止滑动(滑动速度降下来):开始加载可见区域的图片
这样滑动更流畅,用户体验更好。
5.4 预加载
除了"滑动时暂停",还可以"预加载"——提前加载用户即将看到的图片。
比如用户正在看第 1-10 项,提前把第 11-15 项的图片也加载了。等用户滑到的时候,图片已经在内存里了,直接显示,不需要等。
预加载和暂停加载配合使用:
- 快速滑动:不加载
- 慢速滑动 / 停止:加载可见区域 + 预加载前后几屏
步骤6:实战:民族封面图的加载优化
让我们结合「民族图鉴」项目,实现一个优化后的民族卡片图片组件。
@Component
export struct EthnicCoverImage {
@Prop imageUrl: string = '';
@Prop size: number = 56;
@Prop isCircle: boolean = true;
@Prop borderWidth: number = 0;
@Prop borderColor: string = '#FFFFFF';
@State isLoading: boolean = true;
@State isError: boolean = false;
@State showImage: boolean = false;
build() {
Stack({ alignContent: Alignment.Center }) {
// 占位背景
Circle({ width: this.size, height: this.size })
.fill('#F0F0F0')
// 加载中:骨架效果
if (this.isLoading) {
Circle({ width: this.size, height: this.size })
.fill('#E8E8E8')
.opacity(0.5)
.animation({
duration: 1000,
curve: Curve.EaseInOut,
iterations: -1,
playMode: PlayMode.Alternate
})
}
// 错误图
if (this.isError) {
Text('\u{1F5BC}\u{FE0F}')
.fontSize(this.size * 0.4)
.opacity(0.5)
}
// 真实图片
if (!this.isError && this.imageUrl) {
Image(this.imageUrl)
.width(this.size)
.height(this.size)
.objectFit(ImageFit.Cover)
.borderRadius(this.isCircle ? this.size / 2 : 0)
.border({
width: this.borderWidth,
color: this.borderColor
})
.opacity(this.showImage ? 1 : 0)
.animation({ duration: 300, curve: Curve.EaseOut })
.onComplete(() => {
this.isLoading = false;
this.showImage = true;
})
.onError(() => {
this.isLoading = false;
this.isError = true;
})
}
}
.width(this.size)
.height(this.size)
.borderRadius(this.isCircle ? this.size / 2 : 0)
.clip(true)
}
}
优化点:
- 占位背景:浅灰色圆形背景,避免白屏
- 骨架闪烁:加载中显示一个呼吸效果的骨架,比静态占位图更有"正在加载"的感觉
- 淡入动画:加载成功后淡入,不突兀
- 错误状态:加载失败显示一个图片图标,用户知道"加载失败了"
- 圆形裁剪:支持圆形头像,用 borderRadius 而不是外部裁剪
使用示例:
// 民族列表中的头像
EthnicCoverImage({
imageUrl: ethnic.coverImageUrl,
size: 48,
isCircle: true
})
// 详情页的封面图
EthnicCoverImage({
imageUrl: ethnic.coverImageUrl,
size: 120,
isCircle: true,
borderWidth: 3,
borderColor: '#FFFFFF'
})
⚠️ 常见问题与解决方案
问题1:图片闪烁
现象:
列表滚动时,图片一会儿显示这个,一会儿显示那个,闪来闪去。或者图片加载成功后,"嘣"一下蹦出来,很突兀。
常见原因及解决方案:
原因1:列表复用导致的错位
前面讲过的,列表项复用,旧图片还没加载完,项已经被复用了。
解决:
- 给图片设置"当前 url"标记,加载完检查一下
- 列表项滑出时取消旧的加载请求
- 使用成熟的图片加载库
原因2:没有占位图,加载中是白的
图片加载过程中是白的,加载成功后突然出现,就会"闪"。
解决:
- 加占位图,颜色和图片接近
- 加淡入动画,从透明渐变到不透明
@State opacity: number = 0;
Image(src)
.opacity(this.opacity)
.onComplete(() => {
animateTo({ duration: 300 }, () => {
this.opacity = 1;
});
})
原因3:占位图和目标图差异太大
占位图是浅灰色,图片是深颜色的,对比强烈,就会闪。
解决:
- 占位图的颜色尽量和图片的主色调接近
- 或者用模糊的缩略图当占位图(渐进式加载)
问题2:内存溢出(OOM)
现象:
图片看多了,应用闪退,日志里报 OOM(Out Of Memory)。
常见原因及解决方案:
原因1:图片太大,没有采样
一张大图解码出来几 MB,十几张就几十 MB,很容易 OOM。
解决:
- 根据显示尺寸计算采样率,不需要解码那么大
- 列表用缩略图,不要用原图
- 服务端提供多尺寸图片
原因2:内存缓存太大
内存缓存设得太大,占了太多内存。
解决:
- 根据设备内存动态调整缓存大小
- 一般取最大可用内存的 1/8 左右
- 应用退到后台时,可以清空部分内存缓存
原因3:图片没有释放
加载的图片用完了没有释放,越积越多。
解决:
- 用 LRU 缓存,超出容量自动释放旧的
- 页面销毁时,释放该页面的图片
- 大图片用完就放,不要一直占着
问题3:列表图片错位
现象:
快速滑动列表,图片显示的是错的——A 的图片显示到 B 的位置上。过一会儿又变对了。
原因:
列表项复用 + 异步加载,旧请求回来了,显示到了新的项上。
解决方案:
方案1:设置 tag,加载完检查
// 每个列表项有自己的 url
@Prop imageUrl: string = '';
@State currentTag: string = '';
aboutToAppear(): void {
this.currentTag = this.imageUrl;
this.loadImage(this.imageUrl);
}
private onLoadSuccess(url: string, pixelMap: any): void {
// 只有 url 匹配才显示
if (url === this.currentTag) {
// 显示图片
}
}
方案2:项滑出时取消请求
// 列表项滑出可视区域时
onDisappear(): void {
this.cancelLoad(); // 取消当前的图片加载请求
}
方案3:用成熟的图片库
成熟的图片加载库都已经处理好这些问题了,强烈推荐直接用。
问题4:图片圆角性能差
现象:
列表里的图片都是圆角的,滑动起来很卡。
原因:
实时裁剪圆角是比较耗性能的操作,特别是很多图片同时裁剪的时候。
解决方案:
方案1:用 borderRadius 而不是 clip
borderRadius 是硬件加速的,比 clip 性能好。
// ✅ 好:borderRadius
Image(...)
.width(100)
.height(100)
.borderRadius(50)
// ❌ 不好:clip + 外部容器
Circle() {
Image(...)
}
方案2:预先生成圆角图
如果圆角图会被多次用到,可以提前生成好圆角的图片缓存起来,下次直接用,不用每次都裁剪。
方案3:不要太复杂的裁剪
圆形、简单圆角还好,复杂的路径裁剪(比如心形、星形)就比较耗性能。如果列表里很多,考虑用简单的形状。
问题5:列表滑动时图片加载慢
现象:
滑动列表,滑到新的位置,图片要等一会儿才出来,感觉"加载很慢"。
原因:
图片从磁盘加载、解码,需要时间。如果每次滑到都要从磁盘读、解码,就会慢。
解决方案:
方案1:预加载
用户正在看第 1-10 项,提前把第 11-20 项的图片也加载了。等用户滑到的时候,已经在内存里了。
方案2:内存缓存够大
内存缓存够大的话,用户滑过去再滑回来,图片还在内存里,直接显示。
方案3:缩略图 + 原图渐进式
先加载一个很小的缩略图(模糊的),让用户有个大概印象,然后再加载清晰的原图。
📝 本章小结
核心知识点
本文从图片加载的完整流程讲起,系统介绍了三级缓存、占位图、尺寸优化、列表性能等内容:
1. Image 组件进阶
- alt 占位图
- onComplete / onError 回调
- objectFit、renderMode 等属性
2. 三级缓存策略
- 内存缓存:最快,容量小,LRU 淘汰
- 磁盘缓存:较快,容量中等,持久化
- 网络:最慢,无限,耗流量
- 优先从内存 → 磁盘 → 网络
3. 占位图与错误图
- 占位图:加载中显示,避免白屏
- 错误图:加载失败显示,给用户反馈
- 淡入动画:加载成功后平滑过渡
- 点击重试:失败了可以重试
4. 图片尺寸优化
- 内存占用 = 宽 × 高 × 每像素字节数
- 采样率(inSampleSize):解码时就缩小
- 服务端多尺寸:需要多大要多大
- 图片格式选择:WebP > JPG > PNG
5. 列表图片性能优化
- 列表复用带来的问题:闪烁、错位
- 解决错位:tag 检查、取消旧请求
- 滑动时暂停加载,停止后再加载
- 预加载:提前加载即将看到的
最佳实践总结
✅ 三级缓存是标配
内存 → 磁盘 → 网络
优先从最快的地方拿
下载后存到缓存里,下次就快了
✅ 占位图 + 错误图 + 淡入动画
加载中:占位图,不白屏
加载失败:错误图,有反馈
加载成功:淡入,不闪烁
这三个是图片加载的底线体验
✅ 尺寸优化从源头做
服务端提供多尺寸图片,需要多大要多大
不要拿原图来缩小,浪费流量又占内存
列表用缩略图,详情页用大图
✅ 列表图片要特别注意性能
列表是性能重灾区,图片又是列表里最重的
防止错位、防止闪烁、滑动时优化
能用成熟的图片库就用,不要自己造轮子
✅ 内存缓存要节制
不是缓存越大越好
太大了会 OOM,太小了命中率低
根据设备情况动态调整,一般取可用内存的 1/8
✅ WebP 是个好东西
同样质量,体积比 JPG 小 30%
支持透明,比 PNG 小 50%
有条件的话,尽量用 WebP
省流量,加载还快
下一步预告
恭喜你!你已经完成了「民族图鉴」HarmonyOS 应用开发系列的全部 52 篇文章。
从环境搭建、项目结构,到 ArkTS 基础、声明式 UI;从基础组件、状态管理,到路由、列表、资源管理;从页面开发、服务架构,到性能优化、工程实践;从动画、自定义组件,到手势、弹窗、下拉刷新、图片加载……
你已经系统性地掌握了 HarmonyOS 应用开发的完整知识体系。
但这不是结束,而是开始。真正的成长,来自于实战——把学到的知识用到真实的项目中,遇到问题、解决问题、总结经验。
愿你在鸿蒙开发的道路上,越走越远,越走越顺。
加油,开发者! 🚀
🔗 相关链接
💡 提示:图片加载是一个"水很深"的领域——看似简单,实则有无数细节。一个好的图片加载库,是无数开发者踩坑踩出来的。如果你的项目对图片要求高,强烈建议使用成熟的开源图片库,不要自己从头造轮子。但理解背后的原理,能让你更好地使用这些库,遇到问题也能快速定位和解决。
更多推荐

所有评论(0)