Using the Provider Package to Facilitate the MVVM Architecture in Flutter

Flutter, Google’s UI toolkit, has revolutionized mobile app development by providing a fast, expressive, and flexible way to build applications for multiple platforms from a single codebase. A crucial aspect of building maintainable and scalable Flutter applications is adopting a well-defined architecture. One popular choice is the Model-View-ViewModel (MVVM) architecture. This blog post will guide you through using the provider package in Flutter to implement MVVM, focusing on clear explanations and practical examples.

What is MVVM?

MVVM (Model-View-ViewModel) is a software architectural pattern that facilitates a clear separation of concerns within an application. It consists of three interconnected components:

  • Model: Represents the data and business logic.
  • View: Represents the UI and is responsible for displaying data and capturing user input.
  • ViewModel: Acts as an intermediary between the View and the Model, exposing data to the View and handling user interactions.

Why Use MVVM?

  • Separation of Concerns: Clearly separates the UI (View) from the business logic (ViewModel) and data (Model).
  • Testability: Makes it easier to test business logic and UI independently.
  • Maintainability: Simplifies maintenance and updates by isolating different aspects of the application.
  • Reusability: Allows for greater reuse of components, particularly ViewModels and Models.

Introducing the Provider Package

The provider package in Flutter is a wrapper around InheritedWidget, making it easier to manage state and provide data to widgets. It simplifies the process of accessing and updating data throughout the widget tree, which is essential for MVVM architecture.

How to Implement MVVM with the Provider Package

Step 1: Add the Provider Dependency

First, add the provider package to your pubspec.yaml file:

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

Run flutter pub get to install the package.

Step 2: Define the Model

The Model represents the data. For example, if you’re building a counter app, your model might be:

class CounterModel {
  int count;

  CounterModel({this.count = 0});
}

Step 3: Create the ViewModel

The ViewModel exposes data to the View and handles user interactions. It also updates the Model. In Flutter, the ViewModel typically extends ChangeNotifier, allowing it to notify listeners when its state changes.

import 'package:flutter/material.dart';
import 'counter_model.dart';

class CounterViewModel extends ChangeNotifier {
  CounterModel _counterModel = CounterModel();

  int get count => _counterModel.count;

  void incrementCounter() {
    _counterModel.count++;
    notifyListeners(); // Notify the View of the change
  }
}

In this example:

  • The CounterViewModel holds a CounterModel instance.
  • The count getter exposes the current count value.
  • The incrementCounter method increments the count and calls notifyListeners() to alert the View about the update.

Step 4: Build the View

The View is a Flutter widget that displays data from the ViewModel and handles user interactions. The View uses Consumer (from the provider package) to listen for changes in the ViewModel.

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

class CounterView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Counter App'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Consumer<CounterViewModel>(
              builder: (context, viewModel, _) => Text(
                '${viewModel.count}',
                style: Theme.of(context).textTheme.headline4,
              ),
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Provider.of<CounterViewModel>(context, listen: false).incrementCounter();
        },
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

Explanation:

  • Consumer<CounterViewModel> rebuilds the widget inside its builder when notifyListeners() is called in the CounterViewModel.
  • Provider.of<CounterViewModel>(context, listen: false) provides access to the CounterViewModel to call incrementCounter when the FloatingActionButton is pressed. listen: false is used because the button itself doesn’t need to be rebuilt when the count changes.

Step 5: Provide the ViewModel

Wrap your root widget with ChangeNotifierProvider to provide the ViewModel to the widget tree.

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'counter_view.dart';
import 'counter_viewmodel.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CounterViewModel(),
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter MVVM Example',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: CounterView(),
    );
  }
}

Key Points:

  • ChangeNotifierProvider creates an instance of CounterViewModel and makes it available to all descendants in the widget tree.
  • The create method instantiates the ViewModel.

Complete Example:

Below is the full code for the example:

// Model
class CounterModel {
  int count;

  CounterModel({this.count = 0});
}

// ViewModel
import 'package:flutter/material.dart';
import 'counter_model.dart';

class CounterViewModel extends ChangeNotifier {
  CounterModel _counterModel = CounterModel();

  int get count => _counterModel.count;

  void incrementCounter() {
    _counterModel.count++;
    notifyListeners(); // Notify the View of the change
  }
}

// View
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'counter_viewmodel.dart';

class CounterView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Counter App'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Consumer<CounterViewModel>(
              builder: (context, viewModel, _) => Text(
                '${viewModel.count}',
                style: Theme.of(context).textTheme.headline4,
              ),
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Provider.of<CounterViewModel>(context, listen: false).incrementCounter();
        },
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

// Main
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'counter_view.dart';
import 'counter_viewmodel.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CounterViewModel(),
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter MVVM Example',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: CounterView(),
    );
  }
}

Benefits of Using Provider with MVVM

  • Simple State Management: The provider package simplifies state management, making it easy to manage and propagate state changes.
  • Reduced Boilerplate Code: The provider package reduces boilerplate code required for managing state compared to other state management solutions.
  • Reactive Updates: Combined with ChangeNotifier, the provider package facilitates reactive updates to the UI based on changes in the ViewModel.
  • Testability: This architecture promotes better testability, as you can easily mock dependencies and test the ViewModel in isolation.

Conclusion

Using the provider package in Flutter to facilitate the MVVM architecture can greatly improve the structure, maintainability, and testability of your applications. This approach provides a clear separation of concerns, making it easier to manage complex applications. By adopting these best practices, you’ll build scalable, maintainable, and testable Flutter applications.