Provider를 Getx처럼 사용하기

Ximya·2023년 7월 23일
5

Plotz 개발일지

목록 보기
11/12
post-thumbnail

해당 포스팅은 유튜브 영화&드라마 리뷰 영상 큐레이션 플랫폼 Plotz를 개발하면서 도입된 기술 및 방법론에 대한 내용을 다루고 있습니다.
다운로드 링크 : 앱스토어 / 플레이스토어

최근에 GetX로 관리되고 있던 Plotz 프로젝트를 100% Provider로 전환했습니다. 마이그레션을 진행하고 Provider에서 제공하는 기능과 컨셉들이 결과적으로 마음에 들었지만, Provider가 Getx의 최대 장점인 코드의 단순함가독성을 따라오지 못한다는 점이 아쉬웠습니다. 그래서 Provider 환경에서도 GetX처럼 단순하고 가독성이 높은 코드를 작성할 수 있는 방법에 대해 고민했었습니다.

본 포스팅에서는 Getx의 컨셉들에 착안해서 Provider 패키지를 더 단순한 코드로 상태를 관리할 수 있는 방법과 관련 예제 코드를 제공합니다. 글을 다 읽으시고 나서는 Provider 상태관리 패키지를 더 쉽고 심플하게 사용할 수 있는 팁을 얻으실 수 있을 겁니다.

참고로 MVVM 아키텍처에 입각해서 필요한 개념들을 다루고 있지만, 다른 아키텍처에서도 유연하게 적용할 수 있으니 참고해 주세요.

GetX vs Provider: 코드 가독성과 단순성 비교

먼저 GetX와 Provider의 코드를 가독성과 단순성의 관점으로 비교해 보겠습니다.

1. 간단한 상태 접근

아래는 GetX와 Provider에서 간단한 상태에 접근하는 예제 코드입니다.

GetX 예제

class MyGetXScreen extends GetView<MyGetXController> {
  
  Widget build(BuildContext context) {
    return Text(controller.title);
  }
}

Provider 예제

class MyProviderScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<MyProviderController>(
      create: (context) => MyProviderController(),
      builder: (BuildContext context, Widget? child) =>
          Text(context.read<MyProviderController>().title),
    );
  }
}

GetX 예제에서는 GetView를 사용하여 controller 객체에 접근합니다. GetView의 제네릭 타입에 컨트롤러(MyGetXController)를 명시해 주면, controller에 접근할 때 별도의 타입을 명시할 필요가 없어서 상태 접근이 직관적입니다. 하지만 Provider 예제에서는 Provider의 context extension 메소드 context.read<T>()를 사용할 때 제네릭 타입을 필수적으로 명시해 주어야 합니다. 이로 인해 코드가 더 길어지고 번거로워질 수밖에 없습니다.

2. ViewModel과 UI 간의 낮은 의존성

  // Getx버전 라우팅: buildContext가 필요 없음
  getxRoute() {
    Get.to(SomeScreen());
  }

  
  // Provider버전 라우팅: buildContext가 필요함
  providerRoute(BuildContext context) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => SomeScreen(),
      ),
    );
  }

GetX는 context를 전역으로 관리하기 때문에 일반적으로 context가 필요한 라우팅 및 다이얼로그 노출 등의 로직에 context를 사용하지 않습니다. 따라서 코드가 비교적 단순하고 context를 UI 위젯으로부터 전달받을 필요가 없기 때문에 ViewModel과 UI의 의존성이 낮아집니다. 반면 Provider에서는 UI에 종속적인 작업이 필요한 경우 UI 위젯으로부터 context를 인자로 받아와야 하는 번거로움이 발생하며, 이에 따라 UI와 ViewModel 간의 의존성이 높아지게 됩니다.

3. life cycle 메소드

Getx 패키지의 GetxController에서는 onInit , onDispose와 같은 라이프 사이클 메서드를 제공합니다. 이러한 메소드를 사용하여 GetxController의 라이프 사이클 동안 특정 로직을 실행할 수 있습니다.

Provide패키지에서는 GetxController처럼 라이프 사이클 메소드를 제공하지는 않지만 아래 코드와 같이 ViewModel이 생성되고 해제되는 시점을 구분질 수 있습니다.

class CustomScreen extends StatelessWidget {
  const CustomScreen({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<CustomViewModel>(
      create: (context) => CustomViewModel();
      builder: (context, child) {
        return Container();
      },
    );
  }
}

class CustomViewModel extends ChangeNotifier {
 
  CustomViewModel() {
    onInit();
  }
  
  void onInit() {
    // ViewModel이 생성되는 시점에 필요한 메소드
  }

  
  void dispose() {
    super.dispose();
    // ViewModel이 해제되는 시점에 필요한 메소드
  } 
}

CustomViewModel 생성자 구문에 onInit 메소드를 적어놓았기 때문에 CustomViewModel이 생성되는 시점에 onInit 메소드를 호출하여 필요한 초기화 메소드들을 실행할 수 있게 됩니다. 또한 ChangeNotifier에 정의된 dispose 메소드를 오버라이드하여 ViewModel 해제되는 시점에서 필요한 메소드들을 처리할 수 있도록 합니다.

이렇게 접근해도 기능상 문제가 없지만, onInit을 메소드를 ViewModel이 생성되는 시점에 실행시키기 위해 해당 메소드를 생성자 구문에 적는 방법보다는 GetxController처럼 간단하게 오버라이드하여 정의할 수 있게 하는 게 조금 더 직관적이라고 생각합니다.

Solution: Provider를 Getx처럼 간단하게

위에서 언급한 GetX의 이점들을 Provider 환경에서도 적용할 수 있도록 도와주는 Base Module(BaseScreen, BaseViewModel, BaseView)들을 소개해 드리겠습니다.

BaseScreen

abstract class BaseScreen<T extends BaseViewModel> extends StatelessWidget {
  const BaseScreen({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<T>(
      create: (context) {
        final vm = createViewModel(context);
        vm.initContext(context);
        return vm;
      },
      lazy: setLazyInit,
      builder: (BuildContext context, Widget? child) => buildScreen(context),
    );
  }

  
  Widget buildScreen(BuildContext context);

  
  bool get setLazyInit => false;

  
  T vm(BuildContext context) => Provider.of<T>(context, listen: false);

  
  T vmR(BuildContext context) => context.read<T>();

  
  T vmW(BuildContext context) => context.watch<T>();

  
  S vmS<S>(BuildContext context, S Function(T) selector) {
    return context.select((T value) => selector(value));
  }

  
  T createViewModel(BuildContext context);
}

BaseView

abstract class BaseView<T extends BaseViewModel> extends StatelessWidget {
  const BaseView({Key? key}) : super(key: key);

  
  T vm(BuildContext context) => Provider.of<T>(context, listen: false);

  
  T vmR(BuildContext context) => context.read<T>();

  
  T vmW(BuildContext context) => context.watch<T>();

  
  S vmS<S>(BuildContext context, S Function(T) selector) {
    return context.select((T value) => selector(value));
  }
}

BaseViewModel

abstract class BaseViewModel extends ChangeNotifier {
  BaseViewModel() {
    onInit();
  }

  late BuildContext context;

  
  void dispose() {
    onDispose();
    super.dispose();
  }

  
  void onInit() {}

  
  void onDispose() {}

  void initContext(BuildContext contextArg) {
    context = contextArg;
  }
}  

위의 Base Module은 Provider를 GetX처럼 사용할 수 있도록 도와주는 모듈들입니다. BaseScreen은 화면에 해당하는 추상 클래스로 기본적인 화면의 구조와 뷰 모델과의 연결을 정의합니다. BaseView는 뷰에 해당하는 추상 클래스로 화면 위젯들을 별도의 클래스로 쪼개어 관리할 때 사용됩니다. BaseViewModel은 뷰 모델에 해당하는 추상 클래스로 뷰 모델의 구조와 초기화, 해제 로직을 정의합니다. 이러한 모듈들을 사용하면 Provider 환경에서도 GetX의 가독성과 단순성을 얻을 수 있게 됩니다.

예제 적용

class CustomViewModel extends BaseViewModel {
  final String title = "It looks like GetX, but it's actually Provider.";
  final String nestedViewTitle = "Some Text";
}

class CustomScreen extends BaseScreen<CustomViewModel> {
  const CustomScreen({Key? key}) : super(key: key);

  
  Widget buildScreen(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              vm(context).title,
            ),
            const NestedView(),
          ],
        ),
      ),
    );
  }

  
  CustomViewModel createViewModel(BuildContext context) => CustomViewModel();
}

class NestedView extends BaseView<CustomViewModel> {
  const NestedView({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Text(
      vm(context).nestedViewTitle,
    );
  }
}

간단한 예제를 적용해 보았습니다. 여기서 주의할 몇 가지 있으니, 아래를 참고해 주세요.

  • BaseScreenBaseViewModel은 바늘과 실과 같은 관계로 서로 항상 정의가 되어 있어야 합니다.
  • BaseScreen을 extends하고 있는 위젯에서는 build메소드가 아닌 buildScreen 메소드를 오버라이드 해주어야 합니다.
  • BaseView를 extends하고 있는 위젯은 항상 BaseScreen을 extends하고 있는 위젯에 감싸져 있어야 합니다.
  • BaseScreen에 정의된 createViewModel 오버라이드 메소드에 항상 ViewModel의 인스턴스를 필수적으로 넘겨주어야 합니다.

CustomViewModel createViewModel(BuildContext context) => GetIt.I<CustomViewModel>();

get_it과 같은 의존성 주입 패키지를 이용하고 있다면 위에 코드처럼 인스턴스를 넘겨줄 수 있습니다.

분석

그럼, 이 Base모듈 코드들을 분석해 보면서 모듈의 원리와 이점에 대해 자세히 알아보겠습니다.

1. 간단해진 상태 접근

Base 모듈을 사용하면 상태 접근이 훨씬 간편해집니다.

  
  T vm(BuildContext context) => Provider.of<T>(context, listen: false);

  
  T vmR(BuildContext context) => context.read<T>();

  
  T vmW(BuildContext context) => context.watch<T>();

  
  S vmS<S>(BuildContext context, S Function(T) selector) {
    return context.select((T value) => selector(value));
  }

BaseScreen 코드를 보면 vm, vmR, vmW 그리고 vmS 등의 메소드들이 정의되어 있습니다. 이는 Provider read, write, select context extension 메소드를 리턴하고 있으며, 해당 모듈에서 'T' 타입의 ViewModel을 전달받았기 때문에 실제로 해당 메소드를 사용할 때 ViewModel 타입을 명시해줄 필요가 없게 됩니다. (여기서 vm 키워드는 ViewModel의 약어입니다)

예를 들어, 기존에는 다음과 같이 데이터에 접근하였습니다.

// 기존방식
Text(context.read<CounterViewModel>().userName),
Text(context.watch<CounterViewModel>().userName),
Text(context.select<CounterViewModel, String>((value) => value.userName)),  

하지만 Base 모듈을 사용하면 아래와 같이 간결하게 접근할 수 있습니다.

// Base 모듈 사용
Text(vm(context).userName),
Text(vmW(context).userName),
Text(vmS(context, (value) => value.userName)),

데이터에 접근할 때마다 타입을 명시할 필요가 없어져서 코드가 훨씬 간결해졌네요. 또한 BaseScreen 모듈에 ChangeNotifierProvider 위젯이 추상화되어 있기 때문에 BaseScreen을 상속하는 스크린 위젯의 코드들이 전체적으로 더 깔끔하게 정리됩니다. 이러한 점에서 Getx 패키지의 GetView와 유사한 접근 방식을 제공한다고 볼 수 있습니다.


2. ViewModel과 UI 간의 의존성 낮추기

abstract class BaseViewModel extends ChangeNotifier {
  
  // ViewModel에서 사용할 context 변수
  late BuildContext context; 
  
  // context 초기화 메소드 
  void initContext(BuildContext contextArg) {
    context = contextArg;
  }
  
  ...
}  

BaseViewModel의 코드를 보면 BuildContext타입의 변수가 late 키워드로 선언되어 있고, 아래에 해당 변수를 초기화하는 initContext가 존재합니다.

abstract class BaseScreen<T extends BaseViewModel> extends StatelessWidget {
  const BaseScreen({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<T>(
      create: (context) {
        final vm = createViewModel(context);
  		// 여기서 viewModel에서 사용할 context를 초기화함
        vm.initContext(context); 
        return vm;
      },
      
 ... 
}

그리고 BaseScreen 안에서는 initContext 메소드를 실행하여 ViewModel이 생성되는 시점에 BaseViewModelcontext 변수도 초기화 시키게 됩니다.

 /// BaseViewModel 자체에 초기화된 buildContext가 있기 때문에
 /// 라우팅 메소드에서 인자로 buildContext를 전달 받을 필요가 없음. 
  providerRoute() {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => SomeScreen(),
      ),
    );
  }

  
 /// BaseViewModel이 적용이 안되었을 때는 당연히
 /// UI위젯으로부터 buildContext를 받아와야함
  providerRouteByContextArg(BuildContext context) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => SomeScreen(),
      ),
    );
  }  

그럼, 이제 ViewModel 자체에서 context 인스턴에 접근할 수 있게 되므로,
BuildContext를 활용해야 하는 로직들을 ViewModel에서 관리할 때 매번 UI 위젯에서 BuildContext를 넘겨받을 필요가 전혀 없게 되죠. 훨씬 더 직관적이고 ViewModel과 UI의 의존성을 낮출 수 있게 되었습니다.

3. life cycle 메소드 지원

abstract class BaseViewModel extends ChangeNotifier {
  BaseViewModel() {
  	// viewModel이 생성되는 시점에 onInit 메소드 발동
    onInit();
  }

  
  void dispose() {
    // viewModel이 해제되는 시점에 onDispose 메소드 발동
    onDispose();
    super.dispose();
  }

  
  // 간편하게 오버라이드가 가능한 onInit, onDispose 메소드 정의 
  
  void onInit() {}

  
  void onDispose() {}
  
}

BaseViewModel 모듈안에 onInit(), onDispose() 메소드가 정의되어 있습니다. onInit 메소드는 BaseViewModel을 extends하고 있는 클래스가 생성될 때 발동될 수 있도록 생성자 구문안에 메소드를 실행시키고 있고, ChangeNotifier에서 제공하는 dispose 메소드가 발동될 때 onDispose() 메소드가 실행될 수 있도록 설계했습니다.

class CustomViewModel extends BaseViewModel {

  
  void onInit() {
    super.onInit();
    // 필요한 초기화 작업을 수행
  }

  
  void onDispose() {
    super.onDispose();
    // 필요한 dispose 작업을 수행
  }
}

BaseViewModel extends하고 있는 ViewModel은 Getx의 GetxController처럼 라이프 사이클 메소드를 오버라이드 하여 사용할 수 있게 되고 엄청 편리하게 라이프 사이클을 관리할 수 있도록 도와줍니다.

마무리

이번 포스팅에서는 Provider 패키지를 GetX처럼 사용할 수 있도록 도와주는 Base Module에 관해 설명하였습니다. Base Module을 통해 Provider를 더 효과적으로 사용하고, 코드의 가독성을 높일 수 있었죠.

최근에 제가 배포한 provider_screen 패키지에는 Provider를 효과적으로 사용할 수 있는 기능과 더불어 생산성을 높여줄 수 있는 추가 기능들이 적용되어 있으니 참고해보셔도 좋을 것 같습니다.

profile
https://medium.com/@ximya

2개의 댓글

comment-user-thumbnail
2023년 7월 31일

provider를 사용할 때 context를 항상 사용하여 호출해야된다는 것이 가장 귀찮고 불편한 점이라서 어떻하면 viewModel에 분리해볼까 라는 생각을 많이 했던것 같았는데.... 친절하게 코드와 설명까지 정리해주셔서 참고 많이 했습니다! 좋은 자료 감사합니다!

1개의 답글