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.
