Foggy day

[Flutter] MVVM 패턴 적용 (상태관리 라이브러리 없이) 본문

Flutter/Flutter 앱

[Flutter] MVVM 패턴 적용 (상태관리 라이브러리 없이)

jinhan38 2023. 3. 19. 22:35

 

이번 포스팅에서는 MVVM 패턴에 대해서 알아보겠습니다. 

 

MVVM 패턴은 Model, View, ViewModel의 줄임말입니다. 이 패턴을 사용하는 이유는 UI를 그리는 View와 데이터클래스인 Model, 데이터들을 담고 있는 ViewModel을 따로 관리하기 위해서입니다. 따로 관리한다는 것은 의존성을 줄여줌으로써 유지보수의 편의성과 오류의 가능성을 줄여줄 수 있다는 것을 의미합니다. 또한 특정 데이터가 변경됐을 때 UI의 변경도 쉽게 구현할 수 있습니다. 

 

이번 MVVM 패턴 앱에서는 상태관리 라이브러리를 사용하지 않습니다. ChangeNotifier와 ValueListenableBuilder, ValueNotifier, ValueNotifierList(커스텀으로 만든 클래스), AnimatedBuilder, singleton 디자인 패턴을 사용해서 구축했습니다. 이것들은 모두 Dart언어에서 기본 제공하는 클래스들입니다.

상태관리 라이브러리를 사용하지 않은 이유는 개발자들이 모든 상태관리 라이브러리에 능숙한 것은 아니기 때문입니다. 그래서 가장 기본에 충실하자는 생각에 라이브러리를 사용하지 않았습니다. 물론 ValueListenableBuilder와 ValueNotifierList에 대해서 약간의 이해가 필요하지만 크게 어려운 부분은 없습니다.

 

 

정확한 이해를 위해 3개 방식을 구현해봤습니다. 실제 프로젝트를 진행할 때는 3가지 방식을 병행해서 사용하면 좋을 것 같습니다. 

 

1. AddListener 방식

ChangeNotifier을 사용하는 ViewModel에서 특정 변수가 변경될 때 마다 notifyListeners을 호출합니다. 그리고  view단에서는 ViewModel에 addListener를 붙여서 화면을 갱신시킵니다.

 

2. AnimatedBuilder 방식

1번과 마찬가지로 ChangeNotifier을 사용하는 ViewModel에서 특정 변수가 변경될 때 마다 notifyListeners을 호출합니다. 그리고 view단에서는 AnimatedBuilder를 사용해서 화면을 갱신시킵니다. 1번의 addListener는 사용하지는 않습니다. 

 

3. ValueListenableBuilder 방식 

ValueListenableBuilder, ValueNotifier, ValueNotifierList 클래스들을 사용하여 데이터를 관리해줍니다. 

 

 

 

1. AddListener 방식

 

HomeViewModel

ChangeNotifier를 사용하는 HomeViewModel을 만들어서 int 타입의 변수와 List<String>타입의 변수를 추가했습니다. 그리고 해당 변수들의 값을 변경하는 함수를 만들었습니다. 값을 변경시킨 다음에는 notifyListeners를 호출함으로써 view단의 listener가 들을 수 있게 해줬습니다.

import 'package:flutter/cupertino.dart';

class HomeViewModel with ChangeNotifier {
  int count = 0;
  List<String> st = [];

  void countUp() {
    count++;
    notifyListeners();
  }

  void addSt() {
    st.add(DateTime.now().toString());
    notifyListeners();
  }
}

 

HomeScreen

HomeScreen은 앞서 말한 view단을 말합니다. 여기서는 HomeViewModel에서 만든 int 변수와 List<String> 변수의 값을 보여주고, 변경하는 함수를 호출하는 위젯들이 있습니다. 

주목할 것은 initState와 dispose입니다. initState에서 HomeViewModel클래스의 addListener 함수를 사용하고 있습니다. HomeViewModel에서 notifyListeners를 호출하면 addListener에 진입하게 되고, setState를 호출함으로써 화면을 갱신시킬 수 있습니다. 

import 'package:flutter/material.dart';
import 'package:flutter_mvvm_dart/home/home_view_model.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final HomeViewModel _vm = HomeViewModel();

  @override
  void initState() {
    /// ViewModel의 notifyListeners 호출 리스너
    _vm.addListener(() {
      setState(() {});
    });
    super.initState();
  }

  @override
  void dispose() {
    /// 메모리 제거
    _vm.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("HomeScreen"),
      ),
      body: SizedBox(
        width: double.infinity,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () {
                _vm.countUp();
              },
              child: Text(
                "count : ${_vm.count}",
                style: const TextStyle(fontSize: 22),
              ),
            ),
            ElevatedButton(
              onPressed: () {
                _vm.addSt();
              },
              child: Text(
                "st : ${_vm.st.toString()}",
                style: const TextStyle(fontSize: 22),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

 

 

아래의 왼쪽 이미지가 초기 화면의 모습이고, 오른쪽 이미지가 버튼을 클릭했을 때 데이터가 변경된 모습입니다. 

 

 

이렇게 ViewModel에서는 관리하고 있는 데이터가 변경될 때 마다 notifyListeners을 호출해주고, view단에서는 listener를 사용해서 setState를 호출해줍니다. 본 예제에서는 Model을 따로 만들지는 않았습니다. 구조를 간편하게 보여주게 하는 것의 의도가 있기 때문에 기본 변수타입만 사용했습니다. 

 

 

 

2. AnimatedBuilder 방식

2번 방식은 1번 방식과 거의 흡사합니다. 다른 점은 addListener를 사용하지 않고 AnimatedBuilder를 사용한다는 점입니다. 이렇게 했을 때는 Stateful위젯 대신 Stateless 위젯을 사용할 수도 있습니다.

 

SecondViewModel

ViewModel에는 int 타입의 변수 한개와 해당 변수를 변경시키고 notifyListeners를 호출해주는 함수 countUp을 만들었습니다. 그리고 싱글톤 디자인 패턴을 사용했습니다. 싱글톤 패턴을 사용하면 클래스를 한번 생성하면 기존에 생성한 클래스를 계속 사용할 수 있는 장점이 있습니다. 싱글톤에 대한 자세한 설명은 추가적으로 찾아보시는 것을 추천합니다. 

import 'package:flutter/cupertino.dart';

class SecondViewModel with ChangeNotifier {

  /// SecondViewModel은 Singleton 디자인 패턴 활용
  /// Singleton 디자인 패턴을 사용하면 한번 생성된 SecondViewModel을 계속 사용 합니다.
  /// 다시 말해 SecondViewModel은 데이터를 계속 가지고 있습니다.
  static final SecondViewModel _secondViewModel = SecondViewModel._singleton();

  factory SecondViewModel() => _secondViewModel;

  SecondViewModel._singleton();

  int count = 0;

  void countUp() {
    count++;
    notifyListeners();
  }
}

 

 

SecondScreen

SecondScreen에서는 StatelessWidget을 사용했습니다. 그리고 모든 위젯을 AnimatedBuilder로 감싸줬습니다. AnimatedBuilder의 animation argument는 Listenable 타입을 받고 있는데 ChangeNotifier가 Listenable을 구현(implements)하고 있기 때문에 argument로 ChangeNotifier를 사용할 수 있습니다. SecondViewModel이 ChangeNotifier를 구현하고 있으므로 SecondViewModel을 animation argument로 사용했습니다.

이제 SecondViewModel의 notifyListeners가 호출될 때마다 AnimatedBuilder가 하위 위젯들을 갱신시킵니다. 

import 'package:flutter/material.dart';
import 'package:flutter_mvvm_dart/second/second_view_model.dart';

class SecondScreen extends StatelessWidget {
  SecondScreen({Key? key}) : super(key: key);
  final SecondViewModel _vm = SecondViewModel();

  @override
  Widget build(BuildContext context) {
    /// _vm.addListener 대신 AnimatedBuilder로 notifyListeners 호출 체크
    return AnimatedBuilder(
      animation: _vm,
      builder: (context, child) {
        return Scaffold(
          appBar: AppBar(
            title: const Text("SecondScreen"),
          ),
          body: SizedBox(
            width: double.infinity,
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.center,
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  "카운트 : ${_vm.count}",
                  style: const TextStyle(fontSize: 22),
                ),
                ElevatedButton(
                  onPressed: () {
                    _vm.countUp();
                  },
                  child: const Text(
                    "Count up",
                    style: TextStyle(fontSize: 22),
                  ),
                )
              ],
            ),
          ),
        );
      },
    );
  }
}

 

왼쪽이 초기 화면, 오른쪽이 버튼을 클릭해서 숫자를 증가시킨 화면입니다. 

 

 

 

3. ValueListenableBuilder 방식 

 

여기서는 커스텀 클래스 ValueNotifierList를 하나 사용하고 있습니다.ValueNotifier타입에 List를 사용하면 add하거나 remove 했을 때 화면이 갱신되지 않습니다. List를 변경할 때 =을 통해서 반드시 새로 값을 입력해줘만 화면이 갱신됩니다. 그래서 ValueNotifier을 상속받고 add, remove, clear등의 함수를 새로 만들어서 값을 업데이트 해주고 있습니다. 아래 클래스에서 추가적으로 필요한 것들은 만들어서 사용하시면 됩니다.

 

ValueNotifierList

import 'package:flutter/material.dart';

class ValueNotifierList<T> extends ValueNotifier<List<T>> {
  ValueNotifierList(List<T> value) : super(value);

  void add(T valueToAdd) {
    value = [...value, valueToAdd];
  }

  void addAll(List<T> valueToAdd) {
    value = [...value, ...valueToAdd];
  }

  void remove(T valueToRemove) {
    value = value.where((value) => value != valueToRemove).toList();
  }

  void removeAt(int index) {
    value = List.from(value)..removeAt(0);
  }

  void removeLast() {
    value = List.from(value)..removeLast();
  }

  void clear() {
    value = List.from(value)..clear();
  }
}

 

 

 

NotifierViewModel

int 타입의 ValueNotifier와 List<String> 타입의 ValueNotifierList 변수 하나씩 선언해줬습니다. 

import 'package:flutter/cupertino.dart';

import '../value_notifier_list.dart';

class NotifierViewModel {
  static final NotifierViewModel _notifierViewModel =
      NotifierViewModel._singleton();

  factory NotifierViewModel() => _notifierViewModel;

  NotifierViewModel._singleton();

  ValueNotifier<int> countNotifier = ValueNotifier(0);
  final ValueNotifierList<String> stList = ValueNotifierList([]);
}

 

 

NotifierScreen

ValueListenableBuilder는 ValueNotifier으로 선언한 변수가 변경되는 것을 감지해줍니다. 값이 변경될 때 마다 builder에 들어오게 되고, 업데이트된 값이 value로 들어오게 됩니다.

import 'package:flutter/material.dart';
import 'package:flutter_mvvm_dart/notifier/notifier_view_model.dart';

class NotifierScreen extends StatefulWidget {
  const NotifierScreen({Key? key}) : super(key: key);

  @override
  State<NotifierScreen> createState() => _NotifierScreenState();
}

class _NotifierScreenState extends State<NotifierScreen> {
  final NotifierViewModel _vm = NotifierViewModel();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Flutter MVVM only Dart"),
      ),
      body: SizedBox(
        width: double.infinity,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            ValueListenableBuilder(
              valueListenable: _vm.countNotifier,
              builder: (context, value, child) {
                return Text(
                  "countNotifier : $value",
                  style: const TextStyle(fontSize: 22),
                );
              },
            ),
            ElevatedButton(
              onPressed: () {
                _vm.countNotifier.value++;
              },
              child: const Text("countNotifier up"),
            ),
            const SizedBox(height: 30),
            ValueListenableBuilder(
              valueListenable: _vm.stList,
              builder: (context, value, child) {
                return Text(
                  "ValueNotifierList : ${value.toString()}",
                  style: const TextStyle(fontSize: 22),
                );
              },
            ),
            ElevatedButton(
              onPressed: () {
                _vm.stList.value = [
                  ..._vm.stList.value,
                  DateTime.now().toString(),
                ];
              },
              child: const Text("ValueNotifierList add"),
            ),
            ElevatedButton(
              onPressed: () {
                if (_vm.stList.value.isEmpty) return;
                _vm.stList.removeAt(0);
                // _vm.stList.removeLast();
              },
              child: const Text("ValueNotifierList remove"),
            ),
          ],
        ),
      ),
    );
  }
}

 

 

왼쪽이 변경 전, 가운데가 변경 후 입니다. 오른쪽 이미지는 다른 화면에서 NotifierViewModel.countNotifier 변수를 ValueListenableBuilder을 사용해서 감지하고 있는 모습입니다. 

 

오른쪽 사진의 코드 

import 'package:flutter/material.dart';
import 'package:flutter_mvvm_dart/home/home_screen.dart';
import 'package:flutter_mvvm_dart/notifier/notifier_view_model.dart';

import '../notifier/notifier_screen.dart';
import '../second/second_screen.dart';

class MainScreen extends StatefulWidget {
  const MainScreen({Key? key}) : super(key: key);

  @override
  State<MainScreen> createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Flutter MVVM only Dart"),
      ),
      body: SizedBox(
        width: double.infinity,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            _button("HomeScreen", const HomeScreen()),
            ValueListenableBuilder(
              valueListenable: NotifierViewModel().countNotifier,
              builder: (context, value, child) {
                return _button(
                  "NotifierScreen countNotifier $value",
                  const NotifierScreen(),
                );
              },
            ),
            _button("SecondScreen", SecondScreen()),
          ],
        ),
      ),
    );
  }

  Widget _button(String buttonText, Widget widget) {
    return ElevatedButton(
      onPressed: () {
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) => widget,
          ),
        );
      },
      child: Text(
        buttonText,
        style: const TextStyle(fontSize: 18),
      ),
    );
  }
}

 

 

프로젝트 구조 

 

 

 

최종 영상 

 

 

 

 

 

 

궁금한 점이나 개선 의견은 댓글로 남겨주시면 감사하겠습니다.

 

 

 

깃 주소

https://github.com/jinhan38/flutter_mvvm_dart