저번 시간에 riverpod
이 무엇이며, 왜 사용하는지에 대해 알아보았다.
riverpod 에는 provider 라는 중요한 요소가 등장 하는데, 이는 riverpod 의 모태격인 provider
라는 상태 관리 라이브러리에서 유래된 것이다.
상태 관리 라이브러리인 provider 에서는 provider 가 사실상 한가지 형태로 사용 되었지만, riverpod 에서는 여러가지 provider 로 세분화 되었다. 그렇기에 riverpod 은 여러 종류의 provider 를 사용하는 상태 관리 시스템이라고 봐도 무방하다.
riverpod 은 여러가지 provider 를 용도에 따라 세분화 하여, 설명하는 상태 관리 라이브러리
오늘은 riverpod 에서 사용 되는 provider 에 대해 알아보도록 하자
provider
는 가장 기본이 되는 provider 로, 단순히 값을 읽을 수만 있다. 사용 용도는 아래와 같다.
provider 의 기본 사용법은 아래와 같다.
final provider = Provider<String>((ref) {
return 'Hello World';
}
provider 의 값은 Hello World
로 고정되었고, 이 정보는 변경되지 않고 read
만 가능하다. 이와 같이 static 한 정보를 제공할 때 provider 를 사용할 수 있다.
단순히 생각해보면 static 한 정보를 굳이 provider 를 이용해서 보여줘야 하나? 라고 생각할 수 있겠지만, provider 의 강력함은 다른 provider 와 결합 되었을 때이다.
간단한 예를 들어보자.
다양한 로그인 타입이 존재하고 로그인 타입별로 화면에 로그인 메세지를 띄우는 요구사항이 있다고 하면, 어떻게 구현할 수 있을까?
일단 로그인 상태를 정의 하고, 로그인 액션별로 로그인 상태를 변경 시켜줘야 한다.
enum LoginState {
none,
email,
naver,
facebook,
apple
}
final loginStateProvider = StateNotifierProvider<LoginStateNotifier, LoginState>((ref) {
return LoginStateNotifier();
});
class LoginStateNotifier extends StateNotifier<LoginState>{
LoginStateNotifier():super(LoginState.none);
void doEmailLogin(){
... // 로그인 처리
state = LoginState.email;
}
... // 기타 로그인
}
여기서 loginStateProvider 는 로그인 상태를 변경하는 provider 이라고만 알고 넘어가자.
(StateNotifierProvider
에 대해서는 밑에서 자세히 설명한다.)
각 로그인 상태에 따라 화면에 로그인 메세지 출력하는 provider 는 아래와 같이 만들수 있다.
final loginMessageProvider = Provider<String>((ref) {
final loginState = ref.watch(loginStateProvider);
switch(loginState){
case LoginState.email:
return 'email 로그인 입니다';
case LoginState.facebook:
case LoginState.naver:
case LoginState.apple:
return 'SNS 로그인 입니다';
case LoginState.none:
default:
return '비로그인 입니다';
}
});
로그인 상태만 변경 한다면 그 상태를 watch 하고 있는 provider 가 알아서 로그인 메세지를 변경해준다. 또한 facebook 로그인에서 naver 로그인으로 변경한다고 해서 로그인 메세지가 변경되는것이 아니라 불필요한 re-render 가 일어나지도 않는다.
위에서 말한 provider 는 단순한 정보를 보여주기만 할 뿐 상태(state) 를 관리 해 주지 않는다. 그럼 상태 관리는 어떻게 해야 할까?
StateNotifierProvider
는 말 그대로 상태를 알려주는 provider 이다. rivderpod 에서 상태서는 StateNotifier
라는 상태를 알려주는 클래스가 존재하는데, StateNotifierProvider 는 이러한 StateNotifier 의 상태 변화를 관찰하다가 변경된 내용을 알려주는 provider 이다.
공식 문서에 나온 StateNotifierProvider 는 주로 아래와 같은 상황에서 사용 된다.
- exposing an immutable state which can change over time after reacting to custom events.
이벤트를 통해 변경되는 immutable 한 상태를 보여줄 때- centralizing the logic for modifying some state (aka "business logic") in a single place, improving maintainability over time.
상태값을 변경하는 로직 (주로 비지니스 로직이라 부름) 을 한 곳에서 관리 하고 유지보수하기 편하게 하기 위해
여기서 상태는 항상 immutable 이어야 한다는 것을 주의 하자. 상태 변경이 일어난다고 해서 기존의 state 를 변경 하는 것이 아닌 기존의 상태값을 가지고 새로운 상태값을 생성해야 한다.
아래 예시는 할 일 리스트를 관리 하는 provider 에서 사용할 Todo 값이다.
Todo 값이 변경될 때마다 기존의 Todo 값을 복제 하는 copywith
메소드를 만들어주어야 한다. (관련 내용은 Freezed 라는 라이브러리를 사용을 추천한다.)
class Todo {
const Todo({required this.id, required this.description, required this.completed});
final String id;
final String description;
final bool completed;
Todo copyWith({String? id, String? description, bool? completed}) {
return Todo(
id: id ?? this.id,
description: description ?? this.description,
completed: completed ?? this.completed,
);
}
}
이제 Todo 값의 변화를 관리 하는 StateNotifier 를 만들어보자.
StateNotifier 라는 class 를 상속 받아 TodoNotifier 를 만들고, 할 일 을 관리 하는 메소드를 생성할 수 있다.
여기서 Todo 상태 값은 state
property에 저장하자.
이 state 값은 외부로 노출되지 않는다. 즉, getter 로 접근할 수 없다.
class TodosNotifier extends StateNotifier<List<Todo>> {
TodosNotifier(): super([]);
// 할 일 추가
void addTodo(Todo todo) {
state = [...state, todo];
}
// 할 일 삭제
void removeTodo(String todoId) {
state = [
for (final todo in state)
if (todo.id != todoId) todo,
];
}
// 할 일 완료 표시 변경
void toggle(String todoId) {
state = [
for (final todo in state)
if (todo.id == todoId)
todo.copyWith(completed: !todo.completed)
else
todo,
];
}
}
여기서 state 는 immutable 데이터이기 때문에 직접적으로 state 를 변경할 수 없다.
state = [...state, todo];
이와 같이 새로운 state를 생성해주도록 하자
final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) {
return TodosNotifier();
});
StateNotifierProvier 에서는 위에서 제작한 StateNotifier 를 리턴 하기만 하면 된다.
StateNotifierProvier 을 사용하는 방법은 기존의 provider 와 동일 하게 ref 객체를 통해 사용할 수 있다.
class TodoListView extends ConsumerWidget {
const TodoListView({Key? key}): super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
List<Todo> todos = ref.watch(todosProvider);
return ListView(
children: [
for (final todo in todos)
CheckboxListTile(
value: todo.completed,
onChanged: (value) => ref.read(todosProvider.notifier).toggle(todo.id),
title: Text(todo.description),
),
],
);
}
}
StateProvider
는 StateNotifierProvider 보다 심플한 데이터 상태를 관리 할 때는 사용된다. 여기서 심플한 이란 기준이 모호할 수 있다. StateProvider 의 대략적 사용 용도를 살펴보자
- an enum, such as a filter type
필터로 사용되는 enum 객체- a String, typically the raw content of a text field
가공되지 않은 String 객체- a boolean, for checkboxes
checkbox 를 위한 boolean 값- a number, for pagination or age form fields
페이징을 위한 페이징 넘버 값
그럼 사용하지 말아야 할 데이터는 어떤게 있을까?
- your state needs validation logic
validation 로직이 필요한 상태- your state is a complex object (such as a custom class, a list/map, ...)
복잡한 객체를 상태 관리 할 때- the logic for modifying your state is more advanced than a simple count++.
단순히 count++ 하는 로직보다 복잡한 로직이 필요할 때
말 그대로 복잡하지 않은 상태 관리를 할 때 사용하라는 것이다. (모호 할 수 있다.)
리스트 순서 필터를 구현한 예제를 한번 보자.
class Product {
Product({required this.name, required this.price});
final String name;
final double price;
}
final _products = [
Product(name: 'iPhone', price: 999),
Product(name: 'cookie', price: 2),
Product(name: 'ps5', price: 500),
];
enum ProductSortType {
name,
price,
}
final productSortTypeProvider = StateProvider<ProductSortType>(
(ref) => ProductSortType.name,
);
final productsProvider = Provider<List<Product>>((ref) {
final sortType = ref.watch(productSortTypeProvider);
switch (sortType) {
case ProductSortType.name:
return _products..sort((a, b) => a.name.compareTo(b.name));
case ProductSortType.price:
return _products..sort((a, b) => a.price.compareTo(b.price));
}
});
product 를 배열하는 기준인 enum 은 ProductSortType 으로 정의 하고, 배열 상태 기준을 productSortTypeProvider 로 정의 한다.
그리고 제공되는 list 는 productSortTypeProvider 의 상태 값을 기준으로 정렬하여 보여지게 되는 것이다.
build(BuildContext context, WidgetRef ref) {
final products = ref.watch(productsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Products'),
actions: [
DropdownButton<ProductSortType>(
value: ref.watch(productSortTypeProvider),
onChanged: (value) =>
ref.read(productSortTypeProvider.notifier).state = value!,
items: const [
DropdownMenuItem(
value: ProductSortType.name,
child: Icon(Icons.sort_by_alpha),
),
DropdownMenuItem(
value: ProductSortType.price,
child: Icon(Icons.sort),
),
],
),
],
),
body: ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.name),
subtitle: Text('${product.price} \$'),
);
},
),
);
}
Widget
가끔씩 stateProvider의 상태값을 update 해야 하는 경우가 있다.
이 때 update 함수를 통해 쉽게 상태값을 변경 할 수 있다.
final counterProvider = StateProvider<int>((ref) => 0);
class HomeView extends ConsumerWidget {
const HomeView({Key? key}): super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () {
ref.read(counterProvider.notifier).update((state) => state + 1);
},
),
);
}
}
FutureProvider
는 비동기 처리를 하는 provider 이다. 앱에서 비동기 처리는 여러가지가 있을 수 있다.
FutureProvider 를 이용하면, 위와 같은 비동기 처리하는 UI 를 쉽게 만들 수 있다.
json 파일로 만들어진 configuration 파일을 읽어들이는 provider 를 만들어보자
final configProvider = FutureProvider.autoDispose<Configuration>((ref) async {
final content = json.decode(
await rootBundle.loadString('assets/configurations.json'),
) as Map<String, Object?>;
return Configuration.fromJson(content);
});
FutureProvider 를 생성시 사용되는 파라미터 함수는 비동기 처리를 하기 위해 async 를 붙여주어야 한다. 그리고 리턴 값으로 비동기 처리 이후에 반환할 상태 값을 전달 하면 된다.
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<Configuration> config = ref.watch(configProvider);
return config.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error: $err'),
data: (config) {
return Text(config.host);
},
);
}
FutureProvider 의 사용은 ref.watch
를 이용해서 가지고 와야 한다. FutureProvider 를 이용해 생성된 AsyncValue 는 when
이라는 내부 함수를 가지고 있는데, 이 when 을 이용하여, 상태 변화(loading, error, data) 에 따른 화면을 구현할 수 있다. 만약 ref.watch 가 아닌 ref.read 를 이용할 경우 when 메소드를 이용해도 상태 변경에 따른 화면 변화가 일어나지 않는다.
when 함수에 대해 자세히 알아보면, when 은 3가지 콜백 함수를 인자로 받는 함수이다.
FutureProvider 를 사용하는 이유가 위와 같은 when 함수를 이용하면 비동기 처리에서 좀 더 선언적인 형태의 프로그래밍이 가능하기 때문이다.
StreamProvider
는 FutureProvider 와 비슷하지만, future 대신 stream 을 이용한다는 차이점이 있다.
어떠한 프로세스의 진행사항을 보여주는 화면에서 유용하게 사용될 수 있다.
launch 시 여러가지 세팅을 해야 하는 앱이 있다고 하자. 이 앱이 구동시 서버와 통신하면서 설정 값을 받아오고, 로컬 DB 에서 여러가지 설정값을 읽는 등 여러가지 비동기 처리를 한다.
이와 같은 환경에서 StreamProvider 를 이용한다면 쉽게 인트로 화면을 구성할 수 있고, 만약 인트로 작업이 추가 또는 삭제 될 때 쉽게 설정 할 수 있다.
enum IntroState{
processing, finish
}
abstract class IntroJob{
String jobName = '';
Future<void> doJob();
}
class IntroJobState{
IntroState state;
String endedJobName;
int index;
int totalJobCount;
IntroJobState(this.state, this.endedJobName, this.index, this.totalJobCount);
}
// 테스트를 위한 데이터
class IntroJob1 implements IntroJob{
String jobName = 'IntroJob1';
Future<void> doJob() async{
try{
await Future.delayed(const Duration(seconds: 1));
}catch(err){
}
}
}
class IntroJob2 implements IntroJob{
String jobName = 'IntroJob2';
Future<void> doJob() async{
try{
await Future.delayed(const Duration(seconds: 1));
Future.delayed(const Duration(seconds: 2)); // 비동기적으로 처리 되어도 되는 설정 값으 경우 처리를 기다리지 않는다.
}catch(err){
}
}
}
class IntroJob3 implements IntroJob{
String jobName = 'IntroJob3';
Future<void> doJob() async{
try{
await Future.delayed(const Duration(seconds: 2));
}catch(err){
}
}
}
class IntroJob4 implements IntroJob{
String jobName = 'IntroJob4';
Future<void> doJob() async{
try{
await Future.delayed(const Duration(seconds: 1));
}catch(err){
}
}
}
List<IntroJob> introJobList = [
IntroJob1(),
IntroJob2(),
IntroJob1(),
IntroJob3(),
IntroJob4(),
];
인트로 프로세스를 처리 하기 위한 상태 class 와 테스트 데이터를 만들어 주었다.
StreamProvider 는 joblist 를 반복하면서 job 을 실행시켜주면서, 진행상황을 stream 으로 전달한다.
final introProvider = StreamProvider.autoDispose<IntroJobState>((ref) async*{
int count = 1;
final int totalCount = introJobList.length;
for (IntroJob job in introJobList){
await job.doJob();
yield IntroJobState(IntroState.processing, job.jobName, count, totalCount);
count++;
}
yield IntroJobState(IntroState.finish, 'finish', totalCount, totalCount);
});
StreamProvider 의 사용은 FutureProvider 와 마찬가지로 ref.watch 를 이용해서 사용하면 된다.
build(BuildContext context, WidgetRef ref) {
final _processing = useState<double>(0.0);
final _introProvider = ref.watch(introProvider);
_introProvider.whenData((introJobState) {
_processing.value = introJobState.index / introJobState.totalJobCount;
if (introJobState.state == IntroState.finish) _router.replace(const MainRoute());
});
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('this is intro page')
Text('${_processing.value.toString()}% processing...')
],
);
Widget