I've written in the past an example of an iOS app, using The Clean Architecture and MVVM, which is a solution that has worked very well for me and my teams in the past years.
I'm going to share as a series of posts how easy and clean it can be scaling an app from a simple MVVM to something similar to The Clean Architecture whilst keeping your code well tested.
MVVM
There are many articles out there, using all sorts of languages, exemplifying MVVM and its characteristics. What I like about this pattern is that it's easy to reason about and easy to test. A few points I like to follow when using it:
Make use of reactive programming to create "binds" from your "
Views
" (widgets) and theirViewModels
. This enables theViews
to easily push all responsibilities to theirViewModels
and listen to state updates.Managing state changes directly in the widgets is very hard to test and gets messy very quickly.
Views
must be as dumb as possible, meaning, they only render the interface, must not performs API calls, manage Entities, handle tracking or even routing (I know it might sound weird, but we'll get there).ViewModels
should only handle the presentation of theView
, what to do with user input, make the calls to theInteractors
/Use Cases
and/orRepositories
which will return/send some data depending on the business requirements.Geral rule of thumb, each screen has their own
ViewModel
. Since they're specifically responsible for handling itsView
's presentation (including tracking/analytics), it doesn't make much sense to make them ultra generic. The more abstract/generic business logic of the app won't be sitting here, but in theInteractors
/Use Cases
, or optionally inRepositories
if you're not a fan of the "extra layer" (more about this in future posts).
Show me the code!
First of all, let's create an abstract class
, also known as Interface, to represent our ViewModel
.
abstract class ViewModel {
ViewModel();
/// This method is executed exactly once for each State object Flutter's
/// framework creates.
void init() {}
/// This method is executed whenever the Widget's Stateful State gets
/// disposed. It might happen a few times, always matching the amount
/// of times `init` is called.
void dispose();
/// Called when the top route has been popped off, and the current route
/// shows up.
void routingDidPopNext() {}
/// Called when the current route has been pushed.
void routingDidPush() {}
/// Called when the current route has been popped off.
void routingDidPop() {}
/// Called when a new route has been pushed, and the current route is no
/// longer visible.
void routingDidPushNext() {}
}
These are the basic methods of every ViewModel
, all but the dispose
method are optionally implemented by any view model that might extend
this interface. The reason for that is that disposing of resources is crucial for the lifecycle of any app, and having to implement this method in every view model should remind developers of that.
Now it's time to implement the View
's interface:
final RouteObserver<ModalRoute<void>> routeObserver =
RouteObserver<ModalRoute<void>>();
abstract class View<VM extends ViewModel> extends StatefulWidget {
final VM viewModel;
const View.model(this.viewModel, {Key? key}) : super(key: key);
}
abstract class ViewState<V extends View, VM extends ViewModel> extends State<V>
with RouteAware {
late final VM _viewModel;
late final Logger logger;
VM get viewModel => _viewModel;
String get _sanitisedRoutePageName {
return '$runtimeType'.replaceAll('_', '').replaceAll('State', '');
}
@mustCallSuper
ViewState(this._viewModel) {
logger = Logger(runtimeType.toString());
logger.fine('Created $runtimeType.');
}
@mustCallSuper
@override
void initState() {
super.initState();
viewModel.init();
}
@mustCallSuper
@override
void didChangeDependencies() {
super.didChangeDependencies();
// subscribe for the change of route
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
}
/// Called when the top route has been popped off, and the current route
/// shows up.
@mustCallSuper
@override
void didPopNext() {
logger.finer('๐ $_sanitisedRoutePageName didPopNext');
viewModel.routingDidPopNext();
}
/// Called when the current route has been pushed.
@mustCallSuper
@override
void didPush() {
logger.finer('๐ $_sanitisedRoutePageName didPush');
viewModel.routingDidPush();
}
/// Called when the current route has been popped off.
@mustCallSuper
@override
void didPop() {
logger.finer('๐ $_sanitisedRoutePageName didPop');
viewModel.routingDidPop();
}
/// Called when a new route has been pushed, and the current route is no
/// longer visible.
@mustCallSuper
@override
void didPushNext() {
logger.finer('๐ $_sanitisedRoutePageName didPushNext');
viewModel.routingDidPushNext();
}
@mustCallSuper
@override
void dispose() {
routeObserver.unsubscribe(this);
logger.fine('Disposing $runtimeType.');
viewModel.dispose();
super.dispose();
}
}
As you can see this abstract class will take care of informing the view model when the widget was initialised, disposed as well as any routing updates (since it's implementing Flutter's RouteAware mixin). I've added some extra logging for those, which I personally find helpful for debugging.
Upgrading Flutter's new project's app to MVVM
Alright, let's take it for a spin and upgrade Flutter's start project into MVVM. The entire source code can be seen on my Github.
We're going to disable the minus button when the count is 0 and disable the plus button when the count is 5.
View Model
import 'package:flutter_mvvm/mvvm/view_model.abs.dart';
import 'package:rxdart/rxdart.dart';
class HomePageState {
final int count;
final bool isMinusEnabled;
final bool isPlusEnabled;
HomePageState({
this.isMinusEnabled = false,
this.isPlusEnabled = true,
this.count = 0,
});
HomePageState copyWith({
bool? isMinusEnabled,
bool? isPlusEnabled,
int? count,
}) {
return HomePageState(
isMinusEnabled: isMinusEnabled ?? this.isMinusEnabled,
isPlusEnabled: isPlusEnabled ?? this.isPlusEnabled,
count: count ?? this.count,
);
}
}
class HomePageViewModel extends ViewModel {
final _stateSubject = BehaviorSubject<HomePageState>.seeded(HomePageState());
Stream<HomePageState> get state => _stateSubject;
void plusButtonTapped() {
_updateState(_stateSubject.value.count + 1);
}
void minusButtonTapped() {
_updateState(_stateSubject.value.count - 1);
}
void _updateState(int newCount) {
final state = _stateSubject.value;
_stateSubject.add(
state.copyWith(
count: newCount,
isPlusEnabled: newCount < 5,
isMinusEnabled: newCount > 0,
),
);
}
@override
void dispose() {
_stateSubject.close();
}
}
I'm using RxDart to handle Streams
since ReactiveX is a common patterns used in many languages. Note that I'm not a "purist" when it comes to Rx, I like to keep it simple and often will manipulate the object directly instead of really making everything reactive.
The View
, in this case, HomePage
will not have direct access to manipulating the counter, or handle whether buttons are enabled or not. This kind of logic is handled by the presentation layer, our ViewModel
.
The view model exposes a read-only state
stream to the widget, which will listen to it in order to keep itself up-to-date. The private state is a BehaviorSubject, which keeps in memory the last event emitted, allowing us to internally manipulate it whilst pushing new updates to the widget.
Any user input, such as button taps, are "bound" to the view model, the two exposed methods for it, which isn't the most "reactive programming approach" of doing it, but I personally prefer to keep it simple.
All this view model does is increment and decrement the count, as well as update the active status of the buttons.
View
class HomePage extends View<HomePageViewModel> {
// We're instantiating the HomePageViewModel directly here for now. In the
// next part of this series it's going to change
HomePage({Key? key}) : super.model(HomePageViewModel(), key: key);
@override
_HomePageState createState() => _HomePageState(viewModel);
}
class _HomePageState extends ViewState<HomePage, HomePageViewModel> {
_HomePageState(HomePageViewModel viewModel) : super(viewModel);
@override
Widget build(BuildContext context) {
// StreamBuilder will automatically update the UI when the
// stream emits a new value
return StreamBuilder<HomePageState>(
stream: viewModel.state,
builder: (context, snapshot) {
if (!snapshot.hasData) return Container();
final state = snapshot.data!;
return Scaffold(
appBar: AppBar(
title: const Text('Home Page'),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'You have pushed the button this many times:',
),
const SizedBox(height: 32),
Text(
'${state.count}',
style: Theme.of(context).textTheme.headline4,
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppButton(
isEnabled: state.isMinusEnabled,
onTap: viewModel.minusButtonTapped,
child: Text(
'-',
style: Theme.of(context)
.textTheme
.headline3
?.copyWith(color: Colors.blue),
),
),
const SizedBox(width: 32),
AppButton(
isEnabled: state.isPlusEnabled,
onTap: viewModel.plusButtonTapped,
child: Text(
'+',
style: Theme.of(context)
.textTheme
.headline3
?.copyWith(color: Colors.blue),
),
),
],
)
],
),
),
),
),
);
});
}
}
The HomePage
extends the View
abstract class using the HomePageViewModel
as its view model. An instance of this view model needs to be injected into this HomePage
in order for it to operate. For now we're hardcoding it in the initialiser, but in the next post we'll change that.
You can see how no setState
is ever used, there's no data manipulation other than the strings happening in the widget. The strings could be moved as well to the view model, but a real app would make use of internationalisation, which depends on the BuildContext
and that shouldn't be handled in the view model.
Notice that the buttons inputs just point to the view model, and the view model's state is consumed by the widget.
The user interface code in the widget file, the presentation logic in the view model. Beautiful. I bet this is simple to test, want to check it out?
1, 2, 3 testing...
There's multiple ways of testing it, RxDart
and Dart
's stream libs have some testing helpers too. I usually go for the simple approach of extracting the values from the streams. I find it easy to reason about and maintain.
import 'package:flutter_mvvm/home/home_page_vm.dart';
import 'package:test/test.dart';
void main() {
late HomePageViewModel viewModel;
setUp(() {
viewModel = HomePageViewModel();
});
group('HomePageViewModel', () {
test('initial state starts at 0 and minus disabled', () async {
final state = await viewModel.state.first;
expect(state.count, 0);
expect(state.isMinusEnabled, false);
expect(state.isPlusEnabled, true);
});
test('plus button increases the count and enables minus', () async {
viewModel.plusButtonTapped();
final state = await viewModel.state.first;
expect(state.count, 1);
expect(state.isMinusEnabled, true);
expect(state.isPlusEnabled, true);
viewModel.dispose();
});
test('plus button disables at count 5', () async {
viewModel.plusButtonTapped();
var state = await viewModel.state.first;
expect(state.count, 1);
expect(state.isMinusEnabled, true);
expect(state.isPlusEnabled, true);
viewModel.plusButtonTapped();
viewModel.plusButtonTapped();
viewModel.plusButtonTapped();
state = await viewModel.state.first;
expect(state.count, 4);
expect(state.isMinusEnabled, true);
expect(state.isPlusEnabled, true);
viewModel.plusButtonTapped();
state = await viewModel.state.first;
expect(state.count, 5);
expect(state.isMinusEnabled, true);
expect(state.isPlusEnabled, false);
viewModel.dispose();
});
test('minus button disables at count 0', () async {
viewModel.plusButtonTapped();
var state = await viewModel.state.first;
expect(state.count, 1);
expect(state.isMinusEnabled, true);
expect(state.isPlusEnabled, true);
viewModel.minusButtonTapped();
state = await viewModel.state.first;
expect(state.count, 0);
expect(state.isMinusEnabled, false);
expect(state.isPlusEnabled, true);
viewModel.dispose();
});
});
}
Conclusion
This is a very introductory example, but stick around, as this series develops the examples will follow. In the Part 2 we'll be looking into managing routing in the app.
Don't forget to check out the full source code for this project on Github.
Thanks for reading and let me know what you think. Feel free to message me up on Twitter too!