UITableView图片异步加载:从线程到缓存,全面优化列表流畅性 📅 发布时间:2026/9/8 6:23:41 👁 浏览次数: 简介针对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 在快速滑动后只出现一两次请求基本就能确认任务合并和缓存生效了。我这里说的这套方案是比较正统的做法不花哨但很稳你可以在自己的项目里逐步实现从手动下载到加内存缓存再到加磁盘缓存等这些积累够了再优化解码和预加载整个过程会有一种亲眼看到列表从卡顿变丝滑的踏实感。本文还有配套的精品资源点击获取