
简介针对 iOS 开发中 UITableView 加载远程图片易阻塞主线程、导致列表卡顿的痛点这份源码示例提供了可直接运行的异步图片加载方案。工程围绕 LazyTableImages 展开清晰演示在 cellForRowAt 中发起后台下载、回到主线程渲染图片的完整流程并给出 ParseOperation、IconDownloader 等关键类的实现方便理解异步任务调度、占位图与 cell 复用机制。压缩包共 33 个文件以 Objective-C 源码m/h、界面布局xib/plist和 App 图标png为主附 ReadMe 说明整体仅 73KB结构紧凑易读。目前已有 309 人学习下载适合 iOS 新手对照工程掌握 NSOperationQueue、DispatchQueue 与 NSCache 的搭配用法也可为后续迁移到 SDWebImage、Kingfisher 等成熟库建立扎实的认知基础。1. iOS TableView 异步加载图片不只是换行代码的事很多人第一次接触 iOS 异步加载图片是从一个反直觉的结论开始的UITableView 滚动卡顿根源往往不是 cell 布局算得慢而是主线程被图片解码和磁盘 I/O 堵死了。同步加载图片时每次 cell 出现都要从网络或磁盘取数、解压、绘制这一串操作全压在主线程上帧率自然崩。本文要拆的这份「iOS TableView 异步加载图片」资源解决的就是这个核心矛盾把图片的获取、解码、缓存全部挪出主线程同时处理好 cell 复用时图片错乱的问题。适合刚接手列表页优化的 iOS 开发者也适合那些已经用 SDWebImage 但不知道底层在做什么、出了问题只能瞎试的熟手。我会从缓存设计讲到实际落地的队列方案再把常见的坑一个个踩给你看。2. 内存与缓存为什么异步加载必须配 NSCache 而不是字典2.1 图片缓存的血泪教训字典为什么会把内存撑爆很多初学者实现异步加载时第一反应是搞一个 NSMutableDictionary 存图片URL 当 keyUIImage 当 value。表面看没问题但字典是强引用图片只进不出一个列表页几十张图几十 MB 内存是常态图片再多一点直接就收到内存警告。更麻烦的是字典没有自动淘汰机制你根本不知道什么时候该手动清空。NSCache 是系统专门为缓存设计的类它的淘汰策略是系统内存吃紧时自动释放一部分对象不需要你手动干预。这听起来像玄学但原理其实不复杂NSCache 内部维护了一个按访问时间排序的淘汰队列内存压力达到阈值时会从最久没被访问的对象开始清理。和字典相比NSCache 是线程安全的——不用加锁就能在多线程环境里读和写这对异步加载非常关键。实现上还有个细节NSCache 的 countLimit 和 totalCostLimit 默认是 0表示不限制。实际工程里一定要显式设置否则缓存可能膨胀到你想象不到的地步。我一般会这样配NSCache *imageCache [[NSCache alloc] init]; imageCache.countLimit 100; // 最多缓存 100 张图 imageCache.totalCostLimit 50 * 1024 * 1024; // 总开销上限 50MB超了自动淘汰countLimit 控制的是缓存对象个数totalCostLimit 控制的是总开销需要你在写入时手动指定 cost 参数。这两个参数配合使用才能保证缓存既不会占太多内存也不会频繁淘汰导致命中率太低。需要留意的是NSCache 的淘汰策略在 iOS 13 之后有所调整系统会优先保留正在被使用的对象但你仍然不能依赖它来做精准的内存控制。2.2 磁盘缓存补位内存不够用时的第二道防线内存缓存哪怕配得再好App 重启之后缓存就没了。图片资源如果每次都重新从网络拉流量消耗和加载速度都会很难看。常见的做法是加一层磁盘缓存内存里没命中就去磁盘找磁盘也没有才发网络请求。磁盘缓存的实现有两个极端一是直接把图片文件写到 tmp 目录简单但不安全系统随时可能清空二是用 NSCachesDirectory 目录加文件名 MD5 处理把 URL 映射成一个固定文件名。文件名直接拿 URL 当名字是不行的URL 里的斜杠、问号、 这些字符在文件系统里会出问题所以必须做哈希。某开发者的项目我印象很深他当时偷懒直接用 URL 的 lastPathComponent 当文件名结果同一个图片在不同参数下被当成不同文件存了两份磁盘体积直接翻倍。- (NSString *)cacheFilePathWithURL:(NSURL *)url { NSString *cachesDir [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject]; NSString *fileName [self md5:url.absoluteString]; // 对完整 URL 做 MD5 return [cachesDir stringByAppendingPathComponent:fileName]; }磁盘缓存的写入适合放到异步线程用 NSFileManager 写文件本身不慢但主线程频繁 I/O 还是会卡。我一般是在图片解码完成之后先把 UIImage 转成 JPEG 或 PNG 数据再写磁盘注意这里是同步写但放在后台队列里执行不影响主线程。读取的时候反过来先读 Data再在后台解码成 UIImage然后回主线程设置图片。磁盘缓存有个容易踩的细节图片格式。PNG 解码慢但无损JPEG 解码快但压缩有损。通用做法是统一存 JPEG质量设 0.8 左右视觉效果差别不大但解码速度能快不少。除非原图本身是 PNG 且带透明通道这种场景得按 PNG 存。某电商 App 的列表页就是因为把透明 PNG 全转了 JPEG结果商品图上出现黑底排查了半天才找到原因。3. 异步加载落地GCD 与 NSOperationQueue 的选择3.1 主队列 全局队列最简单的异步模型最直观的异步加载写法是在 cellForRowAtIndexPath 里发起异步任务图片回来后回到主线程设置 image。用 GCD 实现就是下面这样- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { MyCell *cell [tableView dequeueReusableCellWithIdentifier:CellID forIndexPath:indexPath]; NSURL *imageURL self.dataArray[indexPath.row][imageURL]; // 先从内存缓存取 UIImage *cachedImage [self.imageCache objectForKey:imageURL.absoluteString]; if (cachedImage) { cell.iconImageView.image cachedImage; return cell; } // 没命中缓存占位图先顶上 cell.iconImageView.image [UIImage imageNamed:placeholder]; // 异步下载 dispatch_async(dispatch_get_global_queue(QOS_QUALITY_CLASS_UTILITY, 0), ^{ NSData *data [NSData dataWithContentsOfURL:imageURL]; UIImage *image [UIImage imageWithData:data]; // 解码发生在后台线程 if (image) { [self.imageCache setObject:image forKey:imageURL.absoluteString]; } dispatch_async(dispatch_get_main_queue(), ^{ // 回到主线程设置但必须确认 cell 没有被复用 if ([cell.iconImageView.image isEqual:[UIImage imageNamed:placeholder]]) { cell.iconImageView.image image; } }); }); return cell; }这段代码有个非常经典的坑图片异步回来的时候cell 可能已经被滚动走了甚至已经被复用显示另一条数据的图片了。上面代码里的判断逻辑是错的isEqual:比较 UIImage 对象不可靠而且占位图被设置成一样的情况下根本区分不出来。正确做法是判断 indexPath 是否还对应当前 cell或者给 cell 加一个标记 URL 的属性。3.2 优化方向把网络库换成 NSURLSession 取消任务dataWithContentsOfURL:是同步方法放在后台线程虽然不卡 UI但遇到网络慢时线程会被长期占住并发一大就会耗尽线程池。正经项目里不会这么写一般换 NSURLSession 的异步回调并且支持取消。异步加载最容易被忽略的就是取消用户快速滑动列表cell 滚出屏幕后之前发起的网络请求还在跑数据回来发现 cell 已经被复用这不仅是浪费还可能导致图片错乱。NSOperationQueue 比 GCD 更适合做图片加载的场景因为 NSOperation 自带取消接口。你可以把一次图片加载封装成一个 NSOperationcell 复用时调用 cancel 把这个操作取消。相比之下GCD 的 block 一旦提交就没法取消了。// 用 NSOperation 封装图片加载任务 interface ImageLoadOperation : NSOperation property (nonatomic, strong) NSURL *imageURL; property (nonatomic, copy) void (^completion)(UIImage *); end implementation ImageLoadOperation { BOOL _executing; BOOL _finished; } - (void)start { if (self.isCancelled) { [self markAsFinished]; return; } _executing YES; NSURLSessionTask *task [[NSURLSession sharedSession] dataTaskWithURL:self.imageURL completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { if (self.isCancelled || error) { [self markAsFinished]; return; } UIImage *image [UIImage imageWithData:data]; dispatch_async(dispatch_get_main_queue(), ^{ if (!self.isCancelled self.completion) { self.completion(image); } [self markAsFinished]; }); }]; [task resume]; }这段代码的关键在于检查取消必须在回到主线程之前和之后各做一次。因为任务被取消时网络回调可能已经在排队了你不检查的话completion 还是会执行UI 还是会被设置。NSOperation 的 isCancelled 只是标记位真正的执行体需要手动在每个关键节点检查这个标记。3.3 自定义并发队列控制最大并发数NSOperationQueue 还有一个杀手锏能力设置最大并发操作数。默认的 maxConcurrentOperationCount 是 1这在很老的系统上没问题因为那时移动网络慢并发高了反而互相抢带宽。现在 Wi-Fi 和 5G 环境下并发数可以放宽但也不能无限制地开否则磁盘 I/O 和内存会飙升。self.imageQueue [[NSOperationQueue alloc] init]; self.imageQueue.maxConcurrentOperationCount 6; // 同时最多 6 个下载任务 self.imageQueue.name com.example.imageLoadQueue;这个并发数不是越大越好。我做过一个简单的测试并发 3 和并发 6 的完成时间差距不大但并发开到 10 以上首页快速滚动时内存会明显上涨。原因在于下载完的图片数据需要等 cell 显示才能释放如果下载速度大于 cell 消耗速度数据就会堆积。经验值一般取 46 就够用了。cell 复用时的取消逻辑配合 NSOperationQueue 就很优雅在 willDisplayCell 里发起加载在 didEndDisplayingCell 里取消之前的加载任务。这两个回调是一对前者告诉你要显示 cell 了后者告诉你要离开屏幕了。借助这两个代理方法可以精确控制加载任务的生命周期。- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath { ImageLoadOperation *op [self loadOperationForIndexPath:indexPath]; [self.imageQueue addOperation:op]; } - (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath { [self cancelLoadOperationForIndexPath:indexPath]; }这里有个关联关系要维护indexPath 和 operation 需要存在一个 map 里否则 didEndDisplaying 的时候拿不到对应的 operation 去取消。我用的是一个 NSMutableDictionarykey 是 indexPath 的字符串描述。注意在数据源更新时要把旧的 map 清理干净否则 indexPath 复用了操作被错误取消图片就永远加载不出来了。4. 避坑与排查TableView 异步加载图片的 5 个常见翻车点4.1 图片错乱显示的是上一张 cell 的图现象快速滚动列表时某张 cell 先显示了 A 图片过一阵子突然变成 B 图片甚至部分 cell 图片完全张冠李戴。原因cell 被复用了。异步下载完成后回调里拿到的是已经被复用的 cell此时 cell 已经被用来显示新的数据行旧的图片就盖到了新数据上。另一个原因是取消了但没彻底取消旧任务跑完又设置了一遍 image。解决最稳妥的方案是 indexPath 比对。在回调里拿到当前 cell 的 indexPath和发起请求时的 indexPath 比对不一致就丢弃结果。实现时可以这样写__weak typeof(cell) weakCell cell; NSIndexPath *originalIndexPath indexPath; // 发起时的 indexPath dispatch_async(dispatch_get_main_queue(), ^{ // 重新获取当前 indexPath和发起时的对比 NSIndexPath *currentIndexPath [self.tableView indexPathForCell:weakCell]; if (currentIndexPath nil || currentIndexPath.row ! originalIndexPath.row) { return; // cell 已滚出或已复用丢弃 } weakCell.iconImageView.image image; });用indexPathForCell:而不是直接判断 cell 是否相等是因为 cell 对象可能已经被销毁或重用拿不到有效比对信息。另外记得配合 4.2 的取消机制双重保障。这种问题最容易在加载网络图 本地占位图的混合场景出现因为占位图会掩盖状态。4.2 卡顿掉帧加载过程中滚动不流畅现象列表滑动有肉眼可见的掉帧Xcode 的 Core Animation 调试面板显示 GPU 使用率很高。原因图片解码是在主线程完成的。很多人以为把dataWithContentsOfURL:放到后台就万事大吉了但imageWithData:真正解码 JPEG/PNG 是在 image 被 GPU 渲染时才开始的而且解码线程不能保证是后台线程。iOS 的顺带解码机制decode-on-draw会把解码工作推迟到图层合成阶段此时主线程就被卡住了。解决强制在后台线程完成解码。UIImage的imageWithData:如果传入完整数据且设置sd_load之类的标志本质上还是懒加载。要强制解码一个做法是直接把图片绘制到位图上下文- (UIImage *)decodedImageWithImage:(UIImage *)image { CGImageRef cgImage image.CGImage; if (cgImage NULL) return image; CGFloat width CGImageGetWidth(cgImage); CGFloat height CGImageGetHeight(cgImage); if (width 0 || height 0) return image; CGContextRef context CGBitmapContextCreate(NULL, width, height, CGImageGetBitsPerComponent(cgImage), 0, CGImageGetColorSpace(cgImage), kCGImageAlphaNoneSkipFirst); if (context NULL) return image; CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage); CGImageRef newCgImage CGBitmapContextCreateImage(context); UIImage *newImage [UIImage imageWithCGImage:newCgImage scale:image.scale orientation:image.imageOrientation]; CGContextRelease(context); CGImageRelease(newCgImage); return newImage; }调用解码的时机建议在下载回调的后台阶段执行解码完成再回到主线程赋值。注意CGImageGetAlphaInfo参数不能随意写如果原图没有 alpha 通道但参数给了kCGImageAlphaPremultipliedFirst会直接闪退。4.3 内存疯涨滚动几屏就收到内存警告现象Xcode 内存监控里看到 App 内存从 30MB 一路涨到 200MB 甚至更高然后系统弹内存警告接着闪退。原因下载的图片数据堆在内存里没有被释放同时 NSCache 的总开销没设置或设置不合理。图片数据本身加上解码后的位图数据是两份位图数据才是大头。一张 1920x1080 的 JPEG解码后的位图要占 1920x1080x4 字节约 8MB。列表里几十张这种图同时存在内存必然爆。解决除了设置 NSCache 的 countLimit 和 totalCostLimit 之外还需要在内存警告时主动清理缓存。UIApplicationDidReceiveMemoryWarningNotification订阅后执行[self.imageCache removeAllObjects]。另外图片显示时按 cell 的实际尺寸压缩成小图再缓存常用的缩略图做法如下CGSize thumbnailSize CGSizeMake(200, 200); UIGraphicsBeginImageContextWithOptions(thumbnailSize, YES, [UIScreen mainScreen].scale); [image drawInRect:CGRectMake(0, 0, thumbnailSize.width, thumbnailSize.height)]; UIImage *thumbnail UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext();裁剪到 cell 需要的尺寸再缓存能省掉至少 80% 的位图内存。这个优化是对内存压力最有效的没有之一。4.4 滚动到底部加载变慢没有复用操作的坑现象滚动越快底部 cell 图片出现得越晚有时等两三秒才出图。原因每个 cell 每次进入屏幕都发起一次新的下载任务没有对同一 URL 的任务做合并。列表快速滚动时同一个 URL 可能被重复请求 5-6 次服务器压力大不说队列里堆满重复任务真正需要的任务排到很后面。解决维护一个待处理任务池相同 URL 的任务只发一个其余等待这个任务完成再取缓存。实现可以用一个 NSMutableDictionary 保存正在加载的 URL 对应的操作队列所有订阅者共用一个请求。- (ImageLoadOperation *)loadImageWithURL:(NSURL *)url completion:(void (^)(UIImage *))completion { // 检查是否已有相同 URL 的操作在跑 NSMutableArray *handlers self.pendingHandlers[url.absoluteString]; if (handlers) { [handlers addObject:[completion copy]]; // 挂到回调列表等待结果 return nil; // 不创建新操作 } // 创建新操作并创建回调数组 NSMutableArray *newHandlers [NSMutableArray arrayWithObject:completion]; self.pendingHandlers[url.absoluteString] newHandlers; ImageLoadOperation *op [[ImageLoadOperation alloc] initWithURL:url]; op.completion ^(UIImage *image) { NSArray *completions self.pendingHandlers[url.absoluteString]; for (void (^handler)(UIImage *) in completions) { handler(image); } [self.pendingHandlers removeObjectForKey:url.absoluteString]; }; return op; }这个逻辑的核心是同 URL 的第二个请求到达时不创建新的 NSOperation只是把 completion 回调挂到已有任务的数组里。图片回来后统一分发然后清掉池子。这能显著降低队列中的任务数也就提升了有效任务的执行效率。4.5 图片加载一半显示破图URL 编码问题现象某些图片加载失败显示空白或破图但同样的 URL 在浏览器里能正常打开。原因URL 里含有中文、空格或特殊字符NSURL 没有做百分号编码。常见的是商品图片 URL 带中文路径或者参数里有|、{这类字符。解决请求前统一做编码转换。用stringByAddingPercentEncodingWithAllowedCharacters:而不是老旧的stringByAddingPercentEscapesUsingEncoding:后者在 iOS 9 已废弃而且转义规则不完全。NSString *urlString [rawString stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; NSURL *url [NSURL URLWithString:urlString];URLQueryAllowedCharacterSet会保留?和所以参数拼接不会出错。如果 URL 路径里也有中文需要拆成 path 和 query 分别处理不能一刀切。这个问题排查起来很隐蔽因为框架层根本不报错URL 解析失败直接返回 nil。5. 进阶技巧预加载、缓存清理与加载状态反馈5.1 预加载滚动方向感知的 prefetchiOS 的 UITableView 从 iOS 10 开始支持 prefetching APIUITableViewDataSourcePrefetching协议可以提前拿到将要显示的 cell 的 indexPath利用滚动的惯性提前发起加载。配合异步加载用户体验提升非常明显。- (void)tableView:(UITableView *)tableView prefetchRowsAtIndexPaths:(NSArrayNSIndexPath * *)indexPaths { // 取最后几个 indexPath 对应的 URL提前加载 for (NSIndexPath *indexPath in indexPaths) { NSURL *url self.dataArray[indexPath.row][imageURL]; [self p_loadImageWithURL:url completion:nil]; // 只缓存不显示 } } - (void)tableView:(UITableView *)tableView cancelPrefetchingForRowsAtIndexPaths:(NSArrayNSIndexPath * *)indexPaths { // 和 didEndDisplayingCell 一样取消任务 for (NSIndexPath *indexPath in indexPaths) { [self p_cancelLoadForURL:self.dataArray[indexPath.row][imageURL]]; } }注意 prefetch 的 completion 传 nil 或空回调只预热缓存不更新 UI。prefetch 的时机由系统决定开发者不需要管触发频率但取消逻辑必须写否则快速回滑时预加载的任务还在跑占资源且可能缓存了用户根本不看的内容。5.2 缓存状态监控给加载加上可见反馈异步加载用户是感知不到过程的不像点击按钮可以立刻给个 loading 动画。图片加载场景下占位图是最基本的反馈但只有占位图容易让人以为是 bug。我在列表页里会做一层状态机加载中显示灰色占位 小菊花失败显示一张破图图标 点击重试手势成功才有真实图片。这个状态机可以挂在自定义 UIImageView 的子类上typedef NS_ENUM(NSInteger, ImageLoadState) { ImageLoadStateNone, ImageLoadStateLoading, ImageLoadStateSuccess, ImageLoadStateFailed }; interface SmartImageView : UIImageView property (nonatomic, assign) ImageLoadState state; property (nonatomic, strong) UIImage *placeholderImage; property (nonatomic, strong) UIImage *failureImage; end状态切换的逻辑写在 setter 里从失败态点击时把 state 重置为 None 再触发一次异步加载。这样处理虽然代码量上多了一些但用户的困惑感会少很多也比任何优化都直观。别小看这个状态机它对调试也有好处从 Xcode 里看视图层级一眼就知道哪个 cell 加载失败不用猜。5.3 缓存清理策略不要等系统帮你清虽然 NSCache 会自动淘汰磁盘缓存也会被系统清理但主动提供清理入口仍然是有必要的。设置页里加一个「清除缓存」是常规操作更关键的是要计算并显示缓存大小。我习惯写一个工具方法在 App 启动时异步计算 NSCachesDirectory 的总大小这样用户能直观感受到缓存占了多少空间清理也有心理预期。- (void)calculateCacheSizeCompletion:(void (^)(NSString *sizeString))completion { dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ NSString *cachesDir [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject]; NSArray *files [[NSFileManager defaultManager] subpathsAtPath:cachesDir]; long long totalSize 0; for (NSString *file in files) { NSString *path [cachesDir stringByAppendingPathComponent:file]; NSDictionary *attrs [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil]; totalSize [attrs fileSize]; } dispatch_async(dispatch_get_main_queue(), ^{ completion([NSByteCountFormatter stringFromByteCount:totalSize countStyle:NSByteCountFormatterCountStyleFile]); }); }); }清理时注意只删自己生成的图片缓存文件不要碰系统生成的缓存。最安全的做法是把图片缓存放到独立的子目录里清理时只删这个子目录。某开发者就踩过这个坑直接把整个 Caches 目录删了结果系统缓存也一起没了导致后续页面加载变慢。5.4 验证手段慢速网络与极端列表的双重压力测试异步加载写完别急着收工。你的优化到底有没有效果得用数据说话。我会在真机上开 Network Link Conditioner 模拟 3G 弱网然后反复快速滚动列表 3-5 分钟。同时打开 Instruments 的 Time Profiler 和 Allocations重点看主线程的 CPU 占用有没有超过 20%内存有没有逐渐上涨不回落。还有一个容易被忽略的验证点快速来回滑动后停在一个位置等两三秒所有可见 cell 的图片应该都能加载出来而且顺序不能是乱序出现的。如果有的 cell 图片等了很久才出来说明任务取消逻辑写得太激进导致必要的加载被取消了。这时候要做的不是调并发数而是检查取消的条件是不是同一个 indexPath 的 controller 刷新时把旧的取消了没重新发起。从那以后我每次调试异步加载都强制走一遍「3G 慢网 快速滑动 停留检查」的流程花不了几分钟但能提前揪出一大半的诡异问题。希望帮到你。本文还有配套的精品资源点击获取