简介一份面向iOS开发者的UITableView图片异步加载示例工程聚焦新闻列表、产品目录等大数据量场景下的主线程阻塞与界面卡顿问题。资源基于Apple官方LazyTableImages思路优化完整展示在cellForRowAt中启动后台下载、主线程回调更新图片的流程并提供占位图、尺寸适配、缓存复用等实用优化技巧可直接迁移到实际项目。压缩包共33个文件包含Objective-C源文件.m/.h、界面布局.xib、工程配置.plist/.pbxproj及占位图资源.png等整体仅73KB代码精简易读适合中级iOS开发者参考。已有309人学习下载适合希望摆脱第三方库依赖、深入理解异步加载机制或需要快速搭建图片列表Demo的开发者。1. ios tableview异步加载图片为什么你的列表一滑动就卡如果你把网络图片的下载直接写进 cellForRow真机表现多半是首屏慢慢加载滑动起来像放幻灯片。iOS TableView 异步加载图片就是在数据源回调里不碰耗时网络操作先显示占位图等图片下载完再更新到对应 cell。这篇文章把这条链路拆开讲为什么异步是必须的、手写一套要做什么、参数怎么调、有哪些高频事故最后再给迁移到成熟框架的路径。适合正在写商品列表、资讯信息流或者刚接手图片列表卡的项目的 iOS 开发者。2. 先拆原理同步加载的耗时与异步三要素“cellForRowAt 默认跑在主线程”这句话你可能听过很多次但没意识到它和图片加载的关系有多直接。主线程同时负责触摸响应、布局计算和图层合成任何一次超过 16.7 毫秒的阻塞都会让屏幕漏掉一帧。也就是说不是“等图片加载完再刷新”这么简单而是主线程被占住以后整个列表的滚动、点击、侧滑全部跟着卡。2.1 卡顿根源tableView 在主线程上做了什么UITableView 的数据源方法本身并不要求必须主线程执行但 UIKit 的 cell 配置、尺寸计算、滚动响应都发生在主线程。实际操作里tableView(_:cellForRowAt:)就是在主线程被调用的。如果把网络图片当成普通数据用同步方式去读问题立刻放大。override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) - UITableViewCell { let cell tableView.dequeueReusableCell(withIdentifier: Cell, for: indexPath) let url imageURLs[indexPath.row] if let data try? Data(contentsOf: url) { cell.imageView?.image UIImage(data: data) } return cell }这段代码的问题在于Data(contentsOf:)发起一次真正的网络请求时是同步阻塞的。遇到弱网或者大图主线程会一直等到数据返回才开始执行下一行。手机屏幕一秒钟需要刷新 60 次每帧留给主线程的时间只有 16.7 毫秒。一张图片下载耗时按 300 毫秒算就是近 20 帧的空白。更麻烦的是TableView 的 Cell 是复用的。你在瀑布流里看到 8 个 cell快速滚动时cellForRowAt会被高频调用每次调用都可能触发新的同步下载。一个页面多张图片累积起来的主线程占用是几百毫秒甚至几秒。这就是为什么很多列表首次进入会白屏几秒然后突然全部冒出来。2.2 异步加载三要素非阻塞、回调线程、缓存要解决这个问题核心不是“用异步”三个字而是把三个动作都做对下载不在主线程、回调切回主线程、结果有缓存。不阻塞主线程是最基本的。把请求丢到后台队列主线程只负责把占位图先摆上去。等图片数据回来以后再切回主线程刷新 UI。UIKit 不是线程安全的UIImageView的任何赋值只能在主线程做所以回调必须经过DispatchQueue.main。DispatchQueue.global().async { // 网络下载和图片解码都放在后台 let image UIImage(data: data) DispatchQueue.main.async { cell.imageView?.image image } }只做完这两步列表滑动不会卡了但会出现另一个经典问题cell 复用导致图片错乱。原因很简单后台任务执行期间用户可能已经在快速滑动某个 cell 被复用到了另一行旧回调这时候才回来直接覆盖了新行的图片。所以异步加载必须带上“身份校验”确保回调回来的图片确实属于当前这一行。这个点我后面会专门展开。缓存也不能漏。没有缓存时滚动一次就重新下载一次同一张图流量和耗电都受不了。常规做法是引入一层 NSCache 作为内存缓存下载前先查缓存命中就直接回图写得好一点的还会加磁盘缓存让第二次启动不再重复下载。对 TableView 场景来说没有缓存的异步加载只能算解决了一半。2.3 选型判断手写 vs 成熟框架当你知道异步加载要做什么之后自然会面临一个选择自己写还是直接用成熟框架。我的建议是如果你只想让头像列表不卡手写就够了如果是电商、信息流这种图片量大、网络环境复杂的业务直接用框架会更稳。对比点手写 URLSession NSCacheSDWebImage 这类框架网络下载自己管理 dataTask内置下载器支持请求优先级缓存主要做内存缓存内存 磁盘多级缓存失败重试要自己写逻辑自带失败占位与重试策略图片解码容易误放在主线程默认在后台解码cell 复用自己用 URL 比对处理内部已处理多数场景框架听起来省事但不是没有代价。引入第三方库意味着要处理依赖、版本兼容和缓存策略冲突。我有段时间出于“少写代码”的目的强行上框架结果因为项目里已有自己的图片缓存两套缓存互相覆盖排查了一个下午。后来学乖了先想清楚要解决的是“网络下载”还是“缓存策略”再决定用哪套方案。手写一遍最大的价值是你会知道框架内部默认帮你做了什么。3. 手写异步加载URLSession NSCache 的落地实现手写一套最小可用方案不要一开始就想着处理所有边缘情况。我的习惯是先写一个 ImageLoader 类把网络请求、内存缓存、回调线程统一收口业务层只调用一个方法后续要加解码、重试、磁盘缓存都在这个类里加不会污染 ViewController。3.1 先写一个可复用的 ImageLoader下面是去掉业务杂质后的核心实现。用单例管理一个 URLSession 和一个 NSCache对外暴露 loadImage 方法。import UIKit final class ImageLoader { static let shared ImageLoader() private let cache NSCacheNSString, UIImage() private let session: URLSession private init() { let config URLSessionConfiguration.default config.timeoutIntervalForRequest 15 config.timeoutIntervalForResource 30 config.waitsForConnectivity true session URLSession(configuration: config) cache.countLimit 100 cache.totalCostLimit 50 * 1024 * 1024 } discardableResult func loadImage( from url: URL, completion: escaping (UIImage?) - Void ) - URLSessionDataTask? { let key url.absoluteString as NSString // 内存缓存命中后直接回主线程 if let cached cache.object(forKey: key) { completion(cached) return nil } let task session.dataTask(with: url) { [weak self] data, _, error in guard let self self, let data data, error nil, let image UIImage(data: data) else { DispatchQueue.main.async { completion(nil) } return } // 计算图片内存占用成本方便 NSCache 淘汰 self.cache.setObject(image, forKey: key, cost: self.imageCost(image)) DispatchQueue.main.async { completion(image) } } task.resume() return task } private func imageCost(_ image: UIImage) - Int { let scale image.scale let pixelCount image.size.width * image.size.height * scale * scale return Int(pixelCount * 4) } }这段代码有几个关键点。timeoutIntervalForRequest 15表示发起请求后等待响应的超时时间是 15 秒超过就会报错timeoutIntervalForResource 30是整个资源下载总耗时的上限。waitsForConnectivity true让任务在网络断开时挂起而不是立刻失败等网络恢复后自动继续适合移动网络切换的场景。NSCache的countLimit和totalCostLimit共同决定缓存上限。只设countLimit 100不够因为 100 张 4000x3000 的大图内存会爆炸totalCostLimit 50 * 1024 * 1024表示总内存成本超过 50MB 时自动清理。imageCost按像素数乘以 4 字节估算内存占用比单纯用固定 cost 更准确。另外注意UIImage(data:)这里是在 URLSession 的回调线程执行的不占主线程。但这不代表解码问题就彻底解决了后面 4.2 会专门讲。3.2 在 cellForRow 中接入占位图、防复用、回调更新ImageLoader 写好后业务层不要直接调用我一般会在自定义 Cell 里保存两个状态当前要显示的 URL以及当前正在执行的下载任务。final class ImageCell: UITableViewCell { IBOutlet weak var photoView: UIImageView! // 当前 cell 需要显示的 URL回调时做比对 var currentLoadingURL: URL? // 保存下载任务cell 复用时取消 var imageTask: URLSessionDataTask? override func prepareForReuse() { super.prepareForReuse() imageTask?.cancel() imageTask nil currentLoadingURL nil photoView.image nil } }然后在 ViewController 里这样接入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.image UIImage(named: placeholder) // 记录当前 URL回调时校验身份 cell.currentLoadingURL url // 取消上一个任务防止旧下载继续跑 cell.imageTask?.cancel() cell.imageTask ImageLoader.shared.loadImage(from: url) { [weak cell] image in guard let cell cell else { return } // 只有当 URL 没变时才把图片赋给 cell guard cell.currentLoadingURL url else { return } cell.photoView.image image ?? UIImage(named: placeholder) } return cell }这里的核心逻辑是 URL 比对。快速滑动时cell 被复用到新行currentLoadingURL已经改成新 URL旧的下载回调回来后guard cell.currentLoadingURL url会失败直接丢弃结果。这一步必须做只能 cancel 任务还不行因为 cancel 之后旧的 dataTask 也可能会进入 completion 回调。[weak cell]是为了避免闭包持有 cell 造成循环引用。loadImage内部把完成回调派发到主线程所以这里直接赋值是安全的。imageTask必须存在 cell 上如果只作为局部变量任务发起后没有强引用ARC 会在方法结束时把 task 释放导致后续无法取消。3.3 参数与边界超时、并发、取消条件一套能用和一套能上线的代码差别往往在参数上。下面这些参数是我在项目里调过的直接拿来参考。timeoutIntervalForRequest不要设太短。移动网络下一个 100KB 的图片也可能要 3 到 5 秒才能起握手。设成 10 秒以下弱网环境会出现大量超时失败设成 15 到 20 秒比较稳妥。timeoutIntervalForResource要大于请求超时一般设为请求超时的两倍避免整张图还没下完就因为总时长超时被中断。let config URLSessionConfiguration.default config.timeoutIntervalForRequest 15 config.timeoutIntervalForResource 30 config.waitsForConnectivity true config.httpMaximumConnectionsPerHost 4httpMaximumConnectionsPerHost 4限制同一个域名下的最大并发连接数。图片 CDN 通常都是同一个域名如果不限制快速滑动的瞬间可能同时建立十几个连接带宽被占满每张图反而下载更慢。NSCache 的 cost 计算要结合实际图片尺寸。按上面的imageCost算一张 200x200 的图成本大约是 0.16MB一张 1000x1000 大约是 4MB。如果列表里大部分是缩略图countLimit 100更实用如果包含大图预览totalCostLimit才是主要限制。还有一个容易忽略的边界页面在加载图片的过程中返回了下载任务还在继续。严格做法是在 ViewController 持有当前所有 taskdeinit时统一取消。不过大多数情况下配合prepareForReuse里的取消已经够用真正需要统一取消的是图片量大、跳转频繁的页面。4. 避坑指南异步图片加载最常见的五个事故这一章的每一条都是我在真机上踩过的坑。写代码的时候都觉得自己想到了跑起来才发现问题出在视角之外。血泪经验汇总成五条每一条按“现象、原因、解决”的顺序拆开。4.1 cell 复用导致图片错乱现象快速滑动列表时某些行的图片会闪一下上一行的图甚至出现明显的错乱。原因异步下载还没完成cell 已经被 TableView 回收复用。旧任务回调回到主线程直接给新行的 imageView 赋了旧图。只取消任务也不够因为 URLSessionDataTask 的 cancel 并不能保证 completion 不再回调。解决在 cell 上记录当前 URL回调里比对成功后才能赋值。guard cell.currentLoadingURL url else { return }注意这里比对的是 URL 字符串。URL结构体本身可以做 equality但我习惯用absoluteString存成属性避免每次比较都做一次解析。这个判断一定要放在主线程回调里、赋值之前不要放在外层。4.2 图片解码放主线程拖慢滚动现象列表不卡网络了但滚动时依然一顿一顿CPU 占用率明显偏高。原因UIImage(data:)的完整解码不一定发生在 init 方法里很多情况是在imageView.image image赋值后由 ImageIO 在主线程触发解码。你以为自己在后台解码了其实真正昂贵的位图渲染还在主线程。解决在后台线程先显式解码再把解码结果传到主线程。iOS 15 以后可以用preparingForDisplay()更早的系统用UIGraphicsImageRenderer强制绘制一张新图。// 在 URLSession 回调线程里做 guard let image UIImage(data: data) else { return } let displayImage image.preparingForDisplay() ?? image DispatchQueue.main.async { completion(displayImage) }另外大图不要直接塞进 cell。列表 cell 的 imageView 可能只有 100x100却下载了一张 4000x3000 的原图解码成本高内存占用也完全没必要。用preparingThumbnail(of:)先生成缩略图再进缓存。let thumbnail image.preparingThumbnail(of: CGSize(width: 200, height: 200))4.3 缓存膨胀触发内存警告现象浏览大量图片后Xcode 控制台收到 memory warning随后 app 变卡甚至被系统内存回收。原因NSCache 虽然能在内存紧张时自动清理但前提是你正确设置了 cost。如果所有图都以默认 cost 塞进去NSCache 无法准确判断优先级缓存会一直膨胀到系统动手。解决给setObject(_:forKey:cost:)传入图片真实的内存成本同时监听内存警告做一次全量清理。NotificationCenter.default.addObserver( forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main ) { [weak self] _ in self?.cache.removeAllObjects() }注意这一步只清理内存缓存即可不要顺手把磁盘缓存也删掉。磁盘缓存的作用是下次启动前直接从本地读删掉反而增加流量消耗。4.4 弱网环境下的超时与失败策略现象弱网下图片长时间转圈最后显示占位图网络恢复后仍不自动重试用户只能上下滑动触发新请求。原因timeoutIntervalForRequest设置太短请求在完成握手前就超时也没有做失败重试一旦失败就彻底放弃。解决确认配置里的超时时间合理并开启网络等待。let config URLSessionConfiguration.default config.timeoutIntervalForRequest 20 config.timeoutIntervalForResource 40 config.waitsForConnectivity truewaitsForConnectivity true时系统会在网络不可用时挂起任务网络一恢复就自动发起请求。它不能替代重试逻辑但能明显改善“从电梯出来以后所有图都失败”的体验。如果要用重试我一般限制最多重试 2 次超过次数显示占位图避免流量浪费。4.5 下载任务的生命周期管理现象页面已经 pop 了网络日志里还在打印图片下载完成后台任务仍然在跑。原因imageTask 没有被持有或者只在 ViewController 的生命周期里部分取消了。快速滑动后之前创建的 dataTask 会一直执行到完成。解决在prepareForReuse里取消任务并把 task 置空。这是最容易遗漏的一步。override func prepareForReuse() { super.prepareForReuse() imageTask?.cancel() imageTask nil currentLoadingURL nil photoView.image nil }如果整个页面要统一取消可以在 ViewController 里维护一个 task 数组在deinit或viewWillDisappear时遍历取消。不过要注意如果图片加载器是单例task 数组放在页面控制器里是安全的不要放到单例里否则一次取消会把其他页面正在显示的图片也一起取消。5. 进阶从手写轮子到成熟框架的迁移与验证手写方案让我理解了异步加载的每个环节但真正成规模的业务我最后还是迁到了成熟框架。原因不是框架省代码而是它能帮我稳定处理解码、重试、磁盘缓存这些手写很容易遗漏的部分。5.1 迁移到 SDWebImage参数与对比迁移成本很低。ImageCell 里的调用代码从十几行变成两行import SDWebImage cell.photoView.sd_setImage( with: url, placeholderImage: UIImage(named: placeholder), options: [.retryFailed, .refreshCached], completed: nil ).retryFailed会在网络恢复后自动重试失败的任务.refreshCached表示先显示缓存图再请求远端更新。SDWebImage 内部会自动 cancel 掉可能属于旧 cell 的请求同时把图片解码放到后台线程这些正是手写方案里最容易出问题的部分。磁盘缓存也可以直接配置SDImageCache.shared.config.maxDiskSize 100 * 1024 * 1024 SDImageCache.shared.config.maxMemoryCost 50 * 1024 * 1024需要提醒一句迁移不是把代码替换掉就完事。如果项目里已经有自己的缓存层要考虑两套缓存会不会互相冲突CDN 地址如果带有时间戳缓存 key 会一直变化SDWebImage 的磁盘缓存效果会大打折扣。这些问题我在项目里都遇到过排查起来比手写阶段难得多。5.2 用 Instruments 验证滚动帧率写完异步加载后我用 Instruments 验证已经成为固定习惯。打开 Xcode 的 Profile选择 Core Animation 录制然后真机滚动列表观察主线程耗时和帧率。稳定在 55 fps 以上才算通过。如果发现某行图片出现瞬间掉帧马上看 Time Profiler 的调用栈大部分情况下栈底都是 ImageIO 的 decode说明解码没有完全移到后台。模拟器看不到这种问题必须真机跑。我现在的开发流程是接图片列表需求先确认图片来源和缓存策略再决定手写还是用框架写完必须做一遍滚动列表的 Instruments 验证再做一次弱网模拟。从那以后图片错乱、掉帧这类问题几乎没有回头找过我。希望帮到你。本文还有配套的精品资源点击获取