UITableView异步加载图片:从卡顿到流畅的实战优化
发布时间:2026/11/2 7:06:31 锦皓数字建站

简介这份资源面向iOS开发初学者与需要优化列表性能的开发者聚焦UITableView中网络图片异步加载这一常见痛点帮助解决主线程阻塞导致的界面卡顿问题。压缩包共33个文件、约73KB以Objective-C源码为主包含7个.m实现文件与6个.h头文件另有xib界面、plist配置及工程文件构成一个可直接运行的LazyTableImages示例工程。代码围绕图片下载器、数据解析与列表控制器等模块组织演示了后台下载、主线程刷新与占位图展示的完整链路并附有ReadMe说明便于对照理解异步加载与缓存的基本思路。目前已有311人学习下载适合作为入门异步图片加载与TableView性能优化的参考样例读者可据此理解第三方库之外的自主实现方式并在此基础上扩展缓存与并发控制策略。1. 列表卡成幻灯片先把异步加载图片这件事做对UITableView 里塞网络图片几乎是每个 iOS 开发者都绕不开的一道坎。刚上手时最容易翻车的场景是列表一滚动主线程被同步下载和图片解码堵死帧率直接掉到个位数手指一滑像在拖一张湿纸。这个项目的核心就是解决这件事——把图片下载、解码、缓存从主线程挪走让 UITableView 在快速滚动时依然跟手。它适合两类人一是刚接触 iOS 列表开发、想搞懂 cell 复用与异步加载配合关系的新手二是已经在项目里写过图片加载、但被错位、闪烁、内存暴涨折磨过的熟手。下面按「能干什么 → 怎么跑 → 怎么改」的顺序把这份源码拆开讲。2. 项目结构与技术选型为什么不用第三方库自己写一遍2.1 目录结构与模块职责这份源码是一个精简的 Objective-C 工程围绕 UITableView 异步图片加载这一条主线组织没有引入 SDWebImage、YYWebImage 这类成熟库目的很明确把异步加载的每个环节摊开给你看。常见做法是拆成下面几块我按这个结构来讲你对照自己下载的工程目录能一一对上。目录/文件职责AppDelegate/SceneDelegate应用入口挂载列表页ListViewControllerUITableView 的数据源与代理负责 cell 配置ImageCell自定义 cell持有 imageView 与当前请求标识ImageLoader异步下载 内存缓存的核心类ImageCache基于 NSCache 的内存缓存封装Model列表数据模型含图片 URL 字段这个划分的关键在于把「下载」和「缓存」从 ViewController 里抽出来。很多新手会把NSURLSession直接写在cellForRowAtIndexPath里代码能跑但一旦要加缓存、要取消请求、要处理复用就会变成一团乱麻。抽成独立类之后ViewController 只关心「这个 cell 该显示哪张图」其余交给 ImageLoader。2.2 技术栈与选型理由工程用的是 Objective-C UIKit NSURLSession NSCache纯系统框架零第三方依赖。选 NSURLSession 而不是 NSURLConnection是因为前者原生支持异步回调和取消任务配合 cell 复用做请求管理更顺手选 NSCache 而不是自己拿 NSMutableDictionary 做缓存是因为 NSCache 在系统内存紧张时会自动清理省掉一堆手动释放的代码。这里有个容易被忽略的点图片下载完拿到的是一段 NSData直接[UIImage imageWithData:]会在主线程做解码大图依然卡。合格的做法是在后台线程把 NSData 解码成位图再回主线程赋值这个细节后面代码里会体现。技术选型上没有花哨的东西但每一层都对应一个具体的性能问题这也是我愿意拆这份源码的原因——它不炫技但把该做的都做了。2.3 跑起来之前的环境准备工程是标准 Xcode 项目用 Xcode 打开.xcodeproj即可。部署目标建议 iOS 12 以上因为用到了部分较新的 API 写法。跑之前确认两件事一是列表数据里的图片 URL 要能访问源码里通常内置了一组示例地址如果失效需要替换成你自己的图床地址二是Info.plist里如果走的是 http 地址需要配置 ATS 例外https 则不用管。# 打开工程 open ImageListDemo.xcodeproj # 命令行构建可选用于 CI 或快速验证编译 xcodebuild -project ImageListDemo.xcodeproj \ -scheme ImageListDemo \ -sdk iphonesimulator \ -configuration Debug buildxcodebuild这条命令的含义是指定工程、指定 scheme、指定模拟器 SDK、Debug 配置构建。如果报 scheme 找不到先在 Xcode 里 Product → Scheme → Manage Schemes 确认 scheme 已勾选共享。构建通过后选一个模拟器 Run列表能滚动、图片能逐步出现就说明环境没问题。3. 核心链路拆解一次 cell 复用里的图片加载全过程3.1 数据源与 cell 配置请求标识是防错位的关键先看列表最核心的cellForRowAtIndexPath。这段代码决定了图片怎么被触发加载也是错位问题的源头。- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { ImageCell *cell [tableView dequeueReusableCellWithIdentifier:ImageCell forIndexPath:indexPath]; Model *item self.dataList[indexPath.row]; // 关键给 cell 打上当前 URL 标识回调时用它比对 cell.currentURL item.imageURL; cell.imageView.image [UIImage imageNamed:placeholder]; __weak typeof(cell) weakCell cell; [[ImageLoader sharedLoader] loadImageWithURL:item.imageURL completion:^(UIImage *image, NSString *url) { // 回调回来时cell 可能已经被复用给了别的行 if ([weakCell.currentURL isEqualToString:url]) { weakCell.imageView.image image; } }]; return cell; }逻辑说明先通过复用池拿到 cell把当前行的 URL 记到cell.currentURL上再发起异步加载。回调里做了一次currentURL与回调 URL 的比对只有一致才赋值。参数上item.imageURL是数据模型里的字符串地址completion的第二个参数把 URL 原样带回就是为了这次比对。为什么必须比对因为 cell 是复用的。用户快速滑动时第 3 行的 cell 可能已经被拿去显示第 30 行此时第 3 行的图片下载完成回调回来如果不加判断直接赋值就会把第 30 行的图覆盖成第 3 行的这就是经典的「图片错位」。这个标识比对是血泪经验少了它列表一滑就乱。3.2 ImageLoader异步下载与后台解码ImageLoader 是整个项目的发动机负责查缓存、发请求、解码、回主线程。- (void)loadImageWithURL:(NSString *)url completion:(void (^)(UIImage *, NSString *))completion { if (!url.length) { return; } // 1. 先查内存缓存命中直接回主线程 UIImage *cached [[ImageCache sharedCache] imageForKey:url]; if (cached) { dispatch_async(dispatch_get_main_queue(), ^{ if (completion) completion(cached, url); }); return; } // 2. 未命中发起异步下载 NSURLSessionDataTask *task [[NSURLSession sharedSession] dataTaskWithURL:[NSURL URLWithString:url] completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { if (error || !data) { return; } // 3. 后台线程解码避免主线程卡顿 dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ UIImage *image [UIImage imageWithData:data]; // 强制解码把位图渲染到上下文避免首次上屏时解码 UIGraphicsBeginImageContextWithOptions(image.size, NO, image.scale); [image drawAtPoint:CGPointZero]; UIImage *decoded UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); [[ImageCache sharedCache] setImage:decoded forKey:url]; dispatch_async(dispatch_get_main_queue(), ^{ if (completion) completion(decoded, url); }); }); }]; [task resume]; }逻辑说明分四步查缓存、下载、解码、回主线程。参数上QOS_CLASS_USER_INITIATED表示用户主动触发的高优先级队列适合图片这种用户能感知到的任务UIGraphicsBeginImageContextWithOptions的第三个参数传image.scale是为了保持 Retina 屏的清晰度传 0 会按设备屏幕自动取。第三步的强制解码是很多人漏掉的。imageWithData:只是把压缩数据读进内存真正的解码发生在图片第一次被绘制到屏幕时如果那时在主线程就会掉帧。提前在后台把位图画出来等于把解码成本挪到了看不见的地方。这个技巧在长列表里效果非常明显。3.3 ImageCacheNSCache 封装与容量控制缓存层不复杂但容量参数值得说清楚。implementation ImageCache (instancetype)sharedCache { static ImageCache *instance; static dispatch_once_t onceToken; dispatch_once(onceToken, ^{ instance [[ImageCache alloc] init]; }); return instance; } - (instancetype)init { self [super init]; if (self) { _cache [[NSCache alloc] init]; _cache.countLimit 100; // 最多缓存 100 张 _cache.totalCostLimit 50 * 1024 * 1024; // 约 50MB } return self; } - (UIImage *)imageForKey:(NSString *)key { return [self.cache objectForKey:key]; } - (void)setImage:(UIImage *)image forKey:(NSString *)key { if (!image || !key) { return; } // cost 用像素字节数估算让 totalCostLimit 真正生效 NSUInteger cost (NSUInteger)(image.size.width * image.size.height * 4); [self.cache setObject:image forKey:key cost:cost]; } end逻辑说明单例保证全局只有一份缓存countLimit控制张数totalCostLimit控制总字节。注意setObject:forKey:cost:里的 cost 必须传否则totalCostLimit不会生效这是 NSCache 的一个坑。cost 用宽 × 高 × 4 估算4 是 RGBA 每像素字节数够用且简单。参数怎么调列表图多、单图小可以把 countLimit 调大单图大就压 totalCostLimit防止内存峰值过高被系统杀掉。3.4 请求取消滚动时别让无用请求占着带宽快速滚动时大量 cell 刚发起请求就被复用走了这些请求如果不管会白白占用连接和带宽回调回来还要走一遍比对逻辑。合格的做法是在 cell 准备复用时取消旧请求。// ImageCell.m - (void)prepareForReuse { [super prepareForReuse]; // 取消上一轮还没完成的请求 if (self.currentTask) { [self.currentTask cancel]; self.currentTask nil; } self.imageView.image nil; self.currentURL nil; }逻辑说明prepareForReuse是 cell 进复用池前必调的方法在这里取消任务、清空图片和标识保证下一次配置是干净状态。参数上currentTask需要在 ImageLoader 里把NSURLSessionDataTask返回给调用方保存否则 cell 拿不到任务句柄就没法取消。这一步做完滚动时的无效请求会明显减少流量和 CPU 都省下来。4. 避坑与排查异步加载图片最容易翻车的五个地方4.1 图片错位现象是滑动后图不对行现象快速上下滑动某些 cell 显示的图片和它对应的数据对不上停稳后过一会儿又自己变对。原因回调回来时没校验 cell 当前绑定的 URL旧请求覆盖了新内容。解决按 3.1 的做法在 cell 上存currentURL回调里比对一致才赋值同时在prepareForReuse里清空标识。这个坑几乎人人踩过加一行比对就能根治。4.2 主线程卡顿现象是滚动掉帧但图片能出来现象图片最终都能显示但滚动时明显不跟手Instruments 里主线程有大量解码耗时。原因只做了异步下载没做后台解码imageWithData:的解码成本留在了首次上屏。解决按 3.2 的第三步在后台队列用UIGraphicsBeginImageContextWithOptions强制解码后再回主线程。改完之后帧率通常能回到 60。4.3 内存暴涨现象是滑久了被系统杀掉现象列表越滑内存越高最后 App 被系统终止日志里有内存警告。原因缓存没设上限或者totalCostLimit设了但setObject:forKey:没传 cost导致上限形同虚设。解决按 3.3 传 cost并合理设置 countLimit 与 totalCostLimit同时在didReceiveMemoryWarning里调[cache removeAllObjects]兜底。4.4 请求堆积现象是滚动时流量异常、回调乱序现象快速滑动后网络请求数远超可见 cell 数回调顺序混乱。原因cell 复用时没取消旧任务旧请求继续跑完。解决按 3.4 在prepareForReuse里取消currentTask并让 ImageLoader 把 task 返回给调用方持有。取消后记得在回调里判断error.code NSURLErrorCancelled直接返回别当失败处理。4.5 占位图闪烁现象是图片加载完还闪一下白现象图片已经缓存命中但 cell 显示时仍先白一下再出图。原因配置 cell 时先设了占位图缓存命中回调又是异步回主线程中间有一帧空窗。解决缓存命中时如果已经在主线程可以同步赋值或者干脆在cellForRow里先同步查一次缓存命中就直接设图未命中再走异步。这个优化对已浏览过的区域体验提升明显。5. 二次开发改造从能跑到好用我一般这么改5.1 加磁盘缓存让冷启动也有图内存缓存一杀进程就没了二次开发第一步通常是补磁盘缓存。常见做法是在 ImageLoader 里加一层文件读写下载完成后把原始 NSData 写到 Caches 目录key 用 URL 的 MD5 避免文件名非法查缓存时先查内存再查磁盘磁盘命中后解码并回填内存。改造时注意磁盘读写也要放后台队列别在主线程碰文件 IO。验证方法是杀掉 App 重进之前看过的图应该秒出而不是重新下载。5.2 用 OperationQueue 管理并发与优先级NSURLSession默认并发数不好控图片多了会互相抢连接。我一般会把下载包成NSOperation丢进NSOperationQueue把maxConcurrentOperationCount设成 4 到 6并给可见区域的请求设高优先级、预取区域设低优先级。这样滚动时可见图优先出预取图慢慢补体验更顺。改造点集中在 ImageLoader对外接口不变属于低风险高收益的改法。5.3 预取让下一屏的图提前在路上iOS 10 之后有UITableViewDataSourcePrefetching可以在 cell 还没出现时就发起加载。实现prefetchRowsAtIndexPaths里调 ImageLoader 预加载cancelPrefetchingForRowsAtIndexPaths里取消。配合 5.2 的低优先级队列预取不会挤占可见图的带宽。验证时用 Instruments 看滚动过程中的请求时序预取请求应该集中在可见区域下方一两屏。5.4 改造后的自检清单检查项通过标准错位快速滑动停稳后每行图与数据一致帧率Instruments 中滚动时主线程无明显长任务内存长时间滑动内存曲线平稳无持续上涨流量快速滑动时请求数接近可见 cell 数冷启动杀进程重进看过的图秒出这张表我每次改完图片加载都会过一遍比凭感觉靠谱。尤其是流量那一项请求数异常往往意味着取消逻辑没生效。5.5 一个我踩过的教训早些年我在一个列表项目里图省事把下载、解码、缓存全塞进cellForRow本地小图测试一切正常一上真机大图列表直接卡成 PPT排查了半天才定位到主线程解码。从那以后我每次做列表图片加载都强制走一遍「后台解码 标识比对 复用取消」这三件套再拿 Instruments 验一遍帧率才敢提交。这份源码把这三件事都摆在了明面上照着改一遍比看十篇原理文都管用。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。