Structural Design Patterns for Dart and Flutter: Flyweight
The Flyweight pattern is all about using memory efficiently. If your Dart or Flutter app deals with relatively heavy objects, and it needs to instantiate many of them, using this pattern can help save memory and increase performance by avoiding data duplication. Objects that have identical data can share that data instead of copying it everywhere it's needed.
About structural design patterns: Structural patterns help us shape the relationships between the objects and classes we create. These patterns are focused on how classes inherit from each other, how objects can be composed of other objects, and how objects and classes interrelate. In this series of articles, you'll learn how to build large, comprehensive systems from simpler, individual modules and components. The patterns assist us in creating flexible, loosely coupled, interconnecting code modules to complete complex tasks in a manageable way.
The code for this article was tested with Dart 2.8.4 and Flutter 1.17.5.
The problem
Suppose you were writing a game that featured a huge number of on-screen enemies for the player to blast. Maybe there are five or six different types of enemy, each with its own associated bulky image data. Enemies also need to store two-dimensional position data for each instance. In this scenario, the image data would be constant for a given type, but the positional fields need to change as the game state updates. Now imagine that you need to create several thousand enemy objects, with each housing its own image and positional data.
Your first attempt at creating an Enemy class might look something like this:
class Enemy {
final String name;
final ByteData imageData;
int x;
int y;
Enemy(this.name, this.imageData);
void moveTo(int x, int y) {
this.x = x;
this.y = y;
}
void draw() {
print("Drawing $name...");
}
}
You can use the Flyweight pattern to avoid copying the same image data all over a device's RAM. Within the context of the pattern, the memory-hogging constant data is referred to as intrinsic state, data that every object of a given type needs access to in an unaltered form. Each enemy's positional data is known as the extrinsic state, because it's often changed by events occurring outside the instance and may be unique to a given instance. With the Flyweight pattern, you keep these types of data apart, enabling you to handle them differently.
So how can separating an object's data save memory space?
The Flyweight pattern
To save space, you can separate the intrinsic state into a flyweight object, creating only one copy of each unique type and caching it for reuse. The extrinsic state goes in its own object, along with a reference to the intrinsic state the object needs. Instead of storing the same data in multiple objects, you can store intrinsic data in just a few flyweight objects that are linked to appropriate context objects, where extrinsic data is kept.
There are several popular approaches to the Flyweight pattern. You can create a dedicated factory object responsible for creating and caching extrinsic state, or you can build the factory functionality right into the extrinsic class as static properties and methods. We're going to explore the second approach here.
First, we need a flyweight object. This will hold intrinsic data for an Enemy:
class EnemyType {
final String name;
final ByteData imageData;
const EnemyType(this.name, this.imageData);
}
For this imaginary game app, assume imageData
is of substantial size, so we stand to save a lot of memory space by not repeating it unnecessarily. Also, there could be some lag while the data is loaded, either from disk or the network, and the Flyweight pattern enables us to avoid paying that cost more than once per enemy type.
Next, we must remove the extrinsic state from the Enemy class and add a cache for the flyweight objects:
class Enemy {
static final Map<String, EnemyType> types = {};
final EnemyType type;
int x;
int y;
Enemy(String typeName) : type = getType(typeName);
void moveTo(int x, int y) {
this.x = x;
this.y = y;
}
void draw() {
print("Drawing ${type.name}...");
}
static EnemyType getType(String typeName) {
return types.putIfAbsent(typeName, () => EnemyType(
typeName,
loadImageData(typeName),
));
}
}
Enemy now has a static property, types
, that acts as a cache for EnemyType objects, keyed by name. We've also added a static method, getType()
, that is used by the Enemy constructor when creating new objects. The getType()
method takes a type's name, then the putIfAbsent()
method on Dart's Map class is used to check whether that type and its associated data already exists in the cache. If the type is cached, putIfAbsent()
simply returns it. If it's not in there, a new EnemyType is instantiated by the anonymous callback, which passes along the type name and loads the image data, then the new flyweight object is placed into the cache for future use and returned. Note that loadImageData()
is a fictional function that somehow acquires image data. In the end, getType()
returns a reference to the cached EnemyType, which is saved in the type
property of Enemy.
With the classes in place, we can create enemies like this:
final List<Enemy> enemies = [
Enemy("Red Avenger"),
Enemy("Red Avenger"),
Enemy("Blue Stinger"),
];
You can create hundreds, thousands, or even millions of enemies, and only a single instance of the Red Avenger EnemyType will be constructed. Once a type is in the cache, all future enemies of that type will refer to the same type instance for its image data, saving untold megabytes of RAM. And again, loading the image data may take a substantial amount of time, and that will occur just once for each enemy type.
Conclusion
Using the Flyweight pattern, your app can conserve memory and improve performance, but it's important not to overuse it. Make sure the gains are consequential before you needlessly complicate your code. If the intrinsic data is huge or your app will be creating a tremendous quantity of objects with identical data, the pattern may serve you well.
To read more about structural design patterns in Dart, check out these related articles: