easy_state_m 1.2.1 copy "easy_state_m: ^1.2.1" to clipboard
easy_state_m: ^1.2.1 copied to clipboard

Easy State Framework is a lightweight, high-performance, strongly-typed state management micro-framework for Flutter.

easy_state_m #

A lightweight, performance-optimized state management framework for Flutter.

English | 中文


English #

Overview #

easy_state_m is built around three ideas:

  1. Localized state — each feature owns its state in an EasyController, scoped to a widget subtree via EasyScope. No global singletons, no accidental cross-feature pollution.
  2. Surgical UI refreshEasyConsumer widgets subscribe directly to a synchronous notifier. Pass an id to rebuild only the exact widget that changed, leaving everything else untouched.
  3. Typed cross-controller messaging — sibling scopes communicate through named channels. The sender specifies the target controller type as a generic parameter; the channel bus routes the message with O(1) HashMap lookups. No shared state, no tight coupling.

Architecture #

┌──────────────────────────────────────────────────────────────────────┐
│                           Widget Tree                                │
│                                                                      │
│  EasyMultiScope                                                      │
│  ├─ EasyScope<AuthController>   (owned — manages lifecycle)          │
│  └─ EasyScope<CartController>   (shared — external lifecycle)        │
│         │                              │                             │
│         │  InheritedWidget lookup      │  refresh(ids: ['badge'])    │
│         ▼                              │  (synchronous, zero alloc)  │
│  EasyConsumer<CartController>          │                             │
│  id: 'badge'  ◄────────────────────────┘                            │
└──────────────────────────────────────────────────────────────────────┘
                         ▲
      emit<CartController>('item_added', product)
      (@protected — only callable from EasyController subclasses)
                         │
         ┌───────────────┴────────────────┐
         │        ProductController        │
         │        (EasyController)         │
         └───────────────┬────────────────┘
                         │
                         ▼
         ┌───────────────────────────────┐
         │          EasyChannel          │  Global channel bus
         │  (controllerType →            │  O(1) two-level HashMap
         │   channelName → handlers)     │  Type-namespaced routing
         └───────────────────────────────┘
                         │
                         ▼
         ┌───────────────────────────────┐
         │        CartController         │
         │  channelBindings:             │
         │  EasyChannelBinding<Product>  │
         │  ('item_added', _onItem)      │
         └───────────────────────────────┘

Data flow #

Step What happens
EasyScope mounts Creates controller, calls initialize(), registers channel bindings
EasyConsumer mounts Subscribes setState callback to _RefreshNotifier (synchronous, no Stream)
Controller calls refresh() _RefreshNotifier dispatches callbacks in-place — setState fires in the same call stack
Controller calls emit<C>(channel, data) EasyChannel routes to all C instances that declared a binding for that channel
EasyScope unmounts Calls dispose(), unregisters channel bindings, cancels debounce timers

Performance Notes #

Local refresh — zero allocation on the hot path #

_RefreshNotifier uses an iteration-depth + null-mark strategy (modelled after Flutter's own ChangeNotifier):

  • No List.of() copy is made on every notify() call.
  • If a callback is removed during notification, the slot is null-marked and skipped inline.
  • Compaction (removing null slots) runs once after the outermost notify returns.
  • The common case — no structural changes during notification — is fully allocation-free.

Cross-controller messaging — O(1) routing #

_ChannelBus uses a two-level HashMap:

controllerType  →  channelName  →  List<handler>

Dispatching a message requires exactly two HashMap lookups regardless of how many controllers or channels exist in the app. Unrelated controllers are never woken up.


Installation #

dependencies:
  easy_state_m: ^1.2.1

Quick Start #

1. Define a controller #

class CounterController extends EasyController {
  int count = 0;

  void increment() {
    count++;
    refresh();                        // rebuilds all consumers
  }

  void incrementHeader() {
    count++;
    refresh(ids: ['header']);         // rebuilds only EasyConsumer(id: 'header')
  }

  void incrementDebounced() {
    count++;
    refresh(debounce: Duration(milliseconds: 16)); // coalesces rapid calls
  }
}

2. Provide it with EasyScope #

EasyScope<CounterController>(
  create: () => CounterController(),   // owned — scope manages lifecycle
  builder: (context, controller) {
    return Scaffold(
      body: EasyConsumer<CounterController>(
        builder: (context, ctrl) => Text('${ctrl.count}'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: controller.increment,
        child: const Icon(Icons.add),
      ),
    );
  },
);

3. Look up the controller anywhere in the subtree #

// Throws if not found — use when the scope is guaranteed to exist.
final ctrl = EasyScope.of<CounterController>(context);

// Returns null if not found — use for optional dependencies.
final ctrl = EasyScope.maybeOf<CounterController>(context);

Async safety — store the controller reference before any await. After the async gap the widget may have unmounted and the context is no longer valid:

Future<void> load() async {
  final ctrl = EasyScope.of<MyController>(context); // before await ✓
  final data = await fetchData();
  ctrl.update(data);                                // no context needed ✓
}

Targeted Rebuild #

Give each EasyConsumer a unique id and call refresh(ids: [...]) to rebuild only the matching widgets. All other consumers are untouched, no matter how many there are.

// Controller
void updateRow(int index) {
  items[index].value++;
  refresh(ids: ['row_$index']);   // only the tapped row rebuilds
}

// Widget
ListView.builder(
  itemBuilder: (context, index) => EasyConsumer<ListController>(
    id: 'row_$index',
    builder: (context, ctrl) => ListTile(title: Text('${ctrl.items[index].value}')),
  ),
);

Cross-Controller Messaging #

Controllers in sibling scopes communicate through named channels. Channels are automatically namespaced by the target controller's runtimeType, so the same channel name in different controllers never collides.

// Receiver — CartController
class CartController extends EasyController {
  int itemCount = 0;

  @override
  List<EasyChannelBinding> get channelBindings => [
    EasyChannelBinding<String>('item_added', _onItemAdded),
  ];

  void _onItemAdded(String productName) {
    itemCount++;
    refresh();
  }
}

// Sender — ProductController (no import of CartController's internals)
class ProductController extends EasyController {
  void addToCart(String productName) {
    emit<CartController>('item_added', productName);
    //   ^^^^^^^^^^^^^^^ target type  ^^^^^^^^^^^^^ channel name
  }
}

Rules:

  • Always specify the type parameter on EasyChannelBinding<T> and emit<C>. Omitting either triggers an AssertionError in debug mode.
  • emit is @protected — only callable from within an EasyController subclass. This enforces the constraint that channels are strictly for controller-to-controller communication.

Multi-Scope Injection #

Use EasyMultiScope to nest multiple scopes without deeply indented code. Entries are ordered outermost-first: entries[0] is the highest ancestor and is accessible to all controllers declared after it.

// main.dart
final authController = AuthController()..initialize();

EasyMultiScope(
  entries: [
    EasyScopeProvide.value(value: authController),   // shared — lifecycle managed externally
    EasyScopeProvide(create: () => ThemeController()), // owned — disposed with the scope
  ],
  child: const MyApp(),
);

Shared Scope #

Use EasyScope.value to inject an already-initialized controller across Navigator barriers or into children that should not own the lifecycle.

// Parent creates and owns the controller.
final controller = MyController()..initialize();

// Pass it into a new route.
Navigator.of(context).push(MaterialPageRoute(
  builder: (_) => EasyScope<MyController>.value(
    value: controller,
    child: const ChildPage(),
  ),
));

EasyScope.value does not call dispose() when unmounted.


Testing #

Replace the channel bus with a mock in setUp and restore it in tearDown:

class MockChannelBus implements EasyChannelBus {
  final List<String> log = [];

  @override void listen(Type ct, String ch, void Function(dynamic) h) {}
  @override void cancel(Type ct, String ch, void Function(dynamic) h) {}
  @override void emit(Type t, String ch, dynamic d) => log.add('$t/$ch/$d');
  @override void dispose() {}
}

setUp(() => EasyChannel.override(MockChannelBus()));
tearDown(() => EasyChannel.reset());

API Reference #

Class Role
EasyController Base class for business logic. Override channelBindings, call refresh() and emit<C>().
EasyScope<T> Provides T to a widget subtree via InheritedWidget. Owns or shares the lifecycle.
EasyConsumer<T> Rebuilds when the controller calls refresh(). Optionally scoped by id.
EasyMultiScope Nests multiple EasyScope instances without deep indentation.
EasyScopeProvide<T> An EasyMultiScope entry. Use .value(value: ...) for shared instances.
EasyChannelBinding<T> Declares a channel subscription with a typed payload.
EasyChannelBus Abstract interface for the channel bus. Implement to provide test doubles.
EasyChannel Global registry. Use override / reset in tests.

Common Pitfalls #

Mistake What happens Fix
Nesting two EasyScope<T> of the same type FlutterError thrown in debug mode Make scopes siblings, or subclass T
Omitting <T> on EasyChannelBinding AssertionError in debug mode EasyChannelBinding<MyType>(...)
Omitting <C> on emit AssertionError in debug mode emit<TargetController>(...)
Injecting an uninitialized shared controller AssertionError in debug mode Call initialize() before passing to EasyScope.value
Calling EasyScope.of(context) after await Potential context invalidation Store the controller before the async gap

License #

MIT



中文 #

概述 #

easy_state_m 围绕三个核心思想构建:

  1. 状态局部化 — 每个功能模块的状态收敛到一个 EasyController,通过 EasyScope 挂载到 Widget 子树。没有全局单例,没有跨模块意外污染。
  2. 精准 UI 刷新EasyConsumer 直接订阅同步通知器。传入 id 可只重建那一个 Widget,其余所有组件保持静止。
  3. 类型化跨控制器通信 — 平级 Scope 之间通过命名频道通信。发送方用泛型参数指定目标 Controller 类型,Channel Bus 以 O(1) 的 HashMap 查找完成路由。无共享状态,无紧耦合。

架构图 #

┌──────────────────────────────────────────────────────────────────────┐
│                           Widget Tree                                │
│                                                                      │
│  EasyMultiScope                                                      │
│  ├─ EasyScope<AuthController>   (owned — 管理生命周期)                │
│  └─ EasyScope<CartController>   (shared — 外部管理生命周期)            │
│         │                              │                             │
│         │  InheritedWidget 查找         │  refresh(ids: ['badge'])   │
│         ▼                              │  (同步派发,零分配)           │
│  EasyConsumer<CartController>          │                             │
│  id: 'badge'  ◄────────────────────────┘                            │
└──────────────────────────────────────────────────────────────────────┘
                         ▲
      emit<CartController>('item_added', product)
      (@protected — 只能在 EasyController 子类内部调用)
                         │
         ┌───────────────┴────────────────┐
         │        ProductController        │
         │        (EasyController)         │
         └───────────────┬────────────────┘
                         │
                         ▼
         ┌───────────────────────────────┐
         │          EasyChannel          │  全局频道总线
         │  (controllerType →            │  O(1) 双层 HashMap
         │   channelName → handlers)     │  类型自动命名空间
         └───────────────────────────────┘
                         │
                         ▼
         ┌───────────────────────────────┐
         │        CartController         │
         │  channelBindings:             │
         │  EasyChannelBinding<Product>  │
         │  ('item_added', _onItem)      │
         └───────────────────────────────┘

数据流 #

步骤 发生了什么
EasyScope 挂载 创建 Controller,调用 initialize(),注册频道绑定
EasyConsumer 挂载 setState 回调订阅到 _RefreshNotifier(同步,无 Stream)
Controller 调用 refresh() _RefreshNotifier 就地派发回调,setState 在同一调用栈内触发
Controller 调用 emit<C>(channel, data) EasyChannel 路由到所有声明了该频道绑定的 C 实例
EasyScope 卸载 调用 dispose(),注销频道绑定,取消去抖 Timer

性能说明 #

本地刷新 — 热路径零分配 #

_RefreshNotifier 采用 迭代深度计数 + null 标记策略(参考 Flutter 内置 ChangeNotifier):

  • notify() 不再每次调用 List.of() 创建副本。
  • 通知过程中若有回调被移除,对应槽位标为 null,迭代时跳过。
  • 紧凑化(清理 null 槽)在最外层 notify 返回后统一执行。
  • 正常路径(通知期间无结构变更)完全零分配。

跨控制器通信 — O(1) 路由 #

_ChannelBus 采用双层 HashMap:

controllerType  →  channelName  →  List<handler>

派发一条消息只需恰好两次 HashMap 查找,与应用中 Controller 的数量、频道数量无关。无关 Controller 完全不被唤醒。


安装 #

dependencies:
  easy_state_m: ^1.2.1

快速上手 #

1. 定义 Controller #

class CounterController extends EasyController {
  int count = 0;

  void increment() {
    count++;
    refresh();                         // 刷新所有消费者
  }

  void incrementHeader() {
    count++;
    refresh(ids: ['header']);          // 只刷新 EasyConsumer(id: 'header')
  }

  void incrementDebounced() {
    count++;
    refresh(debounce: Duration(milliseconds: 16)); // 合并高频调用
  }
}

2. 用 EasyScope 提供 Controller #

EasyScope<CounterController>(
  create: () => CounterController(),   // owned — Scope 管理生命周期
  builder: (context, controller) {
    return Scaffold(
      body: EasyConsumer<CounterController>(
        builder: (context, ctrl) => Text('计数: ${ctrl.count}'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: controller.increment,
        child: const Icon(Icons.add),
      ),
    );
  },
);

3. 在子树任意位置查找 Controller #

// 找不到时抛出 FlutterError(适用于有保证的子树内)
final ctrl = EasyScope.of<CounterController>(context);

// 找不到时返回 null(适用于可选依赖)
final ctrl = EasyScope.maybeOf<CounterController>(context);

异步安全 — 在任何 await 之前存储 Controller 引用。异步间隙之后 Widget 可能已卸载,context 不再有效:

Future<void> load() async {
  final ctrl = EasyScope.of<MyController>(context); // await 之前 ✓
  final data = await fetchData();
  ctrl.update(data);                                // 不再依赖 context ✓
}

精准局部刷新 #

给每个 EasyConsumer 设置唯一 id,配合 refresh(ids: [...]) 只重建匹配的 Widget,无论页面上有多少个 Consumer,其余全部静止不动。

// Controller
void updateRow(int index) {
  items[index].value++;
  refresh(ids: ['row_$index']);   // 只有被点击的那一行重建
}

// Widget
ListView.builder(
  itemBuilder: (context, index) => EasyConsumer<ListController>(
    id: 'row_$index',
    builder: (context, ctrl) => ListTile(
      title: Text('${ctrl.items[index].value}'),
    ),
  ),
);

跨控制器通信(Channel) #

平级 Scope 下的 Controller 通过命名频道通信。频道以目标 Controller 的 runtimeType 为命名空间自动隔离,不同 Controller 中的同名频道绝不互相干扰。

// 接收方 — CartController
class CartController extends EasyController {
  int itemCount = 0;

  @override
  List<EasyChannelBinding> get channelBindings => [
    EasyChannelBinding<String>('item_added', _onItemAdded),
  ];

  void _onItemAdded(String productName) {
    itemCount++;
    refresh();
  }
}

// 发送方 — ProductController(无需 import CartController 内部实现)
class ProductController extends EasyController {
  void addToCart(String productName) {
    emit<CartController>('item_added', productName);
    //   ^^^^^^^^^^^^^^  目标类型     ^^^^^^^^^^^^^ 频道名
  }
}

使用规则:

  • EasyChannelBinding<T>emit<C>必须显式指定泛型。省略任一个都会在 Debug 模式下触发 AssertionError
  • emit@protected 方法,只能在 EasyController 子类内部调用,框架层面强制约束"频道只用于 Controller 之间通信"。

多 Scope 注入 #

EasyMultiScope 嵌套多个 Scope,避免深层缩进。entries 按由外到内顺序排列:entries[0] 是最外层祖先,可被所有后续 Controller 访问。

// main.dart
final authController = AuthController()..initialize();

EasyMultiScope(
  entries: [
    EasyScopeProvide.value(value: authController),    // shared — 外部管理生命周期
    EasyScopeProvide(create: () => ThemeController()), // owned  — 随 Scope 销毁
  ],
  child: const MyApp(),
);

共享 Scope #

跨路由共享同一个 Controller 实例时,用 EasyScope.value 注入,Scope 卸载时不会调用 dispose()

// 外部创建并持有 Controller
final controller = MyController()..initialize();

// 注入到新路由
Navigator.of(context).push(MaterialPageRoute(
  builder: (_) => EasyScope<MyController>.value(
    value: controller,
    child: const ChildPage(),
  ),
));

测试 #

setUp 中替换 Channel Bus,在 tearDown 中还原:

class MockChannelBus implements EasyChannelBus {
  final List<String> log = [];

  @override void listen(Type ct, String ch, void Function(dynamic) h) {}
  @override void cancel(Type ct, String ch, void Function(dynamic) h) {}
  @override void emit(Type t, String ch, dynamic d) => log.add('$t/$ch/$d');
  @override void dispose() {}
}

setUp(() => EasyChannel.override(MockChannelBus()));
tearDown(() => EasyChannel.reset());

API 一览 #

职责
EasyController 业务逻辑基类。覆写 channelBindings,调用 refresh()emit<C>()
EasyScope<T> 通过 InheritedWidget 向子树提供 T。支持 owned / shared 两种模式。
EasyConsumer<T> Controller 调用 refresh() 时重建。可通过 id 限定为精准刷新。
EasyMultiScope 无嵌套缩进地注入多个 EasyScope
EasyScopeProvide<T> EasyMultiScope 的条目。.value(value: ...) 用于共享实例。
EasyChannelBinding<T> 声明一个带类型载荷的频道订阅。
EasyChannelBus 频道总线抽象接口,实现它以提供测试替身。
EasyChannel 全局注册表。测试中用 override / reset 替换实现。

常见错误 #

错误 后果 修正
嵌套两个同类型 EasyScope<T> Debug 模式抛出 FlutterError 改为兄弟节点,或将 T 子类化
EasyChannelBinding 省略 <T> Debug 模式抛出 AssertionError 显式写 EasyChannelBinding<MyType>(...)
emit 省略 <C> Debug 模式抛出 AssertionError 显式写 emit<TargetController>(...)
注入未初始化的共享 Controller Debug 模式抛出 AssertionError 传入前调用 initialize()
await 后调用 EasyScope.of(context) context 可能已失效 await 之前存储 Controller 引用

开源协议 #

MIT

1
likes
160
points
300
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Easy State Framework is a lightweight, high-performance, strongly-typed state management micro-framework for Flutter.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter

More

Packages that depend on easy_state_m