LRU缓存机制详解:从原理到Unity UI回收框架

目录

LRU缓存机制详解:从原理到Unity UI回收框架

摘要:LRU 是缓存淘汰策略里最常见、也最适合入门的一种:它假设“最近用过的数据,短期内更可能再次被用到”。本文会从一句话定义开始,讲清 LRU 解决的问题、核心数据结构、O(1) 实现方式、边界条件和工程陷阱。最后会落到 Unity UI 场景,搭建一个可以管理界面回收、复用和销毁的最小 LRU 框架。

一、背景与动机

1.1 为什么需要缓存淘汰

缓存的目的,是用空间换时间。

在游戏开发里,很多对象的创建成本不低:

  • UI Prefab 需要加载、实例化、绑定脚本、刷新布局。
  • 图标、头像、技能特效、音效片段可能来自 Addressables、AssetBundle 或远程资源。
  • 配置表、寻路结果、计算结果可能会被反复访问。

如果每次需要时都重新创建,就容易产生卡顿。如果永远缓存,又会造成内存越来越高。于是缓存系统必须回答一个问题:

当缓存空间不够时,应该先丢掉谁?

LRU 的回答是:

先丢掉最久没有被访问的对象。

这很符合常见交互行为。玩家刚打开过背包,短时间内再次打开背包的概率通常高于再次打开一个很久没用的二级活动界面。

1.2 LRU 在 Unity UI 中解决什么问题

假设一个 UI 框架里,关闭界面有两种极端做法:

做法 优点 问题
关闭就 Destroy 内存释放及时 下次打开要重新加载和实例化,容易卡顿
关闭只 Hide,永不销毁 再次打开很快 UI 越积越多,内存和事件引用风险变高

LRU 给出的是折中方案:

  1. UI 关闭时先隐藏并放入缓存。
  2. UI 再次打开时优先复用缓存实例。
  3. 缓存超过容量时,销毁最久没被用过的 UI。

它不是单纯“省内存”,也不是单纯“加速打开”,而是在打开速度内存占用之间做一个可控平衡。

1.3 适用范围

本文讨论的 LRU 适用于:

  • 本地内存缓存。
  • Unity UI 实例缓存。
  • 资源句柄缓存。
  • 小规模数据结果缓存。
  • 需要确定性淘汰顺序的框架模块。

不适合直接用于:

  • 需要跨进程共享的大型缓存。
  • 必须按业务优先级精确淘汰的资源系统。
  • 强实时对象池,例如每帧大量创建和回收的子弹、粒子对象。
  • 需要分布式一致性的缓存系统。

这些场景可以借鉴 LRU 的思想,但通常需要更复杂的策略。

二、核心概念

2.1 一句话理解 LRU

LRU,全称 Least Recently Used,意思是最近最少使用

人话说:

缓存像一个有限座位的休息区,每次有人被使用,就把他挪到最显眼的位置;座位满了,新人进来时,把最久没人理的那位请出去。

这里有两个关键动作:

  • 访问后变新:只要被读取或写入,就变成“最近使用”。
  • 满了淘汰旧的:容量不够时,删除“最久未使用”的对象。

2.2 LRU 的三个角色

一个 LRU 缓存里至少有三个角色:

角色 职责
Key 用来定位缓存对象,例如 UI 名称、窗口 ID、资源路径
Value 实际缓存的对象,例如 UI 实例、纹理、配置结果
使用顺序 记录谁最新、谁最旧,用来决定淘汰对象

很多初学者只想到 Dictionary<Key, Value>,但 Dictionary 只能回答“有没有这个 Key”,不能高效回答“谁最久没用”。

所以真正的 LRU 需要两个能力:

  1. 按 Key 快速找到对象。
  2. 快速调整对象的新旧顺序。

2.3 为什么不是只用 List

如果只用 List 记录顺序:

  • 找 Key 可能要 O(n)。
  • 每次访问后把元素挪到末尾,也可能要 O(n)。
  • 淘汰最旧元素虽然简单,但整体性能不稳定。

如果只用 Dictionary

  • 找 Key 是 O(1)。
  • 但无法直接维护访问顺序。

所以经典 LRU 实现会组合:

数据结构 用途
Dictionary 根据 Key O(1) 找到节点
双向链表 O(1) 移动节点到最新位置,O(1) 删除最旧节点

在 C# 中,可以用 Dictionary<TKey, LinkedListNode<Entry>>LinkedList<Entry> 实现。

2.4 LRU 和相邻概念的区别

概念 淘汰依据 适合场景 不适合场景
LRU 最近是否被访问 访问具有时间局部性的缓存 访问模式是周期扫描时容易误判
FIFO 谁最早进入缓存 实现简单、顺序明确 老对象可能一直很热也会被淘汰
LFU 谁访问次数最少 热点长期稳定的缓存 新热点升温慢,实现复杂
TTL 是否过期 数据有明确有效期 不能根据访问热度控制容量
Object Pool 对象生命周期复用 高频创建销毁对象 不负责按访问历史淘汰资源

LRU 关注的是“最近性”,不是“总次数”,也不是“进入时间”。

三、详细讲解

3.1 LRU 的执行流程

一个 LRU 缓存通常支持两个核心操作:

  • Get(key):读取缓存。
  • Put(key, value):写入缓存。

执行流程如下:

flowchart TD
    A[访问缓存 Key] --> B{Key 是否存在}
    B -- 存在 --> C[返回 Value]
    C --> D[把节点移动到最新位置]
    B -- 不存在 --> E[创建或加载 Value]
    E --> F[加入缓存最新位置]
    F --> G{是否超过容量}
    G -- 否 --> H[结束]
    G -- 是 --> I[找到最旧节点]
    I --> J[从字典和链表移除]
    J --> K[执行淘汰回调或销毁资源]
flowchart TD
    A[访问缓存 Key] --> B{Key 是否存在}
    B -- 存在 --> C[返回 Value]
    C --> D[把节点移动到最新位置]
    B -- 不存在 --> E[创建或加载 Value]
    E --> F[加入缓存最新位置]
    F --> G{是否超过容量}
    G -- 否 --> H[结束]
    G -- 是 --> I[找到最旧节点]
    I --> J[从字典和链表移除]
    J --> K[执行淘汰回调或销毁资源]
flowchart TD
    A[访问缓存 Key] --> B{Key 是否存在}
    B -- 存在 --> C[返回 Value]
    C --> D[把节点移动到最新位置]
    B -- 不存在 --> E[创建或加载 Value]
    E --> F[加入缓存最新位置]
    F --> G{是否超过容量}
    G -- 否 --> H[结束]
    G -- 是 --> I[找到最旧节点]
    I --> J[从字典和链表移除]
    J --> K[执行淘汰回调或销毁资源]

这个流程里,最重要的一点是:

读取缓存也会改变缓存状态。

因为读取代表对象刚刚被使用过,它应该从“旧”变成“新”。

3.2 用链表表示新旧顺序

可以约定:

  • 链表头部是最新使用。
  • 链表尾部是最久未使用。

每次访问一个节点时:

  1. 从原位置移除。
  2. 插入链表头部。

每次缓存满时:

  1. 取链表尾部。
  2. 删除它。
  3. 从字典里移除对应 Key。
  4. 调用释放逻辑。

示意:

最新
Head -> [BagView] -> [ShopView] -> [QuestView] -> [SettingsView] <- Tail
                                                        最久未使用

如果玩家再次打开 QuestView

Head -> [QuestView] -> [BagView] -> [ShopView] -> [SettingsView] <- Tail

这就是 LRU 的核心。

3.3 为什么能做到 O(1)

LRU 的高效来自两个引用:

  1. Dictionary 让我们通过 Key 直接找到链表节点。
  2. 双向链表节点知道自己的前后节点,所以移动和删除不用遍历。

复杂度如下:

操作 复杂度 原因
Get 命中 O(1) Dictionary 找节点,链表移动节点
Put 新增 O(1) Dictionary 插入,链表头部插入
Put 更新 O(1) Dictionary 找节点,更新值并移动节点
淘汰最旧 O(1) 直接访问链表尾部
遍历缓存 O(n) 调试或清理时才需要

这也是为什么“Dictionary + 双向链表”是 LRU 的经典实现。

3.4 Get 和 Put 的状态变化

假设容量为 3,依次执行:

Put(A)
Put(B)
Put(C)
Get(A)
Put(D)

过程如下:

步骤 缓存从新到旧 说明
Put(A) A A 最新
Put(B) B, A B 最新
Put(C) C, B, A C 最新,A 最旧
Get(A) A, C, B A 被访问,变最新
Put(D) D, A, C 超容量,淘汰 B

注意,最后被淘汰的是 B,不是 A。因为 A 在 Get(A) 时已经被刷新为最近使用。

3.5 LRU 的边界条件

实现 LRU 时必须明确这些边界:

条件 推荐处理
容量小于等于 0 直接抛异常,避免缓存行为不可预测
Put 已存在 Key 更新 Value,并刷新为最近使用
Get 不存在 Key 返回 falsenull,不要偷偷创建,除非 API 明确叫 GetOrCreate
淘汰 Value 调用释放回调,释放资源、解绑事件、Destroy UI
清空缓存 逐个执行释放回调,而不是只清 Dictionary
多线程访问 Unity 主线程 UI 缓存通常不加锁,跨线程缓存需要锁或并发容器

这些规则决定了缓存框架是否稳定。LRU 本身很简单,真正出问题的地方通常是生命周期没有定义清楚。

3.6 LRU 和 Unity UI 生命周期的关系

Unity UI 缓存一般可以分成四个状态:

stateDiagram-v2
    [*] --> Created: 实例化 Prefab
    Created --> Active: Open
    Active --> Cached: Close 并隐藏
    Cached --> Active: 命中缓存后复用
    Cached --> Destroyed: LRU 淘汰
    Active --> Destroyed: 强制关闭并销毁
stateDiagram-v2
    [*] --> Created: 实例化 Prefab
    Created --> Active: Open
    Active --> Cached: Close 并隐藏
    Cached --> Active: 命中缓存后复用
    Cached --> Destroyed: LRU 淘汰
    Active --> Destroyed: 强制关闭并销毁
stateDiagram-v2
    [*] --> Created: 实例化 Prefab
    Created --> Active: Open
    Active --> Cached: Close 并隐藏
    Cached --> Active: 命中缓存后复用
    Cached --> Destroyed: LRU 淘汰
    Active --> Destroyed: 强制关闭并销毁

这里的重点是:

  • Close 不等于 Destroy
  • Close 可以进入缓存。
  • Evict 才是真正销毁。

所以 UI 框架里通常要把接口拆开:

方法 含义
OnOpen 界面显示,刷新参数和数据
OnCloseToCache 界面关闭但进入缓存,暂停动画、解绑临时监听
OnReuseFromCache 从缓存恢复,重新激活和刷新
OnEvictFromCache 被 LRU 淘汰,彻底解绑并销毁

如果所有逻辑都塞进 OnDestroy,缓存复用时就会很难维护。

3.7 容量应该怎么定

LRU 的容量不是越大越好。容量太小,缓存命中率低;容量太大,内存释放不及时。

可以从三类指标估算:

指标 说明
UI 实例成本 创建是否卡顿、是否加载大量资源
内存成本 Prefab、贴图、字体、RenderTexture、子节点数量
使用频率 是否经常来回打开

常见策略:

  • 高频轻量 UI:可以缓存较多。
  • 低频重量 UI:可以缓存较少,甚至关闭就销毁。
  • 活动界面、商城、角色面板:适合进入 LRU。
  • 战斗 HUD、常驻 Widget:不应交给 LRU 淘汰,而应由父级生命周期管理。

四、实践示例

4.1 最小 C# LRU 缓存

下面是一份可直接放进普通 C# 项目的最小实现。它不依赖 Unity API,便于测试。

using System;
using System.Collections.Generic;

public sealed class LruCache<TKey, TValue>
{
    private readonly int _capacity;
    private readonly Action<TKey, TValue> _onEvicted;
    private readonly Dictionary<TKey, LinkedListNode<Entry>> _map;
    private readonly LinkedList<Entry> _list;

    private readonly struct Entry
    {
        public Entry(TKey key, TValue value)
        {
            Key = key;
            Value = value;
        }

        public TKey Key { get; }
        public TValue Value { get; }
    }

    public LruCache(int capacity, Action<TKey, TValue> onEvicted = null)
    {
        if (capacity <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(capacity), "LRU capacity must be greater than 0.");
        }

        _capacity = capacity;
        _onEvicted = onEvicted;
        _map = new Dictionary<TKey, LinkedListNode<Entry>>(capacity);
        _list = new LinkedList<Entry>();
    }

    public int Count => _map.Count;
    public int Capacity => _capacity;

    public bool TryGet(TKey key, out TValue value)
    {
        if (!_map.TryGetValue(key, out var node))
        {
            value = default;
            return false;
        }

        MoveToFront(node);
        value = node.Value.Value;
        return true;
    }

    public void Put(TKey key, TValue value)
    {
        if (_map.TryGetValue(key, out var existingNode))
        {
            existingNode.Value = new Entry(key, value);
            MoveToFront(existingNode);
            return;
        }

        var node = new LinkedListNode<Entry>(new Entry(key, value));
        _list.AddFirst(node);
        _map.Add(key, node);

        if (_map.Count > _capacity)
        {
            EvictLeastRecentlyUsed();
        }
    }

    public bool Remove(TKey key)
    {
        if (!_map.TryGetValue(key, out var node))
        {
            return false;
        }

        RemoveNode(node, invokeEvicted: true);
        return true;
    }

    public void Clear()
    {
        while (_list.Last != null)
        {
            RemoveNode(_list.Last, invokeEvicted: true);
        }
    }

    private void MoveToFront(LinkedListNode<Entry> node)
    {
        if (node.List == _list && node != _list.First)
        {
            _list.Remove(node);
            _list.AddFirst(node);
        }
    }

    private void EvictLeastRecentlyUsed()
    {
        var last = _list.Last;
        if (last != null)
        {
            RemoveNode(last, invokeEvicted: true);
        }
    }

    private void RemoveNode(LinkedListNode<Entry> node, bool invokeEvicted)
    {
        _list.Remove(node);
        _map.Remove(node.Value.Key);

        if (invokeEvicted)
        {
            _onEvicted?.Invoke(node.Value.Key, node.Value.Value);
        }
    }
}

这份代码的关键点:

  • Dictionary 的 value 不是业务对象,而是链表节点。
  • 链表节点里保存 Key 和 Value。
  • 淘汰时从尾部拿节点,再用节点里的 Key 反删 Dictionary。
  • Clear 不能只清容器,要逐个调用淘汰回调。

4.2 最小测试用例

可以用下面的代码验证访问顺序:

using System;

public static class LruCacheDemo
{
    public static void Run()
    {
        var cache = new LruCache<string, int>(
            capacity: 3,
            onEvicted: (key, value) => Console.WriteLine($"Evict {key}={value}")
        );

        cache.Put("A", 1);
        cache.Put("B", 2);
        cache.Put("C", 3);

        cache.TryGet("A", out _); // A 变成最近使用
        cache.Put("D", 4);        // 淘汰 B

        Console.WriteLine(cache.TryGet("B", out _)); // False
        Console.WriteLine(cache.TryGet("A", out _)); // True
    }
}

预期结果:

Evict B=2
False
True

4.3 为 Unity UI 定义缓存对象接口

Unity UI 不能只缓存 GameObject,否则释放时无法统一处理事件、动画、资源句柄和业务状态。建议定义一个接口。

public interface IUiCacheItem
{
    string CacheKey { get; }
    bool CanCache { get; }

    void OnOpen(object args);
    void OnCloseToCache();
    void OnReuseFromCache(object args);
    void OnEvictFromCache();
}

各方法职责:

方法 触发时机 负责什么
OnOpen 首次创建后打开 初始化参数、绑定长期结构
OnCloseToCache 关闭但准备缓存 隐藏、暂停动画、解绑一次性监听
OnReuseFromCache 缓存命中后重新打开 激活、刷新参数、恢复交互
OnEvictFromCache LRU 淘汰 彻底释放、解绑事件、销毁对象

4.4 Unity UI 缓存管理器

下面是一套最小 UI LRU 框架。它把“关闭回收”和“淘汰销毁”分成两个动作。

using System;
using UnityEngine;

public sealed class UiLruCache
{
    private readonly LruCache<string, IUiCacheItem> _cache;

    public UiLruCache(int capacity)
    {
        _cache = new LruCache<string, IUiCacheItem>(
            capacity,
            onEvicted: (_, item) => item.OnEvictFromCache()
        );
    }

    public bool TryReuse(string key, object args, out IUiCacheItem item)
    {
        if (!_cache.TryGet(key, out item))
        {
            return false;
        }

        item.OnReuseFromCache(args);
        return true;
    }

    public void Recycle(IUiCacheItem item)
    {
        if (item == null)
        {
            return;
        }

        if (!item.CanCache)
        {
            item.OnEvictFromCache();
            return;
        }

        item.OnCloseToCache();
        _cache.Put(item.CacheKey, item);
    }

    public void Remove(string key)
    {
        _cache.Remove(key);
    }

    public void Clear()
    {
        _cache.Clear();
    }
}

4.5 一个可缓存 UI 的基类

实际项目里可以让 UI 基类接入缓存协议。

using UnityEngine;

public abstract class CachedUiView : MonoBehaviour, IUiCacheItem
{
    [SerializeField] private string cacheKey;
    [SerializeField] private bool canCache = true;

    public string CacheKey => cacheKey;
    public bool CanCache => canCache;

    public virtual void OnOpen(object args)
    {
        gameObject.SetActive(true);
    }

    public virtual void OnCloseToCache()
    {
        gameObject.SetActive(false);
    }

    public virtual void OnReuseFromCache(object args)
    {
        gameObject.SetActive(true);
    }

    public virtual void OnEvictFromCache()
    {
        Destroy(gameObject);
    }
}

这只是最小骨架。真实项目中还可能有:

  • Addressables handle 释放。
  • UI 事件总线解绑。
  • Tween 结束或 Kill。
  • ScrollRect 位置重置。
  • 动态子节点回收到对象池。
  • 网络请求取消。

这些都应该放在明确的生命周期方法里,而不是散落在业务代码中。

4.6 UI 打开服务如何接入 LRU

下面是一个简化版 UI 服务,用来说明打开、关闭、复用的流程。

using UnityEngine;

public sealed class UiService
{
    private readonly UiLruCache _closedViewCache;
    private readonly IUiFactory _factory;
    private readonly Transform _uiRoot;

    public UiService(IUiFactory factory, Transform uiRoot, int closedCacheCapacity)
    {
        _factory = factory;
        _uiRoot = uiRoot;
        _closedViewCache = new UiLruCache(closedCacheCapacity);
    }

    public IUiCacheItem Open(string viewKey, object args = null)
    {
        if (_closedViewCache.TryReuse(viewKey, args, out var cached))
        {
            return cached;
        }

        var item = _factory.Create(viewKey, _uiRoot);
        item.OnOpen(args);
        return item;
    }

    public void Close(IUiCacheItem item)
    {
        _closedViewCache.Recycle(item);
    }

    public void ClearClosedCache()
    {
        _closedViewCache.Clear();
    }
}

public interface IUiFactory
{
    IUiCacheItem Create(string viewKey, Transform parent);
}

这套流程背后的原则是:

flowchart LR
    A[Open View] --> B{LRU 缓存命中}
    B -- 是 --> C[ReuseFromCache]
    B -- 否 --> D[Factory Create]
    D --> E[OnOpen]
    C --> F[显示 UI]
    E --> F
    F --> G[Close View]
    G --> H{CanCache}
    H -- 是 --> I[CloseToCache 并放入 LRU]
    H -- 否 --> J[Evict 并 Destroy]
    I --> K{缓存超容量}
    K -- 是 --> L[淘汰最久未使用 UI]
flowchart LR
    A[Open View] --> B{LRU 缓存命中}
    B -- 是 --> C[ReuseFromCache]
    B -- 否 --> D[Factory Create]
    D --> E[OnOpen]
    C --> F[显示 UI]
    E --> F
    F --> G[Close View]
    G --> H{CanCache}
    H -- 是 --> I[CloseToCache 并放入 LRU]
    H -- 否 --> J[Evict 并 Destroy]
    I --> K{缓存超容量}
    K -- 是 --> L[淘汰最久未使用 UI]
flowchart LR
    A[Open View] --> B{LRU 缓存命中}
    B -- 是 --> C[ReuseFromCache]
    B -- 否 --> D[Factory Create]
    D --> E[OnOpen]
    C --> F[显示 UI]
    E --> F
    F --> G[Close View]
    G --> H{CanCache}
    H -- 是 --> I[CloseToCache 并放入 LRU]
    H -- 否 --> J[Evict 并 Destroy]
    I --> K{缓存超容量}
    K -- 是 --> L[淘汰最久未使用 UI]

4.7 如何处理不同类型的 UI

不是所有 UI 都适合放进同一个 LRU。

UI 类型 推荐策略 原因
背包、角色、任务、商城 可进入 LRU 打开频繁,创建成本中等
活动二级页面 小容量 LRU 或关闭即销毁 数量多、访问不稳定
战斗 HUD 不进 LRU,由战斗生命周期控制 常驻且强绑定战斗状态
Toast、飘字、气泡 对象池 高频创建,生命周期短
弹窗确认框 可对象池或轻量缓存 结构简单,复用频繁
大型图鉴、复杂列表 谨慎缓存 子节点和贴图可能很重

一个成熟 UI 框架通常会支持配置:

public sealed class UiCacheConfig
{
    public string ViewKey;
    public bool CanCache;
    public int Priority;
    public int EstimatedMemoryCost;
}

LRU 是基础策略,配置表负责告诉框架哪些 UI 可以被缓存。

4.8 加入成本权重:从容量 LRU 到内存预算 LRU

最简单的 LRU 用“数量”做容量,例如最多缓存 10 个 UI。但 UI 成本不一样:

  • 一个轻量确认框可能只有几个节点。
  • 一个图鉴界面可能带大量 Item、图标和滚动列表。

所以更工程化的做法是用“成本”控制容量:

当前缓存成本 + 新对象成本 > 最大预算
    -> 持续淘汰最旧对象
    -> 直到预算足够

成本可以先用估算值:

成本项 估算方式
节点数量 预制体子节点越多成本越高
图片数量 Image、RawImage、图集引用数量
动态列表 是否保留大量 Cell
RenderTexture 单独加高权重
Addressables 资源 是否持有资源句柄

入门阶段先做数量容量即可;当 UI 复杂度上来,再升级成成本预算。

五、常见误区与注意事项

5.1 误区一:关闭 UI 就是销毁 UI

在有缓存的框架里,关闭 UI 有三种可能:

动作 含义
Hide 只是隐藏,还在当前 UI 栈或父节点下
Recycle 关闭并进入缓存,等待复用
Destroy 彻底销毁,释放引用和资源

如果这三者混在一起,后续很容易出现:

  • UI 明明关闭了但还在监听事件。
  • 复用时显示旧数据。
  • 被 Destroy 后又从缓存里取出来。
  • 资源句柄重复释放。

5.2 误区二:只 Destroy GameObject 就算释放完成

Unity 中 Destroy(gameObject) 只销毁 GameObject 及其组件,不代表所有外部引用都自动清理。

还要检查:

  • 是否从事件中心解绑。
  • 是否取消异步加载回调。
  • 是否释放 Addressables handle。
  • 是否停止协程。
  • 是否 Kill Tween。
  • 是否清理静态缓存引用。

LRU 的淘汰回调必须是完整生命周期出口。

5.3 误区三:所有 UI 都适合 LRU

LRU 适合“近期可能再次打开”的 UI,不适合所有对象。

不建议放进 LRU 的对象:

  • 强依赖当前战斗状态的 UI。
  • 与临时玩法场景绑定的 UI。
  • 含有敏感状态且重置成本高的 UI。
  • 关闭后必须立即释放资源的大型界面。

是否缓存应该是配置决策,不应该靠业务代码临时判断。

5.4 误区四:LRU 命中时忘记刷新数据

从缓存里拿出来的 UI 是旧实例。它的组件、字段、滚动位置、选中状态可能都保留着。

所以 OnReuseFromCache(args) 里通常要做:

  • 更新打开参数。
  • 刷新数据源。
  • 重置临时状态。
  • 恢复交互状态。
  • 重新绑定本次打开需要的短期监听。

缓存复用不是跳过初始化,而是跳过昂贵的实例化。

5.5 误区五:没有调试入口

LRU 问题经常不是算法错,而是“谁被缓存了、谁被淘汰了”看不见。

建议提供调试信息:

public sealed class UiCacheSnapshot
{
    public string Key;
    public int AgeRank;
    public bool Active;
    public int EstimatedCost;
}

至少能在日志或调试面板里看到:

  • 当前缓存数量。
  • 从新到旧的 Key 列表。
  • 每次淘汰的 Key。
  • 每个 UI 的估算成本。
  • 命中率和未命中次数。

没有这些信息,缓存框架上线后会很难排查。

5.6 误区六:忽略扫描式访问

LRU 有一个经典弱点:如果访问模式是扫描式的,它可能把真正有价值的热点挤出去。

例如容量为 3:

热点:A, B, C
突然扫描:D, E, F, G

扫描完成后,A/B/C 可能全部被淘汰。对于 UI 缓存,这种情况可能出现在玩家连续浏览很多活动页或图鉴页时。

解决方式包括:

  • 给高价值 UI 设置 CanCache=false 或独立缓存。
  • 引入优先级,低优先级 UI 先淘汰。
  • 把活动页这类 UI 放到单独的小容量缓存。
  • 用成本预算和白名单保护核心界面。

六、与相关技术的对比

6.1 LRU 与对象池

对比项 LRU 缓存 对象池
核心目标 保留最近使用的具体对象 复用同类对象,减少创建销毁
Key 是否重要 很重要,按 Key 找回原对象 通常不重要,只要类型匹配
淘汰依据 最近访问时间 池容量、空闲数量或手动清理
适合 UI 适合页面级 UI 适合列表 Cell、Toast、飘字
复用状态 可能保留业务状态 通常重置为干净状态

简单说:

LRU 关心“这个具体界面最近用没用过”,对象池关心“有没有一个同类型空闲对象可用”。

6.2 LRU 与资源引用计数

对比项 LRU 引用计数
决策依据 最近访问顺序 当前是否还有引用者
能否自动释放 可以按容量淘汰 引用为 0 才可释放
主要风险 淘汰时机不符合业务预期 引用泄漏导致永不释放
适合管理 缓存对象 资源生命周期

在 Unity 资源系统中,两者常常配合:

  1. UI 实例由 LRU 决定是否保留。
  2. UI 持有的资源由 Addressables 或资源管理器做引用计数。
  3. UI 被淘汰时释放资源引用。

6.3 LRU 与 TTL

对比项 LRU TTL
淘汰原因 最久未使用 到达过期时间
适合数据 访问局部性强 有明确有效期
典型例子 UI 缓存、图标缓存 网络响应、登录态、临时配置
是否需要时间戳 不一定 必须

很多工程缓存会同时使用 LRU 和 TTL:

  • 最近用过才保留。
  • 即使最近用过,超过最大存活时间也要刷新。

对于 Unity UI 实例,TTL 通常不是必需;对于网络数据缓存,TTL 更重要。

6.4 LRU 与系统级缓存

Python 标准库的 functools.lru_cache 是函数结果缓存;Java 的 LinkedHashMap 可以通过访问顺序实现 LRU;Redis 的内存淘汰策略也包含近似 LRU 相关配置。

这些实现背后的目标不同:

系统 LRU 用在哪里 特点
Python functools.lru_cache 缓存函数调用结果 适合纯函数或结果稳定的函数
Java LinkedHashMap 维护访问顺序 Map 可重写 removeEldestEntry 做淘汰
Redis 内存淘汰策略 出于性能考虑使用近似 LRU
Unity UI 框架 缓存关闭后的 UI 实例 必须处理 GameObject 和资源生命周期

这说明 LRU 不是某一种语言的技巧,而是一种通用缓存策略。

七、自己搭建 LRU UI 框架的路线图

7.1 第一阶段:纯数据结构

先不要碰 Unity,写一个普通 C# LRU:

  • TryGet
  • Put
  • Remove
  • Clear
  • onEvicted

用控制台测试顺序和淘汰结果。这个阶段的目标是验证算法正确。

7.2 第二阶段:接入 UI 生命周期

定义 IUiCacheItem

  • OnOpen
  • OnCloseToCache
  • OnReuseFromCache
  • OnEvictFromCache

让 UI 的显示、隐藏、销毁都通过这些方法完成。这个阶段的目标是生命周期清晰。

7.3 第三阶段:接入 UIService

让业务代码只调用:

uiService.Open("BagView", args);
uiService.Close(bagView);

业务层不应该知道这个 UI 是新建的还是从 LRU 里取出来的。这个阶段的目标是封装策略。

7.4 第四阶段:配置化

为每个 UI 配置:

  • 是否允许缓存。
  • 缓存 Key。
  • 缓存容量组。
  • 优先级。
  • 估算成本。

配置化后,调参不需要改业务代码。

7.5 第五阶段:调试与监控

增加:

  • 命中率统计。
  • 淘汰日志。
  • 缓存列表调试面板。
  • 强制清理按钮。
  • 场景切换时的缓存清理策略。

这个阶段的目标是可观察、可排查。

八、参考实现的完整思路

如果把前面的内容收束成框架结构,可以是这样:

UiService
  - Open(viewKey, args)
  - Close(view)
  - ClearClosedCache()

UiLruCache
  - TryReuse(viewKey, args, out view)
  - Recycle(view)
  - Remove(viewKey)
  - Clear()

LruCache<TKey, TValue>
  - TryGet(key, out value)
  - Put(key, value)
  - Remove(key)
  - Clear()

IUiCacheItem
  - CacheKey
  - CanCache
  - OnOpen(args)
  - OnCloseToCache()
  - OnReuseFromCache(args)
  - OnEvictFromCache()

IUiFactory
  - Create(viewKey, parent)

它的职责边界是:

模块 负责 不负责
LruCache 通用缓存顺序和淘汰 Unity 生命周期
UiLruCache UI 回收、复用、淘汰回调 Prefab 加载细节
UiService 对业务提供打开关闭入口 具体 UI 内部刷新逻辑
IUiFactory 创建 UI 实例 决定缓存策略
IUiCacheItem UI 自身生命周期处理 全局缓存容量管理

这套拆分可以避免一个常见坏味道:所有逻辑都堆进 UIManager

九、生产环境注意事项

9.1 场景切换时是否清空缓存

Unity 项目里,场景切换时要明确:

  • 哪些 UI 可以跨场景保留。
  • 哪些 UI 必须清理。
  • 是否存在场景专属资源引用。

如果缓存 UI 引用了旧场景对象,跨场景复用很容易出错。

建议做法:

public enum UiCacheScope
{
    Global,
    Scene,
    Battle,
    Activity
}

场景切换时清理 SceneBattleActivity 作用域的缓存,只保留真正全局的 UI。

9.2 异步加载要防止回调打到已淘汰对象

如果 UI 打开时有异步加载:

  1. UI 被关闭并进入缓存。
  2. UI 又被 LRU 淘汰。
  3. 异步加载回调才回来。

这时回调不能再访问已销毁对象。

解决方式:

  • 给 UI 生命周期加版本号。
  • 淘汰时取消异步请求。
  • 回调里检查对象是否仍有效。
  • 使用统一的资源句柄管理。

9.3 事件监听必须分层

建议把事件分为两类:

事件类型 绑定位置 解绑位置
生命周期级事件 OnOpen 或构造后 OnEvictFromCache
单次打开级事件 OnReuseFromCache / OnOpen OnCloseToCache

不要让关闭进缓存的 UI 继续响应当前页面不该响应的事件。

9.4 缓存 Key 必须稳定

缓存 Key 不能随便拼。

推荐:

BagView
ShopView
QuestView
ActivityView:637
PlayerProfile:12345

如果同一个 Prefab 可以打开多个不同实例,就必须把实例维度写进 Key。否则会出现打开 A 玩家详情,却复用了 B 玩家详情的 UI。

9.5 不要把 LRU 当作业务状态存储

LRU 缓存里的对象随时可能被淘汰,所以不能把重要业务状态只存在 UI 实例里。

正确做法:

  • 业务状态存在 Model、Store、Proxy 或服务层。
  • UI 只是展示和交互入口。
  • UI 被复用时从业务层重新刷新。

缓存对象是性能优化,不是数据来源。

十、缺口检查与学习结论

10.1 本文覆盖范围

本文是专项型文档,围绕 LRU 的原理和 Unity UI 缓存框架展开,覆盖:

  • LRU 定义和适用场景。
  • Dictionary + 双向链表的 O(1) 实现。
  • Get / Put / Evict 的状态变化。
  • Unity UI 关闭、回收、复用、销毁的生命周期拆分。
  • 可落地的 C# 最小实现。
  • UIService 接入方式。
  • 常见误区和生产环境注意事项。

没有展开的内容:

  • Redis 近似 LRU 的底层采样算法。
  • 操作系统页面置换算法的历史细节。
  • 多线程高并发缓存的锁优化。
  • 基于成本、优先级和 TTL 的完整混合缓存策略。

这些可以作为后续专项文章继续深入。

10.2 最重要的三句话

第一,LRU 的本质不是“删除旧对象”,而是每次访问都维护新旧顺序

第二,经典 LRU 靠 Dictionary + 双向链表 实现 O(1) 查询、移动和淘汰。

第三,在 Unity UI 框架里,LRU 不能只管容器,还必须管生命周期:关闭进缓存、命中后复用、淘汰时彻底释放。

如果能把这三句话落到代码里,就已经具备自己搭建 LRU UI 回收框架的基础。

延伸阅读

  • Python 官方文档:functools.lru_cache,用于理解函数结果缓存和 maxsize 语义。https://docs.python.org/3/library/functools.html
  • Java 官方文档:LinkedHashMap,可通过访问顺序维护 Map,并通过 removeEldestEntry 实现淘汰。https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/LinkedHashMap.html
  • Redis 官方文档:Key eviction,介绍 Redis 在内存限制下的淘汰策略,包括 LRU/LFU 相关策略。https://redis.io/docs/latest/develop/reference/eviction/
  • cachetools 文档:LRUCacheTTLCache,适合对比容量淘汰与时间过期策略。https://cachetools.readthedocs.io/
  • Microsoft .NET 文档:LinkedList<T>,用于理解 C# 双向链表节点操作。https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.linkedlist-1

目录