해당 포스팅은 유튜브 영화&드라마 리뷰 영상 큐레이션 플랫폼
Plotz
를 개발하면서 도입된 기술 및 방법론에 대한 내용을 다루고 있습니다.
다운로드 링크 : 앱스토어 / 플레이스토어
최근에 GetX
로 관리되고 있던 Plotz 프로젝트를 100% Provider
로 전환했습니다. 마이그레션을 진행하고 Provider에서 제공하는 기능과 컨셉들이 결과적으로 마음에 들었지만, Provider가 Getx의 최대 장점인 코드의 단순함
과 가독성
을 따라오지 못한다는 점이 아쉬웠습니다. 그래서 Provider 환경에서도 GetX처럼 단순하고 가독성이 높은 코드를 작성할 수 있는 방법에 대해 고민했었습니다.
본 포스팅에서는 Getx의 컨셉들에 착안해서 Provider 패키지를 더 단순한 코드로 상태를 관리할 수 있는 방법과 관련 예제 코드를 제공합니다. 글을 다 읽으시고 나서는 Provider 상태관리 패키지를 더 쉽고 심플하게 사용할 수 있는 팁을 얻으실 수 있을 겁니다.
참고로 MVVM 아키텍처에 입각해서 필요한 개념들을 다루고 있지만, 다른 아키텍처에서도 유연하게 적용할 수 있으니 참고해 주세요.
먼저 GetX와 Provider의 코드를 가독성과 단순성의 관점으로 비교해 보겠습니다.
아래는 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>()
를 사용할 때 제네릭 타입
을 필수적으로 명시해 주어야 합니다. 이로 인해 코드가 더 길어지고 번거로워질 수밖에 없습니다.
// 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 간의 의존성
이 높아지게 됩니다.
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처럼 간단하게 오버라이드
하여 정의할 수 있게 하는 게 조금 더 직관적이라고 생각합니다.
위에서 언급한 GetX의 이점들을 Provider 환경에서도 적용할 수 있도록 도와주는 Base Module(BaseScreen, BaseViewModel, BaseView)
들을 소개해 드리겠습니다.
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);
}
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));
}
}
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,
);
}
}
간단한 예제를 적용해 보았습니다. 여기서 주의할 몇 가지 있으니, 아래를 참고해 주세요.
BaseScreen
과 BaseViewModel
은 바늘과 실과 같은 관계로 서로 항상 정의가 되어 있어야 합니다. buildScreen
메소드를 오버라이드 해주어야 합니다.BaseView
를 extends하고 있는 위젯은 항상 BaseScreen을 extends하고 있는 위젯에 감싸져 있어야 합니다. createViewModel
오버라이드 메소드에 항상 ViewModel의 인스턴스
를 필수적으로 넘겨주어야 합니다.createViewModel(BuildContext context) => GetIt.I<CustomViewModel>();
CustomViewModel
get_it
과 같은 의존성 주입 패키지를 이용하고 있다면 위에 코드처럼 인스턴스를 넘겨줄 수 있습니다.
그럼, 이 Base모듈 코드들을 분석해 보면서 모듈의 원리와 이점에 대해 자세히 알아보겠습니다.
Base 모듈을 사용하면 상태 접근이 훨씬 간편해집니다.
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
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
와 유사한 접근 방식을 제공한다고 볼 수 있습니다.
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이 생성되는 시점에 BaseViewModel
의 context
변수도 초기화 시키게 됩니다.
/// 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의 의존성
을 낮출 수 있게 되었습니다.
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를 효과적으로 사용할 수 있는 기능과 더불어 생산성을 높여줄 수 있는 추가 기능들이 적용되어 있으니 참고해보셔도 좋을 것 같습니다.
provider를 사용할 때 context를 항상 사용하여 호출해야된다는 것이 가장 귀찮고 불편한 점이라서 어떻하면 viewModel에 분리해볼까 라는 생각을 많이 했던것 같았는데.... 친절하게 코드와 설명까지 정리해주셔서 참고 많이 했습니다! 좋은 자료 감사합니다!