Writing a Simple Dependency Injector in Flutter
If you want to use this feature in your project without writing all code again, you can use my kinject package in your project.
Before starting Dependency Injection (it will be noted as DI alongside the article), I would like to explain BuildContext in Flutter.
A handle to the location of a widget in the widget tree.
According to Flutter’s documentation, BuildContext simply saves the location of a widget. So, Flutter uses a Tree Data Structure, and location of nodes is important in this Data Structure.
Every time we hear about 3 Flutter Trees — Widget, Element, and Render Tree. BuildContext is an abstraction between Widget and Render Tree, so it is Element.
Many things in Flutter use BuildContext to provide and get information about configuration — Theme, MediaQuery, etc. In reality, they are using InheritedWidget to inject dependency (DI) to the adjacent nodes of the tree (Flutter Tree). And it is plain that InheritedWidget uses BuildContext to provide this information to adjacent nodes.
There are 2 main methods in BuildContext that we will use:
- getInheritedWidgetOfExactType — it is a generic function, that returns the nearest InheritedWidget instance or null in the tree. I am sure that, you have used read type in Provider, Bloc State Management. It just returns an instance and does not listen to it.
- dependendOnInheritedWidgetOfExactType — the same functionality, but it listens to the instance. Any updates, we reflect to our listener widget.
These two methods work in O(1) (Big O) time complexity. It works very fast. It is a pure and Flutter-based approach to inject dependencies natively (according to my opinion).
Let’s write a simple Provider
Keep in mind that, we are not writing SM (State Management), we are writing DI. So, it will not handle the update process or state changes, it just provides an instance to a tree.
import 'package:flutter/widgets.dart';
/// {@template provider}
/// Simple provider based on [InheritedWidget] to provide instance
/// along subtree
/// {@endtemplate}
class Provider<T> extends InheritedWidget {
/// {@macro provider}
const Provider({
super.key,
required super.child,
required this.instance,
});
/// instance which will be provider in the tree
final T instance;
/// returns near instance by its type in the widget tree
/// if there is not instance in the tree, it will return null.
static T? resolve<T>(BuildContext context) {
final injected = context.getInheritedWidgetOfExactType<Provider<T>>();
return injected?.instance;
}
/// it will ignore update of widget tree when any changes occurred
/// in provider.
///
/// [Provider] will be used to inject instances as singleton or factory
/// to widget tree. It shouldn't handle update process of any instance
/// based on other instance.
@override
bool updateShouldNotify(Provider<T> oldWidget) => false;
}
As you can see, it is a simple InheritedWidet, will provide a given instance to a tree. resolve function will be used to get instances from anywhere in a tree.
Disposing instances automatically
In some cases, we need to dispose of fields in a class when the class instance is disposed (controllers, streams, etc). In this case, we need a protocol to define if our instance is disposable or not.
/// {@template disposable}
/// Interface for instance that should be disposed its fields
/// {@endtemplate}
abstract interface class Disposable {
/// function which will be called to dispose fields of [Disposable] instance.
///
/// subclass should implement this class and its [dispose] function to
/// configure auto-dispose process.
void dispose();
}
Observer (Logger)
You would like to track (log) your instances, if they are created or disposed. For this functionality, we will write a simple protocol for your own observers:
import 'kinject.dart';
/// Interface for observers to configure based on your project needs
abstract interface class KinjectObserver {
/// called when new instance created in the widget tree
void onCreate(Kinject<dynamic> instance);
/// called when instance removed from the widget tree
void onDispose(Kinject<dynamic> instance);
}
You can set your own observer with the following syntax:
Kinject.observer = AppKinjectObserver();
Simple Injector
We can use the Provider we have written above, but it will not handle the instance and its disposal state. So, we will write a simple Injector now:
import 'package:flutter/widgets.dart';
import 'disposable.dart';
import 'kinject_observer.dart';
import 'provider.dart';
/// {@template kinject}
/// Widget which provides instance within [factory] callback and
/// maintains its dispose state
/// {@endtemplate}
class Kinject<T> extends StatefulWidget {
/// {@macro kinject}
const Kinject({
super.key,
required this.builder,
required this.factory,
});
/// A builder method that returns child widget
final WidgetBuilder builder;
/// The method which will return the instance for widget tree
final T Function(BuildContext) factory;
/// Simple observer to track states of instances
static KinjectObserver? observer;
/// type of current instance
dynamic get type => T;
/// The [State] factory of this [StatefulWidget]
@override
State<Kinject<T>> createState() => _KinjectState<T>();
}
class _KinjectState<T> extends State<Kinject<T>> {
/// The instance that will be provided for widget tree
T? instance;
@override
Widget build(BuildContext context) {
return Provider(
instance: getInstance(),
child: Builder(
builder: (context) {
return widget.builder(context);
},
),
);
}
T getInstance() {
if (instance == null) {
instance = widget.factory(context);
Kinject.observer?.onCreate(widget);
}
return instance!;
}
@override
void dispose() {
if (instance is Disposable) {
(instance as Disposable?)?.dispose();
}
Kinject.observer?.onDispose(widget);
super.dispose();
}
}
- observer instance is used to call onCreate and onDispose callbacks.
- build method simply uses the InheritedWidget we wrote above to provide an instance.
And add simple extension to read your instance easily with context:
import 'package:flutter/widgets.dart';
import '../provider.dart';
/// extension for [BuildContext] to get any provided instance easily
extension ProviderExt on BuildContext {
/// gets nearest instance and returns error if instance is null
T resolve<T>() => Provider.resolve<T>(this)!;
/// gets nearest instance and return null if instance can not be found
T? maybeResolve<T>() => Provider.resolve<T>(this);
}
Let’s test our Injector
First, I am writing simple business logic to handle counter.
import 'dart:async';
import 'package:kinject/kinject.dart';
enum CountAction { increase, decrease }
class CounterBloc implements Disposable {
CounterBloc() {
_counterController?.stream.listen(
(countAction) {
if (countAction == CountAction.increase) {
_countController?.add(++_currentValue);
} else {
_countController?.add(--_currentValue);
}
},
);
}
StreamController<CountAction>? _counterController = StreamController();
StreamController<int>? _countController = StreamController()..add(0);
int _currentValue = 0;
Stream<int> get count => _countController!.stream;
void increase() => _counterController?.add(CountAction.increase);
void decrease() => _counterController?.add(CountAction.decrease);
@override
void dispose() {
_counterController?.close();
_counterController = null;
_counterController?.close();
_countController = null;
}
}
And provide CounterBloc with Kinject class:
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Injector Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: Kinject(
factory: (_) => CounterBloc(),
builder: (_) => const CounterPage(),
),
);
}
}
With the following example, you can use CounterBloc easily:
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
final counterBloc = context.resolve<CounterBloc>();
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Counter'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
StreamBuilder<int>(
stream: counterBloc.count,
builder: (context, snapshot) {
return Text(
'${snapshot.data}',
style: Theme.of(context).textTheme.headlineMedium,
);
},
),
],
),
),
floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FloatingActionButton(
onPressed: counterBloc.decrease,
tooltip: 'Decrease',
child: const Icon(Icons.remove),
),
const SizedBox(width: 16),
FloatingActionButton(
onPressed: counterBloc.increase,
tooltip: 'Increase',
child: const Icon(Icons.add),
),
],
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}
It is easy to use. You can use it to provide your Controllers, Data Sources, Repositories, Use Cases, even Providers, BloC, etc.
You can add an observer to detect if it creates and disposes instance correctly:
import 'package:flutter/foundation.dart';
import 'package:kinject/kinject.dart';
final class AppKinjectObserver implements KinjectObserver {
@override
void onCreate(Kinject instance) {
debugPrintThrottled('${instance.type} initialized');
}
@override
void onDispose(Kinject instance) {
debugPrintThrottled('${instance.type} disposed');
}
}
void main() {
Kinject.observer = AppKinjectObserver();
runApp(const MyApp());
}
I have added a page before CounterPage to test the disposal state. (You can get all code snippets from the following gist).
Nice! it is working as expected.
Wait… If I want to provide multiple instances?
It is simple again. First, we will write a simple widget which will be used in multiple instance providers:
import 'package:flutter/widgets.dart';
import 'kinject.dart';
/// Builder for factory instance in the [ProxyKinject] instance
typedef ProxyKinjectBuilder<T> = T Function(BuildContext context);
/// {@template proxy_kinject}
/// Widget which provides instance within [factory] callback and
/// maintains its dispose state
/// {@endtemplate}
class ProxyKinject<T> {
/// {@macro proxy_kinject}
ProxyKinject(this.factory);
/// The method which will return the instance of our dependency
final ProxyKinjectBuilder<T> factory;
/// function which will return [Kinject] instance for the next [ProxyKinject]
Kinject<T> inject(WidgetBuilder builder) => Kinject<T>(
factory: factory,
builder: builder,
);
}
The difference is that it will provide an instance only we call the inject function. And next one is to write the Kinjects widget:
import 'package:flutter/widgets.dart';
import 'proxy_kinject.dart';
/// {@template kinjects}
/// Widget which provides multiple instances and handles
/// their inter-dependencies
/// {@endtemplate}
class Kinjects extends StatefulWidget {
/// {@macro kinjects}
const Kinjects({
super.key,
required this.builder,
required this.kinjects,
});
/// A builder method that returns your child widget
final WidgetBuilder builder;
/// The [ProxyKinject] list that contains instances
final List<ProxyKinject<dynamic>> kinjects;
@override
State<Kinjects> createState() => _KinjectsState();
}
class _KinjectsState extends State<Kinjects> {
/// Widget which holds all instances and their inter-dependencies
WidgetBuilder? builder;
@override
void initState() {
super.initState();
final length = widget.kinjects.length;
builder = widget.builder;
for (var i = length - 1; i >= 0; i--) {
final previousBuilder = builder;
builder = (context) {
return widget.kinjects[i].inject(previousBuilder!);
};
}
}
@override
Widget build(BuildContext context) => builder!(context);
}
It works like a magic. We give a list of providers (Proxy Providers) and it goes through them in reverse side. Because, at the top of the list, we should insert the first provided instance in the Kinjects object.
Usage is very simple. As an example, you can check the following:
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context) {
return Kinjects(
kinjects: [
ProxyKinject<AuthDataSource>((_) => AuthDataSourceImpl()),
ProxyKinject<AuthRepository>(
(context) => AuthRepositoryImpl(
context.resolve<AuthDataSource>(),
),
),
ProxyKinject<LoginBloc>(
(context) => LoginBloc(
context.resolve<AuthRepository>(),
),
),
],
builder: (context) {
return Scaffold(
body: SafeArea(
child: Center(
child: ElevatedButton(
onPressed: context.resolve<LoginBloc>().login,
child: const Text('Log in'),
),
),
),
);
},
);
}
}
Should global instances be injected with BuildContext?
We can make many arguments on this topic. We can write a Singleton instance and use it. But, if you write a unit test, it will be hard to mock your instances (singleton). On the other hand, the Singleton pattern is considered an anti-pattern. If you inject instances with the abstract instances and DI, it is the best way to do it.
What is the problem with other methods? — getIt, riverpod, etc
In simple or middle-level and single-developer projects it is okay to use whatever you want. Because you will never feel it, can make a problem for your co-workers.
getIt is a service locator, not a Dependency Injector. Yes, I agree that it is written as DI. The problem is that you should handle the disposal state. And, you can get any instance from anywhere and modify it. It is problematic because you can corrupt something. Btw, the service locator is also an anti-pattern.
Riverpod is good on its own, but it is trying to provide instances and handle state management, caching, etc. It is a bit confusing for a library to solve many problems at once. It has auto-disposal instances, many providers, family instances, etc. Ref forces us to depend on instances tightly. It can cause many problems for your application architecture. You can read about it in more detail from Michael Lazebny’s blog.
My last tips according to my practice:
- Separate your data sources, repositories, state management solutions, and UI code.
- Inject them with the help of abstract classes (protocols)
- Prefer native (flutter native code) approaches more than external packages
- Prefer BloC as State Management
- Deep dive into SOLID
- Deep dive into Design Patterns
Thanks for reading my article. If you like my article and it was useful for you, don’t forget to clap.