binder

一种轻量级但功能强大的方式,可将您的应用程序状态与业务逻辑绑定。

愿景

与其他状态管理模式一样,binder 旨在将应用程序状态与更新它的业务逻辑分离开来。

data_flow

我们可以将整个应用程序状态看作是许多微小状态的集合。每个状态都独立于其他状态。
视图可能只关注某些特定状态,并且必须使用逻辑组件来更新它们。

入门

安装

在您的 flutter 项目的 pubspec.yaml 文件中,添加以下依赖项

dependencies:
  binder: <latest_version>

在您的库中添加以下导入

import 'package:binder/binder.dart';

基本用法

任何状态都必须通过 StateRef 及其初始值来声明。

final counterRef = StateRef(0);

注意:状态应该是不可变的,因此更新它的唯一方法是通过此包提供的函数。

任何逻辑组件都必须通过 LogicRef 及其用于创建它的函数来声明。

final counterViewLogicRef = LogicRef((scope) => CounterViewLogic(scope));

scope 参数随后可用于逻辑来修改状态和访问其他逻辑组件。

注意:您可以将 StateRefLogicRef 对象声明为公共全局变量,如果您希望它们能从您应用的其余部分访问的话。

如果我们希望我们的 CounterViewLogic 能够递增我们的计数器状态,我们可能会这样写:

/// A business logic component can apply the [Logic] mixin to have access to
/// useful methods, such as `write` and `read`.
class CounterViewLogic with Logic {
  const CounterViewLogic(this.scope);

  /// This is the object which is able to interact with other components.
  @override
  final Scope scope;

  /// We can use the [write] method to mutate the state referenced by a
  /// [StateRef] and [read] to obtain its current state.
  void increment() => write(counterRef, read(counterRef) + 1);
}

为了将所有这些内容绑定到 Flutter 应用中,我们必须使用一个名为 BinderScope 的专用小部件。
该小部件负责保存应用程序状态的一部分并提供逻辑组件。
您通常会在 MaterialApp 小部件之上创建此小部件。

BinderScope(
  child: MaterialApp(
    home: CounterView(),
  ),
);

BinderScope 下的任何小部件中,您都可以调用 BuildContext 上的扩展方法,将视图绑定到应用程序状态和业务逻辑组件。


class CounterView extends StatelessWidget {
  const CounterView({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    /// We call the [watch] extension method on a [StateRef] to rebuild the
    /// widget when the underlaying state changes.
    final counter = context.watch(counterRef);

    return Scaffold(
      appBar: AppBar(title: const Text('Binder example')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text('You have pushed the button this many times:'),
            Text('$counter', style: Theme.of(context).textTheme.headline4),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        /// We call the [use] extension method to get a business logic component
        /// and call the appropriate method.
        onPressed: () => context.use(counterViewLogicRef).increment(),
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

以上是您进行基本使用所需了解的所有内容。

注意:上述代码段的全部代码可在 [example][example_main] 文件中找到。


中级用法

选择

状态可以是简单的类型,如 intString,但也可以更复杂,例如:

class User {
  const User(this.firstName, this.lastName, this.score);

  final String firstName;
  final String lastName;
  final int score;
}

应用程序的某些视图只对全局状态的某些部分感兴趣。在这些情况下,只选择对这些视图有用的状态部分可能更有效。

例如,如果我们有一个应用程序栏标题,它只负责显示 User 的全名,并且我们不希望它在分数每次发生变化时都重建,我们将使用 StateRefselect 方法来仅监视状态的子部分。

class AppBarTitle extends StatelessWidget {
  const AppBarTitle({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final fullName = context.watch(
      userRef.select((user) => '${user.firstName} ${user.lastName}'),
    );
    return Text(fullName);
  }
}

覆盖

在某些情况下,能够覆盖 StateRef 的初始状态或 LogicRef 的工厂可能很有用。

  • 当我们希望某个子树在同一引用下拥有自己的状态/逻辑时。
  • 用于在测试中模拟值。
在不同的作用域下重用引用。

假设我们要创建一个应用程序,用户可以创建计数器并查看所有计数器的总和。

counters

我们可以通过让全局状态成为一个整数列表,并有一个用于添加计数器和递增它们的业务逻辑组件来实现。

final countersRef = StateRef(const <int>[]);

final countersLogic = LogicRef((scope) => CountersLogic(scope));

class CountersLogic with Logic {
  const CountersLogic(this.scope);

  @override
  final Scope scope;

  void addCounter() {
    write(countersRef, read(countersRef).toList()..add(0));
  }

  void increment(int index) {
    final counters = read(countersRef).toList();
    counters[index]++;
    write(countersRef, counters);
  }
}

然后,我们可以在小部件中使用 select 扩展方法来监视此列表的总和。

final sum = context.watch(countersRef.select(
  (counters) => counters.fold<int>(0, (a, b) => a + b),
));

现在,为了创建计数器视图,我们可以在此视图的构造函数中包含一个 index 参数。
这有一些缺点

  • 如果子小部件需要访问此索引,我们将需要将 index 传递给树中的每个小部件,直到我们的子小部件。
  • 我们不能再使用 const 关键字了。

更好的方法是在每个计数器小部件之上创建 BinderScope。然后,我们将配置此 BinderScope 来覆盖其后代的状态 StateRef,并提供不同的初始值。

任何 StateRefLogicRef 都可以在 BinderScope 中被覆盖。在查找当前状态时,后代将获取 BinderScope 中第一个被覆盖的引用的状态,直到根 BinderScope
可以这样写:

final indexRef = StateRef(0);

class HomeView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final countersCount =
        context.watch(countersRef.select((counters) => counters.length));

    return Scaffold(
     ...
      child: GridView(
        ...
        children: [
          for (int i = 0; i < countersCount; i++)
            BinderScope(
              overrides: [indexRef.overrideWith(i)],
              child: const CounterView(),
            ),
        ],
      ),
     ...
    );
  }
}

BinderScope 构造函数有一个 overrides 参数,可以从 StateRefLogicRef 实例上的 overrideWith 方法提供。

注意:上述代码段的全部代码可在 [example][example_main_overrides] 文件中找到。

模拟测试中的值

假设您的应用程序中有一个 API 客户端。

final apiClientRef = LogicRef((scope) => ApiClient());

如果您想在测试期间提供一个模拟的,您可以这样做:

testWidgets('Test your view by mocking the api client', (tester) async {
  final mockApiClient = MockApiClient();

  // Build our app and trigger a frame.
  await tester.pumpWidget(
    BinderScope(
      overrides: [apiClientRef.overrideWith((scope) => mockApiClient)],
      child: const MyApp(),
    ),
  );

  expect(...);
});

每当您的应用程序中使用 apiClientRef 时,都会使用 MockApiClient 实例而不是实际实例。


高级用法

计算值

您可能会遇到这样的情况:不同的视图对由不同状态计算得出的派生状态感兴趣。在这种情况下,能够全局定义此派生状态可能会有所帮助,这样您就不必在视图之间复制代码/粘贴此逻辑。
Binder 提供了一个 Computed 类来帮助您处理这种情况。

假设您有一个由 productsRef 引用的产品列表,每个产品都有价格,您可以根据价格范围(由 minPriceRefmaxPriceRef 引用)过滤这些产品。

然后,您可以定义以下 Computed 实例:

final filteredProductsRef = Computed((watch) {
  final products = watch(productsRef);
  final minPrice = watch(minPriceRef);
  final maxPrice = watch(maxPriceRef);

  return products
      .where((p) => p.price >= minPrice && p.price <= maxPrice)
      .toList();
});

StateRef 一样,您可以在小部件的构建方法中监视 Computed

@override
Widget build(BuildContext context) {
  final filteredProducts = context.watch(filteredProductsRef);
  ...
  // Do something with `filteredProducts`.
}

注意:上述代码段的全部代码可在 [example][example_main_computed] 文件中找到。

观察者

您可能希望在状态更改时进行观察并采取相应操作(例如,记录状态更改)。
为此,您需要实现 StateObserver 接口(或使用 DelegatingStateObserver),并将实例提供给 BinderScope 构造函数的 observers 参数。

bool onStateUpdated<T>(StateRef<T> ref, T oldState, T newState, Object action) {
  logs.add(
    '[${ref.key.name}#$action] changed from $oldState to $newState',
  );

  // Indicates whether this observer handled the changes.
  // If true, then other observers are not called.
  return true;
}
...
BinderScope(
  observers: [DelegatingStateObserver(onStateUpdated)],
  child: const SubTree(),
);

撤销/重做

Binder 提供了一种内置方法来在状态更改的时间线上移动。
要能够撤销/重做状态更改,您必须在树中添加一个 MementoScope
MementoScope 将能够观察它下面的所有更改。

return MementoScope(
  child: Builder(builder: (context) {
    return MaterialApp(
      home: const MyHomePage(),
    );
  }),
);

然后,在位于 MementoScope 下方的业务逻辑中,您将能够调用 undo/redo 方法。

注意:如果您未在调用 undo/redo 的业务逻辑上方提供 MementoScope,您将在运行时收到一个 AssertionError。

可 Dispose

在某些情况下,您可能希望在托管业务逻辑组件的 BinderScope 被 Dispose 之前执行某些操作。要有机会做到这一点,您的逻辑需要实现 Disposable 接口。

class MyLogic with Logic implements Disposable {
  void dispose(){
    // Do some stuff before this logic go away.
  }
}

StateListener

如果您想导航到另一个屏幕或在状态更改时显示对话框,您可以使用 StateListener 小部件。

例如,在身份验证视图中,您可能希望在身份验证失败时显示一个警告对话框。
为此,在逻辑组件中,您可以设置一个指示身份验证是否成功的状态,并在视图中有一个 StateListener 来响应这些状态更改。

return StateListener(
  watchable: authenticationResultRef,
  onStateChanged: (context, AuthenticationResult state) {
    if (state is AuthenticationFailure) {
      showDialog<void>(
        context: context,
        builder: (context) {
          return AlertDialog(
            title: const Text('Error'),
            content: const Text('Authentication failed'),
            actions: [
              TextButton(
                onPressed: () => Navigator.of(context).pop(),
                child: const Text('Ok'),
              ),
            ],
          );
        },
      );
    } else {
      Navigator.of(context).pushReplacementNamed(route_names.home);
    }
  },
  child: child,
);

在上面的代码片段中,每当由 authenticationResultRef 引用的状态发生更改时,都会触发 onStateChanged 回调。在此回调中,我们仅检查状态的类型以确定是否需要显示警告对话框。

DartDev Tools

Binder 旨在简化您的应用程序调试。通过使用 DartDev tools,您将能够检查由任何 BinderScope 托管的当前状态。


GitHub

https://github.com/letsar/binder