简介:这是一份针对iOS开发中UITableView异步加载网络图片的完整示例工程,适合需要在列表场景下优化图片加载性能的初中级开发者。资源以Apple官方LazyTableImages为基础,包含自定cell、图片下载与缓存、占位图及主线程更新等核心代码,同时讲解SDWebImage、Kingfisher等第三方库接入方式,并给出URLSession自定义异步加载方案。压缩包共33个文件,以Objective-C源码(.m/.h)、xib界面布局、png图片资源及plist配置为主,附带工程配置文件,整体仅73KB,结构紧凑便于直接阅读与调试。目前已有309人学习下载,适合想要理解列表图片异步加载原理并快速应用到实际项目的开发者。通过阅读可掌握通过NSOperationQueue或GCD进行并发控制、利用NSCache缓存图片、避免重复下载的优化思路,提升UITableView滚动流畅度。
1. ios tableview 异步 加载图片:卡顿和错位都出在线程与复用上
ios tableview 异步 加载图片是我在搜索框里经常看到的一个组合。它描述的具体任务是:UITableView 的 cell 要从网络把图拉下来显示,但网络请求、图片解码这类耗时操作如果直接放在主线程,列表滚动就会变成 PPT。异步加载要做的,就是把下载和解码挪到后台线程,等结果回来后再回主线程设置 image。下面按机制、现成方案、自建方案、常见问题和进阶优化这条线展开,给出一套能照着复现的参数与写法,适合初到中级的 iOS 开发者,也适合想弄清楚 SDWebImage 内部到底做了什么的人。
2. 先弄清楚卡顿的根因,再谈异步:同步请求、图片解码和 cell 复用
2.1 一次极端的同步加载演示:在主线程用 dataWithContentsOfURL 会发生什么
新手最常见的写法是在cellForRowAt里直接同步下载:
if let url = URL(string: "https://example.com/xxx.png"), let data = try? Data(contentsOf: url) { cell.imageView?.image = UIImage(data: data) }这段代码在主线程完成了几件非常耗时的事。Data(contentsOf:)要发起 DNS 解析、建立 TCP 连接、等服务端返回数据,通常要花 100~500 毫秒;UIImage(data:)还要做图片解码,一张比较大的 JPEG 会再占用 20~300 毫秒。一屏通常有 8~10 个 cell,每多一个 cell 调一次,主线程就多卡几百毫秒。结果是列表首次进入时白屏很久,滑动时 cell 像 PPT 一样一页一页跳出来,帧率可以掉到 20fps 以下。
出现这种问题时,用 Xcode 的 Time Profiler 采样,主线程的调用栈会大量落在网络读取相关方法上。把这段同步逻辑改成异步,才是“ios tableview 异步 加载图片”这个需求的第一步落地。
2.2 异步编程的复杂度:下载是异步了,UI 更新还是必须回主线程
第一版异步改造通常长这样:
DispatchQueue.global().async { let data = try? Data(contentsOf: url) let image = UIImage(data: data) DispatchQueue.main.async { cell.imageView?.image = image } }这一版比同步略好,但只解决了一半问题。网络下载和解码确实离开了主线程,但至少还有三个隐患。第一,没有任何取消机制,cell 滚出屏幕后下载仍在跑,浪费流量和带宽。第二,没有判断“这个 cell 是否还是发起请求时的那个 cell”,图片错乱几乎是必然的,稍后第 2.3 节展开。第三,没有缓存,同一张图每次滚动都要重新请求。
异步编程的难点从来不在于会用DispatchQueue.global().async,而在于把状态管理、取消策略、并发控制一起设计好。网络层异步了,UI 更新却只能在主线程做,这条限制不会因为用了异步方法就消失。主线程 RunLoop 在手指拖动时处于 UITracking 模式下,普通DispatchQueue.main.async的 block 可能要等滚动停下来才被派发。在这个模式下如果你发现图片“滚动时不出来、停下来突然出现”,不是网络慢,而是回调派发时机被系统延后了。
2.3 cell 复用机制:异步加载最容易的错乱来源
UITableView 通过复用池来减少频繁创建 cell 的开销。一个 cell 滚出屏幕后会被放回池中,再滚回来时直接从池里取出重配置。这就意味着同一个内存地址的 cell 先被配置成 A 行的数据,异步请求还没回来,它又被配置成 B 行的数据。等到 A 的下载回调执行imageView.image = image时,cell 已经显示 B 行了,于是 B 行被强行铺上 A 的图。
处理这类异步回调错位,业内常见做法是双保险:配置 cell 时记录当前 URL,回调里校验 URL 是否仍一致;同时在 cell 准备复用时取消没有完成的加载任务。只做 URL 校验或只做取消,在极端时序下都可能有漏网之鱼。SDWebImage 的sd_setImage内部已经实现这套机制,第 3 章会直接演示它的标准写法;第 4 章的自建方案里,我会给出一个不依赖三方库的校验实现。
2.4 同步与异步的完整对比:四个环节缺一不可
| 对比维度 | 同步加载 | 异步加载 |
|---|---|---|
| 网络请求耗时 | 阻塞主线程,卡顿明显 | 后台执行,主线程不阻塞 |
| 图片解码耗时 | 主线程占用几十到几百毫秒 | 可放后台,或延迟解码 |
| UI 更新路径 | 配置时直接赋值 | 回调回主线程再赋值 |
| cell 复用风险 | 不存在旧任务 | 需要 URL 校验与任务取消 |
| 缓存设计 | 一般搭配磁盘缓存 | 内存加磁盘双层缓存更合理 |
“异步”不是简单把耗时操作挪到后台线程就完事。一条完整的异步加载链路应该是:网络请求异步、图片解码尽量异步、最终设置 image 回到主线程,并且用复用校验和任务取消保证“最后设置的图一定属于当前 cell”。把这四个环节想明白,后面用成熟库也好,自己写也罢,方向都不会偏。
2.5 用时间戳和 Instruments 量化卡顿,别靠感觉调优
调优前先量化。最简单的做法是在同步下载代码前后打点:
let start = CFAbsoluteTimeGetCurrent() let data = try? Data(contentsOf: url) let image = UIImage(data: data!) print("cost: \((CFAbsoluteTimeGetCurrent() - start) * 1000) ms")单次超过 100ms 人就能察觉,超过 250ms 就是肉眼可见的掉帧。如果同步加载出现在cellForRowAt里,每一帧的画面都会被阻塞,Instruments 里的 Time Profiler 会显示主线程大量时间花在网络和图片 IO 上。优化目标很明确:把这块耗时从主线程摘出去,让主线程只做配置和赋值。后面第 6 章还会给出一个用 CADisplayLink 统计滚动 FPS 的方法,把“卡不卡”变成可对比的数字。
3. 用 SDWebImage 落地异步加载:接入命令、三个必调参数与复用安全写法
3.1 接入:Swift Package Manager 还是 CocoaPods
SDWebImage 是目前 iOS 列表异步加载图片最常用的库。接入方式有两种,新工程我一般用 Swift Package Manager,在 Xcode 的 File 菜单里选择 Add Package Dependencies,粘贴官方仓库地址后选择版本即可;老旧工程如果已经用 CocoaPods,接法更简单:
platform :ios, '13.0' use_frameworks! target 'DemoApp' do pod 'SDWebImage' end然后在终端执行pod install。两种方式最终在代码里都是import SDWebImage,没有区别。选择依据是团队已有依赖管理习惯,不需要为了这个决定纠结。
3.2 cellForRow 中的标准写法:占位图、加载选项与缩略尺寸
接入后,cellForRowAt里一行代码就能完成异步加载:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "ImageCell", for: indexPath) as! ImageCell let url = imageURLs[indexPath.row] cell.photoView.sd_setImage( with: url, placeholderImage: UIImage(named: "placeholder"), options: [.retryFailed, .refreshCached], context: [.imageThumbnailPixelSize: CGSize(width: 300, height: 300)] ) return cell }这段代码已经自带三项关键能力。第一,SDWebImage 在配置新 URL 时会自动取消 imageView 之前的加载任务,避免复用错乱;第二,请求期间由 placeholder 占位,视觉上不会出现一片空白;第三,.retryFailed让之前失败过的 URL 在当前这行再次出现时能重新发起请求,.refreshCached则适合 URL 不变但内容可能更新的图。
context里的.imageThumbnailPixelSize是很多人忽略的优化项。它告诉 SDWebImage,这张图最终只显示在 300×300 的逻辑像素区域里,解码时直接生成缩略图,而不是把 4000 像素宽的原图完整解进内存。列表场景强烈建议加上,尤其是 cell 尺寸较小、原图偏大的信息流。
3.3 缓存参数:maxDiskAge、maxMemoryCost 与失败重试的边界
SDWebImage 的缓存是全局单例,参数可以按业务调:
let cacheConfig = SDImageCache.shared.config cacheConfig.maxMemoryCost = 200 * 1024 * 1024 cacheConfig.maxDiskSize = 1000 * 1024 * 1024 cacheConfig.maxDiskAge = 60 * 60 * 24 * 3这里有一个容易踩坑的细节:maxMemoryCost的单位不是字节,而是“像素个数”。一张 200×300 的图片 cost 约 6 万像素,经验值是给普通信息流列表开到 100M~200M 的量级,注意这个数字不是严格的 MB 概念。如果你只是头像列表,小图密集,内存缓存可以开小一点,避免把其他模块的内存占用挤掉。
磁盘缓存方面,maxDiskAge默认是 7 天。列表图如果要求内容及时更新,建议缩短到 1~3 天,也就是上面代码的写法。缓存淘汰按“文件最后访问时间”排序,maxDiskSize触发清理时有可能清掉高频使用的旧图,实际项目中这个行为要知道。
对于失败重试,.retryFailed只表示 SDWebImage 不缓存“失败状态”,下一次调用sd_setImage会重新请求。SDWebImage 本身不会做指数退避自动重试,重试节奏通常由业务方控制,比如下拉刷新时清掉失败 URL 再刷新列表。
3.4 复用安全:prepareForReuse 里应不应该清 image
用 SDWebImage 时,复用安全基本由库保证。真正需要决策的是prepareForReuse里要不要把 image 置空。快速滚动时如果看到旧图一闪而过、新图再顶上来,说明复用的 cell 带着上一行的图片渲染了一帧。此时在prepareForReuse里清空 image 能减少错位感,但副作用是内存缓存命中时也会先白一下再出图,白闪可能更明显。
我一般按场景取舍:头像、商品图这类对内容准确性要求高的,在prepareForReuse里清空;瀑布流、长列表这类追求流畅的,保留旧图让 SDWebImage 自己校验替换。两条路都用过,结论是视觉上的“旧图渐变置换”比“闪白再出现”更容易被接受,但如果内容是用户明确知道会变的,清空更保险。
4. 不引入第三方:NSURLSession + 双层缓存的自建异步加载方案
如果团队想减少依赖,或者你希望彻底看懂异步加载的每个环节,自建一套也可以控制得很好。下面这套方案是我在代码量受限的旧项目里实际采用过的结构,核心是下载器去重、双层缓存、以及 UIImageView 分类里的 URL 校验。
4.1 下载器去重与回调合并:同一个 URL 只发一次请求
首要是解决重复请求问题。一个列表快速滚动时,同一张图可能被多个位置的 cell 请求,但网络请求只需要发一次,多个调用方共享结果即可。
final class ImageDownloader { static let shared = ImageDownloader() private let session: URLSession private let queue = OperationQueue() private var handlers: [URL: [(UIImage?, Error?) -> Void]] = [:] private let lock = NSLock() private init() { queue.maxConcurrentOperationCount = 4 session = URLSession(configuration: .ephemeral, delegate: nil, delegateQueue: queue) } func loadImage(url: URL, completion: @escaping (UIImage?, Error?) -> Void) { lock.lock() if var list = handlers[url] { list.append(completion) handlers[url] = list lock.unlock() return } handlers[url] = [completion] lock.unlock() let task = session.dataTask(with: url) { [weak self] data, _, error in guard let self = self else { return } var resultImage: UIImage? if let data = data { resultImage = UIImage(data: data) } self.lock.lock() let callbacks = self.handlers.removeValue(forKey: url) ?? [] self.lock.unlock() callbacks.forEach { $0(resultImage, error) } } task.resume() } }这段代码的关键设计在handlers字典。第一次请求某个 URL 时,字典里没有记录,于是创建新任务并放入一个回调;第二次请求同一个 URL 时,直接追加回调,不再创建网络任务。下载完成后一次性取出全部回调并逐一执行,达到“多 cell 共享一次请求”的效果。
NSLock保护字典的原因是 URLSession 的回调发生在 delegateQueue 线程上,而loadImage可能同时从主线程被调用,不加锁轻则丢回调,重则崩溃。maxConcurrentOperationCount = 4把同时进行的下载数量限制在 4 个左右,避免快速滑动时几十个请求同时涌向服务器。这个并发数可以根据服务器压力适当调整,2~4 是安全区间。
注意一个坑:如果UIImage(data:)返回 nil,这个 URL 并没有被记录为失败,下次还会重新请求。真实工程里通常需要一个失败集合,比如在短时间内直接回调 error,避免反复请求同一张坏图。
4.2 内存缓存 + 磁盘缓存的双层设计
下载器拿到图片后不能立刻丢给 UI,还要过缓存。内存缓存管速度,磁盘缓存管“不重复下载”。下面是一个精简版:
final class ImageCache { static let shared = ImageCache() private let memory = NSCache<NSString, UIImage>() private let fileManager = FileManager.default private lazy var diskDir: URL = { let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] return caches.appendingPathComponent("ImageCache", isDirectory: true) }() init() { memory.totalCostLimit = 150 * 1024 * 1024 try? fileManager.createDirectory(at: diskDir, withIntermediateDirectories: true) } private func fileURL(for url: URL) -> URL { let key = md5(url.absoluteString) return diskDir.appendingPathComponent(key) } func image(for url: URL) -> UIImage? { if let image = memory.object(forKey: url.absoluteString as NSString) { return image } guard let data = try? Data(contentsOf: fileURL(for: url)), let image = UIImage(data: data) else { return nil } if image.size.width * image.size.height > 1024 * 1024 { return image } memory.setObject(image, forKey: url.absoluteString as NSString) return image } func store(_ image: UIImage, for url: URL) { if image.size.width * image.size.height <= 1024 * 1024 { memory.setObject(image, forKey: url.absoluteString as NSString) } guard let data = image.jpegData(compressionQuality: 0.8) else { return } try? data.write(to: fileURL(for: url), options: .atomic) } }磁盘目录放在 Caches 而不是 Documents,系统存储紧张时会自动清理 Caches,这符合“图片只是临时资源”的定位。内存缓存用totalCostLimit,单位同样是像素个数,一张大图能瞬间吃掉很高的 cost,所以大图不放入内存缓存,避免列表内存波动太大。md5(url.absoluteString)可以用 CryptoKit 的Insecure.MD5转成哈希字符串,目的是让 URL 变成合法的文件名,避免斜杠等字符引起路径问题。
4.3 UIImageView 分类:关联对象保存 URL,回调先校验再赋值
自建方案最关键的是 cell 复用保护。做法是利用 Objective-C 的关联对象,在 imageView 上记住“当前正在请求的 URL”,回调返回时先比较再赋值。
private var imageURLKey: UInt8 = 0 extension UIImageView { func sak_setImage(with url: URL, placeholder: UIImage?) { image = placeholder objc_setAssociatedObject(self, &imageURLKey, url, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) if let cached = ImageCache.shared.image(for: url) { image = cached return } ImageDownloader.shared.loadImage(url: url) { [weak self] image, _ in DispatchQueue.main.async { guard let self = self else { return } guard let currentURL = objc_getAssociatedObject(self, &imageURLKey) as? URL, currentURL == url else { return } if let image = image { self.image = image ImageCache.shared.store(image, for: url) } } } } func sak_cancelCurrentLoad() { objc_setAssociatedObject(self, &imageURLKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } }每次设置前先把 placeholder 赋上去,避免复用时显示上一行的图。回调里第一步校验关联 URL 是否仍是发起请求时的那个 URL,不一致直接丢弃。这个校验是 cell 复用时最后一道刹车。
sak_cancelCurrentLoad用在 cell 的prepareForReuse里,把关联对象清空,让旧回调过不了校验。这个方案比 SDWebImage 内部实现简略,但核心原理一致。自建方案的另一件事是它目前没有子线程缩略优化,如果要处理高清大图,需要把 4.2 里的UIImage(data:)换成第 5.4 节的缩略图逻辑。
5. 异步加载图片的 5 个常见问题:现象、原因与解决
5.1 错位:cell 复用后加载出上一条的图片
现象:快速滚动时,看到 imageView 先显示上一行的图片,过几百毫秒才变成正确图;极端情况下某一行会一直显示错误图片。
原因:第一次配置发起的异步请求返回时,cell 已经被复用并配置成另一个 URL。旧回调没有做 URL 校验,直接给 image 赋值,新行就显示了旧图。
解决:依赖 SDWebImage 时用sd_setImage自动取消;自建方案里在prepareForReuse调用取消方法,并在回调里做 URL 校验。具体代码做法参照 4.3 节。另外,图片加载完成前保持 placeholder,不要展示空 image,能减少视觉跳动。
5.2 请求洪峰:快速滑动把几十个请求同时打到服务器
现象:滚动越快,列表越卡,服务器日志出现大量重复图片请求;停止滚动后仍有一堆任务在消耗流量。
原因:每次滚动都会触发cellForRowAt,每一个新 cell 都调用加载方法。快速滑动时虽然 cell 只显示一瞬间,但请求已经被发出,而且根本没有取消。
解决:按需加载,只在滚动停止或减速阶段发起可见 cell 的请求。可以借助 UIScrollViewDelegate:
extension ViewController: UIScrollViewDelegate { func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { loadVisibleImages() } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { loadVisibleImages() } } }思路是用户手指离开屏幕并且没有惯性滚动时才加载。快速滑动过程中,图片保持 placeholder 就好。某些项目用了 SDWebImage 却没做按需加载,并发请求数依然会很高。配合 4.1 节下载器里的请求合并,能做到同一 URL 只请求一次,服务器压力会明显下降。
5.3 后台线程更新 UI 崩溃,或主线程 RunLoop 模式导致图片延迟
现象:日志中出现 “This application is modifying the autolayout engine from a background thread”,或者图片滚动时不出现、停下来突然刷新。
原因:网络回调默认不在主线程,直接把 UIImage 设到 imageView 上属于跨线程 UI 操作。另一个隐蔽原因是主线程 RunLoop 在 UITracking 模式下,默认添加的 block 不会立即执行,导致图片更新被延后到滚动结束。
解决:无论第三方库是否保证回调主线程,自己写代码时 UI 更新要显式放在DispatchQueue.main.async里。SDWebImage 的 completed 回调默认在主线程,自建方案很可能在 URLSession 的回调线程上直接赋值,这一步必须补。滚动时希望回调更及时,可以换用向 RunLoop 的 CommonModes 添加 block 的方式,但一般场景用main.async已经足够。
5.4 大图解码内存峰值,多次内存警告后闪退
现象:流量正常,但列表打开后内存曲线从 80MB 直接跳到 200MB,从后台切回时闪退。
原因:一张 4000×6000 的原始图片,如果 cell 只显示 300 宽,完整解码到内存仍占约 96MB。cell 复用和滚动过程中,同一张图可能同时存在多份内存副本。
解决:缩略图降采样。SDWebImage 用 3.2 节的.imageThumbnailPixelSizecontext 参数即可;自建方案用 ImageIO 生成缩略图:
func downsampleImage(data: Data, to pointSize: CGSize) -> UIImage? { let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { return nil } let maxDim = max(pointSize.width, pointSize.height) * UIScreen.main.scale let thumbnailOptions = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceThumbnailMaxPixelSize: maxDim ] as CFDictionary guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions) else { return nil } return UIImage(cgImage: cgImage) }关键参数是kCGImageSourceThumbnailMaxPixelSize,它决定缩略图最长边的像素值。降采样应在子线程完成,完成后回主线程赋值。kCGImageSourceShouldCache设为 false,避免 ImageIO 把原图在内部再缓存一份,进一步压住内存。
5.5 换头像后一直显示旧图:缓存过期策略没定好
现象:服务端更新了同一 URL 的图片内容,列表却始终显示旧图;下拉刷新后依旧如此。
原因:URL 没变,内存缓存和磁盘缓存按 URL 作为 key,命中旧数据后直接返回,永远不会感知内容变化。
解决:优先让服务端换 URL 或加版本参数,例如头像链接变成https://example.com/avatar.png?v=2。如果接口无法改动,对这类图片开启 SDWebImage 的.refreshCached,每次先返回缓存数据,同时发起请求重新下载覆盖缓存。工程习惯上,头像类资源可以长期缓存,列表类图片缓存有效期控制在 1~3 天,避免旧图长期占用空间。
6. 进阶技巧:预加载、缩略图与滚动帧率自检
6.1 用 UITableViewDataSourcePrefetching 做提前加载
iOS 10 之后,系统提供了UITableViewDataSourcePrefetching代理,会在 cell 即将出现前通知你。预加载时只准备数据,不要在预加载回调里更新 UI。
extension ViewController: UITableViewDataSourcePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { for indexPath in indexPaths { let url = imageURLs[indexPath.row] ImageDownloader.shared.loadImage(url: url) { _ in } } } }系统会根据滑动速度决定预取多少行,所以不需要自己手动计算目标位置。预加载配合缓存之后,cellForRowAt里基本都能直接命中内存缓存,滚动体验最平滑。
6.2 预解码放到子线程
自建方案里,5.4 节的downsampleImage可以放在下载器回调中先执行,再回到主线程赋值。这样解码 CPU 开销不会落在主线程。这一步对滚动帧率的影响比很多人想象中大,尤其原图是 2MB 以上 JPEG 时。
6.3 用 CADisplayLink 量化滚动帧率
“卡不卡”不能靠感觉,要拿数据说话。一个简单的 FPS 监控器如下:
final class FPSMonitor { private var link: CADisplayLink? private var count = 0 private var lastTime: CFTimeInterval = 0 func start() { link = CADisplayLink(target: self, selector: #selector(tick(_:))) link?.add(to: .main, forMode: .common) } @objc private func tick(_ sender: CADisplayLink) { if lastTime == 0 { lastTime = sender.timestamp; return } count += 1 let interval = sender.timestamp - lastTime if interval >= 1 { let fps = Double(count) / interval if fps < 55 { print("滚动帧率掉到 \(fps)") } count = 0 lastTime = sender.timestamp } } }添加时用.common模式,才能捕获滚动过程中 UITracking 模式下的真实帧率。优化目标是滚动时稳定在 50fps 以上。静态页面显示 60fps 并不代表滚动不卡。
我早期接手过一个列表,图片全部在主线程同步加载,后来改成异步加载却又忘了取消旧任务,结果 UI 错乱得一塌糊涂。回过头看,异步加载图片的大多数坑不在下载本身,而在生命周期管理:谁发起、谁取消、结果回来时验证当前状态是否还有效。先把复用校验和按需加载写好,再谈缓存与缩略图,这个顺序能省下大量排查时间。希望帮到你。
本文还有配套的精品资源,点击获取