SwiftUI|优化本地图片加载性能

了解图片加载中哪些场景最消耗性能,并通过创建缩略图的方式改善 App 性能表现。

SwiftUI|优化本地图片加载性能

这篇文章讲解,如何处理显示大量本地图片,同同时保持流畅的交互体验。

图片加载耗时的场景

从网络下载图片,并本地创建缩略图进行显示的流程可能是这样的:

https://www.youtube.com/watch?v=r_clm92NI1s

在上面这个典型案例中,以下场景通常可以迅速返回结果,并不会造成卡顿:

  • 第一步:从 String 构造 URLRequest 请求
  • 第三步:从已下载的 data 数据构造 UIImage

以下场景可能造成主线程阻塞:

  • 第二步:通过网络下载图片数据。造成阻塞的原因是下载需要时间。
  • 第四步:从数据渲染缩略图。造成阻塞的原因是,需要大量的计算资源。

因此,第一步和第三步,可以同步执行。第三步和第四步,必须异步执行才能保证 App 流畅运行。

从 data 解码为 UIImage 非常快

我以为会很慢,但事实并非如此。

UIImage(data:) 虽然涉及一定程度的解码,但苹果在底层对图片解码过程做了大量优化。并且网络中传输的图片数据通常是 JPEG 或 PNG 格式,这些格式是 UIImage 默认支持的。因此,这一过程通常非常高效。

为什么必须创建缩略图

创建缩略图主要是为了减少内存占用减少缩放运算

减少内存占用

  • 虽然从 Data 转换到 UIImage 很快,但是 UIImage 在内存中是完整解码的位图
  • 例如一张 4032×3024 的照片(12MP),即使压缩过的 JPEG 只有 2MB,解码后在内存中可能占用 40MB+ (4032×3024×4bytes)
  • 而 300×225 的缩略图只需要占用 270KB 内存

减少缩放运算

  • 当显示一张 4032×3024 的图片在 300×225 的 UIImageView 中时,系统需要进行像素重采样。即使在 UI 上显示小图(分辨率是原图),iOS 仍然需要对整张图进行缩放运算。
  • 特别是在滚动列表时,这种实时缩放会消耗大量 CPU/GPU 资源

在以下示例中,同时展示 20 张图片时,内存占用从不到 100MB 突增至 600MB。虽然每张图片的文件大小已压缩至不足 1MB,但在显示时,由于图片被解码为未压缩的像素数据,其内存占用显著增加。

使用 UIKit 提供的 preparingForDisplaypreparingThumbnail ,通过异步的方式创建缩略图之后,内存明显减小,并且没有了卡顿感。

是否持久化缩略图数据?

方案一:持久化缩略图数据

持久化存储缩略图数据(例如 SwiftData),缺点是需要额外存储空间,原图更新时需要同步更新缩略图。适合:

  • 照片管理应用(如Photos.app)
  • 图片较多的社交应用
  • 需要快速滚动的网格视图

例如,Textify 应用就采用该方案。

方案二:在显示时按需创建缩略图

每次显示都需要解码和缩放,性能上比方案一略差。适合:

  • 图片数量较少的应用
  • 临时性图片显示
  • 图片尺寸需求多变的场景

整体来说,以上两种方案都挺好,可以优先常用方案二,并在仍然感觉到性能瓶颈时,再过渡到方案一。

创建缩略图

byPreparingThumbnail(异步)

UIKit 从 iOS15 开始,提供了异步的 byPreparingThumbnail 方法,用于为 UIImage 创建缩略图。

byPreparingThumbnail 使用方法非常简单,他作为 UIKit 的官方 API,可以直接通过 UIImage 调用。

以下方法通过同步的方式,加载显示原图:

if let image = UIImage(data: attachment.fileData) {
    await MainActor.run {
        withAnimation(.spring) {
            loadedImages.append(
                (attachment: attachment, image: image)
            )
        }
    }
}

以下优化后的代码,通过异步的方式,创建图片的缩略图用于显示:

if let image = UIImage(data: attachment.fileData) {
    // 生成缩略图

    if let thumbnail = await image.byPreparingThumbnail(
        ofSize: CGSize(width: 600, height: 600))
    {
        await MainActor.run {
            withAnimation(.spring) {
                loadedImages.append(
                    (attachment: attachment, image: thumbnail)
                )
            }
        }
    }
}

这可以显著提高性能表现,减少卡顿和内存占用。

byPreparingForDisplay

还有另一个异步方法 byPreparingForDisplay,用于优化用于显示的图片。

关于 byPreparingForDisplay 方法,官方文档没有太多说明。

根据 Twitter 的信息:

  • byPreparingForDisplay 似乎自动在后台线程解码图像,有利于滚动时的流畅性。
  • byPreparingForDisplay 似乎对 GIF 动画图像的表现更佳。

经过测试显示,

  • byPreparingForDisplay 不会改变图片分辨率,因此内存占用仍然很大。
💡
byPreparingForDisplay 似乎会导致 transition 动画出现异常,原因未知。并且,我没有察觉到明显的性能提升,建议先不使用。

最佳实践做法(2025 年 5 月)

  • 直接显示缩略图,内存占用大概 80 MB。
  • 直接显示原图,内存占用大概 300 MB。
    • 如果使用原图,任然会感觉到卡顿,虽然图片解码在后台线程执行。
  • 显示原图,但使用 byPreparingForDisplay 创建缩略图,内存占用大概

在异步+后台线程中解码图片

创建 async 方法解码图片,并在 View 中通过 .task 调用它。

其他问题

Image vs UIImage vs NSImage

SwiftUI 中的 Image

SwiftUI 中的 Image 具有跨平台的能力(iOS、macOS、tvOS、watchOS)。

它可以从多种图像来源创建(如资产、系统图标、UIKit 或 AppKit 的图像对象等)。

  • 用 Image("someAssetName")(从 Asset Catalog 中加载),
  • 用 Image(uiImage:) 传入 UIKit 的图像
  • 用 Image(nsImage:) 传入 AppKit 的图像(针对 macOS)。

UIKit 中的 UIImage

UIImage 是在 UIKit 里用来表示图像的核心对象,主要适用于 iOS 平台。

如果要在 SwiftUI 中使用一个 UIImage,通常可以通过 Image(uiImage: someUIImage) 的方式,将 UIImage 转换成 SwiftUI 的 Image 来进行显示。

AppKit 中的 NSImage

属于 AppKit,主要用于 macOS。

如果在 macOS 上用 SwiftUI,需要用 Image(nsImage: someNSImage) 的形式把 AppKit 的图像转换为 SwiftUI 的视图进行展示。

为什么推荐使用 Image?

跨平台,并且支持各种修饰符(如 .resizable(), .scaledToFit() 等)。

使用 JPEG 展示图片,减少使用 HEIC 或其他格式

在之前的文章中,我们讲解过使用 SwiftData 来存储图片。得益于苹果的 HEIC 格式图片的高压缩率,将图片转换为 HEIC 格式后再存储,可以大大减小存储空间的占用:

在 Swift 开发中使用 HEIC/HEIF 压缩图片大小
了解如何在 iOS 开发中使用更现代的 HEIC/HEIF 格式,以提高应用的性能,并通过 SwiftData 实现图片的自动转换和存储,轻松优化图片处理过程。

但正如我在上面这篇文章开头提到的:

“经过一段时间对 HEIC 格式的实际使用,发现虽然它能节省约 50% 的存储空间,但对设备性能的要求更高。展示图片时,HEIC 文件需要解码为常规的 JPEG,否则应用可能会出现明显的卡顿,特别是在同时展示数十张图片的情况下。尽管可以通过异步方式进行解码和加载,但每次解码都会占用内存,同时也增加了编程的复杂性。

因此,如果你的 App 不需要存储大量图片,或者不必利用 HEIC 保存原始信息的优势,建议直接使用 JPEG 格式。为了减少存储空间而增加性能负担和编程难度,并不值得。


如果你仍然需要使用 HEIC 格式存储照片,那么就需要在图片展示之前进行解码,可以简单的使用 .jpegData 方法实现转换:

compressedData = image.jpegData(compressionQuality: 1.0)

当我们设置 compressionQuality: 1.0 时,发生了什么?

标准 JPEG 解码速度更快,这对于在滚动时需要快速渲染图像非常重要。