Flutter: A Skeleton App w/ GetIt + GetItMixin

Recently we compared and contrasted some common state management packages. And while app architecture was not the focus of the article, we received some feedback that the examples were too simple to give a good picture of how the approaches would scale. In this post we’ll attempt to address that concern by building a simple yet scalable skeleton app using GetIt and GetItMixin!

Get What?

If you’re not familiar with GetIt, it is a robust implementation of the classic service locator pattern, which allows you to register objects by their type and then look them up from anywhere in the app. Recently it gained a sibling package named GetItMixin which provides reactive widget binding hooks. Combined these two packages make up one of the simplest and cleanest methods of managing state within a Flutter application. Let’s take a look!

NOTE: To follow along, the full demo is posted here:
https://github.com/esDotDev/flutter_experiments/tree/master/get_it_skeleton_app

Step 0. Setting the stage.

To really show off an architectures ability to scale, we need a somewhat complex app. Layout and navigation code are not the focus here, but lets assume that the app being built will have the following “features”:

  • List nearby Bluetooth devices
  • Access to location of users device (gps coords)
  • Persistent app settings
  • In-app purchases
  • Maps integration

In addition to the technical requirement above, this architecture should also:

  • Cleanly separate logic and ui code
  • Allow ui to easily use actions and bind to data reactively
  • Provide ability to override logic components for easy testing

Step 1. The “logic” layer:

Before diving into implementation, let’s look first at structure. Our logic layer will primarily consist of a set of manager and service classes to handle the various features of the app. managers are collections of state and some actions to work on that state. services exist to interact with the outside world, while providing an abstracted API for your managers to use. There can also be other supporting logical packages, like data or utils:

Keep it flat, or organize folder-by-feature, both should work nicely here.

Managers

GetItMixin is designed to work with mutable objects like ChangeNotifiers or ValueNotifiers. This means that each manager class that holds state, should usually be a ChangeNotifier or a collection of ValueNotifier fields. For this example, we’ll use the ValueNotifier approach as it is the most succinct.

Here is an example of how one of the managers might look:

class PurchasesManager {
  //State 
  final isProEnabled = ValueNotifier<bool>(false);
  // Actions
  Future<void> init() async { ...  }
  Future<void> upgradeToPro() async => ...
  Future<void> restorePurchases() async => ...
  // etc
}

You can see that we declare a mutable isProEnabled field, that ui will be able to bind to, and a set of actions that the ui can make use of. More on this below…

For more examples of managers, check out the BlueTooth Manager, Purchases Manager or Settings Manager classes.

Services

Services are just vanilla classes that interact with the world outside your application. Usually with async methods like init(), scanDevices(), saveFile(), getLocation() etc. They are used by managers to complete their actions. The main purpose of the services are to remove implementation details from the manager and provide a simple and clean API for it to consume. Not only does this make testing quite easy, but it allows you to change out implementations later with minimal refactoring.

For some examples of services, check out the LocationService or BlueToothService classes. Keep in mind that this is a skeleton app, and these are stub classes so there is really not a lot to look at here!

Disposal

Another common responsibility of the logic layer is to dispose of things when they are no longer in use. GetIt makes this easy with a Disposable interface you can add to your singletons, enabling you to dispose multiple services or managers with a single call to GetIt.I.reset(), or GetIt.I.popScope().

Step 2. Declaring your singletons (preferably in main.dart!)

With our logical structure defined, lets look at how we can make those objects globally accessible withGetIt:

A couple notes on the above:

  • We use lazy registration here so that any singleton can access any other, with worrying about order of instantiation. This works similarly to the late keyword in dart.
  • We can use the pushNewScope method to easily replace any singleton with a mock version. In this example, when testMode=true, anyone who requests a BlueToothService instance, will instead receive a BlueToothServiceMock.
  • It is a good idea to place this code in a conspicuous place like main.dart or /lib/singletons.dart. This way, new developers are likely to spot it, and will gain an immediate grounding in your architecture.

A spoonful of sugar…

Now that our singletons are all mapped, we could access them with getIt.I.get<Foo> and call it a day, but it is a nicer on your ui developers to add a little syntax sugar, and define shortcuts for our most commonly used instances:

That’s more like it!

To see the complete example, checkout the full main.dart file.

With the logic layer created, and singletons mapped, we’re ready to move on to the ui!

Step 3. Creating the “ui” layer

We have found that ui components for an app can generally be broken up by a few main types:

  • /pages
  • /modals
  • /common

Using that general structure, you can see an example of some of the components this example app might have here:

folder-by-feature structure is less obvious in the ui layer, so we organize by type instead.

As the app grows, many more sub-packages will need to be created, and organization will become critical as your file count will often grow to dozens or even hundreds. However, these three top-level packages should provide a solid set of high level buckets, within which most components or sub-packages will naturally fit.

From within any of these ui components, there are two things we will need to be able to do: trigger actions and bind to data.

Actions

Using actions is simple, we can just reach out to any of our managers, and call methods directly:

onPressed: () => bluetooth.ignoreDevice(...);
onPressed: () => app.showDialog(...);
onPressed: () => settings.darkMode.value = false;

The code above shows one of the core benefits of this approach: its directness. There is no context or ref required to trigger actions within your app, you just grab the appropriate manager and call a method. There is no need for big reducers methods, middleware layers, or for multiple classes or enums to be defined for message passing. If you want to pass some data to the action, just pass it to the method directly! This tight coupling keeps boilerplate to a minimum, and also makes debugging more straightforward.

Data Binding

To bind to some data within the logic layer add the GetItMixin to any StatelessWidget and call watchX to bind to any ValueNotifier.

For example, we could bind to the list of visible devices with:

class ListDevicesPage extends StatelessWidget with GetItMixin {
  ListDevicesPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    List<BtDeviceInfo> devices = watchX((BlueToothManager m) => m.visibleDevices);
    return ListView(
      children: devices.map(_DeviceListTile.new).toList(),
    );
  }
}

This binding syntax is impressively succinct. It’s also nice that we do not need to extend a custom widget, or use a custom build method signature. Just adding the mixin is enough.

As another example, the Settings view might look something like this:

class SettingsDrawer extends StatelessWidget with GetItMixin {
  @override
  Widget build(BuildContext context) {
    // Bind to `darkMode` property, we will rebuild when it changes
    bool darkMode = watchX((SettingsManager m) => m.darkMode);
    return Column(children: [
      Checkbox(
        value: darkMode,
        // Change the darkMode value when pressed
        onChanged: (value) => settings.darkMode.value = value,
      ),
    ]);
  }
}

In this case we just use the .value field on the ValueNotifier as the “action”, since there is not much to be gained by creating a setDarkMode method just to set this value. However, you could certainly do that if you wished to provide a slightly more succinct API for your views, or to make it more obvious when side effects are occurring.

For more simple examples of view bindings, and actions being used, check out the MapsPage, ListDevicesPage or the MainApp.

watch___
In addition to watchX for ValueNotifier, GetItMixin supports a number of other use cases, these are just some of them:

  • watchOnly – Watch a specific field on a ChangeNotifier
  • watchStream – Watch a stream, provide a default value
  • watchFuture – Watch a future, provide a default value
  • registerHandler – attach ui side-effect to ValueNotifier
  • registerStreamHandler – attach ui side-effect to Stream
  • registerFutureHandler – attach ui side-effect to Future

For a list of all available methods, and more information about what they do, check out the GetItMixin docs page.

StatefulWidget
You can use all of these methods on StatefulWidgets as well. To do so, add the GetItStatefulWidgetMixin and GetItStateMixin mixins respectively. You can see a basic example of this in the MainApp class:

That’s it! At this point you have a scalable and testable architecture that can take you very far. You can bind to shared data and initiate actions from any level in the widget tree. Your logic and ui packages have plenty of room to scale, and organization should be easy and obvious for the life of the project. Finally, you can very easily mock or test any of your managers or services, either individually or as an integrated system.

A note on package structure

You may be wondering why this architecture is not organized by the classic folder-by-type, or folder-by-feature style. The answer is that while logical components like services, manager, models often group naturally by feature, many views will touch several features or data sources at once. This can make it difficult, or even impossible, to organize all logical components along with corresponding views (aka folder-by-feature).

For example, in the app above, it would be common for different views to access BlueToothManager, while also accessing SettingsManager and PurchasesManager, etc. It would not be totally clear where to put certain views in the first place, which leads to future developers being unsure where to find them. Which is the opposite of what we want!

Because of this, we’ve found it can be nice to group by the high-level packages of ui and logic making a clean high-level split at the top. And then organize packages a little differently within the two layers:

  • Within the logic package, folder-by-feature approach will work nicely, or you can just keep it mostly flat and rely on good naming to ensure things are grouped together. For instance, blue_tooth_service and blue_tooth_manager will naturally live next to each other. This works well here, because you typically don’t have more than 10 or 20 of these managers and services classes, organization does not need to be overly granular.
  • Within the ui package, you can organize by pages, modals, and any further groupings you need to keep things organized in a more folder-by-type approach. This additional organization is much more critical in the ui layer, as you’ll typically have dozens or even hundreds of custom widgets in your app and good organization is key to easily finding / discovering them later.

Hopefully you enjoyed this article, we plan to do another one on riverpod next. If you have any questions or comments, let us know below!

shawn.blais

Shawn has worked as programmer and product designer for over 20 years, shipping several games on Steam and Playstation and dozens of apps on mobile. He has extensive programming experience with Dart, C#, ActionScript, SQL, PHP and Javascript and a deep proficiency with motion graphics and UX design. Shawn is currently the Technical Director for gskinner.

@tree_fortress

8 Comments

  1. Wow! What a statement “Hopefully you enjoyed this article”, “Hopefully”? This is an excellent article, succinct, full of useful information, and explanatory. Thanks a mil and, please, keep them coming. As usual, your advice on architecture are a gem.
    BTW, Blaise, any plans to write an intermediate/advanced book on dart/flutter? Your writing prowess and ability to make things clear is great and I will buy your book in a flash!

  2. Wow! What a statement “Hopefully you enjoyed this article”, “Hopefully”? This is an excellent article, succinct, full of useful information, and explanatory. Thanks a mil and, please, keep them coming. As usual, your advice on architecture are a gem.
    BTW, Blaise, any plans to write an intermediate/advanced book on dart/flutter? Your writing prowess and ability to make things clear is great and I will buy your book in a flash!

  3. Wow, thanks for the kind words! No, no plans for a book at the moment.

  4. Shawn, thank for sharing your knowledge, I’m looking to adapt any architecture to use in my projects, so I think this is a light at the tunnel’s end.
    You could write about using GetIt and Riverpod, as an practical example. Thank you again, and happy new year.

  5. Hey Pedro, we’re working on some posts for riverpod now, but in the meantime you can view code for the riverpod version here: https://github.com/esDotDev/flutter_experiments/tree/master/riverpod_skeleton_app/lib

  6. why dont have getx ? can u talk about getx with this ?

  7. Truth is we prefer smaller more targeted libraries like `provider`, `riverpod`, `GetIt`, mainly because they solve the one specific thing that we can’t easily do ourselves. Once you have a good reactive DI system in place, the rest of the sugar provided by GetX is either fairly easy to implement, or better served by a dedicated library (like `go_router`).

    The biggest issue we have with large frameworks like GetX, is the additional complexity it imposes on the codebase, as each new developer now needs to understand an extra layer of abstractions, on top of the core abstractions. More lines of code to be understood, and more potential for bugs hiding in those lines of code. We prefer a more modular approach, where dedicated components are used to do specific jobs, but otherwise vanilla flutter and simple architectural design patterns are used as much as possible.

  8. Brilliant thank you very much for creating this gem.

Leave a Reply

Your email address will not be published. Required fields are marked *