View
플러터로 개발을 진행하면서, MVVM 구조로 뷰 로직을 작성하기 위해 state 관리를 하기 위해 플러터의 상태 관리 도구들을 찾아보았고, 그 중에서 Riverpod 이라는 프레임워크를 사용하기로 하였다. 처음에는 너무나도 이해가 안되고 SwiftUI 가 그리웠지만, 한 3일 정도 Riverpod으로 고생을 좀 하다보니 어느정도 감이 잡힌 것 같다.
Riverpod를 공부하면서 유튜브에서 이것저것 강의영상을 찾아봤었는데, 개인적으로는 아래 영상이 제일 이해가 잘됐다. 이전 영상들을 보고 나서 요걸 봐서 이해가 된 걸 수도 있겠지만, Riverpod의 여러가지 Provider를 한 번에 쭉 소개해주면서 비교해서 그런지, 이전에 봤지만 헷갈리던 내용들까지 쫙 정리가 되었다. 추천추천!
Riverpod는 단순히 상태 관리를 위한 도구라기 보다는, Reactive Data Binding, Caching이 가능한 프레임워크라고 생각해야 한다고 공식 문서에 적혀있다.
내가 이해한 바에 따르면, Provider는 state를 앱 전체에 뿌려주는 역할을 한다. 특정한 상태를 담은 객체를 앱 내의 어느 위젯에서든지 쉽게 접근해서 상태를 가져오고, 이에 따라 위젯을 적절하게 보여줄 수 있게 된다. 단순히 데이터를 제공해서 화면을 그릴 수 있게 하는 변수로서의 역할만 하는게 아니라, Provider가 가진 상태 값이 바뀌면 이에 맞춰서 위젯이 바로바로 화면을 그릴 수 있게 만들어준다. 마치 Publisher - Subscriber 관계처럼. 이 덕분에 MVVM 구조처럼 데이터의 변화를 뷰가 감지하고 알아서 그리는 형태를 가져갈 수 있다.
기본적인 Provider는 이렇게 생겼다. Provider는 ref를 인자로 받고, 상태값을 반환하는 형태로 정의되는 객체이다.
final myStringProivider = Provider((ref) => "Hello World!");
여기에서 뜬금없이 인자로 등장한 ref
에 대해 알아두고 갈 필요가 있다. 앞으로 나올 모든 Provider가 요 ref 를 이용한다. ref는 WidgetRef 타입으로, Riverpod에서 위젯이 Provider들과 상호작용을 할 수 있게 해주는 객체이다. 플러터가 가지고 있는 위젯 트리의 구조와 상관없이 위젯이 특정한 Provider에 접근할 수 있도록 도와, 기존의 상태 관리 체계가 가지고 있던 단점을 해결해주는 고마운 녀석이다. 쉽게 말해서, 위젯이 Provider에 접근할 수 있는 지름길이라고 생각하면 될 것 같다. Riverpod 프레임워크가 플러터와 독립적으로 작동한다는게 요것 때문이다.
ref에서 provider의 상태에 반응하는 방식은 크게 3가지를 제공한다.
ref.read(Provider)
→ Provider의 상태를 읽어오기만 함. 상태가 변경되어도 별 다른 액션은 없다.ref.listen(Provider, function)
→ Provider의 상태가 변경될 때 등록한 함수를 실행함ref.watch(Provider)
→ Provider의 상태가 변경될 때 자체적으로 다시 빌드함. 제일 유용한 듯!
Provider로부터 값을 가져오기 위해서는 Consumer
라는 위젯을 이용해야 한다. Consumer로 활용할 수 있는 위젯은 두 종류가 있는데, 일반적인 위젯처럼 사용하는 Consumer
와, StatelessWidget을 대체하여 사용하는 ConsumerWidget
이 있다.
Consumer(builder: (context, ref, child) { return const Placeholder(); }) class exampleWidget extends ConsumerWidget { const exampleWidget({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { return const Placeholder(); } }
여기에서 볼 수 있듯이, 두 위젯 모두 인자로 WidgetRef ref
를 받아온다. 위젯 내에서 ref
를 통해 원하는 Provider에 간편하게 접근이 가능하다. Provider에서 state를 가져오는 부분은 아래 Provider를 설명하면서 함께 작성하겠다. 일단은 Provider → Consumer 로 상태를 전달한다는 것을 기억하자.
이제, 기본 Provider부터 시작해서, StateProvider, FutureProvider, StreamProvider, ChangeNotifierProvider 그리고 StateNotifierProvider까지 하나씩 살펴보면서 Riverpod의 Provider들을 살펴보자.
기본 Provider
가장 기본적인 Provider이다. 요걸 사용하면 Provider를 통해 state를 전달받을 수 있다.
final myStringProivider = Provider((ref) => "Hello World!");
요 녀석의 특징은 read-only로, 상태를 변경시킬 수 없다는 것이다.
Provider 로부터 상태를 받아오기 위해서는 builder 부분에서 ref를 통해 Provider에 접근하면 된다.
Consumer(builder: (context, ref, child) {
return const Placeholder();
})
class exampleWidget extends ConsumerWidget {
const exampleWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const Placeholder();
}
}
State Provider
상태의 변경이 가능한 Provider이다. 요걸 사용하면 상태값을 가져오고, 상태 값에 접근해 직접 변경할 수 있다.
final valueStateProvider = StateProvider<int>((ref) => 50); // 초기 값이 50인 상태에서 시작
상태는 직접 상태의 값을 변경/대입하거나 update 함수를 호출해 수정해줄 수 있다. state에 직접 접근할 때에는 아래처럼 ref.read(provider.notifier).state
의 형태로 접근할 수 있다.
class exampleWidget extends ConsumerWidget {
const exampleWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch(myStringProvider);
return Text(value);
}
}
Future Provider
플러터에서 Future는 “아직 있지는 않지만, 미래에 들어올 데이터를 담을 박스” 정도로 생각하면 된다. 이 데이터는 미래에 받게 되는데, 데이터를 가져오는데 시간이 걸린다는 점을 고려하면 데이터를 가져오지 못하는 상황도 충분히 발생할 수 있고, 이 경우에는 error를 전달받게 된다. (그것이 Future의 의의이니깐…!)
Future에 대한 내용은 여기를 참고하자! Shout out!
어차피 데이터가 즉시 state에 반영이 되든, 딜레이가 걸려 느리게 state에 반영이 되든 뷰에서는 이 상태가 바뀐 시점에 값을 가져오기 때문에 상관 없지 않나? 라고 생각을 했었는데, int 타입 데이터를 기다렸더니 provider를 통해 받은 state에 error가 담겨있는 상황을 생각해본다면 error가 전달되어버린 부분에 대한 관리도 추가로 필요하겠다는 생각이 들었다. 이러한 이유로 단순 타입의 Provider 대신 Future Provider가 필요하다고 판단하였다. 굿.
아래처럼, API를 통해 네트워크에서 데이터를 받아오는 비즈니스 로직이 있다고 해보자. 네트워크를 통해 데이터를 받아오는데에 시간이 걸릴 뿐 더러, 받아오는 과정에서 문제가 발생해 error가 들어오게 되는 경우도 있을 것이다. 이럴 때 Future Provider를 사용해주면 된다!
class exampleWidget extends ConsumerWidget {
const exampleWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch(myStringProvider);
return Column(children: [
Text(value),
// 간략하게 적었음!
Button(onPressed:() {
// 아래 코드 둘 다 같은 의미로 실행된다.
// state를 직접 수정하기 / update 함수 호출해서 수정하기
ref.read(valueStateProvider.notifier).state++;
ref.read(valueStateProvider.notifier).update((state) => state + 1);
})
]);
}
}
바로 위 코드에서 가장 마지막 줄 처럼 APIService를 전달하는 Provider를 하나 만들어서 APIService 객체를 뿌려주고, 아래 코드처럼 요 Provider를 가져와 Future 타입의 데이터를 가져와준다.
final suggestionFutureProvider =
FutureProvider<Suggestion>((ref) async {
final apiService = ref.watch(apiServiceProvider);
return apiService.getSuggestion();
});
이렇게 가져온 데이터는 Future 타입이기 때문에 data, error, loading 3가지 상태가 존재할 수 있고, 각각의 상태에 따라 분기처리하여 화면을 그려줄 수 있다. Future Provider에서 데이터를 새로 다시 받아오기 위해서는 ref.refresh(~)
를 사용해주면 된다.
class FutureProviderPage extends ConsumerWidget {
const FutureProviderPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final suggestionRef = ref.watch(suggestionFutureProvider);
return Scaffold(
appBar: AppBar(title: Text("Future Provider")),
body: RefreshIndicator(
onRefresh: () {
return ref.refresh(suggestionFutureProvider.future);
},
child: ListView(children: [
Center(
child: suggestionRef.when(
data: (data) {
return Text(data.activity);
},
error: (error, stackTrace) {
return Text(error.toString());
},
loading: () {
return const CircularProgressIndicator();
},
...
}
Stream Provider
Future가 나중에 받을 데이터를 위한 박스를 마련하는 것이였다면, Stream은 줄줄이 들어오는 데이터를 위한 것이다. 즉, 1회성(미래) vs 연속성 이라고 봐도 될 것 같다.
아래 코드처럼, 매 초 마다 1씩 커지는 값을 받아오는 Stream이 있다고 해보자.
final streamServiceProvider = Provider<StreamService>((ref) {
return StreamService();
});
class StreamService {
Stream<int> getStream() {
return Stream.periodic(const Duration(seconds: 1), (i) => i + 1);
}
}
그럴 경우에는 아래 코드처럼 int 타입의 state를 Stream 형태로 받아오는 Provider를 가져와서, streamValue에 따라 Future 처럼 data, error, loading으로 나눠 처리해줄 수 있다.
final streamValueProvider = StreamProvider<int>((ref) {
final streamService = ref.watch(streamServiceProvider);
return streamService.getStream();
});
class StreamProviderPage extends ConsumerWidget {
const StreamProviderPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final streamValue = ref.watch(streamValueProvider);
return Scaffold(
appBar: AppBar(title: const Text("Stream Provider")),
body: ListView(children: [
Center(
child: streamValue.when(data: (int data) {
return Text("data : $data");
}, error: (error, _) {
return null;
}, loading: () {
return const CircularProgressIndicator();
})),
]),
);
}
}
ChangeNotifierProvider
ChangeNotifierProvider
는 이름처럼 State가 변한 것에 대한 Notification에 대해 감지하는 Provider이다. State가 변하는 시점에 위젯을 다시 그리는 것이 아니라, Notification에 반응한다는 것만 기억하면 될 것 같다.
아래 코드처럼 ChangeNotifier를 상속하는 특정한 Notifier 클래스를 정의할 수 있고, 이 클래스 내에는 다양한 변수와 메서드를 담을 수 있다. 이 방식에서는 state를 가지지 않는다. state 대신에 변수에 값을 담는 방식을 사용해주고 있고, 눈에 띄는 점은 데이터를 변경시킨 다음에 notifyListeners()
라는 함수를 호출해주고 있다는 점이다. 즉, 변수 값이 바뀌었을 때 Listener에게 신호가 가는 것이 아니라, notifyListeners()
함수가 호출되었을 때 신호를 보낸다. 이래서 ChangeNotifier에 대한 Provider 라는 이름이 붙었다.
final cartNotifierProvider = ChangeNotifierProvider<CartNotifier>((ref) {
return CartNotifier();
});
class CartNotifier extends ChangeNotifier {
List<Product> _cart = [];
List<Product> get cart => _cart;
void addProduct(Product product) {
_cart.add(product);
notifyListeners();
}
void removeProduct(Product product) {
_cart.remove(product);
notifyListeners();
}
void clearCart() {
_cart.clear();
notifyListeners();
}
}
데이터는 이전처럼 ref를 통해 가져올 수 있고, 함수도 호출이 가능하다. 앞서 언급한 것 처럼, 주의할 점은 뷰를 새로 그리는 시점이 참조하고 있는 변수 값이 변경될 때가 아니라 Provider로부터 notifyListeners()
라는 함수가 호출되는 시점이라는 것이다!
// ConsumerWidget 내에서
...
final cartNotifier = ref.watch(cartNotifierProvider);
...
Text(cartNotifier.cart[0].productName);
cartNotifier.clearCart()
StateNotifierProvider
이 글은 사실 요 StateNotifierProvider를 설명하기 위해 여기까지 구구절절하게 여러가지 방법들을 소개하고 있었다. 앞서 나왔던 ChangeNotifierProvider에 State가 추가된 버전이다. 굳이굳이 notifyListeners()
라는 함수를 호출하여 상태 변화를 알리는 대신에, state가 변경된 시점에 알아서 Provider를 바라보고 있는 뷰들을 다시 그리도록 호출해준다.
아래 코드처럼 List라는 Type의 State를 관리하는 StateNotifier를 선언하고, 이를 Provider로 뿌려준다고 생각해보자.
final cartStateNotifierProvider =
StateNotifierProvider<CartStateNotifier, List<Product>>(
(ref) => CartStateNotifier());
class CartStateNotifier extends StateNotifier<List<Product>> {
// 빈 List로 초기화
CartStateNotifier() : super([]);
void addProduct(Product product) {
state = [...state, product];
}
void removeProduct(Product product) {
state = state.where((p) => p != product).toList();
}
void clearCart() {
state = [];
}
}
아래처럼 Provider를 바라보도록 만들어주고, 데이터를 가져와서 사용하거나 메서드를 실행시킬 수 있다. ChangeNotifierProvider랑 다른 점은 state에 변화가 일어나면 이에 대해 즉각적으로 뷰를 새로 그린다는 것이다.
// ConsumerWidget 내에서
...
final cart = ref.watch(cartStateNotifierProvider);
...
Text(cartNotifier.cart[0].productName);
cartNotifier.clearCart()
StateNotifierProvider와 같은 상태관리 도구를 이용하면 MVVM 아키텍처의 뷰모델을 쉽게 만들어낼 수 있다. 뷰를 그리기 위한 데이터(상태)와 이에 대한 메서드 호출이 가능하며, 상태가 변경되는 경우에 뷰가 이에 대응해 화면을 즉각적으로 새로 그려줄 수 있다.
나의 고민과 뻘짓s
StateNotifierProvider를 활용하면 뷰모델을 만들어 데이터에 대한 관리를 할 수 있고, 뷰에서는 이 데이터들을 끌어다 사용하다가 데이터가 변경되는 시점에 바로 뷰에 반영하여, MVVM 스러운 동작을 할 수 있다는 것 까지는 다 이해를 하였다. 좋았어.
이 시점에서 2가지 의문점이 생겼다.
첫 번째 고민은, Provider 라는 녀석은 결국에는 정해진 scope 내에 전체적으로 뷰모델을 라디오 전파를 뿌리듯이 공중에 뿌리고 이걸 ref.watch(특정Provider)
의 방식으로 가져와 사용하는 것인데 똑같은 타입의 Provider는 여러 개를 사용할 수 없는 것인가?? 였다. 예를 들어, 리스트 아이템으로 반복되는 데이터가 있고 각각의 셀을 누르면 해당하는 데이터를 활용한 뷰를 보여준다고 하면, 각 데이터를 state로 활용하여 화면을 구성할 수 있도록 각각의 데이터를 초기 상태로 갖는 Provider를 모두 생성해야 할 것이다. 그러나, 앞서 언급한 내용에 따르면 Provider는 단순히 type 하나만 가지고 있기 때문에 구분해줄 수가 없다.
final cartStateNotifierProvider =
StateNotifierProvider<CartStateNotifier, List<Product>>(
(ref) => CartStateNotifier());
=>
cart List에 있는 각각의 데이터에 대해 cartStateNotifierProvider를 만들어줄 때, 구분이 가능한가?
혹시 몰라서 직접 코드를 짜서 돌려봤는데, 분명 위젯은 여러개를 만들었지만 같은 Provider를 함께 공유하고 있어서 모든 위젯이 동시에 동일하게 변경되는 문제가 있었다.
이를 해결하기 위해 Provider에 구분자를 함께 넘기는 방법을 사용한다. Provider에 인자를 전달하기 위해서는 family
라는 modifier를 추가해주면 된다. family를 붙여주면, Notifier의 타입, state의 타입 이외에 함께 전달해줄 타입과 값을 추가로 보내줄 수 있다. <StateNotifer, StateType, ARG_Type>((ref, arg) => Notifier(arg))
의 방식으로 인자 값을 생성자를 통해 넘겨줄 수 있다. 아래 예시에서 여기에 구분을 위한 id 값을 보냈다고 해보자.
var numberStateNotifierProvider =
StateNotifierProvider.family<NumberStateNotifier, int, int>(
(ref, id) => NumberStateNotifier(id));
class NumberStateNotifier extends StateNotifier<int> {
NumberStateNotifier(this.id) : super(0);
final int id;
void setNumber(int k) {
state = k;
}
}
이렇게 id 값을 함께 넣어서 Provider를 만들도록 정의하면, 인자 값으로 전달되는 id에 의해 provider끼리 구분이 생기게 된다. 이를 통해 각각의 Provider가 전달해주는 state가 나뉘게 되어서 각각의 뷰가 다른 state를 그려주도록 만들 수 있었다.
class ProvidersWidget extends ConsumerWidget {
ProvidersWidget({
super.key,
required this.id,
});
late int id;
@override
Widget build(BuildContext context, WidgetRef ref) {
var state = ref.watch(numberStateNotifierProvider(id));
return Row(children: [
Text(state.toString()),
IconButton(
onPressed: () {
ref.read(numberStateNotifierProvider(id).notifier).setNumber(id+1);
},
icon: Icon(Icons.ac_unit_rounded))
]);
}
}
요걸 실험하면서 발견한 주의할 점이 있다. 요건 나한테 하는 말이다.
object 타입(클래스로 정의하여)으로 상태를 만들게 되면 레퍼런스로 데이터를 다루게 된다. 그리고 이 객체 타입을 상태로 넣어줬을 때, 단순히 객체의 내부 값을 변경한 것은 상태가 변화된 것으로 치지 않고 새로운 객체가 할당되었을 때만 상태가 변한 것으로 판단하게 된다. 따라서, 상태의 변화를 통해 화면을 새로 그려줄 필요가 있을 때에는 상태 객체의 값을 단순히 수정하는 것이 아니라, 수정된 값을 가진 새로운 객체를 생성해 기존 상태 객체를 갈아끼워줘야 한다. 함수형의 특성을 반영한거라나 뭐라나?
var modelStateNotifierProvider =
StateNotifierProvider.family<ModelStateNotifier, DataModel, DataModel>(
(ref, dataModel) => ModelStateNotifier(dataModel));
class ModelStateNotifier extends StateNotifier<DataModel> {
ModelStateNotifier(super._state);
void addValue(int k) {
// state.value += k 라고 하면 안됨
// state 자체에 새로운 객체가 들어가야 뷰를 새로 그려준다.
state = state.copyWith(value: state.value + k);
}
}
두 번째 고민은, 뷰를 충분히 그려낼 수 있을 정도의 state를 하나의 Provider가 가질 수 있을까? 였다. 만약 이 코드가 SwiftUI 였다면 아래처럼 화면을 그리기 위한 다양한 상태들을 하나의 ObservableObject에 담아 관리하는 방법을 적용할 수 있었을텐데, 각각의 @Published 변수들이 하나하나의 StateNotifierProvider로 제공되어야 하는 상태가 된다는게 너무너무 복잡해질 것 같다고 생각을 했다.
여기에서 내가 내린 결론은 “플러터에서의 화면(뷰)는 스크린 단위가 아니라 작은 위젯 단위이니깐, 하나의 데이터 모델로 그릴 수 있는 화면 정도를 위젯으로 다루어야 한다” 이다. 너무 Swift 스럽게 생각을 했던 것 같다. 전체 화면이 아니라 하나의 위젯(컴포넌트)를 그릴 수 있을 정도의 사이즈의 모델을 State로 갖도록 StateNotifierProvider를 만들어주고, 이를 통해 관리하는 위젯을 조합하여 하나의 스크린을 만들어주면 될 것이다.
내가 SwiftUI로 뷰모델을 만들 때 ‘뷰는 아무것도 하지 말고 자기 데이터를 가져가서 그리기만 하면 된다. 뷰모델이 모든 것들을 미리 다 처리하고 화면 구성 요소들을 나눠둘 것이다’ 라는 생각을 가지고 작업을 했었는데, 여기에서 뷰의 역할을 좀 더 늘여야겠다고 생각이 들었다. 뷰모델은 뷰를 그릴 때 사용할 모델 자체를 상태값으로 가지고 있고, 뷰에서 이 모델을 가져가 적절하게 물고 뜯고 잘라내어 자신의 화면을 구성하는 방식을 사용하면, Provider로 모델 하나만 상태 값으로 뿌려도 동일하게 화면을 구성할 수 있을 것이다.
고민을 하다가 깨달은 것도 있었다.
단순히 뷰모델을 구현하기 위해 StateNotifierProvider를 사용해야 한다고 생각했는데, 어쩌면 이게 잘못된 것이고, 그냥 이 정도 수준에서는 setState 함수로 처리를 해버려도 될 것 같다는 것이다. StateNotifierProvider 같은 방식이 아무래도 거의 모든 앱 내에 전역변수처럼 나의 상태에 접근할 수 있게 Provider를 뿌려버리는 것인데, 이걸 너무 난무해버리면 쓸데없이 너무 많은 정보에 접근이 가능해지게 된다고 생각했다. (나는 객체지향을 만든 이유가 이런 값에 대한 접근을 제한하여 안전한 개발을 할 수 있기 위해서라고 생각한다.) 따라서 Provider의 개수는 최대한 줄여야 한다고 생각이 들었다. Provider는 진짜로 앱 전반에 관여하는 요소를 Singleton처럼 넣어주는 역할을 수행하고, 이런 작은 하위 뷰를 그릴 때에는 그런거 없이 setState를 통해 상태를 관리하며 화면을 그려나가도 괜찮겠다는 생각이 들었다.
'Develop > Flutter 개발' 카테고리의 다른 글
[Flutter] 이펙트 버튼과 Animation Controller (0) | 2023.10.28 |
---|---|
[Flutter] Dart는 JIT와 AOT를 한 번에 지원한다고…? (0) | 2023.10.24 |
[Flutter] 클린 아키텍처스럽게 Riverpod 쓰려는 고민s (0) | 2023.10.15 |
[Flutter] MVC, MVVM (0) | 2023.10.09 |
[Flutter] 클린 아키텍처 (4) | 2023.10.08 |