重走Flutter狀態管理之路—Riverpod入門篇

語言: CN / TW / HK

熟悉我的朋友應該都知道,我好幾年前寫過一個「Flutter狀態管理之路」系列,那個時候介紹的是Provider,這也是官方推薦的狀態管理工具,但當時沒有寫完,因為寫著寫著,覺得有很多地方不盡人意,用著很彆扭,所以在寫了7篇文章之後,就暫時擱置了。

一晃時間過了這麼久,Flutter內部依然沒有一個能夠碾壓一切的狀態管理框架,GetX可能是,但是我覺得不是,InheritedWidget系的狀態管理,才應該是正統的狀態管理。

最近在留意Provider的後續進展時,意外發現了一個新的庫——Riverpod,號稱是新一代的狀態管理工具,仔細一看,嘿,居然還是Provider的作者,好傢伙,這是搬起石頭砸自己的腳啊。

就像作者所說,Riverpod就是對Provider的重寫,可不是嗎,字母都沒變,就換了個順序,這名字也是取的博大精深。

其實Provider在使用上已經非常不錯了,只不過隨著Flutter的更加深入,大家對它的需求也就越來越高,特別是對Provider中因為InheritedWidget層次問題導致的異常和BuildContext的使用這些問題詬病很多,而Riverpod,正是在Provider的基礎上,探索出了一條心的狀態管理之路。

大家可以先把官方文件看一看 http://riverpod.dev ,看完之後發現還是一臉懵逼,那就對了,Riverpod和Provider一樣,有很多型別的Provider,分別用於不同的場景,所以,理清這些Provider的不同作用和使用場景,對於我們用好Riverpod是非常有幫助的。

官網的文件,雖然是作者精心編寫的,但它的教程,站在的是一個創作者的角度,所以很多入門的初學者看上去會有點摸不清方向,所以,這才有了這個系列的文章。

我將在這個系列中,帶領大家對文件進行一次精讀,進行一次賞析,本文不全是對文件的翻譯,而且講解的順序也不一樣,所以,如果你想入門Riverpod進行狀態管理,那麼本文一定是你的最佳選擇。

Provider第一眼

首先,我們為什麼要進行狀態管理,狀態管理是解決申明式UI開發,關於資料狀態的一個處理操作,例如Widget A依賴於同級的Widget B的資料,那麼這個時候,就只能把資料狀態上提到它們的父類,但是這樣比較麻煩,Riverpod和Provider這樣的狀態管理框架,就是為了解決類似的問題而產生的。

將一個state包裹在一個Provider中可以有下面一些好處。

  • 允許在多個位置輕鬆訪問該狀態。Provider可以完全替代Singletons、Service Locators、依賴注入或InheritedWidgets等模式
  • 簡化了這個狀態與其他狀態的結合,你有沒有為,如何把多個物件合併成一個而苦惱過?這種場景可以直接在Provider內部實現
  • 實現了效能優化。無論是過濾Widget的重建,還是快取昂貴的狀態計算;Provider確保只有受狀態變化影響的部分才被重新計算
  • 增加了你的應用程式的可測試性。使用Provider,你不需要複雜的setUp/tearDown步驟。此外,任何Provider都可以被重寫,以便在測試期間有不同的行為,這可以輕鬆地測試一個非常具體的行為
  • 允許與高階功能輕鬆整合,如logging或pull-to-refresh

首先,我們通過一個簡單的例子,來感受下,Riverpod是怎麼進行狀態管理的。

Provider是Riverpod應用程式中最重要的部分。Provider是一個物件,它封裝了一個state並允許監聽該state。Provider有很多變體形式,但它們的工作方式都是一樣的。

最常見的用法是將它們宣告為全域性常量,例如下面這樣。

final myProvider = Provider((ref) { return MyValue(); });

不要被Provider的全域性變數所嚇倒。Provider是完全final的。宣告一個Provider與宣告一個函式沒有什麼不同,而且Provider是可測試和可維護的。

這段程式碼由三個部分組成。

  • final myProvider,一個變數的宣告。這個變數是我們將來用來讀取我們Provider的狀態的。Provider應該始終是final的
  • Provider,我們決定使用的Provider型別。Provider是所有Provider型別中最基本的。它暴露了一個永不改變的物件。我們可以用其他Provider如StreamProvider或StateNotifierProvider來替換Provider,以改變值的互動方式
  • 一個建立共享狀態的函式。該函式將始終接收一個名為ref的物件作為引數。這個物件允許我們讀取其他Provider,在我們Provider的狀態將被銷燬時執行一些操作,以及其它一些事情

傳遞給Provider的函式返回的物件的型別,取決於所使用的Provider。例如,一個Provider的函式可以建立任何物件。另一方面,StreamProvider的回撥將被期望返回一個Stream。

你可以不受限制地宣告你想要的多個Provider。與使用package:provider不同的是,Riverpod允許建立多個暴露相同 "型別 "的狀態的provider。

final cityProvider = Provider((ref) => 'London'); final countryProvider = Provider((ref) => 'England');

兩個Provider都建立了一個字串,但這並沒有任何問題。

為了使Provider發揮作用,您必須在Flutter應用程式的根部新增ProviderScope。

void main() { runApp(ProviderScope(child: MyApp())); }

以上就是Riverpod最簡單的使用,我們看下完整的示例程式碼。

``` import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';

// We create a "provider", which will store a value (here "Hello world"). // By using a provider, this allows us to mock/override the value exposed. final helloWorldProvider = Provider((_) => 'Hello world');

void main() { runApp( // For widgets to be able to read providers, we need to wrap the entire // application in a "ProviderScope" widget. // This is where the state of our providers will be stored. ProviderScope( child: MyApp(), ), ); }

// Extend ConsumerWidget instead of StatelessWidget, which is exposed by Riverpod class MyApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final String value = ref.watch(helloWorldProvider);

return MaterialApp(
  home: Scaffold(
    appBar: AppBar(title: const Text('Example')),
    body: Center(
      child: Text(value),
    ),
  ),
);

} } ```

可以發現,Riverpod的使用比package:Provider還要簡單,申明一個全域性變數來管理狀態資料,然後就可以在任意地方獲取資料了。

如何讀取Provider的狀態值

在有了一個簡單的瞭解後,我們先來了解下關於狀態中的「讀」。

在Riverpod中,我們不像package:Provider那樣需要依賴BuildContext,取而代之的是一個「ref」變數。這個東西,就是聯絡存取雙方的紐帶,這個物件允許我們與Provider互動,不管是來自一個Widget還是另一個Provider。

從Provider中獲取ref

所有Provider都有一個 "ref "作為引數。

``` final provider = Provider((ref) { // use ref to obtain other providers final repository = ref.watch(repositoryProvider);

return SomeValue(repository); }) ```

這個引數可以安全地傳遞給其它Provider或者類,來獲取所需要的值。

例如,一個常見的用例是將Provider的 "ref "傳遞給一個StateNotifier。

``` final counterProvider = StateNotifierProvider((ref) { return Counter(ref); });

class Counter extends StateNotifier { Counter(this.ref): super(0);

final Ref ref;

void increment() { // Counter can use the "ref" to read other providers final repository = ref.read(repositoryProvider); repository.post('...'); } } ```

這樣做,可以使我們的Counter類能夠讀取Provider。

這種方式是聯絡元件和Provider的一個重要方式。

從Widget中獲取ref

Widgets自然沒有一個ref引數。但是Riverpod提供了多種解決方案來從widget中獲得這個引數。

擴充套件ConsumerWidget

在widget樹中獲得一個ref的最常見的方法是用ConsumerWidget代替StatelessWidget。

ConsumerWidget在使用上與StatelessWidget相同,唯一的區別是它的構建方法上有一個額外的引數:"ref "物件。

一個典型的ConsumerWidget看起來像這樣。

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

@override Widget build(BuildContext context, WidgetRef ref) { // use ref to listen to a provider final counter = ref.watch(counterProvider); return Text('$counter'); } } ```

擴充套件ConsumerStatefulWidget

與ConsumerWidget類似,ConsumerStatefulWidget和ConsumerState相當於一個帶有狀態的StatefulWidget,不同的是,state有一個 "ref "物件。

這一次,"ref "不是作為構建方法的引數傳遞,而是作為ConsumerState物件的一個屬性。

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

@override HomeViewState createState() => HomeViewState(); }

class HomeViewState extends ConsumerState { @override void initState() { super.initState(); // "ref" can be used in all life-cycles of a StatefulWidget. ref.read(counterProvider); }

@override Widget build(BuildContext context) { // We can also use "ref" to listen to a provider inside the build method final counter = ref.watch(counterProvider); return Text('$counter'); } } ```

通過ref來獲取狀態

現在我們有了一個 "ref",我們可以開始使用它。

ref "有三個主要用途。

  • 獲得一個Provider的值並監聽變化,這樣,當這個值發生變化時,這將重建訂閱該值的Widget或Provider。這是通過ref.watch完成的
  • 在一個Provider上新增一個監聽器,以執行一個action,如導航到一個新的頁面或在該Provider發生變化時執行一些操作。這是通過 ref.listen 完成的
  • 獲取一個Provider的值,同時忽略它的變化。當我們在一個事件中需要一個Provider的值時,這很有用,比如 "點選操作"。這是通過ref.read完成的

只要有可能,最好使用 ref.watch 而不是 ref.read 或 ref.listen 來實現一個功能。 通過依賴ref.watch,你的應用程式變得既是反應式的又是宣告式的,這使得它更容易維護。

通過ref.watch觀察Provider的狀態

ref.watch在Widget的構建方法中使用,或者在Provider的主體中使用,以使得Widget/Provider可以監聽另一個Provider。

例如,Provider可以使用 ref.watch 來將多個Provider合併成一個新的值。

一個例子是過濾一個todo-list,我們需要兩個Provider。

  • filterTypeProvider,一個暴露當前過濾器型別的Provider(None,表示只顯示已完成的任務)
  • todosProvider,一個暴露整個任務列表的Provider

通過使用ref.watch,我們可以製作第三個Provider,結合這兩個Provider來建立一個過濾後的任務列表。

``` final filterTypeProvider = StateProvider((ref) => FilterType.none); final todosProvider = StateNotifierProvider>((ref) => TodoList());

final filteredTodoListProvider = Provider((ref) { // obtains both the filter and the list of todos final FilterType filter = ref.watch(filterTypeProvider); final List todos = ref.watch(todosProvider);

switch (filter) { case FilterType.completed: // return the completed list of todos return todos.where((todo) => todo.isCompleted).toList(); case FilterType.none: // returns the unfiltered list of todos return todos; } }); ```

有了這段程式碼,filteredTodoListProvider現在就可以管理過濾後的任務列表。

如果過濾器或任務列表發生變化,過濾後的列表也會自動更新。同時,如果過濾器和任務列表都沒有改變,過濾後的列表將不會被重新計算。

類似地,一個Widget可以使用ref.watch來顯示來自Provider的內容,並在該內容發生變化時更新使用者介面。

``` final counterProvider = StateProvider((ref) => 0);

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

@override Widget build(BuildContext context, WidgetRef ref) { // use ref to listen to a provider final counter = ref.watch(counterProvider);

return Text('$counter');

} } ```

這段程式碼顯示了一個Widget,它監聽了一個儲存計數的Provider。如果該計數發生變化,該Widget將重建,使用者介面將更新以顯示新的值。

ref.watch方法不應該被非同步呼叫,比如在ElevatedButton的onPressed中。也不應該在initState和其他State的生命週期內使用它。在這些情況下,考慮使用 ref.read 來代替。

通過ref.listen監聽Provider的變化

與ref.watch類似,可以使用ref.listen來觀察一個Provider。

它們之間的主要區別是,如果被監聽的Provider發生變化,使用ref.listen不會重建widget/provider,而是會呼叫一個自定義函式。

這對於在某個變化發生時執行某些操作是很有用的,比如在發生錯誤時顯示一個snackbar。

ref.listen方法需要2個引數,第一個是Provider,第二個是當狀態改變時我們要執行的回撥函式。回撥函式在被呼叫時將被傳遞2個值,即先前狀態的值和新狀態的值。

ref.listen方法也可以在Provider的體內使用。

``` final counterProvider = StateNotifierProvider((ref) => Counter(ref));

final anotherProvider = Provider((ref) { ref.listen(counterProvider, (int? previousCount, int newCount) { print('The counter changed $newCount'); }); // ... }); ```

或在一個Widget的Build方法中使用。

``` final counterProvider = StateNotifierProvider((ref) => Counter(ref));

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

@override Widget build(BuildContext context, WidgetRef ref) { ref.listen(counterProvider, (int? previousCount, int newCount) { print('The counter changed $newCount'); });

return Container();

} } ```

ref.listen也不應該被非同步呼叫,比如在ElevatedButton的onPressed中。也不應該在initState和其他State的生命週期內使用它。

通過ref.read來讀取Provider的狀態

ref.read方法是一種在不監聽的情況下獲取Provider的狀態的方法。

它通常用於由使用者互動觸發的函式中。例如,當用戶點選一個按鈕時,我們可以使用ref.read來增加一個計數器的值。

``` final counterProvider = StateNotifierProvider((ref) => Counter(ref));

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

@override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( floatingActionButton: FloatingActionButton( onPressed: () { // Call increment() on the Counter class ref.read(counterProvider.notifier).increment(); }, ), ); } } ```

應該儘可能地避免使用ref.read,因為它不是響應式的。

它存在於使用watch或listen會導致問題的情況下。如果可以的話,使用watch/listen幾乎總是更好的,尤其是watch。

關於ref.read到底什麼時候用

首先,永遠不要在Widget的build函式中直接使用ref.read。

你可能很想使用ref.read來優化一個Widget的效能,例如通過下面的程式碼來實現。

``` final counterProvider = StateProvider((ref) => 0);

Widget build(BuildContext context, WidgetRef ref) { // use "read" to ignore updates on a provider final counter = ref.read(counterProvider.notifier); return ElevatedButton( onPressed: () => counter.state++, child: const Text('button'), ); } ```

但這是一種非常糟糕的做法,會導致難以追蹤的錯誤。

以這種方式使用 ref.read 通常與這樣的想法有關:"Provider所暴露的值永遠不會改變,所以使用'ref.read'是安全的"。這個假設的問題是,雖然今天該Provider可能確實從未更新過它的值,但不能保證明天也是如此。

軟體往往變化很大,而且很可能在未來,一個以前從未改變的值需要改變。

如果你使用ref.read,當這個值需要改變時,你必須翻閱整個程式碼庫,將ref.read改為ref.watch--這很容易出錯,而且你很可能會忘記一些情況。

如果你一開始就使用ref.watch,你在重構時就會減少問題。

但是如果我想用ref.read來減少我的widget重構的次數呢?

雖然這個目標值得稱讚,但需要注意的是,你可以用ref.watch代替來達到完全相同的效果(減少構建的次數)。

Provider提供了各種方法來獲得一個值,同時減少重建的次數,你可以用這些方法來代替。

例如下面的程式碼(bad)。

``` final counterProvider = StateProvider((ref) => 0);

Widget build(BuildContext context, WidgetRef ref) { StateController counter = ref.read(counterProvider.notifier); return ElevatedButton( onPressed: () => counter.state++, child: const Text('button'), ); } ```

我們可以這樣改。

``` final counterProvider = StateProvider((ref) => 0);

Widget build(BuildContext context, WidgetRef ref) { StateController counter = ref.watch(counterProvider.notifier); return ElevatedButton( onPressed: () => counter.state++, child: const Text('button'), ); } ```

這兩個片段程式碼都達到了同樣的效果:當計數器增加時,我們的按鈕將不會重建。

另一方面,第二種方法支援計數器被重置的情況。例如,應用程式的另一部分可以呼叫。

ref.refresh(counterProvider);

這將重新建立StateController物件。

如果我們在這裡使用ref.read,我們的按鈕仍然會使用之前的StateController例項,而這個例項已經被棄置,不應該再被使用。

而使用ref.watch則可以正確地重建按鈕,使用新的StateController。

關於ref.read可以讀哪些值

根據你想監聽的Provider,你可能有多個可能的值可以監聽。

作為一個例子,考慮下面的StreamProvider。

final userProvider = StreamProvider<User>(...);

當讀取這個userProvider時,你可以像下面這樣。

  • 通過監聽userProvider本身同步讀取當前狀態。

``` Widget build(BuildContext context, WidgetRef ref) { AsyncValue user = ref.watch(userProvider);

return user.when( loading: () => const CircularProgressIndicator(), error: (error, stack) => const Text('Oops'), data: (user) => Text(user.name), ); } ```

  • 通過監聽userProvider.stream來獲得相關的Stream。

Widget build(BuildContext context, WidgetRef ref) { Stream<User> user = ref.watch(userProvider.stream); }

  • 通過監聽userProvider.future獲得一個Future,該Future以最新發出的值進行解析。

Widget build(BuildContext context, WidgetRef ref) { Future<User> user = ref.watch(userProvider.future); }

其他Provider可能提供不同的替代值。

欲瞭解更多資訊,請查閱API參考資料,參考每個Provider的API文件。

通過select來控制精確的讀範圍

最後要提到的一個與讀取Provider有關的功能是,能夠減少Widget/Provider從ref.watch重建的次數,或者ref.listen執行函式的頻率的功能。

這一點很重要,因為預設情況下,監聽一個Provider會監聽整個物件的狀態。但有時,一個Widget/Provider可能只關心一些屬性的變化,而不是整個物件。

例如,一個Provider可能暴露了一個User物件。

abstract class User { String get name; int get age; }

但一個Widget可能只使用使用者名稱。

Widget build(BuildContext context, WidgetRef ref) { User user = ref.watch(userProvider); return Text(user.name); }

如果我們簡單地使用ref.watch,當用戶的年齡發生變化時,這將重建widget。

解決方案是使用select來明確地告訴Riverpod我們只想監聽使用者的名字屬性。

更新後的程式碼將是這樣。

Widget build(BuildContext context, WidgetRef ref) { String name = ref.watch(userProvider.select((user) => user.name)); return Text(name); }

通過使用select,我們能夠指定一個函式來返回我們關心的屬性。

每當使用者改變時,Riverpod將呼叫這個函式並比較之前和新的結果。如果它們是不同的(例如當名字改變時),Riverpod將重建Widget。然而,如果它們是相等的(例如當年齡改變時),Riverpod將不會重建Widget。

這個場景也可以使用select和ref.listen。

ref.listen<String>( userProvider.select((user) => user.name), (String? previousName, String newName) { print('The user name changed $newName'); } );

這樣做也將只在名稱改變時呼叫listener。

另外,你不一定要返回物件的一個屬性。任何覆蓋==的值都可以使用。例如,你可以這樣做。

final label = ref.watch(userProvider.select((user) => 'Mr ${user.name}'));

讀取狀態,是一個非常重要的部分,什麼時候用什麼樣的方式來讀,都會有不同的效果。

ProviderObserver

ProviderObserver可以監聽一個ProviderContainer的變化。

要使用它,你可以擴充套件ProviderObserver類並覆蓋你想使用的方法。ProviderObserver有三個方法。

  • didAddProvider:在每次初始化一個Provider時被呼叫
  • didDisposeProvider:在每次銷燬Provider的時候被呼叫
  • didUpdateProvider:每次在Provider更新時都會被呼叫

ProviderObserver的一個簡單用例是通過覆蓋didUpdateProvider方法來記錄Provider的變化。

``` // A Counter example implemented with riverpod with Logger

class Logger extends ProviderObserver { @override void didUpdateProvider( ProviderBase provider, Object? previousValue, Object? newValue, ProviderContainer container, ) { print(''' { "provider": "${provider.name ?? provider.runtimeType}", "newValue": "$newValue" }'''); } }

void main() { runApp( // Adding ProviderScope enables Riverpod for the entire project // Adding our Logger to the list of observers ProviderScope(observers: [Logger()], child: const MyApp()), ); }

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

@override Widget build(BuildContext context) { return MaterialApp(home: Home()); } }

final counterProvider = StateProvider((ref) => 0, name: 'counter');

class Home extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final count = ref.watch(counterProvider);

return Scaffold(
  appBar: AppBar(title: const Text('Counter example')),
  body: Center(
    child: Text('$count'),
  ),
  floatingActionButton: FloatingActionButton(
    onPressed: () => ref.read(counterProvider.notifier).state++,
    child: const Icon(Icons.add),
  ),
);

} } ```

現在,每當我們的Provider的值被更新時,logger將記錄它。

I/flutter (16783): { I/flutter (16783): "provider": "counter", I/flutter (16783): "newValue": "1" I/flutter (16783): }

對於諸如StateController(StateProvider.state的狀態)和ChangeNotifier等可改變的狀態,previousValue和newValue將是相同的。因為它們引用的是同一個StateController / ChangeNotifier。

這些是對Riverpod的最基本瞭解,但是卻是很重要的部分,特別是如何對狀態值進行讀取,這是我們用好Riverpod的核心。

向大家推薦下我的網站 http://xuyisheng.top/ 專注 Android-Kotlin-Flutter 歡迎大家訪問