diff --git a/.agents/skills/view_model/SKILL.md b/.agents/skills/view_model/SKILL.md index 82641d8..b98f717 100644 --- a/.agents/skills/view_model/SKILL.md +++ b/.agents/skills/view_model/SKILL.md @@ -26,6 +26,7 @@ If examples conflict with README, follow README. - For Chinese responses or terminology checks, also read `references/README_FULL_ZH.md`. - For trivial requests (single API clarification), you may use this SKILL summary first, then open full reference only if uncertain. - If embedded reference and upstream README diverge, treat upstream as latest truth and sync the embedded reference. +- Keep `references/README_FULL_*.md` as real files inside the skill package; do not replace them with symlinks to outside paths. ## Trigger phrases diff --git a/.agents/skills/view_model/references/README_FULL_EN.md b/.agents/skills/view_model/references/README_FULL_EN.md deleted file mode 120000 index 96c16a7..0000000 --- a/.agents/skills/view_model/references/README_FULL_EN.md +++ /dev/null @@ -1 +0,0 @@ -../../../../packages/view_model/README.md \ No newline at end of file diff --git a/.agents/skills/view_model/references/README_FULL_EN.md b/.agents/skills/view_model/references/README_FULL_EN.md new file mode 100644 index 0000000..e013179 --- /dev/null +++ b/.agents/skills/view_model/references/README_FULL_EN.md @@ -0,0 +1,965 @@ +# view_model + +[![pub package](https://img.shields.io/pub/v/view_model.svg)](https://pub.dev/packages/view_model) + +[简体中文](./README_ZH.md) + +A Flutter state management library built on a type-keyed instance registry with automatic reference-counted lifecycle. + +```yaml +dependencies: + view_model: ^1.0.0 +``` + +## Install Skill + +```bash +npx skills add https://github.com/lwj1994/flutter_view_model --skill view_model +``` + +--- + +## Table of Contents +- [Architecture Overview](#architecture-overview) +- [Two Core Mixins](#two-core-mixins) +- [Getting Started](#getting-started) +- [ViewModel](#viewmodel) + - [Basic ViewModel](#basic-viewmodel) + - [StateViewModel](#stateviewmodel) + - [ChangeNotifierViewModel](#changenotifierviewmodel) +- [ViewModelSpec](#viewmodelspec) +- [Widget Integration](#widget-integration) + - [ViewModelStateMixin](#viewmodelstatemixin) + - [ViewModelBuilder](#viewmodelbuilder) + - [ViewModelStatelessMixin](#viewmodelstatelessmixin) +- [viewModelBinding API](#viewmodelbinding-api) + - [watch vs read](#watch-vs-read) + - [Cached Access](#cached-access) + - [listen / listenState / listenStateSelect](#listen--listenstate--listenstateselect) + - [recycle](#recycle) +- [Instance Sharing](#instance-sharing) + - [key-based Sharing](#key-based-sharing) + - [tag-based Lookup](#tag-based-lookup) + - [aliveForever Singletons](#aliveforever-singletons) + - [Static Global Access](#static-global-access) +- [ViewModelBinding in Any Class](#viewmodelbinding-in-any-class) +- [ViewModel-to-ViewModel Dependencies](#viewmodel-to-viewmodel-dependencies) +- [Fine-Grained Reactivity](#fine-grained-reactivity) + - [StateViewModelValueWatcher](#stateviewmodelvaluewatcher) + - [ObservableValue & ObserverBuilder](#observablevalue--observerbuilder) +- [Pause / Resume](#pause--resume) +- [Lifecycle Details](#lifecycle-details) + - [Reference Counting (Binding)](#reference-counting-binding) + - [Resource Cleanup](#resource-cleanup) + - [ViewModelLifecycle Observer](#viewmodellifecycle-observer) +- [Configuration](#configuration) +- [Testing](#testing) +- [Code Generation](#code-generation) +- [DevTools Extension](#devtools-extension) +- [view_model vs riverpod](#view_model-vs-riverpod) + +--- + +## Architecture Overview + +The library is organized in three layers: + +``` +┌─────────────────────────────────────────────────┐ +│ Widget / Consumer Layer │ +│ ViewModelStateMixin, ViewModelBuilder, ... │ +└───────────────────┬─────────────────────────────┘ + │ watch / read +┌───────────────────▼─────────────────────────────┐ +│ ViewModelBinding Layer │ +│ Bridges consumers to the instance registry. │ +│ Both watch() and read() perform binding. │ +│ watch() additionally registers a listener. │ +│ Manages pause/resume and Zone-based DI. │ +└───────────────────┬─────────────────────────────┘ + │ getInstance → bind(bindingId) +┌───────────────────▼─────────────────────────────┐ +│ Instance Management Layer │ +│ InstanceManager ─► Store ─► InstanceHandle │ +│ Type-keyed registry. Each handle tracks a │ +│ list of bindingIds (reference count). │ +│ Auto-disposes when bindingIds becomes empty. │ +└─────────────────────────────────────────────────┘ +``` + +**Key mechanics:** + +1. Each `ViewModelBinding` (typically one per widget) has a unique `id` string. +2. Both `watch(spec)` and `read(spec)` obtain or create the ViewModel instance, then call `bind(id)` on the `InstanceHandle` to add the binding's `id` to the handle's `bindingIds` list. This is the **reference count**. Both methods bind; the difference is that `watch` also attaches a change listener. +3. When the `ViewModelBinding` disposes, it calls `unbind(id)` on every handle it bound to. If a handle's `bindingIds` becomes empty (and `aliveForever` is false), the ViewModel is automatically disposed. +4. `watch` additionally calls `_addListener`, which registers a callback on the ViewModel via `listen()`. When the ViewModel calls `notifyListeners()`, this callback invokes `onUpdate()` on the binding. For `WidgetViewModelBinding`, `onUpdate()` calls `setState()` to trigger a rebuild. +5. ViewModel-to-ViewModel dependencies are resolved through Dart **Zones**: when a ViewModel is constructed via `_createViewModel`, the parent `ViewModelBinding` is stored in a zone value using `runWithBinding()`. Inside the new ViewModel's constructor, accessing `viewModelBinding` resolves from the zone, so nested dependencies bind to the same root binding. + +--- + +## Two Core Mixins + +The entire library revolves around two mixins that can be applied to **any Dart class**: + +### `with ViewModel` — Makes a class a managed instance + +Any class that mixes in `ViewModel` gains: +- Lifecycle callbacks (`onCreate`, `onBind`, `onUnbind`, `onDispose`) +- Listener support (`notifyListeners()`, `listen()`, `update()`) +- Access to other ViewModels via `viewModelBinding` (resolved from the parent binding through Zones) +- Automatic disposal registration via `addDispose()` + +```dart +class UserRepository with ViewModel { /* ... */ } +class AnalyticsService with ViewModel { /* ... */ } +class CartViewModel with ViewModel { /* ... */ } +``` + +### `with ViewModelBinding` — Makes a class able to access ViewModels + +Any class that mixes in `ViewModelBinding` becomes a **binding host** — it can create, bind to, and manage ViewModel instances. It's not limited to widgets. Widget mixins like `ViewModelStateMixin` are simply thin wrappers around `ViewModelBinding` that bridge `onUpdate()` to `setState()`. + +```dart +// A plain Dart class that manages ViewModels +class AppInitializer with ViewModelBinding { + Future run() async { + await viewModelBinding.read(configSpec).load(); + await viewModelBinding.read(authSpec).restoreSession(); + } +} + +// A background service +class SyncService with ViewModelBinding { + void start() { + viewModelBinding.watch(syncSpec).startPeriodicSync(); + } + + @override + void onUpdate() { + // react to ViewModel changes without any widget + } +} +``` + +These two mixins together form the foundation: `ViewModel` is the managed side, `ViewModelBinding` is the managing side. Every other API in the library is built on this relationship. + +--- + +## Getting Started + +```dart +import 'package:view_model/view_model.dart'; + +// 1. Define a ViewModel +class CounterViewModel with ViewModel { + int count = 0; + void increment() => update(() => count++); +} + +// 2. Declare a spec (factory definition) +final counterSpec = ViewModelSpec( + builder: () => CounterViewModel(), +); + +// 3. Use in a widget +class CounterPage extends StatefulWidget { + @override + State createState() => _CounterPageState(); +} + +class _CounterPageState extends State with ViewModelStateMixin { + late final vm = viewModelBinding.watch(counterSpec); + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: vm.increment, + child: Text('${vm.count}'), + ); + } +} +``` + +No root wrapper widget, no `ChangeNotifierProvider`, no `ProviderScope`. The mixin gives you `viewModelBinding`; `watch` wires up instance creation, binding, listener registration, and disposal. + +--- + +## ViewModel + +### Basic ViewModel + +Mix `ViewModel` into any class to give it lifecycle awareness and listener support. `ViewModel` implements `Listenable`, so it works with Flutter's `ListenableBuilder` and `AnimatedBuilder` out of the box. + +```dart +class TodoViewModel with ViewModel { + final _items = []; + List get items => List.unmodifiable(_items); + + void add(String item) { + _items.add(item); + notifyListeners(); // manually notify + } + + // update() is a convenience: runs the block, then calls notifyListeners() + void remove(int index) => update(() => _items.removeAt(index)); +} +``` + +### StateViewModel + +`StateViewModel` manages an immutable state object of type `T`. Internally it uses a `StreamController>` to broadcast `(previousState, currentState)` pairs. This unlocks `listenState` and `listenStateSelect` for selective listening. + +```dart +class UserState { + final String name; + final int age; + final bool loading; + const UserState({this.name = '', this.age = 0, this.loading = false}); +} + +class UserViewModel extends StateViewModel { + UserViewModel() : super(state: const UserState()); + + Future load() async { + setState(UserState(loading: true)); + final user = await api.fetchUser(); + setState(UserState(name: user.name, age: user.age)); + } +} +``` + +State equality is checked by `identical()` by default. You can override this globally via `ViewModelConfig.equals` so that, for example, `==` is used instead (see [Configuration](#configuration)). + +### ChangeNotifierViewModel + +If you need to extend `ChangeNotifier` (e.g., to pass the ViewModel directly to `AnimatedBuilder` or `ValueListenableBuilder`), use `ChangeNotifierViewModel`: + +```dart +class MyViewModel extends ChangeNotifierViewModel { + int value = 0; + void inc() { value++; notifyListeners(); } +} +``` + +--- + +## ViewModelSpec + +`ViewModelSpec` is a declarative factory that tells the system *how to build* a ViewModel and *how to identify it* for caching. + +```dart +// No arguments +final counterSpec = ViewModelSpec( + builder: () => CounterViewModel(), +); + +// With a fixed key (shared globally) +final authSpec = ViewModelSpec( + builder: () => AuthViewModel(), + key: 'auth', + aliveForever: true, +); + +// With one argument: key and tag are computed from the argument +final userSpec = ViewModelSpec.arg( + builder: (userId) => UserViewModel(userId), + key: (userId) => 'user-$userId', +); + +// Two arguments +final chatSpec = ViewModelSpec.arg2( + builder: (roomId, limit) => ChatViewModel(roomId, limit), + key: (roomId, limit) => 'chat-$roomId', +); + +// arg3 and arg4 are also available +``` + +Calling `userSpec('abc')` returns a `ViewModelFactory` that you can pass to `watch` / `read`. + +Internally, `ViewModelSpec` extends `ViewModelFactory`, which defines: +- `build()` — creates the instance +- `key()` — cache key (same key = same instance) +- `tag()` — logical grouping label +- `aliveForever()` — whether to skip auto-disposal + +--- + +## Widget Integration + +### ViewModelStateMixin + +The primary way to use ViewModels in widgets. Mix it into `State`: + +```dart +class _MyPageState extends State with ViewModelStateMixin { + late final vm = viewModelBinding.watch(mySpec); + + @override + Widget build(BuildContext context) { + return Text(vm.data); + } +} +``` + +The mixin: +- Creates a `WidgetViewModelBinding` whose `onUpdate()` calls `setState()`. +- Registers three default `PauseProvider`s (route, ticker mode, app lifecycle). +- Disposes everything (unbinds all handles) in `State.dispose()`. + +### ViewModelBuilder + +A convenience widget that internally uses `ViewModelStateMixin`, so you don't need a custom `State` class: + +```dart +ViewModelBuilder( + counterSpec, + builder: (vm) => Text('${vm.count}'), +) +``` + +For fetching an already-existing (cached) ViewModel: + +```dart +CachedViewModelBuilder( + shareKey: 'my-counter', + builder: (vm) => Text('${vm.count}'), +) +``` + +### ViewModelStatelessMixin + +Mix into `StatelessWidget` for lightweight usage. The mixin creates a custom `Element` that owns the `WidgetViewModelBinding`: + +```dart +class MyWidget extends StatelessWidget with ViewModelStatelessMixin { + late final vm = viewModelBinding.watch(mySpec); + MyWidget({super.key}); + + @override + Widget build(BuildContext context) => Text(vm.data); +} +``` + +> Caveat: if the same widget instance is mounted in multiple locations simultaneously, this won't work correctly. Prefer `ViewModelStateMixin` when in doubt. + +--- + +## viewModelBinding API + +`viewModelBinding` is the accessor provided by `ViewModelStateMixin`, `ViewModelStatelessMixin`, the `ViewModel` mixin, or any class that mixes in `ViewModelBinding`. It exposes `ViewModelBindingInterface` with these methods: + +### watch vs read + +Both `watch` and `read` **bind** the current `ViewModelBinding` to the ViewModel (adding its `bindingId` to the handle's `bindingIds`). Both contribute to the reference count that keeps the ViewModel alive. The difference is only in listener registration: + +| | Creates if absent? | Binds? | Listens for changes? | Triggers rebuild? | +|---|---|---|---|---| +| `watch(spec)` | Yes | Yes | Yes | Yes | +| `read(spec)` | Yes | Yes | No | No | + +```dart +// In initState or build — want rebuilds when ViewModel changes +final vm = viewModelBinding.watch(spec); + +// In an event handler — just need to call a method, no rebuild needed +void _onTap() { + viewModelBinding.read(spec).doSomething(); +} +``` + +### Cached Access + +These methods look up an already-created instance by `key` or `tag`. They never create new instances. Like `watch`/`read`, the `watch` variants bind + listen, while the `read` variants bind only. + +```dart +// Throws if not found +final vm = viewModelBinding.watchCached(key: 'abc'); +final vm = viewModelBinding.readCached(tag: 'dashboard'); + +// Returns null if not found +final vm = viewModelBinding.maybeWatchCached(key: 'abc'); +final vm = viewModelBinding.maybeReadCached(tag: 'dashboard'); +``` + +Batch retrieval by tag: +```dart +List vms = viewModelBinding.watchCachesByTag('group-a'); +List vms = viewModelBinding.readCachesByTag('group-a'); +``` + +### listen / listenState / listenStateSelect + +Fire-and-forget listeners that are automatically cleaned up when the binding disposes. These use `read` internally (bind without triggering widget rebuild) and then attach custom callbacks: + +```dart +// General change callback +viewModelBinding.listen(authSpec, onChanged: () { + print('auth changed'); +}); + +// StateViewModel: full state diff +viewModelBinding.listenState(userSpec, onChanged: (UserState? prev, UserState curr) { + print('user state changed'); +}); + +// StateViewModel: selected property only — fires only when selector output differs +viewModelBinding.listenStateSelect( + userSpec, + selector: (UserState s) => s.name, + onChanged: (String? prevName, String currName) { + print('name changed to $currName'); + }, +); +``` + +### recycle + +Force-disposes a ViewModel by calling `unbindAll()` on its handle (removes all bindingIds, triggering disposal). The next `watch`/`read` call with the same spec will create a fresh instance. + +```dart +viewModelBinding.recycle(vm); +// vm is now disposed +final freshVm = viewModelBinding.watch(spec); // new instance +``` + +--- + +## Instance Sharing + +### key-based Sharing + +When a `ViewModelSpec` has a `key`, any binding that calls `watch`/`read` with the same key gets the **same instance**. Each binding adds its own `bindingId` to the handle — the instance stays alive until all bindings unbind. + +```dart +final spec = ViewModelSpec( + builder: () => CounterViewModel(), + key: 'shared-counter', +); + +// Widget A binds → bindingIds = ['A#123'] +viewModelBinding.watch(spec); + +// Widget B binds → bindingIds = ['A#123', 'B#456'] +viewModelBinding.watch(spec); +``` + +Without a `key`, each binding creates a new, independent instance scoped to that binding alone. + +### tag-based Lookup + +`tag` is a grouping label. Multiple instances can share the same tag. Use `watchCached`/`readCached` with `tag:` to find the most recently created instance with that tag: + +```dart +final spec = ViewModelSpec( + builder: () => ItemVM(), + tag: 'active-items', +); +``` + +### aliveForever Singletons + +Set `aliveForever: true` to prevent auto-disposal. When the handle's `bindingIds` becomes empty, `_recycle()` checks this flag and skips disposal. The instance lives until the process ends: + +```dart +final authSpec = ViewModelSpec( + builder: () => AuthViewModel(), + key: 'auth', + aliveForever: true, +); +``` + +### Static Global Access + +Read any cached ViewModel from anywhere (no binding context needed). These are pure lookups — they don't bind or create instances: + +```dart +final auth = ViewModel.readCached(key: 'auth'); +final auth = ViewModel.maybeReadCached(key: 'auth'); // null-safe +``` + +--- + +## ViewModelBinding in Any Class + +`ViewModelBinding` is not just for widgets — any Dart class can mix it in to gain the full `viewModelBinding` API (`watch`, `read`, `listen`, etc.). Widget mixins like `ViewModelStateMixin` are simply thin wrappers around `ViewModelBinding` that bridge `onUpdate()` to `setState()`. + +**App initialization:** + +```dart +class AppBootstrap with ViewModelBinding { + Future run() async { + final config = viewModelBinding.read(configSpec); + await config.load(); + + final auth = viewModelBinding.read(authSpec); + await auth.restoreSession(); + } +} + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + final bootstrap = AppBootstrap(); + await bootstrap.run(); + bootstrap.dispose(); // unbind when done + runApp(MyApp()); +} +``` + +**Background services:** + +```dart +class SyncService with ViewModelBinding { + void start() { + viewModelBinding.watch(syncSpec).startPeriodicSync(); + } + + @override + void onUpdate() { + // react to ViewModel changes without any widget + print('sync state changed'); + } +} +``` + +**Pure Dart tests (no testWidgets needed):** + +```dart +test('counter increments', () { + final binding = ViewModelBinding(); + final vm = binding.watch(counterSpec); + + expect(vm.count, 0); + vm.increment(); + expect(vm.count, 1); + + binding.dispose(); +}); +``` + +You can override `onUpdate()`, `onPause()`, `onResume()` in your class. You can also add custom `PauseProvider`s via `addPauseProvider()`. + +--- + +## ViewModel-to-ViewModel Dependencies + +Inside a ViewModel, `viewModelBinding` is available and resolves via a Dart Zone to the parent binding that created it. This means ViewModel-to-ViewModel access goes through the same binding system — sub-ViewModels are bound to the same root binding, and their lifecycles are managed together. + +```dart +class OrderViewModel with ViewModel { + late final cart = viewModelBinding.read(cartSpec); + late final user = viewModelBinding.read(userSpec); + + double get total => cart.items.fold(0, (sum, i) => sum + i.price); +} +``` + +Reactive dependencies with `watch` (when the dependency notifies, the parent binding's `onUpdate` fires): + +```dart +class DashboardViewModel with ViewModel { + DashboardViewModel() { + viewModelBinding.watch(authSpec); + } +} +``` + +Side-effect dependencies with `listen`: + +```dart +class ChatViewModel with ViewModel { + ChatViewModel() { + viewModelBinding.listenState(authSpec, onChanged: (prev, curr) { + if (curr.isLoggedOut) clearMessages(); + }); + } +} +``` + +When the root widget's binding disposes, it unbinds from all handles — including those created transitively by ViewModel-to-ViewModel dependencies. If no other binding holds those handles, they are disposed as well. + +--- + +## Fine-Grained Reactivity + +### StateViewModelValueWatcher + +Only rebuilds when the selected properties of a `StateViewModel` change: + +```dart +class _MyPageState extends State with ViewModelStateMixin { + // Use read — the ValueWatcher handles its own subscriptions internally + late final vm = viewModelBinding.read(userSpec); + + @override + Widget build(BuildContext context) { + return StateViewModelValueWatcher( + viewModel: vm, + selectors: [(s) => s.name, (s) => s.age], + builder: (state) => Text('${state.name}, age ${state.age}'), + ); + } +} +``` + +Internally, each selector is wrapped into a `listenStateSelect` call on the ViewModel. The widget only rebuilds when at least one selector's output differs from its previous value (compared using `ViewModelConfig.equals` or `==` by default). + +### ObservableValue & ObserverBuilder + +A lightweight reactive value that doesn't require defining a ViewModel class. Under the hood, each `ObservableValue` creates a hidden `StateViewModel` instance in the registry, keyed by `shareKey`: + +```dart +// Declare (can be top-level) +final isDarkMode = ObservableValue(false, shareKey: 'theme-dark'); + +// Update from anywhere +isDarkMode.value = true; + +// Observe in UI +ObserverBuilder( + observable: isDarkMode, + builder: (dark) => Icon(dark ? Icons.dark_mode : Icons.light_mode), +) +``` + +Multi-value variants: + +```dart +ObserverBuilder2( + observable1: counter, + observable2: label, + builder: (count, label) => Text('$label: $count'), +) + +ObserverBuilder3( + observable1: counter, + observable2: label, + observable3: isActive, + builder: (count, label, active) => /* ... */, +) +``` + +If two `ObservableValue` instances share the same `shareKey`, they point to the same underlying StateViewModel — this is how you share reactive values across unrelated parts of the widget tree. + +--- + +## Pause / Resume + +When a widget is not visible, there's no point rebuilding it. The library automatically pauses ViewModel update delivery in three scenarios: + +| Provider | Pauses when | Resumes when | +|---|---|---| +| `PageRoutePauseProvider` | Another route is pushed on top (`didPushNext`) | The covering route pops (`didPopNext`) | +| `TickerModePauseProvider` | `TickerMode` is `false` (e.g., hidden tab in `TabBarView`) | `TickerMode` is `true` again | +| `AppPauseProvider` | App enters `AppLifecycleState.hidden` | App enters `AppLifecycleState.resumed` | + +The `PauseAwareController` aggregates all providers: if **any** provider signals "pause", the binding is paused. While paused, incoming `notifyListeners()` calls set a `_hasMissedUpdates` flag instead of calling `onUpdate()`. When all providers signal "resume", one catch-up `onUpdate()` fires. + +**Setup**: for `PageRoutePauseProvider` to work, register the route observer: + +```dart +MaterialApp( + navigatorObservers: [ViewModel.routeObserver], +) +``` + +You can add custom pause providers: + +```dart +class MyCustomPauseProvider with ViewModelBindingPauseProvider { + void onScreenOff() => pause(); + void onScreenOn() => resume(); +} + +// In initState or any ViewModelBinding host +viewModelBinding.addPauseProvider(myProvider); +``` + +--- + +## Lifecycle Details + +### Reference Counting (Binding) + +Each `InstanceHandle` maintains a `bindingIds` list — this is the reference count. Both `watch` and `read` add the caller's `bindingId` to this list via `bind()`. The difference is only that `watch` also registers a listener. + +``` +read from Binding A → bind('A#123') → bindingIds = ['A#123'] +watch from Binding B → bind('B#456') → bindingIds = ['A#123', 'B#456'] +Binding A disposes → unbind('A#123') → bindingIds = ['B#456'] +Binding B disposes → unbind('B#456') → bindingIds = [] → auto-dispose +``` + +The full lifecycle sequence: + +``` +ViewModelFactory.build() + │ + ▼ + onCreate(arg) ← InstanceHandle created, stored in Store + │ + ▼ + onBind(arg, bindingId) ← a ViewModelBinding binds (via watch or read) + │ + ▼ + [active: notifyListeners(), setState(), etc.] + │ + ▼ + onUnbind(arg, bindingId) ← a ViewModelBinding unbinds (dispose or recycle) + │ + ▼ + (if bindingIds is empty and not aliveForever) + │ + ▼ + onDispose(arg) ← InstanceHandle nullifies the instance + │ + ▼ + dispose() ← your cleanup code runs +``` + +### Resource Cleanup + +Register cleanup callbacks with `addDispose`. They run in order during `onDispose`: + +```dart +class StreamViewModel with ViewModel { + late final StreamSubscription _sub; + + StreamViewModel() { + _sub = someStream.listen((_) => notifyListeners()); + addDispose(() => _sub.cancel()); + } +} +``` + +You can also override `dispose()` directly: + +```dart +@override +void dispose() { + _controller.close(); + super.dispose(); +} +``` + +### ViewModelLifecycle Observer + +Register global observers to monitor all ViewModel lifecycle events (creation, binding, unbinding, disposal): + +```dart +class DebugLifecycle extends ViewModelLifecycle { + @override + void onCreate(ViewModel vm, InstanceArg arg) { + print('[+] ${vm.runtimeType} created (key=${arg.key})'); + } + + @override + void onBind(ViewModel vm, InstanceArg arg, String bindingId) { + print('[~] ${vm.runtimeType} bound by $bindingId'); + } + + @override + void onUnbind(ViewModel vm, InstanceArg arg, String bindingId) { + print('[~] ${vm.runtimeType} unbound by $bindingId'); + } + + @override + void onDispose(ViewModel vm, InstanceArg arg) { + print('[-] ${vm.runtimeType} disposed'); + } +} + +void main() { + ViewModel.initialize(lifecycles: [DebugLifecycle()]); + runApp(MyApp()); +} +``` + +You can also add/remove lifecycle observers dynamically: + +```dart +final remove = ViewModel.addLifecycle(myObserver); +// later +remove(); +``` + +--- + +## Configuration + +Call `ViewModel.initialize()` once at app startup. Subsequent calls are ignored. + +```dart +void main() { + ViewModel.initialize( + config: ViewModelConfig( + // Enable debug logging + isLoggingEnabled: true, + + // Custom state equality (default: identical()) + // Used by StateViewModel.setState and listenStateSelect + equals: (a, b) => a == b, + + // Global error handler for listener errors + onListenerError: (error, stackTrace, context) { + // context is 'notifyListeners' or 'stateListener' + crashReporter.report(error, stackTrace); + }, + + // Global error handler for disposal errors + onDisposeError: (error, stackTrace) { + debugPrint('Disposal error: $error'); + }, + ), + lifecycles: [DebugLifecycle()], + ); + runApp(MyApp()); +} +``` + +**State equality note**: by default `StateViewModel.setState` uses `identical()` to decide whether to skip the update. This means creating a new object with the same field values will still trigger notification. If you configure `equals: (a, b) => a == b`, you need to implement `==` and `hashCode` on your state classes. + +--- + +## Testing + +`ViewModelSpec` supports proxy overrides for testing. Call `setProxy` to replace the builder (and optionally key/tag), and `clearProxy` to restore: + +```dart +final userSpec = ViewModelSpec( + builder: () => UserViewModel(), + key: 'user', +); + +test('with mock', () { + userSpec.setProxy(ViewModelSpec( + builder: () => MockUserViewModel(), + key: 'user', + )); + + final binding = ViewModelBinding(); + final vm = binding.watch(userSpec); + expect(vm, isA()); + + binding.dispose(); + userSpec.clearProxy(); +}); +``` + +Parameterized specs (`ViewModelSpec.arg`, `.arg2`, etc.) also support `setProxy` / `clearProxy`. + +For widget-free testing, just use a plain `ViewModelBinding`: + +```dart +test('interaction test', () { + final binding = ViewModelBinding(); + final cart = binding.watch(cartSpec); + final checkout = binding.watch(checkoutSpec); + + cart.addItem(Item('test')); + expect(checkout.total, greaterThan(0)); + + binding.dispose(); +}); +``` + +--- + +## Code Generation + +The optional `view_model_generator` package auto-generates `ViewModelSpec` definitions from annotations: + +```yaml +dev_dependencies: + build_runner: ^2.0.0 + view_model_generator: ^latest +``` + +```dart +part 'counter_view_model.vm.dart'; + +@GenSpec +class CounterViewModel with ViewModel { + int count = 0; + void increment() => update(() => count++); +} +``` + +```bash +dart run build_runner build +``` + +Generated: +```dart +// counter_view_model.vm.dart +final counterViewModelSpec = ViewModelSpec( + builder: () => CounterViewModel(), +); +``` + +The generator supports ViewModels with up to 4 constructor parameters and produces the appropriate `ViewModelSpec.argN` variant. + +--- + +## DevTools Extension + +The package includes a Flutter DevTools extension for real-time ViewModel inspection. In debug mode, a `DevToolTracker` lifecycle observer is automatically registered, and a `DevToolsService` starts a VM service extension for communication with DevTools. + +To enable, create `devtools_options.yaml` in your project root: + +```yaml +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: + - view_model: true +``` + +--- + + +## view_model vs riverpod + +Both are built on a central registry + dependency injection model, but they differ in API style, instance scope defaults, and lifecycle ergonomics. This comparison assumes common defaults (for example, a single root `ProviderScope`) and focuses on core state-management concerns: state modeling, reactive derivation, instance scope, and lifecycle. It does not treat `Mutations` / `Automatic retry` / `Offline persistence` as primary evaluation criteria. + +### 1. Core Philosophy + +- **Riverpod**: Everything is a global reactive node (Functional & Declarative). + > Its core is building a global directed acyclic graph (DAG). State is a global singleton by default (mounted on `ProviderScope`), and it emphasizes pure functional derivation between states (Derived State). It strongly discourages binding state to a specific Widget instance. +- **view_model**: A classic component-level ViewModel (OOP & Lifecycle-bound). + > Its core is reference-counting-based instance management. It injects capabilities into any class via mixins. By default, state is locally scoped (it lives and dies with the bound Widget lifecycle). It is closer to Android's ViewModel or traditional client-side MVVM. + + +### 2. Coding Style and Implementation + +| Dimension | Riverpod 3.x | view_model 1.0.0 | +| :--- | :--- | :--- | +| **Class model** | Inheritance/codegen-based (`Notifier`, `AsyncNotifier`, `@riverpod`) | **Mixin-based** (`class X with ViewModel`) | +| **Strengths** | Strong provider composition and reactive derivation patterns | Low-intrusion style, multi-mixin flexibility, any Dart class can become a ViewModel | +| **watch/read location** | In `Consumer` widgets, `ref.watch(...)` is commonly used in `build`; it is also used inside provider/notifier `build`. For listeners outside `build` in widgets, `WidgetRef.listenManual(...)` is available | Can be declared as class fields (`late final vm = viewModelBinding.watch(...)`), not forced into `build` | + +**view_model example (field declaration):** + +```dart +class _MyPageState extends State with ViewModelStateMixin { + late final counterVM = viewModelBinding.watch(counterSpec); // initialized once + late final userVM = viewModelBinding.watch(userSpec); + + @override + Widget build(BuildContext context) { + return Text('${counterVM.count}'); // reactive updates + } +} +``` + +### 3. Instance Scope (Most Important Difference) + +- **Riverpod**: instances are scoped by `ProviderContainer`. In most apps, a single root `ProviderScope` means one shared provider instance app-wide. Isolation is explicit via nested `ProviderScope`, overrides, or families. +- **view_model**: default is **per-binding singleton**. Repeated `watch/read` calls inside the same `ViewModelBinding` share one instance; different pages/bindings are isolated by default. Global sharing is explicit via key: + +```dart +final globalAuthSpec = ViewModelSpec( + builder: () => AuthViewModel(), + key: 'global-auth', + aliveForever: true, // optional: keep alive +); +``` diff --git a/.agents/skills/view_model/references/README_FULL_ZH.md b/.agents/skills/view_model/references/README_FULL_ZH.md deleted file mode 120000 index a542467..0000000 --- a/.agents/skills/view_model/references/README_FULL_ZH.md +++ /dev/null @@ -1 +0,0 @@ -../../../../packages/view_model/README_ZH.md \ No newline at end of file diff --git a/.agents/skills/view_model/references/README_FULL_ZH.md b/.agents/skills/view_model/references/README_FULL_ZH.md new file mode 100644 index 0000000..72d2fac --- /dev/null +++ b/.agents/skills/view_model/references/README_FULL_ZH.md @@ -0,0 +1,253 @@ +# view_model + +[![pub package](https://img.shields.io/pub/v/view_model.svg)](https://pub.dev/packages/view_model) + +[English](./README.md) + +**一切皆 ViewModel。** + +这是一个为 Flutter 量身定制的状态管理框架。它基于“类型键(Type-keyed)实例注册表”构建,并自带“自动引用计数”的生命周期管理系统。无需繁琐的初始化,真正做到**按需创建,自动销毁**。 + +```yaml +dependencies: + view_model: ^1.0.0 +``` + +## 🤖 Skill 安装 + +```bash +npx skills add https://github.com/lwj1994/flutter_view_model --skill view_model +``` + +--- + +## 📖 核心目录 + +- [🌟 为什么选择 view_model?](#-为什么选择-view_model) +- [🏗️ 三层架构设计](#️-三层架构设计) +- [🧩 核心武器:两大 Mixin](#-核心武器两大-mixin) +- [🚀 3 分钟快速上手](#-3-分钟快速上手) +- [📖 ViewModel 深度探索](#-viewmodel-深度探索) +- [⚙️ ViewModelSpec:声明式定义](#-viewmodelspec声明式定义) +- [🎨 Widget 集成指北](#-widget-集成指北) +- [🔗 viewModelBinding 核心接口](#-viewmodelbinding-核心接口) +- [🤝 实例共享与共享策略](#-实例共享与共享策略) +- [🏗️ 在任意非 Widget 类中使用](#️-在任意非-widget-类中使用) +- [🔄 ViewModel 间的强力联动](#-viewmodel-间的强力联动) +- [⚡ 细粒度更新(性能优化)](#-细粒度更新性能优化) +- [💤 智能 暂停 / 恢复 机制](#-智能-暂停--恢复-机制) +- [♻️ 生命周期细节与资源回收](#-生命周期细节与资源回收) +- [🛠️ 全局配置与调试](#-全局配置与调试) +- [🧪 测试方案](#-测试方案) +- [🤖 代码自动生成](#-代码自动生成) +- [🔍 DevTools 视觉化窗口](#-devtools-视觉化窗口) +- [view_model vs riverpod](#view_model-vs-riverpod) + +--- + +## 🌟 为什么选择 view_model? + +在 Flutter 状态管理的丛林里,你可能被 `Provider` 的 `context` 限制搞晕,或者被 `Riverpod` 复杂的 Provider 依赖图劝退。**view_model 的设计哲学是:直觉化、Dart 原生感、零痛苦。** + +* **真正的自动生命周期**:ViewModel 的存活完全取决于是否有 Widget 在用它。没人用了?自动销毁,一行代码都不用写。 +* **跨越 BuildContext 的自由**:不仅仅在 Widget 里,在后台服务、启动逻辑、纯 Dart 类中都能享用同样的 ViewModel 管理逻辑。 +* **自带“防卡顿”光环**:当页面进入后台或被上层路由覆盖时,系统会自动暂停通知,仅在页面恢复时触发一次追赶式刷新。 +* **极致的代码生成**:配合 `@GenSpec` 注解,样板代码归零。 + +--- + + + +## 🏗️ 三层架构设计 + +为了实现极致的灵活性,我们将系统拆分为三层: + +1. **消费者层 (Widget/Consumer)**: 提供 `ViewModelStateMixin`、`ViewModelBuilder` 等贴心的工具。 +2. **绑定层 (ViewModelBinding)**: 核心桥梁。它负责记录谁(哪个 BindingID)在使用哪个 ViewModel。它还掌管着 Zone 依赖注入和 暂停/恢复 状态。 +3. **实例管理层 (InstanceManager)**: 一个高效的底盘。它维护着一个实例池,并根据引用计数(BindingIDs 是否为空)决定实例的死活。 + +--- + +## 🧩 核心武器:两大 Mixin + +这是本库的灵魂。只要能掌握这两个 Mixin,你就掌握了全部。 + +### 1. `with ViewModel` — 赋予“生命” +将它混入任意类,这个类就变成了**受管实例**。它拥有生命周期钩子(`onCreate`, `onDispose` 等),能够发射通知,还能通过 `viewModelBinding` 直接读取其他依赖项。 + +```dart +class UserRepository with ViewModel { /* 业务逻辑 */ } +``` + +### 2. `with ViewModelBinding` — 获取“力量” +将它混入类(不限 Widget),这个类就变成了**管理员**。它拥有了访问注册表的能力。你可以用它来 `watch` 或 `read` 任何 ViewModel。`ViewModelStateMixin` 本质上就是它的一个 Widget 封装版。 + +```dart +class AppBootstrap with ViewModelBinding { + Future init() async { + // 跨越 context 自由读取 + await viewModelBinding.read(configSpec).load(); + } +} +``` + +--- + +## 🚀 3 分钟快速上手 + +```dart +import 'package:view_model/view_model.dart'; + +// 1. 写逻辑 +class CounterViewModel with ViewModel { + int count = 0; + void increment() => update(() => count++); // update 会自动帮你触发 UI 刷新 +} + +// 2. 定规格 (Spec) +final counterSpec = ViewModelSpec( + builder: () => CounterViewModel(), +); + +// 3. 混入 Mixin 即可使用 +class CounterPage extends StatefulWidget { + @override + State createState() => _CounterPageState(); +} + +class _CounterPageState extends State with ViewModelStateMixin { + // watch 会建立连接:ViewModel 变了,当前 Widget 自动刷新 + late final vm = viewModelBinding.watch(counterSpec); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: vm.increment, + child: Text('Count: ${vm.count}'), + ); + } +} +``` + +--- + +## 📖 ViewModel 深度探索 + +### StateViewModel(强状态版) +如果你追求不可变状态(配合 `Freezed` 简直完美),它是你的不二之选。它能记录 `previousState`,并支持字段级的差异化监听(`listenStateSelect`)。 + +```dart +class UserViewModel extends StateViewModel { + UserViewModel() : super(state: const UserState()); + + void updateName(String name) { + setState(state.copyWith(name: name)); // 自动触发 equals 比较 + } +} +``` + +### 资源快捷回收 +在构造函数里使用 `addDispose()`,确保资源不遗忘: + +```dart +StreamViewModel() { + final sub = stream.listen((_) => notifyListeners()); + addDispose(() => sub.cancel()); // 跟着 VM 一起死,优雅! +} +``` + +--- + +## 🔗 viewModelBinding 核心接口 + +当你拥有了 `viewModelBinding` 访问器,你就拥有了以下超能力: + +| 方法 | 使用场景 | 特点 | +| :--- | :--- | :--- | +| **`watch(spec)`** | 在 Widget 的 `build` 或逻辑中 | **响应式**:VM 变化会触发 UI 刷新。若 VM 不存在则创建。 | +| **`read(spec)`** | 事件回调、只需调用方法时 | **非响应式**:仅读取,不监听。若 VM 不存在则创建。 | +| **`watchCached(key/tag)`** | 寻找现有的单例或共享 VM | 如果缓存里没找到,它会抛出异常。 | +| **`listenStateSelect(...)`**| 针对性监听某个字段 | 例如:只有 `user.age` 变了才弹窗,别的字段变了不理。 | +| **`recycle(vm)`** | 强制销毁重来 | 比如:退出登录时,一键回收所有用户相关的 VM。 | + +--- + +## 💤 智能 暂停 / 恢复 机制 + +这是 `view_model` 的独门绝技。 +* **不浪费一分性能**:当你的页面处于“不可见”状态(被覆盖、Tab 被切走、应用退后台),哪怕 ViewModel 疯狂更新,你的 Widget 也**完全不会 rebuild**。 +* **丝滑追赶**:当你重新看到页面的一瞬间,系统会帮你做一次补报刷新,确保数据是最新的。 + +> **提示**:为了让路由感知生效,别忘了在 `MaterialApp` 里加上 `ViewModel.routeObserver`。 + +--- + +## 🤖 代码自动生成 + +厌倦了手写 `ViewModelSpec`?没关系,交给 `view_model_generator`。 + +```dart +@GenSpec(key: 'global_counter', aliveForever: true) // 一键定义单例 +class CounterViewModel with ViewModel { ... } +``` + +一行命令,生成的 Spec 自动帮你搞定参数注入和单例配置。 + +--- + +## 🔍 DevTools 视觉化窗口 + +我们为你准备了强大的 **DevTools 扩展**。在调试模式下,打开 Flutter DevTools: +* **可视化依赖图**:一眼看清哪个 Widget 绑定了哪个 ViewModel,谁又依赖了谁。 +* **状态实时监控**:在不需要打印日志的情况下,直接在浏览器里检视所有存活实例的数据。 + +--- + +## view_model vs riverpod + +两者底层都基于“中央注册表 + 依赖注入”的思想,但设计哲学、API 风格、实例管理机制不同。以下对比基于默认配置与常见用法(如单根 `ProviderScope`),仅讨论状态管理核心:状态建模、依赖派生、实例作用域与生命周期,不将 `Mutations` / `Automatic retry` / `Offline persistence` 作为主要评价项。 + +### 1. 核心设计哲学 + +* Riverpod:一切皆是全局响应式节点(Functional & Declarative) +> 核心是构建一个全局的有向无环图(DAG)。状态默认是全局单例的(挂载在 ProviderScope 上),强调状态与状态之间的纯函数推导(Derived State)。它非常排斥将状态与特定的 Widget 实例强绑定。 +* view_model:经典的组件级视图模型(OOP & Lifecycle-bound) +> 核心是基于引用计数(Reference Counting)的实例管理。它通过 Mixin 将能力注入到任意类中,默认情况下,状态是局部作用域的(与绑定的 Widget 生命周期共存亡)。它更像 Android 的 ViewModel 或传统客户端开发中的 MVVM 模式。 + +### 2. 代码风格与实现方式 + +| 维度 | Riverpod 3.x | view_model 1.0.0 | +| :--- | :--- | :--- | +| **类实现方式** | 继承/codegen 为主(`Notifier`/`AsyncNotifier`/`@riverpod`) | **纯 mixin 方式**(`class X with ViewModel`) | +| **优点** | Provider 组合与响应式派生能力强 | 零侵入、可多 mixin 叠加、任意类可直接成为 ViewModel | +| **watch/read 位置** | 在 `Consumer` 的 `build` 中常用 `ref.watch(...)`;在 Provider/Notifier 的 `build` 中也可 `ref.watch(...)`;在 Widget 中若需在 `build` 外监听,可用 `WidgetRef.listenManual(...)` | 可直接声明为类字段(如 `late final vm = viewModelBinding.watch(...)`),不强制写在 `build` 内 | + +**view_model 示例(字段声明)**: + +```dart +class _MyPageState extends State with ViewModelStateMixin { + late final counterVM = viewModelBinding.watch(counterSpec); // 只初始化一次 + late final userVM = viewModelBinding.watch(userSpec); + + @override + Widget build(BuildContext context) { + return Text('${counterVM.count}'); // 自动响应式 + } +} +``` + +### 3. 实例获取与作用域(核心差异) + +- **Riverpod**:实例按 `ProviderContainer` 隔离。常见项目只有一个根 `ProviderScope`,因此同一 Provider 在整个 App 内通常共享一份状态;需要隔离时通过局部 `ProviderScope`/override/family 控制。 +- **view_model**:默认 **per-binding 单例**。同一 `ViewModelBinding` 内多次 `watch/read` 共享同实例;不同页面(不同 binding)默认隔离。需要全局共享时显式声明 key: + +```dart +final globalAuthSpec = ViewModelSpec( + builder: () => AuthViewModel(), + key: 'global-auth', + aliveForever: true, // 可选:常驻 +); +``` + + +---