查看原文
其他

重走Flutter状态管理之路—Riverpod进阶篇

徐宜生 群英传 2022-12-21

点击上方蓝字关注我,知识会给你力量


前面一篇文章,我们了解了如何正确的去读取状态值,这一篇,我们来了解下不同的Provider都有哪些使用场景。这篇文章,我们将真正的深入了解,如何在不同的场景下,选择合适的种类的Provider,以及这些不同类型的Provider,都有哪些作用。

不同类型的Provider

Provider有多种类型的变种,可以用于多种不同的使用场景。

在所有这些Provider中,有时很难理解何时使用一种Provider类型而不是另一种。使用下面的表格,选择一个适合你想提供给Widget树的Provider。

Provider TypeProvider Create FunctionExample Use Case
ProviderReturns any typeA service class / computed property (filtered list)
StateProviderReturns any typeA filter condition / simple state object
FutureProviderReturns a Future of any typeA result from an API call
StreamProviderReturns a Stream of any typeA stream of results from an API
StateNotifierProviderReturns a subclass of StateNotifierA complex state object that is immutable except through an interface
ChangeNotifierProviderReturns a subclass of ChangeNotifierA complex state object that requires mutability

虽然所有的Provider都有他们的目的,但ChangeNotifierProviders不被推荐用于可扩展的应用程序,因为它存在可变的状态问题。它存在于flutter_riverpod包中,以提供一个简单的从package:provider的迁移组件,并允许一些flutter特定的使用情况,如与一些Navigator 2包的集成。

Provider

Provider是所有Providers中最基本的。它返回了一个Value... 仅此而已。

Provider通常用于下面的场景。

  • 缓存计算后的值
  • 将一个值暴露给其他Provider(比如Repository/HttpClient)
  • 提供了一个可供测试的覆写Provider
  • 通过不使用select,来减少Provider/widget的重建

通过Provider来对计算值进行缓存

当与ref.watch结合时,Provider是一个强大的工具,用于缓存同步操作。

一个典型的例子是过滤一个todos的列表。由于过滤一个列表的成本较高,我们最好不要在我们的应用程序每次需要重新渲染的时候,就过滤一次我们的todos列表。在这种情况下,我们可以使用Provider来为我们做过滤工作。

为此,假设我们的应用程序有一个现有的StateNotifierProvider,它管理一个todos列表。

class Todo {
  Todo(this.description, this.isCompleted);
  final bool isCompleted;
  final String description;
}

class TodosNotifier extends StateNotifier<List<Todo>> {
  TodosNotifier() : super([]);

  void addTodo(Todo todo) {
    state = [...state, todo];
  }
  // TODO add other methods, such as "removeTodo", ...
}

final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) {
  return TodosNotifier();
});

在这里,我们可以使用Provider来管理一个过滤后的todos列表,只显示已完成的todos。

final completedTodosProvider = Provider<List<Todo>>((ref) {
  // We obtain the list of all todos from the todosProvider
  final todos = ref.watch(todosProvider);

  // we return only the completed todos
  return todos.where((todo) => todo.isCompleted).toList();
});

有了这段代码,我们的用户界面现在能够通过监听 completedTodosProvider来显示已完成的todos列表。

Consumer(builder: (context, ref, child) {
  final completedTodos = ref.watch(completedTodosProvider);
  // TODO show the todos using a ListView/GridView/...
});

有趣的是,现在的过滤后的列表是被缓存的。这意味着在添加/删除/更新todos之前,已完成的todos列表不会被重新计算,即使我们多次读取已完成的todos列表。

请注意,当todos列表发生变化时,我们不需要手动使缓存失效。由于有了ref.watch,Provider能够自动知道何时必须重新计算结果。

通过Provider来减少provider/widget的重建

Provider的一个独特之处在于,即使Provider被重新计算(通常在使用ref.watch时),它也不会更新监听它的widgets/providers,除非其值发生了变化。

一个真实的例子是启用/禁用一个分页视图的上一个/下一个按钮。

stepper example

在我们的案例中,我们将特别关注 "上一页 "按钮。这种按钮的一个普通的实现,是一个获得当前页面索引的Widget,如果该索引等于0,我们将禁用该按钮。

这段代码可以是这样。

final pageIndexProvider = StateProvider<int>((ref) => 0);

class PreviousButton extends ConsumerWidget {
  const PreviousButton({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // if not on first page, the previous button is active
    final canGoToPreviousPage = ref.watch(pageIndexProvider) != 0;

    void goToPreviousPage() {
      ref.read(pageIndexProvider.notifier).update((state) => state - 1);
    }

    return ElevatedButton(
      onPressed: canGoToPreviousPage ? null : goToPreviousPage,
      child: const Text('previous'),
    );
  }
}

这段代码的问题是,每当我们改变当前页面时,"上一页 "按钮就会重新Build。在理想的世界里,我们希望这个按钮只在激活和停用之间变化时才重新build。

这里问题的根源在于,我们正在计算用户是否被允许在 "上一页 "按钮中直接转到上一页。

解决这个问题的方法是把这个逻辑从widget中提取出来,放到一个Provider中。

final pageIndexProvider = StateProvider<int>((ref) => 0);

// A provider which computes whether the user is allowed to go to the previous page
final canGoToPreviousPageProvider = Provider<bool>((ref) {
  return ref.watch(pageIndexProvider) != 0;
});

class PreviousButton extends ConsumerWidget {
  const PreviousButton({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // We are now watching our new Provider
    // Our widget is no longer calculating whether we can go to the previous page.
    final canGoToPreviousPage = ref.watch(canGoToPreviousPageProvider);

    void goToPreviousPage() {
      ref.read(pageIndexProvider.notifier).update((state) => state - 1);
    }

    return ElevatedButton(
      onPressed: canGoToPreviousPage ? null : goToPreviousPage,
      child: const Text('previous'),
    );
  }
}

通过这个小的重构,我们的PreviousButton Widget将不会在页面索引改变时重建,这都要归功于Provider的缓存作用。

从现在开始,当页面索引改变时,我们的canGoToPreviousPageProviderProvider将被重新计算。但是如果Provider暴露的值没有变化,那么PreviousButton将不会重建。

这个变化既提高了我们的按钮的性能,又有一个有趣的好处,就是把逻辑提取到我们的Widget之外。

StateProvider

我们再来看下StateProvider,它是一个公开了修改其状态的方法的Provider。它是StateNotifierProvider的简化版,旨在避免为非常简单的用例编写一个StateNotifier类。

StateProvider的存在主要是为了允许用户对简单的变量进行修改。一个StateProvider所维护的状态通常是下面几种。

  • 一个枚举,比如一个filter,用来做筛选
  • 一个字符串,通常是一些固定的文本,可以借助family关键字来做Switch
  • 一个布尔值,用于checkbox这类的状态切换
  • 一个数字,用于分页或者Pager的Index

而下面这些场景,就不适合使用StateProvider。

  • 你的状态中包含对校验逻辑
  • 你的状态是一个复杂的对象,比如一个自定义类,一个List、Map等
  • 状态的修改逻辑比较复杂

对于这些场景,你可以考虑使用StateNotifierProvider代替,并创建一个StateNotifier类。

虽然StateNotifierProvider的模板代码会多一些,但拥有一个自定义的StateNotifier类对于项目的长期可维护性至关重要--因为它将你的状态的业务逻辑集中在一个地方。

由此,我们可以了解,Riverpod最合适的场景,就是「单一状态值的管理」。例如,PageView的切换Index、ListView的切换Index,或者是CheckBox、dropdown的内容改变监听,这些是非常适合用StateProvider的。

一个filter的示例

官方给出了一个dropdown的例子,用来演示如何根据filter来修改列表的排序。

StateProvider在现实世界中的一个使用案例是管理简单表单组件的状态,如dropdown/text fields/checkboxes。特别是,我们将看到如何使用StateProvider来实现一个允许改变产品列表排序方式的dropdown。为了简单起见,我们将获得的产品列表将直接在应用程序中建立,其内容如下。

class Product {
  Product({required this.name, required this.price});

  final String name;
  final double price;
}

final _products = [
  Product(name: 'iPhone', price: 999),
  Product(name: 'cookie', price: 2),
  Product(name: 'ps5', price: 500),
];

final productsProvider = Provider<List<Product>>((ref) {
  return _products;
});

在现实世界的应用中,这个列表通常是通过使用FutureProvider进行网络请求来获得的,然后,用户界面可以显示产品列表,就像下面这样。

Widget build(BuildContext context, WidgetRef ref) {
  final products = ref.watch(productsProvider);
  return Scaffold(
    body: ListView.builder(
      itemCount: products.length,
      itemBuilder: (context, index) {
        final product = products[index];
        return ListTile(
          title: Text(product.name),
          subtitle: Text('${product.price} \$'),
        );
      },
    ),
  );
}

由于这里是写死了products,所以使用Provider来作为数据Provider,是一个很好的选择。

现在我们已经完成了基础框架,我们可以添加一个dropdown,这将允许我们通过价格或名称来过滤产品。为此,我们将使用DropDownButton。

// An enum representing the filter type
enum ProductSortType {
  name,
  price,
}

Widget build(BuildContext context, WidgetRef ref) {
  final products = ref.watch(productsProvider);
  return Scaffold(
    appBar: AppBar(
      title: const Text('Products'),
      actions: [
        DropdownButton<ProductSortType>(
          value: ProductSortType.price,
          onChanged: (value) {},
          items: const [
            DropdownMenuItem(
              value: ProductSortType.name,
              child: Icon(Icons.sort_by_alpha),
            ),
            DropdownMenuItem(
              value: ProductSortType.price,
              child: Icon(Icons.sort),
            ),
          ],
        ),
      ],
    ),
    body: ListView.builder(
      // ... 
    ),
  );
}

现在我们有了一个dropdown,让我们创建一个StateProvider并将dropdown的状态与我们的StateProvider同步。首先,让我们创建StateProvider。

final productSortTypeProvider = StateProvider<ProductSortType>(
  // We return the default sort type, here name.
  (ref) => ProductSortType.name,
);

然后我们可以通过下面这个方式,将StateProvider和dropdown联系起来。

DropdownButton<ProductSortType>(
  // When the sort type changes, this will rebuild the dropdown
  // to update the icon shown.
  value: ref.watch(productSortTypeProvider),
  // When the user interacts with the dropdown, we update the provider state.
  onChanged: (value) =>
      ref.read(productSortTypeProvider.notifier).state = value!,
  items: [
    // ...
  ],
),

有了这个,我们现在应该能够改变排序类型。不过,这对产品列表还没有影响。现在是最后一个部分了。更新我们的productsProvider来对产品列表进行排序。

实现这一点的一个关键部分是使用ref.watch,让我们的productProvider获取排序类型,并在排序类型改变时重新计算产品列表。实现的方法如下。

final productsProvider = Provider<List<Product>>((ref) {
  final sortType = ref.watch(productSortTypeProvider);
  switch (sortType) {
    case ProductSortType.name:
      return _products.sorted((a, b) => a.name.compareTo(b.name));
    case ProductSortType.price:
      return _products.sorted((a, b) => a.price.compareTo(b.price));
  }
});

这就是全部代码,这一改变足以让用户界面在排序类型改变时自动重新对产品列表进行排序。

更新状态的简化

参考下面的这个场景,有时候,我们需要根据前一个状态值,来修改后续的状态值,例如Flutter Demo中的加数器。

final counterProvider = StateProvider<int>((ref) => 0);

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // We're updating the state from the previous value, we ended-up reading
          // the provider twice!
          ref.read(counterProvider.notifier).state = ref.read(counterProvider.notifier).state + 1;
        },
      ),
    );
  }
}

这种更新State的方法,我们可以使用update函数来简化,简化之后,代码如下。

ref.read(counterProvider.notifier).update((state) => state + 1);

所以,如果是对StateProvider的state进行赋值,那么直接使用下面的代码即可。

ref.read(counterProvider.notifier).state = xxxx

那么如果是根据前置状态的值来修改状态值,则可以使用update来简化。

StateNotifierProvider

StateNotifierProvider是一个用于监听和管理StateNotifier的Provider。StateNotifierProvider和StateNotifier是Riverpod推荐的解决方案,用于管理可能因用户交互而改变的状态。

它通常被用于下面这些场景。

  • 暴露一个不可变的,跟随时间和行为而发生改变的状态
  • 将修改某些状态的逻辑(又称 "业务逻辑")集中在一个地方,提高长期的可维护性

作为一个使用例子,我们可以使用StateNotifierProvider来实现一个todo-list。这样做可以让我们暴露出诸如addTodo这样的方法,让UI在用户交互中修改todos列表。

// The state of our StateNotifier should be immutable.
// We could also use packages like Freezed to help with the implementation.
@immutable
class Todo {
  const Todo({required this.id, required this.description, required this.completed});

  // All properties should be `final` on our class.
  final String id;
  final String description;
  final bool completed;

  // Since Todo is immutable, we implement a method that allows cloning the
  // Todo with slightly different content.
  Todo copyWith({String? id, String? description, bool? completed}) {
    return Todo(
      id: id ?? this.id,
      description: description ?? this.description,
      completed: completed ?? this.completed,
    );
  }
}

// The StateNotifier class that will be passed to our StateNotifierProvider.
// This class should not expose state outside of its "state" property, which means
// no public getters/properties!
// The public methods on this class will be what allow the UI to modify the state.
class TodosNotifier extends StateNotifier<List<Todo>> {
  // We initialize the list of todos to an empty list
  TodosNotifier(): super([]);

  // Let's allow the UI to add todos.
  void addTodo(Todo todo) {
    // Since our state is immutable, we are not allowed to do `state.add(todo)`.
    // Instead, we should create a new list of todos which contains the previous
    // items and the new one.
    // Using Dart'
s spread operator here is helpful!
    state = [...state, todo];
    // No need to call "notifyListeners" or anything similar. Calling "state ="
    // will automatically rebuild the UI when necessary.
  }

  // Let's allow removing todos
  void removeTodo(String todoId) {
    // Again, our state is immutable. So we'
re making a new list instead of
    // changing the existing list.
    state = [
      for (final todo in state)
        if (todo.id != todoId) todo,
    ];
  }

  // Let's mark a todo as completed
  void toggle(String todoId) {
    state = [
      for (final todo in state)
        // we'
re marking only the matching todo as completed
        if (todo.id == todoId)
          // Once more, since our state is immutable, we need to make a copy
          // of the todo. We're using our `copyWith` method implemented before
          // to help with that.
          todo.copyWith(completed: !todo.completed)
        else
          // other todos are not modified
          todo,
    ];
  }
}

// Finally, we are using StateNotifierProvider to allow the UI to interact with
// our TodosNotifier class.
final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) {
  return TodosNotifier();
});

现在我们已经定义了一个StateNotifierProvider,我们可以用它来与用户界面中的todos列表进行交互。

class TodoListView extends ConsumerWidget {
  const TodoListView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // rebuild the widget when the todo list changes
    List<Todo> todos = ref.watch(todosProvider);

    // Let's render the todos in a scrollable list view
    return ListView(
      children: [
        for (final todo in todos)
          CheckboxListTile(
            value: todo.completed,
            // When tapping on the todo, change its completed status
            onChanged: (value) => ref.read(todosProvider.notifier).toggle(todo.id),
            title: Text(todo.description),
          ),
      ],
    );
  }
}

FutureProvider

FutureProvider相当于Provider,但仅用于异步代码。

FutureProvider通常用于下面这些场景。

  • 执行和缓存异步操作(如网络请求)
  • 更好地处理异步操作的错误、加载状态
  • 将多个异步值合并为另一个值

FutureProvider在与ref.watch结合时收获颇丰。这种组合允许在一些变量发生变化时自动重新获取一些数据,确保我们始终拥有最新的值。

FutureProvider不提供在用户交互后直接修改计算的方法。它被设计用来解决简单的用例。

对于更高级的场景,可以考虑使用StateNotifierProvider。

示例:读取一个配置文件

FutureProvider可以作为一种方便的方式来管理一个通过读取JSON文件创建的配置对象。

创建配置将用典型的async/await语法完成,但在Provider内部。使用Flutter的asset,这将是下面的代码。

final configProvider = FutureProvider<Configuration>((ref) async {
  final content = json.decode(
    await rootBundle.loadString('assets/configurations.json'),
  ) as Map<String, Object?>;

  return Configuration.fromJson(content);
});

然后,用户界面可以像这样监听配置。

Widget build(BuildContext context, WidgetRef ref) {
  AsyncValue<Configuration> config = ref.watch(configProvider);

  return config.when(
    loading: () => const CircularProgressIndicator(),
    error: (err, stack) => Text('Error: $err'),
    data: (config) {
      return Text(config.host);
    },
  );
}

这将在Future完成后自动重建UI。同时,如果多个widget想要这些解析值,asset将只被解码一次。

正如你所看到的,监听Widget内的FutureProvider会返回一个AsyncValue - 它允许处理错误/加载状态。

StreamProvider

StreamProvider类似于FutureProvider,但用于Stream而不是Future。

StreamProvider通常被用于下面这些场景。

  • 监听Firebase或web-sockets
  • 每隔几秒钟重建另一个Provider

由于Streams自然地暴露了一种监听更新的方式,有些人可能认为使用StreamProvider的价值很低。特别是,你可能认为Flutter的StreamBuilder也能很好地用于监听Stream,但这是一个错误。

使用StreamProvider而不是StreamBuilder有许多好处。

  • 它允许其他Provider使用ref.watch来监听Stream
  • 由于AsyncValue的存在,它可以确保加载和错误情况得到正确处理
  • 它消除了区分broadcast streams和normal stream的需要
  • 它缓存了stream所发出的最新值,确保如果在事件发出后添加了监听器,监听器仍然可以立即访问最新的事件
  • 它允许在测试中通过覆盖StreamProvider的方式来mock stream

ChangeNotifierProvider

ChangeNotifierProvider是一个用来管理Flutter中的ChangeNotifier的Provider。

Riverpod不鼓励使用ChangeNotifierProvider,它的存在主要是为了下面这些场景。

  • 从package:provider的代码迁移到Riverpod时,替代原有的ChangeNotifierProvider
  • 支持可变的状态管理,但是,不可变的状态是首选推荐的

更倾向于使用StateNotifierProvider来代替。

只有当你绝对确定你想要可变的状态时,才考虑使用ChangeNotifierProvider。

使用可变的状态而不是不可变的状态有时会更有效率。但缺点是,它可能更难维护,并可能破坏各种功能。

例如,如果你的状态是可变的,使用provider.select来优化Widget的重建可能就会失效,因为select会认为值没有变化。

因此,使用不可变的数据结构有时会更快。而且,针对你的用例进行基准测试很重要,以确保你通过使用ChangeNotifierProvider真正获得了性能。

作为一个使用例子,我们可以使用ChangeNotifierProvider来实现一个todo-list。这样做将允许我们公开诸如addTodo的方法,让UI在用户交互中修改todos列表。

class Todo {
  Todo({
    required this.id,
    required this.description,
    required this.completed,
  });

  String id;
  String description;
  bool completed;
}

class TodosNotifier extends ChangeNotifier {
  final todos = <Todo>[];

  // Let's allow the UI to add todos.
  void addTodo(Todo todo) {
    todos.add(todo);
    notifyListeners();
  }

  // Let'
s allow removing todos
  void removeTodo(String todoId) {
    todos.remove(todos.firstWhere((element) => element.id == todoId));
    notifyListeners();
  }

  // Let's mark a todo as completed
  void toggle(String todoId) {
    for (final todo in todos) {
      if (todo.id == todoId) {
        todo.completed = !todo.completed;
        notifyListeners();
      }
    }
  }
}

// Finally, we are using StateNotifierProvider to allow the UI to interact with
// our TodosNotifier class.
final todosProvider = ChangeNotifierProvider<TodosNotifier>((ref) {
  return TodosNotifier();
});

现在我们已经定义了一个ChangeNotifierProvider,我们可以用它来与用户界面中的todos列表进行交互。

class TodoListView extends ConsumerWidget {
  const TodoListView({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // rebuild the widget when the todo list changes
    List<Todo> todos = ref.watch(todosProvider).todos;

    // Let's render the todos in a scrollable list view
    return ListView(
      children: [
        for (final todo in todos)
          CheckboxListTile(
            value: todo.completed,
            // When tapping on the todo, change its completed status
            onChanged: (value) =>
                ref.read(todosProvider.notifier).toggle(todo.id),
            title: Text(todo.description),
          ),
      ],
    );
  }
}

这些不同类型的各种Provider,就是我们的军火库,我们需要根据不同的场景和它们的特性来选择不同的「武器」,通过文中给出的例子,相信大家能够很好的理解它们的作用了。

向大家推荐下我的网站 https://xuyisheng.top/  点击原文一键直达

专注 Android-Kotlin-Flutter 欢迎大家访问



往期推荐


本文原创公众号:群英传,授权转载请联系微信(Tomcat_xu),授权后,请在原创发表24小时后转载。
< END >
作者:徐宜生

更文不易,点个“三连”支持一下👇


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存