简介:针对 iOS 开发中 UITableView 加载网络图片常出现的界面卡顿与内存压力,这份示例工程包提供了完整且可直接运行的落地方案,适合新闻列表、商品展示等高频滚动场景下的中初级 iOS 开发者。压缩包共 33 个文件,体积仅 73KB,其中 .m/.h 源文件实现异步下载与缓存逻辑,.xib 负责界面布局,.png 为图片资源,.plist 等用于工程配置,结构完整,便于对照学习一个典型 Xcode 工程的组织方式。资源清晰演示了在 cellForRowAt 中通过后台任务下载图片、切回主线程更新 UI 的流程,并给出占位图、缓存管理、避免重复下载以及根据 cell 尺寸缩放图片等优化思路;相关实现代码封装清晰,可迁移到自己的项目中复用。目前累计已有 309 人学习/下载,非常适合快速掌握列表图片异步加载的核心写法。
1. ios tableview 异步加载图片:为什么你的列表一滑就卡,问题不一定在网络
很多人在 iOS 开发里第一次认真考虑“异步编程”这件事,就是从 UITableView 加载图片开始的。你写完一个简单的 cell,直接把图片 URL 丢给同步请求,结果列表一滚动就卡成了 PPT,甚至主线程直接卡死。其实图片数据量本身并不大,真正的问题是你把网络请求、图片解码、位图渲染全压在了主线程上。UITableView 的 cell 复用机制又会在滚动时频繁创建和销毁视图,每一次同步加载都在跟用户的滑动抢主线程时间片。
这篇文章就围绕“ios tableview 异步 加载图片”这条主线,把从线程模型、回调方式、cell 复用防错乱到性能验证的全套做法拆开。适合刚接触 iOS 异步图片加载的新手照着做,也适合被图片错乱、滚动掉帧折腾过、想系统搞清楚边界和坑的熟手。你不需要提前装任何第三方库,我会先用原生方法把链路打通,再谈组件化方案。
2. 异步加载的核心:线程模型、主线程回推与最小实现
2.1 同步加载为什么会卡:主线程被网络请求和图片解码同时堵住
UITableView 的数据源回调tableView:cellForRowAtIndexPath:跑在主线程上。如果在里面用NSData(contentsOf:)直接拉图片,等于在主线程里发起了一次同步网络调用。网络返回时间不可控,在弱网环境下可能几秒钟都回不来,用户的每一次滑动、点击事件也全被堵在同一个队列里,表现就是列表静止、界面无响应。
图片解码同样是个隐形开销。从网络拿到的 JPEG/PNG 数据要先经过解码变成位图,才能被 UIImageView 渲染。解码是 CPU 密集操作,在主线程做的话,即使网络秒回,滚动时依然会因为解码造成掉帧。常见做法是把网络请求和图片解码都放到后台队列,回到主线程只做最简单的 imageView.image 赋值。
2.2 最小异步实现:用 DispatchQueue 把加载任务扔到后台
先看一段能跑通链路的最小代码,建立异步加载的直观感受:
// 异步加载图片的最小实现 - (void)loadImageWithURL:(NSURL *)url completion:(void(^)(UIImage *image))completion { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSData *data = [NSData dataWithContentsOfURL:url]; UIImage *image = [UIImage imageWithData:data]; dispatch_async(dispatch_get_main_queue(), ^{ if (completion) { completion(image); } }); }); }这段代码的逻辑很直接:全局并发队列负责网络请求和图片解码,完成后再用主队列回调去刷新 UI。DISPATCH_QUEUE_PRIORITY_DEFAULT是一般网络任务的推荐优先级,不需要自己创建线程池,系统会根据负载调整活跃线程数。
它在 Demo 里能跑通,但它没做缓存、没做任务取消、没做并发控制,同一个 URL 重复进入时会发起重复请求,快速滑动时会堆积大量后台任务。这些问题先记下,后面逐一解决。
2.3 主线程回推的三种写法:GCD、NSOperationQueue 与回调时机
回到主线程的写法通常有几种,常见的是 GCD 的dispatch_async(dispatch_get_main_queue(), ...),另一种是NSOperationQueue配合addOperationWithBlock:提交到主队列。NSOperationQueue的优势是可以通过设置maxConcurrentOperationCount来控制并发加载的图片数量,避免瞬间发起过多网络请求。
第三种在 Swift 里更常见,是用 URLSession 的 completion handler,它本身就默认在后台线程回调,最后再切回主线程。无论用哪种,核心原则都一样:UI 操作只能在主线程执行。我会优先用 GCD,因为它的语义清晰、代码简洁,且任务可以随时用 block 包裹。实际开发中还要注意回调时机,如果在快速滑动时 cell 已经被复用了,这时拿到图片不能直接赋值,这个坑在第 4 章详细展开。
3. 三种落地路径:代理回调、block 回调与组件化缓存方案
3.1 用代理回调搭建独立的图片加载管理器
通常业务代码里不会像上面那段最小 Demo 一样到处写 GCD,而是抽一个负责图片加载的管理类。代理回调是比较基础的抽象方式,典型设计如下:
@protocol ImageLoaderDelegate <NSObject> - (void)imageLoader:(ImageLoader *)loader didLoadImage:(UIImage *)image forURL:(NSURL *)url; @end @interface ImageLoader : NSObject @property (nonatomic, weak) id<ImageLoaderDelegate> delegate; - (void)loadImageFromURL:(NSURL *)url; @end @implementation ImageLoader - (void)loadImageFromURL:(NSURL *)url { // 后台加载后回主线程,再通过代理回调通知调用方 } @end在 cell 中持有一个 ImageLoader 实例,下载完成后走代理方法更新图片。@property的 delegate 一定要用weak修饰,否则会造成循环引用,cell 无法被释放。代理回调的好处是调用方不必依赖 block 的捕获语义来管理引用,坏处是代理方法通常只能返回image和url,如果同一时刻有多个 cell 在加载,每个都需要通过 URL 去分辨对应的 cell 位置。
这套方案在早期 App 中很常见,但现实中我见到的代码更多的还是 block 回调,因为简洁、局部性好,不需要为每一类加载事件单独定义协议。
3.2 用 block 回调让 cell 内部代码更紧凑
block 版本的加载管理器改动不大,核心是增加一个 completion 参数:
- (void)loadImageFromURL:(NSURL *)url completion:(void(^)(UIImage *image, NSError *error))completion { dispatch_async(dispatch_get_global_queue(0, 0), ^{ NSError *error = nil; NSData *data = [NSData dataWithContentsOfURL:url options:0 error:&error]; UIImage *image = [UIImage imageWithData:data]; dispatch_async(dispatch_get_main_queue(), ^{ if (completion) completion(image, error); }); }); }调用方在 cellForRow 里可以这样写:
[loader loadImageFromURL:imageURL completion:^(UIImage *image, NSError *error) { cell.imageView.image = image ?: [UIImage imageNamed:@"placeholder"]; }];block 写法不只是少写一个协议,更重要的是把加载逻辑和 UI 更新逻辑放在同一处,阅读顺序和业务想到一起。但它最经典的坑就是 cell 复用下的错乱:滚动后前面 cell 的 block 才执行完,把图片赋给了当前复用的 cell。这个问题不是 block 本身造成的,用了代理也一样会发生,根本原因在于回调回来的图片没有跟“当前滚到的 cell”对应上。
3.3 引入缓存与组件化:为什么 SDWebImage 成了事实标准
自己手写的加载 manager 只能解决异步问题,解决不了缓存问题。每次滚动回去又重新网络下载,用户的流量和你的服务器带宽都会被白白吃掉。这是我在项目里摔过跟头的地方,后来才明白缓存是异步加载里绕不开的一环。
现实中的常见做法是引入成熟的图片加载库,SDWebImage 是 iOS 里用得最普遍的一个。它的核心能力是做两级缓存:内存缓存用 NSCache 管理,磁盘缓存通过文件系统落地,网络层自带下载队列和取消机制。接入很简单,直接给 UIImageView 扩展方法:
[cell.imageView sd_setImageWithURL:imageURL placeholderImage:[UIImage imageNamed:@"placeholder"]];这个方法做了几件事:先从内存缓存取,没有就去磁盘缓存取,再没有就发起网络请求,最后把图片写回两级缓存。它还自带 URL 与UIImageView的重绑定逻辑,能解决一部分复用错乱问题。引入第三方库不代表自己不用懂原理,恰恰相反,只有理解了前面说的异步线程模型和缓存机制,你才能用好 SDWebImage 的 options 参数,比如SDWebImageRetryFailedSDWebImageLowPriority的搭配选择。在只需要少量图片的场景,自研方案没问题;一旦列表图片数量大且复用频繁,组件化是更可靠的路。
4. cell 复用与异步加载:图片不串台、不闪跳的关键处理
4.1 图片错乱的根因:回调时序与 cell 复用
cell 复用机制决定了屏幕外的 cell 会被滚回到屏幕内时重新绑定数据。异步加载的请求是后发先至的:cell A 发起图片请求后滑出屏幕,cell A 被复用为 cell B,这时 cell A 的请求完成了,回调闭包里持有的 self 其实是已经被复用的那个 cell 对象,于是把 A 的图片塞给了 B。
这不是玄学,是网络时序和对象复用共同导致的结果。正常做法是在给 imageView 赋值前检查当前数据源的 URL 与回调返回的 URL 是否一致。我见过很多新人把问题归咎于 SDWebImage 有 bug,其实苹果官方文档已经说明,设置 cell 的内容时要手动处理复用,图片库只是做了一层保险。
4.2 URL 匹配防错乱:最朴素有效的实现
在自己手写加载时,通过 URL 匹配防错乱是相对简单又可靠的方案:
// 在 cellForRow 中发起异步加载 NSURL *currentURL = [NSURL URLWithString:model.imageURL]; cell.imageView.image = [UIImage imageNamed:@"placeholder"]; __weak typeof(cell) weakCell = cell; [loader loadImageFromURL:currentURL completion:^(UIImage *image, NSError *error) { if (!image) return; // 关键:核对当前 URL 与请求 URL 是否一致 if ([weakCell.currentLoadURL isEqual:currentURL]) { weakCell.imageView.image = image; } }];__weak typeof(cell) weakCell是为了避免 block 持有 cell 导致无法复用;currentLoadURL每次在 cellForRow 里更新为当前模型的 URL。回调返回后先比较 URL,一致才赋值。这套逻辑简单、可读性强,作为自研方案是够用的。
但要注意一个边界:如果两张不同图片恰好有相同的 URL,匹配依然会错乱。这种情况通常出现在服务端 URL 不带时间戳或签名参数、图片内容被 CDN 缓存但已被替换时。所以更严格的做法是给 cell 增加一个自增的 loadID,每次绑定数据时加一,回调里只认 loadID 等于当前值的请求。
4.3 复用时的占位图处理与视觉跳变
滚动过程中频繁设置占位图会导致图片闪烁。即便不做频闪优化,至少要保证数据源切换时把旧图清掉,否则会出现上一个数据项的图片在新数据还没加载完成前短暂露出来。常见做法是每次 cellForRow 开头就设置占位图或清空 imageView,再发起异步加载。
如果想在图片加载完成后做一个渐变过渡,可以用UIView transitionWithView:duration:options:animations:来做淡入淡出效果,让图片出现时平滑一些。注意不要在快速滚动时为每个 cell 都开动画,一般只在首次加载完成时做,且最好限制在无用户交互或低速滚动场景。
5. 避坑清单:异步加载图片的 5 个常见问题与排查
5.1 图片闪跳、闪烁:复用导致的视觉问题
现象:滚动列表时,已经显示的图片会被占位图短暂覆盖,然后重新加载出来,看起来像在闪。
原因:cell 在prepareForReuse或 cellForRow 开头被置为占位图,旧图被清掉,新图还没回来。
解决:不要每次都清空 imageView,只有当数据源里的图片 URL 跟之前不一样时才重置 UI,相同 URL 直接沿用已有图片。代码逻辑上可以增加一个 if 判断:如果当前模型的图片 URL 等于上次配置的 URL,就跳过占位图设置。
5.2 快速滑动时卡顿:后台任务堆积与图片解码重开销
现象:不滚动时加载正常,快速滑动后列表开始掉帧,甚至滚动停止后还有一段时间卡。
原因:每次滚动经过一个 cell 就发起一个网络请求,后台队列任务堆积,同时 JPEG 解码占用大量 CPU,主线程依然无法及时处理渲染。
解决:引入并发控制器,限制同时下载的图片数量,比如用 NSOperationQueue 把maxConcurrentOperationCount设为 4 或 6;再用 SDWebImage 时注意设置SDWebImageLowPriority,让网络加载在用户滚动时降低优先级,停止滚动后再恢复加载。
5.3 内存暴涨导致崩溃:图片缓存没有控制
现象:列表加载大量高清图,App 在记忆体压力下被系统杀死,Xcode 里看 Memory Report 曲线一路爬升。
原因:UIImage在赋值给 imageView 时缓存了解码后的位图,每个 imageView 都持有新解码内存。NSCache 如果不设上限,会持续缓存所有访问过的图片。
解决:NSCache 设置countLimit和totalCostLimit,用完图片后不额外持有 UIImage;SDWebImage 里可以根据内存阈值清理缓存,核心参数是autoClearCache相关配置。图片尺寸也应该在服务端做多尺寸输出,缩略图列表就不要请求原图。
5.4 图片加载后不显示:回调线程与 imageView 的绑定问题
现象:日志里明明走到了图片解码完成,但 imageView 上还是空的,偶尔出现一次。
原因:在后台线程直接给imageView.image赋值。UIKit 的视图操作只在主线程有效,后台赋值会被系统忽略或延迟,不稳定地表现为不显示。
解决:强制在主线程回调中赋值,上面代码的dispatch_async(dispatch_get_main_queue(), ...)不能省。也可以用[NSThread isMainThread]做断言检查来帮助确定问题。
5.5 弱网环境请求长时间不结束:缺超时和重试机制
现象:用户网络切换到 2G 或飞行模式下,列表里大量任务挂着,一直不回调 completion。
原因:NSData dataWithContentsOfURL:默认没有超时时间,也不提供重试机制,请求挂起后主队列里没有收到回调,UI 上就一直是占位图。
解决:用 URLSession 替换dataWithContentsOfURL:并设置timeoutIntervalForRequest,比如 15 秒。加载库一般自带超时重试策略,自研方案必须自己加,否则弱网下用户会一直看着空图,最后只能重启 App。
6. 进阶技巧:预加载与性能验证,让你的表格更从容
到这一步,你的列表已经能正确加载图片、防错乱、控制并发和缓存,但要满足生产环境的要求,还需要做两件事。
第一件是预加载。UITableView 的prefetchDataSource是体验提升的关键,它能在 cell 即将出现前就提前下载图片数据。实现很简单:在tableView(_:prefetchRowsAt:)中遍历indexPaths,提取对应 model 的图片 URL,提前调用sd_setImageWithURL:或你自己的加载方法。这样用户真正滚到那一行时,大部分图片已经命中缓存,视觉上就是“秒开”。
需要注意边界:预加载会提前消耗流量,所以一般只在 WiFi 网络下启用,或者把预加载距离控制在一个屏幕范围内。cancelPrefetchingForIndexPaths:也要实现,在用户快速滚过很多行时,取消那些已经不需要的请求。这个逻辑对自研方案尤其重要,能显著降低快速滑动时的请求量。
第二件事是性能验证。我平时会在 Xcode 的 Network Link Conditioner 里模拟弱网,把往返延迟调到 300 毫秒以上,再用 Instruments 的 Core Animation 模板看滚动时的帧率。只要帧率在 50 fps 以上,说明主线程没有被图片解码拖累。如果掉到 40 fps 以下,优先检查是不是有 main-thread 解码、或者过大的图片导致 GPU 渲染负担。另有一个血泪经验:不要只在真机 WiFi 下测,切到 3G 模拟环境,很多卡顿问题才会显形。
我自己在做这类功能时还有个随手习惯:在 cell 里对图片 URL 做 hash 作为关联 key,在 block 回调里用 key 匹配而不是只比对 URL 字符串。这样即使后续服务端改 URL 参数,也不至于影响已经下载的缓存命中。
异步加载图片这条路上的坑大多是表面现象相似、根因各不相同,掌握线程回推、回调时机和缓存控制这三件事,就能覆盖绝大多数场景。希望帮到你。
本文还有配套的精品资源,点击获取