简介:针对iOS平台上UITableView展示大量图片时容易阻塞主线程、造成列表滚动卡顿的典型问题,这份工程压缩包提供了一套完整可运行的异步加载示例。它面向iOS开发者,尤其适合刚接触网络图片加载与列表性能优化的初中级工程师,可从中理解后台下载、缓存复用与界面刷新的协作关系。压缩包共含33个文件,核心由Objective-C源码(.m/.h)、XIB界面布局、Plist及工程配置文件和PNG占位图等组成,整体大小约73KB,结构紧凑,便于直接打开工程进行断点调试和代码阅读。内容基于LazyTableImages示例项目展开,其中RootViewController负责列表展示,IconDownloader负责异步获取图片,ParseOperation负责数据解析,完整演示了在cellForRowAt触发时启动后台下载、完成后回主线程更新图片、并在加载过程中使用占位图的操作流程,同时也涉及图片缓存与避免重复下载等优化策略。已有307人学习,对于希望快速上手TableView异步图片加载机制的开发者来说,是一份值得参考的入门素材。 我做了这么多年iOS开发,一直觉得 UITableView 的图片异步加载是个"看起来人人都会,做起来全是问题"的活儿。尤其现在信息流、电商列表、社交动态这类场景,每个 cell 里至少一张网络图,多的时候视频封面、头像、详情图好几张,要是直接用同步下载塞给主线程,那基本就是给自己找事故。这篇文章我把这套东西从头到尾捋一遍,从线程模型到缓存设计,从 cell 复用到滑动优化,最后附上我踩过的几个典型的坑,希望对刚接触这块或者准备重构列表加载逻辑的同学有点用。
这里面围绕的核心其实就三件事:不卡主线程、不重复下载、不出错图。听起来简单,但把它们同时做到位,需要的东西远比想象中多。
1. 为什么 UITableView 加载图片必须异步处理
先说结论:异步加载不是"优化手段",而是这项功能的底线要求。你要真在 cellForRowAtIndexPath 里写同步网络请求,结果就是列表一滚就白屏,手指停下之后 cell 才一张张蹦出来,体验基本属于不可用状态。
1.1 主线程阻塞是怎么发生的
UITableView 的行高估算、布局计算、cell 的初始化、图片绘制,这些全都在主线程执行。UIKit 里大部分 UI 操作也必须保证在主线程,否则会出现各种不可预期的渲染问题。而网络图片的下载过程涉及 DNS 解析、TCP 连接、TLS 握手、数据传输,最慢的时候一张图耗时好几秒。这几秒内你让主线程去等网络响应,用户那边看到的就是 APP 卡死、列表无法滚动,严重点直接被系统杀掉。
我见过不少初学者在这里踩坑,觉得只是"等一下下没大事",但到了真机弱网环境里,这个"等一下下"足够让用户体验降级到崩溃边缘。所以第一准则:凡是网络请求和耗时 IO,一律丢到子线程去,主线程只负责接收最终回调并刷新 UI。
1.2 异步方案要解决的核心问题
抛除"异步"这个基础概念,真正要解决的有四个问题:第一个是线程的创建和调度,不能每张图都手动 new 一个 Thread;第二个是图片的内存缓存,不同 UI 场景下图片复用率很高,没有缓存就只能反复下载;第三个是磁盘缓存,保证冷启动后二次浏览不用再走网络;第四个是 cell 复用带来的图片错乱,这是新手最容易忽视又最高频出现的问题。
这四个问题说白了,就是一套"下载 + 两级缓存 + 响应取消机制"的组合。下面我逐个讲。
2. 方案选型:线程、缓存、加载器怎么搭
很多朋友一上来就选第三方库,像 SDWebImage、YYWebImage 都很成熟,引入一个就完事。但如果在做基础架构,或者团队项目里需要自己维护一套下载逻辑,还是建议先搞清楚底层原理。再说很多第三方库背后的核心设计,也就是我下面要讲的东西。
2.1 GCD 还是 OperationQueue
图片异步加载最常用的两种并发方案是 GCD 和 NSOperationQueue。GCD 的语法简洁,适合一次性任务;但如果你需要"控制并发数""随时取消任务""按依赖关系执行",GCD 就显得有点不够灵活。
NSOperationQueue 基于 GCD 封装了一层,支持最大并发数设置、任务取消、优先级控制,比如快速滑动时能取消不可见 cell 的图片加载任务,这就用到了 NSOperationQueue 的 cancel 能力。我自己的经验是:下载核心用 NSOperationQueue,回调处理用 GCD。前者解决任务管理,后者保持代码轻巧。
2.2 内存缓存为什么优先选 NSCache
缓存图片有一个选择:自己写一个 NSMutableDictionary 加锁管理,还是直接用系统提供的 NSCache。我强烈建议用 NSCache。有四个理由:第一,NSCache 是线程安全的,你从子线程存、主线程取都不用额外加锁;第二,它自带淘汰策略,内存吃紧时自动释放部分对象,降低被系统强杀的风险;第三,它支持设置 countLimit 和 totalCostLimit,可以按数量和占用空间双维度控制;第四,和字典不同,NSCache 不会拷贝 key,内存占用更小。
磁盘缓存这一层建议直接写在 Library/Caches 目录下,文件名用图片 URL 做哈希处理即可。这块要注意一点:Caches 目录在系统空间不足时可能被系统清理,所以它不适合放关键数据,但放图片缓存恰好合适。你想恢复的话,下载一次就回来了,问题不大。
2.3 自研还是引入第三方库
如果你项目里只是"偶尔加载几张图",我建议直接用现成的库,省时省力。如果你的场景比较特殊,比如需要统一的防盗链 header、特殊的图片处理流程、或者公司要求所有网络请求都统一走公司网关,这时候自研一个小型加载器反而更灵活。
不过无论选哪种方案,下面这些设计思想都是通用的。我先把自研方案的完整实现思路讲清楚,你去用第三方库时也知道它内部大概做了什么。
3. 落地实现:从零搭建一个图片异步加载器
这个加载器我不说具体某个开源库,就按一套可运行的核心逻辑来写,大家理解了之后可以在自己项目里改造成自己想要的样子。整体分两步走:先写核心的下载和缓存逻辑,再在 cell 中安全接入。
3.1 加载器核心逻辑拆解
先定义一个简单的加载管理类,负责"查缓存 -> 下载 -> 回传"。核心接口大概长这样:
// ZYImageLoader.h typedef void(^ZYImageLoadCompletion)(UIImage * _Nullable image, NSError * _Nullable error); @interface ZYImageLoader : NSObject + (instancetype)sharedLoader; - (void)loadImageWithURL:(NSURL *)url placeholder:(UIImage *)placeholder completion:(ZYImageLoadCompletion)completion; - (void)cancelLoadForURL:(NSURL *)url; @end内部的实现逻辑,重点在 loadImageWithURL 里面,步骤按顺序是这样的:
- (void)loadImageWithURL:(NSURL *)url placeholder:(UIImage *)placeholder completion:(ZYImageLoadCompletion)completion { if (!url) { if (completion) completion(placeholder, nil); return; } // 第一步:查内存缓存 UIImage *memoryImage = [self.memoryCache objectForKey:url.absoluteString]; if (memoryImage) { dispatch_async(dispatch_get_main_queue(), ^{ if (completion) completion(memoryImage, nil); }); return; } // 第二步:查磁盘缓存 UIImage *diskImage = [self imageFromDiskCacheWithURL:url]; if (diskImage) { [self.memoryCache setObject:diskImage forKey:url.absoluteString]; dispatch_async(dispatch_get_main_queue(), ^{ if (completion) completion(diskImage, nil); }); return; } // 第三步:发起网络下载 [self downloadImageWithURL:url completion:^(UIImage *image, NSError *error) { if (image) { [self.memoryCache setObject:image forKey:url.absoluteString]; [self saveImageToDiskCache:image url:url]; } dispatch_async(dispatch_get_main_queue(), ^{ if (completion) completion(image ?: placeholder, error); }); }]; }这里有几个细节值得展开说一下。
首先是查询顺序:内存最快,磁盘次之,网络最慢。这个顺序是用时间成本排序的,可以最大化节省流量和等待时间。然后就是回调一定要回主线程,因为拿到图片后要刷新 UI,不在主线程会闪退或者出现诡异表现。
下载那一步里,我建议用 NSURLSession 而不是 NSURLConnection,别用已经很旧的 API。Session 支持系统级连接复用、后台下载等能力,处理起来也比较顺手。下载任务创建后,用一个可变字典维护起来,key 同样用 URL 字符串,方便后面取消。
- (void)downloadImageWithURL:(NSURL *)url completion:(void(^)(UIImage *image, NSError *error))completion { NSURLSessionDataTask *task = [self.session dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { if (error) { completion(nil, error); return; } UIImage *image = [UIImage imageWithData:data]; if (image) { completion(image, nil); } else { NSError *decodeError = [NSError errorWithDomain:@"ZYImageLoader" code:-1 userInfo:@{NSLocalizedDescriptionKey : @"图片数据解码失败"}]; completion(nil, decodeError); } }]; [self.downloadTasks setObject:task forKey:url.absoluteString]; [task resume]; }3.2 在 cell 中安全使用:复用的坑别踩第二次
cell 复用机制是 UITableView 高性能的关键,但它给图片加载带来一个著名问题:cell 滚出屏幕后又被复用给另一个数据源时,旧图片还在下载中,等下载完成刷新 UI,就把新数据配错图了。屏幕上的表现就是你滑着滑着,有的单元格突然闪现了别人家的图。
解决思路是:每次 cell 准备复用前,先把上一次的加载任务取消,并且把 imageView 图片置为占位图。在 cell 里配合加载器使用时,给 imageView 关联上当前的图片 URL,等回调回来之后比对一下,不一样就丢弃,这样可以兜底把问题拦截掉。
简化代码逻辑可以这样写:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { ZYTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"CellID" forIndexPath:indexPath]; NSString *imageUrl = self.dataArray[indexPath.row][@"image_url"]; // 取消上个任务,避免复用错乱 [[ZYImageLoader sharedLoader] cancelLoadForURL:cell.currentImageURL]; cell.currentImageURL = [NSURL URLWithString:imageUrl]; cell.imageView.image = [UIImage imageNamed:@"placeholder"]; __weak typeof(cell) weakCell = cell; [[ZYImageLoader sharedLoader] loadImageWithURL:[NSURL URLWithString:imageUrl] placeholder:[UIImage imageNamed:@"placeholder"] completion:^(UIImage *image, NSError *error) { dispatch_async(dispatch_get_main_queue(), ^{ __strong typeof(weakCell) strongCell = weakCell; if (!strongCell) return; // 关键判断:如果 URL 对不上,说明 cell 已被复用,不刷新 if ([strongCell.currentImageURL.absoluteString isEqualToString:imageUrl]) { strongCell.imageView.image = image; } }); }]; return cell; }这套组合是解决错图的"标准动作",一个小细节都别省。
3.3 磁盘缓存与内存缓存的参数设置
磁盘缓存我一般把图片文件直接写进 Library/Caches 下的一个专门目录,文件名是对 URL 字符串做 MD5/SHA256 后的结果。这样既避免 URL 里带特殊字符导致文件系统路径问题,也能靠哈希天然做去重。
内存缓存参数上,我习惯把 NSCache 的 countLimit 设为 100 左右、totalCostLimit 设为 30MB 上下。这个值不是死的,要看你 App 的图片体积和用户设备的内存情况来调。如果你列表里头像比较多、单张图几十 KB,那 countLimit 可以放宽;如果全是高清大图,更建议限制总字节数。一个不容易出错的做法是:设置缓存总成本 = 图片像素宽 x 高,让系统按真实内存占用去淘汰。
4. 优化进阶:让列表从"能用"变成"跟手"
很多开发做到上一步就觉得可以交差了:图片能显示,也不会错位。但真机上一跑,快速滑动时页面还是掉帧明显。这时候需要进一步做细节优化。
4.1 图片解码放在子线程处理
UIImage 有一个容易被忽略的特性:imageWithData: 拿到图片后,解码并不是立刻完成的,而是在图片第一次绘制到屏幕时才执行。这个解码是 CPU 密集操作,放在主线程绘制时做就会卡顿。
所以更稳的做法是在子线程提前解码,把解码后的位图数据塞进缓存。你可以用 UIGraphicsImageRenderer 把图片重新绘制一遍,强制解码发生,然后将绘制结果保存。我常在下载完成后顺手做这一步,建议你也在自研加载器里加上。自定义绘制能顺带做 scale、裁剪、圆角,一步到位,省得 cell 绘制时再做额外处理。
4.2 快速滑动的并发控制
如果用户快速滑动产生大量下载任务,每个任务都能成功发起网络请求,那么瞬间几十个并发连接会把带宽打满,不仅图片加载速度变慢,还会阻塞其他网络请求。这里建议用 NSOperationQueue 配合 maxConcurrentOperationCount,常见的值是 3 到 5 个并发下载。并发数不是越大越好,控制在 3~5 能保证带宽利用率和响应速度的平衡。
另外还要处理滑动场景的加载策略。我在快速滚动的时候,会先取消所有不可见 cell 的加载任务,只允许当前屏幕显示区域内的 cell 发起下载。这样做能极大降低无效网络开销。等滚动停止后再一次性补载需要展示的图片,这个策略可以让"滑动过程始终有占位图、停下后立刻填充内容"的体验非常平滑。
4.3 预加载可以思考,但别无脑用
UITableView 在 iOS 10 之后提供了 prefetching 相关的 API,可以提前为即将显示的 cell 加载数据。这种方式适合数据体积小、网络稳定的场景。但对超大图或者弱网环境,预加载做过头反而浪费流量和内存。我的建议是:先用最简单的方式做,测试后如果快速滚动依然卡,再逐渐加大预加载范围。以及,通过 RunLoop 空闲时机做预加载也是一种思路,但复杂度更高,小团队不建议一上来就整这套。
5. 我踩过的几个常见问题
这部分我把平时实际开发里遇到的典型问题做了一个速查表,方便你对照排查。
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 列表滚动时图片闪烁、跳动 | 复用时没有重置 imageView 的图片 | prepareForReuse 中设置占位图,并取消下载任务 |
| 快速滑动后出现错图 | 下载回调没有校验当前 cell 是否仍对应同一 URL | 在回调中比对 currentImageURL 与请求 URL |
| 图片加载后 CPU 飙升、掉帧 | 主线程执行了图片解码 | 在子线程提前对 image 做重绘解码处理 |
| 内存暴涨,甚至被系统杀掉 | 内存缓存没有上限,或缓存键值太大 | 使用 NSCache 并设置 countLimit 和 totalCostLimit |
| 图片一直出不来 | 弱网环境下请求超时,或者线程阻塞 | 合理设置请求超时时间,下载队列走后台异步执行 |
| 同一个 URL 的图片被多次请求 | 缺少"正在下载中"的合并机制 | 对同一 URL 使用回调数组,下载完成后统一派发 |
这里面对新手最坑的其实是最后一个。如果你没有做任务合并,一个 cell 上下滑动多次,同一张图会重复下载好几次,看着只是多耗点流量,实际在弱网环境里体验会非常差。解决办法是引入一个"正在下载"任务表,同一个 URL 的所有回调都挂到同一批任务下,下载完成后再逐个回调。这样说起来比较简单,实现起来注意锁或者并发队列即可,本质是"将重复请求合并为一次"。
卡顿问题还有一种隐藏来源,就是图片尺寸过大。如果服务器返回了 2000x2000 的大图,而你的 imageView 撑死只显示 100x100,那我建议在下载完成后直接用 UIGraphicsImageRenderer 裁剪到目标尺寸附近,别让大图进入渲染链路。毕竟一个超过屏幕尺寸好几倍的位图,在内存里占的空间不小,绘制起来开销也大,没必要硬扛。
墓碑机制这块也提一下,如果你的 App 在后台被系统挂起,异步下载任务可能会被暂停。用户回到前台时,如果 NSURLSession 的下载任务没有做恢复处理,列表很容易出现几张图永久空白。这个场景比较冷门,但做 IM 或者资讯类场景经常会遇到,建议在 App 进入前台时统一刷新当前可见的 cell,不依赖任务自动恢复。还有一点,尽量别在 cellForRow 里做耗时操作,有些同事喜欢在返回 cell 前实时计算行高、裁剪圆角,这些顺手动作都会在滚动时叠加出卡顿风险,分散到子线程或者预计算比较好。
6. 这一路做下来的一些小经验
最后分享几个我的个人习惯。第一,内存缓存这层千万别用 NSNull 来占位表示下载失败,要缓存 NSError,否则失败过的图片每次进来还会再请求一次,浪费带宽和电量。第二,如果您项目已经在用 SDWebImage 这类库,那恭喜你可以少折腾;但即便用库,也建议把这一套缓存思路吃透,因为库只是帮你封装了,它的默认配置不一定适合你所有的业务场景,真出问题的时候你还是要看底层原理才能定位。
调试阶段可以用 Charles 观察请求次数来验证缓存逻辑对不对,如果一个 URL 在快速滑动后只出现一两次请求,基本就能确认任务合并和缓存生效了。我这里说的这套方案是比较正统的做法,不花哨但很稳,你可以在自己的项目里逐步实现,从手动下载到加内存缓存再到加磁盘缓存,等这些积累够了,再优化解码和预加载,整个过程会有一种"亲眼看到列表从卡顿变丝滑"的踏实感。
本文还有配套的精品资源,点击获取