Perché la Dependency Injection in Flutter

La Dependency Injection (DI) è un pattern che consente di fornire a una classe le sue dipendenze dall'esterno, invece di crearle internamente. In un'app Flutter ben strutturata, questo porta a numerosi vantaggi:

  • Disaccoppiamento: le classi non dipendono dalle implementazioni concrete.
  • Testabilità: è facile sostituire le dipendenze reali con dei mock nei test.
  • Manutenibilità: la configurazione delle dipendenze è centralizzata.

In questo articolo vedremo come implementare la DI usando due pacchetti molto popolari: get_it, un service locator leggero e veloce, e injectable, che genera automaticamente il codice di registrazione.

Installazione delle dipendenze

Aggiungi i pacchetti al tuo pubspec.yaml:

dependencies:
  get_it: ^7.7.0
  injectable: ^2.4.4

dev_dependencies:
  injectable_generator: ^2.6.2
  build_runner: ^2.4.13

Configurazione di base con get_it

get_it da solo permette di registrare e recuperare le dipendenze in modo manuale. L'oggetto centrale è l'istanza GetIt:

import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

void setupLocator() {
  // Singleton: la stessa istanza per tutta l'app
  getIt.registerSingleton<ApiClient>(ApiClient());

  // Lazy singleton: creato solo al primo utilizzo
  getIt.registerLazySingleton<AuthRepository>(
    () => AuthRepository(getIt<ApiClient>()),
  );

  // Factory: nuova istanza ad ogni richiesta
  getIt.registerFactory<LoginBloc>(
    () => LoginBloc(getIt<AuthRepository>()),
  );
}

Per recuperare una dipendenza basta scrivere getIt<AuthRepository>().

Automatizzare la registrazione con injectable

Scrivere a mano tutte le registrazioni diventa rapidamente noioso e soggetto a errori. injectable risolve il problema generando il codice tramite annotazioni.

Configurare l'entry point

Crea un file injection.dart:

import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'injection.config.dart';

final getIt = GetIt.instance;

@InjectableInit()
void configureDependencies() => getIt.init();

Il metodo getIt.init() viene generato automaticamente nel file injection.config.dart.

Annotare le classi

Usa le annotazioni per indicare come registrare ciascuna classe:

import 'package:injectable/injectable.dart';

@singleton
class ApiClient {
  final String baseUrl = 'https://api.example.com';
}

@lazySingleton
class AuthRepository {
  final ApiClient apiClient;

  AuthRepository(this.apiClient);

  Future<bool> login(String email, String password) async {
    // logica di autenticazione
    return true;
  }
}

@injectable
class LoginBloc {
  final AuthRepository authRepository;

  LoginBloc(this.authRepository);
}

Le annotazioni principali sono:

  • @injectable: registra una factory (nuova istanza ogni volta).
  • @singleton: registra un singleton creato immediatamente.
  • @lazySingleton: singleton creato al primo accesso.

Generare il codice

Esegui il comando:

dart run build_runner build --delete-conflicting-outputs

Questo genera injection.config.dart con tutte le registrazioni. Ricorda di chiamare configureDependencies() nel main:

void main() {
  configureDependencies();
  runApp(const MyApp());
}

Registrare interfacce e implementazioni

Un pattern molto comune è dipendere da astrazioni. Con @Injectable(as:) puoi legare un'interfaccia alla sua implementazione:

abstract class UserService {
  Future<User> getUser(String id);
}

@Injectable(as: UserService)
class UserServiceImpl implements UserService {
  @override
  Future<User> getUser(String id) async {
    // implementazione concreta
    return User(id: id);
  }
}

A questo punto richiederai getIt<UserService>() ottenendo l'implementazione concreta, senza accoppiamento diretto.

Gestire dipendenze asincrone

Alcune dipendenze richiedono un'inizializzazione asincrona, come SharedPreferences. Usa @preResolve:

@module
abstract class RegisterModule {
  @preResolve
  Future<SharedPreferences> get prefs => SharedPreferences.getInstance();
}

In questo caso configureDependencies() deve diventare asincrono:

@InjectableInit()
Future<void> configureDependencies() => getIt.init();

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await configureDependencies();
  runApp(const MyApp());
}

Ambienti e mock per i test

injectable supporta gli ambienti, utilissimi per i test. Puoi annotare classi con @Environment:

@Environment('test')
@Injectable(as: UserService)
class MockUserService implements UserService {
  @override
  Future<User> getUser(String id) async => User(id: 'mock');
}

E nei test inizializzare con l'ambiente desiderato:

void main() {
  setUp(() {
    configureDependencies(environment: 'test');
  });

  // ... i tuoi test
}

Best practice

  • Centralizza la configurazione: mantieni un unico punto di setup delle dipendenze.
  • Dipendi da astrazioni: usa interfacce per facilitare il testing.
  • Evita di abusare del service locator dentro i widget: preferisci passare le dipendenze tramite costruttori o combinare get_it con un sistema di state management.
  • Resetta get_it nei test con getIt.reset() tra un test e l'altro per evitare effetti collaterali.

Conclusioni

La combinazione di get_it e injectable offre un sistema di dependency injection potente, conciso e con un boilerplate minimo grazie alla generazione automatica del codice. Adottare la DI fin dall'inizio del progetto rende l'architettura più pulita, modulare e soprattutto facilmente testabile, elementi fondamentali per un'app Flutter scalabile.