在这里插入图片描述

📖 引言

图片是移动应用中最重要的视觉元素之一。

从应用图标、启动页,到列表中的封面图、详情页的大图……一个 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)是图片加载过程中显示的图。

好的占位图应该

  1. 和目标图尺寸一致:避免加载成功后"跳一下"
  2. 颜色接近:避免加载成功后"闪一下"
  3. 有品牌感:用品牌色、Logo 等,强化品牌印象
  4. 不喧宾夺主:只是占位,不要太花哨

常见的占位图方案

方案 说明 适用场景
纯色占位 纯色背景 简单、性能好
品牌 Logo 居中的小 Logo 有品牌感
骨架图 灰色的形状占位 列表、详情页
缩略图 低分辨率的模糊图 有缩略图时(渐进式)
3.2 错误图的设计

错误图(Error Image)是图片加载失败时显示的图。

好的错误图应该

  1. 告诉用户"加载失败了":不是白屏,不是空白
  2. 不突兀:和整体风格协调
  3. 可以点击重试(可选):给用户一个补救的机会
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. 第 1 项开始加载图片 A
  2. 用户往下滑,第 1 项滑出去,被复用到第 10 项
  3. 第 10 项开始加载图片 B
  4. 这时候图片 A 加载好了,显示到了第 10 项上(错位!)
  5. 过了一会儿图片 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)
  }
}

优化点

  1. 占位背景:浅灰色圆形背景,避免白屏
  2. 骨架闪烁:加载中显示一个呼吸效果的骨架,比静态占位图更有"正在加载"的感觉
  3. 淡入动画:加载成功后淡入,不突兀
  4. 错误状态:加载失败显示一个图片图标,用户知道"加载失败了"
  5. 圆形裁剪:支持圆形头像,用 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 应用开发的完整知识体系。

但这不是结束,而是开始。真正的成长,来自于实战——把学到的知识用到真实的项目中,遇到问题、解决问题、总结经验。

愿你在鸿蒙开发的道路上,越走越远,越走越顺。

加油,开发者! 🚀


🔗 相关链接


💡 提示:图片加载是一个"水很深"的领域——看似简单,实则有无数细节。一个好的图片加载库,是无数开发者踩坑踩出来的。如果你的项目对图片要求高,强烈建议使用成熟的开源图片库,不要自己从头造轮子。但理解背后的原理,能让你更好地使用这些库,遇到问题也能快速定位和解决。

Logo

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

更多推荐