
做iOS开发的朋友大概率都跟SDWebImage打过交道。这个库在项目里的地位基本等同于“图片加载领域的标配”一行sd_setImageWithURL:就能解决网络图片的显示问题省去了自己写下载、解码、缓存这一整套逻辑的麻烦。但用着方便不代表没有隐患我最近在优化一个老项目时就发现不少同学对SDWebImage的缓存机制理解停留在“能用就行”的层面特别是本地缓存清理这一块真正能说清楚的人不多。等线上App跑了一段时间用户反馈存储空间被吃了几百MB才回过头来研究怎么把缓存清掉。这篇文章就把我之前排查和落地缓存清理方案的完整过程拆开聊一聊从底层缓存结构到API调用再到设置页里那个“清理缓存”按钮的具体实现一步步说清楚。1. 先搞懂SDWebImage的缓存体系1.1 内存缓存与磁盘缓存的角色分工SDWebImage的缓存不是一个简单的大集合而是由两层组成内存缓存Memory Cache和磁盘缓存Disk Cache。你可以把这两层理解成一个餐厅的传菜系统和仓库内存缓存是后厨备好的熟菜拿起来就能直接端上桌速度飞快磁盘缓存则是仓库里封存好的半成品原料宾客点单后需要经过二次加工才能上桌但至少不用临时去菜市场进货。// SDImageCache 内部两大核心属性 let memoryCache: NSCacheNSString, UIImage let diskCache: SDDiskCache内存缓存基于NSCache实现特点是读取快、容量有限、系统内存紧张时会自动清理。它的Key是图片的URL字符串Value是解码后的UIImage对象。磁盘缓存则是把图片的原始数据通常是压缩后的Data写在App的Caches目录下访问速度比内存慢一个数量级但胜在容量大、可持续存在。很多人容易忽略一个关键点内存缓存里的UIImage和解码后的位图数据是两回事。SDWebImage在加载图片时会经历“下载Data → 解码 → 得到UIImage”的流程解码后的位图体积通常是原始文件大小的数倍甚至十几倍。比如一张1MB的JPEG图片解码成RGBA位图后可能占用4MB内存。所以内存缓存的清理逻辑不能只按图片张数算还要按实际占用字节数来算。1.2 磁盘缓存目录与文件命名规则磁盘缓存的存储路径在iOS沙盒的Caches目录下具体是Library/Caches/default/com.hackemist.SDImageCacheSDWebImage/。注意这里的default是默认命名空间namespace如果你初始化缓存时传了自定义的namespace目录名会跟着变。// 初始化时传自定义namespace SDImageCache *cache [[SDImageCache alloc] initWithNamespace:myapp]; // 对应目录变为 Library/Caches/myapp/com.hackemist.SDImageCacheSDWebImage/磁盘缓存文件的命名不是直接用URL字符串而是对URL做MD5哈希后的结果。这是为了避免URL里的特殊字符如/、?、破坏文件系统路径结构。所以当你浏览缓存目录时看到的是一堆毫无意义的十六进制文件名不要试图去辨认哪张图对应哪个URL。// SDDiskCache 内部计算文件名的核心逻辑 - (NSString *)cachePathForKey:(NSString *)key { NSString *fileName [self cachedFileNameForKey:key]; return [self.cachePath stringByAppendingPathComponent:fileName]; } - (NSString *)cachedFileNameForKey:(NSString *)key { // 对key做MD5取前16位 return [self.class sharedMD5Digest:key]; }1.3 缓存命中的完整链路了解清理逻辑前得先知道一次图片加载请求是怎么命中缓存的。SDWebImage的查询顺序是“内存缓存 → 磁盘缓存 → 网络下载”任何一个环节命中就会短路返回。调用sd_setImageWithURL:后SDWebImage先从memoryCache里查UIImage命中则直接回调并结束。内存没命中异步去磁盘缓存查找。磁盘缓存命中后把Data读出来解码成UIImage同时回填内存缓存再回调。磁盘也没有走下载流程成功后写入磁盘缓存和内存缓存。这个链路决定了“清理缓存”必须同时处理内存和磁盘两层只清掉其中一层下次访问仍然可能从另一层把数据拉回来导致用户感觉“没清干净”。2. 本地缓存清理的核心API与调用时机2.1 查询缓存大小的正确姿势清理缓存之前设置页通常要展示“当前缓存占用XXMB”这个数值让用户有个心理预期。SDWebImage提供了异步查询缓存大小的方法但要注意它统计的是磁盘缓存大小不包含内存缓存。// 异步查询磁盘缓存大小 [[SDImageCache sharedImageCache] calculateSizeWithCompletionBlock:^(NSUInteger fileCount, NSUInteger totalSize) { // fileCount 是缓存文件数量 // totalSize 是字节数 CGFloat sizeMB totalSize / 1024.0 / 1024.0; dispatch_async(dispatch_get_main_queue(), ^{ self.cacheSizeLabel.text [NSString stringWithFormat:%.2fMB, sizeMB]; }); }];这里有两个坑值得注意。第一个坑calculateSizeWithCompletionBlock:的完成回调不一定在主线程执行需要手动切换主线程更新UI。很多新手在这里直接改UI运气好碰巧在主线程可能没问题但线程调度一变化就容易出现UI更新异常甚至导致主线程渲染错误。第二个坑这个方法统计的是所有命名空间下的文件还是单个命名空间实际测试下来SDWebImage新版本5.x的sharedImageCache只统计默认命名空间如果你在项目里初始化过多个缓存实例记得分别统计再求和别漏掉。2.2 真正执行清理时的三个API版本SDWebImage清理缓存的API按作用范围分三个梯度实际开发中要根据场景选对用错了容易误伤或者清不掉。// 1. 只清内存缓存最快但磁盘缓存还在 [[SDImageCache sharedImageCache] clearMemory]; // 2. 只清磁盘缓存内存缓存还在下一次访问可能重新从内存加载到磁盘 [[SDImageCache sharedImageCache] clearDiskOnCompletion:^{ // 清理完成回调 }]; // 3. 同时清理内存和磁盘最彻底 [[SDImageCache sharedImageCache] clearMemory]; [[SDImageCache sharedImageCache] clearDiskOnCompletion:nil];大部分“清理缓存”功能应该选择方案3也就是内存和磁盘一起清。但这里要留意API名称的细微差别clearDiskOnCompletion:是异步执行传入的block在磁盘清理完成后调用而clearMemory是同步操作内存缓存瞬间清空。两者的语义完全不同写代码时别把它们混在同一行理解。还有一点容易被忽略磁盘清理过程中如果用户滑动列表触发新的图片加载可能会发生“正在写缓存文件同时又在删缓存文件”的竞争。SDWebImage底层对读操作有加锁机制但极端情况下仍有可能出现文件读写异常。所以业内通常的做法是执行清理前先用一个BOOL标志位如isClearingCache拦截新的列表加载请求等清理完成后再恢复这样能有效规避文件系统层面的竞态问题。2.3 缓存策略配置何时自动淘汰何时需手动清理SDWebImage的默认策略是自动淘汰内存缓存根据totalCostLimit限制磁盘缓存根据maxDiskSize限制超过阈值时按“最久未使用”的顺序清理。但默认值是多少不同版本有差异5.x版本的内存默认totalCostLimit为100 * 1024 * 1024100MB磁盘默认maxDiskSize为0而0代表不限制大小。// 设置磁盘缓存上限为500MB SDImageCacheConfig *config [[SDImageCacheConfig alloc] init]; config.maxDiskSize 500 * 1024 * 1024; config.maxMemoryCost 100 * 1024 * 1024; config.maxDiskAge 7 * 24 * 60 * 60; // 7天过期 SDImageCache *cache [[SDImageCache alloc] initWithNamespace:default diskCacheDirectory:nil config:config];既然磁盘缓存默认不限制大小那么用户手上运行了一年半载的版本缓存膨胀到几个GB也毫不奇怪。这也就是为什么“手动清理缓存”这个功能在设置页里如此重要因为自动淘汰机制压根不会触发或者说触发条件太宽松了。另外一个容易被忽略的配置项是maxDiskAge它控制缓存文件的最长存活时间。默认值是60 * 60 * 24 * 77天SDWebImage在启动时会检查过期文件并清理。如果你的业务场景中图片更新频率不高可以适当调大这个值以减少重复下载但反过来如果你的图片是会变化的比如头像、商品图建议把maxDiskAge调小避免用户看到旧图。3. 实操把缓存清理做进App设置页3.1 步骤一计算并展示精确的缓存大小设置页里展示缓存大小最稳妥的做法是在页面出现时异步计算同时给出“正在计算...”的占位状态。这里我推荐用SDImageCache的totalDiskSize属性5.x新增快速获取但要注意它也只代表磁盘占用。// Swift 版本 func updateCacheSizeDisplay() { cacheSizeLabel.text 正在计算... SDImageCache.shared.totalDiskSize { totalSize in DispatchQueue.main.async { let sizeText ByteCountFormatter.string(fromByteCount: Int64(totalSize), countStyle: .file) self.cacheSizeLabel.text sizeText } } }如果想展示得更精确、同时把内存缓存也算进去可以自己算内存部分遍历NSCache没有公开的枚举方法所以内存缓存大小并不能直接拿到。这种情况下可以估算用SDImageCache.shared.memoryCache.totalCost这个值在setImage:forKey:cost:写入时会累加。let memoryCost SDImageCache.shared.memoryCache.totalCost let diskSize SDImageCache.shared.totalDiskSize // 两者之和作为展示值不过实践下来我一般不把内存缓存纳入展示。原因很简单内存缓存是动态变化的打开页面时是20MB滑两下列表可能就变成50MB或者被系统自动清空归零展示这个数字没有参考意义用户看了只会困惑。磁盘缓存才是真正积压在存储空间里的“死重”。3.2 步骤二点击清理按钮后的异步执行点击清理按钮后我建议不要直接调clearDiskOnCompletion就完事而是增加一层UI反馈先把按钮置灰、显示“正在清理...”清理完成后再恢复并更新缓存大小标签。IBAction func clearCacheTapped(_ sender: UIButton) { sender.isEnabled false sender.setTitle(正在清理..., for: .normal) let queue DispatchQueue.global(qos: .userInitiated) queue.async { // 清理磁盘缓存 SDImageCache.shared.clearDisk { DispatchQueue.main.async { sender.isEnabled true sender.setTitle(清理缓存, for: .normal) self.cacheSizeLabel.text 0MB // 弹一个Toast提示清掉了多少空间 self.showToast(message: 缓存已清理) } } // 内存缓存同步清理 SDImageCache.shared.clearMemory() } }关键点在于不要让用户在动画期间反复点击。清理按钮要加防重入否则用户快速连点两次可能触发两次并发清理虽然SDWebImage内部有锁但日志里会出现“另一清理任务进行中”的警告而且前一个清理任务的完成回调可能覆盖后一个的结果。3.3 步骤三App审核与版本升级时的缓存处理有些团队在发布新版本时会在启动流程里加一段“强制清理旧版本缓存”的代码。这个做法我经历过几次结论是可以但要想清楚。// Bundle版本号对比只在升级时清理一次 NSString *currentVersion [[NSBundle mainBundle] objectForInfoDictionaryKey:CFBundleShortVersionString]; NSString *lastVersion [[NSUserDefaults standardUserDefaults] objectForKey:lastLaunchVersion]; if (![currentVersion isEqualToString:lastVersion]) { [[SDImageCache sharedImageCache] clearDiskOnCompletion:nil]; [[SDImageCache sharedImageCache] clearMemory]; [[NSUserDefaults standardUserDefaults] setObject:currentVersion forKey:lastLaunchVersion]; }这样做的好处是能帮用户腾出一大块空间特别是老版本缓存无限增长的情况下。但坏处也很明显新版本第一次启动后所有图片都要重新走下载流程首屏加载速度会变慢流量消耗也会增加。所以如果要做只建议在“缓存膨胀严重影响体验”时作为应急方案不要把每次升级都变成用户流量的大户。另外要提醒的是不要把清理逻辑写在didFinishLaunchingWithOptions的主线程同步区磁盘清理涉及大量文件IO主线程执行会导致启动卡顿甚至被系统看门狗误杀。正确的做法是放到启动后延迟几秒比如用一个延迟执行的GCD任务让首屏先展示出来后台默默清理。4. 避坑清理缓存最容易踩的5个坑4.1 误用主线程导致卡顿清理磁盘缓存是IO密集操作绝不能同步跑在主线程。即使SDWebImage的clearDiskOnCompletion:内部已经是在IO队列异步执行调用本身很快返回但如果你在调用前先主动遍历缓存目录拿文件列表有些开发者为了展示缓存详情会这么做这个遍历操作极易阻塞主线程。我在项目里见过一种写法在设置页viewWillAppear里同步调用[[NSFileManager defaultManager] contentsOfDirectoryAtPath:error:]去数文件数量结果页面切换时有0.5秒的白屏卡顿。这种直接使用文件系统API的行为碰到文件数量上千的缓存目录耗时非常可观。正确做法是不要自己遍历目录调用SDWebImage提供的calculateSizeWithCompletionBlock:它内部已经做好了异步处理。4.2 清理过程中触发新的下载这是一个隐蔽的竞态问题。假设用户点击清理磁盘缓存文件开始逐个删除。此时如果用户快速下拉刷新列表触发一串新图片加载请求SDWebImage会先去磁盘缓存查找文件——文件可能刚被删除于是走到下载流程下载完成后再写回磁盘。整个过程中出现了“边删边写”的状态。这种状态下磁盘缓存目录的引用计数和文件状态可能变得不一致。SDWebImage 5.x的SDDiskCache底层对读写加了信号量锁但为了保证绝对安全我依然建议在UI层做互斥清理期间用占位图替代真实加载或者挂起列表的图片请求。简单来说加个isClearingCache标志位在UICollectionViewDelegate的cellForItemAtIndexPath里判断即可。4.3 缓存目录的备份豁免问题iOS对Library/Caches目录下的内容不会备份到iCloud或iTunes这是官方文档明确说明的。但如果你曾经为了某些原因把SDWebImage的缓存目录改到了Documents下麻烦就大了。Documents目录的内容会自动参与备份一旦用户手机上照片、App数据一多iCloud备份体积会大幅膨胀甚至被系统在设置里提示“此App占用大量存储空间”。检查方法很简单NSURL *cacheURL [[[NSFileManager defaultManager] URLsForDirectory:NSCachesDirectory inDomains:NSUserDomainMask] firstObject]; // 确认SDWebImage的diskCachePath基于cacheURL生成如果你确实需要自定义缓存路径务必将目录的NSURLIsExcludedFromBackupKey属性设置为true否则App上架后审核团队可能以此为理由打回。4.4 多个SDImageCache实例导致清理不彻底很多中大型App不止一个SDImageCache实例。比如某个业务线为了隔离缓存创建了自定义namespace的缓存某个SDK内部也初始化了自己的SDImageCache。而开发者在设置页里的“清理缓存”按钮只清理了sharedImageCache其他实例的缓存文件成了漏网之鱼。我在排查一个项目时发现App的磁盘占用高达1.2GB但sharedImageCache统计出来只有300MB。最终定位到问题出在另外两个自定义namespace的缓存上——它们是第三方库创建的同框架缓存实例。所以方案调整为在App启动时注册所有缓存实例清理时统一遍历调用各自的clearDiskOnCompletion:。// App启动时收集所有缓存实例 NSMutableArraySDImageCache * *allCaches [NSMutableArray array]; [allCaches addObject:[SDImageCache sharedImageCache]]; if (customCache) { [allCaches addObject:customCache]; } // 清理时逐个执行 for (SDImageCache *cache in allCaches) { [cache clearDiskOnCompletion:nil]; }4.5 忽略SDWebImage版本差异5.x和4.x的清理API变化很大。4.x时代用clearDisk和clearMemory5.x仍然兼容但5.x新增了SDImageCacheConfig的很多细粒度配置比如shouldCacheImagesInMemory是否在内存缓存中保留UIImage可以根据业务关闭。更麻烦的是4.x时代的SDImageCache用defaultCachePath属性暴露路径5.x改成了diskCachePath两者的规则不完全一致。如果你的项目里有硬编码路径来手动操作缓存文件升级SDWebImage版本后很可能出现路径对不上缓存清理了一把空气。建议养成“尽量调SDWebImage API不要自己操作文件系统”的习惯框架升级时能少踩很多坑。5. 缓存清理扩展从“手动”到“自动化”的进阶玩法5.1 设置缓存上限让系统自动淘汰手动清理按钮治标不治本用户不主动点击缓存还是悄悄膨胀。更合理的方式是设置磁盘缓存上限让SDWebImage在写入新缓存时自动检查并淘汰旧文件。SDImageCacheConfig *config [[SDImageCacheConfig alloc] init]; // 磁盘缓存上限设为200MB config.maxDiskSize 200 * 1024 * 1024; // 内存缓存上限设为80MB config.maxMemoryCost 80 * 1024 * 1024; // 超过上限后每次写入时淘汰最旧的文件需要注意maxDiskSize的淘汰机制是“写入时触发检查”而不是实时监控。所以当用户短时间内快速浏览大量高清图时缓存可能短暂超过上限但APP空闲后SDWebImage会启动后台清理任务把缓存压回到限制以内。实测下来这个策略能有效把磁盘占用控制在一个可接受的范围。不过要注意maxDiskSize的淘汰算法是“按文件修改时间从小到大逐个删除直到总大小低于上限”。如果你的App有某些图片非常重要希望永久保留这个策略就不适用了——它不区分文件和内容一律按时间淘汰。5.2 按业务场景分区缓存一个更精细化的方案是把不同业务线的图片缓存分区管理。比如头像类图片走avatarCache动态信息流的图走feedCache商品图走productCache。每个分区设置不同的缓存上限和过期时间。// 头像缓存小上限短过期时间 SDImageCacheConfig *avatarConfig [[SDImageCacheConfig alloc] init]; avatarConfig.maxDiskSize 20 * 1024 * 1024; // 20MB avatarConfig.maxDiskAge 3 * 24 * 60 * 60; // 3天过期 SDImageCache *avatarCache [[SDImageCache alloc] initWithNamespace:avatar diskCacheDirectory:nil config:avatarConfig]; // 商品图缓存大上限长过期时间 SDImageCacheConfig *productConfig [[SDImageCacheConfig alloc] init]; productConfig.maxDiskSize 300 * 1024 * 1024; // 300MB productConfig.maxDiskAge 30 * 24 * 60 * 60; // 30天过期 SDImageCache *productCache [[SDImageCache alloc] initWithNamespace:product diskCacheDirectory:nil config:productConfig];调用时按业务选择不同缓存实例[imageView sd_setImageWithURL:avatarURL placeholderImage:nil completed:nil context:{SDWebImageContextImageCache: avatarCache}];这种方案的优点明显不同业务的过期策略互不干扰清理某类缓存时不影响其他业务。但代价是内存占用会上升因为每个缓存实例都有独立的NSCache同尺寸图片可能在不同缓存里各存一份。所以“分区缓存”更适合图片量大且业务隔离清晰的场景小项目不必过度设计。5.3 结合App生命周期实现空闲清理另一个自动化思路是监听App进入后台或空闲时执行清理。SDWebImage自带一个后台清理机制applicationDidEnterBackground时自动清理过期缓存。但如果你设置了maxDiskSize过期检查和大小检查又是两套逻辑。我自己在项目里封装过一个缓存管理类核心逻辑如下class CacheManager { static let shared CacheManager() func performCleanIfNeeded() { let status UIApplication.shared.backgroundRefreshStatus guard status .available else { return } let backgroundTask UIApplication.shared.beginBackgroundTask { // 超时处理 } SDImageCache.shared.calculateSize(withCompletionBlock: { _, totalSize in if totalSize 300 * 1024 * 1024 { // 超过300MB才清理 SDImageCache.shared.clearDisk { UIApplication.shared.endBackgroundTask(backgroundTask) } } else { UIApplication.shared.endBackgroundTask(backgroundTask) } }) } }用beginBackgroundTask包一层确保清理过程在App退到后台时也能持续执行。阈值设定为300MB低于这个数不打扰用户高于就静默清理。这套方案上线后用户投诉存储占用的数量明显下降而且没有感知到性能损耗。6. 常见问题速查表为了方便大家排查问题我整理了SDWebImage缓存清理过程中最常遇到的几个情况直接对照查找。症状可能原因解决办法点击清理后数字很快又反弹内存缓存未清理IO触发重新生成调用clearMemory并设置maxDiskSize磁盘空间始终不下降统计的是sharedImageCache但实际有多个缓存实例检查自定义namespace和第三方SDK的SDImageCache实例一并清理清理时列表出现卡顿或闪退清理过程中主线程有UI同步操作或文件遍历改用异步API统计大小使用calculateSizeWithCompletionBlock:清理期间挂起图片加载清理后图片加载变慢流量猛增磁盘缓存被清空所有图片重新下载这是正常现象可考虑用“LRU淘汰”替代全量清理只清超过上限的部分升级SDWebImage后清理无效4.x到5.x路径或API变化检查diskCachePath和clearDiskOnCompletion不要硬编码旧路径缓存目录出现在iCloud备份自定义缓存路径指向了Documents将路径改为Caches或设置NSURLIsExcludedFromBackupKey7. 实操经验总结做了这几年内存优化和存储优化我对SDWebImage缓存清理最深的一点体会是别把清理当“事后补救”要把它当“日常运维”。手动清理按钮必须要有因为它给用户一个看得见的控制感——就像手机设置里的“清除缓存”数值一变用户就直观感觉到App“轻”了。但真正能解决存储膨胀的是合理的缓存上限和生命周期管理。遇到缓存问题先别急着写清理代码花一个小时搞清楚当前项目里到底有几个SDImageCache实例、各自的namespace是什么、存储路径在哪里比盲目调API管用得多。我见过太多项目代码里加了好几层清理逻辑结果因为实例没对上清理功能就是个摆设该多大还是多大。这组方案落地后我那个老项目的存储占用从峰值1.2GB稳定到400MB以内设置页的“清理缓存”点击后能在2秒内完成反馈用户几乎无感知。如果你的App也面临类似的缓存膨胀问题不妨先跑一下calculateSizeWithCompletionBlock:看看实际占用再根据本文的方案一步步优化。实践出真知踩过坑才能写出真正耐用的缓存管理代码。