Design-Pattern-Flyweight

享元模式(Flyweight是一种结构型设计模式,它摒弃了在每个对象中保存所有数据的方式,旨在通过共享对象来减少内存使用和提高性能。

场景

在一些2D平面游戏中,玩家可以控制一个角色,在地图中移动,和不同单位交互。对于这类游戏,内存中存在一个数组,表示游戏的地图数据,包含例如某个地格的地形、位置、障碍物、是否有道具/角色可交互对象等数据。游戏的图像引擎,通过遍历每个地格的数据,渲染成游戏界面。玩家看到的游戏画面,实际由多个图像组合而成,每个图像都对应某个具体的贴图文件。贴图文件会在游戏启动时被加载进内存,存放在地格中。在地图上,很多地格都是同一个地形,因此这些地格会拥有相同的贴图数据。如果在每个地格的中都储存贴图数据,会导致重复的数据在内存中存放了多份,造成内存浪费。这些地格在初始化时,每个地格都需要创建新的贴图数据对象,导致游戏加载缓慢。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 地格类
public class Tile {
private int positionX;
private int positionY;
private String terrain;
private String resource;
// 贴图数据
private byte[] texture;
...
}

public class game {
// 通常使用一个List管理所有地格对象
private List<tile> tiles;
...
}

享元模式

笔者认为享元模式的解决方案是显而易见,顺其自然的。如果同样的贴图在地图上会出现多次,每个地格完全没必要独立存储一份贴图数据,大家共用同一份就行了。这在代码中也是容易处理的:每个地格储存了贴图对象的引用,这些引用最后都指向同一个贴图对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 地格类
public class Tile {
private int positionX;
private int positionY;
private String terrain;
private String resource;
// 贴图享元对象
private Texture texture;
}

public class Texture {
// 贴图路径/名称,用于区分不同的贴图对象
private String path;
// 具体的贴图数据
private byte[] texture;
}

再抽象一层,什么样的场景适合享元模式?享元模式核心是共享对象,那这些对象本身需要在很多地方被使用,在代码中通常体现存在一个数组持有许多对象。这些对象有一部分属性是相同的,有一部分属性是不同的。用Gof书中的概念,这些相同,重复的属性,称为内部状态,其他的属性被称为外部状态

在上例中,不同地格的位置、地形、资源不同,因此属于外部状态。而贴图会复用同一段贴图数据,因此属于内部状态。把一个类改造成享元模式也十分直观:把需要被共享属性抽取到享元类中,然后在类中引用这个享元类。上例中,Texture类就是享元类,Tile类中通过成员变量引用了享元类。

回到Gof书中的定义。包含原始对象中部分能在多个对象中共享的状态的类称为享元Flyweight。包含了外部状态,并通过引用享元类,组合成了原始对象全部状态的类称为情景Context。依赖Context的类被称为客户端Client

classDiagram
    direction LR
    class Client {
    	- List~Context~
    }
    
    class Context {
        - UniqueState
    	- Flyweight
    }
	Client *-- Context
	
	class Flyweight {
		- RepeatingState
	}
    Context --> Flyweight
    
    class FlyweightFactory {
    	- Map~Flyweight~ cache
    	+ getFlyweight(RepeatingState) Flyweight
    }
    
    Context --> FlyweightFactory
    FlyweightFactory o-- Flyweight

享元工厂

在上图中,额外新增了一个角色享元工厂FlyweightFactory。前文提到,享元是被共享的对象,但还没有说清楚,这个对象由谁创建,以及如何保证不同Context可以共享同一个享元对象。

那就需要一个方法,可以通过内部状态获取到对应的享元对象。这个方法可以定义在享元类中,但更多是定义在专门的享元工厂中。新建一个工厂类,专门负责所有享元对象的创建和管理的想法是自然的。如果对应的享元对象之前已经创建过,那么工厂方法应该返回之前已有的对象,而不是新建一个。这也被称为缓存机制。

1
2
3
4
5
6
7
8
9
public class FlyweightFactory {
private Map<RepeatingState, Flyweight> cache = new HashMap();
public Flyweight getFlyweright(RepeatingState repeatingState) {
if (!flyweights.containsKey(repeatingState)) {
flyweights.put(repeatingState, new Flyweight(repeatingState));
}
return flyweights.get(repeatingState);
}
}

一个享元对象会被多个Context同时引用,可能会引发线程不安全。因此通常将享元类设计为不可变类,具体的采取措施是将所有字段设置为private final,不提供setter方法,并且要求在构造函数能够初始化所有字段。

内部状态和外部状态

关于内部状态和外部状态的定义,笔者是十分疑惑的。这里说的内部和外部,是相对什么的内外?一种解释是,内指对象本身,而外指对象所处的环境。具体来说,指的是对象本身固有的属性,即对象内部存储的、不依赖于外部环境的状态。指的是对象所处的环境或上下文,即依赖于外部环境的状态。这些状态不是对象本身的一部分,而是由对象的使用者(客户端)维护,并在需要时传递给对象。

按照此定义,上例中游戏地格的各种属性,属于内部还是外部状态是模棱两可的。例如每个地格的位置,在创建时就固定了,通常不会被客户端修改,按理说属于内部状态。但每个地格的位置都不同,因此位置数据无法在多个地格之间共享。也有很多文章拿文本编辑器作为例子,提到每个字符具有字体、颜色、大小等属性。有些案例会把字符内容作为内部状态,理由是字符种类数量是固定的,例如26个小写英文字母。也有案例将颜色、字体作为内部状态,理由是同一个文档中通常不会包含太多颜色、字体。

抛开这些概念不谈,笔者认为,享元模式归根结底是为了通过共享对象来节省内存,与其纠结内外状态的概念,不如采用更简单粗暴的定义方法。如果一个属性,包含的属性值在多个对象中重复,并且这个属性本身的存储成本相对于其他属性较高,那这就是一个内部状态。从量化的角度来讲,可以使用信息熵来衡量某个字段的重复程度。这种思路和在数据库中,查找区分率高的字段作为索引的想法是一致的。只不过享元模式是找重复的属性,而索引是要寻找不重复的属性。而储存成本,则与对象数量、属性长度、重复率有关。上面的地格例子中,贴图数据是byte[]类型,这远大于引用(指针)的长度。总体来说,对象越多,属性越长,重复率越高,则越应该设计为内部状态。

就算不理解内部状态和外部状态,也能很好的应用享元模式。

总结

享元模式通过在不同对象间,共享一部分重复率高的数据,达到节省内存和提高性能的目的。

享元模式将占用大,重复率高的属性抽取到享元类中,并在情景类中引用享元类,来共享享元对象。

通常会使用享元工厂类来提供工厂方法,通过该方法获取与内部状态对应的享元对象。同时利用缓存机制,确保对于相同的内部状态,直接返回已创建的享元对象,避免重复创建。


Design-Pattern-Flyweight
http://dracoyus.github.io/2025/02/20/Design-Pattern-Flyweight/
作者
DracoYu
发布于
2025年2月20日
许可协议