Build
Pub
Codecov

Binder

Logo

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

愿景

与其他状态管理模式一样,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 文件中找到。


中级用法

Select

状态可以是简单的类型,如 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);
  }
}

Consumer

如果您只想重建您的 widget 树的一部分,而不想创建新的 widget,可以使用 Consumer widget。
此 widget 可以接受一个可观察对象(StateRef 甚至是 StateRef 的选定状态)。

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

  @override
  Widget build(BuildContext context) {
    return AppBar(
      title: Consumer(
        watchable:
            userRef.select((user) => '${user.firstName} ${user.lastName}'),
        builder: (context, String fullName, child) => Text(fullName),
      ),
    );
  }
}

LogicLoader

如果您想从 widget 端触发逻辑的异步数据加载,LogicLoader 是您需要的 widget!

要使用它,您必须在需要加载数据的逻辑中实现 Loadable 接口。
然后您必须覆盖 load 方法并在其中获取数据。

final usersRef = StateRef(const <User>[]);
final loadingRef = StateRef(false);

final usersLogicRef = LogicRef((scope) => UsersLogic(scope));

class UsersLogic with Logic implements Loadable {
  const UsersLogic(this.scope);

  @override
  final Scope scope;

  UsersRepository get _usersRepository => use(usersRepositoryRef);

  @override
  Future<void> load() async {
    write(loadingRef, true);
    final users = await _usersRepository.fetchAll();
    write(usersRef, users);
    write(loadingRef, false);
  }
}

从 widget 端,您将需要使用 LogicLoader 并提供您想要加载的逻辑引用

class Home extends StatelessWidget {
  const Home({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return LogicLoader(
      refs: [usersLogicRef],
      child: const UsersView(),
    );
  }
}

您可以监视子树中的状态,以便在数据获取时显示进度指示器

class UsersView extends StatelessWidget {
  const UsersView({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final loading = context.watch(loadingRef);
    if (loading) {
      return const CircularProgressIndicator();
    }

    // Display the users in a list when have been fetched.
    final users = context.watch(usersRef);
    return ListView(...);
  }
}

或者,您可以使用 builder 参数来实现相同的目标

class Home extends StatelessWidget {
  const Home({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return LogicLoader(
      refs: [usersLogicRef],
      builder: (context, loading, child) {
        if (loading) {
          return const CircularProgressIndicator();
        }

        // Display the users in a list when have been fetched.
        final users = context.watch(usersRef);
        return ListView();
      },
    );
  }
}

覆盖

在某些情况下,能够覆盖 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);
  }
}

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

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

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

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

更好的方法是在每个计数器 widget 之上创建一个 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 文件中找到。

在测试中模拟值

假设您的应用程序中有一个 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 实例而不是真实实例。


高级用法

计算值

您可能会遇到这样的情况:不同的 widget 对一个由不同状态计算得出的派生状态感兴趣。在这种情况下,有一个全局定义此派生状态的方法会很有帮助,这样您就不必跨 widget 复制/粘贴此逻辑。
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 一样,您可以在 widget 的构建方法中监视 Computed

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

注意:上面代码片段的全部代码可在 example 文件中找到。

观察者

您可能希望观察状态何时发生更改并据此执行某些操作(例如,记录状态更改)。
为此,您需要实现 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 widget。

例如,在身份验证视图中,当身份验证失败时,您可能希望显示一个提醒对话框。
为此,在逻辑组件中,您可以设置一个状态来指示身份验证是否成功,并在视图中有一个 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 回调。在此回调中,我们只需验证状态的类型即可确定是显示提醒对话框还是不显示。

Dart Dev Tools

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


代码片段

您可以在 vscode 上找到代码片段:snippets

赞助

我利用业余时间开发我的软件包,但我没有足够的时间。如果此软件包或我创建的任何其他软件包对您有帮助,请考虑赞助我,以便我能有时间阅读 issues、修复 bug、合并 pull requests 并为这些软件包添加功能。

贡献

欢迎您为本项目做出贡献。

如果您发现 bug 或想要一项功能,但不知道如何修复/实现,请提交一个 issue
如果您修复了一个 bug 或实现了一项功能,请发送一个 pull request

GitHub

https://github.com/letsar/binder