Flutter Lesson 75 of 83 5 min read
Dependency Injection and Environment Configuration in Flutter
Wire up a Flutter app with dependency injection using constructors, provider or get_it, and manage dev and production settings safely.
On this page
In the last lesson, the view model received its repository through its constructor, and the repository received its API the same way. Handing an object what it needs, instead of letting it create or look up its own, is called dependency injection. It is what makes code testable and replaceable.
The problem it solves #
// Hard-wired: the view model creates its own dependencies.
class OrdersViewModel extends ChangeNotifier {
final _repository = OrderRepository(OrderApi(http.Client()));
}
// Injected: it is given them.
class OrdersViewModel extends ChangeNotifier {
OrdersViewModel(this._repository);
final OrderRepository _repository;
}
The first version always talks to the real server. The second can be handed a fake in a test, a cached version offline, or a demo version for screenshots, without changing a line of the class.
The remaining question is who creates everything and passes it along. There are three common answers.
1. By hand, in main #
Create the objects at start-up, in dependency order, and pass them down.
void main() {
final client = http.Client();
final productApi = ProductApi(client);
final productRepository = RemoteProductRepository(productApi);
runApp(MyApp(productRepository: productRepository));
}
This is explicit and needs no package. For a small app it is the right choice. It becomes tedious when objects must be threaded through many widgets.
2. With provider #
Place the objects above the app, and let any widget below read them.
import 'package:provider/provider.dart';
void main() {
runApp(
MultiProvider(
providers: [
Provider(create: (_) => http.Client()),
Provider(create: (context) => ProductApi(context.read<http.Client>())),
Provider<ProductRepository>(
create: (context) => RemoteProductRepository(context.read<ProductApi>()),
),
],
child: const MyApp(),
),
);
}
// Where a screen is created:
ChangeNotifierProvider(
create: (context) => ProductsViewModel(context.read<ProductRepository>())..load(),
child: const ProductsPage(),
)
Each provider can read the ones above it. Providers are lazy: an object is created the first time something asks for it.
With Riverpod, providers themselves are the injection system: ref.watch(productRepositoryProvider). With Bloc, use RepositoryProvider.
3. With a service locator: get_it #
get_it is a registry. You register how to make each object at start-up, then ask for one anywhere, with no BuildContext.
flutter pub add get_it
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
void setupDependencies() {
getIt.registerLazySingleton<http.Client>(http.Client.new);
getIt.registerLazySingleton(() => ProductApi(getIt()));
getIt.registerLazySingleton<ProductRepository>(() => RemoteProductRepository(getIt()));
getIt.registerFactory(() => ProductsViewModel(getIt()));
}
void main() {
setupDependencies();
runApp(const MyApp());
}
// Anywhere
final viewModel = getIt<ProductsViewModel>();
| Registration | Gives |
|---|---|
registerSingleton(obj) | One instance, created now |
registerLazySingleton(() => ...) | One instance, created on first use |
registerFactory(() => ...) | A new instance every time |
Keep the calls to getIt<T>() at the edges: where screens are created. Classes themselves should still take dependencies through their constructors. If a repository calls getIt inside its methods, its dependencies are hidden again, and you are back to the original problem.
Which to use? #
| Approach | Good for |
|---|---|
| By hand | Small apps, and learning |
| provider, Riverpod or Bloc’s providers | Apps already using that package for state |
get_it | Access outside the widget tree, such as in background tasks, or a team that prefers a locator |
All three are fine. Use one, consistently.
Replacing dependencies in tests #
This is the reward.
// With constructors
final vm = ProductsViewModel(FakeProductRepository());
// With provider, in a widget test
await tester.pumpWidget(
Provider<ProductRepository>.value(
value: FakeProductRepository(),
child: const MaterialApp(home: ProductsScreen()),
),
);
// With get_it
setUp(() {
getIt.reset();
getIt.registerSingleton<ProductRepository>(FakeProductRepository());
});
Environment configuration #
Your app needs different settings for development and production: the API address, whether logging is on, which analytics project to use.
Compile-time values with –dart-define #
flutter run --dart-define=API_URL=https://dev.example.com --dart-define=ENV=dev
flutter build appbundle --dart-define=API_URL=https://api.example.com --dart-define=ENV=prod
abstract final class AppConfig {
static const apiUrl = String.fromEnvironment(
'API_URL',
defaultValue: 'https://dev.example.com',
);
static const env = String.fromEnvironment('ENV', defaultValue: 'dev');
static bool get isProduction => env == 'prod';
}
The values are fixed when the app is built. For several values, keep them in a file and pass it:
flutter run --dart-define-from-file=config/dev.json
{ "API_URL": "https://dev.example.com", "ENV": "dev" }
Add the production file to .gitignore if it contains anything sensitive, and supply it in your CI system.
Several entry points #
Another approach is one main file per environment.
// lib/main_dev.dart (Not standalone: bootstrap is shown further down)
void main() => bootstrap(const AppConfig(apiUrl: 'https://dev.example.com', logging: true));
// lib/main_prod.dart
void main() => bootstrap(const AppConfig(apiUrl: 'https://api.example.com', logging: false));
flutter run -t lib/main_dev.dart
flutter build appbundle -t lib/main_prod.dart
Flavors #
A flavor is a native build variant with its own app id, name and icon. With flavors you can install the development and production apps side by side on one phone, each pointing at a different backend and Firebase project.
flutter run --flavor dev -t lib/main_dev.dart
flutter build appbundle --flavor prod -t lib/main_prod.dart
Flavors are configured in the Android Gradle file and in Xcode schemes. They take an hour to set up and are worth it for any app with real users.
Secrets do not belong in the app #
Anything compiled into an app can be extracted from the published file, whatever technique you use: --dart-define, an .env asset or obfuscation.
| Kind of value | Where it belongs |
|---|---|
| API base URL, public keys, Firebase config | In the app. These are not secrets |
| Keys for services that restrict use by app id or domain, such as Maps | In the app, with the restriction switched on in the provider’s console |
| Private keys, payment secrets, admin tokens, database passwords | On your server only. The app calls your server, and your server calls the third party |
Never commit secrets to Git either. Rotating a leaked key is far more work than keeping it out.
A bootstrap function #
Gather the start-up work in one place.
Future<void> bootstrap(AppConfig config) async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final client = http.Client();
final productRepository = RemoteProductRepository(
ProductApi(client, baseUrl: config.apiUrl),
);
runApp(
MultiProvider(
providers: [
Provider.value(value: config),
Provider.value(value: prefs),
Provider<ProductRepository>.value(value: productRepository),
],
child: const MyApp(),
),
);
}
Try it yourself #
Take the products feature from the previous lesson. Register its dependencies with either provider or get_it. Add an AppConfig read from --dart-define with a base URL and a showDebugBanner flag, and run the app with two different sets of values.