
简介面向苹果平台开发者的表格视图异步加载图片示例工程针对新闻列表、产品目录等大量图片场景中常见的滚动卡顿与主线程阻塞问题提供从基本原理到实际落地的完整方案。压缩包共三十三个文件涵盖源代码文件、界面布局文件、配置文件及图标资源工程结构清晰便于直接查看或改造整体体积仅七十三千字节十分轻量。已有三百零七人学习下载适合初中级开发者作为性能优化参考。示例工程收录了表格视图异步加载图片的完整范例项目内包含从网络请求、数据解析到单元格异步绑定的完整链路读者可对照学习除常用第三方图片库之外的系统原生实现方式深入理解后台下载、主线程刷新、并发控制与缓存机制。同时可借鉴占位图、图片尺寸适配、避免重复下载等优化技巧小体积却覆盖了实际开发中常用的关键知识点。 说实话看到“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 NSCacheNSString, 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: TaskVoid, 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 的didEndDisplayingfunc 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 绘制开销。这种时候别急着优化加载代码先排查根本瓶颈。最后分享一个我自己的习惯无论是手写还是上库我都会在列表快速滚动时把日志打出来看请求的发起和取消频率。如果一次快速滚动产生了几十个请求说明预加载和复用取消的逻辑还需要调优。毕竟异步加载图片只是手段让用户在真实环境里流畅地滑动列表才是我们真正要达到的目的。本文还有配套的精品资源点击获取