news 2026/10/14 18:10:26

享元模式实战:五子棋棋子对象共享与内存优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
享元模式实战:五子棋棋子对象共享与内存优化

1. 从棋盘上密密麻麻的棋子说起:为什么需要享元模式

第一次接触享元模式的人,多半会被“享元”这个翻译搞得一头雾水。英文原词是 Flyweight,直译过来是“蝇量级”,意思是拳击比赛里最轻的那个量级。放在设计模式里,它想表达的核心思想其实很朴素:当系统里存在大量细粒度对象,而这些对象之间又有大量重复的、可以共享的状态时,就把这部分共享状态抽出来复用,避免每个对象都存一份,从而把内存占用压下去。

五子棋为什么是讲享元模式的经典例题?你想想一个标准的五子棋棋盘,15×15 就是 225 个交叉点,如果扩展到 19×19 的围棋盘就是 361 个点。假设我们给每一个落子位置都创建一个独立的棋子对象,每个对象里都存着颜色、半径、贴图、阴影、高光这些属性,那么一局棋下到中盘,棋盘上可能同时存在几十上百个棋子对象。每个对象都重复存着“黑色”“半径 12 像素”“圆形贴图”这些完全一样的信息,内存里就塞满了冗余数据。

更关键的是,棋子对象里其实混着两类信息。一类是所有黑子都一样的属性,比如颜色、形状、贴图资源;另一类是每个棋子各不相同的属性,比如它落在第几行第几列。前者是共享的、不变的,后者是独有的、随位置变化的。享元模式要做的,就是把前者抽成一个可复用的“享元对象”,让所有黑子共用同一个实例,而后者作为“外部状态”在渲染或逻辑处理时临时传进去。

这个思路放到实际项目里价值非常大。我做过一个数据可视化的看板项目,页面上要渲染上万个数据点,每个点如果都独立 new 一个对象、各自持有一份颜色和形状配置,浏览器内存直接飙到几百兆,滚动起来卡得没法看。后来把颜色、形状这些共享属性抽成享元,内存直接降了一个数量级。五子棋这个例题虽然小,但它把享元模式最核心的“内部状态与外部状态分离”讲得清清楚楚,是理解这个模式最好的切入点。

这篇文章我会从零把这个例题拆开,不光给你能跑的代码,更重要的是讲清楚每一步为什么这么设计、实际写的时候容易在哪儿翻车、以及怎么判断一个场景到底该不该上享元模式。如果你之前看设计模式的书总觉得“看懂了但不会用”,那这篇应该能帮你把这块补上。

2. 拆解五子棋的棋子对象:哪些状态该共享,哪些该独立

2.1 内部状态与外部状态的划分标准

享元模式落地时第一个要回答的问题就是:到底哪些属性算内部状态,哪些算外部状态?这个划分如果搞错了,模式就白用了。我总结了一个简单的判断标准,你可以直接拿去套:

  • 内部状态:在对象整个生命周期内不变、且被大量同类对象重复持有的属性。它应该被封装进享元对象,可以安全共享。
  • 外部状态:随使用场景变化、每个对象实例各不相同的属性。它不能放进享元,必须在调用时从外部传入。

拿五子棋来说,我们逐个属性过一遍:

属性是否变化是否重复归类理由
棋子颜色不变高度重复内部状态黑子永远是黑,白子永远是白
棋子半径不变高度重复内部状态同一套 UI 下所有棋子大小一致
棋子贴图/纹理不变高度重复内部状态黑子共用一张图,白子共用一张图
阴影/高光样式不变高度重复内部状态视觉风格统一
落子行号变化不重复外部状态每个棋子位置不同
落子列号变化不重复外部状态每个棋子位置不同
落子时间戳变化不重复外部状态用于悔棋、回放
所属玩家变化部分重复外部状态逻辑上归属,随对局变化

这张表就是整个例题的设计蓝图。你会发现,真正被抽进享元对象的,只有颜色、半径、贴图这几样“长相”相关的东西。而位置、时间这些“身份”相关的信息,全部留在外面。

这里有个容易踩的坑:有人会把“所属玩家”也当成内部状态塞进享元,理由是“黑子属于玩家 A,白子属于玩家 B,看起来是固定的”。但实际对局里,玩家可能换边、可能悔棋、可能 AI 接管,归属关系是会变的。一旦你把可变的东西塞进享元,共享实例之间就会互相污染——A 玩家改了归属,B 玩家那边跟着变,这种 bug 极其难查。所以判断内部状态时,一定要问自己:这个属性在整个对象存活期间,真的绝对不会变吗?只要有一丝可能变,就果断放到外部。

2.2 为什么颜色和贴图适合做享元

从内存角度看,颜色和贴图这类属性的“体积”往往比想象中大。一个颜色值可能只是几个字节,但一张棋子贴图如果是 64×64 的 PNG,解码后在内存里就是 64×64×4 字节,约 16KB。棋盘上如果有 50 个黑子,每个都持有一份贴图引用,虽然引用本身不复制图片数据,但如果实现不当(比如每个棋子对象内部各自加载一次图片),那就是 50 份 16KB,白白浪费 800KB。

享元模式通过工厂保证同一种内部状态的组合只创建一次实例。所有黑子拿到的是同一个对象引用,贴图自然只有一份。这在移动端或者低配设备上尤其重要,内存省下来就是流畅度的提升。

从渲染角度看,共享内部状态还有个隐性好处:批处理渲染。当所有黑子共用同一个贴图对象时,渲染引擎更容易把它们合并成一次绘制调用(draw call)。如果每个棋子都是独立对象、各自持有不同的贴图实例,引擎就没法合并,只能一个一个画,性能差距在棋子数量多的时候非常明显。这一点在做游戏或者大数据可视化时是实打实的优化点,不是纸上谈兵。

2.3 外部状态为什么不能塞进享元

外部状态必须外置,根本原因是享元对象是被共享的,而共享的东西不能存个性化数据。这就像公司里的打印机,打印机本身(内部状态:型号、墨盒规格)是大家共用的,但你打印的文档内容(外部状态)是你自己的,不能存到打印机里,否则下一个人打印就会打出你的文件。

在代码层面,外部状态通常有两种存放方式:

  1. 调用时作为参数传入:比如chessPiece.draw(row, col),位置信息在绘制那一刻才传进去。
  2. 由客户端单独维护一个映射表:比如用一个Map<Position, PieceType>记录每个位置放的是什么棋子,享元只负责“长什么样”,位置和类型的对应关系由棋盘自己管。

两种方式各有适用场景。参数传入适合“用完即走”的渲染逻辑;映射表适合需要频繁查询“某个位置是什么棋子”的对局逻辑。实际项目里经常两者结合:棋盘维护位置映射,渲染时再把位置传给享元去画。

3. 手写一个能跑的五子棋享元实现

3.1 享元对象的定义与工厂的缓存逻辑

先把最核心的享元类和工厂写出来。我用 Java 风格来写,因为设计模式的经典讲解大多基于 Java,但思路换成 Python、JavaScript 甚至 C# 都一样。

// 享元对象:只存内部状态,且不可变 public final class ChessPieceFlyweight { private final String color; // 颜色 private final int radius; // 半径 private final String texture; // 贴图资源标识 public ChessPieceFlyweight(String color, int radius, String texture) { this.color = color; this.radius = radius; this.texture = texture; } // 外部状态通过参数传入,不存进对象 public void draw(int row, int col) { System.out.println("在 (" + row + "," + col + ") 绘制 " + color + " 棋子,半径 " + radius + ",贴图 " + texture); } // 只提供 getter,不提供 setter,保证不可变 public String getColor() { return color; } }

注意几个细节。第一,类用final修饰,属性也用final,这是为了保证享元对象一旦创建就不可变。不可变是享元能安全共享的前提,如果允许修改,共享实例之间就会互相影响。第二,draw方法接收row和col作为参数,这就是外部状态的传入通道。

接下来是工厂,它负责缓存和复用享元实例:

import java.util.HashMap; import java.util.Map; public class ChessPieceFactory { // 缓存池:key 是内部状态的组合标识 private static final Map<String, ChessPieceFlyweight> POOL = new HashMap<>(); public static ChessPieceFlyweight getPiece(String color) { // 用颜色作为 key,因为颜色决定了半径和贴图 String key = color; if (!POOL.containsKey(key)) { // 首次请求才创建,之后直接复用 ChessPieceFlyweight piece; if ("black".equals(color)) { piece = new ChessPieceFlyweight("black", 12, "black_stone.png"); } else { piece = new ChessPieceFlyweight("white", 12, "white_stone.png"); } POOL.put(key, piece); System.out.println("创建新享元: " + key); } return POOL.get(key); } }

工厂里的POOL就是享元模式的心脏。它保证同样的内部状态组合只会产生一个实例。你可以把 key 设计得更复杂,比如color + "_" + radius,这样支持不同尺寸的棋子。key 的设计原则是:能唯一确定一组内部状态的字符串。

这里有个实战经验:缓存池要不要考虑线程安全?如果棋盘渲染是多线程的(比如后台预渲染),HashMap会有并发问题,得换成ConcurrentHashMap,并且用computeIfAbsent来保证原子性。我见过有人在单线程 demo 里用HashMap没问题,一上生产环境多线程就偶发创建出重复享元,内存没省下来还引入了诡异的渲染不一致。所以只要有一丝并发可能,就直接上ConcurrentHashMap。

3.2 客户端如何组合内部与外部状态

客户端(也就是棋盘)的职责是:持有外部状态,并在需要时从工厂拿享元,把外部状态传进去使用。

public class ChessBoard { // 外部状态:记录每个位置放的是什么颜色的棋子 private final Map<String, String> positionMap = new HashMap<>(); public void placePiece(int row, int col, String color) { String posKey = row + "," + col; positionMap.put(posKey, color); // 从工厂获取享元,传入外部状态进行绘制 ChessPieceFlyweight piece = ChessPieceFactory.getPiece(color); piece.draw(row, col); } public void renderAll() { for (Map.Entry<String, String> entry : positionMap.entrySet()) { String[] pos = entry.getKey().split(","); int row = Integer.parseInt(pos[0]); int col = Integer.parseInt(pos[1]); ChessPieceFlyweight piece = ChessPieceFactory.getPiece(entry.getValue()); piece.draw(row, col); } } }

跑一下测试:

public class Main { public static void main(String[] args) { ChessBoard board = new ChessBoard(); board.placePiece(7, 7, "black"); board.placePiece(7, 8, "white"); board.placePiece(8, 8, "black"); board.placePiece(6, 6, "white"); board.placePiece(8, 7, "black"); System.out.println("--- 重新渲染整个棋盘 ---"); board.renderAll(); } }

输出会是:

创建新享元: black 在 (7,7) 绘制 black 棋子,半径 12,贴图 black_stone.png 创建新享元: white 在 (7,8) 绘制 white 棋子,半径 12,贴图 white_stone.png 在 (8,8) 绘制 black 棋子,半径 12,贴图 black_stone.png 在 (6,6) 绘制 white 棋子,半径 12,贴图 white_stone.png 在 (8,7) 绘制 black 棋子,半径 12,贴图 black_stone.png --- 重新渲染整个棋盘 --- 在 (7,7) 绘制 black 棋子,半径 12,贴图 black_stone.png ...

你会看到“创建新享元”只出现了两次——一次 black,一次 white。后面无论下多少黑子白子,都不会再创建新对象。这就是享元模式的效果:对象数量从“棋子数量”降到了“棋子种类数量”。五子棋只有两种棋子,所以内存里永远只有两个享元实例,哪怕棋盘上有一百个棋子。

3.3 用 Python 再实现一遍,对比语言差异

Java 写起来比较啰嗦,用 Python 能更直观地看到享元的核心逻辑:

class ChessPieceFlyweight: def __init__(self, color, radius, texture): self._color = color self._radius = radius self._texture = texture def draw(self, row, col): print(f"在 ({row},{col}) 绘制 {self._color} 棋子,半径 {self._radius}") @property def color(self): return self._color class ChessPieceFactory: _pool = {} @classmethod def get_piece(cls, color): if color not in cls._pool: if color == "black": cls._pool[color] = ChessPieceFlyweight("black", 12, "black_stone.png") else: cls._pool[color] = ChessPieceFlyweight("white", 12, "white_stone.png") print(f"创建新享元: {color}") return cls._pool[color] class ChessBoard: def __init__(self): self._positions = {} def place_piece(self, row, col, color): self._positions[(row, col)] = color piece = ChessPieceFactory.get_piece(color) piece.draw(row, col) def render_all(self): for (row, col), color in self._positions.items(): ChessPieceFactory.get_piece(color).draw(row, col) board = ChessBoard() board.place_piece(7, 7, "black") board.place_piece(7, 8, "white") board.place_piece(8, 8, "black") board.render_all()

Python 版本里,_pool是类变量,天然全局共享。@property保证颜色只读。逻辑和 Java 完全一致,但代码量少了一半。如果你用 JavaScript,思路也一样,用一个模块级的Map做缓存池即可。

不同语言实现享元时有个共同注意点:缓存池的生命周期管理。如果缓存池无限增长,比如 key 里带了用户 ID 或者时间戳,那它本身就会变成内存泄漏点。五子棋只有两种棋子,池子永远只有两个元素,没问题。但如果你做的是一个支持多种棋子皮肤、多种尺寸的系统,key 的组合可能爆炸,这时候就得考虑给缓存池加淘汰策略,比如 LRU。这是享元模式从 demo 走向生产必须考虑的问题。

4. 享元模式在五子棋里的边界与常见误用

4.1 什么时候不该用享元

享元模式不是银弹,用错了反而增加复杂度。我总结了几条“不该用”的判断线:

第一,对象数量本来就少。如果你的棋盘只有 5×5,最多 25 个棋子,那抽不抽享元对内存几乎没影响,反而多了一层工厂和缓存,代码更难读。享元的收益和对象数量成正比,数量不上千,收益往往不明显。

第二,内部状态几乎没有重复。如果每个棋子的颜色、贴图、大小都不一样(比如每个棋子都是玩家自定义的独特外观),那缓存池里每个 key 只会命中一次,享元退化成普通对象创建,白忙一场。享元的前提是大量对象共享少量状态组合。

第三,外部状态比内部状态还复杂。如果每个棋子的位置、旋转角度、透明度、动画进度都不同,而且这些外部状态需要频繁读写,那把它们全部外置后,客户端要维护一大堆映射表,代码复杂度飙升。这种情况下,也许该考虑的是对象池(Object Pool)而不是享元。对象池复用整个对象,享元只复用内部状态,两者解决的问题不同。

第四,对象有可变状态且需要独立修改。享元对象必须不可变。如果你的棋子对象需要支持“选中高亮”“悬停放大”这类会改变对象自身状态的交互,那这些状态要么外置,要么就不该用享元。我见过有人硬把选中状态塞进享元,结果选中一个黑子,所有黑子全亮了,这就是典型的误用。

4.2 享元与对象池、单例的区别

这三个模式经常被搞混,我用一张表说清楚:

维度享元模式对象池单例模式
复用单位内部状态(对象的一部分)整个对象整个对象
实例数量每种内部状态组合一个池大小固定,可多可少全局唯一
状态特点内部状态不可变,外部状态外置对象状态可重置后复用全局共享同一状态
典型场景大量细粒度对象共享配置数据库连接、线程全局配置、日志器
五子棋对应棋子外观共享棋子对象整体复用棋盘全局唯一

关键区别在于:享元共享的是“对象的一部分”,对象池共享的是“整个对象”,单例共享的是“唯一实例”。五子棋里,如果你把整个棋子对象(含位置)放进池子里复用,那就是对象池思路;如果你只共享外观、位置外置,那才是享元。两者可以结合,但概念不能混。

4.3 一个真实的踩坑:缓存 key 设计不当导致的内存膨胀

我之前在一个类似棋类的项目里踩过一个坑,值得拿出来说。当时需求是支持“棋子皮肤”功能,玩家可以给棋子换不同风格的贴图。我图省事,把缓存 key 设计成了color + "_" + skinId + "_" + timestamp,想着这样能区分不同时刻加载的皮肤。结果玩家每换一次皮肤,就产生一个新的享元实例,旧的还留在池子里不释放。玩了一下午,池子里堆了几百个几乎一样的享元对象,内存不降反升。

问题出在timestamp 根本不该进 key。皮肤一旦加载,同一套皮肤的内部状态就是固定的,timestamp 是外部状态,应该外置。把 key 改成color + "_" + skinId之后,池子大小立刻稳定在“颜色数 × 皮肤数”这个量级。

这个坑的教训是:设计缓存 key 时,只放真正决定内部状态的维度,任何随时间、随会话变化的维度都不能进 key。判断方法很简单:问自己“两个对象如果 key 不同,它们的内部状态真的不同吗?”如果答案是否定的,那这个维度就不该出现在 key 里。

5. 从五子棋延伸:享元思维在真实项目里的落地

5.1 文本编辑器里的字符渲染

五子棋的棋子共享,和文本编辑器里字符的共享是同一个道理。一个文档里可能有几十万个字符,但字符的“种类”只有几百个(字母、数字、标点、汉字)。每个字符的字体、字号、颜色是内部状态,可以共享;而它在文档中的位置、所属段落是外部状态。成熟的编辑器内核普遍采用这种思路,把字符的样式信息抽成享元,位置信息单独存储,这样打开大文件时内存占用才可控。

如果你自己写过一个简易编辑器,可以试试这个优化:把每个字符对象的字体配置抽出来做成享元池,key 用fontFamily + fontSize + color。改完之后用内存分析工具对比一下,大文档的堆内存通常能降 30% 到 50%。

5.2 地图应用里的标记点

地图上密密麻麻的 POI 标记点也是享元的经典场景。一万个餐厅标记,它们的图标、颜色、大小可能只有几种组合,但经纬度各不相同。把图标样式做成享元,经纬度作为外部状态,渲染时动态传入,内存和渲染性能都会有明显改善。更进一步,还可以结合空间索引(比如四叉树)来管理外部状态,只渲染视野内的标记,这就是享元加其他优化手段的组合拳。

5.3 游戏开发中的粒子系统

粒子系统是享元模式的重度用户。一场爆炸可能产生上千个粒子,每个粒子的贴图、混合模式、生命周期曲线往往是几种预设之一,但位置、速度、旋转各不相同。把预设抽成享元,粒子实例只存变化的部分,是粒子系统能跑得动的关键。Unity 的 ParticleSystem、Cocos 的粒子组件,底层都有类似享元的缓存机制。

从五子棋到粒子系统,享元模式的适用边界其实很清晰:对象数量大、内部状态重复度高、外部状态可外置。满足这三条,享元就能发挥价值;缺任何一条,都要谨慎评估。

5.4 落地时的性能验证方法

用了享元之后,怎么证明它真的有效?不能凭感觉。我一般用这几招:

  • 对象计数:在工厂的创建分支里打点计数,跑一局完整对局,看创建次数是不是等于“棋子种类数”而不是“棋子总数”。
  • 内存快照:用 JVM 的 jmap、Python 的 tracemalloc、浏览器的 Memory 面板,对比优化前后的堆占用。
  • 渲染帧率:在棋子数量大的场景下,对比 draw call 数量和帧率变化。

有个细节要注意:享元的收益在对象数量少时可能被工厂查找的开销抵消。工厂每次都要算 key、查 map,这本身有成本。如果对象只有几十个,这个开销可能比省下的内存更显眼。所以优化后一定要实测,别想当然。

6. 几个容易被忽略的实现细节

写享元的时候,有几个细节看起来小,但处理不好会埋雷。

第一,享元对象的 equals 和 hashCode。如果你把享元对象放进 Set 或者作为 Map 的 key,必须正确实现这两个方法。否则两个内部状态相同的享元会被当成不同对象,缓存失效。不过更推荐的做法是用字符串 key 做缓存,而不是用享元对象本身做 key,这样能绕开 equals 的实现负担。

第二,序列化问题。如果享元对象需要被序列化(比如存到本地缓存),反序列化后可能产生新的实例,破坏“全局唯一”的假设。解决办法是实现readResolve方法,让反序列化时从工厂拿实例,而不是新建。

第三,缓存池的初始化时机。有些内部状态组合是启动时就知道的(比如黑白两种棋子),可以提前预热,避免第一次使用时才创建带来的卡顿。预热很简单,程序启动时把已知的组合都调一遍getPiece即可。

第四,外部状态的传递方式要统一。如果有的地方用参数传位置,有的地方用映射表查位置,代码会变得混乱。团队协作时最好约定一种方式,比如“渲染相关的外部状态一律参数传入,逻辑查询一律走映射表”,保持一致性。

第五,别过度设计。五子棋这个例题里,有人会搞出抽象工厂、策略模式、观察者模式一大堆,把简单问题复杂化。享元模式本身已经够用了,加太多层反而看不清核心。学习阶段,先把最朴素的享元写明白,比堆砌模式更有价值。

7. 我个人在实际项目里用享元的一点体会

享元模式最迷人的地方,不是它省了多少内存,而是它逼着你去思考“什么是对象的本质,什么是对象的上下文”。把内部状态和外部状态分开的那一刻,你对这个对象的理解就上了一个台阶。我在做数据看板、做地图渲染、做文本处理时,反复用到这个思维:先问哪些是共享的,再问哪些是独有的,然后把共享的抽出来。

但我也踩过“为了用而用”的坑。有一次做一个只有几十个节点的流程图,我非要上享元,结果代码里多了一层工厂、一层缓存、一层外部状态映射,维护成本远超那点内存收益。后来我给自己定了个规矩:对象数量不过千,先不上享元;内部状态组合超过十种,先评估缓存池会不会膨胀;外部状态需要频繁双向读写,先考虑对象池。这几条规矩帮我省了不少返工。

五子棋这个例题之所以经典,就是因为它足够小,小到你能在一屏代码里看清享元的全貌,又足够典型,典型到你能把同样的思路搬到任何“大量相似对象”的场景里。如果你正在学设计模式,建议别只看书上的 UML 图,亲手把这段代码敲一遍,改改 key 的设计,试试多线程,看看内存变化。敲完这一遍,享元模式就真正长在你脑子里了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 18:10:14

微信小程序条件渲染与picker 组件:从界面交互到数据 驱动

一、需求分析本次作业实现一个小程序案例&#xff0c;综合练习 picker 选择器组件、wx:if 条件渲染、block 块渲染以及 input 输入框的失焦事件。功能要点&#xff1a;页面上方是一个 picker 选择器&#xff0c;默认显示红色文字与红色色块&#xff1b;下方是数字输入框&#x…

作者头像 李华
网站建设 2026/10/14 18:09:37

ESP32-S3+LVGL驱动1.69寸电容触摸屏:初始化、DMA与坐标映射全解

简介&#xff1a;面向嵌入式开发者的ESP32-S3图形界面驱动资源包&#xff0c;聚焦LVGL在1.69寸电容触摸屏上的落地&#xff1a;屏显部分采用ST7789显示控制器&#xff0c;触摸部分由CST816芯片实现&#xff0c;适合希望快速搭建物联网UI、学习LVGL移植或做屏幕驱动二次开发的工…

作者头像 李华
网站建设 2026/10/14 18:09:36

SQL聚合函数与GROUP BY分组查询详解:从COUNT到HAVING的完整实践

1. 聚合函数&#xff1a;SQL 里的"一键统计"到底是怎么算的先别急着上手写 GROUP BY&#xff0c;聚合函数是这一整套逻辑的基石。所谓聚合&#xff0c;就是把多行数据按照某种规则压缩成一行结果&#xff0c;最常见的五个函数是 COUNT、SUM、AVG、MAX、MIN。它们的输…

作者头像 李华
网站建设 2026/10/14 18:09:25

手写HTML5简历:语义化、响应式与打印友好的技术实践

1. 项目概述&#xff1a;为什么一份“手写”的HTML5简历&#xff0c;比PDF和Word更值得花时间折腾最近帮几位刚毕业的朋友改简历&#xff0c;发现一个特别有意思的现象&#xff1a;他们投递的岗位明明写着“前端开发”“UI设计”“数字营销”&#xff0c;可附件却清一色是Word或…

作者头像 李华