跳到主要內容

動機

這篇深入的文章旨在說明為什麼需要 Riverpod 的動機。

特別是,本節應回答以下問題:

  • 既然 Provider 廣受歡迎,為什麼要遷移到 Riverpod?
  • 我能獲得哪些具體優勢?
  • 如何遷移到 Riverpod?
  • 我可以增量遷移嗎?
  • 等等……

在本節結束時,您應該確信 Riverpod 優先於 Provider。

與 Provider 相比,Riverpod 確實是一種更現代、更推薦和更可靠的方法。

Riverpod 提供更好的狀態管理功能、更好的快取策略和簡化的響應式模型。
然而,Provider 目前在許多領域都缺乏,沒有前進的道路。

Provider 的侷限性

Provider 存在根本問題是由於受到 InheritedWidget API 的限制。
從本質上講,Provider 是一個“更簡單的 InheritedWidget”; Provider 只是一個 InheritedWidget 包裝器,因此它受到它的限制。

下面是已知的提供者程式問題列表。

提供者程式不能保留兩個(或多個)相同“型別”的提供者程式

宣告兩個 Provider<Item> 將導致不可靠的行為: InheritedWidget 的 API 只能獲取兩者中的一個:即最接近 Provider<Item> 的祖先。
雖然 Provider 的文件中解釋了解決方法,但 Riverpod 根本沒有這個問題。

透過消除這個限制,我們可以自由地將邏輯拆分為小塊,如下所示:



List<Item> items(Ref ref) {
return []; // ...
}


List<Item> evenItems(Ref ref) {
final items = ref.watch(itemsProvider);
return [...items.whereIndexed((index, element) => index.isEven)];
}

提供者程式一次合理地只發出一個值

讀取外部 RESTful API 時,通常會顯示上次讀取值,而新呼叫會載入下一個讀取值。
Riverpod 透過其 AsyncValue 的 API 一次發出兩個值(即一個前一個數據值和一個傳入的新載入值)來允許這種行為:



Future<List<Item>> itemsApi(Ref ref) async {
final client = Dio();
final result = await client.get<List<dynamic>>('your-favorite-api');
final parsed = [...result.data!.map((e) => Item.fromJson(e as Json))];
return parsed;
}


List<Item> evenItems(Ref ref) {
final asyncValue = ref.watch(itemsApiProvider);
if (asyncValue.isReloading) return [];
if (asyncValue.hasError) return const [Item(id: -1)];

final items = asyncValue.requireValue;

return [...items.whereIndexed((index, element) => index.isEven)];
}

在前面的程式碼片段中,觀察 evenItemsProvider 將產生以下效果:

  1. 最初,正在發出請求。我們得到一個空列表;
  2. 然後,假設發生錯誤。我們獲得 [Item(id: -1)]
  3. 然後,我們使用拉取重新整理邏輯重試請求(例如,透過 ref.invalidate);
  4. 當我們重新載入第一個提供者程式時,第二個提供者程式仍然公開 [Item(id: -1)]
  5. 這一次,一些解析後的資料被正確接收:我們的偶數項被正確返回。

使用 Provider,上述功能無法遠端實現,甚至更難解決。

合併提供者程式很困難且容易出錯

對於 Provider,我們可能很想在 provider 的 create 中使用 context.watch
這將是不可靠的,因為即使沒有依賴項發生更改(例如,當小部件樹中涉及 GlobalKey 時), didChangeDependencies 也可能被觸發。

儘管如此,Provider 有一個名為 ProxyProvider 的臨時解決方案,但它被認為是乏味且容易出錯的。

合併狀態是 Riverpod 的核心機制,因為我們可以使用簡單而強大的方法(如 ref.watchref.listen)以零開銷組合和快取值:



int number(Ref ref) {
return Random().nextInt(10);
}


int doubled(Ref ref) {
final number = ref.watch(numberProvider);

return number * 2;
}

使用 Riverpod 組合值感覺很自然:依賴項是可讀的,並且 API 保持不變。

缺乏安全性

使用 Provider,在重構和/或大型更改期間以 ProviderNotFoundException 結束是很常見的。
事實上,這個執行時異常最初建立 Riverpod 的主要原因之一。

儘管它帶來了比這更多的實用性,但 Riverpod 根本無法丟擲此異常。

處置狀態很困難

InheritedWidget 無法對消費者程式停止監聽他們的情況做出反應
這可以防止提供者程式在不再使用時自動處置其提供者程式的狀態。
在使用 Provider 的情況下,我們必須依靠作用域提供者程式在停止使用狀態時對其進行處置。
但這並不容易,因為當在頁面之間共享狀態時,它會變得棘手。

Riverpod 透過易於理解的 API(如 autodisposekeepAlive)解決了這個問題。
這兩個 API 支援靈活且創造性的快取策略(例如基於時間的快取):


// With code gen, .autoDispose is the default

int diceRoll(Ref ref) {
// Since this provider is .autoDispose, un-listening to it will dispose
// its current exposed state.
// Then, whenever this provider is listened to again,
// a new dice will be rolled and exposed again.
final dice = Random().nextInt(10);
return dice;
}


int cachedDiceRoll(Ref ref) {
final coin = Random().nextInt(10);
if (coin > 5) throw Exception('Way too large.');
// The above condition might fail;
// If it doesn't, the following instruction tells the Provider
// to keep its cached state, even when no one listens to it anymore.
ref.keepAlive();
return coin;
}

不幸的是,沒有辦法用原始 InheritedWidget 的來實現這一點,因此沒有辦法用 Provider 來實現。

缺乏可靠的引數化機制

Riverpod 允許其使用者使用 .family 修飾符宣告“引數化”提供者程式。
事實上,這是 Riverpod 最強大的功能之一,也是其創新的核心, 例如,.family 它能夠極大地簡化邏輯

如果我們想使用 Provider 實現類似的東西, 我們將不得不放棄這些引數的易用性型別安全性。

此外,無法使用 Provider 實現類似的 .autoDispose 機制 本身就阻止了 .family 的任何等效實現,因為這兩個功能是齊頭並進的

最後,如前所述,事實證明,小部件永遠不會停止收聽 InheritedWidget
這意味著如果某些提供者程式狀態是“動態掛載”的, 即當使用引數構建提供者程式時,則會出現嚴重的記憶體洩漏,而這正是 .family 這樣做的。
因此,目前從根本上不可能獲得 Provider 的 .family 等價物。

測試很乏味

為了能夠編寫測試,您必須在每個測試中重新定義提供者程式。

預設情況下,藉助 Riverpod,提供者程式已準備好在內部測試中使用。 此外,Riverpod 還公開了一組方便的“覆蓋”的工具,這些實用程式在模擬提供者程式時至關重要。

測試上面的組合狀態程式碼段非常簡單,如下所示:


void main() {
test('it doubles the value correctly', () async {
final container = ProviderContainer(
overrides: [numberProvider.overrideWith((ref) => 9)],
);
final doubled = container.read(doubledProvider);
expect(doubled, 9 * 2);
});
}

有關測試的詳細資訊,請參閱測試

引發副作用並不簡單

由於 InheritedWidget 沒有 onChange 回撥,因此 Provider 也沒有回撥。
這對於導航來說是有問題的,例如小吃欄、模態等。

相反,Riverpod 只是提供 ref.listen,它與 Flutter 很好地整合在一起


class DiceRollWidget extends ConsumerWidget {
const DiceRollWidget({super.key});


Widget build(BuildContext context, WidgetRef ref) {
ref.listen(diceRollProvider, (previous, next) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Dice roll! We got: $next')),
);
});
return TextButton.icon(
onPressed: () => ref.invalidate(diceRollProvider),
icon: const Icon(Icons.casino),
label: const Text('Roll a dice'),
);
}
}

轉向 Riverpod

從概念上講,Riverpod 和 Provider 非常相似。 這兩個包都扮演著類似的角色。兩者都嘗試:

  • 快取和處置一些有狀態物件;
  • 提供一種在測試期間模擬這些物件的方法;
  • 為 Widget 提供了一種以簡單的方式監聽這些物件的方法。

你可以把 Riverpod 想象成 Provider 在幾年內繼續成熟時的樣子。

為什麼要單獨建包?

最初,計劃釋出 Provider 的主要版本,以解決上述問題。
但隨後決定反對它,因為由於新的 ConsumerWidget API,這將“太麻煩”甚至有爭議。
由於 Provider 仍然是最常用的 Flutter 包之一,因此決定建立一個單獨的包,因此建立了 Riverpod。

啟用建立單獨的包:

  • 透過同時臨時使用這兩種方法,為任何想要遷移的人提供便利;
  • 如果人們原則上不喜歡 Riverpod,或者他們覺得它還不可靠,請允許他們堅持使用 Provider;
  • 實驗,允許 Riverpod 搜尋生產就緒的解決方案,以應對各種提供者程式的技術限制。

事實上,Riverpod 旨在成為 Provider 的精神繼承者。因此得名“Riverpod”(它是“Provider”的字謎,異位詞)。

破壞性變化

Riverpod 唯一真正的缺點是它需要更改小部件型別才能工作:

  • 使用 Riverpod,您應該擴充套件 ConsumerWidget,而不是擴充套件 StatelessWidget
  • 使用 Riverpod,您應該擴充套件 ConsumerStatefulWidget,而不是擴充套件 StatefulWidget

但這種不便在宏偉的計劃中是相當小的。有朝一日,這種要求可能會消失。

選擇正確的庫

您可能會問自己:“那麼,作為 Provider 使用者,我應該使用 Provider 還是 Riverpod?”

我們想非常清楚地回答這個問題:

您可能應該使用 Riverpod

Riverpod 總體上設計得更好,可以大大簡化您的邏輯。