動機
這篇深入的文章旨在說明為什麼需要 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
將產生以下效果:
- 最初,正在發出請求。我們得到一個空列表;
- 然後,假設發生錯誤。我們獲得
[Item(id: -1)]
; - 然後,我們使用拉取重新整理邏輯重試請求(例如,透過
ref.invalidate
); - 當我們重新載入第一個提供者程式時,第二個提供者程式仍然公開
[Item(id: -1)]
; - 這一次,一些解析後的資料被正確接收:我們的偶數項被正確返回。
使用 Provider,上述功能無法遠端實現,甚至更難解決。
合併提供者程式很困難且容易出錯
對於 Provider,我們可能很想在 provider 的 create
中使用 context.watch
。
這將是不可靠的,因為即使沒有依賴項發生更改(例如,當小部件樹中涉及 GlobalKey 時),
didChangeDependencies
也可能被觸發。
儘管如此,Provider 有一個名為 ProxyProvider
的臨時解決方案,但它被認為是乏味且容易出錯的。
合併狀態是 Riverpod 的核心機制,因為我們可以使用簡單而強大的方法(如 ref.watch 和 ref.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(如 autodispose 和 keepAlive)解決了這個問題。
這兩個 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 總體上設計得更好,可以大大簡化您的邏輯。