3. Flyweight

3. Flyweight:把大量实例的共享固有状态与逐实例外在状态分开,通过可 复位因果实验和反例证据验收。

3. Flyweight

学习目标

  • 能区分固有状态与外在状态
  • 能解释享元工厂如何共享重资源
  • 能计算享元方案的内存节省

为什么"3. Flyweight"从问题证据开始

假设你的游戏是一片森林:一万棵橡树,每棵都有自己的位置、生长状态、渲染网格和材质贴图。最直觉的建模是给每棵树一个对象,各存各的网格和贴图——代码简单,但内存爆炸:一万份重复的网格顶点和贴图引用,把内存撑爆,卡顿随之而来。

本页把"无模式基线"定义为"每棵树一个完整对象"的代码,然后引入享元作为候选机制,验证它是否真的让"一万棵树的渲染内存"发生可观察的下降。通过条件是同一片森林在相同视觉质量下内存占用显著下降——而不是类的数量增加。关键洞察是:一万棵树里真正"不同"的部分只有位置和生长度,网格和贴图其实只有几种。

🔮 猜一猜:一万棵橡树共享一份网格,内存能省多少?

来源、版本与独立重写边界

本页用作者完整在线正文核对正式标题、设计分叉和时代语境,并以作者源码仓库交叉检查结构。仓库许可证明确正文、HTML与样式为 CC BY-NC-ND 4.0,示例程序等其他文件为 MIT;因此下列中文解释、图示、交互和代码均为独立教学重写,不翻译、拼接或改写受 ND 限制的原文表达。

本章机制与术语

理解 Flyweight 需要四个核心概念:

  • :所有实例共享、不可变的部分——树的网格、贴图、物种属性。享元里只存这一份。
  • :每个实例各自不同的部分——树的位置、生长度。由使用方持有,传入享元方法。
  • :按 key 管理享元对象池,保证相同固有状态的享元只创建一次。
  • :多个逻辑实例指向同一物理对象,用不同外在状态区分——内存换取的直接来源。

四个概念共同约束"把大量实例的共享固有状态与逐实例外在状态分开":任何结论都必须回到"相同视觉质量下的实例内存占用"这一可观察量。

⚡ 模式动画演示
Flyweight 模式:共享对象池内部状态(共享) + 外部状态(上下文传入)FlyweightFactorygetFlyweight(key)Tree:OakTree:PineTree:BirchGrassRockClient 1x=10, y=20, scale=1⬅ 共享Client 2x=30, y=40, scale=1.5⬅ 共享Client 3x=5, y=80, scale=0.8⬅ 共享内部状态(共享,不可变)外部状态(每个客户端不同)

第 1 / 5 步 · ① FlyweightFactory:按 key 管理共享对象池

一万棵橡树只占一份内部状态的内存;位置、缩放等外部状态由客户端持有。

💡 对着动画看:动画左侧是 FlyweightFactory 管理的共享池(Oak/Pine/Birch/Grass/Rock 各一份),右侧三个 Client 各自持有不同的位置/缩放参数,却共享同一份内部状态。注意"⬅ 共享"箭头——三个 Client 指向同一棵 Tree:Oak。这就是"一万棵树,一份网格"的直观呈现。读"森林案例"一节时回到这张图,体会内存是怎么省下来的。

官方结构逐项深读

3. Flyweight

Flyweight 的主张是:把对象拆成"共享的固有部分"和"各自的临在部分"。固有部分(如树的网格、贴图)在所有实例间完全相同,只存一份;临在部分(如位置、方向)每个实例都不同,由使用方保存。逻辑上你仍然有一万棵树,物理上共享的内存只有几种。核心代价是:固有状态必须不可变(共享的东西一旦被改,所有"树"同时变)。

Forest for the Trees

经典的森林案例:一万棵树,第一版实现每棵树一个对象,各自持有网格、纹理、颜色。作者数了一笔账——每棵树的网格和纹理占了绝大多数内存,而这些内容在一万棵橡树之间完全相同。省内存的思路不是压缩,而是去重:让所有橡树共享同一份网格和纹理对象。这正是 Flyweight 的起点:先观察数据里哪些部分在大量重复。

A Thousand Instances

一万个实例的问题本质是"重复的固有状态被复制了一万次"。如果每棵树的网格占 1MB,一万棵就是 10GB——不管机器内存多大都扛不住。而位置/生长度这类外在状态每棵只占几十字节,一万棵不过几百 KB。内存大头在重复部分,不在差异部分——这就是 Flyweight 的洞察:省内存要从"消灭重复"入手,而不是优化每个小对象。

The Flyweight Pattern

模式的核心是引入享元对象 + 享元工厂:享元对象只装固有状态(共享的一份),工厂按 key 返回共享实例(相同 key 永远返回同一个对象)。使用时,调用方把外在状态作为参数传入:tree.render(x, y, height)——同一棵享元树,用不同位置渲染一万次。实例的"身份"从对象变成"(共享对象, 外在状态)"的组合。

A Place To Put Down Roots

享元对象要放得进游戏世界里,需要一层"间接层":不是每个逻辑树都直接持有享元指针,而是把享元存在中心注册表(工厂),逻辑实体只持有一个索引或 key。游戏世界按需从工厂取用——要渲染时查表拿享元,传入自己的外在状态。这层间接让"新增树种"只需在工厂注册新 key,游戏实体完全无感。

What About Performance?

享元对性能的收益主要在内存带宽和缓存友好性:渲染一万棵树时,顶点和贴图数据都在同一批共享对象里,连续读取、缓存命中率高;而每棵树的网格若各自存储,渲染器要在一万块分散内存间跳转,缓存命中率惨不忍睹。代价是每次渲染多一层间接(查表取享元),但相比缓存命中的收益,这层间接微不足道——尤其在 GPU 上,纹理和网格的重复上载才是大开销。

See Also

  • Data Locality:Flyweight 关注"共享同一份数据",Data Locality 关注"数据在内存里的排列"——两者常搭配:享元消除重复后,剩下的外在状态再按数组连续存放,性能更佳。
  • Object Pool:Flyweight 省的是"重复内容",Object Pool 省的是"重复分配"——池化复用对象实例,两者互补但目标不同。
  • Type Object:用数据描述类型(共享定义)与 Flyweight 的共享固有状态异曲同工,常结合使用。

可迁移实现或计算骨架

initial state -> 统计重复的固有状态 -> 享元工厂按 key 建池 -> 逻辑实体持 key -> 渲染时传外在状态
fault injection -> 每实例复制完整固有状态
pass condition -> 相同视觉质量下实例总内存显著下降
reset -> initial state

对应实现要点:先 profile 找出内存大头(通常是重复的网格/贴图);建 FlyweightFactory 用 map 缓存 key→享元;逻辑实体只存 key 和外在状态;渲染/使用统一从工厂取享元并传参。该骨架只保存实验合同;真实项目还要固定场景规模、纹理预算与渲染路径,并保留基线实现以便回退。

本章练习与节点验证矩阵

练习

问题 1:重复统计。

操作:统计动画共享池中五种享元与三个客户端的固有/外在状态

问题 2:内存测量。

操作:实现"每树一对象"与"享元共享"两个版本,各建一万棵树并测内存

问题 3:不可变验证。

操作:尝试修改共享享元的固有状态(如换掉橡树网格)

术语复核

名词解释

本章出现的专业名词,用大白话再讲一遍。

固有状态
外在状态
享元工厂

练习答案参考

练习判据即答案:每个练习的"验证判据"列给出了通过标准——先自己动手,再对照判据核验。

术语复核与本章回顾

掌握"3. Flyweight"意味着能从"一万棵树的网格被重复存储一万次"出发,解释固有状态、外在状态、享元工厂与共享四者的关系,再用"相同视觉质量下的实例内存占用"这一可观察量推翻或保留实现。若内存下降不能稳定复现,本章仍未通过。

一句话回顾:让一万棵树共享一份网格——固有状态放享元(一份),外在状态随实例(千份),森林内存从"万份"降到"一份加千份"。

阅读导航

← 上一页:2. Command · 下一页:4. Observer →

资料与写作方式声明

本章以Robert Nystrom《Game Programming Patterns》(游戏编程模式)权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…