Flutter MVVM Application Architecture

Each year I try to learn a new language, framework or technology. This practice encourages me to continuously learn new skills and be a newbie again. This year, I’ve spent the past few months working on a mobile client for Docket . While the mobile web UI works well enough, I wanted to see if the UX could be smoother with a native application. I’ve never done any mobile development and this felt like a great opportunity for me to learn. I chose to go with Dart and Flutter for this exercise. Flutter’s goals are to provide a framework to build ahead of time compiled multi-platform applications from a single code base. This approach differs from technologies like react-native and electron in that there is no JavaScript code or engine present. Both the cross-platform build targets and native compilation were exciting to me. I have limited experience with both and would be learning a ton from this project.

Flutter is built by Google, and written in Dart (also built by google). Dart provides a type-safe and null-safe language with simple and safe concurrency primitives (async/await and isolates which are analogous to threads). While I’m generally skeptical of building on top of Google projects, I decided that if & when Google blows Flutter & Dart up I won’t have lost much. I’ve found Dart to be an excellent language.

Flutter is only a UI toolkit

I learned pretty quickly that Flutter is only a UI toolkit. Much like React, Flutter does not provide direction on how to structure your application. Instead Flutter assumes you’ll bring your own application architecture to manage local persistent state and do network calls.

Doing it the hard way

While there are several stable libraries that provide structure to Flutter applications, I wanted to use the smallest number of libraries possible. This would of course take longer and be more work, but one of my goals was to learn and building Docket ‘the hard way’ would force me to learn more of the basics. I did use provider to act as a gateway between the ‘application logic’ and Flutter. In addition to provider, I used a handful of other libraries for the drag and drop interactions, persistent localstorage, and task ‘mentions’.

My goals for the functionality of the mobile client were:

- Have similar UX to what the web app currently has.
- Support read operations when there is no network connection.
- Perform most sync operations behind the scenes but also provide ‘drag to refresh’.

Minimally viable architecture

After a failed design that attempted to normalize and de-duplicate data shared between views with an ORM like interface, my next pass looked like:

- lib/components Contained reusable UI widgets. These widgets could use providers directly to create side-effects.
- lib/models contained the entities in the application including factory methods to create entities from server responses. Early on I chose to make the local database format the same as the server API response. This worked out well and is an approach I would recommend for others.
- lib/providers This directory contained the logic to read from the local database, make API requests, and prepare data needed by each screen.
- lib/screens Contains the logic for each screen. In this first approach the screens contained a non-trivial amount of application logic.

While I was able to make an application that ‘worked’ with this design, it had several problems:

  1. Because I had made providers for each ‘kind’ of object, I didn’t have a good place to put loading and transformation logic that was specific to a single view. This resulted in verbose UI widgets that contained too much application logic.
  2. Testing the providers was straightforward, but testing all the logic embedded in the ‘screens’ was not. I spent a good amount of time playing whack-a-bug as I added features due to a lack of tests.
  3. Building out background sync and offline reads was challenging as my providers got complicated and bloated.

After doing more reading on Flutter application architectures I came across the MVVM pattern.

MVVM and ViewModels to the rescue

The MVVM (Model, View, ViewModel) pattern is similar to the MVC (Model, View, Controller) pattern I’ve used many times in server applications. In a Flutter context, Flutter Widgets form the View. My ViewModel classes would absorb most of the ‘provider’ logic I had built, and I would keep using my existing Model classes.

Each ‘screen’ would get its own ViewModel, and in turn each ViewModel would have methods to handle the actions for the view, fetch data from the local database, do API requests to fetch data. ViewModels use the provider library to signal when a screen needs to be re-rendered. The stripped down ViewModel for the today screen looks like:

Show Plain Text
  1. import 'package:flutter/material.dart';
  3. import 'package:docket/actions.dart' as actions;
  4. import 'package:docket/database.dart';
  5. import 'package:docket/models/project.dart';
  6. import 'package:docket/models/task.dart';
  7. import 'package:docket/components/tasksorter.dart';
  8. import 'package:docket/providers/session.dart';
  9. import 'package:docket/formatters.dart' as formatters;
  12. class TodayViewModel extends ChangeNotifier {
  13.   late LocalDatabase _database;
  14.   SessionProvider? session;
  16.   /// Whether data is being refreshed from the server or local cache.
  17.   bool _loading = false;
  19.   // Whether we're doing a background reload.
  20.   bool _silentLoading = false;
  22.   /// Task list for the day/evening
  23.   List<TaskSortMetadata> _taskLists = [];
  25.   TodayViewModel(LocalDatabase database, this.session) {
  26.     _taskLists = [];
  27.     _database = database;
  28.     _database.today.addListener(listener);
  29.   }
  31.   @override
  32.   void dispose() {
  33.     _database.today.removeListener(listener);
  34.     super.dispose();
  35.   }
  37.   void listener() {
  38.     loadData();
  39.   }
  41.   bool get loading => _loading && !_silentLoading;
  42.   bool get loadError => _loadError;
  43.   List<TaskSortMetadata> get taskLists => _taskLists;
  45.   setSession(SessionProvider value) {
  46.     session = value;
  47.   }
  49.   // Load data from the local database or refresh from server.
  50.   Future<void> loadData() async {
  51.     var taskView = await _database.today.get();
  52.     if (taskView.isEmpty == false) {
  53.       _buildTaskLists(taskView);
  54.     }
  55.     if (!_loading && taskView.isEmpty) {
  56.       return refresh();
  57.     }
  58.     if (!_loading && !_database.today.isFresh()) {
  59.       await refreshTasks();
  60.     }
  61.   }
  63.   /// Refresh tasks from server state. Does not use loading
  64.   /// state.
  65.   Future<void> refreshTasks() async {
  66.     _loading = _silentLoading = true;
  68.     var taskView = await actions.fetchTodayTasks(session!.apiToken);
  69.     _database.today.set(taskView);
  71.     _loading = _silentLoading = false;
  72.     _buildTaskLists(taskView);
  73.   }
  75.   /// Refresh from the server with loading state
  76.   Future<void> refresh() async {
  77.     _loading = true;
  78.     await Future.wait([
  79.       actions.fetchTodayTasks(session!.apiToken),
  80.       actions.fetchProjects(session!.apiToken),
  81.     ]).then((results) {
  82.       var tasksView = results[0] as TaskViewData;
  83.       var projects = results[1] as List<Project>;
  85.       return Future.wait([
  86.         _database.projectMap.replace(projects),
  87.         _database.today.set(tasksView),
  88.       ]).then((results) {
  89.         _buildTaskLists(tasksView);
  90.       });
  91.     });
  92.   }
  94.   void _buildTaskLists(TaskViewData data) {
  95.     // truncated for brevity
  96.   }
  98.   /// Reorder a task based on the protocol defined by
  99.   /// the drag_and_drop_lists package.
  100.   Future<void> reorderTask(int oldItemIndex, int oldListIndex, int newItemIndex, int newListIndex) async {
  101.     var task = _taskLists[oldListIndex].tasks[oldItemIndex];
  103.     // Get the changes that need to be made on the server.
  104.     var updates = _taskLists[newListIndex].onReceive(task, newItemIndex);
  106.     // Update local state assuming server will be ok.
  107.     _taskLists[oldListIndex].tasks.removeAt(oldItemIndex);
  108.     _taskLists[newListIndex].tasks.insert(newItemIndex, task);
  110.     // Update the moved task and reload from server async
  111.     await actions.moveTask(session!.apiToken, task, updates);
  112.     _database.expireTask(task);
  113.   }
  114. }

While this excerpt doesn’t include all of the logic it shows the data fetch and background refresh features that were challenging to build with general purpose providers. I decided to put ViewModels into their own directory – lib/viewmodel.

Testing ViewModels

Another benefit of adopting the ViewModel pattern is ease of testing. Because ViewModels are basic Dart objects they are trivial to write tests for. Here are some tests for the today view model loading data.

Show Plain Text
  1. import 'dart:convert';
  2. import 'dart:io';
  3. import 'package:flutter/material.dart';
  4. import 'package:flutter_test/flutter_test.dart';
  5. import 'package:http/http.dart';
  6. import 'package:http/testing.dart';
  8. import 'package:docket/actions.dart' as actions;
  9. import 'package:docket/formatters.dart' as formatters;
  10. import 'package:docket/database.dart';
  11. import 'package:docket/models/task.dart';
  12. import 'package:docket/models/project.dart';
  13. import 'package:docket/providers/session.dart';
  14. import 'package:docket/viewmodel/today.dart';
  16. void main() {
  17.   TestWidgetsFlutterBinding.ensureInitialized();
  19.   var today = DateUtils.dateOnly(DateTime.now());
  21.   var file = File('test_resources/tasks_today.json');
  22.   final tasksTodayResponseFixture = file.readAsStringSync().replaceAll('__TODAY__', formatters.dateString(today));
  24.   file = File('test_resources/project_list.json');
  25.   final projectListResponseFixture = file.readAsStringSync();
  27.   group('$TodayViewModel', () {
  28.     var db = LocalDatabase(inTest: true);
  29.     var session = SessionProvider(db, token: 'api-token');
  31.     setUp(() async {
  32.       await db.today.clear();
  33.     });
  35.     test('loadData() refreshes from server', () async {
  36.       // Stub network requests to return fixture data.
  37.       actions.client = MockClient((request) async {
  38.         if (request.url.path == '/tasks/today') {
  39.           return Response(tasksTodayResponseFixture, 200);
  40.         }
  41.         if (request.url.path == '/projects') {
  42.           return Response(projectListResponseFixture, 200);
  43.         }
  44.         throw "Unexpected request to ${request.url.path}";
  45.       });
  47.       var viewmodel = TodayViewModel(db, session);
  48.       expect(viewmodel.taskLists.length, equals(0));
  50.       await viewmodel.loadData();
  51.       expect(viewmodel.taskLists.length, equals(2));
  53.       // Check today
  54.       expect(viewmodel.taskLists[0].title, isNull);
  55.       expect(viewmodel.taskLists[0].showButton, isNull);
  57.       // Check evening
  58.       expect(viewmodel.taskLists[1].title, equals('This Evening'));
  59.       expect(viewmodel.taskLists[1].showButton, isTrue);
  60.     });
  61.   });
  62. }

In this test I’m using MockClient to stub out any network requests that are made. I much prefer this path over using generated mock objects as it ensures more of the code I wrote is working than mocks would. I have really been enjoying how expressive and easy to read and write flutter’s testing libraries are.

While I don’t have a ton of experience with Flutter, I’ve greatly enjoyed the process of learning both Dart and Flutter. The development experience is really smooth and well built. Tools like linting, testing and running a development debugger are all built-in and work quite well. If you’re looking to build a cross-platform UI application I would recommend giving Flutter a try.

If you’re interested in reading more of the code, the full source is on GitHub


There are no comments, be the first!

Have your say: