iOS 网络大图加载优化:从解码内存、缓存到 Cell 复用

最近整理网络图片加载相关问题时,我发现一个很容易被忽略的点:很多时候,我们以为图片加载慢是网络问题,但真正影响体验的,往往是图片解码、内存占用和 UI 生命周期。

比如一张 JPG 文件看起来只有几 MB,但只要被解码成位图,内存可能瞬间变成几十 MB。列表里如果连续加载几张大图,就容易出现滑动卡顿、图片错位、白屏,严重时甚至会 OOM。

所以这篇文章不重点讨论“怎么把图片请求下来”,而是整理一下 iOS 加载网络大图时,真正需要控制的几个点:尺寸、解码、缓存、Cell 复用、并发和异常兜底。

先看一段很多人最开始都会写的代码:

let data = try Data(contentsOf: url)
imageView.image = UIImage(data: data)

如果图片很小、页面不复杂,这样写确实能跑。但放到真实业务里,问题很快就会暴露出来:原图被完整解码、内存不可控、没有取消逻辑、Cell 复用后可能错位,弱网下还容易出现白屏。

一、为什么大图容易出问题?

我以前也容易只看图片文件大小,比如一张 JPG 只有 2MB,就下意识觉得它不算大。但在 iOS 里,真正需要关注的是它解码后的位图大小。

图片解码后的内存大致可以按这个方式估算:

宽度 * 高度 * 每像素字节数

假设一张图是 4000 * 3000,RGBA 每像素 4 字节:

4000 * 3000 * 4 = 48MB

这还只是一张图。

如果列表里同时出现多张大图,再加上缓存、解码过程中的中间对象和页面其它资源,内存压力会来得非常快。

所以我现在处理这类问题时,会先记住一个原则:

不要用原图尺寸去解决展示尺寸的问题。

如果一个 UIImageView 最终只展示 300 200,就不应该把一张 4000 3000 的图片完整解码进内存。能在解码阶段缩小,就不要等到 UIImage 已经创建出来之后再缩放。

二、先把图片加载拆开看

iOS 网络图片加载不是“请求图片,然后显示”这么简单。实际链路可以拆成几步:

URL -> 网络请求 -> 数据缓存 -> 图片解码 -> 尺寸处理 -> UI 展示

每一步都有自己的风险:

环节 常见问题 处理重点
网络请求 超时、取消、弱网、404 超时控制、错误分类、重试策略
数据缓存 重复下载、缓存失控 URLCache、磁盘缓存、过期策略
图片解码 内存暴涨、主线程卡顿 后台解码、按需降采样
尺寸处理 原图过大、展示浪费 Downsample 到目标尺寸
UI 展示 图片错位、闪烁 请求绑定、复用取消
异常兜底 白屏、失败无反馈 占位图、错误图、降级策略

拆开之后,问题会清楚很多。图片加载优化不是只盯着下载速度,而是要让整条链路都可控。

三、几种常见方案对比

方案一:直接下载原图并设置给 UIImageView

let data = try await URLSession.shared.data(from: url).0
imageView.image = UIImage(data: data)

这种写法优点只有一个:简单。

但缺点也很明显:

  • 原图会被完整解码,内存不可控
  • 解码可能影响主线程,导致滑动卡顿
  • 没有明确的缓存策略
  • 没有取消逻辑
  • 列表复用时容易出现图片错位
  • 异常处理太粗糙

所以它只适合 demo 或小图场景,不适合生产环境里的大图列表。

方案二:使用成熟图片库

常见选择有 Kingfisher、SDWebImage、Nuke 等。

这些库通常已经处理了内存缓存、磁盘缓存、请求取消、占位图、图片解码、格式扩展和 Cell 复用等问题。对于大多数业务来说,优先使用成熟图片库是合理的。

但使用图片库并不等于问题自动消失。至少还要注意三点:

  • 默认配置不一定适合大图场景
  • 服务端如果长期返回超大原图,客户端只能缓解,不能根治
  • 极端列表、瀑布流、长图和动图场景,仍然需要额外控制内存和并发

方案三:自建轻量图片加载器

有些项目不想引入第三方依赖,或者图片加载逻辑本身比较简单,也可以自建一个轻量版本。

但自建的重点不是把功能做得很全,而是先守住核心风险:

取消请求 + 缓存 + 降采样 + 异常兜底 + 主线程更新 UI

只要这几件事没做好,代码看起来再简洁,放到列表里也很容易出问题。

四、核心实践一:按显示尺寸降采样

处理大图时,最关键的一步是 downsampling,也就是在解码阶段就把图片缩到接近展示尺寸,而不是先完整解码原图,再用 draw(in:) 去缩放。

推荐使用 ImageIO:

import UIKit
import ImageIO

func downsampleImage(
    data: Data,
    pointSize: CGSize,
    scale: CGFloat = UIScreen.main.scale
) -> UIImage? {
    let sourceOptions: [CFString: Any] = [
        kCGImageSourceShouldCache: false
    ]

    guard let source = CGImageSourceCreateWithData(
        data as CFData,
        sourceOptions as CFDictionary
    ) else {
        return nil
    }

    let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale

    let downsampleOptions: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
    ]

    guard let cgImage = CGImageSourceCreateThumbnailAtIndex(
        source,
        0,
        downsampleOptions as CFDictionary
    ) else {
        return nil
    }

    return UIImage(cgImage: cgImage)
}

这里有几个点要注意:

  • kCGImageSourceShouldCache: false:创建 source 时不要立刻缓存原始解码数据
  • kCGImageSourceCreateThumbnailFromImageAlways: true:直接创建缩略图
  • kCGImageSourceThumbnailMaxPixelSize:控制缩略图最大像素尺寸
  • kCGImageSourceCreateThumbnailWithTransform: true:处理图片方向信息

这比 UIImage(data:) 后再缩放更适合大图,因为它避免了完整解码原图。

另外,downsample 本身也有 CPU 成本,不建议在主线程里直接做。列表场景下,我会把它放到后台任务里处理,完成后再回到 MainActor 更新 UI。

五、核心实践二:缓存要带上尺寸信息

图片缓存不能只按数量控制,因为一张大图和一张小图的成本完全不同。

内存缓存可以用 NSCache,并设置 cost:

import UIKit

struct ImageCacheKey: Hashable {
    let url: URL
    let width: Int
    let height: Int
    let scale: Int

    init(url: URL, pointSize: CGSize, scale: CGFloat) {
        self.url = url
        self.width = Int(pointSize.width.rounded())
        self.height = Int(pointSize.height.rounded())
        self.scale = Int(scale.rounded())
    }

    var cacheKey: NSString {
        "\(url.absoluteString)#\(width)x\(height)@\(scale)x" as NSString
    }
}

final class ImageMemoryCache {
    private let cache = NSCache<NSString, UIImage>()

    init() {
        cache.totalCostLimit = 80 * 1024 * 1024
        cache.countLimit = 200
    }

    func image(for key: ImageCacheKey) -> UIImage? {
        cache.object(forKey: key.cacheKey)
    }

    func insert(_ image: UIImage, for key: ImageCacheKey) {
        cache.setObject(
            image,
            forKey: key.cacheKey,
            cost: image.memoryCost
        )
    }
}

private extension UIImage {
    var memoryCost: Int {
        guard let cgImage else { return 0 }
        return cgImage.bytesPerRow * cgImage.height
    }
}

这里我没有只用 URL 做缓存 key,而是把展示尺寸和 scale 也放了进去。

原因很简单:同一张图片 URL,在列表页可能只需要 120 120,在详情页可能需要 600 400。如果缓存 key 只有 URL,详情页就可能拿到列表页的小图,或者列表页拿到过大的图。

所以对于 downsample 后的图片,URL + targetSize + scale 会更稳妥。

缓存策略里还有一个经验:

内存缓存更适合放“马上可能再次展示的图片”,磁盘缓存更适合放“原始数据或处理后的稳定结果”。

对于大图列表,缓存 downsample 后的展示图通常更划算,因为它更接近 UI 真正需要的尺寸。

六、核心实践三:列表复用必须处理取消和错位

在 UITableViewCell 或 UICollectionViewCell 里加载网络图,最常见的问题就是图片错位。

典型场景是:

cell A 开始加载 URL 1
用户快速滑动
cell A 被复用去展示 URL 2
URL 1 后返回
结果 URL 1 的图片设置到了 URL 2 的 cell 上

解决思路其实不复杂:

  1. 复用时取消旧请求
  2. 图片返回时确认当前 URL 是否仍然匹配

示例:

final class ImageCell: UICollectionViewCell {
    private var imageTask: Task<Void, Never>?
    private var currentURL: URL?

    override func prepareForReuse() {
        super.prepareForReuse()

        imageTask?.cancel()
        imageTask = nil
        currentURL = nil
        imageView.image = placeholderImage
    }

    func configure(url: URL, loader: ImageLoader) {
        imageTask?.cancel()

        currentURL = url
        imageView.image = placeholderImage

        imageTask = Task { [weak self] in
            do {
                let image = try await loader.loadImage(
                    from: url,
                    targetSize: CGSize(width: 120, height: 120)
                )

                guard !Task.isCancelled else { return }

                await MainActor.run {
                    guard self?.currentURL == url else { return }
                    self?.imageView.image = image
                }
            } catch is CancellationError {
                return
            } catch {
                await MainActor.run {
                    guard self?.currentURL == url else { return }
                    self?.imageView.image = errorImage
                }
            }
        }
    }
}

这段代码真正想表达的不是某种固定写法,而是一个生命周期意识:

图片请求属于某一次展示任务,不是永久属于某个 cell。

Cell 被复用、页面退出、图片已经不可见时,请求就应该及时取消。

七、核心实践四:异常处理要分类

图片加载失败不应该只有一个 catch。

至少可以分成几类:

异常类型 示例 处理方式
网络错误 断网、超时 显示重试入口或占位图
HTTP 错误 404、403、500 根据状态码决定是否重试
数据错误 返回不是图片 记录日志,显示错误图
解码失败 图片格式异常 降级展示,避免崩溃
请求取消 Cell 复用、页面退出 静默处理
内存压力 大量图片加载 清缓存、降并发、降质量

可以定义一个明确的错误类型:

enum ImageLoadingError: Error {
    case invalidResponse
    case httpStatus(Int)
    case emptyData
    case decodeFailed
}

这里要特别注意 cancellation。取消请求通常不是业务错误,不应该显示失败图,也不应该上报成异常。比如用户快速滑动列表,旧 cell 对应的请求被取消,这是正常行为。

八、核心实践五:控制并发,别让请求和解码一起冲上来

大图加载不只是网络消耗,还会带来 CPU 解码和内存分配。

如果一个瀑布流页面同时发起几十个图片请求,就算网络没问题,解码阶段也可能把 CPU 和内存打满。

我一般会从三个层面控制:

  • 只预取即将出现的图片,不要无限预加载
  • 页面消失或 cell 离屏时取消任务
  • 限制同时解码的大图数量

复杂一点的页面,可以把图片加载拆成优先级队列:

高优先级:当前屏幕可见图片
中优先级:即将出现的预加载图片
低优先级:离屏缓存或非关键图片

这样当前屏幕的图片会优先展示,资源使用也更可控。

九、一个简化版 ImageLoader

下面是一个轻量版的结构示意,重点是把缓存、请求、状态码判断、后台 downsample 串起来:

final class ImageLoader {
    private let memoryCache = ImageMemoryCache()
    private let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func loadImage(
        from url: URL,
        targetSize: CGSize,
        scale: CGFloat = UIScreen.main.scale
    ) async throws -> UIImage {
        let cacheKey = ImageCacheKey(
            url: url,
            pointSize: targetSize,
            scale: scale
        )

        if let cachedImage = memoryCache.image(for: cacheKey) {
            return cachedImage
        }

        let request = URLRequest(
            url: url,
            cachePolicy: .returnCacheDataElseLoad,
            timeoutInterval: 15
        )

        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw ImageLoadingError.invalidResponse
        }

        guard (200..<300).contains(httpResponse.statusCode) else {
            throw ImageLoadingError.httpStatus(httpResponse.statusCode)
        }

        guard !data.isEmpty else {
            throw ImageLoadingError.emptyData
        }

        let image = try await Task.detached(priority: .utility) {
            guard let image = downsampleImage(
                data: data,
                pointSize: targetSize,
                scale: scale
            ) else {
                throw ImageLoadingError.decodeFailed
            }
            return image
        }.value

        memoryCache.insert(image, for: cacheKey)
        return image
    }
}

这只是一个最小版本,真实项目里还可以继续补磁盘缓存、并发限制、预加载、请求合并和内存警告处理。

但即使是这个版本,也已经比直接 UIImage(data:) 稳很多,因为它至少做到了:

  • 不直接解码原图
  • 有内存缓存
  • 缓存 key 包含展示尺寸
  • 有 HTTP 状态码判断
  • 有超时控制
  • downsample 放到后台任务
  • 可以和 cell 生命周期配合取消

十、服务端也应该参与图片优化

很多图片问题,如果只靠客户端兜底,会很累。

更合理的做法是让服务端或图片 CDN 支持按需裁剪,比如:

https://example.com/image.jpg?w=600&h=400&format=webp

这样客户端拿到的就是接近展示尺寸的图片,而不是一张完全不受控制的原图。

服务端可以做这些事:

  • 根据设备宽度返回不同尺寸
  • 支持 WebP、HEIF 等更高压缩率格式
  • 使用 CDN 缓存不同尺寸的图片变体
  • 控制最大原图尺寸
  • 返回合理的 Cache-Control
  • 提供缩略图、预览图、原图分层加载

客户端 downsample 是兜底手段,不应该长期替服务端擦屁股。源头图片尺寸失控,客户端库再强也只能缓解,不能根治。

十一、实践清单

如果把 iOS 网络大图加载落到项目里,我会优先检查这些点:

  • 不要直接把网络原图 UIImage(data:) 后塞进 UIImageView
  • 根据展示尺寸 downsample,而不是完整解码后再缩放
  • 图片解码和处理尽量离开主线程
  • 内存缓存设置 totalCostLimit,并按图片真实内存成本计算 cost
  • 缓存 key 不要只看 URL,大图场景要考虑 targetSize 和 scale
  • 列表 cell 复用时取消旧请求
  • 图片返回时确认 URL 仍然匹配当前 cell
  • 请求失败要区分网络错误、HTTP 错误、解码失败和取消
  • 页面退出或图片不可见时取消任务
  • 控制同时加载和解码的大图数量
  • 服务端尽量提供裁剪、压缩和缓存能力
  • 内存警告时清理非关键缓存
  • 对超大图、长图、动图、渐进图单独设计策略

十二、边界条件和例外

不是所有图片都要走同一套策略。

头像、小图标、表情这类小图,可以更依赖内存缓存和简单加载。

详情页大图、商品图、瀑布流图片,需要重点处理尺寸和缓存。

超长图、漫画图、地图截图、医学影像这类特殊图片,可能需要分块加载、瓦片渲染或专门的查看器。

GIF、WebP 动图还要额外考虑帧缓存和播放内存。

所以不要一上来就问“iOS 图片加载有没有一个统一方案”。更好的问题是:

这张图片在哪个场景展示?展示多大?出现频率多高?失败后用户是否可接受?内存风险在哪里?

这些问题问清楚了,方案基本也就清楚了。

结语

iOS 加载网络图片,看起来是一个 UI 问题,本质上更像资源管理问题。

网络只是第一步。真正决定体验的是:有没有控制解码尺寸,有没有绑定 UI 生命周期,有没有管理缓存成本,有没有处理失败和取消,有没有让服务端一起参与图片尺寸控制。

我的理解是,客户端至少要守住三条线:

第一,不要用原图尺寸去解决展示尺寸的问题。
第二,图片请求要和当前 UI 生命周期绑定,页面退出或 Cell 复用时及时取消。
第三,服务端最好返回接近展示尺寸的图片,客户端 downsample 只能兜底,不能长期救火。

把这几件事处理好,图片加载才会从“能显示”变成“稳定、可控、可维护”。