Start Debugging

Von FutureBuilder zu einem Riverpod AsyncNotifier in Flutter migrieren (flutter_riverpod 3.3.2)

Eine schrittweise Migration von einem inline FutureBuilder-Widget zu einem Riverpod AsyncNotifier in einer echten Flutter-App: die asynchrone Arbeit aus build herausziehen, als Provider bereitstellen, mit .when() oder switch-Mustervergleich rendern sowie Refresh- und Mutationsmethoden ergänzen. Getestet mit Flutter 3.44, Dart 3.x, flutter_riverpod 3.3.2.

Einen Screen von FutureBuilder zu einem Riverpod AsyncNotifier umzustellen ist pro Screen üblicherweise eine Aufgabe von 30 bis 60 Minuten, und die meiste Zeit verbringen Sie damit, Code zu löschen, statt ihn zu schreiben. Was sich ändert: Das Future, das Sie früher innerhalb von build erzeugt haben, wandert in einen Provider, das Widget verliert seinen StatefulWidget-Boilerplate, und jede manuelle setState-Retry-Logik wird durch ref.invalidate ersetzt. Es lohnt sich in dem Moment, in dem ein zweites Widget dieselben Daten benötigt, Sie über Navigation hinweg zwischenspeichern wollen oder einen Refresh von einer anderen Stelle als dem Widget aus auslösen müssen, das den FutureBuilder besitzt. Wenn ein Screen tatsächlich ein einmaliges Future besitzt, das nichts anderes berührt, lassen Sie es als FutureBuilder — diese Migration bringt Ihnen dort nichts.

Diese Anleitung verwendet Flutter 3.44, Dart 3.x und flutter_riverpod 3.3.2. Die Codegenerierungs-Snippets setzen riverpod_annotation 3.x und riverpod_generator 3.x mit build_runner voraus.

Warum von FutureBuilder weg

Was sich ändert

BereichVorher (FutureBuilder)Nachher (AsyncNotifier)Schweregrad
Wo das Future lebtIn build oder initState erzeugtbuild()-Methode des Notifiershoch
Widget-TypÜblicherweise StatefulWidgetConsumerWidget (stateless)mittel
Loading-/Error-Renderingsnapshot.connectionState + snapshot.hasErrorAsyncValue.when oder switchmittel
RetryRebuild + Future neu erzeugenref.invalidate(provider)niedrig
MutationensetState nach awaitMethode + AsyncValue.guardmittel
Abbruch bei DisposeManuelle mounted-PrüfungenAutomatisch über ref.onDisposeniedrig

Der einzige wirklich schwerwiegende Punkt ist, wo das Future lebt: Alles andere folgt aus dessen Verschiebung.

Pre-flight-Checkliste

// Flutter 3.44, flutter_riverpod 3.3.2
void main() {
  runApp(const ProviderScope(child: MyApp()));
}

Der Ausgangspunkt: ein inline FutureBuilder

Hier ist das Muster, von dem wir wegmigrieren. Ein Profil-Screen ruft einen Benutzer ab und rendert drei Zustände von Hand. Der eingebaute Bug ist der klassische: repo.fetchUser(userId) läuft bei jedem Rebuild erneut, weil das Future innerhalb von build erzeugt wird.

// Flutter 3.44, Dart 3.x -- the BEFORE
class ProfileScreen extends StatelessWidget {
  const ProfileScreen({super.key, required this.userId});
  final String userId;

  @override
  Widget build(BuildContext context) {
    final repo = UserRepository();
    return FutureBuilder<User>(
      future: repo.fetchUser(userId), // re-runs on every rebuild
      builder: (context, snapshot) {
        if (snapshot.connectionState != ConnectionState.done) {
          return const Center(child: CircularProgressIndicator());
        }
        if (snapshot.hasError) {
          return Center(child: Text('Failed: ${snapshot.error}'));
        }
        final user = snapshot.data!;
        return Text(user.name);
      },
    );
  }
}

Migrationsschritte

  1. Den Provider deklarieren. Verschieben Sie den asynchronen Aufruf in einen Notifier. Es gibt zwei Schreibweisen; wählen Sie eine und bleiben Sie über die Codebasis hinweg konsistent.

Codegenerierungs-Stil (empfohlen für neuen Code):

// flutter_riverpod 3.3.2, riverpod_annotation 3.x
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'profile_controller.g.dart';

@riverpod
class ProfileController extends _$ProfileController {
  @override
  Future<User> build(String userId) {
    return ref.watch(userRepositoryProvider).fetchUser(userId);
  }
}

Der Parameter userId auf build macht dies zu einer Family: profileControllerProvider(userId) gibt Ihnen einen zwischengespeicherten Notifier pro id. Führen Sie den Generator aus und prüfen Sie, dass er die .g.dart-Datei ohne Fehler erzeugt:

# verify: the build completes and emits profile_controller.g.dart
dart run build_runner build --delete-conflicting-outputs

Manueller Stil (keine Codegenerierung):

// flutter_riverpod 3.3.2
final profileControllerProvider =
    AsyncNotifierProvider.family<ProfileController, User, String>(
  ProfileController.new,
);

class ProfileController extends FamilyAsyncNotifier<User, String> {
  @override
  Future<User> build(String userId) {
    return ref.watch(userRepositoryProvider).fetchUser(userId);
  }
}

Beide erzeugen einen Provider, dessen Wert ein AsyncValue<User> ist. Das build des Notifiers läuft einmal pro userId, und das Ergebnis wird zwischengespeichert, bis es invalidiert wird. Beachten Sie, dass Sie UserRepository() nicht mehr von Hand konstruieren: Injizieren Sie es über einen anderen Provider, damit es testbar und geteilt ist.

  1. Das Widget in ein ConsumerWidget umwandeln. Aus dem StatefulWidget/StatelessWidget wird ein ConsumerWidget, und build erhält eine WidgetRef. Lesen Sie den Provider mit ref.watch und rendern Sie dann das AsyncValue.
// flutter_riverpod 3.3.2 -- the AFTER
class ProfileScreen extends ConsumerWidget {
  const ProfileScreen({super.key, required this.userId});
  final String userId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(profileControllerProvider(userId));
    return userAsync.when(
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (err, stack) => Center(child: Text('Failed: $err')),
      data: (user) => Text(user.name),
    );
  }
}

Prüfen: Führen Sie einen Hot-Restart des Screens durch. Er sollte genau einmal abrufen. Navigieren Sie weg und zurück — er sollte nicht erneut abrufen (der Cache lebt, solange etwas den Provider eingehängt hält). Dieses Einmal-Abruf-Verhalten ist der gesamte Sinn der Migration.

  1. Mit switch-Mustervergleich rendern (optional, aber sauberer). Der Mustervergleich von Dart 3 liest sich für manche Teams besser als .when() und erlaubt Ihnen, während eines Refreshs veraltete Daten sichtbar zu halten. Die vollständige Behandlung dieser Muster findet sich unter Loading- und Error-Zustände mit AsyncValue anzeigen, aber die Kurzfassung:
// Dart 3.x switch over AsyncValue
final userAsync = ref.watch(profileControllerProvider(userId));
return switch (userAsync) {
  AsyncData(:final value) => Text(value.name),
  AsyncError(:final error) => Text('Failed: $error'),
  _ => const Center(child: CircularProgressIndicator()),
};

Prüfen: Dies kompiliert ohne eine non-exhaustive switch-Warnung. Das _ als Catch-all behandelt AsyncLoading.

  1. Retry durch ref.invalidate ersetzen. Der alte Retry-Pfad erzeugte das Future durch einen Rebuild neu. Jetzt ist Retry eine Zeile. Fügen Sie dem Error-Zweig eine Schaltfläche hinzu:
// flutter_riverpod 3.3.2
error: (err, stack) => Center(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Text('Failed: $err'),
      ElevatedButton(
        onPressed: () => ref.invalidate(profileControllerProvider(userId)),
        child: const Text('Retry'),
      ),
    ],
  ),
),

ref.invalidate verwirft den zwischengespeicherten Wert und führt build erneut aus, was das AsyncValue zurück auf loading und dann auf data oder error schaltet. Prüfen: Erzwingen Sie einen Fehler (schalten Sie das Netzwerk aus), tippen Sie bei wiederhergestelltem Netzwerk auf Retry und bestätigen Sie, dass es von loading zu data übergeht.

  1. Mutationen mit AsyncValue.guard ergänzen. Das ist die Fähigkeit, die FutureBuilder nie hatte. Um den Benutzer zu aktualisieren und das Ergebnis abzubilden, fügen Sie dem Notifier eine Methode hinzu. AsyncValue.guard umschließt den asynchronen Aufruf, sodass eine geworfene Exception zu einem AsyncError wird statt zu einem unbehandelten Absturz.
// flutter_riverpod 3.3.2
@riverpod
class ProfileController extends _$ProfileController {
  @override
  Future<User> build(String userId) {
    return ref.watch(userRepositoryProvider).fetchUser(userId);
  }

  Future<void> rename(String newName) async {
    final repo = ref.read(userRepositoryProvider);
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      await repo.rename(userId, newName);
      return repo.fetchUser(userId);
    });
  }
}

Rufen Sie sie aus dem Widget mit ref.read(...).rename(...) innerhalb eines Callbacks auf (verwenden Sie read, nicht watch, in Callbacks). Prüfen: Lösen Sie ein Rename aus, beobachten Sie, wie die UI in loading geht und dann den neuen Namen anzeigt; lösen Sie ein fehlschlagendes Rename aus und bestätigen Sie, dass der Error-Zustand gerendert wird, statt eine Exception zu werfen.

Verifizierung

Arbeiten Sie diese Checkliste nach der Migration eines Screens ab:

// flutter_test + flutter_riverpod 3.3.2
test('loads the user', () async {
  final container = ProviderContainer(overrides: [
    userRepositoryProvider.overrideWithValue(FakeUserRepository()),
  ]);
  addTearDown(container.dispose);

  final user = await container.read(profileControllerProvider('42').future);
  expect(user.name, 'Ada');
});

Rollback-Plan

Diese Migration ist pro Screen umkehrbar, weil Sie ein Widget nach dem anderen konvertieren können. Um einen einzelnen Screen zurückzurollen, stellen Sie das FutureBuilder-Widget wieder her und löschen Sie dessen Provider; nichts anderes hängt davon ab, wenn Sie inkrementell migriert haben. Die einzige Einbahnstraße ist das Entfernen der alten StatefulWidget-Verkabelung über viele Screens hinweg in einem einzigen Commit — tun Sie das nicht. Halten Sie die Migration jedes Screens in einem eigenen Commit, damit ein Revert ein einzeiliges git revert ist.

Stolpersteine, auf die wir gestoßen sind

ref.watch innerhalb von Callbacks kompiliert nichts Nützliches neu. Verwenden Sie in einem onPressed-Handler ref.read. watch ist für build; es in einem Callback zu verwenden, abonniert zum falschen Zeitpunkt und ist eine häufige Quelle der Verwirrung “meine Schaltfläche aktualisiert den Screen nicht”.

Der Family-Parameter muss stabil sein. profileControllerProvider(userId) schlüsselt den Cache anhand von userId. Wenn Sie versehentlich ein frisch konstruiertes Objekt (eine neue User-Instanz, eine Map) statt eines wertgleichen Schlüssels übergeben, erhalten Sie bei jedem Rebuild einen frischen Notifier, und der Cache trifft nie. Verwenden Sie Primitive oder Typen mit ordentlichem ==.

Disposed ref nach einem await. Wenn eine Mutation wartet und der Provider mitten im Flug disposed wird (der Benutzer ist weggenavigiert), führt das Berühren von ref danach zu einer Exception. Riverpod 3 macht dies klar sichtbar; die Behebung und die genaue Meldung finden sich unter Behebung von “Cannot use ref after the widget was disposed”. Schützen Sie mit ref.mounted, falls Sie ref nach einem await in einer langen Mutation berühren müssen.

Der Provider wird zu früh disposed. Standardmäßig wird ein Provider ohne Listener disposed. Wenn Sie wegnavigieren und zurückkehren und einen erneuten Abruf sehen, den Sie nicht wollten, dann macht Auto-Dispose seine Arbeit. Halten Sie ihn bewusst mit ref.keepAlive() innerhalb von build am Leben oder akzeptieren Sie den erneuten Abruf als korrektes Cache-Verhalten.

Mischen Sie dies nicht mit Zustand aus dem provider-Paket. Wenn Ihre App anderswo noch das alte provider-Paket verwendet, migrieren Sie das separat; die beiden koexistieren, verwischen aber das mentale Modell. Die Migration von provider zu Riverpod behandelt diesen Weg. Und falls Sie noch entscheiden, ob AsyncNotifier für ein bestimmtes Widget überhaupt die richtige Wahl ist, zieht der Entscheidungsleitfaden FutureBuilder vs. Riverpod AsyncValue die Grenze.

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück