我在图文流 App 里落地双层缓存、弱网降级与 OOM 治理

摘要 图文类 App 最难的不是"把列表做出来",而是把首屏速度、滑动流畅度、弱网体验和内存稳定性一起做好。本文结合一个 UIKit 图文流项目,完整拆解图片双层缓存、RunLoop 空闲解码、SQLite 冷启动回填、弱网降级、Memory Warning 止血与埋点观测闭环,并附带核心代码与流程图。


从卡顿白图到稳定可用:我在图文流 App 里落地双层缓存、弱网降级与 OOM 治理

做图文类 App,最常见的几个问题几乎总会一起出现:

  • 列表首屏慢,用户进来先看到白屏
  • 滑动过程中图片半天不出,或者突然一股脑补出来
  • 弱网下疯狂刷新、分页失败、体验很差
  • 多图场景下内存飙升,最终被系统直接杀掉

这些问题表面看起来是网络、缓存、UI、内存各自的问题,但实际上它们是同一条链路上的不同环节。

这篇文章,我结合一个 UIKit 图文流项目,系统梳理一套我认为比较实用的方案:

  • Feed 数据双层缓存
  • 图片双层缓存
  • 请求去重
  • RunLoop 空闲调度解码
  • 弱网降级
  • OOM 内存治理
  • 埋点与日志观测闭环

这套方案不追求"炫技",重点是 能落地、能稳定、能解释清楚为什么这么做


一、图文流项目最容易死在哪

先说结论:图文类 App 真正难的不是加载数据,而是 控制资源峰值

常见问题有这几类:

  • 图片原图解码后 bitmap 太大,瞬时内存暴涨
  • 多图 Cell、快速滚动、预取一起触发,并发峰值非常高
  • 同一张图被重复请求、重复解码、重复缓存
  • 离屏 Cell 还在继续加载图片
  • 弱网时还在做无意义刷新和分页
  • 内存告警后虽然清了缓存,但后台任务继续跑,内存很快反弹

所以图文流的优化,从来不是"加一个缓存"就结束,而是一整套协同机制。


二、先把架构边界立住

这个项目我采用的是比较标准的分层方式:

  • UI 层:ViewControllerFeedPostCell
  • VM 层:FeedViewModel
  • 网络/基础设施层:ImageLoaderNetworkStateMonitorFeatureFlagCenter
  • 持久层:SQLitePostStore
  • 组合根:SceneDelegate

这样拆的核心收益只有一句话:

UI 只管展示,业务只管编排,基础设施只提供能力。

这会直接影响后面的性能治理,因为当图片加载、弱网降级、缓存策略都不散落在 VC 里时,优化会容易很多。


三、Feed 数据双层缓存:先把内容尽快展示出来

列表内容的体验第一原则不是"永远最新",而是 先尽快有内容,再异步校准

所以我在 Feed 数据上做的是两层数据源:

  • L1:SQLite 本地缓存
  • L2:FeedAPI 网络数据

冷启动流程是:

  1. 先尝试从 SQLite 读取缓存
  2. 如果有数据,先展示出来
  3. 再异步发起 refresh,拿最新数据覆盖
  4. 成功后再回写 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) }
    }
}

这三层叠加,才能真正避免"无意义加载"。


十二、可观测性:没有数据,就没有真正的优化

图文流的性能优化,最怕"靠感觉"。

所以这个项目里,我补了几类关键事件:

  • feedCacheHit
  • imageCacheHit
  • imageLoadSuccess
  • imageLoadFailure
  • memoryWarning

这让后续可以回答这些问题:

  • 首屏缓存命中率高不高?
  • 图片内存缓存是否真的有效?
  • 图片失败率是否在某个版本突然变高?
  • memory warning 时缓存成本和 in-flight 数量是多少?
  • OOM 前是不是预取过多、解码过多?

这些信息对于定位线上 OOM 非常关键,因为很多 OOM 根本拿不到可用 crash stack。


十三、整套链路流程图

flowchart TD A[进入 Feed] --> B[SQLite 冷启动回填] A --> C[cellForRowAt -> FeedPostCell.configure] C --> D[ImageLoader.loadImage] D --> E{L1 内存缓存命中?} E -->|是| F[主线程直接出图] E -->|否| G{已有 inFlight 请求?} G -->|是| H[复用同一 task] G -->|否| I[发起 URLSession 请求] I --> J{URLCache 命中?} J -->|是| K[直接返回 data] J -->|否| L[网络下载] L --> K K --> M[RunLoopIdleWorkScheduler.enqueue] M --> N{主线程空闲?} N -->|否| O[等待空闲点] N -->|是| P[decodeQueue 后台 downsample] P --> Q[写入 LRUCache] Q --> R[主线程 completion] R --> F F --> S{Cell 离屏/快速滑过?} S -->|是| T[didEndDisplaying 取消] S -->|是| U[cancelPrefetching 取消预取] F --> V{Memory Warning?} V -->|是| W[记录 cache_cost / inflight] W --> X[上报 memoryWarning] X --> Y[关闭 imagePrefetchEnabled] Y --> Z[清空 LRU + cancelAllLoads]

十四、这套方案带来的实际收益

对用户

  • 首屏更快,先看到内容
  • 滚动更稳,不容易掉帧
  • 弱网下仍然可用
  • 内存告警时更不容易闪退

对研发

  • 问题定位更有依据
  • 缓存、降级、图片加载逻辑都可独立演进
  • 性能优化不再是"拍脑袋"

十五、最后总结

图文流的性能优化,不是某一个小技巧,而是一套系统设计。

如果只做缓存,不做请求去重,效果有限。

如果只做预取,不做取消,反而可能把内存打爆。

如果只清缓存,不停后台任务,memory warning 之后还是会反弹。

如果没有埋点和日志,你甚至不知道优化到底有没有效果。

真正靠谱的做法是:

  • 先尽快让内容出来
  • 再平滑补齐图片
  • 弱网时减少无意义工作
  • 低内存时立刻止血
  • 最后用可观测性把问题闭环

一句话概括:

图文类 App 的性能稳定性,本质上就是资源生命周期管理。

项目

相关推荐
老王以为1 小时前
React Renderer 分离的多平台架构
前端·react native·react.js
hunterandroid1 小时前
Kotlin Coroutines 与 Flow:让异步任务更清晰
前端
Bigger2 小时前
从零搭建 AI 代码审查服务:一份前端也能看懂的 Python 学习笔记
前端·ci/cd·ai编程
lichenyang4532 小时前
JSAPI、NAPI、Biz、Imp:ASCF Demo 如何真正调用系统能力和 C++ 能力
前端
lichenyang4532 小时前
IPC、JSVM、UIThread、libuv:ASCF 架构图里最容易混的几个词
前端
用户059540174462 小时前
Redis记忆存储故障恢复测试踩坑实录:手动测试让我漏掉了2个一致性Bug
前端·css
用户2136610035723 小时前
Vue2脚手架工程化与Axios集成
前端·vue.js
我不是外星人3 小时前
我把 Claude Code 搬到网页!自研高颜值 Web 交互工作台
前端·ai编程·claude
mixuecoding3 小时前
零成本搭建全球科技热点情报站:12 个平台,6 小时,0 元
前端