Dependency Injection
Dependency Injection (DI) is a powerful design pattern that helps separate concerns by decoupling object creation from business logic. It improves code maintainability, scalability, and testability, especially in complex applications.In Flutter, DI becomes essential for managing services, repositories, and view models that various components of the app depend on. The get_it package is one of the most popular DI solutions for Flutter, providing an easy way to register and resolve dependencies. However, as applications grow, using get_it manually can become cumbersome, leading to a need for more automation and advanced features like scoped dependencies.
This is where the injectable package comes into play, providing automatic code generation and enhanced scope management.
Dependency Injection with get_it
get_it serves as a simple service locator, allowing developers to register and retrieve dependencies. It works well in smaller apps or straightforward use cases. Here’s why many developers start with get_it:- Simplicity: Registering services, repositories, and view models in get_it is straightforward and minimalistic.
- Flexibility: It supports registering singletons, factories, and even instances on demand.
- Global Access: Once a dependency is registered, it can be accessed globally throughout the app using the locator.
import 'package:get_it/get_it.dart';
final GetIt locator = GetIt.instance;
void setupLocator() {
locator.registerSingleton<LocalDataSource>(LocalDataSourceImpl());
locator.registerSingleton<RemoteDataSource>(RemoteDataSourceImpl());
locator.registerSingleton<ProductsRepository>(
ProductsRepositoryImpl(
localDataSource: locator<LocalDataSource>(),
remoteDataSource: locator<RemoteDataSource>(),
),
);
locator.registerSingleton<ProductViewModel>(
() => ProductViewModel(repository: locator<ProductsRepository>()),
);
}
Moving to injectable: Automating Dependency Injection
As your Flutter app becomes more complex, you’ll start to notice several challenges with get_it:- Manual Registrations: Manually registering dependencies can become error-prone as the number of services and dependencies grows.
- Maintenance Overhead: It becomes harder to manage dependencies across large applications, especially when you need to refactor or introduce new components.
- Testing: Setting up mock services for testing requires manually overriding or un-registering services, which can be tedious.
- Scopes: Managing the lifecycle of dependencies, such as creating and destroying services for specific user sessions or screens, becomes tricky.
Installing package:
dependencies:
get_it: ^8.0.1
injectable: ^2.5.0
dev_dependencies:
build_runner: ^2.4.11
injectable_generator: ^2.6.2
Key Benefits of Moving to injectable:
- Automatic Code Generation: Using annotations, injectable automates the registration process, reducing the need for manual setup and ensuring consistency.
- Scope Management: With built-in support for scoped dependencies, injectable allows for better control over the lifecycle of services.
- Modularization: Dependencies can be split into modules or features, allowing for more granular control over which services are available in which parts of the app.
- Testability: injectable makes it easier to mock dependencies for testing, by allowing you to swap out services in specific scopes.
import 'package:injectable/injectable.dart';
@injectable
class ProductsRepositoryImpl implements ProductsRepository {
final LocalDataSource localDataSource;
final RemoteDataSource remoteDataSource;
ProductsRepositoryImpl(this.localDataSource, this.remoteDataSource);
}
Difference Between @injectable and @singleton
While both @injectable and @singleton annotations help with automatic registration, they serve different purposes, particularly in how they manage object lifecycles.@injectable: Default Behavior
- Instance Creation: By default, classes annotated with @injectable are created every time they are requested.
This means that the same class can have multiple instances throughout the app. - Use Case: This is useful for classes that should not be reused globally,
such as classes with short lifecycles or objects that need to be created multiple times (e.g., temporary objects or view-models for specific screens).
@singleton: Singleton Behavior
- Global Instance: When you use @singleton, the annotated class is created once and shared throughout the app.
This means that every time the class is requested, the same instance is returned. - Memory Efficiency: Singletons are ideal for services that should be reused globally, such as repositories, database connections, or network services.
This ensures that the service is only initialized once, avoiding unnecessary memory usage.
Key Differences:
- Instance Management:
- @injectable: Creates a new instance every time it is requested.
- @singleton: Creates a single instance that is reused throughout the app.
- Lifecycle:
- @injectable: Shorter lifecycle, suitable for objects that need to be re-created frequently.
- @singleton: Longer lifecycle, typically used for services that should persist across the entire app lifecycle.
- Memory Usage:
- @injectable: More memory usage in cases where multiple instances are created.
- @singleton: More memory efficient, as the same instance is shared.
Using Scopes with @singleton: Memory Management and Cleanup
Scopes allow registration of related dependencies in a different scope, so they can be initialized only when needed and disposed of when they're not.One of the most efficient strategies for managing memory in Flutter applications is to mix singleton usage with temporary objects, and this is where scoped singletons come into play.Scoped singletons provide a mechanism to share the same instance of a class (like a repository) across multiple screens or processes, while ensuring it is properly cleaned up when no longer needed.A common use case for scoped singletons is during the authentication flow. For example, when a user logs in, the app might move between several screens, such as a login screen and a phone verification screen.
Instead of creating a separate instance of the authentication repository and its dependencies (e.g., remote data source) for each screen, you can use a scoped singleton to access the same instance throughout the entire authentication process.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// initialize injectable dependencies
await configureAppDependencies();
runApp(MyApp());
}
dart run build_runner build --delete-conflicting-outputs
This has the following benefits:
- Reduced Memory Usage: By sharing the same repository instance and its dependencies across multiple screens, you avoid creating multiple instances, which helps save memory.
Once the user has completed the authentication flow and navigated to the home screen, you can clean up the authentication-related dependencies, ensuring that they no longer consume memory unnecessarily. - Consistent State: Using a scoped singleton allows different parts of the app (like the login screen and phone verification screen) to access the same state and data, ensuring consistency. For example, if a token is fetched in one screen, it will be accessible in the other without needing to pass data around manually.
- No Duplicate Instances: Instead of creating two separate AuthRepository instances (one for login and another for phone verification), you use the same instance throughout the flow.
- Simplified Data Sharing: If the user logs in and a token is received in the login() method, that token is accessible in the verifyPhoneNumber() method without additional overhead.