Flutter Riverpod Providers

냠냠·2023년 7월 21일
0

flutter

목록 보기
3/3

1. Provider

여러 Provider 중 가장 기본적인 것으로 값(value)를 생성하는데 사용됩니다.
주로 아래와 같은 용도로 사용됩니다.

계산 결과를 캐싱

class Todo {
  Todo(this.description, this.isCompleted);
  final bool isCompleted;
  final String description;
}

@riverpod
class Todos extends _$Todos {
  @override
  List<Todo> build() {
    return [];
  }

  void addTodo(Todo todo) {
    state = [...state, todo];
  }
  // TODO add other methods, such as "removeTodo", ...
}

위와 같은 NotifierProvider 가 있을 때, Provider 를 사용하여 완료된 할일 목록만 노출할 수 있습니다.

@riverpod
List<Todo> completedTodos(CompletedTodosRef ref) {
  final todos = ref.watch(todosProvider);

  // 완료된 할 일 목록만 반환합니다.
  return todos.where((todo) => todo.isCompleted).toList();
}

이제 completedTodosProvider 를 watch 하면 Todos 에 할일이 추가/제거/업데이트 되지 않는 한 완료된 할일 목록은 여러번 읽혀지더라도 다시 계산되지 않습니다. 필터링된 리스트가 캐싱되었다는 겁니다.

Consumer(builder: (context, ref, child) {
  final completedTodos = ref.watch(completedTodosProvider);
});

provider/widget 재빌드를 최소화

provider 가 재계산(ref.watch 를 사용하는경우) 되더라도 값이 변경되지 않으면 해당 provider 를 listen 하고 있는 다른 위젯이나 provider 를 업데이트 하지 않습니다.

@riverpod
class PageIndex extends _$PageIndex {
  @override
  int build() {
    return 0;
  }

  void goToPreviousPage() {
    state = state - 1;
  }
}

class PreviousButton extends ConsumerWidget {
  const PreviousButton({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 첫 페이지가 아닌 경우, 이전 버튼이 활성화됩니다.
    final canGoToPreviousPage = ref.watch(pageIndexProvider) != 0;

    void goToPreviousPage() {
      ref.read(pageIndexProvider.notifier).goToPreviousPage();
    }

    return ElevatedButton(
      onPressed: canGoToPreviousPage ? goToPreviousPage : null,
      child: const Text('previous'),
    );
  }
}

이코드의 문제점은 pageIndexProvider 가 변경될때 마다 PreviousButton 이 리빌드 된다는 것 입니다. 이상적인 경우는 버튼이 활성화/비활성화 로 변경될 때만 재빌드하는것 입니다.
문제의 근본 원인은 유저가 이전 페이지로 이동할수 있는지 여부를 previousButton 안에서 직접 계산하고 있기 때문입니다. 이걸 위젯 밖으로 빼서 provider 로 만들면 아래와 같이 됩니다.

@riverpod
class PageIndex extends _$PageIndex {
  @override
  int build() {
    return 0;
  }

  void goToPreviousPage() {
    state = state - 1;
  }
}

// 사용자가 이전 페이지로 이동할 수 있는지 여부를 계산하는 프로바이더
@riverpod
bool canGoToPreviousPage(CanGoToPreviousPageRef ref) {
  return ref.watch(pageIndexProvider) != 0;
}

class PreviousButton extends ConsumerWidget {
  const PreviousButton({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 이제 새로운 프로바이더를 청취합니다.
    // 위젯은 이제 이전 페이지로 이동할 수 있는지 여부를 계산하지 않습니다.
    final canGoToPreviousPage = ref.watch(canGoToPreviousPageProvider);

    void goToPreviousPage() {
      ref.read(pageIndexProvider.notifier).goToPreviousPage();
    }

    return ElevatedButton(
      onPressed: canGoToPreviousPage ? goToPreviousPage : null,
      child: const Text('previous'),
    );
  }
}

이제 pageIndex 가 변경될 때 canGoToPreviousPageProvider 가 재계산 되지만 해당 provider 가 노출하는 값이 변경되지 않으면 PriviousButton 은 재빌드되지 않습니다.

2. NotifierProvider

이벤트에 반응해서 변경 가능한 상태를 노출하는데 사용됩니다.
상태를 수정하는 코드를 한곳에 모아서 유지보수가 용이하도록 합니다.

예시로 NotifierProvider 를 사용해서 할 일 목록을 구현한걸 보겠습니다.

@freezed
class Todo with _$Todo {
  factory Todo({
    required String id,
    required String description,
    required bool completed,
  }) = _Todo;
}

@riverpod
class Todos extends _$Todos {
  @override
  List<Todo> build() {
    return [];
  }

  // UI가 할 일을 추가할 수 있도록 허용합니다.
  void addTodo(Todo todo) {
    // 상태가 불변이므로 `state.add(todo)`와 같이 할 수 없습니다.
    // 대신 이전 항목과 새 항목을 포함하는 새로운 할 일 목록을 생성해야 합니다.
    // 여기서 Dart의 전개 연산자를 사용하는 것이 도움이 됩니다!
    state = [...state, todo];
    // "notifyListeners" 또는 유사한 메서드를 호출할 필요가 없습니다. "state ="을 호출하면 필요할 때 자동으로 UI를 다시 빌드합니다.
  }

  // 할 일을 삭제하는 것을 허용합니다.
  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)
          // 한 번 더 말하지만, 상태가 불변이므로 할 일의 사본을 만들어야 합니다.
          // 이전에 구현한 "copyWith" 메서드를 활용하여 돕습니다.
          todo.copyWith(completed: !todo.completed)
        else
          // 다른 할 일은 수정되지 않습니다.
          todo,
    ];
  }
}

위에 정의된 NotifierProvider 를 사용하는 코드를 보겠습니다. (todosProvider 로 접근 가능합니다)

class TodoListView extends ConsumerWidget {
  const TodoListView({super.key});

  @override
  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),
          ),
      ],
    );
  }
}

AsyncNotifierProvider

서버와의 통신하여 비동기로 동작하는 AsyncNotifierProvider 입니다.

@freezed
class Todo with _$Todo {
  factory Todo({
    required String id,
    required String description,
    required bool completed,
  }) = _Todo;

  factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}

@riverpod
class AsyncTodos extends _$AsyncTodos {
  Future<List<Todo>> _fetchTodo() async {
    final json = await http.get('api/todos');
    final todos = jsonDecode(json) as List<Map<String, dynamic>>;
    return todos.map(Todo.fromJson).toList();
  }

  @override
  FutureOr<List<Todo>> build() async {
    // 원격 저장소에서 초기 할 일 목록을 로드합니다.
    return _fetchTodo();
  }

  Future<void> addTodo(Todo todo) async {
    // 상태를 로딩 상태로 설정합니다.
    state = const AsyncValue.loading();
    // 새 할 일을 추가하고 원격 저장소에서 할 일 목록을 다시 로드합니다.
    state = await AsyncValue.guard(() async {
      await http.post('api/todos', todo.toJson());
      return _fetchTodo();
    });
  }

  // 할 일을 삭제하는 것을 허용합니다.
  Future<void> removeTodo(String todoId) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      await http.delete('api/todos/$todoId');
      return _fetchTodo();
    });
  }

  // 할 일을 완료로 표시하는 것을 허용합니다.
  Future<void> toggle(String todoId) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      await http.patch(
        'api/todos/$todoId',
        <String, dynamic>{'completed': true},
      );
      return _fetchTodo();
    });
  }
}

해당 AsyncNotifierProvider 를 사용하는 예시입니다.

class TodoListView extends ConsumerWidget {
  const TodoListView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 할 일 목록이 변경될 때 위젯을 다시 빌드합니다.
    final asyncTodos = ref.watch(asyncTodosProvider);

    // 할 일을 스크롤 가능한 리스트 뷰에 렌더링합니다.
    return asyncTodos.when(
      data: (todos) => ListView(
        children: [
          for (final todo in todos)
            CheckboxListTile(
              value: todo.completed,
              // 할 일을 탭하면 완료 상태가 변경됩니다.
              onChanged: (value) =>
                  ref.read(asyncTodosProvider.notifier).toggle(todo.id),
              title: Text(todo.description),
            ),
        ],
      ),
      loading: () => const Center(
        child: CircularProgressIndicator(),
      ),
      error: (err, stack) => Text('Error: $err'),
    );
  }
}

3. FutureProvider

provider 와 똑같으나 비동기 작업에 사용된다.

  • 비동기 작업(네트워크 요청) 수행 및 캐싱
  • 비동기 작업의 에러/로딩 상태 처리
  • 여러 비동기 값을 다른 값으로 결합
  • user interaction 후 직접 computation 을 수정하는 방법을 제공하지 않습니다. 간단한 작업을 위한 것으로 복잡한 시나리오의 경우 AsyncNotifierProvider 를 사용해야 합니다.
    아래는 Json 파일을 읽어서 Configuration 객체를 노출하는 예제 입니다.
@riverpod
Future<Configuration> fetchConfiguration(FetchConfigurationRef 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) {
  final config = ref.watch(fetchConfigurationProvider);

  return config.when(
    loading: () => const CircularProgressIndicator(),
    error: (err, stack) => Text('Error: $err'),
    data: (config) {
      return Text(config.host);
    },
  );
}

FutureProvider 를 listen 할 경우 위와 같이 AsyncValue를 리턴하게 됩니다.

4. StreamProvider

0개의 댓글