Provider vs Riverpod
Questo articolo riepiloga le differenze e le somiglianze tra Provider e Riverpod.
Definire i provider
La differenza principale tra entrambi i package riguarda come vengono definiti i "provider".
Con Provider, i provider sono widget e, come tali, vengono inseriti all'interno dell'albero dei widget,
di solito all'interno di un MultiProvider
:
class Counter extends ChangeNotifier {
...
}
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider<Counter>(create: (context) => Counter()),
],
child: MyApp(),
)
);
}
Con Riverpod, i provider non sono widget. Invece, sono semplici oggetti Dart. Allo stesso modo, i provider sono definiti al di fuori dell'albero dei widget e vengono dichiarati come variabili finali globali.
Inoltre, affinché Riverpod funzioni, è necessario aggiungere un widget ProviderScope
sopra l'intera applicazione.
Di conseguenza, l'equivalente dell'esempio con Provider utilizzando Riverpod sarebbe:
// I provider sono ora variabili top-level
final counterProvider = ChangeNotifierProvider<Counter>((ref) => Counter());
void main() {
runApp(
// Questo widget attiva Riverpod sull'intero progetto
ProviderScope(
child: MyApp(),
),
);
}
Nota come la definizione del provider si sia semplicemente spostata di alcune righe.
Poiché con Riverpod i provider sono semplici oggetti Dart, è possibile utilizzare Riverpod senza Flutter. Ad esempio, Riverpod può essere utilizzato per scrivere applicazioni a riga di comando.
Leggere i provider: BuildContext
Con Provider, un modo per leggere i provider è utilizzare il BuildContext
di un widget.
Ad esempio, se un provider è definito come:
Provider<Model>(...);
il modo per leggerlo con Provider sarà scritto come
class Example extends StatelessWidget {
Widget build(BuildContext context) {
Model model = context.watch<Model>();
}
}
L'equivalente in Riverpod sarebbe:
final modelProvider = Provider<Model>(...);
class Example extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
Model model = ref.watch(modelProvider);
}
}
Nota come:
Lo snippet di Riverpod estende
ConsumerWidget
invece diStatelessWidget
. Questo tipo di widget aggiunge un parametro in più alla nostra funzionebuild
:WidgetRef
.Invece di
BuildContext.watch
, in Riverpod scriveremoWidgetRef.watch
, utilizzando ilWidgetRef
che abbiamo ottenuto daConsumerWidget
.Riverpod non si basa sui tipi generici. Si basa invece sulla variabile creata utilizzando la definizione del provider.
Nota anche quanto simile sia la terminologia. Sia Provider che Riverpod utilizzano la parola chiave "watch" per descrivere "questo widget dovrebbe essere ricostruito quando il valore cambia".
Riverpod utilizza la stessa terminologia di Provider per la lettura dei provider.
BuildContext.watch
->WidgetRef.watch
BuildContext.read
->WidgetRef.read
BuildContext.select
->WidgetRef.watch(myProvider.select)
Le regole per "context.watch" vs "context.read" si applicano anche a Riverpod:
All'interno del metodo build
, utilizza "watch". Nei gestori di eventi come gli eventi di clic, utilizza "read".
Quando hai bisogno di filtrare i valori e ricreare, utilizza "select".
Leggere i provider: Consumer
Provider include opzionalmente un widget chiamato Consumer
(e varianti come Consumer2
) per leggere i provider.
Consumer
è utile per l'ottimizzazione delle prestazioni, consentendo ricostruzioni più granulari dell'albero dei widget e
aggiornando solo i widget rilevanti quando lo stato cambia.
Pertanto, se un provider è definito come:
Provider<Model>(...);
Provider ci permette di leggere quel provider usando Consumer
in questo modo:
Consumer<Model>(
builder: (BuildContext context, Model model, Widget? child) {
}
)
Riverpod segue lo stesso principio. Anch'esso, ha un widget chiamato Consumer
con lo stesso scopo.
Se definiamo un provider come:
final modelProvider = Provider<Model>(...);
Possiamo poi utilizzare Consumer
in questo modo:
Consumer(
builder: (BuildContext context, WidgetRef ref, Widget? child) {
Model model = ref.watch(modelProvider);
}
)
Nota come Consumer
ci fornisce un oggetto WidgetRef
. Si tratta dello stesso oggetto
che abbiamo visto nella parte precedente relativa a ConsumerWidget
.
Non c'è nessun ConsumerN
equivalente in Riverpod
Nota come Consumer2
, Consumer3
e simili di pkg:Provider non sono necessari in Riverpod.
Con Riverpod, se desideri leggere valori da più provider, puoi semplicemente scrivere più istruzioni ref.watch
, come segue:
Consumer(
builder: (context, ref, child) {
Model1 model = ref.watch(model1Provider);
Model2 model = ref.watch(model2Provider);
Model3 model = ref.watch(model3Provider);
// ...
}
)
Rispetto alle API ConsumerN
di pkg:Provider, la soluzione sopra sembra molto meno complessa ed è probabilmente più facile da comprendere.
Combinare provider: ProxyProvider con oggetti stateless
Quando si utilizza Provider, il modo ufficiale di combinare i provider è utilizzare il widget ProxyProvider
(o varianti come ProxyProvider2
).
Ad esempio, potremmo definire:
class UserIdNotifier extends ChangeNotifier {
String? userId;
}
// ...
ChangeNotifierProvider<UserIdNotifier>(create: (context) => UserIdNotifier()),
Da qui abbiamo due opzioni. Possiamo combinare UserIdNotifier
per creare un nuovo provider
"senza stato" (tipicamente un valore immutabile che eventualmente sovrascrive ==).
Ad esempio:
ProxyProvider<UserIdNotifier, String>(
update: (context, userIdNotifier, _) {
return 'The user ID of the the user is ${userIdNotifier.userId}';
}
)
Questo provider restituirebbe automaticamente una nuova String
ogni volta che UserIdNotifier.userId
cambia.
Possiamo fare qualcosa di simile in Riverpod, ma la sintassi è diversa.
Innanzitutto, in Riverpod, la definizione del nostro UserIdNotifier
sarebbe:
class UserIdNotifier extends ChangeNotifier {
String? userId;
}
// ...
final userIdNotifierProvider = ChangeNotifierProvider<UserIdNotifier>(
(ref) => UserIdNotifier(),
);
Da qui, per generare la nostra String
basata su userId
potremmo fare:
final labelProvider = Provider<String>((ref) {
UserIdNotifier userIdNotifier = ref.watch(userIdNotifierProvider);
return 'The user ID of the the user is ${userIdNotifier.userId}';
});
Notare la riga che usa ref.watch(userIdNotifierProvider)
.
Questa riga di codice dice a Riverpod di ottenere il contenuto di userIdNotifierProvider
e che
ogni volta che quel valore cambia, labelProvider
verrà ricomputato.
Di conseguenza, la String
emessa da labelProvider
si aggiornerà automaticamente ogni volta che cambia userId
.
Questo schema è stato illustrato in precedenza spiegando come leggere i provider all'interno dei widget. Infatti, i provider sono ora in grado di ascoltare altri provider allo stesso modo in cui lo fanno i widget.
Combinare provider: ProxyProvider con oggetti stateful
Quando si combinano i provider, un'altra possibile situazione è esporre oggetti con stato,
come un'istanza di ChangeNotifier
.
Per questo scopo, potremmo utilizzare ChangeNotifierProxyProvider
(o varianti come ChangeNotifierProxyProvider2
).
Ad esempio, potremmo definire:
class UserIdNotifier extends ChangeNotifier {
String? userId;
}
// ...
ChangeNotifierProvider<UserIdNotifier>(create: (context) => UserIdNotifier()),
Successivamente, possiamo definire un nuovo ChangeNotifier
che è basato su UserIdNotifier.userId
.
Per esempio, potremmo fare:
class UserNotifier extends ChangeNotifier {
String? _userId;
void setUserId(String? userId) {
if (userId != _userId) {
print('The user ID changed from $_userId to $userId');
_userId = userId;
}
}
}
// ...
ChangeNotifierProxyProvider<UserIdNotifier, UserNotifier>(
create: (context) => UserNotifier(),
update: (context, userIdNotifier, userNotifier) {
return userNotifier!
..setUserId(userIdNotifier.userId);
},
);
Questo nuovo provider crea un'unica istanza di UserNotifier
(che non viene mai ricostruita)
e stampa una stringa ogni volta che l'ID dell'utente cambia.
Per fare la stessa cosa in Riverpod si procede in modo diverso.
Innanzitutto, in Riverpod, la definizione del nostro UserIdNotifier
sarebbe:
class UserIdNotifier extends ChangeNotifier {
String? userId;
}
// ...
final userIdNotifierProvider = ChangeNotifierProvider<UserIdNotifier>(
(ref) => UserIdNotifier(),
),
Da qui, l'equivalente del precedente ChangeNotifierProxyProvider
sarebbe:
class UserNotifier extends ChangeNotifier {
String? _userId;
void setUserId(String? userId) {
if (userId != _userId) {
print('The user ID changed from $_userId to $userId');
_userId = userId;
}
}
}
// ...
final userNotifierProvider = ChangeNotifierProvider<UserNotifier>((ref) {
final userNotifier = UserNotifier();
ref.listen<UserIdNotifier>(
userIdNotifierProvider,
(previous, next) {
if (previous?.userId != next.userId) {
userNotifier.setUserId(next.userId);
}
},
);
return userNotifier;
});
La parte cruciale di questo snippet è la riga ref.listen
.
Questa funzione ref.listen
è un'utilità che consente di ascoltare un provider e
ogni qualvolta che il provider cambia, esegue una funzione.
I parametri previous
e next
di quella funzione corrispondono all'ultimo valore prima che
il provider sia cambiato e al nuovo valore dopo il cambio.
Ambito (Scoping) dei provider vs .family
+ .autoDispose
In pkg:Provider, lo scope veniva utilizzato per due scopi:
- distruggere lo stato quando si lascia una pagina
- avere uno stato personalizzato per pagina
Utilizzare lo scoping solo per distruggere lo stato non è ideale. Il problema è che lo scoping non funziona bene in applicazioni di grandi dimensioni. Ad esempio, lo stato spesso viene creato in una pagina, ma distrutto in seguito in una pagina diversa dopo la navigazione. Questo non consente di avere più cache attive su pagine diverse.
Allo stesso modo, l'approccio "stato personalizzato per pagina" diventa rapidamente difficile da gestire se lo stato deve essere condiviso con un'altra parte dell'albero dei widget, come potrebbe essere necessario con modali o con un form a più passaggi.
Riverpod adotta un approccio diverso: innanzitutto, lo scoping dei provider è in un certo senso scoraggiato;
in secondo luogo, .family
e .autoDispose
sono una soluzione di sostituzione completa per questo.
All'interno di Riverpod, i provider contrassegnati come .autoDispose
distruggono automaticamente il proprio stato quando non vengono più utilizzati.
Quando l'ultimo widget che rimuove un provider viene smontato, Riverpod lo rileverà e distruggerà il provider.
Prova a utilizzare questi due metodi del ciclo di vita in un provider per testare questo comportamento:
ref.onCancel((){
print("Nessuno sta più in ascolto di me!");
});
ref.onDispose((){
print("Se sono stato definito come `.autoDispose`, sono stato appena distrutto!");
});
Ciò risolve intrinsecamente il problema della "distruzione dello stato".
Inoltre, è possibile contrassegnare un Provider come .family
(e, contemporaneamente, come .autoDispose
).
Questo consente di passare parametri ai provider, il che fa sì che vengano creati e tracciati internamente più provider.
In altre parole, quando si passano parametri, viene creato uno stato unico per ogni parametro univoco.
int random(Ref ref, {required int seed, required int max}) {
return Random(seed).nextInt(max);
}
Ciò risolve il problema dello "stato personalizzato per pagina". In realtà, c'è un altro vantaggio: uno stato del genere non è più vincolato a una pagina specifica. Invece, se una diversa pagina cerca di accedere allo stesso stato, potrà farlo semplicemente riutilizzando i parametri.
In molti modi, il passaggio di parametri ai provider è equivalente ad una Map key. Se la chiave è la stessa, si otterrà lo stesso valore. Se si tratta di una chiave diversa, si otterrà uno stato diverso.