object_pool 🎯

pub.flutter-io.cn Dart SDK License: MIT style: lints

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.


image

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();

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.

Libraries

object_pool
A high-performance, generic object pool for Dart and Flutter.