Skip to main content Link Search Menu Expand Document (external link)

MVVM 기본 패턴

유투브 에 괜찮은 강의가 있어 참고했다. udemy 강의에서는 계층이 너무 분리가 되어서 일단 MVVM 자체에 대한 이해를 높히기 위해서 기본 패턴을 먼저 학습했다.

import 'package:flutter/material.dart';
import 'package:flutter_clean_architecture/presentation/simple_mvvm/simple_view_model.dart';

class SimpleScreen extends StatefulWidget {
  @override
  _SimpleScreenState createState() => _SimpleScreenState();
}

class _SimpleScreenState extends State<SimpleScreen> {
  SimpleViewModel viewModel = SimpleViewModel();

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
        body: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          mainAxisSize: MainAxisSize.max,
          children: [
            StreamBuilder(
              stream: viewModel.mvvmStream,
              builder: (context, snapshot) {
                print('StreamBuilder > build > snapshot : ${snapshot.data}');
                int count = 0;
                if (snapshot.data != null) {
                  count = snapshot.data!.count;
                }
                return Center(
                  child: Text(
                    count.toString(),
                    style: TextStyle(fontSize: 30),
                  ),
                );
              },
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                IconButton(
                  onPressed: () {
                    viewModel.increaseCounter();
                  },
                  icon: Icon(Icons.exposure_plus_1),
                ),
                IconButton(
                  onPressed: () {
                    viewModel.decreaseCounter();
                  },
                  icon: Icon(Icons.exposure_minus_1),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}
import 'dart:async';

import 'package:flutter_clean_architecture/presentation/simple_mvvm/simple_model.dart';

class SimpleViewModel {
  late SimpleModel _model;
  final StreamController<SimpleModel> _streamController =
      StreamController<SimpleModel>();

  Stream<SimpleModel> get mvvmStream => _streamController.stream;

  SimpleViewModel() {
    _model = SimpleModel();
  }

  void update() {
    _streamController.sink.add(_model);
  }

  void increaseCounter() {
    _model.count++;
    update();
  }

  void decreaseCounter() {
    _model.count--;
    update();
  }
}
class SimpleModel {
  int count = 0;

  @override
  String toString() {
    return 'SimpleModel{count: $count}';
  }
}

Provider 에서 notifyListeners(); 를 사용하는 방식과 유사하다. 결국 Stream 도 Publish, Subscribe 원리이며 이를 이용해서 해당 Stream 을 구독하는 StreamBuilder 를 통해서 변경이 있을때마다 이를 받아 rebuild 하는 방식이다.

유투브 강의에는 Provider 를 사용한 MVVM 도 있었는데, 사실 명칭을 이렇게 갖다 붙여서 다른 것 같지만 그냥 일반적인 Provider 였다. View 의 도메인 로직을 전부 Provider 에 위임하고 Provider 의 처리에 따라서 View 는 rebuild 가 필요할 때 알아서 rebuild 가 되도록(react) Provider 를 watch 하는 형태인 것이다. 이걸 기본형 맥락에서 보면 StreamBuilder 를 통해서 rebuild 되는 것과 똑같은 방식이다.

강의에서 사용된 MVVM 패턴

소스코드 가 너무 길어서 링크만 남긴다.

위 기본형과 비슷하지만 가장 크게 다른 부분은 계층이 더 세분화 되어 있다는 것이다. 대표적으로 아래 클래스가 있다.

import 'dart:async';

import 'package:complete_advanced_flutter/presentation/common/state_renderer/state_render_impl.dart';
import 'package:rxdart/rxdart.dart';

abstract class BaseViewModel extends BaseViewModelInputs
    with BaseViewModelOutputs {
  StreamController _inputStateStreamController =
  BehaviorSubject<FlowState>();

  @override
  Sink get inputState => _inputStateStreamController.sink;

  @override
  Stream<FlowState> get outputState =>
      _inputStateStreamController.stream.map((flowState) => flowState);

  @override
  void dispose() {
    _inputStateStreamController.close();
  }

// shared variables and functions that will be used through any view model.
}

abstract class BaseViewModelInputs {
  void start(); // will be called while init. of view model
  void dispose(); // will be called when viewmodel dies.

  Sink get inputState;
}

abstract class BaseViewModelOutputs {
  Stream<FlowState> get outputState;
}

BaseViewModel 라는 최상위 class 를 만들고 모든 ViewModel 이 이를 상속하도록 한다. 각 ViewModel 에서도 또 계층을 아래와 같이 나눈다.

import 'dart:async';

import 'package:complete_advanced_flutter/domain/model/model.dart';
import 'package:complete_advanced_flutter/presentation/base/baseviewmodel.dart';
import 'package:complete_advanced_flutter/presentation/resources/assets_manager.dart';
import 'package:complete_advanced_flutter/presentation/resources/strings_manager.dart';
import 'package:easy_localization/easy_localization.dart';

class OnBoardingViewModel extends BaseViewModel
    with OnBoardingViewModelInputs, OnBoardingViewModelOutputs {
  // stream controllers
  final StreamController _streamController =
      StreamController<SliderViewObject>();

  late final List<SliderObject> _list;

  int _currentIndex = 0;

  // inputs
  @override
  void dispose() {
    _streamController.close();
  }

  @override
  void start() {
    _list = _getSliderData();
    // send this slider data to our view
    _postDataToView();
  }

  @override
  int goNext() {
    int nextIndex = _currentIndex++; // +1
    if (nextIndex >= _list.length) {
      _currentIndex = 0; // infinite loop to go to first item inside the slider
    }
    return _currentIndex;
  }

  @override
  int goPrevious() {
    int previousIndex = _currentIndex--; // -1
    if (previousIndex == -1) {
      _currentIndex =
          _list.length - 1; // infinite loop to go to the length of slider list
    }
    return _currentIndex;
  }

  @override
  void onPageChanged(int index) {
    _currentIndex = index;
    _postDataToView();
  }

  @override
  Sink get inputSliderViewObject => _streamController.sink;

  // outputs
  @override
  Stream<SliderViewObject> get outputSliderViewObject =>
      _streamController.stream.map((slideViewObject) => slideViewObject);

  // private functions
  List<SliderObject> _getSliderData() => [
        SliderObject(
            AppStrings.onBoardingTitle1.tr(),
            AppStrings.onBoardingSubTitle1.tr(),
            ImageAssets.onboardingLogo1),
        SliderObject(
            AppStrings.onBoardingTitle2.tr(),
            AppStrings.onBoardingSubTitle2.tr(),
            ImageAssets.onboardingLogo2),
        SliderObject(
            AppStrings.onBoardingTitle3.tr(),
            AppStrings.onBoardingSubTitle3.tr(),
            ImageAssets.onboardingLogo3),
        SliderObject(
            AppStrings.onBoardingTitle4.tr(),
            AppStrings.onBoardingSubTitle4.tr(),
            ImageAssets.onboardingLogo4)
      ];

  _postDataToView() {
    inputSliderViewObject.add(
        SliderViewObject(_list[_currentIndex], _list.length, _currentIndex));
  }
}

// inputs mean the orders that our view model will recieve from our view
abstract class OnBoardingViewModelInputs {
  void goNext(); // when user clicks on right arrow or swipe left.
  void goPrevious(); // when user clicks on left arrow or swipe right.
  void onPageChanged(int index);

  Sink
      get inputSliderViewObject; // this is the way to add data to the stream .. stream input
}

// outputs mean data or results that will be sent from our view model to our view
abstract class OnBoardingViewModelOutputs {
  Stream<SliderViewObject> get outputSliderViewObject;
}

class SliderViewObject {
  SliderObject sliderObject;
  int numOfSlides;
  int currentIndex;

  SliderViewObject(this.sliderObject, this.numOfSlides, this.currentIndex);
}

이렇게 ViewModel 이 있는 .dart 에 위와 같이 abstract class 를 또 만들어서 BaseViewModel 을 상속하게 한뒤 저것들을 다 같이 mixin 한다.

goNext, goPrevious, onPageChanged 는 해당 ViewModel 특유의 것이라서 ViewModel 에 선언해도 되지만 아마도 명시적으로 ~Input 내에 넣어주어서 확실하게 분리시키려는 의도로 보인다.

~Input 내에 Sink 로 설정해 준 것은 사실 결국 StreamController 의 sink 인데, 의미는 View -> ViewModel 로 데이터를 전달(상호작용)할때 데이터가 들어가는 입구라고 생각하면 된다. 개인적으로 따로 분리하지 말고 ~controller.sink 로 그대로 사용하는 것이 훨씬 해석이 단순하고 좋은 것 같다. 단순히 ViewModel 이 데이터를 받아서 stream 으로 publish 할는 과정에서 ‘받는’ input 개념이다.


Stream 을 활용한 View, ViewModel 상호작용 이해

아래 그림으로 만들었다. 강사의 샘플 코드가 중요한 것이 아니라 아래 그림이 골자라는 것을 알고 이해한다.

Stream 은 필요하다면 여러 개가 될 수 있다. 강의에서는 로그인시 name, password, validation, isLoginSuccess 각각을 위한 네 개의 StreamController 를 만들어 사용하고 있다. 강의 코드가 좀 깔끔하지 못하므로 View-ViewModel 간에 Stream 이 다수일 수 있다는 것만 기억하자. 다수인 상황이 오히려 많을 것 같다.