DEV Community

Cover image for Goodbye, Singleton! Should We Implement this in Flutter?

Goodbye, Singleton! Should We Implement this in Flutter?

Singleton is one of the debated topics by several software developers. Some say it is best to avoid it. But on the other hand, some people think it is good and use it in certain cases. Of course, several factors make them think like that. Which camp are you in? By the way, in the end, you may become more confident or it may become the opposite. Let's find out!

Before we discuss this further, we should first know what a singleton is.

The Singleton Pattern is a design paradigm in object-oriented programming that restricts the instantiation of a class to a single object.
- studysmarter.co.uk

Importance of Singleton Design Pattern in Programming
Why should you care about the Singleton Design Pattern? There are a handful of reasons:

  • Enforced control over the access to shared resources
  • Reduced system complexity
  • Providing a shared state among all instances
  • Generation of unique global access point

Deep dive

Instead of debating whether this is good, let's consider the advantages and disadvantages of implementing it in our project.

How to Implement a Singleton in Dart

Simplest way By making the constructor private, we ensure that the class cannot be instantiated outside of the file in which it is defined.



class Singleton {
  //private constructor
  Singleton._();
  //the one instance of this singleton
  static final instance = Singleton._();
}


Enter fullscreen mode Exit fullscreen mode

As a result, the way to access this is to call Singleton.instance.

This is similar to when you use Firebase. Maybe you are quite familiar with the following code:



FirebaseAuth.instance.signInAnonymously();


Enter fullscreen mode Exit fullscreen mode

A singleton pattern is used by Firebase plugins. And the only way to call their methods is with an instance getter. So does this mean we can also design classes in the same way? πŸ€” πŸ’­
You see, these classes are designed as singleton classes to prevent you from creating more than one instance in your code:



// inside Widget A
final auth1 = FirebaseAuth();
// inside Widget B
// with the different instances:
final auth2 = FirebaseAuth();



Enter fullscreen mode Exit fullscreen mode

The code above should not be done, because you should only have one authentication service. Although singletons are often a reasonable solution for library or package design when writing application code, we must be careful about how we use it, as it can cause many problems in our code base.

Singleton Drawbacks & Solutions

As mentioned previously, several things make singletons problematic. Here we will talk about three disadvantages of singletons and how to solve them in Flutter.

1. Singletons are hard to test



class FirebaseAuthRepository {
  Future<void> signInAnonymously() => FirebaseAuth.instance.signInAnonymously();
}


Enter fullscreen mode Exit fullscreen mode

In this case, it's hard to write a test to check that FirebaseAuth.instance.signInAnonymously() is called:



test('calls signInAnonymously', ()async {
final authRepository = FirebaseAuthRepository();
await authRepository.signInAnonymously();
  // how to expect FirebaseAuth.instance.signInAnonymously() was called?
});


Enter fullscreen mode Exit fullscreen mode

Solution
A simple solution is to inject FirebaseAuth as a dependency, like this:



class FirebaseAuthRepository {
  // declare a FirebaseAuth property and pass it as a constructor argument
  const FirebaseAuthRepository(this._auth);
final FirebaseAuth _auth;

  // use it when needed
  Future<void> signInAnonymously() => _auth.signInAnonymously();
}



Enter fullscreen mode Exit fullscreen mode

As a result, we can easily mock the dependency in our test and write expectations against it:

Create a mock file! for example, test/mocks/firebase_auth_mocks.dart, and run build_runner to generate the mock class.



@GenerateMocks([FirebaseAuth])
void main() {}


Enter fullscreen mode Exit fullscreen mode

Create a test file! for example, test/firebase_auth_repository_test.dart:



test('calls signInAnonymously', () async {
      // create the mock dependency
      final mock = MockFirebaseAuth();
      // stub its method(s) to return a value when called
      when(mock.signInAnonymously()).thenAnswer((_) => Future.value(UserCredential(user: MockUser())));
      // create the object under test and pass the mock as an argument
      final authRepository = FirebaseAuthRepository(mock);
      // call the desired method
      await authRepository.signInAnonymously();
      // check that the method was called on the mock
      verify(mock.signInAnonymously()).called(1);
    });


Enter fullscreen mode Exit fullscreen mode

Check out the mockito package for more info about how to write tests using mocks.

2. Lazy Initialization

Initializing certain objects can be expensive. Let's take a look at this example:



class GoodBaller {
  GoodBaller._() {
    print('Player is ready to play');
    // Perform some heavy processing, like loading player data
  }
  static final instance = GoodBaller._();
}


Enter fullscreen mode Exit fullscreen mode


void main() {
  // Prints 'Player is ready to play' immediately
  final player = GoodBaller.instance;
}


Enter fullscreen mode Exit fullscreen mode

In the example above, the heavy processing of loading player data runs as soon as we initialize the player variable inside the main() method. This can be inefficient if we don't need the GoodBaller instance.

Solution
To defer the initialization until the object is needed, we can use the late keyword:



void main() {
  // Initialization will happen later when we use the player
  late final player = GoodBaller.instance;
  ...
  // Initialization happens here
  player.startPlaying();
}


Enter fullscreen mode Exit fullscreen mode

However, this approach is error-prone as it's easy to forget to use late.

In Dart, all global variables are lazy-loaded by default (and this is also true for static class variables). This means that they are only initialized when they are first used. On the other hand, local variables are initialized as soon as they are declared unless they are declared as late.

As an alternative, we can use packages such as [get_it](https://pub.dev/packages/get_it), which makes it easy to register a lazy singleton:



class GoodBaller {
  GoodBaller() {
    // Perform some heavy processing, like loading player data
  }
}


Enter fullscreen mode Exit fullscreen mode


// Register a lazy singleton (won't be created yet)
getIt.registerLazySingleton<GoodBaller>(() => GoodBaller());

// When we need it, do this
final player = getIt.get<GoodBaller>();


Enter fullscreen mode Exit fullscreen mode

the GetIt class is itself a singleton. But this is ok because what matters is that it allows us to decouple our dependencies from the objects that need them. For a more in-depth overview, read the package documentation.

And we can do the same thing with Riverpod, since all providers are lazy by default:



// Create a provider
final playerProvider = Provider<GoodBaller>((ref) {
  return GoodBaller();
});

// Read the provider
final player = ref.read(playerProvider);



Enter fullscreen mode Exit fullscreen mode

3. Instance Lifecycle

When we initialize a singleton instance, it will remain alive until the application is closed. If the instance consumes a lot of memory or keeps an open network connection, we can't release it early if we want to.

Solution
Some packages like get_it and Riverpod give us more control over when a certain instance is disposed.

Riverpod is quite smart and lets us easily control the lifecycle of the state of a provider. For example, we can use the autoDispose modifier to ensure our AppConnection is disposed as soon as the last listener is removed:



final playerProvider = Provider.autoDispose<GoodBaller>((ref) {
  return GoodBaller();
});


Enter fullscreen mode Exit fullscreen mode

This is most useful when we want to dispose an object as soon as the widget is unmounted.

Conclusion

Now that we've covered the main drawbacks of using singletons and solutions. As we know, singletons have several advantages and are useful in certain cases. However, on the other hand, singleton does have several disadvantages. As software developers, we must be wise about when to use it and how we look for alternatives or solutions to the problems. There are several important things based on what I have experienced, but they could be different from the current conditions of your project.

1. It's better not to create your "own" singleton
Yes, this could be good if you are working on a project that is small and easy to maintain or you want to create a package and have a good reason to do so. But imagine, if it gets bigger, you will have a lot of difficulty maintaining your code in the future because you will create code that is difficult to test. The point is it depends on your needs, there is no absolute good or bad but which one is more suitable for you. Instead, you can consider step two!

2. Use a package like get_it or Riverpod
You can't use the package as you please, you need to consider how big your needs are and the impact you get from using it.
But in this case, you can use get_it or Riverpod.
These packages give you better control over your dependencies, meaning you can easily initialize, access, and remove them, without any of the drawbacks outlined above.

Once you understand this, you need to know what dependencies exist between different types of objects (widgets, controllers, services, repositories, etc.).

References

https://pub.dev/packages/get_it
https://riverpod.dev/
https://www.studysmarter.co.uk/
https://resocoder.com/

Top comments (0)