Flutter MVVM and Clean Architecture - Part 1: Setup

ยท

8 min read

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 their ViewModels. This enables the Views to easily push all responsibilities to their ViewModels 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 the View, what to do with user input, make the calls to the Interactors/Use Cases and/or Repositories 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 its View'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 the Interactors/Use Cases, or optionally in Repositories 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.

counter.gif

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!