[Flutter] 상태관리와 설계패턴 MVVM

Comely·2024년 11월 16일

Flutter

목록 보기
12/26

1. MVVM = Model - View - ViewModel

View : 사용자에게 보여지는 UI 부분으로 사용자의 동작이나 각정 처리는 ViewModel에 의뢰

ViewModel : Model을 View에 표시하기 위한 처리
-View로부터 의뢰 받은 처리를 Model에 의뢰합니다.
-Model로부터 처리 결과를 View에 통지(자동갱신) 합니다.
-하나의 화면(View)에 하나의 ViewModel 이 일반적입니다.
-여러가지 상태(변수)를 캡슐화 : 화면에 표시할 데이터, 로딩 상태 등이 있습니다.

Model: 데이터와 데이터를 처리하는 부분 (비즈니스 로직)
-ViewModel로부터 의뢰받은 로직을 처리
-DB, File, Web으로부터 데이터를 가져오거나 전송 등 (Repository)

View -> ViewModel(UI 로직) -> Repository(Data 로직) -> DataSource

2. 상태 관리

어플리케이션 상태의 종류

  • Preference
  • 로그인 정보
  • 쇼핑몰의 카트
  • 읽은 메일 / 안 읽은 메일
  • 소셜 앱의 알림

상태 = 데이터 = 변수
즉, 변수를 수정하면 알아서 UI도 바뀌게 합니다.
= IngeritedWidget + (ValueNotifier 또는 ChangeNotifier)

2_1. ChangeNotifier

widget.viewModel.addListener(updateUi);

viewModel이 변경되면 updateUi을 호출합니다.
화면이 종료될 때 dispose로 구독을 제거해야 합니다.
잊게되면 메모리 Leak이 발생될 수 있습니다.

class _TodoScreenState extends State<TodoScreen> {
  void updateUi() => setState(() {});
  
  void initState() {
    super.initState();
    
    widget.viewModel.addListener(updateUi);
  }
  
  void dispose() {
	widget.viewModel.removeListener(updateUi);
	super.dispose();
  }
}

2_2. ListenableBuilder

01. Listenable이란?

  • Listenable은 Dart에서 상태 변경을 알리고 이를 구독(subscribe)할 수 있도록 하는 인터페이스입니다.
    이 객체는 내부 상태가 변경되면 addListener()를 호출한 구독자들에게 알립니다.
  • ValueNotifier : 단일 값의 변화를 알릴 때 사용.

  • ChangeNotifier : 더 복잡한 상태 관리를 위해 사용.

02. ListenableBuilder의 주요 특징

  • 역할 : Listenable 객체를 관찰하고 상태가 변경될 때 자동으로 UI를 다시 빌드.

  • 장점 : 간결한 코드: addListener와 removeListener를 직접 관리할 필요 없음.

  • 효율성 : 특정 위젯만 다시 빌드되므로 성능에 유리.

  • 제약 : Listenable 객체에 의존하므로, Listenable이 아닌 상태 관리 솔루션에는 사용할 수 없음.

03. ListenableBuilder의 매개변수 (viewModel)

  • listenable : 관찰할 Listenable 객체 (필수).

  • builder : UI를 다시 빌드할 함수.

    • 구조: (BuildContext context, Widget? child) → Widget
  • child : 상태 변경에 영향을 받지 않는 부분을 캐싱하여 성능 최적화 가능.

Widget build(BuildContext context) {
  return Scaffold(
    body: ListenableBuilder(
      listenable: viewModel,
      builder: (context, child) {
        return Column(
          children: [
            ElevatedButton(
              onPressed: () => viewModel.fetchTodos(),
              child: const Text('가져오기'),
            ),
            ...viewModel.todos.map((todo) => Text(todo.title)).toList(),
          ],
        );
      },
    ),
  );
}

ListenableBuilder 사용해야 할 위젯이 많을 경우
또는 앱의 상태 관리가 복잡해질 경우
Riverpod이나 Provider 같은 상태 관리 패키지를 함께 사용할 수 있습니다.

2_3. Provider

setState()에 의해 하위 모든 위젯이 다시 그려지는 상태관리를
Provider 사용시 꼭 필요한 위젯만 다시 그릴 수 있습니다.
즉, 전역에서 사용해야 하는 데이터들을 관리합니다.

View(ChangeNotifierProvider) : 위젯 트리의 최상위에 설정하여 ChangeNotifier를 감시
ViewModel(ChangeNotifier) : NotifyListeners() 변경을 통지

즉, Provider는 IngeritedWidget을 만들지 않아도 심플하게 사용하도록 한 것입니다.
IngeritedWidget와 가장 흡사합니다 = 근본

2_3_1. Consumer

child: Consumer<CounterModel>(
          builder: (context, counterModel, child) {
            return Text(
              'Counter: ${counterModel.counter}',
              style: TextStyle(fontSize: 24),
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // Provider로 상태 접근 가능
          Provider.of<CounterModel>(context, listen: false).increment();
        },

Consumer
특정 상태의 변경 사항을 감지하여, 원하는 위젯만 다시 빌드합니다.
context를 통해 CounterModel() 가 MyApp()에 주입됩니다.

2_3_2. context.watch();

watch()는 지속적인 관찰을 하고 변경시 build()를 리빌드 합니다.
build() 메서드 내에서 consumer 대신 다음과 같이 사용합니다.

Widget build(BuildContext context) {
final model = context.watch<counterModel>();

2_3_3. context.read();

read()는 지속적인 관찰이 아닌 단발성 접근에 사용합니다.
즉, initState() 또는 버튼 클릭에 사용됩니다.

  context.read<counterModel>().increase();
  Provider.of<CounterModel>(context, listen: false).increase();

위 코드 두가지는 동일한 코드입니다.

Provider 요약

  • build()에서는 watch
  • 단발성 실행은 read
  • iniState()에서는 Future.microtask() 후에 read
    • build가 한 번이라도 실행되어야 context 접근이 가능하므로 microtask 사용
  • didChangeDependencies()에서 viewModel 접근가능(매번 실행하는 코드는 사용하면 안됨)
  • watch를 통해 viewModel에 접근합니다.
  • read를 통해 increase 기능에 접근합니다.

2_3_4. Provider 예시

  	  //main.dart
      //기본 제공되는 ChangeNotifierProvider로 Provider<MainViewModel> 주입
  	  //ChangeNotifierProvider 어디에 배치하느냐에 따라 사용범위가 달라짐
      //화면 하나 당 1개의 ChangeNotifierProvider를 사용 추천
      home: ChangeNotifierProvider<MainViewModel>(
                create: (BuildContext context) {
                  return MainViewModel(
                    storeRepository: StoreRepositoryImpl(),
                    locationRepository: LocationRepositoryImpl(),
                  );
                },
                child: const MainScreen(),
   
       //MainScreen
      class MainScreen extends StatelessWidget {
      const MainScreen({super.key});

      
      Widget build(BuildContext context) {
        final viewModel = context.watch<MainViewModel>();
        return Scaffold(
          appBar: AppBar(
            title: Text('마스크 재고 있는 곳: ${viewModel.stores.length}곳'),
          ),
          body: ListView(
            children:
                viewModel.stores.map((store) => StoreItem(store: store)).toList(),
          ),
        );
      }
    }

3. Model Class

데이터 구조

  • data
    • data_source
    • model (비슷한 용어: 도메인 모델, Entity, DTO, POJO, VO, 데이터 클래스)
    • repository

모델링 방법

  • DDD(Domain Driven Design)
    • 객체, 기능을 중심으로 디자인
    • Domain 정의 : 유사한 업무의 집합, 특정 상황(주문, 결재, 로그인), 특정 객체(유저, 손님)이 중심이 될 수 있습니다.
    • 모델 클래스 : 도메인을 클래스로 작성한 것입니다.
  • ORM(Object-relational mapping)
    • 데이터베이스에서 많이 사용합니다.
    • 데이터 소스가 DB인 경우 DB와 모델간 상호 변환을 도와주는 기법입니다.

Factory의 개념

동일한 데이터로 이미 생성된 객체를 재사용하려고 할 때 사용합니다.
특정 조건에 따라 객체의 서브클래스를 반환하거나 초기화 로직을 추가해야 할 때 사용합니다.
조건에 따라 새 객체 생성 또는 기존 객체 반환 가능합니다.
Dart에서 객체를 반환할 때 새 인스턴스를 생성하거나 기존 객체를 재활용할 수 있습니다.

class User {
  final String name;
  static final Map<String, User> _cache = {};

  // 팩토리 생성자
  factory User(String name) {
    // 이미 동일한 name으로 생성된 User가 있다면 재사용
    if (_cache.containsKey(name)) {
      return _cache[name]!;
    } else {
      final newUser = User._internal(name);
      _cache[name] = newUser;
      return newUser;
    }
  }

  // 실제 객체를 생성하는 private 생성자
  User._internal(this.name);
}

싱글톤(Singleton)의 개념

클래스의 인스턴스를 단 하나만 유지하도록 보장하는 디자인 패턴입니다.
앱 전체에서 동일한 인스턴스를 공유합니다.
주로 공유 리소스(예: 데이터베이스 연결, 설정 정보 등)나 상태를 유지하기 위해 사용합니다.
메모리 낭비 방지하며, 전역적인 데이터나 리소스를 한 곳에서 관리합니다.

class Singleton {
  // static 변수로 단 하나의 인스턴스 유지
  static final Singleton _instance = Singleton._internal();

  // private 생성자
  Singleton._internal();

  // 외부에서 호출 가능한 싱글톤 인스턴스 제공
  factory Singleton() {
    return _instance;
  }

  void someMethod() {
    print("싱글톤 메서드 호출");
  }
}

Repository 패턴

소프트웨어 개발에서 데이터 저장소에 접근하는 객체를 추상화하고,
데이터소스 (DB, File 등)와의 통신을 담당하는 객체를 캡슐화하는 디자인 패턴입니다.

  • Repository 패턴

    • 데이터 소스와 상호 작용하여 데이터를 추가, 조회, 수정, 삭제(CRUD)하는 역할을 담당
    • 데이터 캡슐화
    • Repository Interpace 정의를 통한 데이터 추상화 (RepositoryImpl 로 CRUD 구현)
    • 데이터 접근 제어
    • 예외 처리
  • 비즈니스 로직과 데이터를 분리하는 이점

    • 유지 관리성 향상

    • 재사용성 향상(다형성)
      캐시가 비어 있으면 데이터를 가져오고, 캐시가 차 있으면 캐시의 데이터를 리턴

    • 테스트 용이성 향상

    • 확장성 향상

    • 데이터 액세스 추상화

[ViewModel 예시] 공적 마스크 조회 API

num

Int 와 Double 은 호환이 되지 않기 때문에
API가 Double이라면 Model을 만들 때
임시로 Int 와 Double을 포함하는 num 을 사용하여 위도와 경도를 입력할 수 있습니다.

utf8.decode

http.get(url)을 이용합니다.

print('Response body: ${response.body}');

서버에서 API 값을 가져왔는데 한글이 깨질 경우, utf8.decode을 사용합니다.

import 'dart:convert';

print('Response body: ${jsonDecode(utf8.decode(response.bodyBytes))}');

URL

https://gist.githubusercontent.com/junsuk5/bb7485d5f70974deee920b8f0cd1e2f0/raw/063f64d9b343120c2cb01a6555cf9b38761b1d94/sample.json

"stores": [{
      "addr": "서울특별시 강북구 삼양로 255 (미아동)",
      "code": "11819723",
      "created_at": "2020/07/03 11:00:00",
      "lat": 37.6261612,
      "lng": 127.0180494,
      "name": "청구약국",
      "remain_stat": "plenty",
      "stock_at": "2020/07/03 10:40:00",
      "type": "01"
    }, 
    {...},
    {...},
    ]

jsonList 형식 변경

where을 이용하여 가져올 데이터를 선택합니다.

.where((e) => true) : 조건

true: 모든 데이터를 가져옵니다.
false: 모든 데이터를 가져오지 않습니다.

return jsonList
        .where((e) => e['remain_stat'] != null)
        .map((e) => MaskStore(
            name: e['name'] as String,
            address: e['addr'] as String,
            distance: 0,
            remainStatus: e['remain_stat'] as String,
            latitude: e['lat'] as double,
            longitude: e['lng'] as double))
        .toList();

MainViewModel

  • bool _isLoading = false; 로딩 표시를 합니다.
  • @immutable 어노테이션 사용시 Model Class의 const 표시의 오류를 나타낼 수 있습니다.
class MainViewModel with ChangeNotifier {
  final StoreRepository _storeRepository;
  final LocationRepository _locationRepository;

  MainViewModel({
    required StoreRepository storeRepository,
    required LocationRepository locationRepository,
  })  : _storeRepository = storeRepository,
        _locationRepository = locationRepository {
    fetchStores();
  }

  // 상태
  List<MaskStore> _stores = [];
  bool _isLoading = false;

  List<MaskStore> get stores => List.unmodifiable(_stores);

  bool get isLoading => _isLoading;

  void fetchStores() async {
    _isLoading = true;
    notifyListeners();

    final stores = await _storeRepository.getStores();
    final location = await _locationRepository.getLocation();

    for (var store in stores) {
      store.distance = _locationRepository.distanceBetween(
        store.latitude,
        store.longitude,
        location.latitude,
        location.longitude,
      );
    }

    stores.sort((a, b) => a.distance.compareTo(b.distance));

    _stores = stores;
    _isLoading = false;
    notifyListeners();
  }
}

StoreRepository

class StoreRepositoryImpl implements StoreRepository {
  
  Future<List<MaskStore>> getStores() async {
    final response = await http.get(Uri.parse(
        'https://gist.githubusercontent.com/junsuk5/bb7485d5f70974deee920b8f0cd1e2f0/raw/063f64d9b343120c2cb01a6555cf9b38761b1d94/sample.json'));

    final List jsonList = jsonDecode(response.body)['stores'];

    return jsonList
        .where((e) => e['remain_stat'] != null)
        .map((e) => MaskStore(
            name: e['name'] as String,
            address: e['addr'] as String,
            distance: 0,
            remainStatus: e['remain_stat'] as String,
            latitude: e['lat'] as double,
            longitude: e['lng'] as double))
        .toList();
  }
}

LocationRepository

class LocationRepositoryImpl implements LocationRepository {
  
  double distanceBetween(
          double startLat, double startLng, double endLat, double endLng) =>
      Geolocator.distanceBetween(startLat, startLng, endLat, endLng);

  
  Future<Location> getLocation() async {
    final serviceEnabled = await Geolocator.isLocationServiceEnabled();

    if (serviceEnabled) {
      var permission = await Geolocator.checkPermission();

      if (permission == LocationPermission.denied) {
        permission = await Geolocator.requestPermission();

        return const Location(latitude: 0, longitude: 0);
      } else if (permission == LocationPermission.deniedForever) {
        return const Location(latitude: 0, longitude: 0);
      }

      // 승인
      final position = await Geolocator.getCurrentPosition();
      return Location(
        latitude: position.latitude,
        longitude: position.longitude,
      );
    }

    return const Location(latitude: 0, longitude: 0);
  }
}
profile
App, Web Developer

0개의 댓글