Recently we took a deep dive into the new skeleton
template included in the Flutter SDK. As noted in the article, one of the big missing pieces in the template is a scalable state management solution with dependency injection and data-binding.
Given that, we thought it would be informative to convert it to use a couple of popular state management libraries, specifically riverpod
and GetItMixin
.
Converting to riverpod
For some background, the skeleton template uses a ChangeNotifier
based SettingsController
and it passes that controller down through the tree to various widgets. To simulate that with riverpod
, we’ll create 2 providers inside of main.dart
. We also need to add a ProviderScope
to the top of the tree:
void main() async { runApp( const ProviderScope(child: MyApp()), ); } final settingsProvider = ChangeNotifierProvider((ref) => SettingsController(ref)); final settingsServiceProvider = Provider((ref) => SettingsService());
In the original template, SettingsController
requires a SettingsService
dependency be passed in via the constructor. With riverpod
, the expected way to do something like this, is to pass a ref
into the controller, and have the controller lookup it’s dependency using the ref.read
method.
This looks something like:
class SettingsController with ChangeNotifier { SettingsController(this.ref); final Ref ref; SettingsService get _settingsService => ref.read(settingsServiceProvider); ... }
The benefits of having SettingsManager look up the settings object rather than instantiating it itself, is that it becomes very easy for us to mock the SettingsService
later if we want. This is typically known as the inversion of control principle.
View Binding
With the changes to the controller, we’re now ready to update the views. In the skeleton app there are two pages that access this SettingsController.themeData
, they are MyApp
and SettingsView
.
MyApp.dart
In the original template, an AnimatedBuilder
is used to rebuild the entire app anytime settings controller calls notifyListeners
. With riverpod
we can remove the AnimatedBuilder
, and extend ConsumerWidget
instead. ConsumerWidget
has a 2nd parameter in it’s build
method which is a ref
we can use to watch
one of our providers:
class MyApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final themeMode = ref.watch(settingsProvider.select((s) => s.themeMode)); ...
Notice that the provider.select
method here which allows us to bind only to the themeMode
value instead of the entire ChangeNotifier
.
SettingsView.dart
Like the MainApp
, this view binds to themeMode
. It also modifies it by calling updateThemeMode
. To accomplish this, we switch to a ConsumerWidget
, and use the watch
and read
methods respectively:
class SettingsView extends ConsumerWidget { ... @override Widget build(BuildContext context, WidgetRef ref) { final themeMode = ref.watch(settingsProvider.select((s) => s.themeMode)); return Scaffold( body: Padding( child: DropdownButton<ThemeMode>( value: themeMode, onChanged: ref.read(settingsProvider).updateThemeMode, ...
Finishing Up
The final step to converting the template, is to load the settings before showing the app. The original template did this in main.dart
, before calling runApp
. Like this:
void main() async { final settingsController = SettingsController(SettingsService()); await settingsController.loadSettings(); runApp(MyApp(settingsController: settingsController)); }
This is a bit tricky to do with riverpod
, as it requires you be inside of a Widget to obtain a ref. To do this, we converted MyApp
to be a StatefulWidget
, then used initState
and a _hasSettingsLoaded
flag to load the settings, before we show the actual app:
class _MyAppState extends ConsumerState<MyApp> { bool _hasSettingsLoaded = false; @override void initState() { ref.read(settingsProvider).loadSettings().then((_) { setState(() => _hasSettingsLoaded = true); }); super.initState(); } @override Widget build(BuildContext context) { if (_hasSettingsLoaded == false) return Container(); final themeMode = ref.watch(settings.select((s) => s.themeMode)); return MaterialApp(...); } }
That’s it! The app is identical to before, but the property drilling is gone, and you can easily mock the settings service, or the controller if you need.
If you’d like, check out the full example on github.com.
Converting to GetItMixin
Another great DI + binding setup is GetIt
and GetItMixin
. If you’re not familiar with these packages, check out our post here for a quick dive into how they work.
Converting to GetIt
is even easier than using riverpod
. To get started, we need to register our singletons, which we’ll do in main.dart
. Additionally, because GetIt
has no requirement for a WidgetRef
we can restore the original logic of loading settings inside of main
, and we can revert MyApp
to be stateless:
void main() async { registerSingletons(); await settings.loadSettings(); runApp(MyApp()); } void registerSingletons() { GetIt.I.registerLazySingleton(() => SettingsController()); GetIt.I.registerLazySingleton(() => SettingsService()); } // Add some shortcuts methods so views can easily fetch controller SettingsController get settings => GetIt.I.get<SettingsController>();
You may notice that our SettingsController
no longer takes anything in it’s constructor. This is because GetIt
does not require a ref
when looking things up. Thus, SettingsController
can be simplified to:
class SettingsController with ChangeNotifier { SettingsService get _settingsService => GetIt.I.get<SettingsService>(); ... }
Similar to the previous example with riverpod
this service is easily swapped out for a different implementation later, making it very simple to mock.
Another way we could handle this, is to pass the service in via constructor as before, but use GetIt to look it up:
GetIt.I.registerLazySingleton(() => SettingsController(GetIt.I.get<SettingsService>()));
In some ways this is preferable, as it more clearly shows the dependencies that SettingsController
has. But it’s a little harder to read and slightly more convoluted. In the name of simplicity we will keep it similar to the riverpod
approach, and go with the previous example, where the controller request the service itself.
View Binding
The final thing we need to do, is update the ui
views to bind to the themeMode
. Again the two views we need to change are MyApp.dart
and SettingsView.dart
MyApp.dart
We can bind to the themeMode
value using a GetItMixin
and the watchOnly
method:
class MyApp extends StatelessWidget with GetItMixin { @override Widget build(BuildContext context) { final themeMode = watchOnly((SettingsController s) => s.themeMode); ...
In the above, watchOnly()
allows us to bind only to the single property, and not to the entire ChangeNotifier
. This is similar to provider.select()
.
SettingsView.dart
class SettingsView extends StatelessWidget with GetItMixin { @override Widget build(BuildContext context) { final themeMode = watchOnly((SettingsController s) => s.themeMode); return Scaffold( body: Padding( padding: const EdgeInsets.all(16), child: DropdownButton<ThemeMode>( value: themeMode, onChanged: settings.updateThemeMode, ...
Again we use GetItMixin
and the watchOnly
method to bind to data. To change the value we can just use the settings
shortcut we defined earlier, and use the updateThemeMode
method.
We’re done! Again the app is identical in functionality, but the services and managers are now easily mocked, and there is no more need for property drilling. Check out the full example on github.com.
As always, please let us know if you have any questions or comments below!
Need help building something cool in Flutter? We’d love to chat.
Nice article! I think this demonstrates really nicely how easy it is to add your favorite Dependency Injection / View Binding package to the skeleton template, and one reason we didn’t want to “pick a winner.”
The only part where I disagree with your approach: In my view, the `SettingsController` should continue to take in a `SettingsService` as part of the constructor. First, that still allows for proper inversion of control — you can pass in any instance of `SettingsService` that you’d like.
Second, constructor parameters are even easier to work with in tests — just pass a `MockSettingsService` as part of the constructor. You don’t need to register any mocks with Riverpod or get_it, or remember to reset any mocks between tests. This means that each test can be properly isolated from each other without sharing any state. And sharing state between tests has always ended poorly for me, haha 🙂
What do you think?
Ha, ya I have argued with Remi about a similar thing: https://github.com/rrousselGit/river_pod/issues/864
Basically, when it comes to `riverpod`, as it is really geared towards using immutable data, it’s considered good practice to look up the dependency each time you access it, because it may have changed. I don’t entirely agree it always matters, but it is heavily recommended in the docs so I went with it for this example.
When it comes to `GetIt`, I think both ways have subtle pros and cons. It’s bit of a 50/50 thing for me… Of course you can directly supply a mock to the controller, but being able to override is important too. You could override your SettingsService indirectly with a mock, by doing something like:
registerSingletons();
GetIt.I.pushNewScope();
GetIt.I.registerLazySingleton<SettingsController>(() => SettingsController(SettingsServiceMock()));
await tester.pumpWidget(const MyApp());
The benefit here is `SettingsController` more clearly communicates what it depends on. The potential downside I see is the lack of directness. I’m creating 2 controllers, when what I really want to do is swap out a service. It shouldn’t create any weird side effect, especially since these are lazy mappings, but you never know? I could see this getting messy if I had many different services, or shared services across managers?
My cautious side says just keep it simple, live with the fact that the code is a little less self-documenting, for the trade-off of eliminating any future headaches or bugs.
So if we want to mock a service, we can simply mock the service:
registerSingletons();
GetIt.I.pushNewScope();
GetIt.I.registerLazySingleton<SettingsService>(() => SettingsServiceMock());
await tester.pumpWidget(const MyApp());
There is a best of both worlds solution imo. With riverpod, you just pass in a closure, that returns the dependency. This lets you mock/override services directly, while still exposing the dependencies in the constructor, something like:
final settingsProvider = ChangeNotifierProvider(
(ref) => SettingsController(() -> ref.read(settingsServiceProvider));
);
or, I guess with `GetIt`, we could just pass it directly:
GetIt.I.registerLazySingleton(() =>
SettingsController(GetIt.I.get<SettingsService>()));
I tend to
I tend to care very little about how easy it is to “mock” something. It should be doable, of course, but it’s not something I care to have my app optimized for.
Managing the actual dependencies between the actual code should be simple.
> This means that each test can be properly isolated from each other without sharing any state. And sharing state between tests has always ended poorly for me, haha
I don’t write much test for Flutter (yet), but I do dozens a week at work in another language.
We have thousands of tests with thousands of mocked services.
It’s not flutter/dart so there’s none of this riverpod stuff-stored-in-global-variables-but-accessed-through-context-which-is-actually-just-the-element-in-the-tree-that-the-widget-created nonsense, but there is a dependency container and dependency injection through constructor autowire (or manual wire).
Mocking classes is super easy and isolating test is also super easy.
Our unit tests are littered with mocked services – we have thousands of tests with thousands of mocked services in them, and I can remove about 70-80% of our actual code and all unit tests would pass just fine.
At this point we are testing the mocking library and not much else. But at least the builds are green, and we can deploy.
When the tooling allows for mocking any class with a single line of code, developers will tend to favor that solution when writing tests, rather than properly separating code with different concerns.
I’ve been finding mocking quite useful lately, as I do a lot of responsive UI testing on desktop, and it’s very nice to be able to quickly mock certain native services like Location, Payment or BlueTooth, so that the entire app continues to work and the UI can be built in desktop where it’s extremely fast to test responsive designs and behaviors.
Regarding containers, refs, contexts etc. I do find `GetIt`s method of pushing and popping scope, a more intuitive way to handle scoping and overrides, than `riverpods` use of a Widget based scoping system. But both work fine, and it’s not a major inconvenience.
I mean, it’s pretty important that your state-management solution provide a simple and robust way to swap out a service or controller with a mock.
I don’t actually use this primarily for unit testing, I use it often to allow me to build 99% of the app on desktop, mocking device-specific services like payments etc, but building the UI extremely quickly.
So for example, it’s really great with GetItMixin, I can do something like this when starting the app:
(MockPurchaseService());
if(isOnDesktop){
GetIt.I.pushScope();
GetIt.I.registerSingleton
}
Now no line in my entire app needs to change, no one is aware that a mock purchase service is being used, and I can test my entire IAP flow on desktop, easily and quickly dialing in the responsiveness.
Of course this is also super useful for running integration tests, where you want to run through the core views/features of your app, but isolate any service dependencies.
Hi Author, thanks for bring us this interesting article .
At the riverpod section, I want to know is there any elengent way to skip the `_hasSettingsLoaded` in `_MyAppState`?
Instead, can I use a `FutureProvider` to solve the loading state of settingsController?
Thanks,