摘要 图文类 App 最难的不是"把列表做出来",而是把首屏速度、滑动流畅度、弱网体验和内存稳定性一起做好。本文结合一个 UIKit 图文流项目,完整拆解图片双层缓存、RunLoop 空闲解码、SQLite 冷启动回填、弱网降级、Memory Warning 止血与埋点观测闭环,并附带核心代码与流程图。
从卡顿白图到稳定可用:我在图文流 App 里落地双层缓存、弱网降级与 OOM 治理
做图文类 App,最常见的几个问题几乎总会一起出现:
- 列表首屏慢,用户进来先看到白屏
- 滑动过程中图片半天不出,或者突然一股脑补出来
- 弱网下疯狂刷新、分页失败、体验很差
- 多图场景下内存飙升,最终被系统直接杀掉
这些问题表面看起来是网络、缓存、UI、内存各自的问题,但实际上它们是同一条链路上的不同环节。
这篇文章,我结合一个 UIKit 图文流项目,系统梳理一套我认为比较实用的方案:
- Feed 数据双层缓存
- 图片双层缓存
- 请求去重
- RunLoop 空闲调度解码
- 弱网降级
- OOM 内存治理
- 埋点与日志观测闭环
这套方案不追求"炫技",重点是 能落地、能稳定、能解释清楚为什么这么做。
一、图文流项目最容易死在哪
先说结论:图文类 App 真正难的不是加载数据,而是 控制资源峰值。
常见问题有这几类:
- 图片原图解码后 bitmap 太大,瞬时内存暴涨
- 多图 Cell、快速滚动、预取一起触发,并发峰值非常高
- 同一张图被重复请求、重复解码、重复缓存
- 离屏 Cell 还在继续加载图片
- 弱网时还在做无意义刷新和分页
- 内存告警后虽然清了缓存,但后台任务继续跑,内存很快反弹
所以图文流的优化,从来不是"加一个缓存"就结束,而是一整套协同机制。
二、先把架构边界立住
这个项目我采用的是比较标准的分层方式:
- UI 层:
ViewController、FeedPostCell - VM 层:
FeedViewModel - 网络/基础设施层:
ImageLoader、NetworkStateMonitor、FeatureFlagCenter - 持久层:
SQLitePostStore - 组合根:
SceneDelegate
这样拆的核心收益只有一句话:
UI 只管展示,业务只管编排,基础设施只提供能力。
这会直接影响后面的性能治理,因为当图片加载、弱网降级、缓存策略都不散落在 VC 里时,优化会容易很多。
三、Feed 数据双层缓存:先把内容尽快展示出来
列表内容的体验第一原则不是"永远最新",而是 先尽快有内容,再异步校准。
所以我在 Feed 数据上做的是两层数据源:
- L1:SQLite 本地缓存
- L2:FeedAPI 网络数据
冷启动流程是:
- 先尝试从 SQLite 读取缓存
- 如果有数据,先展示出来
- 再异步发起 refresh,拿最新数据覆盖
- 成功后再回写 SQLite
核心代码:
swift
func loadInitial() async {
if featureFlags.bool(.diskPostCacheEnabled) {
if let cached = try? store.fetchLatest(limit: 50), !cached.isEmpty {
posts = cached
analytics.track(.feedCacheHit, properties: ["count": cached.count])
onStateChanged?(self)
}
}
await refresh()
}
这个策略非常适合图文流:
- 冷启动首屏更快
- 离线时仍然有内容可看
- 网络恢复后能自动校准最新数据
四、图片双层缓存:真正决定滚动体验的关键
图片缓存这块,我拆成两层:
- L1:
LRUCache<String, UIImage>,缓存已解码图片 - L2:
URLCache.shared,缓存原始响应 data
1)L1 命中直接回调
如果同一张图同一尺寸已经在内存里,直接返回 UIImage,这条路径是最快的。
swift
if let cached = memoryCache.value(forKey: key) {
log.debug("memory cache hit key=\(key, privacy: .public)")
AnalyticsTracker.shared.track(.imageCacheHit, properties: nil)
completion(.success(cached))
return token
}
2)L2 没命中才走网络
URLSession 配合 URLCache.shared,会优先走系统缓存,没命中才发网络请求。
swift
let config = URLSessionConfiguration.default
config.requestCachePolicy = .useProtocolCachePolicy
config.urlCache = .shared
config.httpMaximumConnectionsPerHost = 8
session = URLSession(configuration: config)
3)downsample 后再写回 L1
注意这里非常关键:写进 L1 的不是原始 data,而是 按目标尺寸 downsample 后的 UIImage。
这一步本质上是在控制位图内存。
五、为什么"滑动时白图,停下来才出图"
很多人第一次看到这种现象,会以为是网络慢。
其实很多时候,真正慢的不是网络,而是 解码被主动延迟了。
这个项目里,下载完成后的 data 并不会立刻去解码,而是先丢进 RunLoopIdleWorkScheduler,等主线程进入空闲点,再把任务派发到后台解码队列。
核心代码:
swift
RunLoopIdleWorkScheduler.shared.enqueue { [weak self] in
guard let self else { return }
self.decodeQueue.async {
let image = ImageDownsampler.downsample(
data: data,
to: targetPixelSize,
scale: UIScreen.main.scale
)
if let image {
let cost = ImageLoader.approxCost(of: image)
self.memoryCache.setValue(image, forKey: key, cost: cost)
self.finish(key: key, result: .success(image))
} else {
self.finish(key: key, result: .failure(NSError(domain: "ImageLoader", code: -2)))
}
}
}
RunLoop 监听点:
swift
let obs = CFRunLoopObserverCreate(
kCFAllocatorDefault,
CFRunLoopActivity.beforeWaiting.rawValue,
true,
0,
{ _, _, info in
guard let info else { return }
let scheduler = Unmanaged<RunLoopIdleWorkScheduler>.fromOpaque(info).takeUnretainedValue()
scheduler.drain(maxCount: 2)
},
&context
)
这意味着什么?
- 用户在快速滑动时,主线程很忙
- RunLoop 不容易进入空闲状态
- 解码任务就先积压
- 停下来后,开始 drain
- 于是视觉上就会看到"停下来才开始补图"
这不是 bug,而是一种典型的性能权衡:
宁可延迟一点点出图,也不要滚动时掉帧。
六、请求去重:省的不只是流量
图文流里还有一个很容易被忽略的问题:同一张图的重复请求。
比如:
- 同一个 cell 被快速复用
- 预取和真实显示同时请求
- 多个位置同时请求相同 URL
如果不做去重,问题会非常明显:
- 网络浪费
- 解码重复
- 内存重复占用
- in-flight 请求数量激增
所以项目里加了一层 inFlight 合并:
swift
if var inflight = inFlight[key] {
inflight.tokens.insert(token)
inflight.completions.append(completion)
inFlight[key] = inflight
lock.unlock()
return token
}
这个设计的收益非常大:
- 相同 key 只会发一次请求
- 结果回来后统一回调所有等待者
- 对网络和内存都是减法
七、LRUCache 为啥一定要按 cost 淘汰
图片缓存不能只按"张数"做上限,因为一张小图和一张大图的内存占用不是一个量级。
所以缓存必须按成本淘汰。
这里我用了一个自定义 LRUCache,内部结构是:
dict负责 O(1) 查找- 双向链表负责维护最近使用顺序
- 超出
totalCostLimit后从尾部淘汰
核心代码:
swift
func setValue(_ value: Value, forKey key: Key, cost: Int) {
lock.lock()
defer { lock.unlock() }
if let node = dict[key] {
totalCost -= node.cost
node.value = value
node.cost = max(0, cost)
totalCost += node.cost
moveToHead(node)
} else {
let node = Node(key: key, value: value, cost: max(0, cost))
dict[key] = node
insertAtHead(node)
totalCost += node.cost
}
evictIfNeeded()
}
位图成本估算:
swift
private static func approxCost(of image: UIImage) -> Int {
guard let cg = image.cgImage else { return 1 }
return cg.bytesPerRow * cg.height
}
这才是图文流图片缓存更合理的做法。
八、弱网降级:不是提示一句"没网了"就结束
很多项目做弱网处理,实际上只是弹一个提示框。
但对 Feed 来说,真正有用的弱网降级应该是:
- 离线时优先展示已有缓存
- 有内容时不打扰用户
- 没内容时给出明确提示
- 阻止无意义刷新和分页
这个项目里的降级逻辑是:
swift
if featureFlags.bool(.weakNetworkDegradeEnabled), networkMonitor.isOnline == false {
if posts.isEmpty {
onError?("当前离线,已降级仅展示本地缓存")
}
return
}
配合 SQLite 的冷启动回填,用户在弱网或离线场景下的体验会稳定很多:
- 不会突然空白
- 不会每次都弹错
- 不会不停地重试分页
九、OOM 的根因,不是"缓存太大",而是"峰值失控"
很多人遇到 OOM,第一反应是把缓存关小。
这当然有帮助,但真正更常见的原因是:内存峰值瞬时太高。
比如一个典型场景:
- 用户快速滑动
- 预取触发很多图片
- 下载都完成了
- 用户一停下来
- RunLoop 开始 drain
- decodeQueue 连续处理多张图
- 一堆 bitmap 同时进入内存
- 再叠加 LRU 和系统缓存
- 峰值瞬间冲高,被系统杀掉
所以 OOM 治理真正要做的是:
- 减少不必要的大对象生成
- 减少重复解码
- 减少不可见资源继续工作
- 在 memory warning 时立刻止血
十、Memory Warning 之后,为什么不能只清缓存
很多项目在 memory warning 时只做一件事:
swift
cache.removeAll()
但图文流里这通常不够。
因为你刚清完,后台下载还在继续,idle 队列里的解码任务还在继续,下一秒内存就又涨回来了。
所以这个项目的 MemoryGuard 做的是一整套动作:
swift
@objc private func didReceiveMemoryWarning() {
let cacheCost = ImageLoader.shared.currentCacheCost
let inFlightCount = ImageLoader.shared.currentInFlightCount
log.warning("memory warning cache_cost=\(cacheCost, privacy: .public) inflight=\(inFlightCount, privacy: .public)")
AnalyticsTracker.shared.track(.memoryWarning, properties: [
"cache_cost": cacheCost,
"inflight": inFlightCount
])
FeatureFlagCenter.shared.set(false, for: .imagePrefetchEnabled)
imageCache?.removeAll()
ImageLoader.shared.cancelAllLoads()
}
再配合:
swift
func cancelAllLoads() {
lock.lock()
let tasks = inFlight.values.map(\.task)
tokenToKey.removeAll()
inFlight.removeAll()
lock.unlock()
tasks.forEach { $0.cancel() }
RunLoopIdleWorkScheduler.shared.removeAll()
}
这套策略的意义在于:
- 清空 LRU
- 取消所有 in-flight 请求
- 清理待执行解码任务
- 自动关闭预取,进入低内存降级模式
这才是真正的"止血"。
十一、取消策略:别等复用才取消
很多人只在 prepareForReuse() 里取消图片任务。
这有一个问题:
- cell 已经离屏
- 但还没被复用
- 这段时间它仍可能继续下载和解码
所以更稳的做法是三层取消:
1)Cell 复用时取消
swift
override func prepareForReuse() {
super.prepareForReuse()
cancelImageLoading()
imageViews.forEach { $0.image = nil }
lastPostID = nil
}
2)Cell 离屏时取消
swift
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
(cell as? FeedPostCell)?.cancelImageLoading()
prefetchTokensByIndexPath.removeValue(forKey: indexPath)?.forEach { imageLoader.cancelLoad($0) }
}
3)预取取消
swift
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let tokens = prefetchTokensByIndexPath.removeValue(forKey: indexPath) else { continue }
tokens.forEach { imageLoader.cancelLoad($0) }
}
}
这三层叠加,才能真正避免"无意义加载"。
十二、可观测性:没有数据,就没有真正的优化
图文流的性能优化,最怕"靠感觉"。
所以这个项目里,我补了几类关键事件:
feedCacheHitimageCacheHitimageLoadSuccessimageLoadFailurememoryWarning
这让后续可以回答这些问题:
- 首屏缓存命中率高不高?
- 图片内存缓存是否真的有效?
- 图片失败率是否在某个版本突然变高?
- memory warning 时缓存成本和 in-flight 数量是多少?
- OOM 前是不是预取过多、解码过多?
这些信息对于定位线上 OOM 非常关键,因为很多 OOM 根本拿不到可用 crash stack。
十三、整套链路流程图
十四、这套方案带来的实际收益
对用户
- 首屏更快,先看到内容
- 滚动更稳,不容易掉帧
- 弱网下仍然可用
- 内存告警时更不容易闪退
对研发
- 问题定位更有依据
- 缓存、降级、图片加载逻辑都可独立演进
- 性能优化不再是"拍脑袋"
十五、最后总结
图文流的性能优化,不是某一个小技巧,而是一套系统设计。
如果只做缓存,不做请求去重,效果有限。
如果只做预取,不做取消,反而可能把内存打爆。
如果只清缓存,不停后台任务,memory warning 之后还是会反弹。
如果没有埋点和日志,你甚至不知道优化到底有没有效果。
真正靠谱的做法是:
- 先尽快让内容出来
- 再平滑补齐图片
- 弱网时减少无意义工作
- 低内存时立刻止血
- 最后用可观测性把问题闭环
一句话概括:
图文类 App 的性能稳定性,本质上就是资源生命周期管理。