简介:面向苹果平台开发者的表格视图异步加载图片示例工程,针对新闻列表、产品目录等大量图片场景中常见的滚动卡顿与主线程阻塞问题,提供从基本原理到实际落地的完整方案。压缩包共三十三个文件,涵盖源代码文件、界面布局文件、配置文件及图标资源,工程结构清晰,便于直接查看或改造;整体体积仅七十三千字节,十分轻量。已有三百零七人学习下载,适合初中级开发者作为性能优化参考。示例工程收录了表格视图异步加载图片的完整范例,项目内包含从网络请求、数据解析到单元格异步绑定的完整链路;读者可对照学习除常用第三方图片库之外的系统原生实现方式,深入理解后台下载、主线程刷新、并发控制与缓存机制。同时可借鉴占位图、图片尺寸适配、避免重复下载等优化技巧,小体积却覆盖了实际开发中常用的关键知识点。 说实话,看到“ios tableview 异步 加载图片”这个标题,我的第一反应是:又有人在滚动列表时被图片加载坑了。在 TableView 里展示网络图片,几乎是每个 iOS 开发每天都会碰到的需求。乍一听很简单,无非是把图片异步下载下来再显示到 cell 上,但真正自己动手写几轮就知道,这里面有主线程卡顿、cell 复用导致图片错位、缓存策略、弱网超时、大图解码一长串问题等着你。这篇文章是我把这些坑系统踩了一遍后的复盘:不只给代码,还会把为什么这样选型、哪些坑必须躲解释清楚。
不论你是刚接触 iOS 的新人,还是写了两三年 TableView 的老手,只要你的列表里要显示网络图片,这篇都值得你花十分钟看完。
1. 从一次“滚不动”的线上事故说起:卡顿与 cell 复用的双重问题
1.1 主线程到底被什么拖垮的
先复盘一个典型场景:下拉刷新后立刻滚动列表,结果列表像幻灯片一样一卡一卡。打开 Instruments 一看,主线程一堆耗时操作,其中就有Data(contentsOf:)或UIImage(contentsOfFile:)这种同步加载。别忘了,TableView 的布局、绘制、事件响应全部都在主线程上跑,主线程一旦有长时间阻塞,用户的触摸事件就会被堆积,于是表现为卡顿、掉帧、甚至白屏。
很多人误以为“异步下载”就是开个线程请求网络。其实这只是第一层,真正的元凶往往在后面:UIImage(data:)这个接口会同步进行图片解码。解码一张高清图可能耗时几十毫秒甚至上百毫秒,如果这个操作放在主线程,等于让 UI 线程去干 CPU 密集的脏活,不卡才怪。
我用一个容易理解的类比:主线程就像是餐厅里唯一的前台服务员,用户每滑一次就相当于一次点单。如果他为了上菜,必须自己先去后厨炒完菜再接待下一位客人,那后面所有排队的人都得陪他等。同步加载图片,就是在让主线程的大厨自己去后厨处理整桌菜。
1.2 cell 复用后出现的“幽灵图片”
更隐蔽的问题来自于 UITableViewCell 的复用机制。TableView 不会为每一行创建全新 cell,而是把滚出屏幕的 cell 丢进复用池,滚动到新位置时再从池里取出来用。这个机制保证了列表滚动流畅,但它也埋了一个雷:异步下载完成后,闭包里的cell很可能已经被复用到了另一个 indexPath。
我见过很多新手这样写:
let task = URLSession.shared.dataTask(with: url) { [weak cell] data, _, error in guard let data = data, let image = UIImage(data: data) else { return } DispatchQueue.main.async { cell?.imageView?.image = image } } task.resume()表面上看用了[weak cell]防止循环引用,但这并不能阻止图片错位。快速滚动时,第一次请求发出去后还没回来,这个 cell 就已经被滚出屏幕,并复用于第 7 行的数据。等图片返回时,闭包里的cell还是同一个对象,但界面早已经是第 7 行的内容了。于是你会看到第 1 行的图片“瞬移”到了第 7 行。这就是我在实际项目中遇到的“幽灵图片”。
所以,真正的异步加载,从来不只是“开个子线程下载”,还要处理“下载完成后,这个 cell 还值不值得更新”的问题。
2. 先动手写一个基础异步加载:URLSession 回调不够,还要管好解码和取消
2.1 最朴素的 URLSession + 行号校验
先别急着上 SDWebImage,我们先从手写方案理解原理。用 URLSession 的dataTask发起请求,核心逻辑就三步:下载 Data、把 Data 转成 UIImage、回到主线程赋值给 imageView。如果我们在第 3 步前加一个“校验当前 cell 是否还对应同一个 indexPath”的判断,就能避开大部分复用错乱问题。
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) let url = dataList[indexPath.row].imageURL cell.imageView?.image = placeholderImage let targetIndexPath = indexPath URLSession.shared.dataTask(with: url) { data, _, error in guard let data = data, let image = UIImage(data: data), error == nil else { return } DispatchQueue.main.async { // 关键:检查 cell 当前显示的还是不是目标行 guard let currentIndexPath = tableView.indexPath(for: cell), currentIndexPath == targetIndexPath else { return } cell.imageView?.image = image } }.resume() return cell }这里的关键是tableView.indexPath(for: cell)。它能拿到这个 cell 此刻在 TableView 中对应的真实位置。如果和发起请求的targetIndexPath不一致,说明 cell 已经被复用到别的行了,直接丢弃图片,绝不要赋值。这个判断虽然朴素,但解决了 80% 的错乱问题。
2.2 别在主线程执行 UIImage(data:)
上面的代码还有一个隐患:UIImage(data: data)在闭包里执行。URLSession 的 completion 默认在后台线程调用,所以这行代码并不在主线程。但如果你把下载好的 data 丢回主线程再做图片构造,那就会把解码压力压回主线程,卡顿依然存在。
正确的姿势是:让后台线程完成解码,主线程只负责把解码好的 image 赋给 imageView。
DispatchQueue.global(qos: .userInitiated).async { guard let data = data, let image = UIImage(data: data) else { return } DispatchQueue.main.async { guard let currentIndexPath = tableView.indexPath(for: cell), currentIndexPath == targetIndexPath else { return } cell.imageView?.image = image } }这种“后台下载 + 后台解码 + 主线程赋值”的组合,是我认为手写方案里性价比最高的做法。注意qos用.userInitiated,表示用户主动触发的加载,系统会适当提高优先级,但又不会像.userInteractive那样抢占过多资源。
2.3 prepareForReuse 里别忘了取消旧请求
另外一个容易踩的坑是prepareForReuse。cell 被复用之前会调用这个方法,默认系统只会重置一些基础状态。如果你不在这里取消上一次的下载任务,旧的请求可能还在网络上跑,即使不会导致图片错位,也白白浪费流量和资源。
我的习惯是给 cell 增加一个任务持有,比如在自定义 cell 里加一个URLSessionDataTask属性,然后在prepareForReuse里插一句loadTask?.cancel()。配合上面的行号校验,基本能保证待复用的 cell 不会带着旧包袱上新场。
3. 缓存和并发调度:NSCache、磁盘缓存和 OperationQueue 的配合
3.1 没有缓存,再好的异步也扛不住
很多手写方案的硬伤是没有缓存。同一张图片在列表里反复出现,比如用户头像、商品主图,每次cellForRowAt都重新走一遍网络下载。浪费流量是小事,滚回去再滚回来还要重新转圈,体验非常差。
内存缓存我建议直接用NSCache,而不是[String: UIImage]字典。NSCache是系统级别的缓存容器,它会在内存紧张时自动清理条目,不需要你自己写 LRU 淘汰算法。同时它还支持countLimit和totalCostLimit,你可以根据项目情况手动控制缓存上限。
final class ImageCache { static let shared = ImageCache() private let cache = NSCache<NSString, UIImage>() init() { cache.countLimit = 100 cache.totalCostLimit = 50 * 1024 * 1024 // 约 50MB } func setImage(_ image: UIImage, forKey key: String) { cache.setObject(image, forKey: key as NSString) } func image(forKey key: String) -> UIImage? { return cache.object(forKey: key as NSString) } }totalCostLimit的单位是字节,但注意它不是一个硬性精确值,系统会参考它做内存权衡。我之前设过 200MB,发现内存警告来的次数明显增加,后来压到 50MB 左右,实测更均衡。
3.2 磁盘缓存:把图片存到 Caches 目录
内存缓存只能管一次 App 生命周期。App 重启后,内存缓存全部清空,如果又要重新下载,体验会断档。此时需要磁盘缓存。iOS 的 Caches 目录是专门用来存放可以重新生成的数据的,系统在磁盘空间紧张时可能清掉它,正好符合图片缓存的定位。
磁盘缓存的 key 不建议直接用 URL 字符串,因为 URL 里可能含特殊字符,直接当文件名会出现路径混乱。稳妥做法是对 URL 做一次哈希。很多成熟库用 MD5,虽然后来有一些关于 MD5 安全性的讨论,但这里它只是做一个缓存键,不做敏感数据校验,完全够用。
func cacheKey(for url: URL) -> String { let hash = url.absoluteString.md5() return hash } func saveImageData(_ data: Data, forKey key: String) { let fileURL = cachesDirectory.appendingPathComponent(key) try? data.write(to: fileURL) } func loadImageData(forKey key: String) -> Data? { let fileURL = cachesDirectory.appendingPathComponent(key) return try? Data(contentsOf: fileURL) }读取顺序我建议:内存缓存 → 磁盘缓存 → 网络。命中内存就直接用,没有就到磁盘找,磁盘也没有才发起网络请求。这是所有主流图片加载库的共同思路。这里强调一个很容易被忽略的点:如果磁盘缓存命中,读取文件是在后台线程完成的,不要在cellForRowAt里同步读文件,否则滚动到新图片时同样会卡顿。
3.3 OperationQueue 比 GCD 更适合做下载队列
如果你在项目里手动管理多个图片下载任务,我更推荐用 OperationQueue 而不是纯 GCD。原因很简单:OperationQueue 天然支持取消、优先级、最大并发数。快速滚动时,我们希望只保留当前屏幕附近图片的下载任务,把离屏的旧任务取消掉。用 GCD 虽然也能传入 DispatchWorkItem 做 cancel,但写起来不如 Operation 直观。
一个稳定的下载队列大概是这样的:
final class ImageDownloader { static let shared = ImageDownloader() private let queue: OperationQueue = { let queue = OperationQueue() queue.maxConcurrentOperationCount = 4 queue.qualityOfService = .userInitiated queue.name = "com.example.imageDownload" return queue }() func download(url: URL, completion: @escaping (UIImage?) -> Void) { let operation = BlockOperation { // 尝试缓存 let cacheKey = url.absoluteString if let cached = ImageCache.shared.image(forKey: cacheKey) { DispatchQueue.main.async { completion(cached) } return } guard let data = try? Data(contentsOf: url), let image = UIImage(data: data) else { DispatchQueue.main.async { completion(nil) } return } ImageCache.shared.setImage(image, forKey: cacheKey) DispatchQueue.main.async { completion(image) } } queue.addOperation(operation) } }maxConcurrentOperationCount设成 4 是我调出来的经验值。设成 1 时,滑动时加载太慢,图片迟迟不出来;设成 10 时,手机带宽被同时打满,弱网环境反而更容易超时。4 到 6 之间通常比较均衡,具体还要看你图片的平均大小。
配合 UITableView 的prefetchRowsAt代理方法,可以实现预加载。系统会在 cell 即将进入屏幕前调用这个代理,把图片提前下载好。真正显示到 cell 时,命中缓存直接显示,肉眼几乎看不到占位图。
extension ViewController: UITableViewDataSourcePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { for indexPath in indexPaths { let url = dataList[indexPath.row].imageURL ImageDownloader.shared.download(url: url) { _ in } } } }注意预加载的 completion 不一定要更新 UI,它主要是把数据塞进缓存,所以这里闭包体留空即可。
4. 升级到 Swift Concurrency:用 async/await 重写图片加载的边界
4.1 一个干净的单图加载函数
Swift 5.5 之后有了 async/await,我在新项目里越来越多用它替代传统回调。你只需要写一个UIImage.load(from:)这样的扩展,网络下载 + 图片解码都放到后台执行,调用方能同步心智地等待结果。
extension UIImage { static func load(from url: URL) async throws -> UIImage? { let (data, _) = try await URLSession.shared.data(from: url) return UIImage(data: data) } }这个函数的执行隔离有一个细节值得说清楚:它是一个非隔离的 async 函数,并不会跑到调用方的 MainActor 上执行网络请求操作。无论是下载还是UIImage(data:)解码,都发生在后台执行器上。调用方只管等结果,主线程不会因为执行这个函数被卡住。
4.2 在 cellForRowAt 中使用 Task 与自动取消
在 cellForRowAt 里不能直接 await 异步函数,因为 cellForRowAt 本身是同步返回 cell 的。实际做法是用 Task 发起一个异步任务,把结果赋值回来。同时也需要为自定义 cell 增加一个 task 引用,方便在复用或离开屏幕时取消。
final class ImageCell: UITableViewCell { var imageLoadTask: Task<Void, Never>? override func prepareForReuse() { super.prepareForReuse() imageLoadTask?.cancel() imageView?.image = nil } }然后在 cell 绑定数据时:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "ImageCell", for: indexPath) as! ImageCell let url = dataList[indexPath.row].imageURL cell.imageView?.image = placeholderImage cell.imageLoadTask?.cancel() cell.imageLoadTask = Task { [weak cell] in let image = try? await UIImage.load(from: url) guard !Task.isCancelled else { return } cell?.imageView?.image = image } return cell }由于 cellForRowAt 是在主线程调用,Task 闭包默认继承 MainActor 隔离,所以cell?.imageView?.image = image这句其实是回到了主线程更新 UI。而UIImage.load(from:)内部已经做了后台解码,主线程只做一次简单的赋值。这套写法的好处是:代码可读性比回调嵌套高一大截,几乎不会出现“回调里再套回调”的意大利面式代码。
4.3 didEndDisplaying 与取消时机
除了在prepareForReuse里取消,还可以在 cell 完全滚出屏幕时取消任务,这对应 UITableViewDelegate 的didEndDisplaying:
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard let cell = cell as? ImageCell else { return } cell.imageLoadTask?.cancel() }有人会问,既然prepareForReuse已经取消了,为什么还要在didEndDisplaying再取消一次?因为一个 cell 滚出屏幕后不一定马上被复用,它可能停留在不可见区域里。取消掉这个阶段已经发起的下载任务,可以避免那些“用户根本看不到”的图片继续占用网络带宽。尤其在弱网环境下,这个细节能省下大量无效请求。
5. 实战选型与真机避坑:SDWebImage 能帮你省事,但 Instruments 不能省
5.1 什么时候应该直接上成熟库
说实话,如果你只是想要一个稳定、不折腾的方案,我推荐直接使用 SDWebImage 或 Kingfisher,不需要手写。手写方案最大的价值是理解原理,而不是在生产环境里重复造轮子。
以 SDWebImage 为例,一行代码就能实现异步加载 + 占位图 + 缓存 + 复用取消:
imageView.sd_setImage(with: url, placeholderImage: UIImage(named: "placeholder"), options: [.retryFailed, .refreshCached])它在内部做的事情,基本就是我们前面说的整套流程:磁盘/内存缓存检查、异步下载、后台解码、主线程回调、cell 复用时的自动取消。当你快速滚动时,它已经有专门机制保证不会出现图片错乱。
但注意,不是引入库就万事大吉。我见过有人在 cell 复用时忘了调用sd_cancelCurrentImageLoad,结果还是出现了旧图片闪现。正确惯例是在prepareForReuse里做两件事:清空当前图片、取消当前加载。
override func prepareForReuse() { super.prepareForReuse() imageView?.sd_cancelCurrentImageLoad() imageView?.image = nil }5.2 同类型库的选型对比
Kingfisher 也是一款很成熟的纯 Swift 图片加载库。如果你项目是 Swift 为主,Kingfisher 的 API 更符合 Swift 直觉,支持 async/await,对 Swift Concurrency 的集成更丝滑。SDWebImage 老牌稳定,OC 和 Swift 混编项目兼容性更好,生态也更庞大。
在选型上给一个实用建议:先看项目语言构成,再看团队熟悉度。不要因为“某个库看起来新潮”就随便换。这两个库在核心功能上差异不大,真正决定体验的是你是否正确配置了下载超时、缓存策略和占位图。
5.3 真机调试时不可省的两个步骤
不要只在模拟器里点两下就宣布“完成了”。模拟器网络环境走的是 Mac 的网卡,路径和真机差距很大。我每次写完图片加载,都至少做两轮真机检查:
第一轮,弱网。在真机上用 Xcode 的 Network Link Conditioner 模拟 3G 甚至更差的网络,观察滚动列表时占位图到真实图片的过渡是否自然,有没有大量请求超时。如果弱网下图片长时间空白,除了网络原因,也可能是后端图片服务器没做合理的超时策略,前端逻辑要兜底,比如设置更短的请求超时时间,或把占位图做得更美观。
第二轮,用 Xcode 里的 Instruments 打开 Time Profiler,只观察主线程调用栈。判断标准是:主线程里不应该出现图片解码相关的高耗时方法。如果在UIImage(data:)或CGBitmapContextCreate这类方法上看到明显耗时,说明你的图片解码要么跑错线程了,要么图片本身过大,需要服务端做缩略图。列表里的图不是越大越清晰,适合当前 cell 尺寸才是最优方案。
另外一个值得提醒的指标是“滚动掉帧率”。打开 Core Animation 的 Debug 面板,能看到每秒帧数。如果异步加载后仍然掉帧严重,大概率不是加载问题,而是 TableView 自身的高度计算复杂或其他 UI 绘制开销。这种时候别急着优化加载代码,先排查根本瓶颈。
最后分享一个我自己的习惯:无论是手写还是上库,我都会在列表快速滚动时把日志打出来看请求的发起和取消频率。如果一次快速滚动产生了几十个请求,说明预加载和复用取消的逻辑还需要调优。毕竟,异步加载图片只是手段,让用户在真实环境里流畅地滑动列表,才是我们真正要达到的目的。
本文还有配套的精品资源,点击获取