Implementing the Model-View-ViewModel (MVVM) Pattern in Flutter

Flutter, Google’s UI toolkit for building natively compiled applications for mobile, web, and desktop from a single codebase, offers various architectural patterns to manage code efficiently. One of the most popular and recommended patterns is the Model-View-ViewModel (MVVM). MVVM facilitates the separation of concerns, making the code more maintainable, testable, and scalable. This article provides a comprehensive guide to implementing the MVVM pattern in Flutter, with practical code examples and explanations.

What is the MVVM Pattern?

Model-View-ViewModel (MVVM) is an architectural pattern that separates an application into three interconnected parts:

  • Model: The data layer. It is responsible for managing the data, either from a local database, a remote API, or any other data source.
  • View: The UI layer. It displays the data to the user and captures user interactions.
  • ViewModel: Acts as a bridge between the View and the Model. It exposes data relevant to the View and commands to handle user interactions, without containing any UI-specific logic.

Why Use MVVM in Flutter?

Adopting the MVVM pattern in Flutter applications offers several advantages:

  • Separation of Concerns: Divides the application into distinct components, each with a specific responsibility.
  • Testability: ViewModels are easily testable as they do not depend on the UI.
  • Maintainability: Changes in the UI or data layer do not directly affect other parts of the application.
  • Reusability: ViewModels can be reused across multiple views, reducing code duplication.
  • Scalability: Facilitates scaling the application with a clear and structured architecture.

Implementing MVVM in Flutter: A Step-by-Step Guide

Let’s walk through implementing MVVM in a Flutter application with a practical example. We will create a simple counter app where the user can increment a counter and display its value on the screen.

Step 1: Set Up a New Flutter Project

First, create a new Flutter project using the Flutter CLI:

flutter create flutter_mvvm_example
cd flutter_mvvm_example

Step 2: Define the Model

Create a model directory and add a file named counter.dart:

// lib/model/counter.dart
class Counter {
  int value;

  Counter({this.value = 0});

  void increment() {
    value++;
  }
}

This Counter model simply holds an integer value and a method to increment it.

Step 3: Create the ViewModel

Create a viewmodel directory and add a file named counter_viewmodel.dart:

// lib/viewmodel/counter_viewmodel.dart
import 'package:flutter/material.dart';
import '../model/counter.dart';

class CounterViewModel with ChangeNotifier {
  Counter _counter = Counter();

  int get counterValue => _counter.value;

  void incrementCounter() {
    _counter.increment();
    notifyListeners(); // Notify the UI to update
  }
}

In this ViewModel:

  • We import ChangeNotifier from Flutter’s material.dart to manage the state and notify the UI about changes.
  • _counter is an instance of the Counter model.
  • counterValue is a getter that returns the current value of the counter.
  • incrementCounter() increments the counter value and calls notifyListeners() to update the UI.

Step 4: Build the View

Modify the main.dart file to create the UI:

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'viewmodel/counter_viewmodel.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => CounterViewModel(),
      child: MaterialApp(
        title: 'MVVM Counter App',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: CounterView(),
      ),
    );
  }
}

class CounterView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final counterViewModel = Provider.of<CounterViewModel>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('MVVM Counter'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'Counter Value:',
              style: TextStyle(fontSize: 20),
            ),
            Text(
              '${counterViewModel.counterValue}',
              style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          counterViewModel.incrementCounter();
        },
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

In this View:

  • We use the provider package to inject the CounterViewModel into the widget tree.
  • ChangeNotifierProvider makes the ViewModel available to the CounterView and automatically disposes of it when it is no longer needed.
  • Provider.of<CounterViewModel>(context) retrieves the ViewModel from the context.
  • We display the counterValue from the ViewModel in a Text widget.
  • The FloatingActionButton calls the incrementCounter() method from the ViewModel when pressed.

Step 5: Install the Provider Package

Add the provider package to your pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.0.0 # Use the latest version

dev_dependencies:
  flutter_test:
    sdk: flutter

Then, run flutter pub get to install the package.

Testing the ViewModel

One of the main benefits of using MVVM is the ease of testing ViewModels. Here’s how you can write a simple test for the CounterViewModel:

// test/counter_viewmodel_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_mvvm_example/viewmodel/counter_viewmodel.dart';

void main() {
  group('CounterViewModel', () {
    test('Initial counter value should be 0', () {
      final counterViewModel = CounterViewModel();
      expect(counterViewModel.counterValue, 0);
    });

    test('Increment counter should increase value by 1', () {
      final counterViewModel = CounterViewModel();
      counterViewModel.incrementCounter();
      expect(counterViewModel.counterValue, 1);
    });
  });
}

This test checks:

  • The initial value of the counter is 0.
  • Incrementing the counter increases its value by 1.

Advanced Implementation: Using Services and Repositories

In more complex applications, you might want to include service and repository layers to further separate concerns.

  • Service: Contains business logic and can orchestrate multiple repositories.
  • Repository: Abstracts the data access layer (e.g., local database, API).

Example: Implementing a Repository

Let’s assume we want to persist the counter value in a local storage. Here’s how you can implement a simple repository:

// lib/repository/counter_repository.dart
import 'package:shared_preferences/shared_preferences.dart';

class CounterRepository {
  static const String _counterKey = 'counterValue';

  Future<int> getCounterValue() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getInt(_counterKey) ?? 0;
  }

  Future<void> saveCounterValue(int value) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt(_counterKey, value);
  }
}

Add the shared_preferences package to your pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.0.0 # Use the latest version
  shared_preferences: ^2.0.0 # Use the latest version

dev_dependencies:
  flutter_test:
    sdk: flutter

Then, run flutter pub get to install the package.

Update the ViewModel to Use the Repository

// lib/viewmodel/counter_viewmodel.dart
import 'package:flutter/material.dart';
import '../model/counter.dart';
import '../repository/counter_repository.dart';

class CounterViewModel with ChangeNotifier {
  Counter _counter = Counter();
  final CounterRepository _counterRepository = CounterRepository();

  int get counterValue => _counter.value;

  CounterViewModel() {
    _loadCounterValue();
  }

  Future<void> _loadCounterValue() async {
    _counter.value = await _counterRepository.getCounterValue();
    notifyListeners();
  }

  Future<void> incrementCounter() async {
    _counter.increment();
    await _counterRepository.saveCounterValue(_counter.value);
    notifyListeners(); // Notify the UI to update
  }
}

In this updated ViewModel:

  • We instantiate CounterRepository.
  • The constructor CounterViewModel() calls _loadCounterValue() to load the counter value from local storage when the ViewModel is created.
  • incrementCounter() saves the updated counter value to local storage using the repository.

Conclusion

The MVVM pattern is a powerful way to structure Flutter applications, promoting clean code, separation of concerns, and ease of testing. By separating the data, logic, and UI into distinct components, you can create more maintainable and scalable applications. Implementing services and repositories can further enhance the separation, making the codebase even more robust and flexible. This guide provided a comprehensive walkthrough with code examples, equipping you to apply the MVVM pattern in your Flutter projects effectively.