MVVM Without Libraries
Flutter MVVM
The Flutter team have updated their State Management page to include MVVM. We’ve been using MVVM for 4 years now, and have updated our internal template for this, still using Provider and GetIt. In this post I’m going to show you how to set up MVVM and a basic Service Locator for your project without any libraries though.
The ViewModel
As before, the ViewModel is simple. We create an abstract class and extend ChangeNotifier
. All of our ViewModels will now extend this.
abstract class ViewModel extends ChangeNotifier {}
The View
To create the View, we want to make sure it has access to a ViewModel via a builder or injection, and that the view is updated when changes occur in the ViewModel.
abstract class ViewModelWidget<T extends ViewModel> extends StatefulWidget {
const ViewModelWidget({super.key});
@override
State<ViewModelWidget> createState() => _ViewModelWidgetState<T>();
T model(BuildContext context) => context.get<T>();
Widget build(BuildContext context, T model);
}
class _ViewModelWidgetState<T extends ViewModel>
extends State<ViewModelWidget<T>> {
late T _model;
@override
void initState() {
super.initState();
_model = widget.model(context);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.model(context),
builder: (BuildContext context, Widget? child) =>
widget.build(context, _model),
);
}
}
What we’re doing is fairly simple, we’re creating an abstract ViewModelWidget
class which we can extend from in the view layer, supplying a type, and providing a build(context, model)
function which the developer will be forced to override giving them access to the ViewModel.
We are also providing a model(context)
function which can be optionally overridden, with a default which will (once made) attempt to pull a ViewModel instance of type <T extends ViewModel>
from our ServiceLocator. This could also be done inside of initState()
without the function.
Service Locator
The Service Locator is the glue which will hold this together. This is where you will define your dependency tree and let the app know how to construct models, services, repositories etc. Every dependency should be created beforehand (singleton), or have a function to be able to create an instance of an object when required (factory). Our Service Locator will be simple for now, though in future it would be more pragmatic to use a library such as GetIt
or Provider
to do this for us as this is just reinventing the wheel at this point.
extension on Type {
String serviceName([String? name]) =>
[toString(), name].where((s) => s != null).join('+');
}
final class ServiceLocator {
final Map<String, dynamic> _services;
ServiceLocator({Map<String, (bool, dynamic)>? services})
: _services = services ?? {};
T get<T>([String? name]) {
final record = _services[T.serviceName(name)];
if (record != null) {
if (record is Function) {
return record() as T;
}
return record as T;
}
throw Exception('Ojbect of type "$T" is not registered');
}
void register<T>(T service, [String? name]) {
final serviceName = _getServiceName<T>(service, name);
if (_services.containsKey(serviceName)) {
throw Exception('Ojbect of type "$T" is already registered');
}
_services[serviceName] = service;
}
void registerFactory<T>(T Function() factory, [String? name]) {
final serviceName = _getServiceName<T>(factory, name);
if (_services.containsKey(serviceName)) {
throw Exception('Object of type "$T" is already registered');
}
_services[serviceName] = factory;
}
void unregister<T>([String? name]) {
_services.remove(T.serviceName(name));
}
String _getServiceName<T>(service, String? name) {
// We should _never_ register a dynamic type.
if (T.toString() == 'dynamic') {
return service.runtimeType.serviceName(name);
}
return T.serviceName(name);
}
static ServiceLocator of(BuildContext context, [String? aspect]) =>
_ServiceLocator.of(context, aspect);
}
final class _ServiceLocator extends InheritedModel<ServiceLocator> {
final ServiceLocator _locator;
const _ServiceLocator({
required ServiceLocator locator,
required super.child,
}) : _locator = locator;
static ServiceLocator? maybeOf(BuildContext context, [String? aspect]) {
return InheritedModel.inheritFrom<_ServiceLocator>(context, aspect: aspect)
?._locator;
}
static ServiceLocator of(BuildContext context, [String? aspect]) {
final ServiceLocator? result = maybeOf(context, aspect);
assert(result != null, 'Unable to find an instance of ServiceLocator...');
return result!;
}
@override
bool updateShouldNotify(covariant InheritedWidget oldWidget) => false;
@override
bool updateShouldNotifyDependent(
covariant InheritedModel<ServiceLocator> oldWidget,
Set<ServiceLocator> dependencies) =>
false;
}
Let’s break this down:
The map of services
final Map<String, (bool, dynamic)> _services;
Here we are simply declaring where our services will be held in memory. The <String>
will be the name of the service, and dynamic
is for holding the service itself.
Retrieving a service
T get<T>([String? name]) {
final record = _services[T.serviceName(name)];
if (record != null) {
if (record is Function) {
return record() as T;
}
return record as T;
}
throw Exception('Ojbect of type "$T" is not registered');
}
When we retrieve a service from the locator we want to first check if the service exists in the locator, and throw an exception if not.
If the service does exist, we check if it’s a singleton or factory (is a Function
), and then either return the singleton, or return the result of running the factory function, to the caller.
Registering a service
void register<T>(T service, [String? name]) {
final serviceName = _getServiceName<T>(service, name);
if (_services.containsKey(serviceName)) {
throw Exception('Ojbect of type "$T" is already registered');
}
_services[serviceName] = service;
}
void registerFactory<T>(T Function() factory, [String? name]) {
final serviceName = _getServiceName<T>(factory, name);
if (_services.containsKey(serviceName)) {
throw Exception('Object of type "$T" is already registered');
}
_services[serviceName] = factory;
}
When we register a service it can either be a Singleton or a Factory. We first get a service name then check if a service of that name is already registered, and throw an exception if it is. If not, we simply add to the map of services under the service name. We expose 2 functions for this, one for registering singletons and the other for registering factories.
void unregister<T>([String? name]) {
_services.remove(T.serviceName(name));
}
We also expose a unregister
function, mainly used to help with testing.
Service Name
To hold the services in our map, and ensuring we don’t have multiple services registered of the same type or name
String _getServiceName<T>(service, String? name) {
// We should _never_ register a dynamic type.
if (T.toString() == 'dynamic') {
return service.runtimeType.serviceName(name);
}
return T.serviceName(name);
}
This first check lives inside the ServiceLocator
class as a quick function to generate the name on register, ensuring that the name is never dynamic
.
extension on Type {
String serviceName([String? name]) =>
[toString(), name].where((s) => s != null).join('+');
}
We also create a simple extension on Type
to generate a name from the type of service we’re registering and an optional name
passed in. This we could retrieve multiple services of the same type but with different names, like so:
final localService = context.get<MyService>('local');
final remoteService = context.get<MyService>('remote');
Retrieving the Service Locator
We would like to be able to retrieve services using our context
so we’re not referencing a static instance throughout our code. This will make our lives easier later on, for example if we want to swap out our implementation with another such as GetIt
final class _ServiceLocator extends InheritedModel<ServiceLocator> {
final ServiceLocator _locator;
const _ServiceLocator({
required ServiceLocator locator,
required super.child,
}) : _locator = locator;
static ServiceLocator? maybeOf(BuildContext context, [String? aspect]) {
return InheritedModel.inheritFrom<_ServiceLocator>(context, aspect: aspect)
?._locator;
}
static ServiceLocator of(BuildContext context, [String? aspect]) {
final ServiceLocator? result = maybeOf(context, aspect);
assert(result != null, 'Unable to find an instance of ServiceLocator...');
return result!;
}
@override
bool updateShouldNotify(covariant InheritedWidget oldWidget) => false;
@override
bool updateShouldNotifyDependent(
covariant InheritedModel<ServiceLocator> oldWidget,
Set<ServiceLocator> dependencies) =>
false;
}
We’re creating a InheritedModel<ServiceLocator>
here so that we can inject our ServiceLocator
at the root of our application and access it via a BuildContext
. As this is a singleton we can just set all notifies to false
, we want to only create this once, and not change it during runtime.
Accessing the ServiceLocator
Now we have a ServiceLocator, how do we access it? We create a wrapper around it to inject it at the root of or application:
class ServiceLocatorProvider extends StatelessWidget {
final ServiceLocator _locator;
final Widget child;
ServiceLocatorProvider.services({
super.key,
required this.child,
required List<dynamic> services,
}) : _locator = ServiceLocator() {
for (final service in services) {
_locator.register(service);
}
}
const ServiceLocatorProvider.locator({
super.key,
required this.child,
required ServiceLocator locator,
}) : _locator = locator;
@override
Widget build(BuildContext context) {
return _ServiceLocator(
locator: _locator,
child: child,
);
}
}
This works by accepting an instance of a ServiceLocator and using our previously created InheritedModel<ServiceLocator>
in order to provide access to it via a context using ServiceLocator.of(context)
. As long as the view we’re using for this is a child of ServiceLocatorProvider
, we will have access to the ServiceLocator.
Let’s create an extension to make this a bit less verbose:
extension ServiceLocatorExtension on BuildContext {
T get<T>() => ServiceLocator.of(this).get<T>();
}
Now we can call context.get<MyService>
from our codebase to get an instance of MyService
.
Putting it all together
So now we have our ViewModel
, ViewModelWidget
and ServiceLocator
defined, let’s put them together.
The ViewModel
Let’s create a ViewModel with a dependency on a CounterService
, modifying the standard flutter starter app.
The HomeViewModel
:
class HomeViewModel extends ViewModel {
final CounterService _counterService;
HomeViewModel(CounterService countService) : _counterService = countService {
_counterSubscription = _counterService.count.listen(
(i) {
_serviceCount = i;
notifyListeners();
},
);
}
@override
void dispose() {
_counterSubscription.cancel();
super.dispose();
}
late int _serviceCount = _counterService.currentCount;
int get serviceCount => _serviceCount;
late StreamSubscription<int> _counterSubscription;
int _count = 0;
int get count => _count;
void incrementServiceCount() => _counterService.increment();
void incrementCount() {
_count++;
notifyListeners();
}
}
And the CounterService
:
import 'dart:async';
class CounterService {
int _currentCount = 0;
int get currentCount => _currentCount;
final StreamController<int> _countController = StreamController.broadcast();
Stream<int> get count => _countController.stream;
void increment() {
_currentCount++;
_countController.add(_currentCount);
}
}
In practice this would be our access to repositories, APIs etc, and I’ve included a stream so show how this could be done.
The ViewModelWidget
Here we’re creating a simple page that displays the overall count in the counter service, as well as an internal count for the ViewModel. Navigating to a new page will display the same overall count in the counter service as that is a singleton, but will show a new count for a new ViewModel as that is created as a factory.
class HomePage extends ViewModelWidget<HomeViewModel> {
const HomePage({super.key});
@override
Widget build(BuildContext context, HomeViewModel model) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text('Simple MVVM Demo'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8.0,
children: <Widget>[
const Text('CounterService count:'),
Text(
'${model.serviceCount}',
style: Theme.of(context).textTheme.headlineMedium,
),
IconButton(
onPressed: model.incrementServiceCount,
icon: const Icon(Icons.add),
),
Divider(),
const Text('ViewModel count:'),
Text(
'${model.count}',
style: Theme.of(context).textTheme.headlineMedium,
),
IconButton(
onPressed: model.incrementCount,
icon: const Icon(Icons.add),
),
Divider(),
FilledButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const HomePage()),
),
child: Text('New Page'),
)
],
),
),
);
}
}
The App Class
Create an instance of a ServiceLocator
with your dependencies.
ServiceLocator locator = ServiceLocator()
..register<CounterService>(CounterService())
..registerFactory<HomeViewModel>(
() => HomeViewModel(locator.get<CounterService>()));
Then update your App class to use the ServiceLocatorProvider
to give access to it’s children to the ServiceLocator
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ServiceLocatorProvider.locator(
locator: locator,
child: MaterialApp(
title: 'Simple MVVM Demo',
home: const HomePage(),
),
);
}
}
And hit run! There we have it, an implementation of MVVM using no external libraries that can be expanded easily and where the state can be updated using standard functions.
Moving forwards
Moving forwards with this I would personally recommend using one of the more complete libraries to hold references to your services, such as GetIt
or Provider
, and simplify this code with the more feature complete libraries. This was simply a fun exercise to create a simple MVVM implementation using out of the box tools.
You can see all this code inside a repo Here