jelly-2vm: A Lightweight MVVM Microframework for Flutter

GitHub Stars GitHub Issues GitHub License

jelly-2vm is a lightweight microframework for Flutter, based on the Model-View-ViewModel (MVVM) pattern, designed to enhance the maintainability, simplicity, and scalability of your applications. Born from AppFactory's experience with apps serving millions of users in Italy, jelly-2vm provides a clear and reactive structure for Flutter app development.

Read more about the philosophy and implementation of jelly-2vm in our article on Medium: Building Scalable Flutter Apps with jelly-2vm: A Lightweight MVVM Architecture

Key Features

  • Clear MVVM Architecture: Clean separation between View, ViewModel, and Service for more organized code.
  • Optimized Reactivity: Targeted UI updates with ChangeBuilder, avoiding unnecessary rebuilds.
  • Service Management: Services as singletons managed with GetIt for easy dependency injection.
  • Reactive Services: ServiceNotifier for reactive communication between services.
  • Simplicity: Minimal API with only three main classes: ViewModel, ViewWidget, and ChangeBuilder.
  • Testability: Easy mocking and testing thanks to dependency injection.
  • Scalability: Designed for large-scale applications and extended teams.

Installation

Add jelly-2vm to your pubspec.yaml dependencies:

dependencies:
  jelly_2vm: ^latest_version

Run flutter pub get to install the package.

Usage

Example: Counter App

ViewModel:

class CounterViewModel extends ViewModel {
  CounterViewModel(this.count);
  int count;

  void increment(int amount) {
    count += amount;
    notifyListeners();
  }

  void decrement(int amount) {
    count -= amount;
    notifyListeners();
  }
}

View:

class CounterView extends ViewWidget<CounterViewModel> {
  CounterView({Key? key}) : super(key: key);

  @override
  CounterViewModel createViewModel() {
    return CounterViewModel(0);
  }

  @override
  Widget builder(BuildContext context, CounterViewModel counterViewModel) {
    return Scaffold(
      body: SafeArea(
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              MaterialButton(
                onPressed: () => counterViewModel.increment(1),
                child: const Text("Increment"),
              ),
              ChangeBuilder<CounterViewModel>(
                listen: counterViewModel,
                builder: (context, state) => Text("${state.count}"),
              ),
              MaterialButton(
                onPressed: () => counterViewModel.decrement(1),
                child: const Text("Decrement"),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Services

Please refer to the Medium article for more details on the services and how to use them.

StorageService (Example):

class StorageService {
  StorageService();

  Future<void> save(String key, String value) async {
    final pref = await SharedPreferences.getInstance();
    await pref.setString(key, value);
  }

  // ... other methods ...
}

Service Initialization:

final getIt = GetIt.instance;

class Services {
  static void init() {
    getIt.registerSingleton<StorageService>(StorageService());
    // ... other services ...
  }

  static T get<T extends Object>() {
    return getIt.get<T>();
  }
}

Service Notifier:

mixin class ServiceNotifier {
  final List<VoidCallback> _callbacks = [];
  void addListener(VoidCallback callback) {
    if (_callbacks.contains(callback)) {
      return;
    }
    _callbacks.add(callback);
  }

  void removeListener(VoidCallback callback) {
    _callbacks.remove(callback);
  }

  void notifyListeners() {
    for (final c in _callbacks) {
      try {
        c();
      } catch (e) {
        AppLogger.d(e.toString());
      }
    }
  }
}

Delegator 🐊

Inspired by Swift for iOS, it will help you to dispatch functions that will run on the View from your ViewModel.

Please refer to the docs for more details.

Best Practices

  • A View + ViewModel can represent an entire page or a portion of it.
  • Views can use StatelessWidget for layout sections.
  • Services are singletons managed with GetIt.
  • Use ServiceNotifier to make services reactive.

Quantums ⚛️

Quantums is the evolution of the Model-View-ViewModel pattern, in order to have a finer-grained reactivity and control over the state of the UI. Using quantums you can have a state of a specific part of the UI that can be observed and updated independently from the rest of the UI.

Why

Sometimes having a state of the whole page can lead to performance issues, for this reason you can use a Quantum to represent the state of a specific part of the page. Using then the QuantumBuilder you can make the part reactive.

How

Let's take as an example a simple counter:

View:

class CounterView extends ViewWidget<CounterViewModel> {
  const CounterView({super.key});
  
  @override
  HomeViewModel createViewModel() {
    return HomeViewModel(delegate: this);
  }

  @override
  Widget build(BuildContext context, CounterViewModel viewModel) {
    return Scaffold(
      body: Center(
        child: Column(
          children: [
            QuantumBuilder<int>(
              quantum: viewModel.counter,
              builder: (context, value) {
                return Text('$value');
              },
            ),
            ElevatedButton(
              onPressed: viewModel.increment,
              child: const Text('Increment'),
            ),
          ],
        ),
      ),
    );
  }
}

ViewModel:

class CounterViewModel extends ViewModel {
  final Quantum<int> counter = Quantum<int>(0);

  void increment() {
    counter.value++;
  }
}

For more examples, see the docs.

Contributing

jelly-2vm is open-source! Feel free to contribute with pull requests, report bugs, or suggest new features.

License

jelly-2vm is released under the MIT License. See the LICENSE file for details.

Contact

For questions or support, please open an issue on GitHub.