말랑한 하루
[Flutter] Riverpod 그리고 Provider 본문
※ reference : https://riverpod.dev/docs/concepts/providers
우리가 Provider를 사용하는 이유는 Provider가 상태를 Rapping 해주기 때문입니다. 관련해서 우리가 얻을 수 있는 좋은 영향은 다음과 같습니다.
🍒 여러 위치에서 해당 상태에 쉽게 접근할 수 있다.
Provider는 SingleTon, Service Locator, Dependency Injection 또는 InheritedWidgets와 같은 패턴들을 완전히 대체할 수 있다.
🍒 현재 상태를 다른 상태와 결합 하는 것을 단순화 한다.
riverpod은 Provider내부에 여러 상태를 하나로 병합하는 것이 구축되어 있다.
🍒 성능을 최적화를 활성화 한다.
Provider가 상태 변경의 영향을 받는 항목만 다시 계산할 수 있도록 한다.
🍒 애플리케이션의 테스트 가능성을 높인다.
setUp/tearDown Provider를 사용하면 복잡한 / 단계가 필요하지 않다. 또한, 테스트 중에 다르게 동작하도록 모든 공급자를 재 정의할 수 있으므로 매우 구체적인 동작을 쉽게 테스트할 수 있다.
🍒 Loggin 또는 pusy/pull refresh같은 고급 기능과 쉽게 통합할 수 있다.
앞으로의 내용은 Provdier에 대한 자세한 내용이 서술되지만, 공식 문서를 통해 제가 느낀 그대로를 정리해서 올려놓는 글입니다.
🐇 Provider Create
Provider는 다양한 변형으로 제공되지만, 모두 동일한 방식으로 진행된다. 가장 일반적인 사용 방법을 말하면, 다음과 같이 전역 상수로 선언하는 것이다.
@riverpod
MyValue my(MyRef ref) { return MyValue(); }
※ Provider를 전역으로 사용하는 것에 두려워하지 마십시오.
Provider의 선언은 함수 선언과 다르지않으며, Provider는 테스트 및 유지관리가 가능합니다.
이 코드에서 바라봐야 할 관점은 다음과 같다,
🍒 변수 선언 : final myProvider
만들어낸 이 변수는 앞으로 My의 상태를 읽는 것에 사용합니다. 변수는 무조건 final이어야 한다.
🍒 공급자 : Provider
결코 변하지 않는 객체를 노출합니다. 하지만 StreamProvider/NotifierProvider과 같은 Provider를 사용하면 값이 상호작용 하는 방식으로 변경할 수 있다.
🍒 공유상태 생성 : ref
항상 매개변수로 호출되는 객체를 받습니다. 이 개체를 사용하면 다른 Provider를 읽고 Provider의 상태가 삭제될 때 일부 작업을 수행하는 등 제약없는 행위를 진행할 수 있다.
🥕 Important
Provider를 생성할 때 가장 중요한 부분입니다. Provider가 작동하기 위해선, Flutter의 Application의 Root App에 ProviderScope가 존재해야 합니다.
void main() {
runApp(ProviderScope(child: MyApp()));
}
🐇 Provider Type
Provider는 사용사례에 맞게 다양하게 세분화되어 있다.
🥕 Provider
(return: any type), service class, computed property(flitered list)
모든 Provider 중 가장 기본으로, 다음과 같은 용도로 사용 된다.
🍒 Cashing 계산
모든 Provider의 가장 큰 특징입니다.
만약 List에서 Filtering를 하기위해 Provider를 사용하는 경우, List가 변경되기 전 까지 Filtering하여 반환한 ListValue를 Provider가 캐싱하고 있습니다. 그래서 Provider를 이후 여러번 사용한다 해도, List가 변경되기 전 까진 다시 Provider가 계산하지 않습니다.
이는 Widget과도 연관되어 있습니다. Button이 활성화 되는 색인을 제공할 때, Provider는 그 색인이 변경될 때만 Button을 다시 Build하여 그립니다.
🍒 Repository, HttpClient 등의 다른 공급자들에게 Value를 제공한다
🍒 Test/Widget이 Value를 재정의 하는 방법을 제공한다
🍒 select를 사용하지 않고도 공급자/Widget의 재구축을 줄인다
🥕 StateProvider
(return: any type), filter condition, simple state object
상태를 수정하는 방법을 제공하는 공급자입니다. Notifier Class를 작성할 필요가 없도록 설계된 NotifierProvider를 단순화한 것입니다. 일반적인 사용으로는 다음과 같습니다.
🍒 Filter/List 등의 열거형
🍒 String (ex, TextFiled`s value)
🍒 Bool (ex, checkBox`s checkValue)
🍒 Number (ex, pageNumber)
하지만 다음과 같은 경우에는 사용을 주의해야 합니다.
⚠️ 상태에 논리적 유효성 검사가 필요한 경우
⚠️ 상태가 복잡한 객체 (User Defined Class, List, Map)
⚠️ count++ 같은 단순한 상태를 수정하는 논리
이들을 제외하고 더 나은 Provdier에 대한 상태를 변경하고 사용하기 위해서는 NotifierProvider를 고려하고 Notifier Class를 생성하는 것을 추천합니다.
초기의 러닝커브는 높겠지만, Notifier Class를 갖는 것은 프로젝트의 장기적인 유지 및 관리에 매우 중요합니다. 그 이유는, 상태의 비즈니스 로직을 Notifer Class 한곳에 집중시키기 때문입니다.
🥕 FutureProvider
(return: Future of any type), any result from api call
기본적으로 Provider와 동일하지만, 비동기 작업을 처리할 때 사용합니다. 일반적으로 다음과 같이 사용됩니다.
🍒 비동기 작업 수행 및 캐싱 (ex, call api/http request)
🍒 비동기 작업의 오류/로드 상태를 훌륭하게 처리
🍒 여러 비동기 값을 다른 값으로 결합
ref.watch와 결합하면 많은 이점을 얻을 수 있습니다. 일부 변수가 변경될 때, 일부러 데이터를 자동으로 다시 가져올 수 있어 항상 최신 값을 유지할 수 있습니다.
단, FutureProvider는 사용자 상호 작용 후 계산을 직접 수정하는 방법은 제공하지 않습니다. 고급 시나리오의 경우 AsyncNotifierProvider를 사용하세요.
FutureProvider와 async/await 구문을 사용하는 예제는 다음과 같습니다.
final configProvider = FutureProvider<Configuration>((ref) async {
final content = json.decode(
await rootBundle.loadString("assets/configurations.json"),
) as Map(String, Object?);
return Configuration.fromJson(content);
});
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<Configuration> config = ref.watch(configuProvider);
return switch (config) {
AsyncData(:final value) => Text(value.host),
AsyncError(:final error) => Text("Error: $error"),
_ => const CircularProgressIndicator(),
}
}
위 예제를 통해 확인할 수 있듯이, FutureProvider에서 Value를 제공받으면, 오류/로드 상태를 처리할 수 있는 AsyncValue가 반환됩니다. AsyncValue에서는 AsyncData, AsyncError를 사용하여 보다 훌륭하게 오류/로드 상태를 처리할 수 있습니다.
🥕 StreamProvider
(return: Stream of any type), stream result from api call
FutureProvider와 유사하지만 Future 대신 Stream을 위한 Provider 입니다.
🍒 Firebase 수신
🍒 Web-socket 수신
🍒 몇 초마다 공급자를 다시 구축할 때
Stream은 자연스럽게 업데이트를 수신하는 방법을 제공합니다. 하지만 StreamBuilder가 Stream을 듣는 데에도 잘 작동할 것이라고 생각하는데 이는 실수입니다.
StreamProvider를 통해 StreamBuilder를 사용한다면 다음과 같은 이점을 얻을 수 있습니다.
🍒 다른 공급자는 ref.watch를 통해 Stream을 수신할 수 있습니다.
🍒 AsyncValue 덕분에 로드/오류 사례가 올바르게 처리되도록 보장합니다.
🍒 스트림을 구별할 필요가 없습니다.
🍒 스트림에서 보낸 최신 값을 캐시하므로, 이벤트가 내보낸 후 리스너가 추가되면, 리스너가 계속해서 최신 이벤트에 즉시 엑세스 할 수 있습니다.
🍒 NET Framework를 재 정의하여, 테스트 중에 Stream을 쉽게 조종할 수 있습니다.
예시는 다음과 같습니다.
final chatProvider = StreamProvider<List<String>>((ref) async* {
final socket = await Socket.connect("my-api", 4242);
ref.onDispose(socket.close);
val allMessages = const <String>[];
await for (final message in socket.map(utf8.decode)) {
allMessages = [...allMessages, message];
yield allMessage;
}
});
Widget build(BuildContext context, WidgetRef ref) {
final liveChats = ref.watch(charProvider);
return switch(liveChats) {
AsyncData(:final value) => ListView.builder(
reverse: true,
itemCount: value.length,
itemBuilder: (context, index) {
final message = value[index];
return Text(messgae);
}
),
AsyncError(:final error) => Text(error.toString)),
_ => const CircularProgressIndicator(),
}
}
🥕 NotifierProvider
(return: subclass of (Async)Notifier), complex state object (is immutable except through an interface)
앞서 언급된 Notifier Class를 수신하고 제공하는데 사용되는 Provider입니다. AsyncNotifierProvider는 AsyncNotifier를 수신하고 제공하는데 사용됩니다.
(Async)NotifierProvider에 대해서는 다음 NotifierProvider 칼럼에서 예시와 함께 더 자세하게 소개하겠습니다.
🥕 StateNotifierProvider
(return: subclass of StateNotifier), complex state object (is immutable except through an interce). Prefer using a notifierProvider
riverpod가 내보내는 state_notifier 패키지를 수신하고 제공하는 Provider입니다. 일반적으로 다음과 같은 용도로 사용됩니다.
🍒 사용자 정의 이벤트에 반응한 후, 시간이 지남에 따라 변경될 수 있는 불변상태를 노출합니다.
🍒 비즈니스 로직을 수정하기 위한 내용을 한 곳에 중앙 집중화하여 시간이 지남에 따라 유지 관리 가능성을 향상시킵니다.
이 내용을 토대로 우리는 NotifierProvider와 유사하지만, "불변"이라는 새로운 키워드를 얻을 수 있었습니다.
하지만 변하지 않는 데이터를 관리하는데 용이하기 때문에, 다양한 상태를 수정하고 관리하는 앱의 특성상 사용성이 떨어집니다. 공식문서에서도 StateNotifierProvider 대신 NotifierProvider를 사용하라 권장하고 있습니다.
🥕 ChangeNotifierProvider : 권장하지 않음(불변성의 이유)
(return: subclass of ChangeNotifier), complex state object (is requires mutability)
StateNotifierProvider와 마찬가지로 불변성의 이유로 인해 사용하지 않으므로, 추가적으로 정리하지 않겠습니다.
우리는 총7가지의 Provider에 대해서 알아봤고, 그 중 가장 대중적으로 쓰이는 Provider는 StateProvider, NotifierProvider, AsyncNotifierProvider라고 확인할 수 있었습니다. 다음으로는 Provider를 사용하는 방법과 그 과정에서 주의해야 할 사항들에 대해서 기술하겠습니다.
🐇 Provider Useage
Provider를 사용하려면, 무조건 ref 객체를 얻어야 한다. ref 개체를 사용하면 Widget이나 다른 Provider와 상호작용 할 수 있다.
🥕 Get "ref" from Provider
모든 Provider는 "ref"를 매개변수(ex, MyRef ref)로 받는다. 이 매개변수는 Provider가 반환하는 값에 전달되는 것이 안전하다.
조금 풀어서 말하면 MyRef를 통해 받은 ref로 repositoryProvider를 감시(watch)할 수 있고, 그 반환 값을 다시 전달할 수도 있습니다. 자세한 코드는 다음과 같다.
@riverpod
String value(ValueRef ref) {
final repository = ref.watch(repositoryProvider);
return repository.get();
}
@riverpod
class Counter extends _$Counter {
@ovveride
int build() => 0
void increment() {
final repository = ref.read(repositoryProvider);
repository.post(~);
}
}
🥕 Get "ref" from Widget
위젯에는 기본적으로 ref 매개변수가 존재하지 않는다. 그래서 Riverpod는 다음과 같은 솔루션을 제공하고 있다.
🍒 ConsumerWidget
기존의 StatelessWidget을 ConsumerWidget으로 변경하는 것이다. ConsumerWidget은 StatelessWidget과 사용법이 동일하지만, BuildMethod에 "ref" 매개변수가 추가되어 있다.
🍒 ConsumerStatefulWidget / ConsumerState
ConsumerStatefulWidget과 ConsumerState는 State가 존재하는 StatefulWidget과 동일하지만, 매개변수가 아닌 State에 "ref" 개체가 있다는 차이점이 있다. 이곳에서는 함수 내부라면, 언제든 "ref” 변수를 가져다 사용할 수 있다.
🥕 "ref" Useage
ref에는 주로 사용하는 용도가 세가지 있다. 다음 내용은 가능할 때마다 무조건적으로 사용하면, Application이 더 반응적이고 선언적이 되어 유지관리가 쉬워질 수 있다.
🍒 ref.watch
Provider의 값을 얻고 변경 사항을 수신하여, 값이 변경되면 값을 참조하는 Widget과 Provider를 다시 빌드합니다.
🍒 ref.listen
Provider에 listener를 추가하여, Provider가 변경될 때마다 새 페이지로 이동하거나 Modal을 표시하는 작업을 진행합니다
🍒 ref.read
변경사항을 무시하면서 Provider의 값을 얻을 수 있습니다. “when, Clicked”와 같은 이벤트에서 Provider의 값이 필요할 때 유용합니다.
🍒 ref.refresh
단, ElevatedButton의 내부 처럼 ref.watch/ref.listen를 비동기식으로 호출하면 안된다. 또한, 다른 상태 수명주기인 onPressed/initState같은 내부에서 사용되어서도 안된다. 이때는 ref.read를 대신 사용하도록 하자.
🥕 "ref.watch" Useage
서로 다른 역할을 하는 Provider를 ref.watch를 활용하여 결합하고, 새로운 Provider를 만들어 낼 수 있다. 그에 대한 예시는 다음과 같다.
@riverpod
FilterType filterType(FilterTypeRef ref) { return FilterType.none; }
@riverpod
class Todos extends _$Todos {
@override
List<Todo> build() {
return [];
}
}
@riverpod
List<Todo> filteredTodoList(FliteredTodoListRef ref) {
final FliterType filter = ref.watch(fliterTypeProvder);
final List<Todo> todos = ref.watch(todosProvider);
switch(filter) {
case FilterType.completed:
return todos.where((todo) => todo.isCompleted).toList();
case FilterType.none:
return todos;
}
}
위에는 현재 유형의 필터를 반환하는 공급자(filterTypeProvider)와 전체 작업 목록을 반환하는 공급자(todosProvider)가 있다.
이를 결합하여 만들어낸 공급자(filteredTodoListProvider)는 필터링 된 작업 목록이 표시될 수 있다. 필터나 작업 목록이 변경되면, 필터링 된 목록도 자동으로 업데이트 되며, 동시에 필터나 작업 목록이 변경되지 않는 경우 필터링 된 목록은 다시 계산되지 않는다.
마찬가지로 위젯은 ref.watch를 사용하여 Provider의 공급자를 표시하고, 해당 콘텐츠가 변경될 때마다 사용자 인터페이스를 업데이트 하는데 사용할 수 있다.
🥕 "ref.listen" Usage
Provider가 변경되는 경우, Widget을 업데이트하거나 Provider를 재설정 하는 대신 사용자 정의 함수를 호출할 수 있다.
대체로 오류가 발생할 때, SnackBar를 표시하는 등, 특정 변경이 발생할 때 작업을 수행하는 것에 도움을 줄 수 있다.
ref.listen은 다음과 같이 2개의 인수가 필요하고, Widget/Provider의 본문 내에서만 사용할 수 있다.
@riverpod
void another(AnotherRef ref) {
ref.listen<int>(currentProvider, (int? previousCount, int newCount) {
print("The counter changed $newCount");
});
return Container();
}
listen에 전달되는 매개변수는 Provider와 Callback함수이다. 콜백 함수가 호출되면, 이전 상태 값과 새 상태 값이라는 2개의 값이 전달된다.
🥕 "ref.read" Usage
공식 문서에서도 사용을 하지 말라고 당부하고 있다. 매우 나쁜 습관으로, 추적하기 어려운 버그를 유발할 수 있기 때문이다.
Provider가 제공하는 값은 절대 변경되지 않으므로 ref.read를 쓰는 것은 안전하다는 생각으로 변질되는데, 이 가정의 문제점은 현재 Provider 공급자가 실제로 값을 업데이트 하지 않을 수 있지만, 내일도 동일할 것이라는 보장이 없기 때문이다.
소프트웨어는 많이 변하는 경향이 있고, 앞으로는 이전에 변하지 않았던 가치도 변해야 할 가능성이 높다.
red.read를 통해 위젯이 다시 빌드되는 회수를 줄이기 위해 사용했다는 반론이 나올 수 있다. 공식 문서에서는 목표는 훌륭하지만 우리는 ref.watch를 올바르게 사용하면 동일한 결과를 얻을 수 있다는 것에 유의해야 한다고 설명한다.
'개발 > Flutter' 카테고리의 다른 글
[Flutter] AsyncValue? (0) | 2023.12.28 |
---|---|
[Flutter] Riverpod의 (Async)NotifierProvider (0) | 2023.12.28 |
[Flutter] (Error) Undefined name 'riverpod' used as an annotation. (0) | 2023.12.26 |
[Flutter] Riverpod 시작하기 (0) | 2023.12.26 |
[Flutter] (Project) Todo List (0) | 2023.12.25 |