[Flutter] Riverpod 으로 상태 관리 하기 2

동동·2022년 6월 28일
6

flutter_riverpod

목록 보기
2/3
post-thumbnail

저번 시간에 riverpod 이 무엇이며, 왜 사용하는지에 대해 알아보았다.
riverpod 에는 provider 라는 중요한 요소가 등장 하는데, 이는 riverpod 의 모태격인 provider 라는 상태 관리 라이브러리에서 유래된 것이다.

상태 관리 라이브러리인 provider 에서는 provider 가 사실상 한가지 형태로 사용 되었지만, riverpod 에서는 여러가지 provider 로 세분화 되었다. 그렇기에 riverpod 은 여러 종류의 provider 를 사용하는 상태 관리 시스템이라고 봐도 무방하다.

riverpod 은 여러가지 provider 를 용도에 따라 세분화 하여, 설명하는 상태 관리 라이브러리

오늘은 riverpod 에서 사용 되는 provider 에 대해 알아보도록 하자

provider

provider 는 가장 기본이 되는 provider 로, 단순히 값을 읽을 수만 있다. 사용 용도는 아래와 같다.

  • 계산 값을 캐싱
  • 다른 provider 를 반환 (Respository/HttpClient 등)
  • 테스트를 위한 테스트 값 제시
  • provider/widget 의 불필요한 rebuild 를 최소화

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 가 일어나지도 않는다.

StateNotifierProvider

위에서 말한 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 를 변경 하는 것이 아닌 기존의 상태값을 가지고 새로운 상태값을 생성해야 한다.

state class 생성

아래 예시는 할 일 리스트를 관리 하는 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,
    );
  }
}

StateNotifier 생성

이제 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를 생성해주도록 하자

StateNotifierProvider 생성

final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) {
  return TodosNotifier();
});

StateNotifierProvier 에서는 위에서 제작한 StateNotifier 를 리턴 하기만 하면 된다.

StateNotifierProvider 사용

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

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 의 상태 값을 기준으로 정렬하여 보여지게 되는 것이다.


  Widget 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} \$'),
          );
        },
      ),
    );
  }

state update

가끔씩 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

FutureProvider 는 비동기 처리를 하는 provider 이다. 앱에서 비동기 처리는 여러가지가 있을 수 있다.

  • network 요청
  • file 입출력
  • DataBase 입출력

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가지 콜백 함수를 인자로 받는 함수이다.

  • loading : 비동기 처리를 하는 동안의 사용되는 함수
  • error : 비동기 처리중 error 발생시 사용되는 함수
  • data : 비동기 처리 후 반환 되는 data 를 핸들링 하는 함수

FutureProvider 를 사용하는 이유가 위와 같은 when 함수를 이용하면 비동기 처리에서 좀 더 선언적인 형태의 프로그래밍이 가능하기 때문이다.

StreamProvider

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 를 이용해서 사용하면 된다.


  Widget 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...')
      ],
    );

0개의 댓글