Understanding Different Architectural Approaches in Flutter

Flutter, Google’s UI toolkit for building natively compiled applications for mobile, web, and desktop from a single codebase, provides developers with a plethora of options to architect their apps. Choosing the right architecture is critical for maintaining code scalability, testability, and maintainability. This blog post aims to delve into different architectural approaches commonly used in Flutter development, shedding light on their strengths, weaknesses, and appropriate use cases.

Why Architectural Approaches Matter in Flutter

The architecture of a Flutter application dictates how the app’s components are structured, how they interact with each other, and how data flows throughout the application. A well-architected app leads to several benefits:

  • Maintainability: Easier to understand, modify, and extend.
  • Testability: Allows for isolated unit tests and integration tests.
  • Scalability: Enables adding new features without destabilizing the existing codebase.
  • Reusability: Components can be reused across different parts of the app.
  • Collaboration: Makes it easier for teams to work on the same project.

Common Architectural Patterns in Flutter

Several architectural patterns can be applied in Flutter. Here are some of the most common:

1. Model-View-Controller (MVC)

MVC is a classic architectural pattern that divides an application into three interconnected parts:

  • Model: Manages the application data and business logic.
  • View: Presents the data to the user and captures user input.
  • Controller: Acts as an intermediary between the Model and the View, handling user input and updating the Model.

In Flutter, an MVC implementation might look like this:


// Model
class CounterModel {
  int _count = 0;

  int get count => _count;

  void increment() {
    _count++;
  }
}

// View
import 'package:flutter/material.dart';
import 'controller.dart'; // Assuming Controller is in controller.dart

class CounterView extends StatelessWidget {
  final CounterController controller;

  CounterView({required this.controller});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('MVC Counter')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Count: ${controller.model.count}'),
            ElevatedButton(
              onPressed: () {
                controller.incrementCounter();
              },
              child: Text('Increment'),
            ),
          ],
        ),
      ),
    );
  }
}


// Controller
import 'model.dart'; // Assuming Model is in model.dart

class CounterController {
  final CounterModel model = CounterModel();

  void incrementCounter() {
    model.increment();
  }
}

Pros:

  • Clear separation of concerns.
  • Simple to understand and implement for small projects.

Cons:

  • Can become complex with increasing app size.
  • Flutter’s reactive nature can make direct updates from the Controller to the View cumbersome, often requiring workarounds like setState or streams.

2. Model-View-Presenter (MVP)

MVP is another classic architectural pattern similar to MVC, but with key differences:

  • Model: Manages the application data and business logic.
  • View: Displays data and receives user actions. More passive than in MVC, typically implemented as an interface.
  • Presenter: Acts as an intermediary, similar to a Controller, but is specifically responsible for preparing the data for the View. The View does not directly access the Model.

Here’s a basic implementation:


// Model (same as in MVC)
class CounterModel {
  int _count = 0;

  int get count => _count;

  void increment() {
    _count++;
  }
}


// View Interface
abstract class CounterViewInterface {
  void refreshCounter(int count);
}

// View
import 'package:flutter/material.dart';
import 'presenter.dart'; // Assuming Presenter is in presenter.dart

class CounterView extends StatefulWidget implements CounterViewInterface {
  final CounterPresenter presenter;

  CounterView({required this.presenter});

  @override
  _CounterViewState createState() => _CounterViewState();

  @override
  void refreshCounter(int count) {
    // Flutter way to rebuild the view
    (context as Element).markNeedsBuild(); // Explicitly request the Widget to rebuild.  Not typical in Flutter.
  }
}

class _CounterViewState extends State {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('MVP Counter')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Count: ${widget.presenter.model.count}'), // Access count through model. Can be improved with streams for real reactivity.
            ElevatedButton(
              onPressed: () {
                widget.presenter.incrementCounter();
                widget.refreshCounter(widget.presenter.model.count); // Typically not done this way in Flutter due to reactivity
              },
              child: Text('Increment'),
            ),
          ],
        ),
      ),
    );
  }
}


// Presenter
import 'model.dart';  // Assuming Model is in model.dart
import 'view.dart';   // Assuming View Interface is in view.dart

class CounterPresenter {
  final CounterModel model = CounterModel();
  final CounterViewInterface view;

  CounterPresenter({required this.view});

  void incrementCounter() {
    model.increment();
    view.refreshCounter(model.count);
  }
}

Pros:

  • Improved testability of the View.
  • Clearer separation of concerns than MVC.

Cons:

  • Can be verbose.
  • In Flutter, similar to MVC, direct manipulation of the View from the Presenter (e.g., using setState) is often less reactive and more cumbersome compared to stream-based solutions.

3. Model-View-ViewModel (MVVM)

MVVM is a modern architectural pattern gaining popularity in Flutter development. It provides a reactive approach to UI development:

  • Model: Holds the data and business logic, similar to MVC and MVP.
  • View: The UI elements. In Flutter, typically a StatelessWidget or StatefulWidget.
  • ViewModel: Exposes data streams that the View can subscribe to. The ViewModel processes data from the Model and formats it for the View. Crucially, the ViewModel knows nothing about the View itself, which makes it highly testable.

A sample implementation using ChangeNotifier and Provider:


// Model
class CounterModel {
  int _count = 0;

  int get count => _count;

  void increment() {
    _count++;
  }
}


// ViewModel
import 'package:flutter/foundation.dart';
import 'model.dart';

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

  int get count => _counterModel.count;

  void incrementCounter() {
    _counterModel.increment();
    notifyListeners(); // Notify listeners when the state changes
  }
}


// View
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'viewmodel.dart';  // Assuming ViewModel is in viewmodel.dart

class CounterView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('MVVM Counter')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Consumer(
              builder: (context, viewModel, child) {
                return Text('Count: ${viewModel.count}');
              },
            ),
            ElevatedButton(
              onPressed: () {
                Provider.of(context, listen: false).incrementCounter();
              },
              child: Text('Increment'),
            ),
          ],
        ),
      ),
    );
  }
}

// main.dart - wrap your app with Provider
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'view.dart';
import 'viewmodel.dart';

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

Pros:

  • Excellent separation of concerns.
  • Highly testable ViewModels.
  • Reactive UI through streams and ChangeNotifier.

Cons:

  • Requires understanding of reactive programming concepts.
  • Can be overkill for small projects.

4. Bloc/Cubit

The Bloc (Business Logic Component) pattern is specifically designed for managing state in Flutter applications. Cubit is a lighter-weight version of Bloc. Both Bloc and Cubit rely on streams for handling asynchronous data and events.

  • Bloc/Cubit: Contains the business logic for a specific feature. Transforms input events into output states.
  • State: Represents the current state of the application or feature.
  • Event: Represents an action that can change the state.

Example implementation using flutter_bloc package:


// State
abstract class CounterState {}

class CounterInitial extends CounterState {
  final int count;
  CounterInitial(this.count);
}

class CounterUpdate extends CounterState {
    final int count;
    CounterUpdate(this.count);
}


// Event
abstract class CounterEvent {}

class IncrementCounter extends CounterEvent {}


// Bloc
import 'package:flutter_bloc/flutter_bloc.dart';
import 'event.dart';
import 'state.dart';

class CounterBloc extends Bloc {
  CounterBloc() : super(CounterInitial(0)) { // Initialize the state here

    on((event, emit) {
      final currentState = state;
      if (currentState is CounterInitial) {
        emit(CounterUpdate(currentState.count + 1));
      }
      if(currentState is CounterUpdate){
           emit(CounterUpdate(currentState.count + 1));
      }

    });

  }

  @override
  void onChange(change) {
    print(change);
    super.onChange(change);
  }

}

// View
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'bloc.dart';  // Assuming Bloc is in bloc.dart
import 'state.dart';   // Assuming State is in state.dart
import 'event.dart';    // Assuming Event is in event.dart


class CounterView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Bloc Counter')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            BlocBuilder(
              builder: (context, state) {
                 if (state is CounterInitial) {
                   return Text('Count: ${state.count}');
                 }
                 if (state is CounterUpdate){
                     return Text('Count: ${state.count}');
                 }
                  return const Text('Count: 0');

              },
            ),
            ElevatedButton(
              onPressed: () {
                BlocProvider.of(context).add(IncrementCounter());
              },
              child: Text('Increment'),
            ),
          ],
        ),
      ),
    );
  }
}


// Main
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'view.dart';
import 'bloc.dart';


void main() {
  runApp(
    MaterialApp(
      home: BlocProvider(
        create: (context) => CounterBloc(),
        child: CounterView(),
      ),
    ),
  );
}

Pros:

  • Excellent state management solution.
  • Good testability due to clear input (events) and output (states).
  • Suitable for complex stateful applications.

Cons:

  • Steep learning curve.
  • Can lead to boilerplate code.

5. Redux

Redux is a predictable state container for JavaScript apps, but it has been adapted to other languages, including Dart/Flutter. Redux revolves around a single store containing the entire application state.

  • Store Holds the complete state tree of your app. Only way to change the state is to dispatch an action.
  • Actions Are plain JavaScript objects that describe an intention to change the state.
  • Reducers Are pure functions that take the previous state and an action, and return the next state.

Here’s a very basic Flutter Redux example:


// Define Actions
class IncrementAction {}

// Define State
class AppState {
  final int counter;

  AppState({this.counter = 0});
}

// Define Reducer
AppState reducer(AppState state, dynamic action) {
  if (action is IncrementAction) {
    return AppState(counter: state.counter + 1);
  }
  return state;
}


// Flutter UI
import 'package:flutter/material.dart';
import 'package:flutter_redux/flutter_redux.dart';
import 'package:redux/redux.dart';


void main() {
  final store = Store(
    reducer,
    initialState: AppState(),
  );

  runApp(MyApp(store: store));
}

class MyApp extends StatelessWidget {
  final Store store;

  MyApp({Key? key, required this.store}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return StoreProvider(
      store: store,
      child: MaterialApp(
        home: Scaffold(
          appBar: AppBar(
            title: Text('Flutter Redux Example'),
          ),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  'Counter Value:',
                  style: TextStyle(fontSize: 20),
                ),
                StoreConnector(
                  converter: (store) => store.state.counter.toString(),
                  builder: (context, counter) {
                    return Text(
                      counter,
                      style: TextStyle(fontSize: 30),
                    );
                  },
                ),
              ],
            ),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: () => store.dispatch(IncrementAction()),
            tooltip: 'Increment',
            child: Icon(Icons.add),
          ),
        ),
      ),
    );
  }
}

Pros:

  • Centralized state management, leading to predictable state changes.
  • Debugging is easier with unidirectional data flow and a single state container.
  • Excellent for complex apps with numerous interactions.

Cons:

  • Can introduce boilerplate, especially for smaller apps.
  • Requires a solid understanding of functional programming concepts.
  • Can be overkill for simple applications where the benefits of centralized state management aren’t necessary.

Choosing the Right Architecture

The choice of architecture depends on various factors:

  • Project size and complexity: Smaller projects might benefit from simpler architectures like MVC or MVP, while larger, more complex applications might require MVVM, Bloc, or Redux.
  • Team expertise: Choose an architecture that the development team is familiar with.
  • Scalability requirements: If the application is expected to grow significantly, opt for an architecture that facilitates scalability, such as MVVM or Bloc.
  • Testability requirements: MVVM and Bloc offer excellent testability features.

Conclusion

Choosing the right architectural approach is a crucial step in Flutter development. While there is no one-size-fits-all solution, understanding the strengths and weaknesses of each pattern enables developers to make informed decisions. MVC and MVP offer simplicity for smaller projects, while MVVM, Bloc, and Redux provide robust solutions for complex, scalable applications. Selecting the architecture that aligns with your project’s specific needs will result in a well-structured, maintainable, and testable Flutter application.