一次关于 LRUCache 的工程化落地:从数据结构到 Feed 图片缓存实践

在客户端工程里,缓存从来不是一个"是否需要"的问题,而是一个"放在哪一层、以什么粒度、用什么淘汰策略"的问题。

如果把范围缩小到 Feed 流场景,问题会更具体一些:列表快速滚动、图片反复进入可视区、同一资源被多次请求、解码和渲染持续挤压主线程与内存水位。这个时候,单纯依赖网络层缓存往往不够,因为真正影响滚动体验的,很多时候不是"有没有把数据下载过",而是"有没有一份可以直接给 UI 使用的解码结果"。

这也是我在这个项目里落地 LRUCache 的原因。

它不是一个为了"练手数据结构"而存在的工具类,而是一个很典型的工程化基础设施:一头连着数据结构本身的时间复杂度与淘汰语义,一头连着 Feed 图片加载链路中的性能、内存和稳定性治理。

一、为什么是 LRU,而不是普通字典

先说结论:在图片内存缓存这个场景里,普通字典只能解决"能不能存",解决不了"该淘汰谁"。

Feed 场景有两个特点:

  • 用户刚看过的图片,短时间内很可能再次出现
  • 内存容量有限,不可能无限保留所有已解码图片

这就意味着缓存系统必须同时具备两种能力:

  • 快速命中:能用 key 常数时间找到目标
  • 有序淘汰:知道谁是"最近最少使用"的对象

如果只有字典,我们能做到快速查找,却不知道淘汰顺序;如果只有链表,我们能维护访问顺序,却无法高效查找。因此,一个真正可用的 LRU 实现,核心一定是"字典 + 双向链表"的组合。

在项目实现里,LRUCache 就是这样设计的,见 DemoImages.swift

二、这个 LRUCache 的结构并不复杂,但足够工程化

这份实现的核心定义很简洁:

swift 复制代码
final class LRUCache<Key: Hashable, Value> {
    final class Node {
        let key: Key
        var value: Value
        var cost: Int
        var prev: Node?
        var next: Node?
    }

    private let totalCostLimit: Int
    private var totalCost: Int = 0
    private var dict: [Key: Node] = [:]
    private var head: Node?
    private var tail: Node?
    private let lock = NSLock()
}

如果只从数据结构视角看,它做了四件事:

  • dict 建立 Key -> Node 的映射,保证查找是 O(1)
  • head / tail / prev / next 构建双向链表,维护访问顺序
  • costtotalCostLimit 管理总内存成本,而不是简单按数量限制
  • NSLock 保证多线程读写下状态一致

这个设计非常适合客户端图片缓存,因为它不是"学术意义上的 LRU",而是已经带有明确的工程目标:

  • 支持泛型
  • 支持按内存成本淘汰
  • 支持线程安全
  • 支持高频读写

三、为什么 Node 必须是 class

这类实现里,一个经常被忽略但很关键的点是:Node 为什么是 class,而不是 struct

原因很简单:双向链表天然依赖引用语义。

Node 需要同时被三个地方持有和修改:

  • 字典持有它
  • 前驱节点指向它
  • 后继节点指向它

如果用值类型,每次修改 prevnext 或把它移到链表头部,都可能触发拷贝语义,维护成本会迅速上升,逻辑也很容易失真。而引用类型可以保证"操作的是同一个节点对象",这对链表这类结构是基础前提。

所以在这份实现里,Node 使用 final class,不是语法习惯,而是数据结构语义本身决定的。

四、LRU 的关键,不在"存",而在"移动"

很多人第一次写 LRU 时,会把重点放在"set 进去"和"超过容量就删掉",但真正体现 LRU 语义的,其实是"访问后如何调整顺序"。

在这个实现里,查找逻辑非常典型:

swift 复制代码
func value(forKey key: Key) -> Value? {
    lock.lock()
    defer { lock.unlock() }

    guard let node = dict[key] else { return nil }
    moveToHead(node)
    return node.value
}

也就是说,只要命中缓存,这个节点就会被移动到头部

这一步的工程意义很大。因为在 Feed 场景里,"最近访问"本身就是最有价值的缓存信号:刚刚出现在屏幕里的图片,通常比很久之前滑过的图片更值得保留。LRU 正是把这种访问局部性显式编码进缓存策略里。

moveToHead 的实现也很简洁,本质就是两步:

  • 先从原位置摘掉
  • 再插入到头部

也因此,整个顺序维护过程依然是 O(1)。

五、为什么这个项目里不是按数量淘汰,而是按 cost 淘汰

这是这份实现最"工程化"的地方之一。

很多入门版 LRU 只会定义一个固定容量,例如最多缓存 100 个对象。但图片缓存显然不适合这么做,因为 100 张缩略图和 100 张大图,对内存的压力完全不是一个量级。

所以这个项目的实现引入了 cost 概念:

  • 每个节点有自己的 cost
  • 缓存整体维护 totalCost
  • 超过 totalCostLimit 后,从链表尾部逐步淘汰

evictIfNeeded()

在图片场景里,这个 cost 不是拍脑袋估出来的,而是通过图片实际像素内存占用近似得到:

swift 复制代码
private static func approxCost(of image: UIImage) -> Int {
    guard let cg = image.cgImage else { return 1 }
    return cg.bytesPerRow * cg.height
}

approxCost(of:)

这意味着,缓存系统关注的是"这张图真实占了多少内存",而不是"它只是一个条目"。从工程治理角度看,这比按 count 更合理,也更接近客户端实际资源约束。

六、在本项目里,LRUCache 的真正职责是什么

如果只谈数据结构,LRUCache 只是一个容器;但放进项目上下文里,它承担的是 图片加载链路中的内存缓存层

它的直接挂载点在 ImageLoader

swift 复制代码
let memoryCache = LRUCache<String, UIImage>(totalCostLimit: 120 * 1024 * 1024)

这里的 key 不是单纯 URL,而是:

swift 复制代码
let key = "\(url.absoluteString)|\(Int(targetPixelSize.width))x\(Int(targetPixelSize.height))"

loadImage

这说明它缓存的并不是"原始图片资源",而是"某张图片在某个目标展示尺寸下的结果图"。

这个设计很关键。

因为对于图片加载来说,真正昂贵的不只是网络请求,还有:

  • 图片解码
  • 降采样
  • 主线程展示前的数据准备

而这个项目的图片链路正是这样组织的:

  1. 先查 memoryCache
  2. 未命中则检查是否已有相同请求在进行中
  3. 没有的话发起网络请求
  4. 数据回来后在后台做 downsample
  5. 把结果图放进 LRUCache
  6. 回主线程分发给 UI

这一套流程非常典型,也很健康:缓存的对象是"UI 可直接消费的结果",而不是更原始、更便宜但还要二次处理的数据。

七、它在 Feed 场景中的几个具体价值

1. 避免重复下载,更重要的是避免重复解码

很多时候,团队会高估网络缓存的价值,而低估内存结果缓存的价值。

即使 URLCache 命中了,数据从磁盘或内存回来后,图片仍然要经历解码和缩放过程。而对于滚动列表来说,真正让用户感知到卡顿的,往往是后者。

LRUCache 在这里保存的是解码后的 UIImage,因此它能直接减少二次处理成本。对于"同一张图反复进入可视区"的场景,这个收益非常直接。

2. 支撑图片预取,把"将来要显示的图"提前准备好

项目里有 prefetch(urls:targetPixelSize:) 接口,见 prefetch

它本质上仍然复用同一套加载逻辑,最终把结果落入内存缓存。这样一来,当 cell 真正滚动到屏幕里时,图片往往已经处于"内存命中可直接展示"的状态。

从用户视角看,这类优化很难被描述成某个单一指标,但它会体现在非常具体的体验上:列表更"跟手",出图更稳定,快速回滚时更少看到占位图闪烁。

3. 为弱网和滚动抖动兜底

在网络不稳定或快速滚动场景下,缓存不是可有可无的锦上添花,而是稳定性的组成部分。

如果没有内存 LRU,列表每次回滚都可能重新触发下载、解码、主线程回调分发,抖动会被放大。而 LRU 的存在,本质上是在把一部分高频路径从"依赖网络与后台处理"转换成"纯内存读取"。

4. 作为内存治理的第一道阀门

这个项目里,LRUCache 还和内存告警治理串在一起。

AppDelegate 中,应用启动时把图片缓存交给了 MemoryGuard

swift 复制代码
MemoryGuard.shared.start(imageCache: ImageLoader.shared.memoryCache)

当系统发出内存告警时,MemoryGuard 会做几件事:

  • 记录当前缓存成本和进行中的请求数
  • 关闭图片预取
  • 清空内存缓存
  • 取消所有进行中的图片请求

didReceiveMemoryWarning

这说明 LRUCache 在本项目里不仅服务于性能,也服务于稳定性。它既是加速器,也是泄压阀。

八、它和 URLCache、请求去重是什么关系

如果从分层视角看,这个项目里的图片加载体系其实很清晰:

第一层:URLCache

  • 负责 HTTP 语义上的缓存
  • 关注的是响应数据能否复用
  • AppDelegate.swift

第二层:请求去重 inFlight

  • 负责把同一时刻的重复请求合并成一次
  • 避免同一 URL + 尺寸组合被并发请求多次
  • ImageLoader.InFlight

第三层:LRUCache

  • 负责缓存已解码、可直接展示的结果图
  • 目标是降低滚动和回滚过程中的 UI 成本

这三层分别解决三个不同问题:

  • 网络层复用
  • 请求层去重
  • 展示层复用

它们并不冲突,反而是一个比较完整的客户端图片缓存分层模型。

九、从数据结构到工程实践,我更看重什么

如果站在面试或算法题视角,LRUCache 最常被强调的是 O(1) 复杂度。但在工程实践里,我更看重的是另外三件事:

  • 它是否符合真实访问模式
  • 它是否能和业务链路自然耦合
  • 它是否能纳入稳定性治理体系

在这个项目里,LRUCache 之所以有价值,不是因为"我们也实现了一个 LRU",而是因为它被放在了正确的位置:

  • 它缓存的是结果图,而不是中间态数据
  • 它服务的是 Feed 图片加载链路,而不是抽象演示
  • 它和预取、请求去重、内存告警形成了闭环

这才是我理解的"工程化数据结构落地":

不是把一个经典结构写出来,而是让它真正承担系统中的某一层职责。

十、结语

很多基础数据结构,在课本里看起来很简单;但只有放进真实项目,才会暴露它真正的价值边界。

LRUCache 就是一个典型例子。

从结构上看,它不过是"字典 + 双向链表";但一旦进入 Feed 图片场景,它就不再只是一个缓存容器,而变成了一套面向性能和稳定性的工程手段:

  • 用访问顺序表达资源价值
  • 用内存成本约束缓存规模
  • 用线程安全保障高频读写一致性
  • 用治理入口对接内存告警与降级策略

如果说算法题里的 LRU 解决的是"如何在 O(1) 内完成查找与淘汰",那工程里的 LRU 解决的则是另一类问题:

如何把有限内存,优先留给最值得保留、且最可能再次影响用户体验的数据。

这才是它在客户端项目里最真实的意义。

相关推荐
jeffwang2 小时前
我把同一个压测做错了三次,第四次才发现真正的瓶颈
分布式·性能优化·rust
番茄炒鸡蛋加糖3 小时前
专项2:项目性能优化&可量化指标
性能优化
灯澜忆梦16 小时前
【MySQL10】进阶篇 | 索引_#2性能优化
数据库·sql·mysql·性能优化
古法安卓19 小时前
Android-深入理解 Android 回调(Callback)机制
android·性能优化·android studio
fivebliss1 天前
取算存三步细化及延迟指标解析
人工智能·性能优化
GitLqr1 天前
Impeller 时代:Shader Jank 消失了,但渲染性能的战场也变了
flutter·面试·性能优化
码云数智-园园1 天前
Android 全方位性能优化合集
android·性能优化
fivebliss1 天前
取算存——模型容量、能耗指标和架构梳理
人工智能·性能优化·gpu算力
淡海水1 天前
15-07-YooAsset面试篇-Unity性能优化与内存管理
java·unity·面试·性能优化·c#·游戏引擎