flutter-architecture-expert

Par flutter-it · get_it

Guide d'architecture pour les applications Flutter utilisant le kit de construction flutter_it (get_it, watch_it, command_it, listen_it). Couvre l'architecture Flutter pragmatique (PFA) avec Services/Managers/Views, la structure de projet orientée fonctionnalités, le patron manager, le patron proxy avec mises à jour optimistes et champs de substitution, DataRepository avec comptage de références, les services scopés, la granularité des widgets, les tests et les bonnes pratiques. À utiliser lors de la conception d'une architecture d'application, de la structuration de projets Flutter, de l'implémentation de managers ou de proxies, ou de la planification de l'organisation par fonctionnalités.

npx skills add https://github.com/flutter-it/get_it --skill flutter-architecture-expert

flutter_it Architecture Expert - Structure & Patterns d'Application

Quoi : Guidance d'architecture pour les applications Flutter utilisant le kit de construction flutter_it (get_it + watch_it + command_it + listen_it).

Démarrage de l'Application

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  configureDependencies();  // Enregistrer tous les services (sync)
  runApp(MyApp());
}

// L'écran de démarrage attend les services async
class SplashScreen extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final ready = allReady(
      onReady: (context) => Navigator.pushReplacement(context, mainRoute),
    );
    if (!ready) return CircularProgressIndicator();
    return MainApp();
  }
}

Architecture Pragmatique Flutter (PFA)

Trois composants : Services (limites externes), Managers (logique métier), Views (UI auto-responsable).

  • Services : Encapsulent UN aspect externe (API REST, base de données, service OS, matériel). Convertissent les données depuis/vers des formats externes (JSON). NE MODIFIENT PAS l'état de l'application.
  • Managers : Encapsulent une logique métier sémantiquement liée (UserManager, BookingManager). PAS des ViewModels – ne correspondent pas 1:1 aux vues. Fournissent Commands/ValueListenables pour l'UI. Utilisent Services ou d'autres Managers.
  • Views : Pages complètes ou widgets de haut niveau. Auto-responsables – savent quelles données elles ont besoin. Lisent les données des Managers via ValueListenables. Modifient les données via Managers, jamais directement via Services.

Structure du Projet (par feature, PAS par couche)

lib/
  _shared/                   # Partagé entre features (le préfixe _ les trie en haut)
    services/                # Services cross-feature
    widgets/                 # Widgets réutilisables
    models/                  # Objets domaine partagés
  features/
    auth/
      pages/                 # Vues plein écran
      widgets/               # Widgets spécifiques à la feature
      manager/               # AuthManager, commands
      model/                 # User, UserProxy, DTOs
      services/              # AuthApiService
    chat/
      pages/
      widgets/
      manager/
      model/
      services/
  locator.dart               # Configuration DI (enregistrements get_it)

Règles clés :

  • Organisez par features, pas par couches
  • Déplacez un composant vers _shared/ seulement si plusieurs features en ont besoin
  • Pas de classes interface par défaut – seulement si vous savez avoir plusieurs implémentations

Motif Manager

Les Managers encapsulent une logique métier sémantiquement liée, enregistrée dans get_it. Ils fournissent Commands et ValueListenables pour l'UI :

class UserManager extends ChangeNotifier {
  final _userState = ValueNotifier<UserState>(UserState.loggedOut);
  ValueListenable<UserState> get userState => _userState;

  late final loginCommand = Command.createAsync<LoginRequest, User>(
    (request) async {
      final api = di<ApiClient>();
      return await api.login(request);
    },
    initialValue: User.empty(),
    errorFilter: const GlobalIfNoLocalErrorFilter(),
  );

  late final logoutCommand = Command.createAsyncNoParamNoResult(
    () async { await di<ApiClient>().logout(); },
  );

  void dispose() { /* cleanup */ }
}

// Enregistrement
di.registerLazySingleton<UserManager>(
  () => UserManager(),
  dispose: (m) => m.dispose(),
);

// Utilisation dans un widget
class LoginWidget extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final isRunning = watch(di<UserManager>().loginCommand.isRunning).value;
    registerHandler(
      select: (UserManager m) => m.loginCommand.errors,
      handler: (context, error, _) {
        showErrorSnackbar(context, error.error);
      },
    );
    return ElevatedButton(
      onPressed: isRunning ? null : () => di<UserManager>().loginCommand.run(request),
      child: isRunning ? CircularProgressIndicator() : Text('Login'),
    );
  }
}

Services avec Portée (Sessions Utilisateur)

// Services de base (survivent aux erreurs)
void setupBaseServices() {
  di.registerSingleton<ApiClient>(createApiClient());
  di.registerSingleton<CacheManager>(WcImageCacheManager());
}

// Scope jetable (peut être réinitialisé sur erreurs)
void setupThrowableScope() {
  di.pushNewScope(scopeName: 'throwable');
  di.registerLazySingletonAsync<StoryManager>(
    () async => StoryManager().init(),
    dispose: (m) => m.dispose(),
    dependsOn: [UserManager],
  );
}

// Scope session utilisateur (créé à la connexion, détruit à la déconnexion)
void createUserSession(User user) {
  di.pushNewScope(
    scopeName: 'user-session',
    init: (getIt) {
      getIt.registerSingleton<User>(user);
      getIt.registerLazySingleton<UserPrefs>(() => UserPrefs(user.id));
    },
  );
}

Future<void> logout() async {
  await di.popScope();  // Dispose les services de session utilisateur
}

Motif Proxy

Les Proxies encapsulent les types DTO avec comportement réactif – propriétés calculées, commands et notification de changement. Le DTO contient les données brutes, le proxy ajoute la couche « intelligente » par dessus.

// Proxy simple – encapsule un DTO, ajoute du comportement
class UserProxy extends ChangeNotifier {
  UserProxy(this._user);

  UserDto _user;
  UserDto get user => _user;

  // Met à jour les données sous-jacentes, notifie les observateurs
  set user(UserDto value) {
    _user = value;
    notifyListeners();
  }

  // Propriétés calculées sur le DTO
  String get displayName => '${_user.firstName} ${_user.lastName}';
  bool get isVerified => _user.verificationStatus == 'verified';

  // Commands pour les opérations sur cette entité
  late final toggleFollowCommand = Command.createAsyncNoParamNoResult(
    () async {
      await di<ApiClient>().toggleFollow(_user.id);
    },
    errorFilter: const GlobalIfNoLocalErrorFilter(),
  );

  late final updateAvatarCommand = Command.createAsyncNoResult<File>(
    (file) async {
      _user = await di<ApiClient>().uploadAvatar(_user.id, file);
      notifyListeners();
    },
  );
}

// Utilisation dans un widget – regardez le proxy pour les mises à jour réactives
class UserCard extends WatchingWidget {
  final UserProxy user;
  @override
  Widget build(BuildContext context) {
    watch(user);  // Reconstruit quand le proxy notifie
    final isFollowing = watch(user.toggleFollowCommand.isRunning).value;
    return Column(children: [
      Text(user.displayName),
      if (user.isVerified) Icon(Icons.verified),
    ]);
  }
}

Mises à jour UI optimistes avec motif override – ne modifiez pas le DTO, utilisez des champs override qui se placent par dessus :

class PostProxy extends ChangeNotifier {
  PostProxy(this._target);
  PostDto _target;

  // Champ override – nullable, se place par dessus la valeur DTO
  bool? _likeOverride;

  // Le getter retourne l'override s'il est défini, sinon se rabat sur le DTO
  bool get isLiked => _likeOverride ?? _target.isLiked;
  String get title => _target.title;

  // Mise à jour de la cible depuis l'API efface tous les overrides
  set target(PostDto value) {
    _likeOverride = null;  // Efface l'override sur les données fraîches
    _target = value;
    notifyListeners();
  }

  // Approche simple : définir l'override, inverser sur erreur
  late final toggleLikeCommand = Command.createAsyncNoParamNoResult(
    () async {
      _likeOverride = !isLiked;  // Mise à jour UI instantanée
      notifyListeners();
      if (_likeOverride!) {
        await di<ApiClient>().likePost(_target.id);
      } else {
        await di<ApiClient>().unlikePost(_target.id);
      }
    },
    restriction: commandRestrictions,
    errorFilter: const LocalAndGlobalErrorFilter(),
  )..errors.listen((e, _) {
      _likeOverride = !_likeOverride!;  // Inverser en cas d'erreur
      notifyListeners();
    });

  // Ou utiliser UndoableCommand pour rollback automatique
  late final toggleLikeUndoable = Command.createUndoableNoParamNoResult<bool>(
    (undoStack) async {
      undoStack.push(isLiked);  // Sauvegarder l'état actuel
      _likeOverride = !isLiked;
      notifyListeners();
      if (_likeOverride!) {
        await di<ApiClient>().likePost(_target.id);
      } else {
        await di<ApiClient>().unlikePost(_target.id);
      }
    },
    undo: (undoStack, reason) {
      _likeOverride = undoStack.pop();  // Restaurer l'état précédent
      notifyListeners();
    },
  );
}

Règles clés pour les mises à jour optimistes dans les proxies :

  • N'UTILISEZ JAMAIS copyWith sur les DTOs – utilisez plutôt des champs override nullable
  • Le getter retourne _override ?? _target.field (l'override gagne, se rabat sur le DTO)
  • Sur actualisation API : effacez tous les overrides, mettez à jour la cible
  • Sur erreur : inversez l'override (simple) ou dépillez de la pile d'annulation (UndoableCommand)

Proxy avec fallbacks intelligents (données chargées vs initiales) :

class PodcastProxy extends ChangeNotifier {
  PodcastProxy({required this.item});
  final SearchItem item;  // Données initiales légères

  Podcast? _podcast;  // Données complètes chargées plus tard
  List<Episode>? _episodes;

  // Les getters se rabattent sur les données initiales si les données complètes pas encore chargées
  String? get title => _podcast?.title ?? item.collectionName;
  String? get image => _podcast?.image ?? item.bestArtworkUrl;

  late final fetchCommand = Command.createAsyncNoParam<List<Episode>>(
    () async {
      if (_episodes != null) return _episodes!;  // Cache
      final result = await di<PodcastService>().findEpisodes(item: item);
      _podcast = result.podcast;
      _episodes = result.episodes;
      return _episodes!;
    },
    initialValue: [],
  );
}

Avancé : DataRepository avec Comptage de Références

Quand la même entité apparaît à plusieurs endroits (feeds, pages de détail, résultats de recherche), utilisez un repository pour dédupliquer les proxies et gérer leur cycle de vie via comptage de références :

abstract class DataProxy<T> extends ChangeNotifier {
  DataProxy(this._target);
  T _target;
  int _referenceCount = 0;

  T get target => _target;
  set target(T value) { _target = value; notifyListeners(); }

  @override
  void dispose() {
    assert(_referenceCount == 0);
    super.dispose();
  }
}

abstract class DataRepository<T, TProxy extends DataProxy<T>, TId> {
  final _proxies = <TId, TProxy>{};

  TId identify(T item);
  TProxy makeProxy(T entry);

  // Retourne le proxy existant (mis à jour) ou en crée un nouveau
  TProxy createProxy(T item) {
    final id = identify(item);
    if (!_proxies.containsKey(id)) {
      _proxies[id] = makeProxy(item);
    } else {
      _proxies[id]!.target = item;  // Mise à jour avec des données fraîches
    }
    _proxies[id]!._referenceCount++;
    return _proxies[id]!;
  }

  void releaseProxy(TProxy proxy) {
    proxy._referenceCount--;
    if (proxy._referenceCount == 0) {
      proxy.dispose();
      _proxies.remove(identify(proxy.target));
    }
  }
}

Flux de comptage de références :

Feed crée ChatProxy(id=1) -> refCount=1
Page ouvre le même proxy  -> refCount=2
Page se ferme, relâche    -> refCount=1 (proxy reste pour le feed)
Feed s'actualise, relâche -> refCount=0 (proxy dispose)

Motif Feed/DataSource

Pour les listes paginées et le défilement infini, voir la compétence dédiée feed-datasource-expert. Concepts clés : FeedDataSource<TItem> (non-paginé) et PagedFeedDataSource<TItem> (pagination basée sur curseur) avec Commands séparés pour le chargement initial vs pagination, auto-pagination à items.length - 3, et comptage de références proxy sur actualisation.

Granularité des Widgets

Un widget regardant plusieurs objets est parfaitement acceptable. Ne séparez en plus petits WatchingWidgets que si les valeurs regardées changent à des fréquences différentes et la reconstruction est coûteuse. Gardez l'équilibre – ne sur-divisez pas. Seuls les widgets qui regardent des valeurs devraient être des WatchingWidgets :

// ✅ Le parent ne regarde pas – StatelessWidget simple
class MyScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(children: [_Header(), _Counter()]);
  }
}

// Chaque enfant regarde seulement ce dont IL a besoin
class _Header extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final user = watchValue((Auth x) => x.currentUser);
    return Text(user.name);
  }
}
class _Counter extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final count = watchValue((Counter x) => x.count);
    return Text('$count');
  }
}
// Résultat : un changement utilisateur reconstruit seulement _Header, un changement de compte reconstruit seulement _Counter

Note : Quand vous travaillez avec Listenable, ValueListenable, ChangeNotifier, ou ValueNotifier, consultez la compétence listen-it-expert pour listen() et les opérateurs réactifs (map, debounce, where, etc.).

Tests

// Option 1 : scopes get_it pour les mocks
setUp(() {
  GetIt.I.pushNewScope(
    init: (getIt) {
      getIt.registerSingleton<ApiClient>(MockApiClient());
    },
  );
});
tearDown(() async {
  await GetIt.I.popScope();
});

// Option 2 : Injection de constructeur hybride (commodité optionnelle)
class MyService {
  final ApiClient api;
  MyService({ApiClient? api}) : api = api ?? di<ApiClient>();
}
// Test : MyService(api: MockApiClient())

Manager init() vs Commands

Manager init() charge les données initiales via appels API directs, pas via commands. Les Commands sont l'interface réactive exposée à l'UI – les widgets regardent leur isRunning, errors, et results. Ne routez pas l'init via commands :

class MyManager {
  final items = ValueNotifier<List<Item>>([]);

  // Command pour l'actualisation déclenchée par l'UI (le widget regarde isRunning)
  late final loadCommand = Command.createAsyncNoParam<List<Item>>(
    () async {
      final result = await di<ApiClient>().getItems();
      items.value = result;
      return result;
    },
    initialValue: [],
  );

  // init() appelle l'API directement – pas besoin de command
  Future<MyManager> init() async {
    items.value = await di<ApiClient>().getItems();
    return this;
  }
}

Ne nichez pas les commands : Si un command a besoin de recharger les données après une mutation, appelez l'API directement dans le corps du command – n'appelez pas run() d'un autre command :

// ✅ Appel API direct dans le command
late final deleteCommand = Command.createAsync<int, bool>((id) async {
  final result = await di<ApiClient>().delete(id);
  items.value = await di<ApiClient>().getItems(); // recharger directement
  return result;
}, initialValue: false);

// ❌ N'appelez pas un command d'intérieur d'un command
late final deleteCommand = Command.createAsync<int, bool>((id) async {
  final result = await di<ApiClient>().delete(id);
  loadCommand.run(); // INCORRECT – nicher les commands
  return result;
}, initialValue: false);

Réagir aux Résultats de Command

Dans les WatchingWidgets : Utilisez registerHandler sur les résultats de command pour les effets de bord (navigation, dialogues). N'utilisez jamais addListener ou runAsync() :

class MyPage extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final isRunning = watchValue((MyManager m) => m.createCommand.isRunning);

    // Réagir au résultat – naviguer en cas de succès
    registerHandler(
      select: (MyManager m) => m.createCommand.results,
      handler: (context, result, cancel) {
        if (result.hasData && result.data != null) {
          appPath.push(DetailRoute(id: result.data!.id));
        }
      },
    );

    return ElevatedButton(
      onPressed: isRunning ? null : () => di<MyManager>().createCommand.run(params),
      child: isRunning ? CircularProgressIndicator() : Text('Create'),
    );
  }
}

En dehors des widgets (managers, services) : Utilisez listen_it listen() au lieu de raw addListener – il retourne une ListenableSubscription pour annulation facile :

_subscription = someCommand.results.listen((result, subscription) {
  if (result.hasData) doSomething(result.data);
});
// plus tard : _subscription.cancel();

Où Appartient allReady()

allReady() appartient à l'UI (WatchingWidget), pas au code impératif. Le widget racine avec allReady() affiche un indicateur de chargement jusqu'à ce que tous les singletons async (incluant les scopes récemment poussés) soient prêts :

// ✅ L'UI gère l'état de chargement
class MyApp extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    if (!allReady()) return LoadingScreen();
    return MainApp();
  }
}

// ✅ Pousser un scope, laisser l'UI réagir
Future<void> onAuthenticated(Client client) async {
  di.pushNewScope(scopeName: 'auth', init: (scope) {
    scope.registerSingleton<Client>(client);
    scope.registerSingletonAsync<MyManager>(() => MyManager().init(), dependsOn: [Client]);
  });
  // Pas d'await di.allReady() ici – l'UI la gère
}

Gestion des Erreurs

Trois couches : InteractionManager (abstraction toast), gestionnaire global (catch-all), listeners locaux (messages personnalisés).

InteractionManager

Un singleton sync enregistré avant les services async. Abstrait les retours utilisateur (toasts, dialogues futures). Reçoit un BuildContext via un widget connecteur pour pouvoir afficher l'UI dépendant du contexte sans enfiler le contexte via les managers :

class InteractionManager {
  BuildContext? _context;

  void setContext(BuildContext context) => _context = context;

  BuildContext? get stableContext {
    final ctx = _context;
    if (ctx != null && ctx.mounted) return ctx;
    return null;
  }

  void showToast(String message, {bool isError = false}) {
    Fluttertoast.showToast(msg: message, ...);
  }
}

// Widget connecteur – enveloppez le contenu de l'application à l'intérieur de MaterialApp
class InteractionConnector extends StatefulWidget { ... }
class _InteractionConnectorState extends State<InteractionConnector> {
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    di<InteractionManager>().setContext(context);
  }
  @override
  Widget build(BuildContext context) => widget.child;
}

Enregistrez sync dans le scope de base (avant les singletons async) :

di.registerSingleton<InteractionManager>(InteractionManager());

Gestionnaire d'Exception Global

Une méthode statique sur votre coordinateur d'app (p. ex. TheApp), assignée à Command.globalExceptionHandler dans main(). Capture toute erreur de command qui n'a pas de listener .errors local (par défaut ErrorReaction.firstLocalThenGlobalHandler) :

// Dans TheApp
static void globalErrorHandler(CommandError error, StackTrace stackTrace) {
  debugPrint('Command error [${error.commandName}]: ${error.error}');
  di<InteractionManager>().showToast(error.error.toString(), isError: true);
}

// Dans main()
Command.globalExceptionHandler = TheApp.globalErrorHandler;

Listeners d'Erreur Locaux

Pour les commands où vous voulez un message convivial au lieu de l'exception brute, ajoutez .errors.listen() (listen_it) dans la méthode init() du manager. Ceux-ci suppriment le gestionnaire global :

Future<MyManager> init() async {
  final interaction = di<InteractionManager>();
  startSessionCommand.errors.listen((error, _) {
    interaction.showToast('Impossible de démarrer la session', isError: true);
  });
  submitOutcomeCommand.errors.listen((error, _) {
    interaction.showToast('Impossible de soumettre le résultat', isError: true);
  });
  // ... charger les données initiales
  return this;
}

Flux : Command échoue → ErrorFilter (par défaut : firstLocalThenGlobalHandler) → si .errors local a des listeners, seulement eux se déclenchent → si pas de listeners locaux, le gestionnaire global se déclenche → toast affiché.

Bonnes Pratiques

  • Enregistrez tous les services avant runApp()
  • Utilisez allReady() dans les WatchingWidgets pour le chargement des services async – pas dans le code impératif
  • Séparez l'UI en petits WatchingWidgets (regardez seulement ce dont vous avez besoin)
  • Utilisez les managers (sous-classes ChangeNotifier/ValueNotifier) pour l'état
  • Utilisez les commands pour les opérations async déclenchées par l'UI avec états de chargement/erreur
  • Manager init() appelle les APIs directement, les commands sont pour l'interaction UI
  • Ne nichez pas les commands – utilisez les appels API directs pour la logique interne
  • Utilisez les scopes pour les sessions utilisateur et les services réinitialisables
  • Utilisez createOnce() pour les objets jetables locaux aux widgets
  • Utilisez registerHandler() pour les effets de bord dans les widgets (dialogues, navigation, snackbars)
  • Utilisez listen_it listen() pour les effets de bord en dehors des widgets (managers, services)
  • N'utilisez jamais raw addListener – utilisez registerHandler (widgets) ou listen() (non-widgets)
  • Utilisez run() pas execute() sur les commands
  • Utilisez les proxies pour encapsuler les DTOs avec comportement réactif (commands, propriétés calculées, notification de changement)
  • Utilisez DataRepository avec comptage de références quand la même entité apparaît à plusieurs endroits

Skills similaires