最近整理网络图片加载相关问题时,我发现一个很容易被忽略的点:很多时候,我们以为图片加载慢是网络问题,但真正影响体验的,往往是图片解码、内存占用和 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 上
解决思路其实不复杂:
- 复用时取消旧请求
- 图片返回时确认当前 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 只能兜底,不能长期救火。
把这几件事处理好,图片加载才会从“能显示”变成“稳定、可控、可维护”。