简介:这份资源面向iOS开发初学者与需要优化列表性能的开发者,聚焦UITableView中网络图片异步加载这一常见痛点,帮助解决主线程阻塞导致的界面卡顿问题。压缩包共33个文件、约73KB,以Objective-C源码为主,包含7个.m实现文件与6个.h头文件,另有xib界面、plist配置及工程文件,构成一个可直接运行的LazyTableImages示例工程。代码围绕图片下载器、数据解析与根视图控制器展开,演示了后台下载、主线程刷新、占位图显示与缓存复用的完整链路,并附有ReadMe说明,便于对照理解各模块职责。已有311人学习下载,适合作为异步加载与列表性能优化的入门参考,读者可从中获取可运行的工程骨架、下载与解析的分层写法,以及避免重复请求的排错思路。
1. 列表卡成幻灯片,问题往往不在网络
UITableView 里塞网络图片,滚动起来一顿一顿的,第一反应通常是「网太慢」。真把请求日志打出来看,带宽根本不是瓶颈——卡的是主线程在同步等图、在 cell 复用时反复起请求、在滚动过程中一遍遍解码大图。这个项目要解决的就是这件事:把图片加载从主线程剥出去,做成带缓存、带取消、带占位的一套异步加载链路,让列表在快速滑动时依然跟手。适合谁?正在写 iOS 列表页、被图片卡顿折磨过一轮、想看懂「异步加载 + 缓存 + 复用」这条完整链路的开发者。下面按「这套东西怎么组织 → 怎么跑起来 → 怎么改」的顺序拆,代码可以直接抄进工程里跑。
2. 整体架构:一条图片从 URL 到 cell 的完整链路
先把链路摆清楚,后面看代码才不会迷路。一次图片展示要经过四层:cell 触发请求 → 内存缓存命中判断 → 磁盘缓存/网络下载 → 回主线程赋值。任何一层处理不好,都会在滚动时暴露成卡顿或者图片错位。
2.1 分层设计与各层职责
这套结构我一般拆成四个角色,职责边界划清楚,改起来才不互相牵连:
| 角色 | 职责 | 关键约束 |
|---|---|---|
| Cell / View | 展示占位图、触发加载、复用前取消旧任务 | 只碰 UI,不做 IO |
| ImageLoader(单例) | 统一入口,协调缓存与下载,管理任务去重 | 线程安全,回调切主线程 |
| Cache(内存 + 磁盘) | 内存用 NSCache,磁盘按 key 存文件 | 内存缓存要设上限,磁盘要控总量 |
| Downloader | 发起 URLSession 请求,产出 Data | 支持取消,避免重复下载 |
分层的核心目的是让「谁负责什么」没有歧义。cell 复用时只需要说一句「我不要旧图了」,取消逻辑由 Loader 兜住;缓存命中与否由 Cache 判断,Downloader 只管拿数据。这样后面加预加载、加降级、换缓存策略,都只动一层。
2.2 目录结构长什么样
工程里按职责分目录,别把所有文件堆在一个文件夹里。常见做法是这样:
ImageLoadingDemo/ ├── App/ │ ├── AppDelegate.swift │ └── SceneDelegate.swift ├── List/ │ ├── FeedViewController.swift # 列表页,UITableView 数据源 │ └── FeedCell.swift # 自定义 cell,持有 imageView ├── ImageLoading/ │ ├── ImageLoader.swift # 单例入口,任务调度 │ ├── ImageCache.swift # 内存 + 磁盘缓存 │ ├── ImageDownloader.swift # URLSession 下载 │ └── ImageTask.swift # 任务模型,支持取消 └── Resources/ └── placeholder.png # 占位图这个结构的好处是 ImageLoading 目录可以整块抽成内部库,列表页只依赖 ImageLoader 一个公开接口。二次开发时想换缓存实现,只动 ImageCache,上层无感。
2.3 为什么不用第三方库直接上
很多人第一反应是引 SDWebImage 或 Kingfisher。能用,但如果你连「为什么需要取消」「为什么内存缓存要设 countLimit」都说不清,出了问题只能干瞪眼。这个项目的定位是手写一遍最小可用链路,把黑匣子打开。等你清楚每个环节在干什么,再换成熟库就是替换实现,而不是替换认知。血泪经验是:线上图片错位、内存暴涨这类问题,八成出在复用和缓存策略上,跟用哪个库关系不大。
3. 核心模块拆解:缓存、下载、任务取消三件套
这一章是重点,三个模块逐个过。每个模块给出可运行的代码,代码后面说清楚参数和逻辑,照着改就能用。
3.1 内存 + 磁盘双层缓存
缓存是异步加载的性能地基。内存缓存扛住滚动时的重复请求,磁盘缓存扛住冷启动和弱网。先看 ImageCache 的实现:
import UIKit final class ImageCache { static let shared = ImageCache() // 内存缓存:NSCache 自带线程安全,收到内存警告会自动清理 private let memoryCache = NSCache<NSString, UIImage>() // 磁盘缓存目录 private let diskQueue = DispatchQueue(label: "image.cache.disk", qos: .utility) private lazy var diskDirectory: URL = { let url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] .appendingPathComponent("ImageCache", isDirectory: true) try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url }() private init() { // 内存上限按设备内存粗估,常见做法是总内存的 1/8 左右 memoryCache.countLimit = 200 memoryCache.totalCostLimit = 50 * 1024 * 1024 // 50MB } // 同步读内存,命中直接返回 func imageFromMemory(forKey key: String) -> UIImage? { memoryCache.object(forKey: key as NSString) } // 异步读磁盘,避免阻塞调用线程 func imageFromDisk(forKey key: String, completion: @escaping (UIImage?) -> Void) { diskQueue.async { let fileURL = self.diskDirectory.appendingPathComponent(key.md5) guard let data = try? Data(contentsOf: fileURL), let image = UIImage(data: data) else { completion(nil) return } // 回填内存缓存,下次直接命中 self.memoryCache.setObject(image, forKey: key as NSString, cost: data.count) completion(image) } } func store(_ image: UIImage, data: Data, forKey key: String) { memoryCache.setObject(image, forKey: key as NSString, cost: data.count) diskQueue.async { let fileURL = self.diskDirectory.appendingPathComponent(key.md5) try? data.write(to: fileURL, options: .atomic) } } }逻辑说明:读的时候先查内存,命中就同步返回,这是滚动流畅的关键;没命中再异步查磁盘,磁盘读出来顺手回填内存。写的时候内存和磁盘都写,磁盘写放在串行队列里,不占调用线程。参数上,countLimit控制对象个数,totalCostLimit控制总字节数,两个都要设,只设一个容易在图片尺寸差异大时失控。key 用 URL 字符串,落盘前做一次 md5 转成短文件名,避免路径过长和特殊字符问题。
提示:磁盘缓存目录放在 Caches 下,系统在空间紧张时会清理,符合图片缓存的定位。别放 Documents,会被 iCloud 备份,审核和体积都容易出问题。
3.2 下载器与任务去重
下载层要解决两个问题:同一张图被多个 cell 同时请求时不能重复下载;请求要能取消。先看下载器:
import Foundation final class ImageDownloader { static let shared = ImageDownloader() private let session: URLSession private init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 15 config.requestCachePolicy = .reloadIgnoringLocalCacheData // 缓存自己管 session = URLSession(configuration: config) } @discardableResult func download(url: URL, completion: @escaping (Result<Data, Error>) -> Void) -> URLSessionDataTask { let task = session.dataTask(with: url) { data, response, error in if let error = error { completion(.failure(error)) return } guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), let data = data else { completion(.failure(NSError(domain: "ImageDownloader", code: -1, userInfo: [NSLocalizedDescriptionKey: "bad response"]))) return } completion(.success(data)) } task.resume() return task } }逻辑说明:requestCachePolicy设成忽略本地缓存,是因为缓存策略由我们自己的 ImageCache 统一管,两套缓存叠在一起容易出现「明明删了缓存还显示旧图」的玄学问题。超时设 15 秒,弱网下不至于让用户干等。返回 task 是为了让上层能取消。
去重放在 ImageLoader 里做,用一个字典记录「key → 回调数组」,同一个 key 的请求合并成一次下载,下载完统一回调:
final class ImageLoader { static let shared = ImageLoader() private let cache = ImageCache.shared private let downloader = ImageDownloader.shared private var pending: [String: [(UIImage?) -> Void]] = [:] private let lock = NSLock() func load(url: URL, completion: @escaping (UIImage?) -> Void) { let key = url.absoluteString if let cached = cache.imageFromMemory(forKey: key) { completion(cached) return } lock.lock() if pending[key] != nil { pending[key]?.append(completion) lock.unlock() return } pending[key] = [completion] lock.unlock() cache.imageFromDisk(forKey: key) { [weak self] diskImage in guard let self = self else { return } if let image = diskImage { self.finish(key: key, image: image) return } self.downloader.download(url: url) { result in switch result { case .success(let data): let image = UIImage(data: data) if let image = image { self.cache.store(image, data: data, forKey: key) } self.finish(key: key, image: image) case .failure: self.finish(key: key, image: nil) } } } } private func finish(key: String, image: UIImage?) { lock.lock() let callbacks = pending[key] ?? [] pending[key] = nil lock.unlock() DispatchQueue.main.async { callbacks.forEach { $0(image) } } } }逻辑说明:pending字典是去重的核心,第一个请求进来占位,后续同 key 请求只挂回调不重复下载。finish里统一回主线程,保证 UI 赋值安全。锁只保护字典读写,不包住网络请求,避免锁粒度过大。这里有个容易翻车的点:回调数组在finish里取出后要立刻置空,否则同一 key 的后续请求会挂到一个已经完成的批次上,永远等不到回调。
3.3 cell 复用时的取消与占位
cell 复用是图片错位的高发区。快速滑动时,cell A 的图可能被赋给复用了它的 cell B。解决办法是每次配置前取消旧任务、重置占位图,并用当前 URL 做一次校验:
final class FeedCell: UITableViewCell { static let reuseID = "FeedCell" private let photoView = UIImageView() private var currentURL: String? override func prepareForReuse() { super.prepareForReuse() // 复用前清空,防止旧图闪现 photoView.image = UIImage(named: "placeholder") currentURL = nil } func configure(with url: URL) { let key = url.absoluteString currentURL = key photoView.image = UIImage(named: "placeholder") ImageLoader.shared.load(url: url) { [weak self] image in // 关键校验:回调回来时 cell 可能已经复用于别的 URL guard let self = self, self.currentURL == key else { return } self.photoView.image = image ?? UIImage(named: "placeholder") } } }逻辑说明:prepareForReuse里重置占位图和 URL,是防错位的第一道闸。回调里的currentURL == key校验是第二道闸,两者缺一不可。只做前者,回调晚到时仍可能覆盖新图;只做后者,占位图会残留旧内容。参数上,占位图建议用纯色或轻量小图,别用大图,否则滚动时解码占位图本身也是开销。
4. 跑起来:从建工程到列表滑动的验证步骤
代码有了,得能跑起来看到效果,不然都是纸上谈兵。这一章按顺序走一遍,每一步都给出验证点。
4.1 工程配置与依赖
新建一个 iOS App 工程,语言选 Swift,界面用 Storyboard 或纯代码都行,这个项目用纯代码更直观。不需要引入任何第三方依赖,全部用系统框架:UIKit、Foundation。最低部署版本按项目需要设,代码里用到的 API 在 iOS 13 以上都可用。
把第 2 章目录结构里的文件建好,ImageLoading 目录下的四个文件是核心,List 目录下是演示页。占位图放一张 1x1 的纯色 png 到 Resources,命名 placeholder。
注意:如果工程开了 Swift Concurrency 严格检查,
DispatchQueue.main.async里的闭包捕获要留意 Sendable 警告,演示工程可以先关掉严格检查,或者把回调类型标成@Sendable。
4.2 列表页数据源与触发加载
列表页负责造数据、配 cell。用一个假数据数组模拟图片 URL,cellForRowAt里调 configure:
final class FeedViewController: UIViewController, UITableViewDataSource { private let tableView = UITableView(frame: .zero, style: .plain) private let urls: [URL] = (0..<100).compactMap { URL(string: "https://picsum.photos/seed/\($0)/300/300") } override func viewDidLoad() { super.viewDidLoad() tableView.dataSource = self tableView.register(FeedCell.self, forCellReuseIdentifier: FeedCell.reuseID) tableView.rowHeight = 120 view.addSubview(tableView) tableView.frame = view.bounds } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { urls.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: FeedCell.reuseID, for: indexPath) as! FeedCell cell.configure(with: urls[indexPath.row]) return cell } }逻辑说明:urls用公开的占位图服务造 100 条数据,方便验证滚动。rowHeight固定 120,避免自适应高度带来的额外计算干扰观察。dequeueReusableCell保证复用,配合 cell 里的取消校验,快速滑动时观察图片是否错位。
4.3 验证清单:怎么确认真的异步了
跑起来之后,按这几条验证,每条对应一个可能的坑:
- 快速上下滑动,图片不应错位到别的 cell,这是复用校验生效的标志。
- 断网后滑动,已加载过的图应仍能显示,说明磁盘缓存生效。
- 用 Instruments 的 Time Profiler 看主线程,滚动时不应有长时间的文件读取或解码调用。
- 同一张图在列表里出现两次,抓包应只有一次请求,说明去重生效。
- 连续滑动后内存不持续上涨,说明 NSCache 上限在起作用。
如果某条不通过,回到对应模块查:错位查 3.3 的校验,缓存不生效查 3.1 的落盘路径,重复请求查 3.2 的 pending 字典。
5. 避坑与排查:五个真实踩过的坑
这一章单独拎出来,因为下面这几条都是我在实际项目里翻过车的,现象、原因、解决一条条写清楚,遇到时直接对号入座。
5.1 图片错位:回调晚到覆盖了新图
现象:快速滑动后停下,某些 cell 显示的是别的行的图,过一会儿又自己变回来。原因:cell 复用时旧请求没取消,回调晚到直接赋值,覆盖了当前 URL 的图。解决:按 3.3 的做法,prepareForReuse重置状态,回调里用currentURL == key校验。更彻底的做法是给每个 cell 持有一个可取消的 task,复用时主动 cancel,双保险。
5.2 内存暴涨:NSCache 没设 cost 或上限过大
现象:滑动几十屏后收到内存警告,严重时被系统杀掉。原因:setObject时没传 cost,totalCostLimit形同虚设;或者上限设成几百 MB,等于没设。解决:每次 set 都传data.count作为 cost,totalCostLimit按设备内存的 1/8 左右设,countLimit兜底。另外图片解码后的位图大小是原始数据的数倍,估算内存时按解码后尺寸算更准。
5.3 主线程卡顿:磁盘读放在了主线程
现象:首次进入列表,滑动明显掉帧,Time Profiler 显示主线程有Data(contentsOf:)。原因:磁盘缓存读取写成了同步调用,或者回调没切队列。解决:磁盘读写全部放到专用串行队列,读出来再回主线程。注意Data(contentsOf:)本身是同步阻塞的,放在后台队列才安全。
5.4 重复下载:去重字典的竞态
现象:同一张图在列表里出现多次,抓包看到并发了好几个相同请求。原因:pending字典的读写没加锁,或者加锁范围不对,两个线程同时判断「不存在」然后都去下载。解决:字典读写用同一把锁包住,判断和占位在同一个临界区里完成,参考 3.2 的写法。锁只保护字典,别把网络请求包进去。
5.5 缓存不更新:URL 不变但图片换了
现象:服务端换了图,客户端还是显示旧图,清缓存才好。原因:key 只用 URL,URL 不变时缓存永远命中。解决:让服务端在 URL 里带版本号或内容 hash,key 自然变化;或者给缓存加过期时间,读磁盘时校验文件修改时间。别在客户端做「强制刷新」按钮糊弄,那是把问题推给用户。
6. 二次开发:把演示链路改成生产可用的几个改法
演示工程跑通只是起点,真接到业务里还得改几处。这一章说改法,都是能直接落地的。
第一个改法是加预加载。列表滚动时提前加载即将出现的行,能明显减少白块。在scrollViewDidScroll里算出可见区域上下各一屏的 indexPath,对未加载的 URL 调ImageLoader.shared.load预热,回调丢弃即可,图会进缓存。注意预加载要限流,别一次灌几十个请求,常见做法是维护一个待加载队列,同时最多 3 到 4 个在飞。
第二个改法是换缓存 key 策略。生产环境图片 URL 往往带签名参数,同一张图每次签名不同,key 会失效。做法是取 URL 的 path 部分做 key,或者服务端返回内容 hash 时用 hash。改的时候只动 ImageLoader 里算 key 的那一行,其他模块无感,这就是分层的好处。
第三个改法是加降级。下载失败时按次数退避重试,重试仍失败就显示错误占位图,别让 cell 一直空白。可以在 ImageDownloader 外面包一层重试逻辑,失败回调里判断重试次数,指数退避。参数上,重试 2 次、间隔 1 秒和 3 秒,够覆盖大部分弱网抖动。
第四个改法是可观测性。给 ImageLoader 加几个计数:内存命中、磁盘命中、网络下载、失败,定期打日志或上报。线上排查图片问题时,这几个数一比就知道瓶颈在哪。我一般会在 Debug 下用一个悬浮面板显示命中率,比翻日志快得多。
验证改造是否生效,还是回到第 4 章那套清单,外加一条:预加载开启后,首屏到可交互的时间应该变短,用 Instruments 的 App Launch 模板对比改前改后。
最后说个习惯。这个项目里最容易被忽略的是取消逻辑,我早期写列表图,总觉得「回调回来判断一下 URL 就行」,结果线上还是偶发错位,查了半天是回调里判断用的变量被复用覆盖了。从那以后我每次写图片加载,都强制走一遍「复用重置 → 请求占位 → 回调校验 → 主动取消」四步,一步不省。希望帮到你。
本文还有配套的精品资源,点击获取