object_pool 1.0.0
object_pool: ^1.0.0 copied to clipboard
A high-performance generic object pool for Dart and Flutter that reduces garbage collection pressure by reusing pre-allocated objects, ideal for games, animations, particle systems, and performance-cr [...]
object_pool 🎯 #
A high-performance, generic object pool for Dart and Flutter that eliminates Garbage Collector (GC) pressure by reusing pre-allocated objects instead of creating and destroying them on every request.
The Problem #
Dart's GC must pause your app to collect short-lived objects. When you create thousands of objects per second — common in games, particle systems, and real-time data processing — GC pauses cause visible stuttering (jank) in Flutter UIs.
// ❌ This causes GC pressure — 60,000 heap allocations/sec at 60fps
void onFrame() {
for (var i = 0; i < 1000; i++) {
final p = Particle(); // ← heap allocation
p.init(x: i.toDouble(), y: 0);
// p goes out of scope → GC must collect it → frame drops
}
}
The Solution #
// ✅ Zero GC pressure — zero allocations after warmup
final pool = ObjectPool<Particle>(
factory: () => Particle(),
initialSize: 1000, // pre-allocate once
);
void onFrame() {
for (var i = 0; i < 1000; i++) {
final p = pool.acquire()!; // O(1), no allocation
p.init(x: i.toDouble(), y: 0);
// ... use p ...
pool.release(p); // O(1), no deallocation/GC
}
}
Installation #
Add to your pubspec.yaml:
dependencies:
object_pool: ^1.0.0
Then run:
dart pub get
# or
flutter pub get
Quick Start #
import 'package:object_pool/object_pool.dart';
// 1. Create the pool
final pool = ObjectPool<MyObject>(
factory: () => MyObject(),
initialSize: 50, // pre-allocate 50 objects
maxSize: 200, // grow up to 200 if needed
);
// 2a. Manual acquire/release
final obj = pool.acquire();
if (obj != null) {
obj.doWork();
pool.release(obj); // always release!
}
// 2b. Scoped use() — RECOMMENDED (auto-releases even on exception)
pool.use((obj) {
obj.doWork();
}); // obj automatically released here
// 2c. Async scoped
await pool.useAsync((obj) async {
await obj.fetchData();
});
// 3. Monitor performance
print(pool.metrics); // utilization, hitRate, acquires/sec, etc.
// 4. Dispose when done
pool.dispose();
Implementing Poolable (Optional but Recommended) #
Implement Poolable to hook into the pool's acquire/release lifecycle:
class Particle implements Poolable {
double x = 0, y = 0;
double velocityX = 0, velocityY = 0;
double alpha = 1.0;
bool active = false;
void init({required double x, required double y}) {
this.x = x; this.y = y;
}
@override
void onAcquire() {
active = true; // called just before object is returned to caller
}
@override
void onRelease() {
// called when returned to pool — RESET ALL STATE here
x = 0; y = 0; velocityX = 0; velocityY = 0;
alpha = 1.0; active = false;
}
@override
bool get isValid => true; // return false for broken/expired objects
}
Overflow Strategies #
What happens when all objects are in use? Choose your strategy:
// Default: grow the pool (safe for most cases)
final pool = ObjectPool<T>(
factory: () => T(),
strategy: ExpandStrategy(maxSize: 500, growthIncrement: 10),
);
// Drop the request — return null (best for particles, sound FX)
final pool = ObjectPool<T>(
factory: () => T(),
strategy: const DiscardStrategy(),
);
// Reclaim the oldest in-use object (best for sound channels)
final pool = ObjectPool<T>(
factory: () => T(),
strategy: const ReplaceStrategy(),
);
| Strategy | Exhaustion Behavior | acquire() Returns |
Best For |
|---|---|---|---|
ExpandStrategy |
Creates new objects | Non-null (new object) | General use ✅ |
DiscardStrategy |
Does nothing | null |
Particles, audio FX |
ReplaceStrategy |
Reclaims oldest | Non-null (reclaimed) | Sound channels, VFX |
Performance Tuning #
final m = pool.metrics;
// Utilization: fraction of pool currently in use (0.0–1.0)
// > 0.85 sustained → increase initialSize or maxSize
print('Utilization: ${(m.utilizationRate * 100).toStringAsFixed(1)}%');
// Hit rate: fraction of acquires served from existing pool
// < 0.95 → increase initialSize to reduce expansions
print('Hit rate: ${(m.hitRate * 100).toStringAsFixed(1)}%');
// Acquires per second — helps size the pool for peak load
print('Acq/sec: ${m.acquiresPerSecond.toStringAsFixed(0)}');
// Exhaustion count — how often requests were dropped (DiscardStrategy)
print('Drops: ${m.exhaustionCount}');
// Peak active — highest simultaneous usage; use as initialSize guide
print('Peak active: ${m.peakActiveCount}');
API Reference #
ObjectPool<T> Constructor #
| Parameter | Type | Default | Description |
|---|---|---|---|
factory |
T Function() |
required | Creates new instances of T |
initialSize |
int |
10 |
Objects to pre-allocate at startup |
maxSize |
int? |
null |
Hard cap (used by default ExpandStrategy) |
strategy |
PoolOverflowStrategy<T>? |
ExpandStrategy |
Exhaustion behavior |
validateOnAcquire |
bool |
false |
Check Poolable.isValid before returning |
resetOnRelease |
bool |
true |
Call Poolable.onRelease automatically |
Methods #
| Method | Returns | Description |
|---|---|---|
acquire() |
T? |
Get an object; null if exhausted + strategy returns null |
acquireOrThrow() |
T |
Get an object; throws PoolExhaustedException on failure |
release(T) |
void |
Return an object to the pool |
releaseIfNotNull(T?) |
void |
Release if not null (convenience) |
use<R>(R Function(T)) |
R? |
Scoped acquire+release (sync) |
useAsync<R>(Future<R> Function(T)) |
Future<R?> |
Scoped acquire+release (async) |
prewarm(int) |
void |
Ensure pool has at least N objects ready |
drain() |
void |
Remove all idle objects (free memory) |
dispose() |
void |
Destroy the pool and all objects |
Properties #
| Property | Type | Description |
|---|---|---|
metrics |
PoolMetrics |
Performance statistics snapshot |
size |
int |
Total objects (in-use + available) |
activeCount |
int |
Objects currently checked out |
availableCount |
int |
Objects ready for acquisition |
hasAvailable |
bool |
Whether at least one object is free |
Common Use Cases #
🎮 Game — Particle System #
class Particle implements Poolable { /* ... */ }
final particlePool = ObjectPool<Particle>(
factory: Particle.new,
initialSize: 500,
maxSize: 1000,
strategy: const DiscardStrategy(), // drop excess particles gracefully
);
// On explosion:
void spawnExplosion(double x, double y) {
for (var i = 0; i < 50; i++) {
final p = particlePool.acquire();
if (p == null) return; // pool full — skip gracefully
p.init(x: x, y: y, angle: Random().nextDouble() * 6.28);
activeParticles.add(p);
}
}
// Each frame:
void update() {
activeParticles.removeWhere((p) {
p.update();
if (!p.alive) {
particlePool.release(p); // O(1) — no GC
return true;
}
return false;
});
}
🌐 Network — Connection Pool #
class HttpConnection implements Poolable {
bool _valid = true;
Future<Response> get(String url) async { /* ... */ }
@override void onAcquire() {}
@override void onRelease() {} // keep TCP connection alive
@override bool get isValid => _valid;
}
final httpPool = ObjectPool<HttpConnection>(
factory: HttpConnection.new,
initialSize: 10,
maxSize: 50,
validateOnAcquire: true, // auto-replace broken connections
);
Future<Response> fetch(String url) async {
return await httpPool.useAsync((conn) => conn.get(url))
?? Response.error(503);
}
🎵 Audio — Sound Channel Pool #
final soundPool = ObjectPool<SoundChannel>(
factory: SoundChannel.new,
initialSize: 32,
maxSize: 32,
strategy: const ReplaceStrategy(), // new sounds replace oldest
);
void playSfx(String asset) {
soundPool.use((channel) => channel.play(asset));
}
🏗️ Prewarm Before Peak Load #
// During Flutter app splash screen / level loading:
Future<void> loadResources() async {
particlePool.prewarm(1000); // allocate 1000 particles NOW, not during gameplay
await Future<void>.delayed(const Duration(seconds: 2)); // loading animation
}
Common Mistakes to Avoid #
❌ Using an object after releasing it #
final obj = pool.acquire()!;
pool.release(obj);
obj.x = 10; // DANGER: obj may now belong to another caller!
// ✅ Use scoped pattern — impossible to use after release
pool.use((obj) {
obj.x = 10; // safe — can't escape this scope
});
❌ Forgetting to release (pool leak) #
Future<void> bad() async {
final obj = pool.acquire()!;
await riskyOperation(); // throws → release never called → leak!
pool.release(obj);
}
// ✅ useAsync guarantees release even on exception
Future<void> good() async {
await pool.useAsync((obj) => riskyOperation());
}
❌ Pool too small for peak load #
// ❌ Causes constant expansions → defeats the purpose
final pool = ObjectPool<Particle>(factory: Particle.new, initialSize: 5);
// ✅ Profile peak usage with metrics.peakActiveCount, then size accordingly
final pool = ObjectPool<Particle>(
factory: Particle.new,
initialSize: 200, // observed peak × 1.25
);
Performance Expectations #
On a typical modern device with HeavyObject (~200 bytes):
| Scenario | Naive (alloc/GC) | Object Pool | Speedup |
|---|---|---|---|
| Single acquire/release | ~600 ns | ~60 ns | ~10× |
| 1000/frame at 60fps | ~36 ms/s GC | ~0 ms GC | ∞× GC events |
| Peak memory pressure | High (fragmented) | Low (fixed block) | Significant |
The biggest win is not raw speed — it's eliminating GC pauses entirely. Each GC pause that exceeds 16ms drops a frame and causes visible jank. Object pools make GC pauses from short-lived objects structurally impossible.
Internal Architecture #
ObjectPool<T>
│
├── _entries: List<PoolEntry<T>> ← all objects, in-use + available
├── _entryMap: HashMap.identity() ← O(1) object → entry lookup
└── _freeListHead: PoolEntry<T>? ← head of embedded free list
│
PoolEntry<T> ▼
├── object: T [Entry0] → [Entry3] → [Entry1] → null
├── inUse: bool free free free
├── nextFree: PoolEntry<T>?
├── lastAcquiredAt: DateTime?
└── reuseCount: int
The embedded free list is the key insight: available entries form a
linked list that threads through the entries themselves via nextFree.
No separate data structure is needed. Both acquire() (pop head) and
release() (push head) are O(1).
Changelog #
See CHANGELOG.md.
License #
MIT — See LICENSE.
Contributing #
Issues and PRs welcome at the GitHub repository.
Please run dart test and dart analyze before submitting a pull request.