“Dependency Injection” in Flutter with InheritedWidget
Flutter and Dart gives us a lot of liberty. So much that we can write an entire app in just one file, combining UI, business logic and API calls in a tremendous dart file. However, we tend not to do this as software developers, for many reasons. For starters, it’ll be a mess to test the code, it’ll be difficult to implement new features or correct bugs while traversing through thousands of lines of code and it will also could prove troublesome for future new elements in the project, since a monolithic file of code can be daunting.
That’s why we tend to decouple the code of our app in several classes: network, models, bloc and widgets, etc… However, since these classes depend on each other, we start to notice a couple of issues:
- Where should we initialise each class?
- Where and how should we access them?
Let’s look at the following example:
class HttpClient {
//...
}
class NetworkEndpointsA {
HttpClient client;
NetworkEndpointsA(this.client);
}
class NetworkEndpointsB
HttpClient client;
NetworkEndpointsB(this.client);
}
class HomeBloc {
NetworkEndpointsA endpointsA;
NetworkEndpointsA endpointsB;
HomeBloc(this.endpointsA, this.endpointsB);
}
If our HomePageWidget
needs to access HomeBloc
, it will have to know how to initialise classes HttpClient
, NetworkEndpointsA
and NetworkEndpointsB
. But what if a DetailsPageWidget
also depend on this bloc? Should we copy the code from one class to another? And how can we create a single instance of HomeBloc
so that both the HomePageWidget
and DetailsPageWidget
can access and modify?
One solution to this problem would be to have this bloc declared globally so that every class could access it, but this tactic makes testing our code more difficult and can make the managing and disposing of the bloc objects more tricky. You can read a StackOverflow answer about why global singletons should be avoided.
Since we want to avoid using global objects, we can take advantage of the InheritedWidget. This widget can have only one child, and it’s purpose is to hold data and make it accessible to its children. To use this widget we can have two possible approaches, that can be used at the same time:
- We can use one
InheritedWidget
that is the parent of all the widgets in our app. This way, everyStatelessWidget
andStatefulWidget
can access it. - Or, we can create several
InheritedWidget
s so that we can make the information “private” and accessible only to a subset of widgets. For example, we may want to keep theProfileBloc
that is used in theProfilePageWidget
private to theFeedPageWidget
, as such, we create aProfileInjectorWidget
and aFeedInjectorWidget
[Image exemplifying]
For the current article we will create just one instance of the InheritedWidget
.
class InjectorWidget extends InheritedWidget {
InjectorWidget({
Key key,
@required Widget child,
}) : assert(child != null),
super(key: key, child: child);
static InjectorWidget of(BuildContext context) {
return context.inheritFromWidgetOfExactType(InjectorWidget) as InjectorWidget;
}
@override
bool updateShouldNotify(InjectorWidget old) => false;
}
The updateShouldNotify
parameter tells the widget if it should notify his children if he is updated and force them to rebuild. Why is this set to false? Presumably, we will create the instances of our classes when the app starts. When creating a new instance, by explicit action of a child widget, the other children do not need to be rebuilt, and as such, they don’t need to be notified by the InjectorWidget
. The child
parameter will be the child widget to which we want to share the data/instances, usually it is our MaterialApp
or CupertinoApp
widget. Finally, the of
method will allow any child of this widget to access it, provided they have a valid BuildContext
, via InheritedWidget.of(context)
. We can see this pattern used in other Flutter classes such as Navigator
.
Since the InjectorWidget
widget will be responsible for the creation of the dependencies in our app, we will declare each dependency as a private variable that can only be accessed by a public getter. In our case, we just want to expose the HomeBloc
, so we will only create that public getter.
class InjectorWidget extends InheritedWidget {
//...
HomeBloc _homeBloc;
EndpointsA _endpointsA;
EndpointsB _endpointsB;
HomeBloc getHomeBloc() => _homeBloc;
}
In order to initialise each depedency, we create a init()
method that will handle the creation and injection of objects inside this widget.
class InjectorWidget extends InheritedWidget {
//...
void init() {
httpClient = HttpClient();
endpointA = EndpointsA(httpClient);
endpointB = EndpointsB(httpClient);
_homeBloc = HomeBloc(endpointA, endpointB);
}
}
With this approach, after we call init()
, the HomeBloc
can be accessed by any child of the InjectorWidget
. However, what happens if we need to create a new instance of the HomeBloc
? Will we need to call init()
and force the initialisation of every dependency in our project? What if there is another bloc, ProfileBloc
that we don’t want to be initialised again?
To solve this, when we call getHomeBloc()
we validate if our _homeBloc
variable is null. If it is, then we should create a new HomeBloc
and assign it to this variable. If not, then we should return the value that we assigned to the homeBloc
variable. Now a ProfileWidget
can call getHomeBloc()
and access the instance that is used by the HomeWidget
. However, we can go one step ahead by adding a bool
parameter that will dictate if we need a new instance of the bloc, even if the InheritedWidget
already holds a reference to one. This can be used when we want to reset the bloc state.
class InjectorWidget extends InheritedWidget {
//...
HomeBloc getHomeBloc({bool forceCreate = false}) {
if (_homeBloc == null || forceCreate) {
_homeBloc = HomeBloc(endpointA, endpointB)
}
return _homeBloc;
}
//...
}
Now getHomeBloc()
can be used both to create a new instance of the HomeBloc
or access the current instance hold in the InjectorWidget
or, it can force the creation of a new HomeBloc
with getHomeBloc(forceCreate = true)
.
InjectorWidget Initialization
We currently have a InjectorWidget
that can provide us the HomeBloc
, given a valid BuildContext
. To be able to access this bloc, we will first need to initialise it via the init()
function, that we will assume as async
.
class InjectorWidget extends InheritedWidget {
//...
void init() async {
// this can be an access to the SharedPreferences, for example
var token = await getToken();
var httpClient = HttpClient(token);
_endpointA = EndpointsA(httpClient);
_endpointB = EndpointsB(httpClient);
_homeBloc = HomeBloc(endpointA, endpointB);
}
}
The purpose of this widget is to be accessed by all of the remaining widgets in our app. In fact, we may even want for our MaterialApp
or CupertinoApp
to access it if the initialisation process gives us some critical information that is needed to decide the initial route, or widget, or our app. For this reason, we will create the widget and initialise it in our main()
method.
void main() async {
var injector = InjectorWidget(child : MyApp());
// assume that the `init` method is an async operation
await injector.init();
runApp(injector);
}
When running the above code we notice the following: we get a black screen before our app launches. While this screen is shown, Flutter is initialising the engine and also running our injector.init()
before creating the first widget, and since Flutter doesn’t have anything configured to display, it shows the black screen. In order to avoid this, we can take advantage of the Native Splash Screen. When using this solution, Flutter will show the native Android and iOS splash screen when initialising the code, giving us the opportunity to show our app logo. To know more about how to use a native splash screen in a Flutter app, please refer to this article.
As we can se above, our InjectorWidget
has MyApp
as a child. This means that both MyApp
and its children will be able to access our injector via InjectorWidget.of(context)
, including the HomePageWidget
that can access the HomeBloc
. However, some issues might arise:
- If we are in a
StatelessWidget
, this would have to go into ourbuild
method. This method can be called again in some special situations, so we would have to add additional checks in the state so that we are not callinggetHomeBloc()
several times. - If, by some reason, we have static information in our bloc, such as a String, the first time we could access it is after the first
build
since we need to accessBuildContext
, which means that to be able to show that String, we would need to rebuild the widget tree. Additionally, since we are accessing our bloc in thebuild
method, the same problems that occur in theStatelessWidget
would happen here. The same problem would occur if we need to initialise our bloc with some data provided by the widget’s constructor. In this case, where we ideally would put this logic ininitState()
, we would have to put it inside ourbuild()
method with additional flags to not be adding data to ourSink
in every rebuild.
From the issues raised above, we reach the following conclusion: it would be best to initialise the block outside the State
of our widgets, or outside our StatelessWidgets
. We can do this by using Named Routes. Using named routes, every time we use Navigator.of(context).pushNamed(routeName)
, we are either calling the routes
or onGenerateRoute
of our MyApp
widget. Since here we have access to the BuildContext
, this can be a good place to initialise our widgets.
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: "Injection Example",
theme: ThemeData(
primaryColor: Colors.blue,
accentColor: Colors.pink,
),
initialRoute: "/",
routes: {
// other routes...
"/home": (context) {
var bloc = InjectorWidget.of(context).getHomeBloc(forceCreate = true));
return HomePageWidget(
bloc: bloc)},
}
);
}
An important note. We might be tempted to refactor the following lines:
routes: {
// other routes...
"/home": (context) {
var bloc = InjectorWidget.of(context).getHomeBloc(forceCreate = true));
return HomePageWidget(bloc: bloc);
},
}
To
routes: {
// other routes...
"/home": (context) =>
HomePageWidget(
bloc: InjectorWidget.of(context).getHomeBloc(forceCreate = true))},
}
This approach has an issue: Since in our declaration we are getting directly the bloc instance, this means that every time the whole widget has to be recreated, we are going to be calling again the InjectorWidget
to provide us with a new HomeBloc
. This can happen if we are using Navigator.of(context).pushNamed(otherRoute)
to push, but not replace, a new route over our HomeWidget
. When this new route pops
, the code:
HomePageWidget(
bloc: InjectorWidget.of(context).getHomeBloc(forceCreate = true))}
Is going to be called again and our HomeBloc
is going to be recreated. If, on the other hand, we declare the bloc as a local variable, every time the widget has to be recreated, we are going to call
HomePageWidget(bloc: bloc)
Referencing the bloc
that we created when we first navigated to this page.
And that’s it! 🙌 We have created an InheritedWidget
that allows us to store and access objects in it without using any external packages. Though this approach can be used in many Flutter projects, it has some drawbacks:
- To access the objects stored in the
InheritedWidget
we must always have a validBuildContext
. - With the current setup, if some of our dependencies needs to access the
BuildContext
when initialising, to get theLocale
of the phone for example, we would have to restructure ourinit
method by splitting in two: one that we could call for async operations beforerunApp
and another that we could call inside theMyApp
widget when we have access to theBuildContext
. - This solution generates a lot of boilerplate code. In our app we currently have close to 500 lines of code in this class.
- By using
getHomeBloc(forceCreate : true)
we are not disposing the previous bloc. This can be solved if we add further logic to our getter method. - Though we must call the
init()
method before accessing any data stored in this widget, there isn’t any indication that either theinit()
method has been called or that it has completed the initialisation.
To conclude, this approach does not invalidate the need for dependency injection or service locator packages, we should search them, learn how they are used and eventually use them in our projects! Nonetheless, if we see that the drawbacks that this approach has don’t impact our project, we can consider a “pure Flutter” solution.
Want to get the latest articles and news? Subscribe to the newsletter here 👇
And for other articles, check the rest of the blog! Blog - Gonçalo Palma