One to find them all: How to use Service Locators with Flutter

Thomas BurkhartThomas Burkhart
9 min read

ATTENTION: This is an article about get_it which has nothing to do with the getX package please stop mixing them up.

Most people, when starting with Flutter, look for a way to access their data from views to keep them separate. The Flutter documentation recommends using an InheritedWidget. This not only allows data access from anywhere in the widget tree but also automatically updates widgets that reference it.

Some problems with InheritedWidgets

The biggest problem for me when I wrote this article was that I couldn't get only the parts of the widget tree to rebuild where the data had changed. Additionally, the need to have access to the BuildContext limits its use to the UI part of your code.

I now know that you can make it work, but I still wonder why struggle with InheritedWidgets when there are easier solutions. The requirement for the BuildContext still remains.

Alternatives

Since the automatic updating of widgets that reference it isn't working perfectly, you might question why you should use an InheritedWidget at all. Especially if you're using the InheritedWidget just to access your model from anywhere, like when using BLOC or other reactive patterns, there are other solutions that might be even better.

Singletons

Singletons might be the first thing that comes to mind. While a Singleton makes it easy to access an object from anywhere, it complicates unit testing because you can only create one instance, making it hard to mock.

IoC containers with Dependency Injection

While this is a possible alternative for accessing an object and keeping it flexible for testing, I have some concerns about automatically injecting objects at runtime.

  • For me, it makes it harder to track where a specific object instance comes from, but that might just be personal preference.

  • Using an IoC container creates a network of dependent objects, which means that when you access the first object, all dependent objects are instantiated at the same time. This can impact startup performance, especially for mobile apps. Even objects that might be needed later could be created unnecessarily. (I know some IoCs offer lazy creation, but this doesn't fully solve the problem.)

  • IoC containers usually require some form of reflection to determine which objects need to be injected where. Since Dart doesn't support this in Flutter, it can only be addressed using code generation tools.

Provider / Riverpod

Provider is a strong alternative to get_it. However, I still think get_it is a good choice for these reasons:

  • Provider requires a BuildContext to access registered objects, so you can't use it inside business objects outside the Widget tree or in a pure Dart package.

  • Provider adds its own Widget classes to the widget tree that are not GUI elements but are needed to access the objects registered in Provider. Personally, I prefer to have as few non-UI Widgets in my widget trees as possible.

  • In general, the concept behind get_it is easier for many people to understand.

  • Although Riverpod is very powerful, its API is too complex, in my opinion.

  • With watch_it, get_it gets the simplest state management solution for Fluttter

There are now ways to access objects from Provider without the BuildContext, but I still prefer get_it’s API and features.

Service Locators

Like with IoCs, you need to register the types you want to access later. The difference is that instead of having an IoC inject instances automatically, you call the service locator directly to get the object you need.

Some people criticize this pattern, calling it old-fashioned and hard to test. However, as we will see, the testing concern isn't really valid. In my view, delivering software is more important than getting caught up in theoretical debates about the best pattern. For me and many others, Service Locators are a straightforward and practical solution.

A great advantage of using a Service Locator or IoC is that you are not restricted to using it inside a widget tree; you can use it anywhere to access any type of registered object.

GetIt the Service Locator for Dart

Coming from C#, I was used to a simple Service Locator (SL) called Splat. This inspired me to create something similar in Dart, resulting in the development of GetIt.

GetIt is very fast because it uses a Map<Type> internally, allowing access in O(1) time.

GetIt is a singleton, so you can access it from anywhere using its instance property or its shortcut:

final getItInstance = GetIt.instance; 
//shortcut 
final getItInstance2 = GetIt.I;

Usage

It's pretty straightforward. Typically, at the start of your app, you register the types you want to access later from anywhere in your app. After that, you can access instances of the registered types by calling the Service Locator again.

The nice thing is you can register an interface or abstract class along with a concrete implementation. When accessing the instance, you always request the interface or abstract class type. This makes it easy to switch the implementation by simply changing the concrete type at registration time.

Globals strike back or the Return of the Globals

One major difference from C# is that Dart allows the use of global variables. Although GetIt is a singleton, I like to assign its instance to a global variable to make accessing GetIt easier.

I can almost hear some of you cringe at the mention of 'global variable,' especially if, like me, you were always told that globals are bad. Recently, I learned a much nicer term for them: 'Ambient variables.' This might sound like a euphemism, but it actually describes their purpose better. These are variables that hold object instances defining the environment in which the app operates.

if you use my watch_it package you get a gloabal di instance to get_it included

Getting practical

I refactored a simple example to use GetIt instead of an inherited Widget. To set up the Service Locator, I added a new file called di.dart, which also contains the global (ambient) variable for the Service Locator. This makes it easier to reference when writing unit tests.

// ambient variable to access the service locator 
final di = GetIt.instance;
void setup() 
{ 
    di.registerSingleton(AppModel());
// Alternatively you could write it GetIt.I.registerSingleton(AppModel()); 
}

Someone recently pointed out that there is a conceptual difference between DI (Dependency Injection) and a Service Locator, and that I should not have named the global get_it instance di. In my view, this is more of an implementation detail because both DI and SL aim to achieve "inversion of control." This means you can control how an object or function behaves from the outside by injecting or locating objects.

Because of the well-known abbreviation DI for dependency injection, I continue to use di as the variable name. Feel free to use sl, locator, or whatever you prefer.

GetIt offers various methods to register types. The registerSingleton method ensures you always receive the same instance of the registered object.

When using the InheritedWidget, the definition of a button looked like this:

MaterialButton( 
    child: Text("Update"), 
    onPressed: TheViewModel.of(context).update 
),

Now with GetIt it changes to

MaterialButton( 
    child: Text("Update"), 
    onPressed: di.get<AppModel>().update
),

Actually, because GetIt is a callable class we can write

MaterialButton( 
    child: Text("Update"), 
    onPressed: di<AppModel>().update 
),

which is pretty concise.

you can find the whole code for the SL version of this App here https://github.com/escamoteur/flutter_weather_demo/tree/using_service_loactor

Extremely important if you use GetIt: ALWAYS use the same style to import your project files either as relative paths OR as package which I recommend. DON'T mix them because currently Dart treats types imported in different ways as two different types although both reference the same file.

This warning seems to be no longer necessary according to an issue in the Dart compiler. However, I would still choose to consistently use one method..

Registration in Detail

Different ways of registration

Besides the above used registerSingleton there are two more ways to register types in GetIt

Factory

di.registerFactory( () => AppModelImplementation() );

If you register your type like this, each call to di.get<AppModel>() will create a new instance of AppModelImplementation, as long as it's a descendant of AppModel. To do this, you need to pass a factory function to registerFactory.

Sometimes, it's useful to pass different values to factories when calling get(). For this, there are versions of registering factories where the factory function takes two parameters:

void registerFactoryParam<AppModel,String,int>((title, size) => AppModelImplementation(title,size));

When requesting an instance you pass the values for those parameters:

final instance = di<AppModel>(param1: 'abc',param2:3);

LazySingleton

Creating the instance during registration can take time at app start-up. You can delay the creation until the object is requested for the first time using:

di.registerLazySingleton(() => AppModelImplementation());

Only the first time you call get<AppModel>(), the factory function you provided will be executed. After that, you will always receive the same instance.

Applications beyond just accessing models from views

When using a service locator with interfaces or abstract classes (I really wish Dart still had interfaces), you gain a lot of flexibility in configuring your app's behavior at runtime:

  • Easily switch between different implementations of services. For example, define your REST API service class as an abstract class "WebAPI" and register it in the service locator with different implementations, such as various API providers or a mock class.

  •       if (emulation) { 
              di.registerSingleton( WeatherAPIEmulation() ); 
          } else { 
              di.registerSingleton(WeatherAPIOpenWeatherMap() ); 
          }
    
  • Register parts of your widget tree as builder factories in the SL, and choose different builders at runtime based on the screen size (phone/tablet).

  • If your business objects or services need to reference each other, register them in GetIt.

Testing with GetIt

Testing with GetIt is very easy because you can register a mock object instead of the real one and then run your tests.

GetIt offers a reset() method that clears all registered types, allowing you to start fresh in each test.

If you prefer to inject your mocks for the test, this pattern is recommended for objects that use the service locator:

AppModel([WeatherAPI? weatherAPI]): _weatherAPI = weatherAPI ?? di();

There's more

GetIt offers many more features than I've mentioned here. If you like GetIt, I recommend reading the GetIt Readme, where you will find:

  • Asynchronous Factories and Singletons

  • Functions to unregister or reset registered factories/singletons

  • How to create more than one instance of GetIt if needed

  • Registering multiple objects of the same type by name

  • Startup orchestration of your app. You can find more on this in my other post Let's get this party started

  • GetIt scopes, which make creating and disposing of objects based on your app's state very easy

  • And much more...

WatchIt

GetIt makes accessing your objects from anywhere very easy. To make it the simplest state management solution for Flutter, you can use its companion package, watch_it.

At https://github.com/escamoteur/flutter_weather_demo/tree/using_watch_it, you can find a version of the demo app that uses watch_it instead of a StreamBuilder. After registering the AppModel as before, we can make the widget rebuild automatically every time the stream emits a new item.

class WeatherListView extends WatchingWidget {
  WeatherListView();
  @override
  Widget build(BuildContext context) {
    final snapshot = watchStream(
      (AppModel model) => model.WeatherStream,
    );
    if (snapshot.hasData && snapshot.data!.length > 0) {
      return ListView.builder(
          itemCount: snapshot.data!.length,
          itemBuilder: (BuildContext context, int index) =>
              buildRow(context, index, snapshot.data!));
    } else {
      return Text("No items");
    }
  }

watchStream accesses the AppModel inside GetIt and watches the WeatherStream property.

You can make widgets rebuild with watch_it by watching:

  • Listenables / ValueListenables

  • Futures

  • Streams

Additionally, you can do almost anything that would typically require a StatefulWidget inside a StatelessWidget, which helps reduce a lot of boilerplate code.

0
Subscribe to my newsletter

Read articles from Thomas Burkhart directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Thomas Burkhart
Thomas Burkhart