在 SwiftUI 中优化图片加载性能

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

理解图片加载耗时的场景

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

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

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

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

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

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


为什么从 data 解码为 UIImage 很快?

(我以为会很慢)

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

因此,这一过程通常非常高效。

为什么要创建缩略图?

为什么要创建缩略图,而不是直接显示已经解压的 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 ,通过异步的方式创建缩略图之后,内存明显减小,并且没有了卡顿感。

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() 等)。

核心原则

  1. 如果只显示一张图片,直接使用 Image 组件加载即可。
    1. 无论加载 assets 图片,还是加载 uiImage,通常都不会造成卡顿。
    2. 如果你感觉到卡顿,通常是其他原因导致的。例如是否使用 .glur 创建模糊?glur 调用 Metal,需要大量性能。
  2. 如果需要显示很多张图片,推荐通过异步并创建缩略图来显示。

使用 by PreparingThumbnail 创建缩略图(异步)

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

prepareThumbnail(of:completionHandler:) | Apple Developer Documentation
Creates a thumbnail image at the specified size asynchronously on a background thread.

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

prepareForDisplay(completionHandler:) | Apple Developer Documentation
Decodes an image asynchronously and provides a new one for display in views and animations.

byPreparingThumbnail 基本用法

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)
                )
            }
        }
    }
}

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

使用 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 解码速度更快,这对于在滚动时需要快速渲染图像非常重要。

显示多张图片都应该创建缩略图,显示一张图片可以渲染原图。

为图片添加缓存机制

在处理远程加载的图片时,本地缓存机制是非常好的实践。

但对于处理本地存储的图像时(例如使用 SwiftData 存储),通常没有必要实施图像缓存机制。因为从本地读取图片速度快,UIKit 和 SwiftUI 已进行了优化,缓存机制主要是为了减轻通过网络从远程来源加载图像时的延迟和带宽限制。

只有在处理高分辨率图片、非常高频繁访问相同图片或需要自定义图片处理时才考虑缓存(缓存到内存中)。

远程图片

SwiftUI 原生的 AsyncImage 组件。